API reference¶
Engine — savitr.mlx_ocr¶
Generic MLX runtime for Surya OCR — load the model once, OCR page images to text.
This is the engine layer (roll-agnostic): it turns a page image into the model’s raw text — HTML
for the base surya-ocr-2, terse rows for the distilled roll model — given whatever prompt
you pass. The electoral-roll parsing of that text lives in savitr.rolls.
Runs ~175-180 tok/s on Apple Silicon (~3.6x the llama.cpp f16 pipeline). Reusable for any Surya OCR task, not just rolls.
- class savitr.mlx_ocr.MLXSuryaOCR(mlx_path='models/surya-mlx-4bit', max_tokens=8192, prompt='OCR this image to HTML.')¶
Load an MLX-converted Surya model once; OCR page images to text.
- Parameters:
mlx_path (str)
max_tokens (int)
prompt (str)
- ocr_image(png_path)¶
OCR one page image; return
(text, generation_token_count).- Return type:
tuple[str,int]- Parameters:
png_path (str)
Electoral-roll parsing — savitr.rolls.parse¶
Electoral-roll parsing: Surya output (HTML or terse) -> voter records.
This is the roll-specific layer. The generic MLX engine (savitr.mlx_ocr.MLXSuryaOCR)
turns a page image into text; these functions turn that text into voter dicts with the canonical
fields number, id, elector_name, relationship, father_or_husband_name, house_no, age, sex.
parse_voters()/dedupe_voters()parse the base model’s verbose HTML.parse_terse()parses the distilled terse model’s one-line-per-voter output.to_terse()renders voter dicts back to the terse training target.resolve_terse_model()locates (or downloads) the terse model weights.
- savitr.rolls.parse.TERSE_REPO = 'gojiberries/savitr'¶
Hugging Face repo for the distilled terse roll model (used when no local copy is present).
- savitr.rolls.parse.resolve_terse_model(local='models/surya-terse-8bit')¶
Return a local terse-model dir if present, else download
TERSE_REPOfrom the Hub.- Return type:
str- Parameters:
local (str)
- savitr.rolls.parse.parse_voters(html)¶
Split page HTML into per-voter records by the
Name :anchor (layout-agnostic).- Return type:
list[dict]- Parameters:
html (str)
- savitr.rolls.parse.dedupe_voters(voters)¶
Collapse duplicated rows, keeping the fullest record per voter.
Keys by EPIC id when present (the unique voter key — a repeated EPIC means the model mis-associated or looped, so those rows must collapse), else by identity (name+relation+age). Robust to the messy duplication the VLM emits on looped pages.
- Return type:
list[dict]- Parameters:
voters (list[dict])
- savitr.rolls.parse.TERSE_COLS = ['number', 'id', 'elector_name', 'relationship', 'father_or_husband_name', 'house_no', 'age', 'sex']¶
Canonical column order for one terse voter line.
- savitr.rolls.parse.TERSE_PROMPT = 'Extract every voter from this electoral-roll page as pipe-delimited rows, one per line, columns: serial|epic|name|relation(F/H/M)|relation_name|house|age|sex'¶
Instruction given to the distilled model.
- savitr.rolls.parse.to_terse(voters)¶
Render voter dicts to terse pipe-delimited text (the training target).
- Return type:
str- Parameters:
voters (list[dict])
- savitr.rolls.parse.parse_terse(text)¶
Parse the terse model’s output into voter dicts (value-anchored, not positional).
The distilled model occasionally drops the relation-code column on hard pages, which would shift every field if split blindly. So we anchor on recognizable values — EPIC at the front, sex (M/F/T) and age at the end, relation only when it is literally F/H/M — and the free-form name/relname/house fall out of what remains. This keeps the reliable fields (EPIC, name, age, sex) aligned even when a middle column is missing.
- Return type:
list[dict]- Parameters:
text (str)
Pipeline — savitr.rolls.pipeline¶
Parse Manipur 2025 rolls into the canonical voter CSV — using the fast MLX OCR engine.
Drop-in alternative to the repo’s RapidOCR-based parse_manipur_2025.py, but ~5× faster and higher fidelity: it OCRs each page with MLX Surya (~34 s/page), parses voters layout-robustly, and reuses the existing fields.py (cover-page metadata + serial dedup) and column schema so the output is byte-compatible with the rest of the pipeline.
- Run in the MLX env:
.venv-mlx/bin/python savitr/parse_manipur_mlx.py -f AC01_part001_final_ENG.pdf -o out.csv .venv-mlx/bin/python savitr/parse_manipur_mlx.py -d english/ -o out.csv [–limit N] [–resume]
- savitr.rolls.pipeline.html_to_text(html)¶
HTML -> newline-separated text (so fields.py’s cover regexes see line structure).
- Return type:
str- Parameters:
html (str)
- savitr.rolls.pipeline.parse_pdf_mlx(eng, pdf_path, dpi, terse=True, cover_eng=None)¶
OCR + parse one PDF with MLX; return (rows, recon) in the canonical schema.
terse mode (default): the terse model
engreads every page for voters (covers yield none), and — only ifcover_engis provided — the HTML model reads the first two pages for cover metadata. Without a cover model, voter extraction still works; cover metadata is just skipped and the AC/part number is taken from the filename.HTML mode (
terse=False): the HTML modelengreads every page for voters + metadata.- Return type:
tuple[list[dict],dict]- Parameters:
eng (MLXSuryaOCR)
pdf_path (str)
dpi (int)
terse (bool)
cover_eng (MLXSuryaOCR | None)
- savitr.rolls.pipeline.main()¶
Run the savitr parse-rolls command: roll PDFs -> canonical voter CSV.
- Return type:
int
Schema — savitr.rolls.schema¶
Canonical in-rolls voter CSV schema (vendored from parse_unsearchable_rolls).
Kept byte-compatible with parse_unsearchable_rolls/scripts/manipur/parse_manipur_2025.py so savitr’s CSV output drops into the same downstream pipeline.
- savitr.rolls.schema.ac_part_from_filename(name)¶
AC01_part001_final_ENG.pdf -> (‘1’, ‘1’).
- Return type:
tuple[str,str]- Parameters:
name (str)
Generic MLX backend — savitr.mlx_backend¶
An MLX inference backend for Surya — Apple Silicon, ~3.6× faster than llama.cpp.
Surya ships vllm (NVIDIA) and llamacpp (CPU/Apple Silicon) backends. This adds a third, mlx, that serves the MLX-converted model through mlx-vlm’s OpenAI-compatible server and plugs into the exact same SuryaInferenceManager / chat_completions_batch path — so the full Surya pipeline (layout, full-page OCR, table rec) runs unchanged, just faster.
Design mirrors LlamaCppBackend: spawn an OpenAI server, attach an OpenAI client, delegate generation to Surya’s shared chat_completions_batch. The mlx-vlm server runs as its own subprocess (its own venv), so it never conflicts with Surya’s dependencies.
- Usage (drop-in, no fork needed):
import mlx_backend; mlx_backend.register() os.environ[“SURYA_MLX_MODEL_PATH”] = “models/surya-mlx-4bit” os.environ[“SURYA_MLX_PYTHON”] = “.venv-mlx/bin/python” # python that has mlx-vlm manager = SuryaInferenceManager(method=”mlx”)
- Convert the model once:
.venv-mlx/bin/python -m mlx_vlm convert –hf-path datalab-to/surya-ocr-2 –mlx-path models/surya-mlx-4bit -q –q-bits 4
Upstreaming: this file is the body of surya/inference/backends/mlx.py; register() is the two-line addition to surya.inference._build_backend (add an “mlx” branch).
- class savitr.mlx_backend.MlxBackend(*args, **kwargs)¶
Surya inference backend that serves the model via mlx-vlm’s OpenAI server.
- start()¶
Spawn (or reuse) the mlx-vlm server and return its handle.
- Return type:
ServerHandle
- stop()¶
Terminate the mlx-vlm server and clear cached client/handle.
- Return type:
None
- generate(batch)¶
Run a batch of chat completions against the running server.
- Return type:
list[BatchOutputItem]- Parameters:
batch (list[surya.inference.schema.BatchInputItem])
- savitr.mlx_backend.register()¶
Teach SuryaInferenceManager(method=’mlx’) to build this backend.
Upstream, this is just an if method == ‘mlx’: return MlxBackend() branch in surya.inference._build_backend. Here we patch it so no fork is required.
- Return type:
None