wonq-backend / processors /ocr_classification.py
Aaron
Sync backend with GitHub main (f6aa117): OCR word-coherence filter, full_text consistency fix
e87916b
Raw History Blame Contribute Delete
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,
}