| |
| """ |
| Shared, shape-agnostic primitives used by both classical_cv_detector.py and |
| the per-shape modules under shapes/: PDF loading, binarization, tiling, |
| OCR helpers, and deduplication. No shape-specific (diamond/circle/hexagon) |
| logic lives here. |
| """ |
|
|
| import os |
| import re |
| import subprocess |
|
|
| import cv2 |
| import easyocr |
| import fitz |
| import numpy as np |
|
|
| |
| |
| _MODEL_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "Model", "easyocr") |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _GPU_ENABLED = os.environ.get("CV_FORCE_CPU") != "1" |
|
|
| |
| |
| _READER = easyocr.Reader(["en"], gpu=_GPU_ENABLED, verbose=False, |
| model_storage_directory=_MODEL_DIR) |
| _ALLOWLIST = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" |
|
|
|
|
| |
| DPI = 200 |
| TILE_SIZE = 640 |
| TILE_OVERLAP = 0.15 |
| MIN_AREA = 100 |
| MAX_AREA = 8000 |
| OCR_UPSCALE = 6 |
| MASK_ERODE = 2 |
| NMS_DIST = 25 |
| HV_LINE_MIN = 80 |
| |
|
|
|
|
| |
|
|
| def load_pages(pdf_path: str, dpi: int = DPI) -> list[np.ndarray]: |
| """Return each PDF page as a BGR numpy array.""" |
| doc = fitz.open(pdf_path) |
| mat = fitz.Matrix(dpi / 72, dpi / 72) |
| pages = [] |
| for page in doc: |
| pix = page.get_pixmap(matrix=mat, colorspace=fitz.csRGB) |
| arr = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width, 3) |
| pages.append(arr[:, :, ::-1].copy()) |
| return pages |
|
|
|
|
| |
|
|
| def binarize(bgr: np.ndarray) -> np.ndarray: |
| """ |
| Adaptive threshold β inverted binary (white lines on black background). |
| Working on a tile rather than the full page makes blockSize=15 meaningful |
| relative to the symbol size. |
| """ |
| gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) |
| blur = cv2.GaussianBlur(gray, (3, 3), 0) |
| return cv2.adaptiveThreshold( |
| blur, 255, |
| cv2.ADAPTIVE_THRESH_GAUSSIAN_C, |
| cv2.THRESH_BINARY_INV, |
| blockSize=15, |
| C=6, |
| ) |
|
|
|
|
| |
|
|
| def remove_hv_lines(binary: np.ndarray, min_length: int = HV_LINE_MIN) -> np.ndarray: |
| """ |
| Erase long horizontal and vertical lines from the binary image. |
| |
| Morphological OPEN with a long thin kernel extracts only the portions of |
| white-pixel runs that span at least min_length in one direction β i.e. the |
| wall lines, dimension lines, and grid lines that typically cross symbol |
| outlines in blueprints. Subtracting them breaks the connected-component |
| link between a symbol outline and whatever crosses it. |
| """ |
| h_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (min_length, 1)) |
| v_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, min_length)) |
| h_lines = cv2.morphologyEx(binary, cv2.MORPH_OPEN, h_kernel) |
| v_lines = cv2.morphologyEx(binary, cv2.MORPH_OPEN, v_kernel) |
| return cv2.subtract(binary, cv2.add(h_lines, v_lines)) |
|
|
|
|
| |
|
|
| def _tess(img: np.ndarray, psm: int) -> str: |
| """ |
| Call Tesseract via stdin/stdout subprocess β bypasses pytesseract's |
| error handler which crashes on Tesseract 5.5's binary stderr output. |
| Kept as a utility but no longer used in the main pipeline. |
| """ |
| _, buf = cv2.imencode(".png", img) |
| r = subprocess.run( |
| ["tesseract", "stdin", "stdout", |
| "--psm", str(psm), |
| "-c", "tessedit_char_whitelist=ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"], |
| input=buf.tobytes(), |
| capture_output=True, |
| ) |
| return r.stdout.decode("utf-8", errors="replace").strip().replace(" ", "") |
|
|
|
|
| def _eocr(img: np.ndarray) -> str: |
| """ |
| Run EasyOCR on a uint8 image (grayscale or BGR) and return the |
| concatenated recognised text, stripped of spaces. |
| |
| EasyOCR's neural recognition substantially outperforms Tesseract on |
| small architectural text β in particular it correctly separates visually |
| similar glyphs such as E/C and B/8 that Tesseract confuses at small sizes. |
| The allowlist restricts output to the valid character set for diamond codes. |
| """ |
| try: |
| results = _READER.readtext( |
| img, |
| allowlist=_ALLOWLIST, |
| detail=0, |
| paragraph=False, |
| ) |
| return "".join(results).strip().replace(" ", "") |
| except Exception: |
| return "" |
|
|
|
|
| def _ocr_with_mask( |
| tile: np.ndarray, |
| mask: np.ndarray, |
| bbox: tuple[int, int, int, int], |
| upscale: int = OCR_UPSCALE, |
| ) -> str: |
| """ |
| Mask out a shape's outline using a precomputed full-tile binary mask |
| (255 = interior, 0 = background/outline), leaving only the interior |
| (letter + digit), then OCR the clean crop. |
| |
| `mask` must already be eroded inward enough to clear outline pixels. |
| Shared by every shape's OCR helper (mask built from a detected contour) |
| and the exemplar-driven matcher (mask built once from the user's example |
| image and repositioned per match). |
| """ |
| x, y, w, h = bbox |
| gray = cv2.cvtColor(tile, cv2.COLOR_BGR2GRAY) |
| interior = gray.copy() |
| interior[mask == 0] = 255 |
|
|
| |
| |
| |
| |
| crop = interior[max(0, y):y + h, max(0, x):x + w] |
| if crop.size == 0: |
| return "" |
| crop = cv2.resize(crop, None, fx=upscale, fy=upscale, interpolation=cv2.INTER_CUBIC) |
|
|
| text = _eocr(crop) |
| return text if re.match(r"^[A-Z]\d$", text) else "" |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| _REPORT_FAILED_KEYS: set = set() |
|
|
|
|
| def report_progress(progress_dir: str | None, progress_key, msg: str) -> None: |
| if progress_dir is None: |
| return |
| try: |
| with open(os.path.join(progress_dir, f"page_{progress_key}.txt"), "w") as f: |
| f.write(msg) |
| except Exception as e: |
| if progress_key not in _REPORT_FAILED_KEYS: |
| _REPORT_FAILED_KEYS.add(progress_key) |
| print(f"[cv_core pid={os.getpid()}] progress write FAILED for " |
| f"page {progress_key}: {e!r} (dir={progress_dir!r})", flush=True) |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| class SpatialIndex: |
| """ |
| Grid-based spatial index for the `abs(dx) < dist and abs(dy) < dist` |
| square-window membership test used throughout the NMS passes. Bucketing |
| by a grid cell sized to `dist` makes each query O(points in the 3x3 |
| neighbouring cells) instead of O(n) -- close to O(1) for realistically |
| sparse detections, regardless of how many points have been indexed. |
| """ |
|
|
| def __init__(self, dist: int): |
| self.dist = dist |
| self._cells: dict[tuple[int, int], list[tuple[int, int]]] = {} |
|
|
| def _key(self, x: int, y: int) -> tuple[int, int]: |
| return (x // self.dist, y // self.dist) |
|
|
| def near_any(self, x: int, y: int) -> bool: |
| gx, gy = self._key(x, y) |
| for dgx in (-1, 0, 1): |
| for dgy in (-1, 0, 1): |
| for ex, ey in self._cells.get((gx + dgx, gy + dgy), ()): |
| if abs(x - ex) < self.dist and abs(y - ey) < self.dist: |
| return True |
| return False |
|
|
| def add(self, x: int, y: int) -> None: |
| self._cells.setdefault(self._key(x, y), []).append((x, y)) |
|
|
|
|
| |
|
|
| def iter_tiles( |
| bgr: np.ndarray, tile_size: int, overlap: float |
| ): |
| """Yield (tile_bgr, x_offset, y_offset) for every overlapping tile.""" |
| H, W = bgr.shape[:2] |
| stride = max(1, int(tile_size * (1 - overlap))) |
| for y0 in range(0, H, stride): |
| for x0 in range(0, W, stride): |
| x1 = min(W, x0 + tile_size) |
| y1 = min(H, y0 + tile_size) |
| yield bgr[y0:y1, x0:x1], x0, y0 |
|
|
|
|
| def deduplicate(dets: list[dict], min_dist: int = NMS_DIST) -> list[dict]: |
| """ |
| Remove duplicate detections caused by a symbol appearing in multiple tiles. |
| Two detections are duplicates if their bbox centres are within min_dist px. |
| """ |
| kept: list[dict] = [] |
| for d in dets: |
| x, y, w, h = d["bbox"] |
| cx, cy = x + w / 2.0, y + h / 2.0 |
| is_dup = any( |
| abs(cx - (k["bbox"][0] + k["bbox"][2] / 2.0)) < min_dist |
| and abs(cy - (k["bbox"][1] + k["bbox"][3] / 2.0)) < min_dist |
| for k in kept |
| ) |
| if not is_dup: |
| kept.append(d) |
| return kept |
|
|
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| MARKED_BOX_MIN_AREA = 400 |
| MARKED_BOX_MAX_AREA = 20000 |
|
|
|
|
| def find_marked_boxes( |
| page_bgr: np.ndarray, |
| min_area: int = MARKED_BOX_MIN_AREA, |
| max_area: int = MARKED_BOX_MAX_AREA, |
| ) -> list[tuple[int, int, int, int]]: |
| """ |
| Find pre-existing red highlight boxes on a RAW (un-annotated) page. |
| |
| Thresholds directly on colour (high red channel, low green/blue) rather |
| than via binarize()/contour-shape logic, since the markup is a solid, |
| saturated red distinct from the black/grey line work everywhere else on |
| a blueprint page. Returns one (x, y, w, h) bounding box per contiguous |
| red outline whose bounding-box area falls in [min_area, max_area]. |
| """ |
| b, g, r = cv2.split(page_bgr.astype(np.int16)) |
| red_mask = ((r > 150) & (g < 100) & (b < 100)).astype(np.uint8) * 255 |
|
|
| contours, _ = cv2.findContours(red_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) |
| boxes = [] |
| for cnt in contours: |
| x, y, w, h = cv2.boundingRect(cnt) |
| if min_area <= w * h <= max_area: |
| boxes.append((x, y, w, h)) |
| return boxes |
|
|
|
|
| def match_marked_boxes( |
| marked_boxes: list[tuple[int, int, int, int]], |
| dets: list[dict], |
| min_dist: int = NMS_DIST, |
| ) -> dict: |
| """ |
| Cross-reference pre-existing marked boxes (see find_marked_boxes) against |
| this pipeline's own detections to measure recall. |
| |
| A marked box counts as "matched" if any detection's centre falls within |
| tolerance of the marked box's centre. Tolerance is the larger of |
| min_dist (NMS_DIST) and half the marked box's own footprint plus a |
| margin, since these are hand-placed highlight boxes and won't align |
| pixel-for-pixel with a detection's own bbox the way two independent |
| passes of this pipeline would. |
| |
| Returns {"total_marked", "matched", "missed": [(x,y,w,h), ...]}. |
| """ |
| det_centres = [ |
| (d["bbox"][0] + d["bbox"][2] / 2.0, d["bbox"][1] + d["bbox"][3] / 2.0) |
| for d in dets |
| ] |
|
|
| missed: list[tuple[int, int, int, int]] = [] |
| for (mx, my, mw, mh) in marked_boxes: |
| mcx, mcy = mx + mw / 2.0, my + mh / 2.0 |
| tol_x = max(min_dist, mw / 2.0 + 10) |
| tol_y = max(min_dist, mh / 2.0 + 10) |
| found = any( |
| abs(mcx - dcx) < tol_x and abs(mcy - dcy) < tol_y |
| for dcx, dcy in det_centres |
| ) |
| if not found: |
| missed.append((mx, my, mw, mh)) |
|
|
| return { |
| "total_marked": len(marked_boxes), |
| "matched": len(marked_boxes) - len(missed), |
| "missed": missed, |
| } |
|
|