Spaces:
Sleeping
Sleeping
Aaron
Sync backend with GitHub main (f6aa117): OCR word-coherence filter, full_text consistency fix
e87916b Download processors/ocr_classification.py from lylron/wonq-backend: direct link, hf CLI and curl.
- Browser
- Download file 14.4 kB
-
https://huggingface.co/spaces/lylron/wonq-backend/resolve/main/processors/ocr_classification.py
- Command line
-
hf download hf://spaces/lylron/wonq-backend/processors/ocr_classification.py
-
curl -L -o ocr_classification.py https://huggingface.co/spaces/lylron/wonq-backend/resolve/main/processors/ocr_classification.py
14.4 kB
| """ | |
| OCR Classification | |
| =================== | |
| Shared text-classification rules used by every OCR provider (Tesseract, | |
| PaddleOCR, ...) so a phrase like "3,640 x 3,640" or "SCALE" is bucketed the | |
| same way regardless of which engine recognised it. | |
| Classification buckets: | |
| - dimensions : numeric strings WITH a recognised unit suffix (mm/cm/m/ft/in/'/") | |
| or a "WIDTH x HEIGHT" pair (with or without a unit suffix) | |
| - room_names : multi-character capitalised phrases (title-block keywords excluded) | |
| - numbers : bare numeric tokens (no unit suffix) | |
| - labels : everything else with sufficient confidence | |
| `clean_elements()` also drops phrases that don't look like real words at | |
| all β e.g. Tesseract misreading degraded line art as "ab ema", "Bm mma", | |
| "OOO" β so what reaches the UI reads as coherent text rather than letter | |
| soup. See that function's docstring for the rules and their measured | |
| justification. | |
| """ | |
| import os | |
| import re | |
| from typing import Any, Dict, List | |
| # Patterns for classification. | |
| # Digits may use comma thousands-separators (e.g. "3,640"), common in | |
| # architectural dimension callouts. | |
| _NUM = r"\d+(?:,\d{3})*(?:\.\d+)?" | |
| _DIM_RE = re.compile(rf"^{_NUM}\s*(mm|cm|m|ft|in|'|\")$", re.IGNORECASE) | |
| _DIM_PAIR_RE = re.compile( | |
| rf"^{_NUM}\s*[xΓ]\s*{_NUM}\s*(mm|cm|m|ft|in|'|\")?$", re.IGNORECASE | |
| ) | |
| _NUM_RE = re.compile(rf"^{_NUM}$") | |
| # Title-block / drawing-metadata words that are capitalised (and would | |
| # otherwise pass the room-name heuristic) but are not room names. | |
| _TITLE_BLOCK_WORDS = { | |
| "scale", "floor", "plan", "level", "section", "elevation", "detail", | |
| "site", "drawn", "checked", "approved", "date", "rev", "revision", | |
| "sheet", "north", "title", "project", "drawing", "no", "nts", "n.t.s", | |
| "scale:", "client", "architect", "job", | |
| } | |
| # Multi-word keywords first (longest-match preference), so "FLOOR PLAN" | |
| # matches as one unit rather than "PLAN" alone splitting it apart. | |
| _TITLE_BLOCK_KEYWORDS = [ | |
| "FLOOR PLAN", "SITE PLAN", "SCALE", "LEVEL", "SECTION", | |
| "ELEVATION", "DETAIL", "PLAN", | |
| ] | |
| _KEYWORD_SPLIT_RE = re.compile( | |
| r"\b(" + "|".join(re.escape(k) for k in _TITLE_BLOCK_KEYWORDS) + r")\b", | |
| re.IGNORECASE, | |
| ) | |
| _SCALE_RATIO_RE = re.compile(r"(\d+)\s*[:/]\s*(\d+)") | |
| # A phrase mixing CJK script with Latin letters/digits (e.g. a hatching | |
| # pattern or leader-line fragment misread as "5R5R5t5γ5γγ€γΉ") never | |
| # occurs in real architectural drawing text β legitimate content is either | |
| # pure ASCII (dimensions, room codes) or pure Japanese. Shared here (moved | |
| # from ocr_processor.py, which still re-exports it for compatibility) so | |
| # both the coherence filter below and any provider-level gibberish check | |
| # use the exact same definition of "this text is CJK." | |
| _CJK_RE = re.compile(r"[γ-γΏδΈ-ιΏΏ]") | |
| # --------------------------------------------------------------------- # | |
| # Word-coherence filter | |
| # --------------------------------------------------------------------- # | |
| # Real OCR problem, not hypothetical: on degraded/faded real scans (see | |
| # CLAUDE.md benchmark/real_samples/), Tesseract regularly emits confident- | |
| # looking but meaningless Latin-alphabet noise from misread line art β | |
| # "ab ema", "Bm mma", "OOO", "xD", "Ww", "Dag" β that survives the existing | |
| # confidence/height/CJK filters because each of those filters targets a | |
| # different specific failure mode (low confidence, tick-mark height, mixed | |
| # CJK/Latin script), not "does this spell an actual word." This targets | |
| # that gap directly: any purely-Latin-alphabet word in a phrase must be a | |
| # recognised English word or known architectural term, or the whole phrase | |
| # is dropped. Numeric, dimension, title-block, and CJK content is exempt β | |
| # each already has its own dedicated, more-appropriate validation. | |
| # | |
| # Deliberately dictionary-based rather than a hand-tuned regex heuristic | |
| # (e.g. "must contain a vowel"): short real garbage like "ee"/"we"/"ag" | |
| # would pass a vowel check but isn't real content here, while a dictionary | |
| # + a small curated domain vocabulary correctly separates the two. | |
| # | |
| # Two dictionaries, not one β measured directly on real_faded_1/2 (see | |
| # CLAUDE.md): checking the full ~370k-word dictionary alone let too much | |
| # garbage through, because English is large enough that short/mid-length | |
| # noise coincidentally spells *some* real (if obscure) word β "Nid", | |
| # "Sie", "pia", "teem", "ava", "icy", "dag" all measured as real dictionary | |
| # entries despite being OCR misreads of degraded line art, not real | |
| # drawing content. Fix: gate 3-5 letter words on the ~10k *common* English | |
| # words instead (architectural drawing text is ordinary vocabulary, not | |
| # rare words), and only fall back to the full dictionary for 6+ letter | |
| # words, where a garbage token coincidentally spelling a real word is much | |
| # rarer and the full dictionary's coverage (e.g. "wardrobe", "mezzanine") | |
| # is worth having. | |
| # | |
| # Both wordlists (processors/data/wordlist_en.txt, ~370k entries, dwyl's | |
| # english-words; wordlist_common_en.txt, ~10k entries, first20hours' | |
| # google-10000-english) ship zero-download β committed data files, not | |
| # downloaded packages β consistent with this project's proxy-blocked- | |
| # machine fallback pattern; see CLAUDE.md's Mac Cloudflare TLS note. | |
| # wordlist_en.txt is Unlicense/public domain; wordlist_common_en.txt is | |
| # word-frequency data derived from Google's public n-gram corpus. | |
| # | |
| # Known gap, not addressed here: CJK phrases (see _CJK_RE below) are | |
| # exempted wholesale rather than validated, since a real Japanese | |
| # coherence check needs a dictionary/morphological analyser (e.g. MeCab) | |
| # this project doesn't currently depend on. manga-ocr's own hallucination | |
| # risk on non-CJK crops is a separate, already-documented issue. | |
| _DATA_DIR = os.path.join(os.path.dirname(__file__), "data") | |
| def _load_wordlist(filename: str) -> frozenset: | |
| try: | |
| with open(os.path.join(_DATA_DIR, filename), "r", encoding="utf-8") as f: | |
| return frozenset(line.strip().lower() for line in f if line.strip()) | |
| except OSError: | |
| return frozenset() | |
| _COMMON_ENGLISH_WORDS = _load_wordlist("wordlist_common_en.txt") | |
| _ALL_ENGLISH_WORDS = _load_wordlist("wordlist_en.txt") | |
| # Common architectural-drawing vocabulary, kept separate from the general | |
| # dictionary so it survives even for terms too domain-specific (or, for | |
| # "mm"/"nts", too short/abbreviated) to reliably appear in a general | |
| # wordlist. Overlaps _TITLE_BLOCK_WORDS on purpose β that set is used for | |
| # title-block bucketing, this one for coherence screening; keeping them | |
| # separate avoids coupling two independent decisions to one constant. | |
| _ARCHITECTURAL_VOCAB = { | |
| "bedroom", "kitchen", "bathroom", "living", "room", "dining", "garage", | |
| "hall", "hallway", "closet", "office", "entry", "entrance", "patio", | |
| "balcony", "storage", "laundry", "pantry", "foyer", "study", "den", | |
| "porch", "deck", "utility", "mudroom", "loft", "attic", "basement", | |
| "genkan", "tatami", "veranda", "corridor", "lobby", "stairs", | |
| "stairway", "landing", "terrace", "carport", "workshop", | |
| "mm", "cm", "sq", "no", "rm", "wc", "el", "fl", "nts", | |
| "scale", "floor", "plan", "level", "section", "elevation", "detail", | |
| "site", "drawn", "checked", "approved", "date", "rev", "revision", | |
| "sheet", "north", "title", "project", "drawing", "client", | |
| "architect", "job", | |
| } | |
| # 1-2 letter tokens are too short for dictionary lookup to be meaningful | |
| # (many short garbage reads β "we", "ea", "ag" β happen to be, or nearly | |
| # resemble, real words/dictionary entries) β so these need an explicit | |
| # whitelist instead. "x"/"Γ" are the standard dimension-pair separator | |
| # ("3,640 x 3,640") and must survive even though they're single characters. | |
| _SHORT_WORD_WHITELIST = {"a", "i", "x", "no", "up", "dn", "el", "fl", "rm", "wc"} | |
| _LETTERS_RE = re.compile(r"[A-Za-z']+") | |
| # Grid-line / door / window / room reference codes ("G7", "A1", "W-12") are | |
| # common, legitimate architectural-drawing content β not real "words" by | |
| # any dictionary, but not gibberish either. Exempted from the plausibility | |
| # check below so this filter doesn't strip them. | |
| _REF_CODE_RE = re.compile(r"^[A-Za-z]{1,2}-?\d{1,3}$|^\d{1,3}-?[A-Za-z]{1,2}$") | |
| def _word_is_plausible(word: str) -> bool: | |
| """`word` is already lowercased. See the tiered-dictionary comment | |
| above _COMMON_ENGLISH_WORDS for why the cutoff differs by length.""" | |
| if word in _SHORT_WORD_WHITELIST or word in _ARCHITECTURAL_VOCAB: | |
| return True | |
| if len(word) <= 2: | |
| return False # too short for dictionary lookup to mean anything β see _SHORT_WORD_WHITELIST comment | |
| if len(word) <= 5: | |
| return word in _COMMON_ENGLISH_WORDS | |
| return word in _COMMON_ENGLISH_WORDS or word in _ALL_ENGLISH_WORDS | |
| def _is_coherent(text: str) -> bool: | |
| """False if `text` is Latin-alphabet noise β an OCR misread that | |
| doesn't spell real words. See module-level comment above for the full | |
| rationale and what's deliberately exempted (numbers, title-block | |
| keywords, CJK).""" | |
| if not text or not text.strip(): | |
| return False | |
| if _CJK_RE.search(text): | |
| return True | |
| if is_title_block(text): | |
| return True | |
| tokens = text.split() | |
| if not tokens: | |
| return False | |
| saw_letters = False | |
| for tok in tokens: | |
| if _REF_CODE_RE.match(tok): | |
| saw_letters = True | |
| continue | |
| letters = "".join(_LETTERS_RE.findall(tok)) | |
| if not letters: | |
| continue # pure digit/punctuation token β dimension figures, tick marks, etc. | |
| saw_letters = True | |
| if not _word_is_plausible(letters.lower()): | |
| return False | |
| return saw_letters or bool(re.search(r"\d", text)) | |
| def clean_elements(elements: List[Dict[str, Any]]) -> List[Dict[str, Any]]: | |
| """Splits fused title-block phrases and drops phrases that don't read | |
| as coherent text (see _is_coherent). Every OCR provider should call | |
| this once and use the result as *the* canonical elements list β what | |
| full_text, element_count, and classify() are all built from β so | |
| filtered-out noise never resurfaces in one of those three places | |
| while being absent from another.""" | |
| return [e for e in split_title_block_phrases(elements) if _is_coherent(e["text"])] | |
| def _split_bbox(elem: Dict[str, Any], spans: List[tuple]) -> List[Dict[str, Any]]: | |
| """Divide elem's bounding box across `spans` (list of (start, end) char | |
| offsets into elem["text"]) proportionally by character count β the | |
| detector gave one box for the whole fused phrase, so this is an | |
| approximation, but good enough for downstream highlighting.""" | |
| text = elem["text"] | |
| total = len(text) or 1 | |
| out = [] | |
| for start, end in spans: | |
| piece = text[start:end].strip(" -ββ:,") | |
| if not piece: | |
| continue | |
| frac_start = start / total | |
| frac_len = max(end - start, 1) / total | |
| out.append({ | |
| **elem, | |
| "text": piece, | |
| "x": elem["x"] + round(elem["width"] * frac_start), | |
| "width": max(1, round(elem["width"] * frac_len)), | |
| }) | |
| return out | |
| def _split_scale_ratio(elem: Dict[str, Any]) -> List[Dict[str, Any]]: | |
| """'SCALE 1:100,600mm' -> ['SCALE', '1:100', '600mm'] β PaddleOCR's | |
| detector fuses the SCALE keyword directly onto its ratio value with no | |
| separating gap, so the keyword splitter above (which only splits on | |
| keyword *boundaries*) can't separate them; this handles that one | |
| specific, common title-block pattern directly.""" | |
| text = elem["text"] | |
| if not re.match(r"^\s*SCALE\b", text, re.IGNORECASE): | |
| return [elem] | |
| m = _SCALE_RATIO_RE.search(text, pos=5) # search after "SCALE" | |
| if not m: | |
| return [elem] | |
| spans = [(0, 5), m.span()] | |
| remainder_start = m.end() | |
| if remainder_start < len(text): | |
| spans.append((remainder_start, len(text))) | |
| return _split_bbox(elem, spans) | |
| def split_title_block_phrases(elements: List[Dict[str, Any]]) -> List[Dict[str, Any]]: | |
| """ | |
| Splits a single detected phrase that fuses multiple title-block fields | |
| into one string β e.g. PaddleOCR returning "FLOOR PLAN - LEVEL 1" as one | |
| detection instead of two β into separate elements per field, so | |
| classification (and ground-truth token matching) sees them as the | |
| distinct tokens they actually are. A no-op for phrases with 0-1 keyword | |
| matches (the common case, including Tesseract's output, which already | |
| segments these into separate detections upstream). | |
| """ | |
| out: List[Dict[str, Any]] = [] | |
| for elem in elements: | |
| matches = list(_KEYWORD_SPLIT_RE.finditer(elem["text"])) | |
| if len(matches) < 2: | |
| out.extend(_split_scale_ratio(elem)) | |
| continue | |
| spans = [] | |
| for i, m in enumerate(matches): | |
| end = matches[i + 1].start() if i + 1 < len(matches) else len(elem["text"]) | |
| spans.append((m.start(), end)) | |
| for piece in _split_bbox(elem, spans): | |
| out.extend(_split_scale_ratio(piece)) | |
| return out | |
| def classify(elements: List[Dict[str, Any]]) -> Dict[str, List]: | |
| classified: Dict[str, List] = { | |
| "dimensions": [], | |
| "room_names": [], | |
| "numbers": [], | |
| "labels": [], | |
| } | |
| for elem in clean_elements(elements): | |
| text = elem["text"] | |
| if _DIM_PAIR_RE.match(text) or _DIM_RE.match(text): | |
| classified["dimensions"].append(elem) | |
| elif _NUM_RE.match(text): | |
| classified["numbers"].append(elem) | |
| elif is_title_block(text): | |
| classified["labels"].append(elem) | |
| elif len(text) > 2 and text[0].isupper(): | |
| classified["room_names"].append(elem) | |
| else: | |
| classified["labels"].append(elem) | |
| return classified | |
| def is_title_block(text: str) -> bool: | |
| """True if any word in the phrase is a drawing-metadata keyword.""" | |
| words = re.findall(r"[A-Za-z.:]+", text.lower()) | |
| return any(w in _TITLE_BLOCK_WORDS for w in words) | |
| def empty_result(reason: str) -> Dict[str, Any]: | |
| return { | |
| "full_text": "", | |
| "element_count": 0, | |
| "elements": [], | |
| "classified": { | |
| "dimensions": [], | |
| "room_names": [], | |
| "numbers": [], | |
| "labels": [], | |
| }, | |
| "error": reason, | |
| } | |