""" 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, }