Skip to content

About

No description, website, or topics provided.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Latest commit

 

History

30 Commits

Folders and files

Repository files navigation

landrecords-card-reader

Extract structured property data from assessment card PDFs using LLM-powered text extraction.

Property cards (also called land cards or assessment cards) are PDF documents produced by county tax assessors.

Installation

pip install landrecords-card-reader

With optional extras:

# LangGraph wiring (only needed if you want to use the graph node)
pip install landrecords-card-reader[graph]

# Everything
pip install landrecords-card-reader[all]

System dependencies

  • An OpenAI-compatible chat-completions server (Ollama, vLLM, sglang, TGI, llama-server, OpenAI itself, …) reachable at CARD_READER_LLM_BASE_URL with a text model loaded (e.g. gemma4:26b-a4b-it-q8_0). The pipeline talks to it via the openai Python SDK, so anything that speaks the /v1/chat/completions shape works.
  • wkhtmltopdf for HTML→PDF conversion of property pages that aren't served as PDFs:
    # macOS
    brew install --cask wkhtmltopdf
    # Debian/Ubuntu
    sudo apt-get install wkhtmltopdf

OCR (docTR + PyTorch) ships as a Python dependency; the model weights (~100MB) are downloaded to ~/.cache/doctr/ on first call. The OCR predictor auto-selects CUDA, then Apple-Silicon MPS, then CPU. On GPU OOM it transparently falls back to CPU, and on NVIDIA H200 it caps its CUDA memory share at 80% so a co-located LLM can use the rest.

Quick start

from landrecords_card_reader import read_property_card

data, photo = read_property_card("https://example.com/card.pdf")

print(data["ownername"])    # "SMITH, JOHN A"
print(data["totalvalue"])   # 285000
print(data["parceladdr"])   # "123 MAIN ST"

# photo is raw image bytes of the first property photo, or None
if photo:
    with open("photo.jpg", "wb") as f:
        f.write(photo)

Use analyze_photo=True to send the property photo (if it exists) to the vision model, filling in missing building details (exterior walls, roof style, number of floors, etc.):

data, photo = read_property_card(url, analyze_photo=True)

If you already have the PDF bytes, pass them directly to skip the download:

data, photo = read_property_card(url, pdf_bytes=raw_bytes)

For URLs that might be HTML property report pages (e.g. Beacon, Tyler, or other county assessment sites), use read_property_card_from_url. It fetches the URL, detects whether the response is a PDF or HTML, and converts HTML pages to PDF via pdfkit (wkhtmltopdf) automatically:

from landrecords_card_reader import read_property_card_from_url

data, photo = read_property_card_from_url(
    "https://www.webgis.net/LinkedFiles/va/pulaski/pc/cards/PC17759.htm"
)

CLI

landrecords-card-reader https://example.com/card.pdf --dry-run -v

Configuration

Set via environment variables or a .env file:

Variable Default Description
CARD_READER_LLM_BASE_URL http://graybase:8000/v1 OpenAI-compatible chat-completions endpoint
CARD_READER_LLM_API_KEY dummy API key sent as Authorization: Bearer … (use any non-empty string for self-hosted servers)
CARD_READER_EXTRACTION_MODEL gemma4:26b-a4b-it-q8_0 Model for structured extraction
CARD_READER_PHOTO_CLASSIFICATION_MODEL gemma4:e2b Lightweight vision model for photo classification

Extracted fields

The extraction prompt maps over 80 property card fields including:

  • Identity: parcelid, taxacctnum, taxyear
  • Owner: ownername, owneraddr, ownercity, ownerstate, ownerzip
  • Location: parceladdr, parcelcity, parcelstate, parcelzip, legaldesc
  • Valuation: landvalue, imprvalue, totalvalue, assessedvalue, appraisedvalue
  • Building: yearbuilt, bldgsqft, bedrooms, fullbaths, halfbaths, bldgtype
  • Construction: foundation, roofcover, extwall, heating, heatfuel, cooling
  • Sale: saleamt, saledate
  • Zoning: zoningcode, zoningdesc, zoningtype

How it works

  1. Download the PDF (or accept pre-downloaded bytes). For HTML pages, the renderer picks one of two paths:
    • Image-scan HTML (e.g. richmondcountypropertycards.com) — the thin HTML wrapper around <img> scans is detected, the source JPEGs are fetched directly, and they're embedded losslessly into a PDF at native pixel size. This avoids wkhtmltopdf's re-encoding pass that destroys thin glyphs.
    • Rich HTML (server-rendered assessor reports) — sent through wkhtmltopdf with JavaScript enabled and a 4 s delay so async value-history / sales-history tables (Stafford County VA, other PublicAccessNow / Aumentum-rendered sites) finish populating before the PDF snapshot is taken.
  2. In parallel:
    • Per-page text extraction. Each page is first probed for a usable native text layer via PyMuPDF (cheap, lossless). Pages without one go to docTR OCR. As an optimisation, pages that are dominated by a single embedded raster image (typical of the image-scan HTML path) skip the pypdfium re-rasterisation entirely — the source JPEG bytes are pulled straight out via doc.extract_image(xref) and handed to docTR. This avoids a 16× pixel-count blow-up on cards where the PDF is just a wrapped scan, and is the main reason scan cards now finish in seconds instead of minutes. The OCR predictor is a thread-safe singleton — only one copy is loaded into VRAM per process.
    • Photo extraction & classification — candidate images are filtered by size / aspect ratio, then sent to a vision model to keep only actual photographs (discarding sketches, floorplans, maps, etc.).
  3. Extract structured data by sending the OCR text to the configured LLM via the OpenAI-compatible chat-completions API. The model emits one labelled CSV row per chunk (~14 chunks of ≤10 cells each) — a chunked positional format that bounds counting errors to a single chunk instead of cascading across all 80+ fields.
  4. Reconcile + recover. Several deterministic post-passes run after the LLM call to backstop common extractor failures:
    • landvalue + imprvalue == totalvalue is verified and the missing value computed arithmetically when any one is absent. When all three disagree, the smaller component is treated as a sub-row mistake and re-derived from the total.
    • Label-anchored fallbacks scan the OCR text for explicit field labels and recover values the LLM dropped or mis-aligned: heatfuel, heating, cooling, roofstyle / roofcover, extwall, zoningcode, taxacres, bldgsqft.
    • Misplacement strippers drop values the LLM put in the wrong STRUCT slot (a roof material in extwall, a digit-only artefact) so the recovery pass can fill the slot from the labelled cell instead.
    • Inference fills add parcelstate / ownerstate from the matching ZIP prefix (full US state map) or from a "X County, " header, infer heating from a "Central Air" signal when the card only shows a heat fuel + AC %, and translate bare HVAC codes ("C-HEAT", "C-AIR") to canonical values.
    • Unverified "NONE" strings are dropped when the literal token doesn't appear in the OCR text.
  5. Targeted retries. If parcelid looks too short to be real, one focused LLM retry asks for a better candidate. If analyze_photo=True was passed, a separate vision-model call fills any remaining building / exterior fields the text-only pass couldn't determine.

License

MIT

About

No description, website, or topics provided.

Resources

Stars

4 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages