Codex_Extractor / smoke_signal /scripts /font_library.py
Claude
chore: track *.traineddata via LFS + Gill Sans font->model mapping
57311b9
Raw
History Blame Contribute Delete
14.6 kB
"""
Smoke Signal — Font Detection + Font-Aware OCR Helpers
======================================================
Lightweight utilities for:
1) identifying page fonts via MixFont Lens API
2) maintaining per-font OCR preference metadata
3) selecting per-font custom Tesseract models when available
4) exposing font-specific punctuation-map paths
This module is intentionally defensive:
- if MixFont key is missing, it degrades gracefully
- if no custom .traineddata exists, it falls back to "eng"
"""
from __future__ import annotations
import json
import os
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Optional
SS_ROOT = Path(os.environ.get("SS_DATA_ROOT", os.environ.get("SS_ROOT", "/tmp/smoke_signal")))
CALIBRATION_DIR = SS_ROOT / "calibration"
FONTS_DIR = SS_ROOT / "fonts"
TESSDATA_DIR = SS_ROOT / "tessdata"
REPO_TESSDATA_DIR = Path(__file__).resolve().parents[1] / "tessdata"
FONT_REGISTRY_PATH = CALIBRATION_DIR / "font_registry.json"
for _dir in (CALIBRATION_DIR, FONTS_DIR, TESSDATA_DIR, REPO_TESSDATA_DIR):
_dir.mkdir(parents=True, exist_ok=True)
MIXFONT_API_KEY = os.environ.get("MIXFONT_API_KEY", "").strip()
# MixFont Lens API docs endpoint.
MIXFONT_API_URL = os.environ.get("MIXFONT_API_URL", "https://www.mixfont.com/v1/api/lens").strip()
try:
MIXFONT_TIMEOUT_SEC = max(1.0, float(os.environ.get("MIXFONT_TIMEOUT_SEC", "4")))
except Exception:
MIXFONT_TIMEOUT_SEC = 4.0
def _slug_font_name(font_name: str) -> str:
cleaned = "".join(ch.lower() if ch.isalnum() else "_" for ch in (font_name or "").strip())
while "__" in cleaned:
cleaned = cleaned.replace("__", "_")
return cleaned.strip("_")
def _load_registry() -> dict:
if not FONT_REGISTRY_PATH.exists():
return {}
try:
return json.loads(FONT_REGISTRY_PATH.read_text(encoding="utf-8"))
except Exception:
return {}
def _save_registry(registry: dict) -> None:
FONT_REGISTRY_PATH.write_text(
json.dumps(registry, indent=2, ensure_ascii=False),
encoding="utf-8",
)
def _ensure_registry_entry(font_name: str) -> dict:
registry = _load_registry()
entry = registry.get(font_name, {})
if not entry:
entry = {
"identified_count": 0,
"status": "unknown", # unknown | available | missing | specimen_only
"preferred_engine": "surya", # surya | tesseract
"tesseract_model": None,
"avg_confidence_surya": None,
"avg_confidence_tess": None,
"last_identified_at": None,
}
registry[font_name] = entry
_save_registry(registry)
return entry
def _build_public_image_url(local_image_path: str) -> Optional[str]:
"""
Construct a publicly reachable URL for a local rendered page image.
Priority:
1) MIXFONT_IMAGE_URL_TEMPLATE with {path} placeholder
2) MIXFONT_IMAGE_BASE_URL + /gradio_api/file=<abs_path>
3) SPACE_HOST + /gradio_api/file=<abs_path>
4) HF_SPACE_URL + /gradio_api/file=<abs_path>
"""
raw_path = str(Path(local_image_path).resolve())
encoded_path = urllib.parse.quote(raw_path, safe="")
template = os.environ.get("MIXFONT_IMAGE_URL_TEMPLATE", "").strip()
if template:
if "{path}" in template:
return template.replace("{path}", encoded_path)
return template
base = (
os.environ.get("MIXFONT_IMAGE_BASE_URL", "").strip()
or os.environ.get("HF_SPACE_URL", "").strip()
or ""
)
if not base:
space_id = os.environ.get("SPACE_ID", "").strip()
if space_id:
base = f"https://huggingface.co/spaces/{space_id}"
if not base:
space_host = os.environ.get("SPACE_HOST", "").strip()
if space_host:
if space_host.startswith("http://") or space_host.startswith("https://"):
base = space_host
else:
base = f"https://{space_host}"
if not base:
return None
return f"{base.rstrip('/')}/gradio_api/file={encoded_path}"
def mixfont_preflight() -> dict:
"""
Return runtime readiness checks for MixFont integration.
"""
template = os.environ.get("MIXFONT_IMAGE_URL_TEMPLATE", "").strip()
base = (
os.environ.get("MIXFONT_IMAGE_BASE_URL", "").strip()
or os.environ.get("HF_SPACE_URL", "").strip()
or ""
)
if not base:
space_id = os.environ.get("SPACE_ID", "").strip()
if space_id:
base = f"https://huggingface.co/spaces/{space_id}"
space_host = os.environ.get("SPACE_HOST", "").strip()
has_public_source = bool(template or base or space_host)
return {
"api_key_set": bool(MIXFONT_API_KEY),
"api_url": MIXFONT_API_URL,
"image_url_template_set": bool(template),
"image_base_set": bool(base),
"space_host_set": bool(space_host),
"public_image_url_source_available": has_public_source,
}
def identify_page_font(image_path: str, image_url: Optional[str] = None) -> dict:
"""
Identify font via MixFont Lens API.
Returns:
{
"font_name": str|None,
"confidence": float,
"alternatives": list[str],
"image_url": str|None,
"error": str|None
}
"""
if not MIXFONT_API_KEY:
return {
"font_name": None,
"confidence": 0.0,
"alternatives": [],
"image_url": image_url,
"error": "MIXFONT_API_KEY not set",
}
path_obj = Path(image_path)
if not path_obj.exists():
return {
"font_name": None,
"confidence": 0.0,
"alternatives": [],
"image_url": image_url,
"error": f"image not found: {image_path}",
}
resolved_url = image_url or _build_public_image_url(str(path_obj))
if not resolved_url:
return {
"font_name": None,
"confidence": 0.0,
"alternatives": [],
"image_url": None,
"error": "no public image URL (set MIXFONT_IMAGE_BASE_URL or MIXFONT_IMAGE_URL_TEMPLATE)",
}
payload = json.dumps({"image_url": resolved_url}).encode("utf-8")
request = urllib.request.Request(
MIXFONT_API_URL,
data=payload,
method="POST",
headers={
"Content-Type": "application/json",
"x-api-key": MIXFONT_API_KEY,
},
)
try:
with urllib.request.urlopen(request, timeout=MIXFONT_TIMEOUT_SEC) as response:
body = response.read().decode("utf-8", errors="replace")
data = json.loads(body)
except Exception as e:
return {
"font_name": None,
"confidence": 0.0,
"alternatives": [],
"image_url": resolved_url,
"error": str(e),
}
matches = data.get("font_matches", [])
if not isinstance(matches, list):
matches = []
top = matches[0] if matches else {}
if not isinstance(top, dict):
top = {}
font_name = str(top.get("name") or "").strip() or None
try:
confidence = float(top.get("confidence", 0.0))
except Exception:
confidence = 0.0
alternatives = []
for match in matches[1:4]:
if isinstance(match, dict):
name = str(match.get("name") or "").strip()
if name:
alternatives.append(name)
if font_name:
registry = _load_registry()
entry = registry.get(font_name, _ensure_registry_entry(font_name))
entry["identified_count"] = int(entry.get("identified_count", 0)) + 1
entry["last_identified_at"] = str(data.get("datetime") or "")
registry[font_name] = entry
_save_registry(registry)
return {
"font_name": font_name,
"confidence": round(confidence, 4),
"alternatives": alternatives,
"image_url": resolved_url,
"error": None,
}
def register_font_model(
font_name: str,
model_name: Optional[str] = None,
preferred_engine: str = "tesseract",
status: str = "available",
) -> dict:
"""
Manually register a font->tesseract model mapping.
Use this for bespoke fonts where only a specimen-based traineddata exists.
"""
if not font_name:
return {"ok": False, "error": "font_name required"}
model = (model_name or _slug_font_name(font_name) or "eng").strip()
registry = _load_registry()
entry = registry.get(font_name, _ensure_registry_entry(font_name))
entry["tesseract_model"] = model
entry["preferred_engine"] = preferred_engine if preferred_engine in {"surya", "tesseract"} else "tesseract"
entry["status"] = status
registry[font_name] = entry
_save_registry(registry)
return {"ok": True, "font_name": font_name, "tesseract_model": model}
# Static font name → model name mapping.
# Handles cases where the slug doesn't match the traineddata filename.
FONT_MODEL_MAP: dict[str, str] = {
"gill sans infant": "gill_sans_infant",
"gill sans": "gill_sans_infant", # MixFont may return either name
"gill sans mt": "gill_sans_infant",
"gillsans": "gill_sans_infant",
}
def resolve_tesseract_lang(font_name: Optional[str]) -> str:
"""
Return best tesseract language/model to use for this font.
Defaults to "eng" if no custom traineddata is available.
"""
if not font_name:
return "eng"
# Check static map first
mapped = FONT_MODEL_MAP.get(font_name.lower().strip())
if mapped:
tess_dirs = [TESSDATA_DIR, REPO_TESSDATA_DIR]
if any((d / f"{mapped}.traineddata").exists() for d in tess_dirs):
return mapped
registry = _load_registry()
entry = registry.get(font_name, {})
candidates = []
explicit_model = str(entry.get("tesseract_model") or "").strip()
if explicit_model:
candidates.append(explicit_model)
slug = _slug_font_name(font_name)
if slug:
candidates.append(slug)
candidates.append(slug.replace("_", ""))
# unique keep order
seen = set()
deduped = []
for c in candidates:
if c and c not in seen:
deduped.append(c)
seen.add(c)
tess_dirs = [TESSDATA_DIR, REPO_TESSDATA_DIR]
for model_name in deduped:
if any((tess_dir / f"{model_name}.traineddata").exists() for tess_dir in tess_dirs):
if entry:
entry["status"] = "available"
entry["preferred_engine"] = "tesseract"
entry["tesseract_model"] = model_name
registry[font_name] = entry
_save_registry(registry)
return model_name
# Common misplacement: .traineddata.otf (font file, not Tesseract model)
otf_like_candidates = []
for model_name in deduped:
otf_like_candidates.extend(
[
REPO_TESSDATA_DIR / f"{model_name}.traineddata.otf",
TESSDATA_DIR / f"{model_name}.traineddata.otf",
REPO_TESSDATA_DIR / f"{model_name}.otf",
TESSDATA_DIR / f"{model_name}.otf",
]
)
if any(path.exists() for path in otf_like_candidates):
if entry:
entry["status"] = "font_file_only"
entry["preferred_engine"] = "surya"
registry[font_name] = entry
_save_registry(registry)
# If a specimen exists but no model compiled yet, make that explicit in registry.
if (FONTS_DIR / "gruffalo_specimen.png").exists():
if entry:
entry["status"] = entry.get("status") or "specimen_only"
if entry["status"] == "unknown":
entry["status"] = "specimen_only"
registry[font_name] = entry
_save_registry(registry)
return "eng"
def resolve_tessdata_dir(model_name: Optional[str]) -> Optional[str]:
if not model_name:
return None
model = str(model_name).strip()
if not model:
return None
for tess_dir in (TESSDATA_DIR, REPO_TESSDATA_DIR):
if (tess_dir / f"{model}.traineddata").exists():
return str(tess_dir)
return None
def update_font_engine_stats(font_name: Optional[str], surya_conf: float, tess_conf: float) -> None:
if not font_name:
return
registry = _load_registry()
entry = registry.get(font_name, _ensure_registry_entry(font_name))
alpha = 0.3
prev_surya = entry.get("avg_confidence_surya")
prev_tess = entry.get("avg_confidence_tess")
try:
prev_surya_val = float(prev_surya) if prev_surya is not None else None
except Exception:
prev_surya_val = None
try:
prev_tess_val = float(prev_tess) if prev_tess is not None else None
except Exception:
prev_tess_val = None
surya_new = float(surya_conf or 0.0)
tess_new = float(tess_conf or 0.0)
surya_avg = surya_new if prev_surya_val is None else round(alpha * surya_new + (1 - alpha) * prev_surya_val, 4)
tess_avg = tess_new if prev_tess_val is None else round(alpha * tess_new + (1 - alpha) * prev_tess_val, 4)
entry["avg_confidence_surya"] = surya_avg
entry["avg_confidence_tess"] = tess_avg
entry["preferred_engine"] = "tesseract" if tess_avg > surya_avg else "surya"
if entry.get("status") == "unknown":
entry["status"] = "available"
registry[font_name] = entry
_save_registry(registry)
def font_correction_map_path(font_name: Optional[str]) -> Path:
"""
Font-specific punctuation learning map path.
"""
if not font_name:
return CALIBRATION_DIR / "punct_correction_map.json"
safe = _slug_font_name(font_name) or "unknown"
return CALIBRATION_DIR / f"punct_correction_map_{safe}.json"
def font_registry_summary() -> list[dict]:
registry = _load_registry()
rows = []
for name, row in sorted(
registry.items(),
key=lambda x: int(x[1].get("identified_count", 0)),
reverse=True,
):
rows.append(
{
"font_name": name,
"pages_seen": int(row.get("identified_count", 0)),
"status": str(row.get("status", "unknown")),
"preferred_engine": str(row.get("preferred_engine", "surya")),
"tesseract_model": row.get("tesseract_model"),
"surya_avg": row.get("avg_confidence_surya"),
"tess_avg": row.get("avg_confidence_tess"),
}
)
return rows