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)