Spaces:
Sleeping
Sleeping
File size: 16,978 Bytes
ef464f0 d9106f7 ef464f0 d9106f7 ef464f0 d9106f7 ef464f0 d9106f7 ef464f0 e87916b d9106f7 ef464f0 d9106f7 ef464f0 d9106f7 ef464f0 d9106f7 ef464f0 d9106f7 e87916b d9106f7 0f1af41 ef464f0 e87916b ef464f0 d9106f7 ef464f0 d9106f7 ef464f0 d9106f7 ef464f0 d9106f7 ef464f0 d9106f7 e87916b d9106f7 e87916b d9106f7 ef464f0 e87916b ef464f0 d9106f7 ef464f0 d9106f7 ef464f0 d9106f7 ef464f0 d9106f7 ef464f0 d9106f7 ef464f0 d9106f7 ef464f0 d9106f7 ef464f0 d9106f7 ef464f0 d9106f7 ef464f0 d9106f7 0f1af41 d9106f7 0f1af41 d9106f7 0f1af41 d9106f7 0f1af41 d9106f7 0f1af41 d9106f7 ef464f0 d9106f7 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 | """
OCR Processor
=============
Extracts and classifies text from enhanced architectural drawings using
Tesseract 5 (LSTM engine via pytesseract).
Words are grouped into phrases (see `_group_phrases`) before classification
so that multi-word labels such as "Living Room" or "3,640 x 3,640" are
treated as single tokens rather than being split apart and losing meaning.
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
"""
import os
import re
import shutil
import cv2
import numpy as np
import logging
from typing import Any, Dict, List
from .ocr_classification import _CJK_RE, classify, clean_elements, empty_result
try:
import pytesseract
_TESSERACT_AVAILABLE = True
# On Windows the UB-Mannheim installer doesn't add tesseract to PATH β
# fall back to its default install location so pytesseract can spawn it.
if os.name == "nt" and shutil.which("tesseract") is None:
_win_default = r"C:\Program Files\Tesseract-OCR\tesseract.exe"
if os.path.exists(_win_default):
pytesseract.pytesseract.tesseract_cmd = _win_default
except ImportError:
_TESSERACT_AVAILABLE = False
logger = logging.getLogger(__name__)
# Tesseract page-segmentation-mode:
# 3 = fully automatic page segmentation (default) β floor plans scatter
# room labels and dimension callouts across a 2D grid rather than one
# paragraph, so psm 6 ("assume a uniform block of text") was forcing
# a single reading order across unrelated regions: it merged distant
# labels onto one "line" and regularly dropped isolated words (e.g.
# a lone "Bedroom" label with nothing beside it) that automatic
# segmentation finds correctly.
# OEM 3 = LSTM + legacy combined (most accurate)
# lang: jpn+eng handles Japanese architectural drawings; Tesseract falls back
# gracefully to eng-only if the jpn data file is not installed.
_TESS_CONFIG = "--psm 3 --oem 3"
# Supplementary pass: psm 11 ("sparse text, no particular order") recovers
# isolated labels that automatic segmentation sometimes drops entirely β
# e.g. a single room name with nothing else nearby. Only phrases that don't
# overlap the primary pass are kept (see the merge in extract_text).
_TESS_CONFIG_SPARSE = "--psm 11 --oem 3"
_TESS_LANG = "jpn+eng"
# Minimum confidence (0-100) to include a token
_MIN_CONFIDENCE = 30
# Adaptive resolution boost β see _estimate_upscale_factor().
#
# A blind pixel-count floor doesn't work here: the project's own clean,
# computer-rendered benchmark images are already well above any reasonable
# floor (e.g. 1400x1000), so a floor keyed to *overall image size* never
# fires for them β but a naive fix that instead forces a large fixed
# upscale on *every* image regressed the clean-sample benchmark hard
# (OCR Token F1 96.3% -> 68.5%): interpolation blur/re-splitting hurts
# text that was already legible. The real fix has to be conditional on
# whether the text is *actually* too small, not on the image's pixel count.
#
# Measured directly: at native resolution, Tesseract found
# - floor_plan_level1.jpg (clean render): 34 word boxes, median height 21px
# - synthetic_floor_plan.png(clean render): 5 word boxes, median height 36px
# - real_faded_1.jpg (real 1970s drawing): 4 word boxes, median height 9-11px
# Tesseract's LSTM models are trained on ~25-30px glyph height β the clean
# samples already sit in that range and must be left alone; the real
# drawing is running at roughly a third of it, which is why OCR was
# effectively finding almost nothing (not primarily a "handwriting is
# hard" problem β a resolution problem). Upscaling the real drawing ~3.2x
# brought its median glyph height to ~32px and raw box count from 4 to 17+,
# recovering real dimension digits and label fragments that were
# completely invisible before.
_OCR_TARGET_GLYPH_HEIGHT_PX = 28
_OCR_MIN_HEALTHY_HEIGHT_PX = 18 # skip upscaling if already at least this tall
_OCR_MAX_UPSCALE = 4.0 # safety cap against a single stray tiny box
# Phrases shorter than this (pixels, post _prepare()'s upscale-to-β₯800px-side
# floor) are almost never real text β they're dimension tick-marks, arrowheads,
# and extension-line stubs that Tesseract occasionally misreads as a stray
# digit or punctuation mark. Real content in practice starts around 17px at
# this preprocessing scale (the smallest dimension-callout digits); the
# smallest observed misread artifacts sit at 10-11px, so 14px cleanly
# separates the two with margin on both sides. Applied to the *merged
# phrase's* height (not each word's) so a short lowercase separator like the
# "x" in "3,640 x 3,640" β legitimately ~11px tall on its own β doesn't get
# dropped: it survives because the phrase's bounding box is dominated by its
# taller digit neighbours.
_MIN_PHRASE_HEIGHT_PX = 14
# Words on the same Tesseract line are only merged into one phrase if the
# horizontal gap between them is below this many pixels β otherwise a
# single wide title-block row (e.g. "SCALE 1:100 FLOOR PLAN 6,400mm")
# would collapse into one meaningless blob. Scaled by the line's own text
# height so it works across resolutions.
_MAX_WORD_GAP_RATIO = 2.5
# Tesseract occasionally misreads the comma in a thousands-grouped mm value
# as a period (e.g. "6,400mm" -> "6.400mm"). Only correct this for "mm"
# specifically: a real mm measurement is never expressed to 3 decimal
# places (that would be sub-micrometre precision), so "\d.\d\d\dmm" is
# always a misread comma, not a genuine decimal β unlike metres, where
# 3-decimal precision (e.g. "12.345m") is a completely normal way to state
# a millimetre-accurate length. Scoped to mm only so real decimals survive.
_FIX_MM_THOUSANDS_RE = re.compile(r"(\d)\.(\d{3})(?=\s*mm\b)", re.IGNORECASE)
# Tokens made up entirely of dash/line/box-drawing glyphs. Tesseract
# regularly misreads isolated line strokes (door-swing arcs, leader lines,
# dashed hidden-line marks) as these characters β most commonly the
# katakana chΕon mark (γΌ) when jpn+eng is active. They are dropped
# unconditionally: real text never consists solely of these glyphs, and
# they otherwise survive confidence filtering with deceptively high scores.
_NOISE_RE = re.compile(r"^[\-_|.Β·β-ββ-βΏγΌ]+$")
# 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. Combined jpn+eng
# recognition hallucinates this kind of mixed gibberish from fine line
# art (hatching, tick marks, arc endpoints), so it is dropped outright.
# Shared definition lives in ocr_classification.py (also used by the
# word-coherence filter); re-imported under this name for compatibility.
_LATIN_DIGIT_RE = re.compile(r"[A-Za-z0-9]")
class OCRProcessor:
"""Extracts text elements and their positions from architectural drawings."""
def extract_text(self, image: np.ndarray) -> Dict[str, Any]:
"""
Parameters
----------
image : enhanced binary or grayscale image
Returns
-------
dict with keys:
full_text β cleaned phrases joined by newline (see module
docstring and ocr_classification.clean_elements β
*not* Tesseract's raw image_to_string dump, which
skips every quality filter elements/classified
already go through and reads as gibberish on
degraded scans)
element_count
elements β list of {text, x, y, width, height, confidence}
(phrase-grouped β see module docstring)
classified β dict of {dimensions, room_names, numbers, labels}
"""
if not _TESSERACT_AVAILABLE:
logger.warning("pytesseract not installed β OCR skipped")
return empty_result("pytesseract not installed")
prepared = self._prepare(image)
try:
primary_phrases = self._group_phrases(
self._parse_words(self._run_tesseract_data(prepared, _TESS_CONFIG))
)
sparse_phrases = self._group_phrases(
self._parse_words(self._run_tesseract_data(prepared, _TESS_CONFIG_SPARSE))
)
except Exception as exc:
logger.exception("Tesseract error")
return empty_result(str(exc))
# Keep every primary-pass phrase, plus any sparse-pass phrase that
# doesn't spatially overlap something the primary pass already found
# β this recovers isolated labels automatic segmentation drops
# (see _TESS_CONFIG_SPARSE) without duplicating anything.
raw_elements = primary_phrases + [
p for p in sparse_phrases if not self._overlaps_any(p, primary_phrases)
]
elements = clean_elements(raw_elements)
classified = classify(elements)
return {
"full_text": "\n".join(e["text"] for e in elements),
"element_count": len(elements),
"elements": elements,
"classified": classified,
}
# ------------------------------------------------------------------ #
# Private helpers #
# ------------------------------------------------------------------ #
def _run_tesseract_data(self, prepared: np.ndarray, config: str) -> Dict[str, List]:
return pytesseract.image_to_data(
prepared, config=config, lang=_TESS_LANG, output_type=pytesseract.Output.DICT,
)
@staticmethod
def _overlaps_any(phrase: Dict[str, Any], others: List[Dict[str, Any]]) -> bool:
"""True if `phrase`'s box intersects any box in `others` at all."""
px0, py0 = phrase["x"], phrase["y"]
px1, py1 = px0 + phrase["width"], py0 + phrase["height"]
for other in others:
ox0, oy0 = other["x"], other["y"]
ox1, oy1 = ox0 + other["width"], oy0 + other["height"]
if px0 < ox1 and px1 > ox0 and py0 < oy1 and py1 > oy0:
return True
return False
def _prepare(self, image: np.ndarray) -> np.ndarray:
"""
Upscale genuinely-too-small text and ensure white-background /
black-text convention that Tesseract expects.
"""
# Grayscale
if len(image.shape) == 3:
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
else:
gray = image.copy()
# Absolute floor for pathologically tiny images (unchanged from
# before) β resolution-adaptive upscaling below still needs enough
# pixels for its own probe pass to find anything at all.
h, w = gray.shape
if min(h, w) < 800:
scale = 800 / min(h, w)
gray = cv2.resize(
gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC
)
# Tesseract expects dark-text-on-light-background
if np.mean(gray) < 127:
gray = cv2.bitwise_not(gray)
scale = self._estimate_upscale_factor(gray)
if scale > 1.0:
gray = cv2.resize(gray, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
return gray
def _estimate_upscale_factor(self, gray: np.ndarray) -> float:
"""
Probe Tesseract's own detected glyph heights at current resolution
to decide whether upscaling is actually needed β see the
module-level comment above _OCR_TARGET_GLYPH_HEIGHT_PX for why a
blind pixel-count floor doesn't work (it regressed the clean-sample
benchmark) and how these specific thresholds were measured.
"""
try:
data = pytesseract.image_to_data(
gray, config=_TESS_CONFIG, lang=_TESS_LANG, output_type=pytesseract.Output.DICT,
)
except Exception:
return 1.0
heights = [
data["height"][i] for i in range(len(data["level"]))
if (data["text"][i] or "").strip() and int(data["conf"][i]) > 0
]
if heights and np.median(heights) >= _OCR_MIN_HEALTHY_HEIGHT_PX:
return 1.0 # already legible at native resolution β leave it alone
sample_height = float(np.median(heights)) if heights else 10.0
scale = _OCR_TARGET_GLYPH_HEIGHT_PX / max(sample_height, 1.0)
return float(min(scale, _OCR_MAX_UPSCALE))
def _parse_words(self, data: Dict[str, List]) -> List[Dict[str, Any]]:
"""Filter and normalise Tesseract word-level output."""
words: List[Dict[str, Any]] = []
n = len(data["level"])
for i in range(n):
text = (data["text"][i] or "").strip()
conf = int(data["conf"][i])
if conf < _MIN_CONFIDENCE or not text:
continue
if _NOISE_RE.match(text):
continue
words.append(
{
"text": text,
"confidence": conf,
"x": int(data["left"][i]),
"y": int(data["top"][i]),
"width": int(data["width"][i]),
"height": int(data["height"][i]),
"block_num": data["block_num"][i],
"par_num": data["par_num"][i],
"line_num": data["line_num"][i],
"word_num": data["word_num"][i],
}
)
return words
def _group_phrases(self, words: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
"""
Merge words on the same Tesseract line into phrases, splitting on
large horizontal gaps so unrelated title-block cells that happen to
share a line don't collapse into one token (see _MAX_WORD_GAP_RATIO).
"""
phrases: List[Dict[str, Any]] = []
current: List[Dict[str, Any]] = []
def flush() -> None:
if not current:
return
text = " ".join(w["text"] for w in current)
text = _FIX_MM_THOUSANDS_RE.sub(r"\1,\2", text)
# Stray quote/backtick marks are a common misread of a tick-mark
# or witness-line endpoint fused onto the adjacent character by
# Tesseract (e.g. '"SCALE'); real drawing text never legitimately
# starts or ends with one, so it's safe to trim unconditionally.
text = text.strip("\"'`")
if not text:
return
if _CJK_RE.search(text) and _LATIN_DIGIT_RE.search(text):
return # mixed CJK/Latin gibberish β see _CJK_RE comment
xs0 = min(w["x"] for w in current)
ys0 = min(w["y"] for w in current)
xs1 = max(w["x"] + w["width"] for w in current)
ys1 = max(w["y"] + w["height"] for w in current)
if ys1 - ys0 < _MIN_PHRASE_HEIGHT_PX:
return # tick-mark/arrowhead misread as a character β see _MIN_PHRASE_HEIGHT_PX
phrases.append({
"text": text,
"confidence": round(sum(w["confidence"] for w in current) / len(current)),
"x": xs0,
"y": ys0,
"width": xs1 - xs0,
"height": ys1 - ys0,
})
prev = None
for word in words:
same_line = (
prev is not None
and word["block_num"] == prev["block_num"]
and word["par_num"] == prev["par_num"]
and word["line_num"] == prev["line_num"]
)
gap_ok = True
if same_line:
gap = word["x"] - (prev["x"] + prev["width"])
max_gap = max(30, prev["height"] * _MAX_WORD_GAP_RATIO)
gap_ok = gap <= max_gap
if same_line and gap_ok:
current.append(word)
else:
flush()
current = [word]
prev = word
flush()
return phrases
def _classify(self, elements: List[Dict[str, Any]]) -> Dict[str, List]:
"""Kept as an instance method for backward compatibility with
existing callers/tests; delegates to the provider-shared rules in
ocr_classification.py so Tesseract and PaddleOCR bucket text
identically."""
return classify(elements)
|