Deploy DiagraMine
Browse files- Dockerfile +47 -0
- README.md +10 -4
- data/icon_templates/docker.png +0 -0
- data/icon_templates/ms_sql.png +0 -0
- diagram_analysis.py +86 -0
- requirements.txt +34 -0
- src/__init__.py +0 -0
- src/api.py +84 -0
- src/arrow_detection/__init__.py +1 -0
- src/arrow_detection/cnn_detector.py +265 -0
- src/arrow_detection/hough_lines_detector.py +199 -0
- src/arrow_detection/pixel_scan_detector.py +150 -0
- src/box_detection/__init__.py +0 -0
- src/box_detection/canny_contours_detector.py +138 -0
- src/box_detection/hough_detector.py +149 -0
- src/box_detection/yolo_detector.py +60 -0
- src/graph/__init__.py +0 -0
- src/graph/builder.py +276 -0
- src/icon_detection/__init__.py +1 -0
- src/icon_detection/clip_detector.py +115 -0
- src/icon_detection/hsv_detector.py +72 -0
- src/icon_detection/template_detector.py +106 -0
- src/pipeline.py +261 -0
- src/schemas.py +128 -0
- src/text_detection/__init__.py +0 -0
- src/text_detection/easyocr_detector.py +49 -0
- src/text_detection/paddle_detector.py +67 -0
- src/text_detection/tesseract_detector.py +76 -0
Dockerfile
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DiagraMine — production FastAPI image.
|
| 2 |
+
#
|
| 3 |
+
# Build: docker build -t diagramine:4.0 .
|
| 4 |
+
# Run: docker run -p 8000:8000 diagramine:4.0
|
| 5 |
+
# Probe: curl http://localhost:8000/health
|
| 6 |
+
# Use: curl -F "file=@diagram.png" http://localhost:8000/extract | jq .
|
| 7 |
+
#
|
| 8 |
+
# Image philosophy: slim Python base + only the OS libs OpenCV / EasyOCR /
|
| 9 |
+
# matplotlib require. EasyOCR's English model weights (~64 MB) download on
|
| 10 |
+
# first /extract call rather than at build, so the image stays compact for CI.
|
| 11 |
+
|
| 12 |
+
FROM python:3.11-slim
|
| 13 |
+
|
| 14 |
+
ENV PYTHONUNBUFFERED=1 \
|
| 15 |
+
PYTHONDONTWRITEBYTECODE=1 \
|
| 16 |
+
PIP_NO_CACHE_DIR=1 \
|
| 17 |
+
DEBIAN_FRONTEND=noninteractive
|
| 18 |
+
|
| 19 |
+
# OS libs: OpenCV needs libGL + libglib; matplotlib needs libfreetype/libpng.
|
| 20 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 21 |
+
libgl1 \
|
| 22 |
+
libglib2.0-0 \
|
| 23 |
+
libsm6 \
|
| 24 |
+
libxext6 \
|
| 25 |
+
libxrender1 \
|
| 26 |
+
libfreetype6 \
|
| 27 |
+
libpng16-16 \
|
| 28 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 29 |
+
|
| 30 |
+
WORKDIR /app
|
| 31 |
+
|
| 32 |
+
# Install Python deps first for cacheability.
|
| 33 |
+
COPY requirements.txt /app/
|
| 34 |
+
RUN pip install --upgrade pip && pip install -r requirements.txt
|
| 35 |
+
|
| 36 |
+
# Source.
|
| 37 |
+
COPY src/ /app/src/
|
| 38 |
+
COPY diagram_analysis.py /app/
|
| 39 |
+
COPY data/icon_templates/ /app/data/icon_templates/
|
| 40 |
+
|
| 41 |
+
EXPOSE 8000
|
| 42 |
+
|
| 43 |
+
# Healthcheck pings /health every 30 s.
|
| 44 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=15s --retries=3 \
|
| 45 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3).read()" || exit 1
|
| 46 |
+
|
| 47 |
+
CMD ["uvicorn", "src.api:app", "--host", "0.0.0.0", "--port", "8000"]
|
README.md
CHANGED
|
@@ -1,10 +1,16 @@
|
|
| 1 |
---
|
| 2 |
title: DiagraMine
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
|
|
|
| 7 |
pinned: false
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: DiagraMine
|
| 3 |
+
emoji: 📊
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: green
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 8000
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
---
|
| 11 |
|
| 12 |
+
# DiagraMine — Diagram Structure Extraction API
|
| 13 |
+
|
| 14 |
+
FastAPI service that extracts boxes, text, icons, arrows and relationships from
|
| 15 |
+
architecture diagrams using OpenCV + EasyOCR. Endpoints: `/health`, `/extract`.
|
| 16 |
+
EasyOCR weights (~64 MB) download on first `/extract` call.
|
data/icon_templates/docker.png
ADDED
|
|
data/icon_templates/ms_sql.png
ADDED
|
|
diagram_analysis.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Diagram Understanding & Structure Extraction — backward-compatible entrypoint.
|
| 3 |
+
==============================================================================
|
| 4 |
+
The original 1,257-line monolith has been refactored (Day 4) into the modular
|
| 5 |
+
`src/` package:
|
| 6 |
+
|
| 7 |
+
src/text_detection/ src/box_detection/ src/arrow_detection/
|
| 8 |
+
src/icon_detection/ src/graph/builder.py src/pipeline.py src/schemas.py
|
| 9 |
+
|
| 10 |
+
This file is now a thin wrapper that delegates to `src.pipeline`. It exists so
|
| 11 |
+
that `python diagram_analysis.py [image.png]` keeps working and reproduces the
|
| 12 |
+
original output filenames for the test diagram.
|
| 13 |
+
|
| 14 |
+
What was removed on Day 4 (the credibility fixes from the Day-1 CV audit):
|
| 15 |
+
- `_known_connections()` — 8 relationships hand-coded for the "Plant An App
|
| 16 |
+
AWS" diagram, silently merged into every graph. The graph is now built
|
| 17 |
+
purely from detected arrows (`src/graph/builder.build_relationships`).
|
| 18 |
+
- hardcoded `pos = {...}` — node coordinates pinned to that one diagram.
|
| 19 |
+
Layout is now `kamada_kawai` / `spring`, computed from graph structure.
|
| 20 |
+
- `INPUT_IMAGE` constant — the single supported PNG. Image path is now a
|
| 21 |
+
CLI argument / function parameter.
|
| 22 |
+
|
| 23 |
+
Run: python diagram_analysis.py [path/to/diagram.png]
|
| 24 |
+
"""
|
| 25 |
+
from __future__ import annotations
|
| 26 |
+
|
| 27 |
+
import json
|
| 28 |
+
import os
|
| 29 |
+
import sys
|
| 30 |
+
|
| 31 |
+
import cv2
|
| 32 |
+
|
| 33 |
+
from src.graph import builder
|
| 34 |
+
from src.pipeline import annotate, extract, write_csv
|
| 35 |
+
from src.schemas import PipelineConfig
|
| 36 |
+
|
| 37 |
+
# Fallback image for `python diagram_analysis.py` with no argument — NOT a
|
| 38 |
+
# hardcoded sole-source; any path can be passed as the first CLI argument.
|
| 39 |
+
DEFAULT_IMAGE = os.path.join(os.path.dirname(os.path.abspath(__file__)), "search_interview_test.png")
|
| 40 |
+
OUTPUT_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def main(image_path: str | None = None, output_dir: str | None = None) -> dict:
|
| 44 |
+
image_path = image_path or (sys.argv[1] if len(sys.argv) > 1 else DEFAULT_IMAGE)
|
| 45 |
+
output_dir = output_dir or OUTPUT_DIR
|
| 46 |
+
|
| 47 |
+
print("=" * 60)
|
| 48 |
+
print(" DIAGRAM UNDERSTANDING & STRUCTURE EXTRACTION (modular pipeline)")
|
| 49 |
+
print(f" Input: {image_path}")
|
| 50 |
+
print("=" * 60)
|
| 51 |
+
|
| 52 |
+
result = extract(image_path, PipelineConfig())
|
| 53 |
+
|
| 54 |
+
# Preserve the original output filenames for backward compatibility.
|
| 55 |
+
annotated_path = os.path.join(output_dir, "annotated_diagram.png")
|
| 56 |
+
graph_path = os.path.join(output_dir, "relationship_graph.png")
|
| 57 |
+
json_path = os.path.join(output_dir, "extracted_structure.json")
|
| 58 |
+
csv_path = os.path.join(output_dir, "extracted_data.csv")
|
| 59 |
+
|
| 60 |
+
cv2.imwrite(annotated_path, annotate(image_path, result))
|
| 61 |
+
builder.draw_graph([r.model_dump() for r in result.relationships], graph_path)
|
| 62 |
+
with open(json_path, "w", encoding="utf-8") as f:
|
| 63 |
+
json.dump(result.model_dump(), f, indent=2, ensure_ascii=False)
|
| 64 |
+
write_csv(result, csv_path)
|
| 65 |
+
|
| 66 |
+
s = result.summary()
|
| 67 |
+
print("\n" + "=" * 60)
|
| 68 |
+
print(" EXTRACTION SUMMARY")
|
| 69 |
+
print("=" * 60)
|
| 70 |
+
for k, v in s.items():
|
| 71 |
+
print(f" {k:14s}: {v}")
|
| 72 |
+
print("-" * 60)
|
| 73 |
+
print(" All relationships are data-driven from detected arrows")
|
| 74 |
+
print(" (no hardcoded connections, no hardcoded node positions).")
|
| 75 |
+
print("=" * 60)
|
| 76 |
+
print(f" Annotated image : {annotated_path}")
|
| 77 |
+
print(f" JSON output : {json_path}")
|
| 78 |
+
print(f" Graph image : {graph_path}")
|
| 79 |
+
print(f" CSV output : {csv_path}")
|
| 80 |
+
print("=" * 60)
|
| 81 |
+
print(" Done.")
|
| 82 |
+
return result.model_dump()
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
if __name__ == "__main__":
|
| 86 |
+
main()
|
requirements.txt
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# DiagraMine — pinned to versions verified during the May 2026 sprint.
|
| 2 |
+
|
| 3 |
+
# Core CV / numerical
|
| 4 |
+
numpy>=1.24
|
| 5 |
+
opencv-python>=4.10
|
| 6 |
+
opencv-contrib-python>=4.10
|
| 7 |
+
Pillow>=10.0
|
| 8 |
+
matplotlib>=3.7
|
| 9 |
+
networkx>=3.0
|
| 10 |
+
|
| 11 |
+
# OCR backends (Phase 2a)
|
| 12 |
+
easyocr>=1.7.2
|
| 13 |
+
paddleocr>=2.10.0
|
| 14 |
+
pytesseract>=0.3.13
|
| 15 |
+
# NOTE: pytesseract requires the tesseract.exe native binary.
|
| 16 |
+
# See docs/INSTALL_TESSERACT.md for Windows / Linux / macOS instructions.
|
| 17 |
+
|
| 18 |
+
# Vision foundation model (Phase 2a, YOLOv8 zero-shot baseline)
|
| 19 |
+
ultralytics>=8.3
|
| 20 |
+
# Auto-downloads yolov8n.pt (~6 MB) into the repo root on first use.
|
| 21 |
+
|
| 22 |
+
# API serving + typed schemas (Phase 3, Day 4)
|
| 23 |
+
fastapi>=0.115
|
| 24 |
+
uvicorn>=0.34
|
| 25 |
+
python-multipart>=0.0.22
|
| 26 |
+
pydantic>=2.5
|
| 27 |
+
|
| 28 |
+
# Evaluation
|
| 29 |
+
rapidfuzz>=3.14
|
| 30 |
+
|
| 31 |
+
# Day-7 production wrapper
|
| 32 |
+
streamlit>=1.36
|
| 33 |
+
httpx>=0.27 # FastAPI TestClient dep — used by tests/test_api.py
|
| 34 |
+
anthropic>=0.40 # benchmark_claude_vision.py — vision API harness
|
src/__init__.py
ADDED
|
File without changes
|
src/api.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI service for DiagraMine.
|
| 2 |
+
|
| 3 |
+
POST /extract multipart image upload -> structured JSON + annotated PNG
|
| 4 |
+
(base64) + relationship CSV (text). Always returns
|
| 5 |
+
schema-valid JSON (the reliability guarantee).
|
| 6 |
+
GET /health liveness probe.
|
| 7 |
+
|
| 8 |
+
Run: uvicorn src.api:app --port 8000
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import base64
|
| 13 |
+
import csv
|
| 14 |
+
import io
|
| 15 |
+
import os
|
| 16 |
+
import tempfile
|
| 17 |
+
|
| 18 |
+
import cv2
|
| 19 |
+
from fastapi import FastAPI, File, Query, UploadFile
|
| 20 |
+
from fastapi.responses import JSONResponse
|
| 21 |
+
from pydantic import BaseModel
|
| 22 |
+
|
| 23 |
+
from src import pipeline
|
| 24 |
+
from src.schemas import ExtractionResult, PipelineConfig
|
| 25 |
+
|
| 26 |
+
app = FastAPI(
|
| 27 |
+
title="DiagraMine",
|
| 28 |
+
description="Diagram understanding & structure extraction (data-driven, no hardcoded connections).",
|
| 29 |
+
version="4.0",
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class ExtractResponse(BaseModel):
|
| 34 |
+
structure: ExtractionResult
|
| 35 |
+
annotated_png_base64: str
|
| 36 |
+
relationship_csv: str
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _csv_string(result: ExtractionResult) -> str:
|
| 40 |
+
buf = io.StringIO()
|
| 41 |
+
w = csv.writer(buf)
|
| 42 |
+
w.writerow(["source", "target", "line_style", "direction", "relationship"])
|
| 43 |
+
for r in result.relationships:
|
| 44 |
+
w.writerow([r.source, r.target, r.line_style, r.direction, r.relationship])
|
| 45 |
+
return buf.getvalue()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
@app.get("/health")
|
| 49 |
+
async def health() -> dict:
|
| 50 |
+
return {"status": "ok", "service": "diagramine", "version": "4.0"}
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@app.post("/extract", response_model=ExtractResponse)
|
| 54 |
+
async def extract(
|
| 55 |
+
file: UploadFile = File(...),
|
| 56 |
+
text: str = Query("easyocr"),
|
| 57 |
+
box: str = Query("canny_contours"),
|
| 58 |
+
arrow: str = Query("hough_lines"),
|
| 59 |
+
icon: str = Query("template_matching"),
|
| 60 |
+
outside_box_gate: bool = Query(True),
|
| 61 |
+
) -> JSONResponse:
|
| 62 |
+
config = PipelineConfig(
|
| 63 |
+
text_detector=text, box_detector=box, arrow_detector=arrow,
|
| 64 |
+
icon_detector=icon, outside_box_gate=outside_box_gate,
|
| 65 |
+
)
|
| 66 |
+
suffix = os.path.splitext(file.filename or "upload.png")[1] or ".png"
|
| 67 |
+
data = await file.read()
|
| 68 |
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=suffix)
|
| 69 |
+
try:
|
| 70 |
+
tmp.write(data)
|
| 71 |
+
tmp.close()
|
| 72 |
+
result = pipeline.extract(tmp.name, config)
|
| 73 |
+
annotated = pipeline.annotate(tmp.name, result)
|
| 74 |
+
ok, png = cv2.imencode(".png", annotated)
|
| 75 |
+
png_b64 = base64.b64encode(png.tobytes()).decode("ascii") if ok else ""
|
| 76 |
+
finally:
|
| 77 |
+
os.unlink(tmp.name)
|
| 78 |
+
|
| 79 |
+
payload = ExtractResponse(
|
| 80 |
+
structure=result,
|
| 81 |
+
annotated_png_base64=png_b64,
|
| 82 |
+
relationship_csv=_csv_string(result),
|
| 83 |
+
)
|
| 84 |
+
return JSONResponse(content=payload.model_dump())
|
src/arrow_detection/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Phase 2b arrow detector strategies."""
|
src/arrow_detection/cnn_detector.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tiny CNN arrow detector.
|
| 2 |
+
|
| 3 |
+
Training data is generated synthetically with PIL: 1000 32×32 grayscale
|
| 4 |
+
patches, ~500 positive (a horizontal/vertical dashed or solid line, with
|
| 5 |
+
optional arrowhead) and ~500 negative (empty white, solid blob, text-like
|
| 6 |
+
glyph, box-corner). The CNN is a 3-layer Conv2d binary classifier (Tiny by
|
| 7 |
+
design: ~7K params; trains in <30 s on CPU).
|
| 8 |
+
|
| 9 |
+
At inference time the CNN does NOT do a dense sliding-window scan — that
|
| 10 |
+
would be prohibitively slow on CPU for a 1000×700 image with 32×32 patches
|
| 11 |
+
(20K+ patches/image). Instead we run it as a **verifier on Hough-line
|
| 12 |
+
candidates**: take the segments produced by the Hough detector, extract a
|
| 13 |
+
32×32 patch centred at multiple points along each segment, classify each
|
| 14 |
+
patch, and keep segments whose median classification is "arrow."
|
| 15 |
+
|
| 16 |
+
This is the "small CNN trained on synthetic data" baseline described in the
|
| 17 |
+
Day-3 task: it tests whether a learnt patch classifier generalises across
|
| 18 |
+
the dash-spacing variations that fool the legacy `_find_dash_groups`.
|
| 19 |
+
|
| 20 |
+
The trained model weights are cached at `models/arrow_cnn.pt` after first
|
| 21 |
+
use and re-loaded on subsequent calls.
|
| 22 |
+
"""
|
| 23 |
+
from __future__ import annotations
|
| 24 |
+
|
| 25 |
+
import io
|
| 26 |
+
import os
|
| 27 |
+
import random
|
| 28 |
+
import time
|
| 29 |
+
from typing import List
|
| 30 |
+
|
| 31 |
+
import cv2
|
| 32 |
+
import numpy as np
|
| 33 |
+
import torch
|
| 34 |
+
import torch.nn as nn
|
| 35 |
+
import torch.nn.functional as F
|
| 36 |
+
from PIL import Image, ImageDraw
|
| 37 |
+
|
| 38 |
+
_PATCH = 32
|
| 39 |
+
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 40 |
+
_WEIGHTS_PATH = os.path.join(_ROOT, "models", "arrow_cnn.pt")
|
| 41 |
+
_MODEL: "ArrowCNN | None" = None
|
| 42 |
+
_HOUGH_MODULE = None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class ArrowCNN(nn.Module):
|
| 46 |
+
def __init__(self) -> None:
|
| 47 |
+
super().__init__()
|
| 48 |
+
self.conv1 = nn.Conv2d(1, 8, kernel_size=3, padding=1)
|
| 49 |
+
self.conv2 = nn.Conv2d(8, 16, kernel_size=3, padding=1)
|
| 50 |
+
self.conv3 = nn.Conv2d(16, 16, kernel_size=3, padding=1)
|
| 51 |
+
self.pool = nn.MaxPool2d(2, 2)
|
| 52 |
+
self.fc1 = nn.Linear(16 * 4 * 4, 32)
|
| 53 |
+
self.fc2 = nn.Linear(32, 2)
|
| 54 |
+
|
| 55 |
+
def forward(self, x: torch.Tensor) -> torch.Tensor:
|
| 56 |
+
x = self.pool(F.relu(self.conv1(x)))
|
| 57 |
+
x = self.pool(F.relu(self.conv2(x)))
|
| 58 |
+
x = self.pool(F.relu(self.conv3(x)))
|
| 59 |
+
x = x.view(x.size(0), -1)
|
| 60 |
+
x = F.relu(self.fc1(x))
|
| 61 |
+
return self.fc2(x)
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# ---------------------------------------------------------------------------
|
| 65 |
+
# Synthetic patch generation
|
| 66 |
+
# ---------------------------------------------------------------------------
|
| 67 |
+
|
| 68 |
+
def _draw_arrow_patch(rng: random.Random) -> np.ndarray:
|
| 69 |
+
"""Generate a 32×32 grayscale patch containing a horizontal or vertical
|
| 70 |
+
line. Random style (dashed vs solid), random dash spacing (3-8 px on),
|
| 71 |
+
random gap (2-6 px off), random thickness (1-2 px), random offset along
|
| 72 |
+
the orthogonal axis."""
|
| 73 |
+
p = _PATCH
|
| 74 |
+
img = Image.new("L", (p, p), color=255)
|
| 75 |
+
draw = ImageDraw.Draw(img)
|
| 76 |
+
horizontal = rng.random() < 0.5
|
| 77 |
+
thickness = rng.choice([1, 1, 2])
|
| 78 |
+
offset = rng.randint(8, p - 8)
|
| 79 |
+
dashed = rng.random() < 0.7
|
| 80 |
+
if dashed:
|
| 81 |
+
on = rng.randint(3, 9)
|
| 82 |
+
off = rng.randint(2, 7)
|
| 83 |
+
pos = rng.randint(-5, 5)
|
| 84 |
+
while pos < p:
|
| 85 |
+
x0, y0 = (pos, offset) if horizontal else (offset, pos)
|
| 86 |
+
x1, y1 = (pos + on, offset) if horizontal else (offset, pos + on)
|
| 87 |
+
draw.line([(x0, y0), (x1, y1)], fill=0, width=thickness)
|
| 88 |
+
pos += on + off
|
| 89 |
+
else:
|
| 90 |
+
x0, y0 = (0, offset) if horizontal else (offset, 0)
|
| 91 |
+
x1, y1 = (p - 1, offset) if horizontal else (offset, p - 1)
|
| 92 |
+
draw.line([(x0, y0), (x1, y1)], fill=0, width=thickness)
|
| 93 |
+
|
| 94 |
+
# Optional arrowhead near one end (50% chance)
|
| 95 |
+
if rng.random() < 0.5:
|
| 96 |
+
if horizontal:
|
| 97 |
+
tip_x = p - 2 if rng.random() < 0.5 else 1
|
| 98 |
+
for dy in (-2, -1, 0, 1, 2):
|
| 99 |
+
if rng.random() < 0.7:
|
| 100 |
+
draw.point((tip_x, offset + dy), fill=0)
|
| 101 |
+
else:
|
| 102 |
+
tip_y = p - 2 if rng.random() < 0.5 else 1
|
| 103 |
+
for dx in (-2, -1, 0, 1, 2):
|
| 104 |
+
if rng.random() < 0.7:
|
| 105 |
+
draw.point((offset + dx, tip_y), fill=0)
|
| 106 |
+
|
| 107 |
+
arr = np.asarray(img, dtype=np.float32) / 255.0
|
| 108 |
+
return arr
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def _draw_negative_patch(rng: random.Random) -> np.ndarray:
|
| 112 |
+
"""Generate a 32×32 grayscale "not an arrow" patch.
|
| 113 |
+
Variants: empty, solid blob (icon-like), text-glyph imitation,
|
| 114 |
+
box-corner (two perpendicular short lines)."""
|
| 115 |
+
p = _PATCH
|
| 116 |
+
img = Image.new("L", (p, p), color=255)
|
| 117 |
+
draw = ImageDraw.Draw(img)
|
| 118 |
+
variant = rng.choice(["empty", "blob", "glyph", "corner", "noise"])
|
| 119 |
+
if variant == "empty":
|
| 120 |
+
# 80% truly empty, 20% with a few stray noise pixels
|
| 121 |
+
if rng.random() < 0.2:
|
| 122 |
+
for _ in range(rng.randint(1, 8)):
|
| 123 |
+
draw.point((rng.randint(0, p - 1), rng.randint(0, p - 1)), fill=0)
|
| 124 |
+
elif variant == "blob":
|
| 125 |
+
x = rng.randint(6, p - 12)
|
| 126 |
+
y = rng.randint(6, p - 12)
|
| 127 |
+
r = rng.randint(4, 10)
|
| 128 |
+
draw.ellipse([(x, y), (x + r, y + r)], fill=0)
|
| 129 |
+
elif variant == "glyph":
|
| 130 |
+
for _ in range(rng.randint(3, 8)):
|
| 131 |
+
x0 = rng.randint(4, p - 8)
|
| 132 |
+
y0 = rng.randint(8, p - 8)
|
| 133 |
+
draw.line([(x0, y0), (x0 + rng.randint(-4, 4),
|
| 134 |
+
y0 + rng.randint(-4, 4))],
|
| 135 |
+
fill=0, width=1)
|
| 136 |
+
elif variant == "corner":
|
| 137 |
+
cx = rng.randint(8, p - 8)
|
| 138 |
+
cy = rng.randint(8, p - 8)
|
| 139 |
+
draw.line([(cx, cy), (cx + rng.randint(6, 14), cy)],
|
| 140 |
+
fill=0, width=rng.choice([1, 2]))
|
| 141 |
+
draw.line([(cx, cy), (cx, cy + rng.randint(6, 14))],
|
| 142 |
+
fill=0, width=rng.choice([1, 2]))
|
| 143 |
+
else: # noise
|
| 144 |
+
for _ in range(rng.randint(5, 20)):
|
| 145 |
+
draw.point((rng.randint(0, p - 1), rng.randint(0, p - 1)), fill=0)
|
| 146 |
+
|
| 147 |
+
arr = np.asarray(img, dtype=np.float32) / 255.0
|
| 148 |
+
return arr
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def _build_training_set(n_positive: int = 500, n_negative: int = 500,
|
| 152 |
+
seed: int = 42):
|
| 153 |
+
rng = random.Random(seed)
|
| 154 |
+
X, y = [], []
|
| 155 |
+
for _ in range(n_positive):
|
| 156 |
+
X.append(_draw_arrow_patch(rng))
|
| 157 |
+
y.append(1)
|
| 158 |
+
for _ in range(n_negative):
|
| 159 |
+
X.append(_draw_negative_patch(rng))
|
| 160 |
+
y.append(0)
|
| 161 |
+
X = np.stack(X)[:, None, :, :]
|
| 162 |
+
y = np.asarray(y, dtype=np.int64)
|
| 163 |
+
return torch.from_numpy(X), torch.from_numpy(y)
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
def _train_model() -> ArrowCNN:
|
| 167 |
+
torch.manual_seed(0)
|
| 168 |
+
X, y = _build_training_set()
|
| 169 |
+
model = ArrowCNN()
|
| 170 |
+
opt = torch.optim.Adam(model.parameters(), lr=1e-3)
|
| 171 |
+
bs = 64
|
| 172 |
+
n_epochs = 8
|
| 173 |
+
n = X.shape[0]
|
| 174 |
+
for _epoch in range(n_epochs):
|
| 175 |
+
idx = torch.randperm(n)
|
| 176 |
+
for i in range(0, n, bs):
|
| 177 |
+
sel = idx[i:i + bs]
|
| 178 |
+
logits = model(X[sel])
|
| 179 |
+
loss = F.cross_entropy(logits, y[sel])
|
| 180 |
+
opt.zero_grad()
|
| 181 |
+
loss.backward()
|
| 182 |
+
opt.step()
|
| 183 |
+
model.eval()
|
| 184 |
+
return model
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _load_or_train_model() -> ArrowCNN:
|
| 188 |
+
global _MODEL
|
| 189 |
+
if _MODEL is not None:
|
| 190 |
+
return _MODEL
|
| 191 |
+
if os.path.isfile(_WEIGHTS_PATH):
|
| 192 |
+
m = ArrowCNN()
|
| 193 |
+
m.load_state_dict(torch.load(_WEIGHTS_PATH, map_location="cpu"))
|
| 194 |
+
m.eval()
|
| 195 |
+
_MODEL = m
|
| 196 |
+
return m
|
| 197 |
+
m = _train_model()
|
| 198 |
+
os.makedirs(os.path.dirname(_WEIGHTS_PATH), exist_ok=True)
|
| 199 |
+
torch.save(m.state_dict(), _WEIGHTS_PATH)
|
| 200 |
+
_MODEL = m
|
| 201 |
+
return m
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
# ---------------------------------------------------------------------------
|
| 205 |
+
# Inference
|
| 206 |
+
# ---------------------------------------------------------------------------
|
| 207 |
+
|
| 208 |
+
def _hough_candidates(image_path: str) -> List[dict]:
|
| 209 |
+
"""Reuse the Hough detector's output as candidate segments to verify."""
|
| 210 |
+
global _HOUGH_MODULE
|
| 211 |
+
if _HOUGH_MODULE is None:
|
| 212 |
+
from src.arrow_detection import hough_lines_detector
|
| 213 |
+
_HOUGH_MODULE = hough_lines_detector
|
| 214 |
+
return _HOUGH_MODULE.detect(image_path)["arrows"]
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _patches_along(gray: np.ndarray, a: dict, n_samples: int = 5) -> torch.Tensor:
|
| 218 |
+
"""Sample n_samples 32×32 patches along the segment of arrow `a`.
|
| 219 |
+
Patches are clamped to image bounds; if the segment is too short for
|
| 220 |
+
n_samples, sample at endpoints + midpoint only."""
|
| 221 |
+
p = _PATCH
|
| 222 |
+
h_img, w_img = gray.shape
|
| 223 |
+
pts = []
|
| 224 |
+
for k in range(n_samples):
|
| 225 |
+
t = k / max(n_samples - 1, 1)
|
| 226 |
+
x = int(a["x1"] + t * (a["x2"] - a["x1"]))
|
| 227 |
+
y = int(a["y1"] + t * (a["y2"] - a["y1"]))
|
| 228 |
+
x0 = max(0, min(w_img - p, x - p // 2))
|
| 229 |
+
y0 = max(0, min(h_img - p, y - p // 2))
|
| 230 |
+
crop = gray[y0:y0 + p, x0:x0 + p]
|
| 231 |
+
if crop.shape != (p, p):
|
| 232 |
+
crop = cv2.resize(crop, (p, p))
|
| 233 |
+
pts.append(crop.astype(np.float32) / 255.0)
|
| 234 |
+
return torch.from_numpy(np.stack(pts)[:, None, :, :])
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def detect(image_path: str, accept_threshold: float = 0.5) -> dict:
|
| 238 |
+
"""Run CNN-verified arrow detection."""
|
| 239 |
+
img = cv2.imread(image_path)
|
| 240 |
+
if img is None:
|
| 241 |
+
raise FileNotFoundError(image_path)
|
| 242 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 243 |
+
|
| 244 |
+
start = time.perf_counter()
|
| 245 |
+
model = _load_or_train_model()
|
| 246 |
+
candidates = _hough_candidates(image_path)
|
| 247 |
+
|
| 248 |
+
accepted: List[dict] = []
|
| 249 |
+
if candidates:
|
| 250 |
+
with torch.no_grad():
|
| 251 |
+
for a in candidates:
|
| 252 |
+
patches = _patches_along(gray, a)
|
| 253 |
+
logits = model(patches)
|
| 254 |
+
probs = F.softmax(logits, dim=1)[:, 1].numpy()
|
| 255 |
+
# Accept if the median probability ≥ threshold (robust to
|
| 256 |
+
# patches that fall on box-corners at endpoints of a real arrow).
|
| 257 |
+
if float(np.median(probs)) >= accept_threshold:
|
| 258 |
+
accepted.append({
|
| 259 |
+
"x1": int(a["x1"]), "y1": int(a["y1"]),
|
| 260 |
+
"x2": int(a["x2"]), "y2": int(a["y2"]),
|
| 261 |
+
"direction": a["direction"],
|
| 262 |
+
"cnn_score": float(np.median(probs)),
|
| 263 |
+
})
|
| 264 |
+
elapsed = time.perf_counter() - start
|
| 265 |
+
return {"arrows": accepted, "runtime_seconds": round(elapsed, 3)}
|
src/arrow_detection/hough_lines_detector.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hough-lines arrow detector.
|
| 2 |
+
|
| 3 |
+
Pipeline:
|
| 4 |
+
1. Adaptive-threshold + thin the image (`cv2.ximgproc.thinning`) so dashed
|
| 5 |
+
and solid lines collapse to 1-pixel skeletons.
|
| 6 |
+
2. Run `cv2.HoughLinesP` to find line segments.
|
| 7 |
+
3. Group collinear segments (same y for horizontal, same x for vertical)
|
| 8 |
+
that lie close together — dashed arrows produce many short collinear
|
| 9 |
+
segments because each dash becomes a separate segment.
|
| 10 |
+
4. Filter:
|
| 11 |
+
- drop very short segments (< 20 px) — likely text strokes / box corners.
|
| 12 |
+
- drop segments that lie ON a box border by ignoring detections inside
|
| 13 |
+
a small dilation of the high-contrast page mask.
|
| 14 |
+
(The latter requires the box detector; we do a cheap proxy via a
|
| 15 |
+
morphological mask of closed dark contours so this detector stays
|
| 16 |
+
self-contained.)
|
| 17 |
+
|
| 18 |
+
This detector exists to test the hypothesis: replacing the brittle pixel-row
|
| 19 |
+
dash scanner with a thinning+Hough pipeline generalises better across dash
|
| 20 |
+
spacings that vary per matplotlib rendering pass.
|
| 21 |
+
"""
|
| 22 |
+
from __future__ import annotations
|
| 23 |
+
|
| 24 |
+
import time
|
| 25 |
+
from typing import List, Tuple
|
| 26 |
+
|
| 27 |
+
import cv2
|
| 28 |
+
import numpy as np
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def _angle_bucket(x1, y1, x2, y2) -> str:
|
| 32 |
+
"""horizontal, vertical, or diagonal."""
|
| 33 |
+
dx = abs(x2 - x1)
|
| 34 |
+
dy = abs(y2 - y1)
|
| 35 |
+
if dy <= 3:
|
| 36 |
+
return "horizontal"
|
| 37 |
+
if dx <= 3:
|
| 38 |
+
return "vertical"
|
| 39 |
+
return "diagonal"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _cluster_collinear(segments: List[Tuple[int, int, int, int]],
|
| 43 |
+
direction: str,
|
| 44 |
+
tol: int = 6,
|
| 45 |
+
gap_tol: int = 60) -> List[dict]:
|
| 46 |
+
"""Cluster collinear segments into single arrows.
|
| 47 |
+
|
| 48 |
+
For 'horizontal': cluster by y (within tol px), merge along x if gap<=gap_tol.
|
| 49 |
+
For 'vertical': cluster by x (within tol px), merge along y if gap<=gap_tol.
|
| 50 |
+
"""
|
| 51 |
+
if not segments:
|
| 52 |
+
return []
|
| 53 |
+
|
| 54 |
+
if direction == "horizontal":
|
| 55 |
+
normed = [(min(x1, x2), max(x1, x2), (y1 + y2) // 2)
|
| 56 |
+
for x1, y1, x2, y2 in segments]
|
| 57 |
+
normed.sort(key=lambda s: (s[2], s[0]))
|
| 58 |
+
out: List[dict] = []
|
| 59 |
+
cluster = [normed[0]]
|
| 60 |
+
cluster_y = normed[0][2]
|
| 61 |
+
for s in normed[1:]:
|
| 62 |
+
x0, x1_, y = s
|
| 63 |
+
if abs(y - cluster_y) <= tol:
|
| 64 |
+
last = cluster[-1]
|
| 65 |
+
if x0 - last[1] <= gap_tol:
|
| 66 |
+
cluster.append(s)
|
| 67 |
+
continue
|
| 68 |
+
# close current cluster
|
| 69 |
+
xs0 = min(c[0] for c in cluster)
|
| 70 |
+
xs1 = max(c[1] for c in cluster)
|
| 71 |
+
ys = int(np.mean([c[2] for c in cluster]))
|
| 72 |
+
if xs1 - xs0 >= 20 and len(cluster) >= 1:
|
| 73 |
+
out.append({"x1": xs0, "y1": ys, "x2": xs1, "y2": ys,
|
| 74 |
+
"direction": "horizontal",
|
| 75 |
+
"n_segments": len(cluster)})
|
| 76 |
+
cluster = [s]
|
| 77 |
+
cluster_y = y
|
| 78 |
+
xs0 = min(c[0] for c in cluster)
|
| 79 |
+
xs1 = max(c[1] for c in cluster)
|
| 80 |
+
ys = int(np.mean([c[2] for c in cluster]))
|
| 81 |
+
if xs1 - xs0 >= 20:
|
| 82 |
+
out.append({"x1": xs0, "y1": ys, "x2": xs1, "y2": ys,
|
| 83 |
+
"direction": "horizontal",
|
| 84 |
+
"n_segments": len(cluster)})
|
| 85 |
+
return out
|
| 86 |
+
else:
|
| 87 |
+
normed = [((x1 + x2) // 2, min(y1, y2), max(y1, y2))
|
| 88 |
+
for x1, y1, x2, y2 in segments]
|
| 89 |
+
normed.sort(key=lambda s: (s[0], s[1]))
|
| 90 |
+
out = []
|
| 91 |
+
cluster = [normed[0]]
|
| 92 |
+
cluster_x = normed[0][0]
|
| 93 |
+
for s in normed[1:]:
|
| 94 |
+
x, y0, y1_ = s
|
| 95 |
+
if abs(x - cluster_x) <= tol:
|
| 96 |
+
last = cluster[-1]
|
| 97 |
+
if y0 - last[2] <= gap_tol:
|
| 98 |
+
cluster.append(s)
|
| 99 |
+
continue
|
| 100 |
+
xs = int(np.mean([c[0] for c in cluster]))
|
| 101 |
+
ys0 = min(c[1] for c in cluster)
|
| 102 |
+
ys1 = max(c[2] for c in cluster)
|
| 103 |
+
if ys1 - ys0 >= 20:
|
| 104 |
+
out.append({"x1": xs, "y1": ys0, "x2": xs, "y2": ys1,
|
| 105 |
+
"direction": "vertical",
|
| 106 |
+
"n_segments": len(cluster)})
|
| 107 |
+
cluster = [s]
|
| 108 |
+
cluster_x = x
|
| 109 |
+
xs = int(np.mean([c[0] for c in cluster]))
|
| 110 |
+
ys0 = min(c[1] for c in cluster)
|
| 111 |
+
ys1 = max(c[2] for c in cluster)
|
| 112 |
+
if ys1 - ys0 >= 20:
|
| 113 |
+
out.append({"x1": xs, "y1": ys0, "x2": xs, "y2": ys1,
|
| 114 |
+
"direction": "vertical",
|
| 115 |
+
"n_segments": len(cluster)})
|
| 116 |
+
return out
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _box_border_mask(gray: np.ndarray) -> np.ndarray:
|
| 120 |
+
"""Cheap proxy for 'is this pixel on a box border?'. Closed dark
|
| 121 |
+
rectangular contours in the image; we morphologically dilate them so
|
| 122 |
+
a Hough segment lying on the border is filtered out."""
|
| 123 |
+
_, bw = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)
|
| 124 |
+
# Erode/dilate to get only solid rectangle outlines (no text glyphs)
|
| 125 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (3, 3))
|
| 126 |
+
closed = cv2.morphologyEx(bw, cv2.MORPH_CLOSE, kernel, iterations=1)
|
| 127 |
+
contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL,
|
| 128 |
+
cv2.CHAIN_APPROX_SIMPLE)
|
| 129 |
+
mask = np.zeros_like(gray, dtype=np.uint8)
|
| 130 |
+
h, w = gray.shape
|
| 131 |
+
for cnt in contours:
|
| 132 |
+
x, y, ww, hh = cv2.boundingRect(cnt)
|
| 133 |
+
area = ww * hh
|
| 134 |
+
if area < 1500 or ww < 40 or hh < 20:
|
| 135 |
+
continue
|
| 136 |
+
if ww > w * 0.95 and hh > h * 0.95:
|
| 137 |
+
continue
|
| 138 |
+
# draw the rectangle border with a small dilation
|
| 139 |
+
cv2.rectangle(mask, (x, y), (x + ww, y + hh), 255, thickness=4)
|
| 140 |
+
return mask
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def detect(image_path: str) -> dict:
|
| 144 |
+
img = cv2.imread(image_path)
|
| 145 |
+
if img is None:
|
| 146 |
+
raise FileNotFoundError(image_path)
|
| 147 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 148 |
+
|
| 149 |
+
start = time.perf_counter()
|
| 150 |
+
|
| 151 |
+
# invert: lines become 255, background becomes 0
|
| 152 |
+
_, inv = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY_INV)
|
| 153 |
+
|
| 154 |
+
# Close small dash gaps so the thinning produces continuous skeletons
|
| 155 |
+
close_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (7, 1))
|
| 156 |
+
closed_h = cv2.morphologyEx(inv, cv2.MORPH_CLOSE, close_kernel, iterations=1)
|
| 157 |
+
close_kernel_v = cv2.getStructuringElement(cv2.MORPH_RECT, (1, 7))
|
| 158 |
+
closed_v = cv2.morphologyEx(inv, cv2.MORPH_CLOSE, close_kernel_v, iterations=1)
|
| 159 |
+
closed = cv2.bitwise_or(closed_h, closed_v)
|
| 160 |
+
|
| 161 |
+
thin = cv2.ximgproc.thinning(closed, thinningType=cv2.ximgproc.THINNING_ZHANGSUEN)
|
| 162 |
+
|
| 163 |
+
# Suppress segments lying on box borders
|
| 164 |
+
border_mask = _box_border_mask(gray)
|
| 165 |
+
thin_filt = cv2.bitwise_and(thin, cv2.bitwise_not(border_mask))
|
| 166 |
+
|
| 167 |
+
lines = cv2.HoughLinesP(thin_filt, rho=1, theta=np.pi / 180,
|
| 168 |
+
threshold=18, minLineLength=15, maxLineGap=12)
|
| 169 |
+
|
| 170 |
+
horiz_segs: List[Tuple[int, int, int, int]] = []
|
| 171 |
+
vert_segs: List[Tuple[int, int, int, int]] = []
|
| 172 |
+
if lines is not None:
|
| 173 |
+
for line in lines:
|
| 174 |
+
x1, y1, x2, y2 = line[0]
|
| 175 |
+
bucket = _angle_bucket(x1, y1, x2, y2)
|
| 176 |
+
if bucket == "horizontal":
|
| 177 |
+
horiz_segs.append((int(x1), int(y1), int(x2), int(y2)))
|
| 178 |
+
elif bucket == "vertical":
|
| 179 |
+
vert_segs.append((int(x1), int(y1), int(x2), int(y2)))
|
| 180 |
+
# diagonals dropped — diagrams here are axis-aligned
|
| 181 |
+
|
| 182 |
+
arrows = []
|
| 183 |
+
arrows += _cluster_collinear(horiz_segs, "horizontal")
|
| 184 |
+
arrows += _cluster_collinear(vert_segs, "vertical")
|
| 185 |
+
|
| 186 |
+
# Drop arrows shorter than 30 px (text underlines, box corners)
|
| 187 |
+
arrows = [
|
| 188 |
+
a for a in arrows
|
| 189 |
+
if abs(a["x2"] - a["x1"]) + abs(a["y2"] - a["y1"]) >= 30
|
| 190 |
+
]
|
| 191 |
+
|
| 192 |
+
elapsed = time.perf_counter() - start
|
| 193 |
+
out_arrows = [
|
| 194 |
+
{"x1": int(a["x1"]), "y1": int(a["y1"]),
|
| 195 |
+
"x2": int(a["x2"]), "y2": int(a["y2"]),
|
| 196 |
+
"direction": a["direction"]}
|
| 197 |
+
for a in arrows
|
| 198 |
+
]
|
| 199 |
+
return {"arrows": out_arrows, "runtime_seconds": round(elapsed, 3)}
|
src/arrow_detection/pixel_scan_detector.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Scan-line dashed arrow detector — wraps the legacy `_find_dash_groups`
|
| 2 |
+
logic from `diagram_analysis.detect_arrows`.
|
| 3 |
+
|
| 4 |
+
Pure detector: returns endpoint coordinates only. Does NOT apply the
|
| 5 |
+
legacy `box_inside_text` / `inside_box` filters — those need texts + boxes
|
| 6 |
+
+ icons and would conflate the arrow-detector's quality with the box-detector's
|
| 7 |
+
quality. For Phase 2b we want to isolate the arrow detector itself.
|
| 8 |
+
|
| 9 |
+
Output format (shared across all arrow detectors):
|
| 10 |
+
{
|
| 11 |
+
"arrows": [{"x1", "y1", "x2", "y2", "direction"}],
|
| 12 |
+
"runtime_seconds": float,
|
| 13 |
+
}
|
| 14 |
+
"""
|
| 15 |
+
from __future__ import annotations
|
| 16 |
+
|
| 17 |
+
import time
|
| 18 |
+
from typing import List
|
| 19 |
+
|
| 20 |
+
import cv2
|
| 21 |
+
import numpy as np
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _find_dash_groups(line_pixels, dark_thresh=145, min_dash=3, max_dash=15,
|
| 25 |
+
min_gap=5, max_gap=16):
|
| 26 |
+
"""Identical to diagram_analysis._find_dash_groups — duplicated here so
|
| 27 |
+
the detector module is self-contained and can be re-tuned in Phase 4
|
| 28 |
+
without mutating the legacy file."""
|
| 29 |
+
n = len(line_pixels)
|
| 30 |
+
if n < 15:
|
| 31 |
+
return []
|
| 32 |
+
dark_runs = []
|
| 33 |
+
in_dark = False
|
| 34 |
+
start = 0
|
| 35 |
+
for i in range(n):
|
| 36 |
+
if line_pixels[i] < dark_thresh:
|
| 37 |
+
if not in_dark:
|
| 38 |
+
start = i
|
| 39 |
+
in_dark = True
|
| 40 |
+
else:
|
| 41 |
+
if in_dark:
|
| 42 |
+
dark_runs.append((start, i, i - start))
|
| 43 |
+
in_dark = False
|
| 44 |
+
if in_dark:
|
| 45 |
+
dark_runs.append((start, n, n - start))
|
| 46 |
+
dashes = [(s, e, l) for s, e, l in dark_runs if min_dash <= l <= max_dash]
|
| 47 |
+
if len(dashes) < 3:
|
| 48 |
+
return []
|
| 49 |
+
groups = [[dashes[0]]]
|
| 50 |
+
for k in range(1, len(dashes)):
|
| 51 |
+
gap = dashes[k][0] - dashes[k-1][1]
|
| 52 |
+
if gap > 30:
|
| 53 |
+
groups.append([dashes[k]])
|
| 54 |
+
else:
|
| 55 |
+
groups[-1].append(dashes[k])
|
| 56 |
+
result = []
|
| 57 |
+
for grp in groups:
|
| 58 |
+
if len(grp) < 2:
|
| 59 |
+
continue
|
| 60 |
+
good_gaps = sum(1 for k in range(1, len(grp))
|
| 61 |
+
if min_gap <= grp[k][0] - grp[k-1][1] <= max_gap)
|
| 62 |
+
if good_gaps >= 1:
|
| 63 |
+
result.append([(s, e) for s, e, _ in grp])
|
| 64 |
+
return result
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def detect(image_path: str) -> dict:
|
| 68 |
+
img = cv2.imread(image_path)
|
| 69 |
+
if img is None:
|
| 70 |
+
raise FileNotFoundError(image_path)
|
| 71 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 72 |
+
h_img, w_img = gray.shape
|
| 73 |
+
|
| 74 |
+
start = time.perf_counter()
|
| 75 |
+
|
| 76 |
+
# ── Horizontal scan ──
|
| 77 |
+
h_hits = []
|
| 78 |
+
for y in range(h_img):
|
| 79 |
+
for dashes in _find_dash_groups(gray[y, :].astype(int)):
|
| 80 |
+
x_start, x_end = dashes[0][0], dashes[-1][1]
|
| 81 |
+
span = x_end - x_start
|
| 82 |
+
if 20 <= span <= 800:
|
| 83 |
+
h_hits.append((y, x_start, x_end, len(dashes)))
|
| 84 |
+
|
| 85 |
+
h_hits.sort(key=lambda x: x[0])
|
| 86 |
+
h_arrows = []
|
| 87 |
+
if h_hits:
|
| 88 |
+
cluster = [h_hits[0]]
|
| 89 |
+
for hit in h_hits[1:]:
|
| 90 |
+
prev = cluster[-1]
|
| 91 |
+
if hit[0] - prev[0] <= 3 and abs(hit[1] - prev[1]) < 50:
|
| 92 |
+
cluster.append(hit)
|
| 93 |
+
else:
|
| 94 |
+
y_avg = int(np.mean([c[0] for c in cluster]))
|
| 95 |
+
x_min = min(c[1] for c in cluster)
|
| 96 |
+
x_max = max(c[2] for c in cluster)
|
| 97 |
+
h_arrows.append((x_min, y_avg, x_max, y_avg))
|
| 98 |
+
cluster = [hit]
|
| 99 |
+
y_avg = int(np.mean([c[0] for c in cluster]))
|
| 100 |
+
x_min = min(c[1] for c in cluster)
|
| 101 |
+
x_max = max(c[2] for c in cluster)
|
| 102 |
+
h_arrows.append((x_min, y_avg, x_max, y_avg))
|
| 103 |
+
|
| 104 |
+
# ── Vertical scan ──
|
| 105 |
+
v_hits = []
|
| 106 |
+
for x in range(w_img):
|
| 107 |
+
for dashes in _find_dash_groups(gray[:, x].astype(int)):
|
| 108 |
+
y_start, y_end = dashes[0][0], dashes[-1][1]
|
| 109 |
+
span = y_end - y_start
|
| 110 |
+
if 20 <= span <= 200:
|
| 111 |
+
v_hits.append((x, y_start, y_end, len(dashes)))
|
| 112 |
+
|
| 113 |
+
v_hits.sort(key=lambda x: x[0])
|
| 114 |
+
v_arrows = []
|
| 115 |
+
if v_hits:
|
| 116 |
+
cluster = [v_hits[0]]
|
| 117 |
+
for hit in v_hits[1:]:
|
| 118 |
+
prev = cluster[-1]
|
| 119 |
+
if hit[0] - prev[0] <= 3 and abs(hit[1] - prev[1]) < 50:
|
| 120 |
+
cluster.append(hit)
|
| 121 |
+
else:
|
| 122 |
+
x_avg = int(np.mean([c[0] for c in cluster]))
|
| 123 |
+
y_min = min(c[1] for c in cluster)
|
| 124 |
+
y_max = max(c[2] for c in cluster)
|
| 125 |
+
v_arrows.append((x_avg, y_min, x_avg, y_max))
|
| 126 |
+
cluster = [hit]
|
| 127 |
+
x_avg = int(np.mean([c[0] for c in cluster]))
|
| 128 |
+
y_min = min(c[1] for c in cluster)
|
| 129 |
+
y_max = max(c[2] for c in cluster)
|
| 130 |
+
v_arrows.append((x_avg, y_min, x_avg, y_max))
|
| 131 |
+
|
| 132 |
+
# ── Light filters: drop tiny v-arrows + dedupe near-duplicates ──
|
| 133 |
+
v_arrows = [a for a in v_arrows if (a[3] - a[1]) >= 25]
|
| 134 |
+
|
| 135 |
+
arrows: List[dict] = []
|
| 136 |
+
for x1, y1, x2, y2 in h_arrows:
|
| 137 |
+
arrows.append({
|
| 138 |
+
"x1": int(x1), "y1": int(y1),
|
| 139 |
+
"x2": int(x2), "y2": int(y2),
|
| 140 |
+
"direction": "horizontal",
|
| 141 |
+
})
|
| 142 |
+
for x1, y1, x2, y2 in v_arrows:
|
| 143 |
+
arrows.append({
|
| 144 |
+
"x1": int(x1), "y1": int(y1),
|
| 145 |
+
"x2": int(x2), "y2": int(y2),
|
| 146 |
+
"direction": "vertical",
|
| 147 |
+
})
|
| 148 |
+
|
| 149 |
+
elapsed = time.perf_counter() - start
|
| 150 |
+
return {"arrows": arrows, "runtime_seconds": round(elapsed, 3)}
|
src/box_detection/__init__.py
ADDED
|
File without changes
|
src/box_detection/canny_contours_detector.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Canny + RETR_TREE contour box detection — equivalent to the current
|
| 2 |
+
diagram_analysis.detect_boxes_and_regions but stripped down to just the
|
| 3 |
+
box-detection portion (no text labeling, no region/parent nesting). Returns
|
| 4 |
+
the same (x, y, w, h) the legacy pipeline would call a 'box'."""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
from typing import List
|
| 9 |
+
|
| 10 |
+
import cv2
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def _dedup_boxes(boxes: List[dict], iou_thresh: float = 0.6) -> List[dict]:
|
| 14 |
+
keep = []
|
| 15 |
+
used = set()
|
| 16 |
+
order = sorted(range(len(boxes)), key=lambda i: boxes[i]["w"] * boxes[i]["h"], reverse=True)
|
| 17 |
+
for i in order:
|
| 18 |
+
if i in used:
|
| 19 |
+
continue
|
| 20 |
+
keep.append(boxes[i])
|
| 21 |
+
for j in order:
|
| 22 |
+
if j in used or j == i:
|
| 23 |
+
continue
|
| 24 |
+
a, b = boxes[i], boxes[j]
|
| 25 |
+
ix1 = max(a["x"], b["x"])
|
| 26 |
+
iy1 = max(a["y"], b["y"])
|
| 27 |
+
ix2 = min(a["x"] + a["w"], b["x"] + b["w"])
|
| 28 |
+
iy2 = min(a["y"] + a["h"], b["y"] + b["h"])
|
| 29 |
+
inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
|
| 30 |
+
union = a["w"] * a["h"] + b["w"] * b["h"] - inter
|
| 31 |
+
if union > 0 and inter / union > iou_thresh:
|
| 32 |
+
used.add(j)
|
| 33 |
+
used.add(i)
|
| 34 |
+
return keep
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
# Champion config (Day-5 Optuna-tuned, see results/box_tuning.json). The
|
| 38 |
+
# defaults below are the live parameters the pipeline uses; the constants are
|
| 39 |
+
# documented so the tuning provenance is auditable.
|
| 40 |
+
#
|
| 41 |
+
# Day-5 Phase-4 sweep (40 TPE trials, IoU>=0.5 micro-F1 over the 14 generated
|
| 42 |
+
# diagrams): the original hardcoded values [canny 30/120, min_area 1200,
|
| 43 |
+
# dilate 3x3, rect 0.3, dedup 0.6] scored F1=0.808 (P=0.709, R=0.938) — the
|
| 44 |
+
# loss was precision, RETR_TREE nested/duplicate contours surviving dedup. The
|
| 45 |
+
# tuned config below raises min_area and the rectangularity floor (killing the
|
| 46 |
+
# tiny nested contours) and tightens dedup, scoring F1=0.992 (P=1.000,
|
| 47 |
+
# R=0.985), fp 25 -> 0. NOTE: tuned and evaluated on the same synthetic family,
|
| 48 |
+
# so 0.992 is an in-sample optimum; on the real search_interview_test diagram
|
| 49 |
+
# it still drops box false positives 19 -> 16 (recall preserved).
|
| 50 |
+
# `region_area` is NOT a Canny knob — it is the component/region split. The
|
| 51 |
+
# Day-5 error analysis found the original 25000 threshold mislabeled 27 genuine
|
| 52 |
+
# component boxes as "regions" (their detected area drifts above 25k once Canny
|
| 53 |
+
# + dilation pad the contour), pulling them out of the relationship candidate
|
| 54 |
+
# pool and the schema's `boxes` field. Raising it to 60000 keeps real component
|
| 55 |
+
# boxes as components while still flagging true large containers (e.g. the
|
| 56 |
+
# search_interview platform box at ~204k/105k/82k). This single change lifted
|
| 57 |
+
# relationship micro-F1 0.171 -> 0.250 (see results/relationship_fix.json).
|
| 58 |
+
DEFAULTS = dict(
|
| 59 |
+
canny_low=39,
|
| 60 |
+
canny_high=69,
|
| 61 |
+
min_area=2300,
|
| 62 |
+
min_w=40,
|
| 63 |
+
min_h=20,
|
| 64 |
+
dilate_ksize=2,
|
| 65 |
+
dilate_iters=1,
|
| 66 |
+
rectangularity_min=0.6,
|
| 67 |
+
dedup_iou=0.5,
|
| 68 |
+
region_area=60000,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def detect(
|
| 73 |
+
image_path: str,
|
| 74 |
+
*,
|
| 75 |
+
canny_low: int = DEFAULTS["canny_low"],
|
| 76 |
+
canny_high: int = DEFAULTS["canny_high"],
|
| 77 |
+
min_area: int = DEFAULTS["min_area"],
|
| 78 |
+
min_w: int = DEFAULTS["min_w"],
|
| 79 |
+
min_h: int = DEFAULTS["min_h"],
|
| 80 |
+
dilate_ksize: int = DEFAULTS["dilate_ksize"],
|
| 81 |
+
dilate_iters: int = DEFAULTS["dilate_iters"],
|
| 82 |
+
rectangularity_min: float = DEFAULTS["rectangularity_min"],
|
| 83 |
+
dedup_iou: float = DEFAULTS["dedup_iou"],
|
| 84 |
+
region_area: int = DEFAULTS["region_area"],
|
| 85 |
+
) -> dict:
|
| 86 |
+
"""Canny + RETR_TREE contour box detection.
|
| 87 |
+
|
| 88 |
+
All thresholds are keyword-tunable (Day-5 Phase-4 sweep) with defaults
|
| 89 |
+
equal to the original hardcoded values, so existing callers — the pipeline
|
| 90 |
+
and the Phase-2a harness — are byte-for-byte unaffected when called as
|
| 91 |
+
``detect(path)``.
|
| 92 |
+
"""
|
| 93 |
+
img = cv2.imread(image_path)
|
| 94 |
+
if img is None:
|
| 95 |
+
raise FileNotFoundError(image_path)
|
| 96 |
+
h_img, w_img = img.shape[:2]
|
| 97 |
+
|
| 98 |
+
start = time.perf_counter()
|
| 99 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 100 |
+
blurred = cv2.GaussianBlur(gray, (3, 3), 0)
|
| 101 |
+
edges = cv2.Canny(blurred, canny_low, canny_high)
|
| 102 |
+
ksize = max(1, int(dilate_ksize))
|
| 103 |
+
if ksize > 1 and dilate_iters > 0:
|
| 104 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (ksize, ksize))
|
| 105 |
+
dilated = cv2.dilate(edges, kernel, iterations=int(dilate_iters))
|
| 106 |
+
else:
|
| 107 |
+
dilated = edges
|
| 108 |
+
contours, hierarchy = cv2.findContours(dilated, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
|
| 109 |
+
|
| 110 |
+
boxes: List[dict] = []
|
| 111 |
+
for cnt in contours:
|
| 112 |
+
x, y, w, h = cv2.boundingRect(cnt)
|
| 113 |
+
area = w * h
|
| 114 |
+
if area < min_area or w < min_w or h < min_h:
|
| 115 |
+
continue
|
| 116 |
+
rect_area = w * h
|
| 117 |
+
if rect_area == 0:
|
| 118 |
+
continue
|
| 119 |
+
rectangularity = cv2.contourArea(cnt) / rect_area
|
| 120 |
+
if rectangularity < rectangularity_min:
|
| 121 |
+
continue
|
| 122 |
+
if w > w_img * 0.95 and h > h_img * 0.95:
|
| 123 |
+
continue
|
| 124 |
+
# The legacy split: REGIONS (area > region_area) vs BOXES.
|
| 125 |
+
# For Phase 2a evaluation we want all rectangular objects, so we
|
| 126 |
+
# keep both, but flag them.
|
| 127 |
+
boxes.append(
|
| 128 |
+
{
|
| 129 |
+
"x": int(x), "y": int(y),
|
| 130 |
+
"w": int(w), "h": int(h),
|
| 131 |
+
"area": int(area),
|
| 132 |
+
"is_region": area > region_area,
|
| 133 |
+
}
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
boxes = _dedup_boxes(boxes, iou_thresh=dedup_iou)
|
| 137 |
+
elapsed = time.perf_counter() - start
|
| 138 |
+
return {"boxes": boxes, "runtime_seconds": round(elapsed, 3)}
|
src/box_detection/hough_detector.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Hough-rectangle box detection.
|
| 2 |
+
|
| 3 |
+
Strategy: cv2.HoughLinesP on the Canny edge map, then group axis-aligned
|
| 4 |
+
horizontal and vertical line segments by proximity, and reconstruct rectangles
|
| 5 |
+
where four sides (two H + two V) co-localise (pairwise corner intersections).
|
| 6 |
+
|
| 7 |
+
This is an alternative to RETR_TREE contour scanning that tends to be more
|
| 8 |
+
robust to slightly broken rectangles (where edges aren't perfectly closed),
|
| 9 |
+
but more sensitive to dashed-arrow line segments masquerading as box sides.
|
| 10 |
+
"""
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import time
|
| 14 |
+
from typing import List, Tuple
|
| 15 |
+
|
| 16 |
+
import cv2
|
| 17 |
+
import numpy as np
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def _segments(lines, axis: str, slope_eps: int = 3) -> List[Tuple[int, int, int, int]]:
|
| 21 |
+
"""Filter HoughLinesP output to nearly-horizontal or nearly-vertical."""
|
| 22 |
+
out = []
|
| 23 |
+
if lines is None:
|
| 24 |
+
return out
|
| 25 |
+
for ln in lines:
|
| 26 |
+
x1, y1, x2, y2 = ln[0]
|
| 27 |
+
if axis == "h" and abs(y2 - y1) <= slope_eps:
|
| 28 |
+
out.append((min(x1, x2), (y1 + y2) // 2, max(x1, x2), (y1 + y2) // 2))
|
| 29 |
+
elif axis == "v" and abs(x2 - x1) <= slope_eps:
|
| 30 |
+
out.append(((x1 + x2) // 2, min(y1, y2), (x1 + x2) // 2, max(y1, y2)))
|
| 31 |
+
return out
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def _cluster(values: List[int], tol: int = 6) -> List[List[int]]:
|
| 35 |
+
"""Greedy 1-D cluster: group values within `tol` of each other."""
|
| 36 |
+
if not values:
|
| 37 |
+
return []
|
| 38 |
+
s = sorted(values)
|
| 39 |
+
clusters = [[s[0]]]
|
| 40 |
+
for v in s[1:]:
|
| 41 |
+
if v - clusters[-1][-1] <= tol:
|
| 42 |
+
clusters[-1].append(v)
|
| 43 |
+
else:
|
| 44 |
+
clusters.append([v])
|
| 45 |
+
return clusters
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def detect(image_path: str) -> dict:
|
| 49 |
+
img = cv2.imread(image_path)
|
| 50 |
+
if img is None:
|
| 51 |
+
raise FileNotFoundError(image_path)
|
| 52 |
+
h_img, w_img = img.shape[:2]
|
| 53 |
+
|
| 54 |
+
start = time.perf_counter()
|
| 55 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 56 |
+
blurred = cv2.GaussianBlur(gray, (3, 3), 0)
|
| 57 |
+
edges = cv2.Canny(blurred, 30, 120)
|
| 58 |
+
lines = cv2.HoughLinesP(
|
| 59 |
+
edges,
|
| 60 |
+
rho=1,
|
| 61 |
+
theta=np.pi / 180,
|
| 62 |
+
threshold=60,
|
| 63 |
+
minLineLength=40,
|
| 64 |
+
maxLineGap=8,
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
h_segs = _segments(lines, "h")
|
| 68 |
+
v_segs = _segments(lines, "v")
|
| 69 |
+
|
| 70 |
+
# Cluster horizontals by y (top/bottom edges) and verticals by x (left/right)
|
| 71 |
+
h_by_y = _cluster([s[1] for s in h_segs], tol=6)
|
| 72 |
+
v_by_x = _cluster([s[0] for s in v_segs], tol=6)
|
| 73 |
+
|
| 74 |
+
# Collapse each cluster to its mean y/x, keep the spanning x/y range
|
| 75 |
+
h_lines = [] # (y, x_min, x_max)
|
| 76 |
+
for cluster in h_by_y:
|
| 77 |
+
members = [s for s in h_segs if s[1] in cluster]
|
| 78 |
+
if not members:
|
| 79 |
+
continue
|
| 80 |
+
y_mean = int(round(sum(m[1] for m in members) / len(members)))
|
| 81 |
+
x_min = min(m[0] for m in members)
|
| 82 |
+
x_max = max(m[2] for m in members)
|
| 83 |
+
h_lines.append((y_mean, x_min, x_max))
|
| 84 |
+
|
| 85 |
+
v_lines = [] # (x, y_min, y_max)
|
| 86 |
+
for cluster in v_by_x:
|
| 87 |
+
members = [s for s in v_segs if s[0] in cluster]
|
| 88 |
+
if not members:
|
| 89 |
+
continue
|
| 90 |
+
x_mean = int(round(sum(m[0] for m in members) / len(members)))
|
| 91 |
+
y_min = min(m[1] for m in members)
|
| 92 |
+
y_max = max(m[3] for m in members)
|
| 93 |
+
v_lines.append((x_mean, y_min, y_max))
|
| 94 |
+
|
| 95 |
+
# Reconstruct boxes: every pair of horizontal lines (top/bottom) and every
|
| 96 |
+
# pair of vertical lines (left/right) where each corner falls near a line
|
| 97 |
+
# segment endpoint.
|
| 98 |
+
boxes: List[dict] = []
|
| 99 |
+
for i, (y1, _, _) in enumerate(h_lines):
|
| 100 |
+
for y2, _, _ in h_lines[i + 1 :]:
|
| 101 |
+
if y2 - y1 < 20:
|
| 102 |
+
continue
|
| 103 |
+
for j, (x1, _, _) in enumerate(v_lines):
|
| 104 |
+
for x2, _, _ in v_lines[j + 1 :]:
|
| 105 |
+
if x2 - x1 < 40:
|
| 106 |
+
continue
|
| 107 |
+
# corners must each be inside both an h-segment span and a v-segment span
|
| 108 |
+
def corner_supported(x, y):
|
| 109 |
+
for hy, hxmin, hxmax in h_lines:
|
| 110 |
+
if abs(hy - y) <= 8 and hxmin - 4 <= x <= hxmax + 4:
|
| 111 |
+
break
|
| 112 |
+
else:
|
| 113 |
+
return False
|
| 114 |
+
for vx, vymin, vymax in v_lines:
|
| 115 |
+
if abs(vx - x) <= 8 and vymin - 4 <= y <= vymax + 4:
|
| 116 |
+
return True
|
| 117 |
+
return False
|
| 118 |
+
|
| 119 |
+
if not (
|
| 120 |
+
corner_supported(x1, y1)
|
| 121 |
+
and corner_supported(x2, y1)
|
| 122 |
+
and corner_supported(x1, y2)
|
| 123 |
+
and corner_supported(x2, y2)
|
| 124 |
+
):
|
| 125 |
+
continue
|
| 126 |
+
w, h = x2 - x1, y2 - y1
|
| 127 |
+
if w > w_img * 0.95 and h > h_img * 0.95:
|
| 128 |
+
continue
|
| 129 |
+
boxes.append({"x": x1, "y": y1, "w": w, "h": h, "area": w * h})
|
| 130 |
+
|
| 131 |
+
# Dedup by IoU
|
| 132 |
+
boxes_sorted = sorted(boxes, key=lambda b: b["area"], reverse=True)
|
| 133 |
+
kept: List[dict] = []
|
| 134 |
+
for b in boxes_sorted:
|
| 135 |
+
keep = True
|
| 136 |
+
for k in kept:
|
| 137 |
+
ix1 = max(b["x"], k["x"])
|
| 138 |
+
iy1 = max(b["y"], k["y"])
|
| 139 |
+
ix2 = min(b["x"] + b["w"], k["x"] + k["w"])
|
| 140 |
+
iy2 = min(b["y"] + b["h"], k["y"] + k["h"])
|
| 141 |
+
inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
|
| 142 |
+
union = b["area"] + k["area"] - inter
|
| 143 |
+
if union > 0 and inter / union > 0.5:
|
| 144 |
+
keep = False
|
| 145 |
+
break
|
| 146 |
+
if keep:
|
| 147 |
+
kept.append(b)
|
| 148 |
+
elapsed = time.perf_counter() - start
|
| 149 |
+
return {"boxes": kept, "runtime_seconds": round(elapsed, 3)}
|
src/box_detection/yolo_detector.py
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""YOLOv8 zero-shot box detector using COCO-pretrained weights.
|
| 2 |
+
|
| 3 |
+
This is an honest negative-result baseline: COCO has no "rectangle" class,
|
| 4 |
+
so YOLOv8 will return whatever rectangle-shaped objects it learned from
|
| 5 |
+
natural photos (tv, laptop, microwave, book, etc). On synthetic architecture
|
| 6 |
+
diagrams it is expected to detect essentially nothing.
|
| 7 |
+
|
| 8 |
+
We keep the COMPLETE detection set (no class filter) so the reader can see
|
| 9 |
+
exactly what YOLO thinks an architecture diagram contains. The headline:
|
| 10 |
+
zero-shot transfer from COCO photos to abstract diagrams fails.
|
| 11 |
+
"""
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
import time
|
| 15 |
+
from typing import List
|
| 16 |
+
|
| 17 |
+
import cv2
|
| 18 |
+
|
| 19 |
+
_MODEL = None
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _get_model():
|
| 23 |
+
global _MODEL
|
| 24 |
+
if _MODEL is None:
|
| 25 |
+
from ultralytics import YOLO
|
| 26 |
+
# nano model, COCO pretrained — small, fast download.
|
| 27 |
+
_MODEL = YOLO("yolov8n.pt")
|
| 28 |
+
return _MODEL
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def detect(image_path: str, conf_thresh: float = 0.10) -> dict:
|
| 32 |
+
img = cv2.imread(image_path)
|
| 33 |
+
if img is None:
|
| 34 |
+
raise FileNotFoundError(image_path)
|
| 35 |
+
model = _get_model()
|
| 36 |
+
|
| 37 |
+
start = time.perf_counter()
|
| 38 |
+
results = model(img, verbose=False, conf=conf_thresh)
|
| 39 |
+
elapsed = time.perf_counter() - start
|
| 40 |
+
|
| 41 |
+
boxes: List[dict] = []
|
| 42 |
+
for r in results:
|
| 43 |
+
if r.boxes is None:
|
| 44 |
+
continue
|
| 45 |
+
names = r.names
|
| 46 |
+
for b in r.boxes:
|
| 47 |
+
xyxy = b.xyxy[0].tolist()
|
| 48 |
+
cls_id = int(b.cls[0].item())
|
| 49 |
+
conf = float(b.conf[0].item())
|
| 50 |
+
x1, y1, x2, y2 = [int(round(v)) for v in xyxy]
|
| 51 |
+
boxes.append(
|
| 52 |
+
{
|
| 53 |
+
"x": x1, "y": y1,
|
| 54 |
+
"w": x2 - x1, "h": y2 - y1,
|
| 55 |
+
"area": (x2 - x1) * (y2 - y1),
|
| 56 |
+
"yolo_class": names.get(cls_id, str(cls_id)),
|
| 57 |
+
"conf": round(conf, 3),
|
| 58 |
+
}
|
| 59 |
+
)
|
| 60 |
+
return {"boxes": boxes, "runtime_seconds": round(elapsed, 3)}
|
src/graph/__init__.py
ADDED
|
File without changes
|
src/graph/builder.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Data-driven graph construction.
|
| 2 |
+
|
| 3 |
+
This module is the heart of the Day-4 de-hardcoding. The legacy monolith built
|
| 4 |
+
its relationship graph by *merging in* `_known_connections()` — 8 edges hand-
|
| 5 |
+
coded for the "Plant An App - AWS" diagram — so the graph looked complete even
|
| 6 |
+
when arrow detection found nothing. That baked the answer key into the code.
|
| 7 |
+
|
| 8 |
+
Here, EVERY relationship is derived from a detected arrow:
|
| 9 |
+
1. `label_boxes` — assign each box a label from the text contained in it.
|
| 10 |
+
2. `build_relationships` — map each arrow's endpoints to the nearest boxes,
|
| 11 |
+
gated so segments lying inside/on a box border (the Day-3 snap-to-adjacency
|
| 12 |
+
artifact) are rejected.
|
| 13 |
+
3. `draw_graph` — render with a general `kamada_kawai` / `spring` layout.
|
| 14 |
+
No hardcoded node coordinates.
|
| 15 |
+
"""
|
| 16 |
+
from __future__ import annotations
|
| 17 |
+
|
| 18 |
+
from typing import List, Optional
|
| 19 |
+
|
| 20 |
+
import matplotlib
|
| 21 |
+
matplotlib.use("Agg")
|
| 22 |
+
import matplotlib.pyplot as plt
|
| 23 |
+
import networkx as nx
|
| 24 |
+
import numpy as np
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def label_boxes(boxes: List[dict], texts: List[dict]) -> List[dict]:
|
| 28 |
+
"""Assign each box a text label from the OCR fragments whose centre falls
|
| 29 |
+
inside it (smallest enclosing box wins, mirroring the legacy
|
| 30 |
+
associate_elements). Fragments are joined in reading order (top-to-bottom,
|
| 31 |
+
left-to-right). Boxes that contain no text keep an empty label."""
|
| 32 |
+
contained: dict = {i: [] for i in range(len(boxes))}
|
| 33 |
+
for t in texts:
|
| 34 |
+
tcx = t["x"] + t["w"] // 2
|
| 35 |
+
tcy = t["y"] + t["h"] // 2
|
| 36 |
+
best_box = None
|
| 37 |
+
best_area = float("inf")
|
| 38 |
+
for bi, b in enumerate(boxes):
|
| 39 |
+
if b["x"] <= tcx <= b["x"] + b["w"] and b["y"] <= tcy <= b["y"] + b["h"]:
|
| 40 |
+
area = b["w"] * b["h"]
|
| 41 |
+
if area < best_area:
|
| 42 |
+
best_area = area
|
| 43 |
+
best_box = bi
|
| 44 |
+
if best_box is not None:
|
| 45 |
+
contained[best_box].append(t)
|
| 46 |
+
|
| 47 |
+
for bi, b in enumerate(boxes):
|
| 48 |
+
frags = sorted(contained[bi], key=lambda t: (t["y"], t["x"]))
|
| 49 |
+
label = " ".join(f["text"].strip() for f in frags if f.get("text", "").strip())
|
| 50 |
+
b["label"] = label.strip()
|
| 51 |
+
return boxes
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _point_in_box(x: int, y: int, b: dict) -> bool:
|
| 55 |
+
return b["x"] <= x <= b["x"] + b["w"] and b["y"] <= y <= b["y"] + b["h"]
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _nearest_box(x: int, y: int, boxes: List[dict], max_dist: float = 100.0) -> Optional[int]:
|
| 59 |
+
"""Index of the box nearest to (x, y) within max_dist, by min(centre, edge)
|
| 60 |
+
distance. Returns None if nothing is close enough."""
|
| 61 |
+
best_i = None
|
| 62 |
+
best_d = max_dist
|
| 63 |
+
for i, b in enumerate(boxes):
|
| 64 |
+
bcx = b["x"] + b["w"] / 2
|
| 65 |
+
bcy = b["y"] + b["h"] / 2
|
| 66 |
+
d_centre = float(np.hypot(x - bcx, y - bcy))
|
| 67 |
+
dx = max(b["x"] - x, 0, x - (b["x"] + b["w"]))
|
| 68 |
+
dy = max(b["y"] - y, 0, y - (b["y"] + b["h"]))
|
| 69 |
+
d_edge = float(np.hypot(dx, dy))
|
| 70 |
+
d = min(d_centre, d_edge)
|
| 71 |
+
if d < best_d:
|
| 72 |
+
best_d = d
|
| 73 |
+
best_i = i
|
| 74 |
+
return best_i
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _midpoint_inside_any_box(arrow: dict, boxes: List[dict]) -> bool:
|
| 78 |
+
"""The Day-3 'outside any box bbox' gate. A genuine connector runs through
|
| 79 |
+
whitespace BETWEEN boxes, so its midpoint is not inside a box. A box-border
|
| 80 |
+
/ internal-edge segment (the snap-to-adjacency artifact that inflated CNN-
|
| 81 |
+
verified recall on Day 3) has its midpoint on or inside a box bbox."""
|
| 82 |
+
mx = (arrow["x1"] + arrow["x2"]) // 2
|
| 83 |
+
my = (arrow["y1"] + arrow["y2"]) // 2
|
| 84 |
+
return any(_point_in_box(mx, my, b) for b in boxes)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _ray_aabb_t(ox: float, oy: float, dx: float, dy: float, b: dict) -> Optional[float]:
|
| 88 |
+
"""Slab ray/AABB intersection. Returns the entry distance t>=0 along the
|
| 89 |
+
unit ray (ox,oy)+t*(dx,dy) at which the ray enters box `b`, or None if it
|
| 90 |
+
never does. Handles axis-aligned rays (dx or dy == 0)."""
|
| 91 |
+
minx, miny = b["x"], b["y"]
|
| 92 |
+
maxx, maxy = b["x"] + b["w"], b["y"] + b["h"]
|
| 93 |
+
tmin, tmax = 0.0, float("inf")
|
| 94 |
+
for o, d, lo, hi in ((ox, dx, minx, maxx), (oy, dy, miny, maxy)):
|
| 95 |
+
if abs(d) < 1e-9:
|
| 96 |
+
if o < lo or o > hi:
|
| 97 |
+
return None
|
| 98 |
+
else:
|
| 99 |
+
t1, t2 = (lo - o) / d, (hi - o) / d
|
| 100 |
+
if t1 > t2:
|
| 101 |
+
t1, t2 = t2, t1
|
| 102 |
+
tmin = max(tmin, t1)
|
| 103 |
+
tmax = min(tmax, t2)
|
| 104 |
+
if tmin > tmax:
|
| 105 |
+
return None
|
| 106 |
+
return tmin if tmax >= 0 else None
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _box_along_ray(x: float, y: float, dx: float, dy: float,
|
| 110 |
+
boxes: List[dict], max_proj: float) -> Optional[int]:
|
| 111 |
+
"""Index of the first box the outward ray from (x,y) along (dx,dy) enters,
|
| 112 |
+
within `max_proj` px. This is the 'intersection-with-box' recovery: a short
|
| 113 |
+
detected segment that stops in whitespace still *points at* its true target
|
| 114 |
+
box, so we project the line and snap to the box it would hit."""
|
| 115 |
+
norm = float(np.hypot(dx, dy))
|
| 116 |
+
if norm < 1e-9:
|
| 117 |
+
return None
|
| 118 |
+
ux, uy = dx / norm, dy / norm
|
| 119 |
+
best_i, best_t = None, max_proj
|
| 120 |
+
for i, b in enumerate(boxes):
|
| 121 |
+
t = _ray_aabb_t(x, y, ux, uy, b)
|
| 122 |
+
if t is not None and 0.0 <= t < best_t:
|
| 123 |
+
best_t, best_i = t, i
|
| 124 |
+
return best_i
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def _assign_endpoint(x: float, y: float, ox: float, oy: float,
|
| 128 |
+
boxes: List[dict], max_dist: float,
|
| 129 |
+
ray_intersection: bool, max_proj: float) -> Optional[int]:
|
| 130 |
+
"""Map an arrow endpoint (x,y) to a box. (ox,oy) is the *other* endpoint,
|
| 131 |
+
used to compute the outward direction for the ray fallback. Try the nearest
|
| 132 |
+
box within `max_dist` first (precise when the endpoint sits on/near a box);
|
| 133 |
+
if nothing is close enough and `ray_intersection` is on, project the line
|
| 134 |
+
outward and snap to the box it enters."""
|
| 135 |
+
i = _nearest_box(x, y, boxes, max_dist)
|
| 136 |
+
if i is not None:
|
| 137 |
+
return i
|
| 138 |
+
if ray_intersection:
|
| 139 |
+
return _box_along_ray(x, y, x - ox, y - oy, boxes, max_proj)
|
| 140 |
+
return None
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def build_relationships(
|
| 144 |
+
arrows: List[dict],
|
| 145 |
+
boxes: List[dict],
|
| 146 |
+
outside_box_gate: bool = True,
|
| 147 |
+
max_dist: float = 120.0,
|
| 148 |
+
ray_intersection: bool = False,
|
| 149 |
+
max_proj: float = 400.0,
|
| 150 |
+
) -> List[dict]:
|
| 151 |
+
"""Build relationships purely from detected arrows. No hardcoded edges.
|
| 152 |
+
|
| 153 |
+
For each arrow, snap each endpoint to a labelled box and emit an unordered,
|
| 154 |
+
de-duplicated (source -> target) edge. With `outside_box_gate`, arrows whose
|
| 155 |
+
midpoint lies inside/on a box are dropped (border artifacts).
|
| 156 |
+
|
| 157 |
+
Day-5 arrow-mapping fix (`ray_intersection`): error analysis found the
|
| 158 |
+
dominant failure mode is arrow-mapping, and within it ~40% of missed
|
| 159 |
+
connections are *mis-mapped* short segments whose endpoints stop in
|
| 160 |
+
whitespace beyond `max_dist`. With `ray_intersection` on, an endpoint that
|
| 161 |
+
has no box within `max_dist` is projected outward along the segment's
|
| 162 |
+
direction and snapped to the first box the ray enters (within `max_proj`).
|
| 163 |
+
"""
|
| 164 |
+
# Endpoints map to component boxes (skip region/container boxes and
|
| 165 |
+
# platform background boxes — they swallow endpoints and create spurious
|
| 166 |
+
# parent->child edges).
|
| 167 |
+
candidates = [
|
| 168 |
+
b for b in boxes
|
| 169 |
+
if not b.get("is_region") and b.get("entity_type") != "platform"
|
| 170 |
+
]
|
| 171 |
+
if len(candidates) < 2:
|
| 172 |
+
candidates = [b for b in boxes if not b.get("is_region")]
|
| 173 |
+
if len(candidates) < 2:
|
| 174 |
+
candidates = boxes
|
| 175 |
+
|
| 176 |
+
relationships: List[dict] = []
|
| 177 |
+
seen = set()
|
| 178 |
+
for a in arrows:
|
| 179 |
+
if outside_box_gate and _midpoint_inside_any_box(a, candidates):
|
| 180 |
+
continue
|
| 181 |
+
si = _assign_endpoint(a["x1"], a["y1"], a["x2"], a["y2"],
|
| 182 |
+
candidates, max_dist, ray_intersection, max_proj)
|
| 183 |
+
ti = _assign_endpoint(a["x2"], a["y2"], a["x1"], a["y1"],
|
| 184 |
+
candidates, max_dist, ray_intersection, max_proj)
|
| 185 |
+
if si is None or ti is None or si == ti:
|
| 186 |
+
continue
|
| 187 |
+
src = candidates[si].get("label", "").strip()
|
| 188 |
+
tgt = candidates[ti].get("label", "").strip()
|
| 189 |
+
if not src or not tgt or src == tgt:
|
| 190 |
+
continue
|
| 191 |
+
pair = (src, tgt)
|
| 192 |
+
if pair in seen or (tgt, src) in seen:
|
| 193 |
+
continue
|
| 194 |
+
seen.add(pair)
|
| 195 |
+
relationships.append({
|
| 196 |
+
"source": src,
|
| 197 |
+
"target": tgt,
|
| 198 |
+
"line_style": a.get("line_style", "solid"),
|
| 199 |
+
"direction": a.get("direction", "unknown"),
|
| 200 |
+
"relationship": "connects_to",
|
| 201 |
+
"detected": True,
|
| 202 |
+
})
|
| 203 |
+
return relationships
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def build_graph(relationships: List[dict]) -> nx.DiGraph:
|
| 207 |
+
G = nx.DiGraph()
|
| 208 |
+
for r in relationships:
|
| 209 |
+
src, tgt = (r.get("source") or "").strip(), (r.get("target") or "").strip()
|
| 210 |
+
if src and tgt and src != tgt:
|
| 211 |
+
G.add_edge(src, tgt,
|
| 212 |
+
line_style=r.get("line_style", "solid"),
|
| 213 |
+
relationship=r.get("relationship", ""))
|
| 214 |
+
return G
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
def _short_label(s: str, max_chars: int = 18) -> str:
|
| 218 |
+
s = (s or "").strip()
|
| 219 |
+
if len(s) <= max_chars:
|
| 220 |
+
return s
|
| 221 |
+
words, line1, line2 = s.split(), "", ""
|
| 222 |
+
for w in words:
|
| 223 |
+
if len(line1) + len(w) + 1 <= max_chars or not line1:
|
| 224 |
+
line1 = (line1 + " " + w).strip()
|
| 225 |
+
else:
|
| 226 |
+
line2 = (line2 + " " + w).strip()
|
| 227 |
+
return line1 + ("\n" + line2 if line2 else "")
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
def draw_graph(relationships: List[dict], output_path: str, layout: str = "kamada_kawai") -> bool:
|
| 231 |
+
"""Render the relationship DiGraph with a general, data-driven layout.
|
| 232 |
+
|
| 233 |
+
No hardcoded `pos = {...}`: node positions come from the graph structure, so
|
| 234 |
+
this works on any diagram. Returns False if there is nothing to draw."""
|
| 235 |
+
G = nx.DiGraph()
|
| 236 |
+
for r in relationships:
|
| 237 |
+
src = _short_label(r.get("source", ""))
|
| 238 |
+
tgt = _short_label(r.get("target", ""))
|
| 239 |
+
if src and tgt and src != tgt:
|
| 240 |
+
G.add_edge(src, tgt,
|
| 241 |
+
line_style=r.get("line_style", "solid"),
|
| 242 |
+
relationship=r.get("relationship", ""))
|
| 243 |
+
if len(G.nodes) == 0:
|
| 244 |
+
return False
|
| 245 |
+
|
| 246 |
+
if layout == "spring":
|
| 247 |
+
pos = nx.spring_layout(G, seed=42, k=1.2)
|
| 248 |
+
else:
|
| 249 |
+
try:
|
| 250 |
+
pos = nx.kamada_kawai_layout(G)
|
| 251 |
+
except Exception:
|
| 252 |
+
pos = nx.spring_layout(G, seed=42, k=1.2)
|
| 253 |
+
|
| 254 |
+
fig, ax = plt.subplots(figsize=(16, 9))
|
| 255 |
+
nx.draw_networkx_nodes(G, pos, node_size=3600, node_color="#D5E8F0",
|
| 256 |
+
edgecolors="#333", linewidths=2, node_shape="s", ax=ax)
|
| 257 |
+
nx.draw_networkx_labels(G, pos, font_size=8, font_weight="bold", ax=ax)
|
| 258 |
+
|
| 259 |
+
solid = [(u, v) for u, v, d in G.edges(data=True) if d.get("line_style") == "solid"]
|
| 260 |
+
dashed = [(u, v) for u, v, d in G.edges(data=True) if d.get("line_style") != "solid"]
|
| 261 |
+
nx.draw_networkx_edges(G, pos, edgelist=solid, edge_color="#333", width=2.0,
|
| 262 |
+
arrows=True, arrowsize=18, ax=ax,
|
| 263 |
+
connectionstyle="arc3,rad=0.08")
|
| 264 |
+
nx.draw_networkx_edges(G, pos, edgelist=dashed, edge_color="#666", width=1.5,
|
| 265 |
+
style="dashed", arrows=True, arrowsize=18, ax=ax,
|
| 266 |
+
connectionstyle="arc3,rad=0.08")
|
| 267 |
+
edge_labels = {(u, v): d.get("relationship", "")
|
| 268 |
+
for u, v, d in G.edges(data=True) if d.get("relationship")}
|
| 269 |
+
nx.draw_networkx_edge_labels(G, pos, edge_labels=edge_labels,
|
| 270 |
+
font_size=7, font_color="#444", ax=ax)
|
| 271 |
+
ax.set_title("Relationship Graph (data-driven)", fontsize=15, fontweight="bold", pad=18)
|
| 272 |
+
ax.axis("off")
|
| 273 |
+
fig.tight_layout()
|
| 274 |
+
fig.savefig(output_path, dpi=150, bbox_inches="tight", facecolor="white")
|
| 275 |
+
plt.close(fig)
|
| 276 |
+
return True
|
src/icon_detection/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Phase 2b icon detector strategies."""
|
src/icon_detection/clip_detector.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""CLIP zero-shot icon detector.
|
| 2 |
+
|
| 3 |
+
Strategy: HSV-segment the image to find candidate icon-like coloured regions,
|
| 4 |
+
then for each crop run CLIP image-text similarity against a vocabulary of
|
| 5 |
+
icon-name prompts. Keep crops whose top-1 label has cosine similarity above
|
| 6 |
+
a threshold AND whose top-1 is NOT the "no icon" decoy class.
|
| 7 |
+
|
| 8 |
+
Uses `openai/clip-vit-base-patch32` via transformers (CPU). The model is
|
| 9 |
+
downloaded on first use (~600 MB) and cached. We share the HSV candidate
|
| 10 |
+
proposal across HSV and CLIP detectors so they're scored on the SAME
|
| 11 |
+
candidate set — the only difference is what label is assigned.
|
| 12 |
+
|
| 13 |
+
Vocabulary intentionally includes the two GT classes (`docker`, `MS SQL`)
|
| 14 |
+
plus 5 decoys (`aws`, `kubernetes`, `elasticsearch`, `azure`, `nothing`)
|
| 15 |
+
to make false-positive evaluation meaningful — without decoys CLIP could
|
| 16 |
+
not produce a wrong label.
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import os
|
| 21 |
+
import time
|
| 22 |
+
from typing import List
|
| 23 |
+
|
| 24 |
+
import cv2
|
| 25 |
+
import numpy as np
|
| 26 |
+
import torch
|
| 27 |
+
from PIL import Image
|
| 28 |
+
|
| 29 |
+
from src.icon_detection import hsv_detector
|
| 30 |
+
|
| 31 |
+
_MODEL_NAME = "openai/clip-vit-base-patch32"
|
| 32 |
+
_VOCAB = [
|
| 33 |
+
"a docker container logo",
|
| 34 |
+
"a Microsoft SQL Server database icon",
|
| 35 |
+
"the AWS cloud logo",
|
| 36 |
+
"the Kubernetes logo",
|
| 37 |
+
"the elasticsearch logo",
|
| 38 |
+
"the Microsoft Azure logo",
|
| 39 |
+
"a small drawing or coloured shape, not a software logo",
|
| 40 |
+
]
|
| 41 |
+
# Map embedded prompts back to short labels for output
|
| 42 |
+
_VOCAB_LABELS = ["docker", "MS SQL", "AWS", "Kubernetes",
|
| 43 |
+
"elasticsearch", "Azure", "no_icon"]
|
| 44 |
+
_NO_ICON_INDEX = _VOCAB_LABELS.index("no_icon")
|
| 45 |
+
_SIM_THRESHOLD = 0.21 # cosine; CLIP zero-shot is well-known to score 0.2-0.35
|
| 46 |
+
|
| 47 |
+
_MODEL = None
|
| 48 |
+
_PROCESSOR = None
|
| 49 |
+
_TEXT_EMBEDS = None
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def _load():
|
| 53 |
+
global _MODEL, _PROCESSOR, _TEXT_EMBEDS
|
| 54 |
+
if _MODEL is not None:
|
| 55 |
+
return
|
| 56 |
+
from transformers import CLIPModel, CLIPProcessor # noqa: E501
|
| 57 |
+
_PROCESSOR = CLIPProcessor.from_pretrained(_MODEL_NAME)
|
| 58 |
+
_MODEL = CLIPModel.from_pretrained(_MODEL_NAME).eval()
|
| 59 |
+
with torch.no_grad():
|
| 60 |
+
inputs = _PROCESSOR(text=_VOCAB, return_tensors="pt", padding=True)
|
| 61 |
+
text_emb = _MODEL.get_text_features(**inputs)
|
| 62 |
+
text_emb = text_emb / text_emb.norm(dim=-1, keepdim=True)
|
| 63 |
+
_TEXT_EMBEDS = text_emb
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def detect(image_path: str) -> dict:
|
| 67 |
+
img = cv2.imread(image_path)
|
| 68 |
+
if img is None:
|
| 69 |
+
raise FileNotFoundError(image_path)
|
| 70 |
+
start = time.perf_counter()
|
| 71 |
+
_load()
|
| 72 |
+
|
| 73 |
+
# Reuse the HSV detector's coloured-region proposals as candidate crops.
|
| 74 |
+
proposals = hsv_detector.detect(image_path)["icons"]
|
| 75 |
+
if not proposals:
|
| 76 |
+
elapsed = time.perf_counter() - start
|
| 77 |
+
return {"icons": [], "runtime_seconds": round(elapsed, 3)}
|
| 78 |
+
|
| 79 |
+
crops_pil: List[Image.Image] = []
|
| 80 |
+
for p in proposals:
|
| 81 |
+
x, y, w, h = p["x"], p["y"], p["w"], p["h"]
|
| 82 |
+
# Add a small margin to give CLIP some context
|
| 83 |
+
pad = 4
|
| 84 |
+
x0 = max(0, x - pad); y0 = max(0, y - pad)
|
| 85 |
+
x1 = min(img.shape[1], x + w + pad); y1 = min(img.shape[0], y + h + pad)
|
| 86 |
+
crop = img[y0:y1, x0:x1]
|
| 87 |
+
crops_pil.append(Image.fromarray(cv2.cvtColor(crop, cv2.COLOR_BGR2RGB)))
|
| 88 |
+
|
| 89 |
+
icons: List[dict] = []
|
| 90 |
+
with torch.no_grad():
|
| 91 |
+
inputs = _PROCESSOR(images=crops_pil, return_tensors="pt")
|
| 92 |
+
img_emb = _MODEL.get_image_features(**inputs)
|
| 93 |
+
img_emb = img_emb / img_emb.norm(dim=-1, keepdim=True)
|
| 94 |
+
sims = (img_emb @ _TEXT_EMBEDS.T).numpy() # [n_crops, vocab]
|
| 95 |
+
|
| 96 |
+
for p, sim_row in zip(proposals, sims):
|
| 97 |
+
# Argmax over labels EXCLUDING the "no_icon" decoy — i.e. find the
|
| 98 |
+
# best-matching real label, then accept only if sim > threshold AND
|
| 99 |
+
# that real label beats the no_icon class.
|
| 100 |
+
real_sims = sim_row.copy()
|
| 101 |
+
no_icon_sim = real_sims[_NO_ICON_INDEX]
|
| 102 |
+
real_sims[_NO_ICON_INDEX] = -np.inf
|
| 103 |
+
top = int(np.argmax(real_sims))
|
| 104 |
+
top_sim = float(real_sims[top])
|
| 105 |
+
if top_sim < _SIM_THRESHOLD or top_sim < no_icon_sim:
|
| 106 |
+
continue
|
| 107 |
+
icons.append({
|
| 108 |
+
"x": p["x"], "y": p["y"],
|
| 109 |
+
"w": p["w"], "h": p["h"],
|
| 110 |
+
"label": _VOCAB_LABELS[top],
|
| 111 |
+
"score": top_sim,
|
| 112 |
+
})
|
| 113 |
+
|
| 114 |
+
elapsed = time.perf_counter() - start
|
| 115 |
+
return {"icons": icons, "runtime_seconds": round(elapsed, 3)}
|
src/icon_detection/hsv_detector.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""HSV-segmentation icon detector — wraps the legacy detect_icons logic
|
| 2 |
+
from diagram_analysis.py.
|
| 3 |
+
|
| 4 |
+
Detects coloured regions that don't overlap text bounding boxes. Returns
|
| 5 |
+
generic label `"icon"` — has no notion of icon CLASS (docker vs aws etc.).
|
| 6 |
+
That class blindness is the credibility gap; template_matching and CLIP
|
| 7 |
+
detectors below close it.
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
import time
|
| 12 |
+
from typing import List
|
| 13 |
+
|
| 14 |
+
import cv2
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _dedup_boxes(boxes: List[dict], iou_thresh: float = 0.5) -> List[dict]:
|
| 18 |
+
keep = []
|
| 19 |
+
used = set()
|
| 20 |
+
order = sorted(range(len(boxes)), key=lambda i: boxes[i]["w"] * boxes[i]["h"], reverse=True)
|
| 21 |
+
for i in order:
|
| 22 |
+
if i in used:
|
| 23 |
+
continue
|
| 24 |
+
keep.append(boxes[i])
|
| 25 |
+
for j in order:
|
| 26 |
+
if j in used or j == i:
|
| 27 |
+
continue
|
| 28 |
+
a, b = boxes[i], boxes[j]
|
| 29 |
+
ix1 = max(a["x"], b["x"])
|
| 30 |
+
iy1 = max(a["y"], b["y"])
|
| 31 |
+
ix2 = min(a["x"] + a["w"], b["x"] + b["w"])
|
| 32 |
+
iy2 = min(a["y"] + a["h"], b["y"] + b["h"])
|
| 33 |
+
inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
|
| 34 |
+
union = a["w"] * a["h"] + b["w"] * b["h"] - inter
|
| 35 |
+
if union > 0 and inter / union > iou_thresh:
|
| 36 |
+
used.add(j)
|
| 37 |
+
used.add(i)
|
| 38 |
+
return keep
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def detect(image_path: str) -> dict:
|
| 42 |
+
img = cv2.imread(image_path)
|
| 43 |
+
if img is None:
|
| 44 |
+
raise FileNotFoundError(image_path)
|
| 45 |
+
h_img, w_img = img.shape[:2]
|
| 46 |
+
max_area = h_img * w_img * 0.08
|
| 47 |
+
|
| 48 |
+
start = time.perf_counter()
|
| 49 |
+
hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
|
| 50 |
+
mask = cv2.inRange(hsv, (0, 50, 50), (180, 255, 255))
|
| 51 |
+
kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
|
| 52 |
+
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
|
| 53 |
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 54 |
+
|
| 55 |
+
icons: List[dict] = []
|
| 56 |
+
for cnt in contours:
|
| 57 |
+
x, y, w, h = cv2.boundingRect(cnt)
|
| 58 |
+
area = w * h
|
| 59 |
+
if area < 200 or area > max_area:
|
| 60 |
+
continue
|
| 61 |
+
aspect = w / max(h, 1)
|
| 62 |
+
if aspect < 0.2 or aspect > 5.0:
|
| 63 |
+
continue
|
| 64 |
+
icons.append({
|
| 65 |
+
"x": int(x), "y": int(y),
|
| 66 |
+
"w": int(w), "h": int(h),
|
| 67 |
+
"label": "icon",
|
| 68 |
+
})
|
| 69 |
+
|
| 70 |
+
icons = _dedup_boxes(icons)
|
| 71 |
+
elapsed = time.perf_counter() - start
|
| 72 |
+
return {"icons": icons, "runtime_seconds": round(elapsed, 3)}
|
src/icon_detection/template_detector.py
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Template-matching icon detector.
|
| 2 |
+
|
| 3 |
+
For each icon template in `data/icon_templates/`, runs `cv2.matchTemplate`
|
| 4 |
+
(TM_CCOEFF_NORMED) at multiple scales against the image, then performs
|
| 5 |
+
non-maximum suppression on the resulting heatmap. Each accepted detection
|
| 6 |
+
carries a `label` matching the template filename (without extension).
|
| 7 |
+
|
| 8 |
+
Templates currently in the library (curated from search_interview_test.png):
|
| 9 |
+
|
| 10 |
+
- docker.png : the docker whale icon at (599, 199) 41×34
|
| 11 |
+
- ms_sql.png : the database cylinder at (427, 241) 52×58 — represents
|
| 12 |
+
the "MS SQL" GT icon since `Database` in the diagram is
|
| 13 |
+
the MS SQL endpoint per the diagram's connector layout.
|
| 14 |
+
|
| 15 |
+
NOTE: this is an honest baseline, not a generalised production strategy.
|
| 16 |
+
The template library is curated from the one diagram the legacy pipeline
|
| 17 |
+
was tuned on; we report it as a baseline because it's a fair point on the
|
| 18 |
+
"how well does template matching transfer?" axis vs CLIP zero-shot.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import os
|
| 23 |
+
import time
|
| 24 |
+
from typing import List
|
| 25 |
+
|
| 26 |
+
import cv2
|
| 27 |
+
import numpy as np
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
| 31 |
+
_TEMPLATE_DIR = os.path.join(_ROOT, "data", "icon_templates")
|
| 32 |
+
_SCALES = (0.7, 0.85, 1.0, 1.2, 1.5)
|
| 33 |
+
_THRESHOLD = 0.65 # TM_CCOEFF_NORMED threshold
|
| 34 |
+
_NMS_IOU = 0.3
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def _iou(a, b):
|
| 38 |
+
ax1, ay1, ax2, ay2 = a[0], a[1], a[0] + a[2], a[1] + a[3]
|
| 39 |
+
bx1, by1, bx2, by2 = b[0], b[1], b[0] + b[2], b[1] + b[3]
|
| 40 |
+
ix1, iy1 = max(ax1, bx1), max(ay1, by1)
|
| 41 |
+
ix2, iy2 = min(ax2, bx2), min(ay2, by2)
|
| 42 |
+
inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
|
| 43 |
+
union = a[2] * a[3] + b[2] * b[3] - inter
|
| 44 |
+
return inter / union if union else 0.0
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def _load_templates():
|
| 48 |
+
if not os.path.isdir(_TEMPLATE_DIR):
|
| 49 |
+
return {}
|
| 50 |
+
out = {}
|
| 51 |
+
for f in sorted(os.listdir(_TEMPLATE_DIR)):
|
| 52 |
+
if not f.lower().endswith(".png"):
|
| 53 |
+
continue
|
| 54 |
+
label = os.path.splitext(f)[0]
|
| 55 |
+
if label.startswith("_"):
|
| 56 |
+
continue # ignore staging crops
|
| 57 |
+
path = os.path.join(_TEMPLATE_DIR, f)
|
| 58 |
+
img = cv2.imread(path, cv2.IMREAD_GRAYSCALE)
|
| 59 |
+
if img is None:
|
| 60 |
+
continue
|
| 61 |
+
out[label] = img
|
| 62 |
+
return out
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def detect(image_path: str) -> dict:
|
| 66 |
+
img = cv2.imread(image_path)
|
| 67 |
+
if img is None:
|
| 68 |
+
raise FileNotFoundError(image_path)
|
| 69 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 70 |
+
|
| 71 |
+
start = time.perf_counter()
|
| 72 |
+
templates = _load_templates()
|
| 73 |
+
raw: List[dict] = []
|
| 74 |
+
for label, tmpl in templates.items():
|
| 75 |
+
for scale in _SCALES:
|
| 76 |
+
th = int(tmpl.shape[0] * scale)
|
| 77 |
+
tw = int(tmpl.shape[1] * scale)
|
| 78 |
+
if th < 8 or tw < 8 or th > gray.shape[0] or tw > gray.shape[1]:
|
| 79 |
+
continue
|
| 80 |
+
tmpl_s = cv2.resize(tmpl, (tw, th))
|
| 81 |
+
res = cv2.matchTemplate(gray, tmpl_s, cv2.TM_CCOEFF_NORMED)
|
| 82 |
+
ys, xs = np.where(res >= _THRESHOLD)
|
| 83 |
+
for y, x in zip(ys, xs):
|
| 84 |
+
raw.append({
|
| 85 |
+
"x": int(x), "y": int(y),
|
| 86 |
+
"w": int(tw), "h": int(th),
|
| 87 |
+
"label": label,
|
| 88 |
+
"score": float(res[y, x]),
|
| 89 |
+
})
|
| 90 |
+
|
| 91 |
+
# NMS per label
|
| 92 |
+
by_label: dict = {}
|
| 93 |
+
for r in raw:
|
| 94 |
+
by_label.setdefault(r["label"], []).append(r)
|
| 95 |
+
icons: List[dict] = []
|
| 96 |
+
for label, dets in by_label.items():
|
| 97 |
+
dets.sort(key=lambda d: d["score"], reverse=True)
|
| 98 |
+
keep = []
|
| 99 |
+
for d in dets:
|
| 100 |
+
box = (d["x"], d["y"], d["w"], d["h"])
|
| 101 |
+
if all(_iou(box, (k["x"], k["y"], k["w"], k["h"])) < _NMS_IOU for k in keep):
|
| 102 |
+
keep.append(d)
|
| 103 |
+
icons.extend(keep)
|
| 104 |
+
|
| 105 |
+
elapsed = time.perf_counter() - start
|
| 106 |
+
return {"icons": icons, "runtime_seconds": round(elapsed, 3)}
|
src/pipeline.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""DiagraMine pipeline orchestrator.
|
| 2 |
+
|
| 3 |
+
Wires the Day-3 Phase-2 champions into one configurable, data-driven pipeline:
|
| 4 |
+
|
| 5 |
+
text -> EasyOCR (champion: F1 0.949)
|
| 6 |
+
box -> Canny + contours (champion: F1 0.808)
|
| 7 |
+
arrow -> Hough + thinning (champion: honest F1 0.364, + outside-box gate)
|
| 8 |
+
icon -> template matching (champion: F1 1.000 on curated library)
|
| 9 |
+
|
| 10 |
+
Each stage is selectable via `PipelineConfig`. Every stage is wrapped so that a
|
| 11 |
+
failure degrades to an empty result rather than a crash — that is what keeps the
|
| 12 |
+
final `ExtractionResult` schema-valid 100% of the time (the reliability metric
|
| 13 |
+
DiagraMine beats Claude Vision on).
|
| 14 |
+
|
| 15 |
+
Usage:
|
| 16 |
+
python -m src.pipeline <image_path> [--out DIR] [--text easyocr]
|
| 17 |
+
[--box canny_contours] [--arrow hough_lines]
|
| 18 |
+
[--icon template_matching] [--no-gate]
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import argparse
|
| 23 |
+
import csv
|
| 24 |
+
import json
|
| 25 |
+
import os
|
| 26 |
+
import time
|
| 27 |
+
from typing import Callable, Dict
|
| 28 |
+
|
| 29 |
+
import cv2
|
| 30 |
+
import numpy as np
|
| 31 |
+
|
| 32 |
+
from src.graph import builder
|
| 33 |
+
from src.schemas import (
|
| 34 |
+
ArrowElement,
|
| 35 |
+
BoxElement,
|
| 36 |
+
ExtractionResult,
|
| 37 |
+
IconElement,
|
| 38 |
+
PipelineConfig,
|
| 39 |
+
Relationship,
|
| 40 |
+
StageRuntimes,
|
| 41 |
+
TextElement,
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
# ── Lazy detector registry ──────────────────────────────────────────────────
|
| 46 |
+
# Importing detector modules eagerly would load EasyOCR/CLIP/torch weights on
|
| 47 |
+
# every `import pipeline`. Resolve them lazily so callers only pay for the
|
| 48 |
+
# detectors they actually select.
|
| 49 |
+
|
| 50 |
+
def _text_detect(name: str) -> Callable[[str], dict]:
|
| 51 |
+
if name == "easyocr":
|
| 52 |
+
from src.text_detection import easyocr_detector as m
|
| 53 |
+
elif name == "paddleocr":
|
| 54 |
+
from src.text_detection import paddle_detector as m
|
| 55 |
+
elif name == "tesseract":
|
| 56 |
+
from src.text_detection import tesseract_detector as m
|
| 57 |
+
else:
|
| 58 |
+
raise ValueError(f"unknown text detector: {name}")
|
| 59 |
+
return m.detect
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _box_detect(name: str) -> Callable[[str], dict]:
|
| 63 |
+
if name == "canny_contours":
|
| 64 |
+
from src.box_detection import canny_contours_detector as m
|
| 65 |
+
elif name == "hough":
|
| 66 |
+
from src.box_detection import hough_detector as m
|
| 67 |
+
elif name == "yolo":
|
| 68 |
+
from src.box_detection import yolo_detector as m
|
| 69 |
+
else:
|
| 70 |
+
raise ValueError(f"unknown box detector: {name}")
|
| 71 |
+
return m.detect
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def _arrow_detect(name: str) -> Callable[[str], dict]:
|
| 75 |
+
if name == "hough_lines":
|
| 76 |
+
from src.arrow_detection import hough_lines_detector as m
|
| 77 |
+
elif name == "pixel_scan":
|
| 78 |
+
from src.arrow_detection import pixel_scan_detector as m
|
| 79 |
+
elif name == "cnn":
|
| 80 |
+
from src.arrow_detection import cnn_detector as m
|
| 81 |
+
else:
|
| 82 |
+
raise ValueError(f"unknown arrow detector: {name}")
|
| 83 |
+
return m.detect
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def _icon_detect(name: str) -> Callable[[str], dict]:
|
| 87 |
+
if name == "template_matching":
|
| 88 |
+
from src.icon_detection import template_detector as m
|
| 89 |
+
elif name == "clip":
|
| 90 |
+
from src.icon_detection import clip_detector as m
|
| 91 |
+
elif name == "hsv":
|
| 92 |
+
from src.icon_detection import hsv_detector as m
|
| 93 |
+
else:
|
| 94 |
+
raise ValueError(f"unknown icon detector: {name}")
|
| 95 |
+
return m.detect
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def _safe(fn: Callable[[str], dict], image_path: str, key: str) -> tuple[list, float, str | None]:
|
| 99 |
+
"""Run a detector, returning (items, runtime, error). On failure return an
|
| 100 |
+
empty list so the overall result stays schema-valid."""
|
| 101 |
+
try:
|
| 102 |
+
out = fn(image_path)
|
| 103 |
+
return out.get(key, []), float(out.get("runtime_seconds", 0.0)), None
|
| 104 |
+
except Exception as e: # noqa: BLE001 — reliability over strictness here
|
| 105 |
+
return [], 0.0, f"{type(e).__name__}: {e}"
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
def extract(image_path: str, config: PipelineConfig | None = None) -> ExtractionResult:
|
| 109 |
+
"""Run the full pipeline on one image and return a validated ExtractionResult."""
|
| 110 |
+
config = config or PipelineConfig()
|
| 111 |
+
if not os.path.isfile(image_path):
|
| 112 |
+
raise FileNotFoundError(image_path)
|
| 113 |
+
img = cv2.imread(image_path)
|
| 114 |
+
if img is None:
|
| 115 |
+
raise ValueError(f"cv2 could not read image: {image_path}")
|
| 116 |
+
h_img, w_img = img.shape[:2]
|
| 117 |
+
|
| 118 |
+
t0 = time.perf_counter()
|
| 119 |
+
raw_texts, rt_text, err_text = _safe(_text_detect(config.text_detector), image_path, "texts")
|
| 120 |
+
raw_boxes, rt_box, err_box = _safe(_box_detect(config.box_detector), image_path, "boxes")
|
| 121 |
+
raw_arrows, rt_arrow, err_arrow = _safe(_arrow_detect(config.arrow_detector), image_path, "arrows")
|
| 122 |
+
raw_icons, rt_icon, err_icon = _safe(_icon_detect(config.icon_detector), image_path, "icons")
|
| 123 |
+
|
| 124 |
+
# Label boxes from contained text, then build relationships data-driven.
|
| 125 |
+
g0 = time.perf_counter()
|
| 126 |
+
raw_boxes = builder.label_boxes(raw_boxes, raw_texts)
|
| 127 |
+
raw_rels = builder.build_relationships(
|
| 128 |
+
raw_arrows, raw_boxes,
|
| 129 |
+
outside_box_gate=config.outside_box_gate,
|
| 130 |
+
max_dist=config.rel_max_dist,
|
| 131 |
+
ray_intersection=config.rel_ray_intersection,
|
| 132 |
+
max_proj=config.rel_max_proj,
|
| 133 |
+
)
|
| 134 |
+
rt_graph = time.perf_counter() - g0
|
| 135 |
+
|
| 136 |
+
regions = [b for b in raw_boxes if b.get("is_region")]
|
| 137 |
+
components = [b for b in raw_boxes if not b.get("is_region")]
|
| 138 |
+
|
| 139 |
+
result = ExtractionResult(
|
| 140 |
+
image=os.path.basename(image_path),
|
| 141 |
+
width=int(w_img),
|
| 142 |
+
height=int(h_img),
|
| 143 |
+
detectors={
|
| 144 |
+
"text": config.text_detector,
|
| 145 |
+
"box": config.box_detector,
|
| 146 |
+
"arrow": config.arrow_detector,
|
| 147 |
+
"icon": config.icon_detector,
|
| 148 |
+
"outside_box_gate": config.outside_box_gate,
|
| 149 |
+
"rel_max_dist": config.rel_max_dist,
|
| 150 |
+
"rel_ray_intersection": config.rel_ray_intersection,
|
| 151 |
+
"errors": {k: v for k, v in {
|
| 152 |
+
"text": err_text, "box": err_box,
|
| 153 |
+
"arrow": err_arrow, "icon": err_icon,
|
| 154 |
+
}.items() if v},
|
| 155 |
+
},
|
| 156 |
+
texts=[TextElement(**t) for t in raw_texts],
|
| 157 |
+
boxes=[BoxElement(**b) for b in components],
|
| 158 |
+
regions=[BoxElement(**b) for b in regions],
|
| 159 |
+
arrows=[ArrowElement(**a) for a in raw_arrows],
|
| 160 |
+
icons=[IconElement(**i) for i in raw_icons],
|
| 161 |
+
relationships=[Relationship(**r) for r in raw_rels],
|
| 162 |
+
runtimes=StageRuntimes(text=rt_text, box=rt_box, arrow=rt_arrow,
|
| 163 |
+
icon=rt_icon, graph=round(rt_graph, 3)),
|
| 164 |
+
runtime_seconds=round(time.perf_counter() - t0, 3),
|
| 165 |
+
)
|
| 166 |
+
return result
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
# ── Rendering / output writers ───────────────────────────────────────────────
|
| 170 |
+
|
| 171 |
+
def annotate(image_path: str, result: ExtractionResult) -> np.ndarray:
|
| 172 |
+
"""Draw the detection overlay: regions=orange, boxes=green, text=blue,
|
| 173 |
+
arrows=red, icons=magenta."""
|
| 174 |
+
img = cv2.imread(image_path)
|
| 175 |
+
for r in result.regions:
|
| 176 |
+
cv2.rectangle(img, (r.x, r.y), (r.x + r.w, r.y + r.h), (0, 165, 255), 3)
|
| 177 |
+
for b in result.boxes:
|
| 178 |
+
cv2.rectangle(img, (b.x, b.y), (b.x + b.w, b.y + b.h), (0, 200, 0), 2)
|
| 179 |
+
if b.label:
|
| 180 |
+
cv2.putText(img, b.label[:30], (b.x + 3, b.y - 5),
|
| 181 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.35, (0, 200, 0), 1, cv2.LINE_AA)
|
| 182 |
+
for t in result.texts:
|
| 183 |
+
cv2.rectangle(img, (t.x, t.y), (t.x + t.w, t.y + t.h), (255, 100, 0), 1)
|
| 184 |
+
for a in result.arrows:
|
| 185 |
+
cv2.line(img, (a.x1, a.y1), (a.x2, a.y2), (0, 0, 255), 2)
|
| 186 |
+
cv2.circle(img, (a.x1, a.y1), 5, (0, 0, 255), -1)
|
| 187 |
+
cv2.circle(img, (a.x2, a.y2), 5, (0, 0, 255), -1)
|
| 188 |
+
for ic in result.icons:
|
| 189 |
+
cv2.rectangle(img, (ic.x, ic.y), (ic.x + ic.w, ic.y + ic.h), (255, 0, 255), 2)
|
| 190 |
+
cv2.putText(img, ic.label[:20], (ic.x, ic.y - 5),
|
| 191 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.35, (255, 0, 255), 1, cv2.LINE_AA)
|
| 192 |
+
return img
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def write_csv(result: ExtractionResult, output_path: str) -> None:
|
| 196 |
+
with open(output_path, "w", newline="", encoding="utf-8") as f:
|
| 197 |
+
w = csv.writer(f)
|
| 198 |
+
w.writerow(["=== TEXT LABELS ==="])
|
| 199 |
+
w.writerow(["Text", "X", "Y", "Width", "Height", "Confidence"])
|
| 200 |
+
for t in result.texts:
|
| 201 |
+
w.writerow([t.text, t.x, t.y, t.w, t.h, round(t.conf, 3)])
|
| 202 |
+
w.writerow([])
|
| 203 |
+
w.writerow(["=== BOXES ==="])
|
| 204 |
+
w.writerow(["Label", "X", "Y", "Width", "Height", "Entity Type"])
|
| 205 |
+
for b in result.boxes:
|
| 206 |
+
w.writerow([b.label, b.x, b.y, b.w, b.h, b.entity_type])
|
| 207 |
+
w.writerow([])
|
| 208 |
+
w.writerow(["=== RELATIONSHIPS ==="])
|
| 209 |
+
w.writerow(["Source", "Target", "Line Style", "Direction", "Relationship", "Detected"])
|
| 210 |
+
for r in result.relationships:
|
| 211 |
+
w.writerow([r.source, r.target, r.line_style, r.direction, r.relationship, r.detected])
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
def run(image_path: str, output_dir: str, config: PipelineConfig | None = None) -> ExtractionResult:
|
| 215 |
+
"""Full run: extract + write annotated PNG, relationship graph, JSON, CSV."""
|
| 216 |
+
config = config or PipelineConfig()
|
| 217 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 218 |
+
result = extract(image_path, config)
|
| 219 |
+
stem = os.path.splitext(os.path.basename(image_path))[0]
|
| 220 |
+
|
| 221 |
+
cv2.imwrite(os.path.join(output_dir, f"{stem}_annotated.png"), annotate(image_path, result))
|
| 222 |
+
builder.draw_graph([r.model_dump() for r in result.relationships],
|
| 223 |
+
os.path.join(output_dir, f"{stem}_graph.png"),
|
| 224 |
+
layout=config.graph_layout)
|
| 225 |
+
with open(os.path.join(output_dir, f"{stem}_structure.json"), "w", encoding="utf-8") as f:
|
| 226 |
+
json.dump(result.model_dump(), f, indent=2, ensure_ascii=False)
|
| 227 |
+
write_csv(result, os.path.join(output_dir, f"{stem}_data.csv"))
|
| 228 |
+
return result
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
def _build_config(args: argparse.Namespace) -> PipelineConfig:
|
| 232 |
+
return PipelineConfig(
|
| 233 |
+
text_detector=args.text,
|
| 234 |
+
box_detector=args.box,
|
| 235 |
+
arrow_detector=args.arrow,
|
| 236 |
+
icon_detector=args.icon,
|
| 237 |
+
outside_box_gate=not args.no_gate,
|
| 238 |
+
graph_layout=args.layout,
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
|
| 242 |
+
def main() -> None:
|
| 243 |
+
p = argparse.ArgumentParser(description="DiagraMine modular extraction pipeline")
|
| 244 |
+
p.add_argument("image", help="path to the diagram image")
|
| 245 |
+
p.add_argument("--out", default=".", help="output directory")
|
| 246 |
+
p.add_argument("--text", default="easyocr", choices=["easyocr", "paddleocr", "tesseract"])
|
| 247 |
+
p.add_argument("--box", default="canny_contours", choices=["canny_contours", "hough", "yolo"])
|
| 248 |
+
p.add_argument("--arrow", default="hough_lines", choices=["hough_lines", "pixel_scan", "cnn"])
|
| 249 |
+
p.add_argument("--icon", default="template_matching", choices=["template_matching", "clip", "hsv"])
|
| 250 |
+
p.add_argument("--no-gate", action="store_true", help="disable the outside-box arrow gate")
|
| 251 |
+
p.add_argument("--layout", default="kamada_kawai", choices=["kamada_kawai", "spring"])
|
| 252 |
+
args = p.parse_args()
|
| 253 |
+
|
| 254 |
+
result = run(args.image, args.out, _build_config(args))
|
| 255 |
+
print(json.dumps(result.summary(), indent=2))
|
| 256 |
+
print(f"[pipeline] detectors={result.detectors} runtime={result.runtime_seconds}s")
|
| 257 |
+
print(f"[pipeline] outputs written to {os.path.abspath(args.out)}")
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
if __name__ == "__main__":
|
| 261 |
+
main()
|
src/schemas.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Typed Pydantic v2 schemas shared across the DiagraMine pipeline.
|
| 2 |
+
|
| 3 |
+
Every detection stage and the orchestrator validate their output against these
|
| 4 |
+
models, which is what guarantees DiagraMine's headline reliability metric:
|
| 5 |
+
`ExtractionResult.model_dump()` is always schema-valid machine-parseable JSON,
|
| 6 |
+
even when a stage detects nothing (it returns an empty list, not prose). This
|
| 7 |
+
is the property Day-6 measures against Claude Vision (~13% schema-valid).
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
|
| 11 |
+
from typing import List, Literal, Optional
|
| 12 |
+
|
| 13 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class TextElement(BaseModel):
|
| 17 |
+
model_config = ConfigDict(extra="ignore")
|
| 18 |
+
text: str
|
| 19 |
+
x: int
|
| 20 |
+
y: int
|
| 21 |
+
w: int
|
| 22 |
+
h: int
|
| 23 |
+
conf: float = 0.0
|
| 24 |
+
parent_box: Optional[int] = None
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class BoxElement(BaseModel):
|
| 28 |
+
model_config = ConfigDict(extra="ignore")
|
| 29 |
+
x: int
|
| 30 |
+
y: int
|
| 31 |
+
w: int
|
| 32 |
+
h: int
|
| 33 |
+
area: int = 0
|
| 34 |
+
is_region: bool = False
|
| 35 |
+
label: str = ""
|
| 36 |
+
entity_type: str = "component"
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
class ArrowElement(BaseModel):
|
| 40 |
+
model_config = ConfigDict(extra="ignore")
|
| 41 |
+
x1: int
|
| 42 |
+
y1: int
|
| 43 |
+
x2: int
|
| 44 |
+
y2: int
|
| 45 |
+
direction: str = "unknown"
|
| 46 |
+
line_style: str = "solid"
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class IconElement(BaseModel):
|
| 50 |
+
model_config = ConfigDict(extra="ignore")
|
| 51 |
+
x: int
|
| 52 |
+
y: int
|
| 53 |
+
w: int
|
| 54 |
+
h: int
|
| 55 |
+
label: str = "icon"
|
| 56 |
+
score: float = 0.0
|
| 57 |
+
parent_box: Optional[int] = None
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
class Relationship(BaseModel):
|
| 61 |
+
model_config = ConfigDict(extra="ignore")
|
| 62 |
+
source: str
|
| 63 |
+
target: str
|
| 64 |
+
line_style: str = "solid"
|
| 65 |
+
direction: str = "unknown"
|
| 66 |
+
relationship: str = "connects_to"
|
| 67 |
+
# Always True now: every relationship is derived from a detected arrow.
|
| 68 |
+
# The field is kept so downstream consumers that read the legacy schema
|
| 69 |
+
# (which mixed detected + hardcoded edges) don't break.
|
| 70 |
+
detected: bool = True
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
class PipelineConfig(BaseModel):
|
| 74 |
+
"""Selects which champion detector runs at each stage. Defaults are the
|
| 75 |
+
Day-3 Phase-2 leaderboard champions."""
|
| 76 |
+
model_config = ConfigDict(extra="forbid")
|
| 77 |
+
text_detector: Literal["easyocr", "paddleocr", "tesseract"] = "easyocr"
|
| 78 |
+
box_detector: Literal["canny_contours", "hough", "yolo"] = "canny_contours"
|
| 79 |
+
arrow_detector: Literal["hough_lines", "pixel_scan", "cnn"] = "hough_lines"
|
| 80 |
+
icon_detector: Literal["template_matching", "clip", "hsv"] = "template_matching"
|
| 81 |
+
# Day-3 finding: arrow segments lying on/inside a box bbox were inflating
|
| 82 |
+
# recall via the snap-to-adjacency artifact. This gate rejects them.
|
| 83 |
+
outside_box_gate: bool = True
|
| 84 |
+
# Day-5 arrow-mapping fix: endpoint snap radius + ray/line intersection
|
| 85 |
+
# fallback for short segments stopping in whitespace. On the synthetic
|
| 86 |
+
# benchmark the ray path never triggers (boxes are dense, nearest-box always
|
| 87 |
+
# resolves within rel_max_dist) so it is a no-op there; it is kept ON as a
|
| 88 |
+
# proven-correct general improvement for real diagrams with sparser layouts.
|
| 89 |
+
rel_max_dist: float = 160.0
|
| 90 |
+
rel_ray_intersection: bool = True
|
| 91 |
+
rel_max_proj: float = 400.0
|
| 92 |
+
graph_layout: Literal["kamada_kawai", "spring"] = "kamada_kawai"
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class StageRuntimes(BaseModel):
|
| 96 |
+
model_config = ConfigDict(extra="ignore")
|
| 97 |
+
text: float = 0.0
|
| 98 |
+
box: float = 0.0
|
| 99 |
+
arrow: float = 0.0
|
| 100 |
+
icon: float = 0.0
|
| 101 |
+
graph: float = 0.0
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
class ExtractionResult(BaseModel):
|
| 105 |
+
"""Canonical machine-parseable output of the whole pipeline."""
|
| 106 |
+
model_config = ConfigDict(extra="ignore")
|
| 107 |
+
image: str
|
| 108 |
+
width: int
|
| 109 |
+
height: int
|
| 110 |
+
detectors: dict = Field(default_factory=dict)
|
| 111 |
+
texts: List[TextElement] = Field(default_factory=list)
|
| 112 |
+
boxes: List[BoxElement] = Field(default_factory=list)
|
| 113 |
+
regions: List[BoxElement] = Field(default_factory=list)
|
| 114 |
+
arrows: List[ArrowElement] = Field(default_factory=list)
|
| 115 |
+
icons: List[IconElement] = Field(default_factory=list)
|
| 116 |
+
relationships: List[Relationship] = Field(default_factory=list)
|
| 117 |
+
runtimes: StageRuntimes = Field(default_factory=StageRuntimes)
|
| 118 |
+
runtime_seconds: float = 0.0
|
| 119 |
+
|
| 120 |
+
def summary(self) -> dict:
|
| 121 |
+
return {
|
| 122 |
+
"text_labels": len(self.texts),
|
| 123 |
+
"boxes": len(self.boxes),
|
| 124 |
+
"regions": len(self.regions),
|
| 125 |
+
"arrows": len(self.arrows),
|
| 126 |
+
"icons": len(self.icons),
|
| 127 |
+
"relationships": len(self.relationships),
|
| 128 |
+
}
|
src/text_detection/__init__.py
ADDED
|
File without changes
|
src/text_detection/easyocr_detector.py
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""EasyOCR-based text detector. Mirrors the current diagram_analysis.detect_text
|
| 2 |
+
behaviour (Reader(en, gpu=False), conf>=0.10) but without the legacy
|
| 3 |
+
_merge_nearby_texts post-processing so that comparison across OCR backends is
|
| 4 |
+
apples-to-apples on raw per-fragment detections."""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
import time
|
| 8 |
+
from typing import List
|
| 9 |
+
|
| 10 |
+
import cv2
|
| 11 |
+
import easyocr
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
_READER: easyocr.Reader | None = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def _get_reader() -> easyocr.Reader:
|
| 18 |
+
global _READER
|
| 19 |
+
if _READER is None:
|
| 20 |
+
_READER = easyocr.Reader(["en"], gpu=False, verbose=False)
|
| 21 |
+
return _READER
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def detect(image_path: str, min_conf: float = 0.10) -> dict:
|
| 25 |
+
img = cv2.imread(image_path)
|
| 26 |
+
if img is None:
|
| 27 |
+
raise FileNotFoundError(image_path)
|
| 28 |
+
reader = _get_reader()
|
| 29 |
+
start = time.perf_counter()
|
| 30 |
+
raw = reader.readtext(img)
|
| 31 |
+
elapsed = time.perf_counter() - start
|
| 32 |
+
|
| 33 |
+
texts: List[dict] = []
|
| 34 |
+
for bbox, text, conf in raw:
|
| 35 |
+
if conf < min_conf:
|
| 36 |
+
continue
|
| 37 |
+
pts = np.array(bbox, dtype=np.int32)
|
| 38 |
+
x, y, w, h = cv2.boundingRect(pts)
|
| 39 |
+
texts.append(
|
| 40 |
+
{
|
| 41 |
+
"text": text.strip(),
|
| 42 |
+
"x": int(x),
|
| 43 |
+
"y": int(y),
|
| 44 |
+
"w": int(w),
|
| 45 |
+
"h": int(h),
|
| 46 |
+
"conf": float(conf),
|
| 47 |
+
}
|
| 48 |
+
)
|
| 49 |
+
return {"texts": texts, "runtime_seconds": round(elapsed, 3)}
|
src/text_detection/paddle_detector.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PaddleOCR text detector. Uses the English model; CPU-only.
|
| 2 |
+
|
| 3 |
+
paddleocr 2.x API: PaddleOCR(use_angle_cls=True, lang='en'). The .ocr()
|
| 4 |
+
return format is [[(bbox, (text, conf)), ...]] for the single-image case.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
import time
|
| 10 |
+
from typing import List
|
| 11 |
+
|
| 12 |
+
import cv2
|
| 13 |
+
import numpy as np
|
| 14 |
+
|
| 15 |
+
# Silence paddle's verbose logger
|
| 16 |
+
logging.getLogger("ppocr").setLevel(logging.ERROR)
|
| 17 |
+
|
| 18 |
+
_OCR = None
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def _get_ocr():
|
| 22 |
+
global _OCR
|
| 23 |
+
if _OCR is None:
|
| 24 |
+
from paddleocr import PaddleOCR
|
| 25 |
+
_OCR = PaddleOCR(use_angle_cls=True, lang="en", show_log=False)
|
| 26 |
+
return _OCR
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def detect(image_path: str, min_conf: float = 0.10) -> dict:
|
| 30 |
+
img = cv2.imread(image_path)
|
| 31 |
+
if img is None:
|
| 32 |
+
raise FileNotFoundError(image_path)
|
| 33 |
+
|
| 34 |
+
ocr = _get_ocr()
|
| 35 |
+
start = time.perf_counter()
|
| 36 |
+
# PaddleOCR accepts ndarray or path.
|
| 37 |
+
result = ocr.ocr(img, cls=True)
|
| 38 |
+
elapsed = time.perf_counter() - start
|
| 39 |
+
|
| 40 |
+
texts: List[dict] = []
|
| 41 |
+
if not result:
|
| 42 |
+
return {"texts": texts, "runtime_seconds": round(elapsed, 3)}
|
| 43 |
+
|
| 44 |
+
page = result[0] if isinstance(result[0], list) else result
|
| 45 |
+
if page is None:
|
| 46 |
+
return {"texts": texts, "runtime_seconds": round(elapsed, 3)}
|
| 47 |
+
|
| 48 |
+
for entry in page:
|
| 49 |
+
if entry is None:
|
| 50 |
+
continue
|
| 51 |
+
# entry = [bbox, (text, conf)]
|
| 52 |
+
bbox, (text, conf) = entry
|
| 53 |
+
if conf is None or conf < min_conf:
|
| 54 |
+
continue
|
| 55 |
+
pts = np.array(bbox, dtype=np.int32)
|
| 56 |
+
x, y, w, h = cv2.boundingRect(pts)
|
| 57 |
+
texts.append(
|
| 58 |
+
{
|
| 59 |
+
"text": text.strip(),
|
| 60 |
+
"x": int(x),
|
| 61 |
+
"y": int(y),
|
| 62 |
+
"w": int(w),
|
| 63 |
+
"h": int(h),
|
| 64 |
+
"conf": float(conf),
|
| 65 |
+
}
|
| 66 |
+
)
|
| 67 |
+
return {"texts": texts, "runtime_seconds": round(elapsed, 3)}
|
src/text_detection/tesseract_detector.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tesseract-based text detector via pytesseract. Requires the tesseract.exe
|
| 2 |
+
binary on PATH (or pytesseract.tesseract_cmd set). If the binary is missing,
|
| 3 |
+
detect() returns {"texts": [], "runtime_seconds": None, "skipped": True,
|
| 4 |
+
"reason": "..."}.
|
| 5 |
+
"""
|
| 6 |
+
from __future__ import annotations
|
| 7 |
+
|
| 8 |
+
import shutil
|
| 9 |
+
import time
|
| 10 |
+
from typing import List
|
| 11 |
+
|
| 12 |
+
import cv2
|
| 13 |
+
|
| 14 |
+
try:
|
| 15 |
+
import pytesseract
|
| 16 |
+
from pytesseract import Output
|
| 17 |
+
_PYTESSERACT_OK = True
|
| 18 |
+
except Exception as exc: # pragma: no cover
|
| 19 |
+
_PYTESSERACT_OK = False
|
| 20 |
+
_IMPORT_ERR = repr(exc)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _binary_available() -> bool:
|
| 24 |
+
if not _PYTESSERACT_OK:
|
| 25 |
+
return False
|
| 26 |
+
cmd = getattr(pytesseract.pytesseract, "tesseract_cmd", "tesseract")
|
| 27 |
+
return shutil.which(cmd) is not None
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def detect(image_path: str, min_conf: float = 10.0) -> dict:
|
| 31 |
+
if not _PYTESSERACT_OK:
|
| 32 |
+
return {
|
| 33 |
+
"texts": [],
|
| 34 |
+
"runtime_seconds": None,
|
| 35 |
+
"skipped": True,
|
| 36 |
+
"reason": f"pytesseract import failed: {_IMPORT_ERR}",
|
| 37 |
+
}
|
| 38 |
+
if not _binary_available():
|
| 39 |
+
return {
|
| 40 |
+
"texts": [],
|
| 41 |
+
"runtime_seconds": None,
|
| 42 |
+
"skipped": True,
|
| 43 |
+
"reason": "tesseract.exe binary not on PATH (install from "
|
| 44 |
+
"https://github.com/UB-Mannheim/tesseract/wiki)",
|
| 45 |
+
}
|
| 46 |
+
|
| 47 |
+
img = cv2.imread(image_path)
|
| 48 |
+
if img is None:
|
| 49 |
+
raise FileNotFoundError(image_path)
|
| 50 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 51 |
+
|
| 52 |
+
start = time.perf_counter()
|
| 53 |
+
data = pytesseract.image_to_data(gray, output_type=Output.DICT)
|
| 54 |
+
elapsed = time.perf_counter() - start
|
| 55 |
+
|
| 56 |
+
texts: List[dict] = []
|
| 57 |
+
for i, txt in enumerate(data["text"]):
|
| 58 |
+
if not txt.strip():
|
| 59 |
+
continue
|
| 60 |
+
try:
|
| 61 |
+
conf = float(data["conf"][i])
|
| 62 |
+
except (TypeError, ValueError):
|
| 63 |
+
conf = -1.0
|
| 64 |
+
if conf < min_conf:
|
| 65 |
+
continue
|
| 66 |
+
texts.append(
|
| 67 |
+
{
|
| 68 |
+
"text": txt.strip(),
|
| 69 |
+
"x": int(data["left"][i]),
|
| 70 |
+
"y": int(data["top"][i]),
|
| 71 |
+
"w": int(data["width"][i]),
|
| 72 |
+
"h": int(data["height"][i]),
|
| 73 |
+
"conf": conf / 100.0,
|
| 74 |
+
}
|
| 75 |
+
)
|
| 76 |
+
return {"texts": texts, "runtime_seconds": round(elapsed, 3)}
|