Spaces:
Sleeping
feat(ai): tiled inference, machine API, and label JSON interchange in annotator
Browse files- detection.py: detect_tiled (640x640 windows, stride 480, cross-tile NMS
IoU 0.5); detect_rectangles auto-tiles images larger than one tile, fixing
zero-detection on full sheets; render_pdf_page + pt/px converters; model
resolution via FACADE_MODEL_REPO (HF Hub, revision-pinnable) with
FACADE_MODEL_PATH override; CUDA device auto-detect.
- app.py: all detect paths render at DETECTION_DPI=200 (Review tab scales
back to its 100-DPI canvas space); hidden machine API /detect_image and
/detect_page for gradio_client callers; Review tab exports/imports
canonical per-page label JSON (PDF-point coords, provenance, rejected
boxes) for the training pipeline.
Verified: 229-box parity with the validated tiled baseline on
testRollingPart5.pdf p1 via both API endpoints; label export/import
round-trips.
- CLAUDE.md +37 -2
- app.py +236 -9
- detection.py +168 -11
- requirements.txt +1 -0
|
@@ -4,12 +4,47 @@ Gradio annotation tool for facade frame detection. Deployed to HF Spaces as `Inf
|
|
| 4 |
|
| 5 |
## Files
|
| 6 |
|
| 7 |
-
- `app.py` — Gradio UI (3 tabs: Detect, Review & Edit, Send to Backend)
|
| 8 |
-
- `detection.py` — YOLO inference wrapper with OpenCV fallback
|
| 9 |
- `requirements.txt` — Python deps for local dev and HF Spaces
|
| 10 |
- `packages.txt` — System deps (poppler-utils) for HF Spaces
|
| 11 |
- `best.pt` — YOLO11n weights (not in git — download from HF Hub or train locally)
|
| 12 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
## Deploying to HF Spaces
|
| 14 |
|
| 15 |
HF Spaces builds from `hf/main`. The Space expects files at the repo root, so use git subtree:
|
|
|
|
| 4 |
|
| 5 |
## Files
|
| 6 |
|
| 7 |
+
- `app.py` — Gradio UI (3 tabs: Detect, Review & Edit, Send to Backend) + hidden machine API
|
| 8 |
+
- `detection.py` — YOLO inference wrapper (tiled for full sheets) with OpenCV fallback
|
| 9 |
- `requirements.txt` — Python deps for local dev and HF Spaces
|
| 10 |
- `packages.txt` — System deps (poppler-utils) for HF Spaces
|
| 11 |
- `best.pt` — YOLO11n weights (not in git — download from HF Hub or train locally)
|
| 12 |
|
| 13 |
+
## Detection
|
| 14 |
+
|
| 15 |
+
Full drawing sheets are detected with **tiled inference** (`detect_tiled`: 640×640 windows,
|
| 16 |
+
stride 480, cross-tile NMS IoU 0.5) at **200 DPI** — whole-sheet single-pass inference finds
|
| 17 |
+
nothing because mullions shrink below detectable size. `detect_rectangles()` tiles
|
| 18 |
+
automatically for images larger than one tile. Constants live in `detection.py`
|
| 19 |
+
(`TILE_SIZE`, `TILE_STRIDE`, `TILE_MERGE_IOU`, `DETECTION_DPI`) and must match the
|
| 20 |
+
training dataset builder.
|
| 21 |
+
|
| 22 |
+
Model resolution order: `FACADE_MODEL_PATH` (local file) → `FACADE_MODEL_REPO`
|
| 23 |
+
(HF Hub, e.g. `Infin8-AI/estimat8-vm-v1`, pin with `FACADE_MODEL_REVISION`, auth with
|
| 24 |
+
`HF_TOKEN`) → `best.pt` next to `detection.py`.
|
| 25 |
+
|
| 26 |
+
## Machine API (for backends)
|
| 27 |
+
|
| 28 |
+
Hidden endpoints exposed via the Gradio API, called with `gradio_client`:
|
| 29 |
+
|
| 30 |
+
```python
|
| 31 |
+
from gradio_client import Client, handle_file
|
| 32 |
+
client = Client("Infin8-AI/Annotation", hf_token=...) # or http://localhost:7860
|
| 33 |
+
result = client.predict(handle_file("page.png"), 0.25, api_name="/detect_image")
|
| 34 |
+
# -> {"width_px", "height_px", "model_version", "boxes": [{x1,y1,x2,y2,conf}]}
|
| 35 |
+
```
|
| 36 |
+
|
| 37 |
+
- `/detect_image (image_file, conf)` — primary: caller renders the PDF page and uploads one PNG.
|
| 38 |
+
- `/detect_page (pdf_file, page_number, dpi, conf)` — testing/fallback; avoid for big PDFs
|
| 39 |
+
(gradio_client re-uploads the file every call).
|
| 40 |
+
|
| 41 |
+
## Label JSON import/export (Review & Edit tab)
|
| 42 |
+
|
| 43 |
+
`↓ Labels` exports every annotated page as canonical per-page label JSON (coordinates in
|
| 44 |
+
**PDF points**, provenance + rejected boxes preserved), zipped. The Import field loads such
|
| 45 |
+
JSONs back (audited → approved, machine bootstrap → pending, rejected → rejected). This is
|
| 46 |
+
the audit interchange format consumed by `scripts/annotation/` at the repo root.
|
| 47 |
+
|
| 48 |
## Deploying to HF Spaces
|
| 49 |
|
| 50 |
HF Spaces builds from `hf/main`. The Space expects files at the repo root, so use git subtree:
|
|
@@ -12,7 +12,15 @@ import cv2
|
|
| 12 |
import gradio as gr
|
| 13 |
import numpy as np
|
| 14 |
import requests
|
| 15 |
-
from detection import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
from PIL import Image
|
| 17 |
|
| 18 |
# Fix gradio_client 5.9.1 crash when schema is a bool
|
|
@@ -100,7 +108,7 @@ def _load_image_for_detect(image_path, pdf_path, pdf_page):
|
|
| 100 |
img = _pil_to_bgr(Image.open(image_path))
|
| 101 |
return img
|
| 102 |
if pdf_path and PDF_SUPPORT:
|
| 103 |
-
rgb = _render_pdf_page(pdf_path, pdf_page, dpi=
|
| 104 |
return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) if rgb is not None else None
|
| 105 |
return None
|
| 106 |
|
|
@@ -336,6 +344,177 @@ def run_detection(
|
|
| 336 |
return annotated_rgb, payload, shapes, f"[{engine}] Detected {len(boxes)} panel(s)."
|
| 337 |
|
| 338 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
# ---------------------------------------------------------------------------
|
| 340 |
# Send to Backend handler
|
| 341 |
# ---------------------------------------------------------------------------
|
|
@@ -494,18 +673,24 @@ def on_rv_detect(rv_pdf, rv_page, rv_annotations, rv_conf):
|
|
| 494 |
img_rgb = _render_pdf_page(rv_pdf, rv_page, dpi=100)
|
| 495 |
if img_rgb is None:
|
| 496 |
return _build_canvas_json(None, []), _build_review_df([]), rv_annotations, "Failed to render page."
|
| 497 |
-
img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
|
| 498 |
from detection import _get_model
|
| 499 |
|
| 500 |
using_yolo = _get_model() is not None
|
| 501 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
annotations = [
|
| 503 |
{
|
| 504 |
"id": i,
|
| 505 |
-
"x1": b["x1"],
|
| 506 |
-
"y1": b["y1"],
|
| 507 |
-
"x2": b["x2"],
|
| 508 |
-
"y2": b["y2"],
|
| 509 |
"conf": b["conf"],
|
| 510 |
"status": "pending",
|
| 511 |
"source": "yolo" if using_yolo else "opencv",
|
|
@@ -533,7 +718,7 @@ def on_rv_detect_all(rv_pdf, rv_page, rv_total, rv_annotations, rv_conf):
|
|
| 533 |
using_yolo = _get_model() is not None
|
| 534 |
all_anns = dict(rv_annotations)
|
| 535 |
total_found = 0
|
| 536 |
-
detect_dpi =
|
| 537 |
try:
|
| 538 |
doc = fitz.open(rv_pdf)
|
| 539 |
mat = fitz.Matrix(detect_dpi / 72, detect_dpi / 72)
|
|
@@ -1184,7 +1369,13 @@ with gr.Blocks(title="Takeoff AI Annotator") as demo:
|
|
| 1184 |
with gr.Row():
|
| 1185 |
export_events_btn = gr.Button("↓ Events", scale=1)
|
| 1186 |
export_dataset_btn = gr.Button("↓ Dataset", scale=1)
|
|
|
|
| 1187 |
export_file_out = gr.File(label="Download", visible=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1188 |
|
| 1189 |
gr.Markdown(
|
| 1190 |
"`A` approve `C` copy `Del` delete "
|
|
@@ -1318,6 +1509,16 @@ with gr.Blocks(title="Takeoff AI Annotator") as demo:
|
|
| 1318 |
inputs=[rv_ledger_state, rv_pdf_state],
|
| 1319 |
outputs=[export_file_out],
|
| 1320 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1321 |
|
| 1322 |
# ------------------------------------------------------------------ #
|
| 1323 |
# Tab 3: Send to Backend #
|
|
@@ -1354,6 +1555,32 @@ with gr.Blocks(title="Takeoff AI Annotator") as demo:
|
|
| 1354 |
outputs=[response_output],
|
| 1355 |
)
|
| 1356 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1357 |
gr.HTML(_CANVAS_JS)
|
| 1358 |
|
| 1359 |
if __name__ == "__main__":
|
|
|
|
| 12 |
import gradio as gr
|
| 13 |
import numpy as np
|
| 14 |
import requests
|
| 15 |
+
from detection import (
|
| 16 |
+
DETECTION_DPI,
|
| 17 |
+
detect_rectangles,
|
| 18 |
+
draw_detections,
|
| 19 |
+
get_model_version,
|
| 20 |
+
pt_to_px,
|
| 21 |
+
px_to_pt,
|
| 22 |
+
render_pdf_page,
|
| 23 |
+
)
|
| 24 |
from PIL import Image
|
| 25 |
|
| 26 |
# Fix gradio_client 5.9.1 crash when schema is a bool
|
|
|
|
| 108 |
img = _pil_to_bgr(Image.open(image_path))
|
| 109 |
return img
|
| 110 |
if pdf_path and PDF_SUPPORT:
|
| 111 |
+
rgb = _render_pdf_page(pdf_path, pdf_page, dpi=DETECTION_DPI)
|
| 112 |
return cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR) if rgb is not None else None
|
| 113 |
return None
|
| 114 |
|
|
|
|
| 344 |
return annotated_rgb, payload, shapes, f"[{engine}] Detected {len(boxes)} panel(s)."
|
| 345 |
|
| 346 |
|
| 347 |
+
# ---------------------------------------------------------------------------
|
| 348 |
+
# Machine API endpoints (called by the backend via gradio_client)
|
| 349 |
+
# ---------------------------------------------------------------------------
|
| 350 |
+
def detect_image_api(image_file, conf=0.25):
|
| 351 |
+
"""
|
| 352 |
+
Detect frames on a single page image (PNG/JPG). Primary backend entrypoint:
|
| 353 |
+
the caller renders the PDF page itself and uploads only the image.
|
| 354 |
+
|
| 355 |
+
Returns {"width_px", "height_px", "model_version", "boxes": [{x1,y1,x2,y2,conf}]}
|
| 356 |
+
with coordinates in the uploaded image's pixel space.
|
| 357 |
+
"""
|
| 358 |
+
if image_file is None:
|
| 359 |
+
return {"error": "image file is required"}
|
| 360 |
+
path = image_file if isinstance(image_file, str) else image_file.name
|
| 361 |
+
img = cv2.imread(path)
|
| 362 |
+
if img is None:
|
| 363 |
+
try:
|
| 364 |
+
img = _pil_to_bgr(Image.open(path))
|
| 365 |
+
except Exception as exc:
|
| 366 |
+
return {"error": f"could not read image: {exc}"}
|
| 367 |
+
boxes = detect_rectangles(img, conf=float(conf))
|
| 368 |
+
h, w = img.shape[:2]
|
| 369 |
+
return {
|
| 370 |
+
"width_px": int(w),
|
| 371 |
+
"height_px": int(h),
|
| 372 |
+
"model_version": get_model_version(),
|
| 373 |
+
"boxes": boxes,
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
def detect_page_api(pdf_file, page_number=1, dpi=DETECTION_DPI, conf=0.25):
|
| 378 |
+
"""
|
| 379 |
+
Render one PDF page at the given DPI and detect frames on it.
|
| 380 |
+
Testing/fallback path — prefer detect_image_api to avoid re-uploading
|
| 381 |
+
large PDFs per page.
|
| 382 |
+
"""
|
| 383 |
+
if pdf_file is None:
|
| 384 |
+
return {"error": "pdf file is required"}
|
| 385 |
+
path = pdf_file if isinstance(pdf_file, str) else pdf_file.name
|
| 386 |
+
rgb = render_pdf_page(path, int(page_number), dpi=int(dpi))
|
| 387 |
+
if rgb is None:
|
| 388 |
+
return {"error": f"could not render page {page_number}"}
|
| 389 |
+
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
|
| 390 |
+
boxes = detect_rectangles(bgr, conf=float(conf))
|
| 391 |
+
h, w = rgb.shape[:2]
|
| 392 |
+
return {
|
| 393 |
+
"page_number": int(page_number),
|
| 394 |
+
"dpi": int(dpi),
|
| 395 |
+
"width_px": int(w),
|
| 396 |
+
"height_px": int(h),
|
| 397 |
+
"model_version": get_model_version(),
|
| 398 |
+
"boxes": boxes,
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
# ---------------------------------------------------------------------------
|
| 403 |
+
# Per-page label JSON import/export (canonical ground-truth format)
|
| 404 |
+
# ---------------------------------------------------------------------------
|
| 405 |
+
_LABEL_DISPLAY_DPI = 100 # the Review tab canvas coordinate space
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def _source_tag(ann, model_version):
|
| 409 |
+
src = ann.get("source", "unknown")
|
| 410 |
+
if src == "yolo":
|
| 411 |
+
return f"yolo:{model_version}"
|
| 412 |
+
if src in ("human_added", "human"):
|
| 413 |
+
return "human:gradio"
|
| 414 |
+
return src
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def on_rv_export_labels(rv_pdf, rv_annotations):
|
| 418 |
+
"""Export every page with annotations as canonical label JSONs (zipped).
|
| 419 |
+
|
| 420 |
+
Coordinates are converted from the 100-DPI canvas space to PDF points so
|
| 421 |
+
labels are DPI-independent and merge with other sources downstream.
|
| 422 |
+
"""
|
| 423 |
+
if rv_pdf is None:
|
| 424 |
+
return None, "No PDF loaded."
|
| 425 |
+
pages = {p: anns for p, anns in (rv_annotations or {}).items() if anns}
|
| 426 |
+
if not pages:
|
| 427 |
+
return None, "No annotations to export."
|
| 428 |
+
stem = Path(rv_pdf).stem
|
| 429 |
+
mv = get_model_version()
|
| 430 |
+
tmp_dir = Path(tempfile.mkdtemp(prefix="labels_"))
|
| 431 |
+
written = []
|
| 432 |
+
for page, anns in sorted(pages.items()):
|
| 433 |
+
boxes, rejected = [], []
|
| 434 |
+
for ann in anns:
|
| 435 |
+
entry = {
|
| 436 |
+
"id": ann.get("uid") or str(uuid.uuid4()),
|
| 437 |
+
"x1_pt": round(px_to_pt(ann["x1"], _LABEL_DISPLAY_DPI), 3),
|
| 438 |
+
"y1_pt": round(px_to_pt(ann["y1"], _LABEL_DISPLAY_DPI), 3),
|
| 439 |
+
"x2_pt": round(px_to_pt(ann["x2"], _LABEL_DISPLAY_DPI), 3),
|
| 440 |
+
"y2_pt": round(px_to_pt(ann["y2"], _LABEL_DISPLAY_DPI), 3),
|
| 441 |
+
"source": _source_tag(ann, mv),
|
| 442 |
+
"conf": round(float(ann.get("conf", 1.0)), 4),
|
| 443 |
+
"history": ann.get("history", []),
|
| 444 |
+
}
|
| 445 |
+
(rejected if ann.get("status") == "rejected" else boxes).append(entry)
|
| 446 |
+
data = {
|
| 447 |
+
"doc": stem,
|
| 448 |
+
"pdf": Path(rv_pdf).name,
|
| 449 |
+
"page": int(page),
|
| 450 |
+
"page_type": "",
|
| 451 |
+
"audit_status": "audited",
|
| 452 |
+
"audited_at": datetime.now(UTC).isoformat(),
|
| 453 |
+
"boxes": boxes,
|
| 454 |
+
"rejected_boxes": rejected,
|
| 455 |
+
}
|
| 456 |
+
out = tmp_dir / f"p{int(page):03d}.json"
|
| 457 |
+
out.write_text(json.dumps(data, indent=2), encoding="utf-8")
|
| 458 |
+
written.append(out)
|
| 459 |
+
zip_path = tmp_dir / f"{stem}_labels.zip"
|
| 460 |
+
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
| 461 |
+
for f in written:
|
| 462 |
+
zf.write(f, f.name)
|
| 463 |
+
return str(zip_path), f"Exported labels for {len(written)} page(s)."
|
| 464 |
+
|
| 465 |
+
|
| 466 |
+
def on_rv_import_labels(label_files, rv_pdf, rv_page, rv_annotations):
|
| 467 |
+
"""Import canonical label JSON(s) into the Review tab.
|
| 468 |
+
|
| 469 |
+
Boxes from audited/verified files arrive approved; machine bootstrap files
|
| 470 |
+
arrive pending; rejected_boxes arrive rejected (so they round-trip).
|
| 471 |
+
"""
|
| 472 |
+
if not label_files:
|
| 473 |
+
return _build_canvas_json(None, []), _build_review_df([]), rv_annotations, "No label files provided."
|
| 474 |
+
rv_annotations = dict(rv_annotations or {})
|
| 475 |
+
loaded_pages = []
|
| 476 |
+
for lf in label_files:
|
| 477 |
+
path = lf if isinstance(lf, str) else lf.name
|
| 478 |
+
try:
|
| 479 |
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
| 480 |
+
except Exception as exc:
|
| 481 |
+
return (
|
| 482 |
+
_build_canvas_json(None, rv_annotations.get(rv_page, [])),
|
| 483 |
+
_build_review_df(rv_annotations.get(rv_page, [])),
|
| 484 |
+
rv_annotations,
|
| 485 |
+
f"Failed to parse {Path(path).name}: {exc}",
|
| 486 |
+
)
|
| 487 |
+
page = int(data.get("page", 1))
|
| 488 |
+
default_status = "approved" if data.get("audit_status") in ("audited", "verified") else "pending"
|
| 489 |
+
anns = []
|
| 490 |
+
for status, group in ((default_status, data.get("boxes", [])), ("rejected", data.get("rejected_boxes", []))):
|
| 491 |
+
for box in group:
|
| 492 |
+
anns.append(
|
| 493 |
+
{
|
| 494 |
+
"id": len(anns),
|
| 495 |
+
"uid": box.get("id"),
|
| 496 |
+
"x1": pt_to_px(box["x1_pt"], _LABEL_DISPLAY_DPI),
|
| 497 |
+
"y1": pt_to_px(box["y1_pt"], _LABEL_DISPLAY_DPI),
|
| 498 |
+
"x2": pt_to_px(box["x2_pt"], _LABEL_DISPLAY_DPI),
|
| 499 |
+
"y2": pt_to_px(box["y2_pt"], _LABEL_DISPLAY_DPI),
|
| 500 |
+
"conf": float(box.get("conf", 1.0)),
|
| 501 |
+
"status": status,
|
| 502 |
+
"source": box.get("source", "unknown"),
|
| 503 |
+
"history": box.get("history", []),
|
| 504 |
+
}
|
| 505 |
+
)
|
| 506 |
+
rv_annotations[page] = anns
|
| 507 |
+
loaded_pages.append(page)
|
| 508 |
+
current = rv_annotations.get(rv_page, [])
|
| 509 |
+
img_rgb = _render_pdf_page(rv_pdf, rv_page, dpi=100) if rv_pdf is not None and rv_page in loaded_pages else None
|
| 510 |
+
return (
|
| 511 |
+
_build_canvas_json(img_rgb, current),
|
| 512 |
+
_build_review_df(current),
|
| 513 |
+
rv_annotations,
|
| 514 |
+
f"Imported labels for page(s) {sorted(loaded_pages)}.",
|
| 515 |
+
)
|
| 516 |
+
|
| 517 |
+
|
| 518 |
# ---------------------------------------------------------------------------
|
| 519 |
# Send to Backend handler
|
| 520 |
# ---------------------------------------------------------------------------
|
|
|
|
| 673 |
img_rgb = _render_pdf_page(rv_pdf, rv_page, dpi=100)
|
| 674 |
if img_rgb is None:
|
| 675 |
return _build_canvas_json(None, []), _build_review_df([]), rv_annotations, "Failed to render page."
|
|
|
|
| 676 |
from detection import _get_model
|
| 677 |
|
| 678 |
using_yolo = _get_model() is not None
|
| 679 |
+
# Detect on a DETECTION_DPI render (the model's working resolution), then
|
| 680 |
+
# scale boxes back into the 100-DPI display space the canvas uses.
|
| 681 |
+
detect_rgb = _render_pdf_page(rv_pdf, rv_page, dpi=DETECTION_DPI)
|
| 682 |
+
if detect_rgb is None:
|
| 683 |
+
return _build_canvas_json(None, []), _build_review_df([]), rv_annotations, "Failed to render page."
|
| 684 |
+
detect_bgr = cv2.cvtColor(detect_rgb, cv2.COLOR_RGB2BGR)
|
| 685 |
+
boxes = detect_rectangles(detect_bgr, conf=float(rv_conf))
|
| 686 |
+
scale = 100.0 / DETECTION_DPI
|
| 687 |
annotations = [
|
| 688 |
{
|
| 689 |
"id": i,
|
| 690 |
+
"x1": b["x1"] * scale,
|
| 691 |
+
"y1": b["y1"] * scale,
|
| 692 |
+
"x2": b["x2"] * scale,
|
| 693 |
+
"y2": b["y2"] * scale,
|
| 694 |
"conf": b["conf"],
|
| 695 |
"status": "pending",
|
| 696 |
"source": "yolo" if using_yolo else "opencv",
|
|
|
|
| 718 |
using_yolo = _get_model() is not None
|
| 719 |
all_anns = dict(rv_annotations)
|
| 720 |
total_found = 0
|
| 721 |
+
detect_dpi = DETECTION_DPI # the model's working resolution; lower DPI misses most frames
|
| 722 |
try:
|
| 723 |
doc = fitz.open(rv_pdf)
|
| 724 |
mat = fitz.Matrix(detect_dpi / 72, detect_dpi / 72)
|
|
|
|
| 1369 |
with gr.Row():
|
| 1370 |
export_events_btn = gr.Button("↓ Events", scale=1)
|
| 1371 |
export_dataset_btn = gr.Button("↓ Dataset", scale=1)
|
| 1372 |
+
export_labels_btn = gr.Button("↓ Labels", scale=1)
|
| 1373 |
export_file_out = gr.File(label="Download", visible=True)
|
| 1374 |
+
import_labels_in = gr.File(
|
| 1375 |
+
label="Import label JSON(s)",
|
| 1376 |
+
file_count="multiple",
|
| 1377 |
+
file_types=[".json"],
|
| 1378 |
+
)
|
| 1379 |
|
| 1380 |
gr.Markdown(
|
| 1381 |
"`A` approve `C` copy `Del` delete "
|
|
|
|
| 1509 |
inputs=[rv_ledger_state, rv_pdf_state],
|
| 1510 |
outputs=[export_file_out],
|
| 1511 |
)
|
| 1512 |
+
export_labels_btn.click(
|
| 1513 |
+
fn=on_rv_export_labels,
|
| 1514 |
+
inputs=[rv_pdf_state, rv_annotations_state],
|
| 1515 |
+
outputs=[export_file_out, rv_status],
|
| 1516 |
+
)
|
| 1517 |
+
import_labels_in.upload(
|
| 1518 |
+
fn=on_rv_import_labels,
|
| 1519 |
+
inputs=[import_labels_in, rv_pdf_state, rv_page_state, rv_annotations_state],
|
| 1520 |
+
outputs=[rv_canvas_render_txt, rv_df, rv_annotations_state, rv_status],
|
| 1521 |
+
)
|
| 1522 |
|
| 1523 |
# ------------------------------------------------------------------ #
|
| 1524 |
# Tab 3: Send to Backend #
|
|
|
|
| 1555 |
outputs=[response_output],
|
| 1556 |
)
|
| 1557 |
|
| 1558 |
+
# ------------------------------------------------------------------ #
|
| 1559 |
+
# Machine API (hidden) — called by backends via gradio_client: #
|
| 1560 |
+
# client.predict(handle_file(png), 0.25, api_name="/detect_image") #
|
| 1561 |
+
# ------------------------------------------------------------------ #
|
| 1562 |
+
with gr.Row(visible=False):
|
| 1563 |
+
api_image_in = gr.File(label="api_image", file_types=["image"])
|
| 1564 |
+
api_pdf_in = gr.File(label="api_pdf", file_types=[".pdf"])
|
| 1565 |
+
api_page_in = gr.Number(value=1, precision=0)
|
| 1566 |
+
api_dpi_in = gr.Number(value=DETECTION_DPI, precision=0)
|
| 1567 |
+
api_conf_in = gr.Number(value=0.25)
|
| 1568 |
+
api_json_out = gr.JSON()
|
| 1569 |
+
api_detect_image_btn = gr.Button("api detect_image")
|
| 1570 |
+
api_detect_page_btn = gr.Button("api detect_page")
|
| 1571 |
+
api_detect_image_btn.click(
|
| 1572 |
+
fn=detect_image_api,
|
| 1573 |
+
inputs=[api_image_in, api_conf_in],
|
| 1574 |
+
outputs=[api_json_out],
|
| 1575 |
+
api_name="detect_image",
|
| 1576 |
+
)
|
| 1577 |
+
api_detect_page_btn.click(
|
| 1578 |
+
fn=detect_page_api,
|
| 1579 |
+
inputs=[api_pdf_in, api_page_in, api_dpi_in, api_conf_in],
|
| 1580 |
+
outputs=[api_json_out],
|
| 1581 |
+
api_name="detect_page",
|
| 1582 |
+
)
|
| 1583 |
+
|
| 1584 |
gr.HTML(_CANVAS_JS)
|
| 1585 |
|
| 1586 |
if __name__ == "__main__":
|
|
@@ -1,11 +1,17 @@
|
|
| 1 |
"""
|
| 2 |
YOLO-based facade frame detector for Takeoff AI Annotator.
|
| 3 |
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
"""
|
| 10 |
|
| 11 |
from __future__ import annotations
|
|
@@ -16,20 +22,59 @@ from pathlib import Path
|
|
| 16 |
import cv2
|
| 17 |
import numpy as np
|
| 18 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
# ---------------------------------------------------------------------------
|
| 20 |
# Model loading
|
| 21 |
# ---------------------------------------------------------------------------
|
| 22 |
_MODEL = None
|
| 23 |
_MODEL_PATH: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
def _get_model():
|
| 27 |
global _MODEL, _MODEL_PATH
|
| 28 |
|
| 29 |
-
path =
|
| 30 |
-
"FACADE_MODEL_PATH",
|
| 31 |
-
str(Path(__file__).parent / "best.pt"),
|
| 32 |
-
)
|
| 33 |
|
| 34 |
if _MODEL is None or path != _MODEL_PATH:
|
| 35 |
try:
|
|
@@ -37,7 +82,7 @@ def _get_model():
|
|
| 37 |
|
| 38 |
_MODEL = YOLO(path)
|
| 39 |
_MODEL_PATH = path
|
| 40 |
-
print(f"[detection] Loaded YOLO model from: {path}")
|
| 41 |
except Exception as exc:
|
| 42 |
print(f"[detection] WARNING: Could not load YOLO model ({exc}). Falling back to OpenCV contour detection.")
|
| 43 |
_MODEL = None
|
|
@@ -85,15 +130,127 @@ def detect_rectangles(
|
|
| 85 |
|
| 86 |
Returns a list of dicts: {x1, y1, x2, y2, conf} in image pixel coordinates.
|
| 87 |
conf is the model confidence (0-1). OpenCV fallback sets conf=1.0.
|
|
|
|
|
|
|
|
|
|
| 88 |
"""
|
| 89 |
model = _get_model()
|
| 90 |
|
| 91 |
if model is not None:
|
|
|
|
|
|
|
|
|
|
| 92 |
return _yolo_detect(model, image, conf=conf, iou=iou)
|
| 93 |
|
| 94 |
return _cv_detect(image, min_area=min_area, max_area=max_area, epsilon_factor=epsilon_factor, threshold=threshold)
|
| 95 |
|
| 96 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
def _yolo_detect(
|
| 98 |
model,
|
| 99 |
image: np.ndarray,
|
|
@@ -105,7 +262,7 @@ def _yolo_detect(
|
|
| 105 |
conf=conf,
|
| 106 |
iou=iou,
|
| 107 |
imgsz=640,
|
| 108 |
-
device=
|
| 109 |
verbose=False,
|
| 110 |
)
|
| 111 |
boxes: list[dict] = []
|
|
|
|
| 1 |
"""
|
| 2 |
YOLO-based facade frame detector for Takeoff AI Annotator.
|
| 3 |
|
| 4 |
+
Model resolution order:
|
| 5 |
+
1. FACADE_MODEL_PATH — explicit local weights path.
|
| 6 |
+
2. FACADE_MODEL_REPO — HF Hub model repo (e.g. 'Infin8-AI/estimat8-vm-v1');
|
| 7 |
+
downloads 'best.pt' at FACADE_MODEL_REVISION (default 'main') using
|
| 8 |
+
HF_TOKEN for private repos. Pinning a revision is the rollback mechanism.
|
| 9 |
+
3. 'best.pt' next to this file.
|
| 10 |
+
|
| 11 |
+
Full drawing sheets must be detected with tiled inference (the model is
|
| 12 |
+
trained on 640x640 tiles; whole-sheet inference shrinks mullions below
|
| 13 |
+
detectable size). detect_rectangles() routes through detect_tiled()
|
| 14 |
+
automatically for images larger than one tile.
|
| 15 |
"""
|
| 16 |
|
| 17 |
from __future__ import annotations
|
|
|
|
| 22 |
import cv2
|
| 23 |
import numpy as np
|
| 24 |
|
| 25 |
+
# Tiling geometry — must match the training dataset builder.
|
| 26 |
+
TILE_SIZE = 640
|
| 27 |
+
TILE_STRIDE = 480
|
| 28 |
+
TILE_MERGE_IOU = 0.5
|
| 29 |
+
DETECTION_DPI = 200
|
| 30 |
+
|
| 31 |
# ---------------------------------------------------------------------------
|
| 32 |
# Model loading
|
| 33 |
# ---------------------------------------------------------------------------
|
| 34 |
_MODEL = None
|
| 35 |
_MODEL_PATH: str | None = None
|
| 36 |
+
_DEVICE: str | None = None
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _resolve_model_path() -> str:
|
| 40 |
+
explicit = os.environ.get("FACADE_MODEL_PATH")
|
| 41 |
+
if explicit:
|
| 42 |
+
return explicit
|
| 43 |
+
|
| 44 |
+
repo = os.environ.get("FACADE_MODEL_REPO")
|
| 45 |
+
if repo:
|
| 46 |
+
try:
|
| 47 |
+
from huggingface_hub import hf_hub_download
|
| 48 |
+
|
| 49 |
+
return hf_hub_download(
|
| 50 |
+
repo_id=repo,
|
| 51 |
+
filename="best.pt",
|
| 52 |
+
revision=os.environ.get("FACADE_MODEL_REVISION") or "main",
|
| 53 |
+
token=os.environ.get("HF_TOKEN") or None,
|
| 54 |
+
)
|
| 55 |
+
except Exception as exc:
|
| 56 |
+
print(f"[detection] WARNING: HF Hub download failed ({exc}). Trying local best.pt.")
|
| 57 |
+
|
| 58 |
+
return str(Path(__file__).parent / "best.pt")
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def get_device() -> str:
|
| 62 |
+
"""'cuda' when available (local training boxes), else 'cpu' (HF Space)."""
|
| 63 |
+
global _DEVICE
|
| 64 |
+
if _DEVICE is None:
|
| 65 |
+
try:
|
| 66 |
+
import torch
|
| 67 |
+
|
| 68 |
+
_DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
|
| 69 |
+
except Exception:
|
| 70 |
+
_DEVICE = "cpu"
|
| 71 |
+
return _DEVICE
|
| 72 |
|
| 73 |
|
| 74 |
def _get_model():
|
| 75 |
global _MODEL, _MODEL_PATH
|
| 76 |
|
| 77 |
+
path = _resolve_model_path()
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
if _MODEL is None or path != _MODEL_PATH:
|
| 80 |
try:
|
|
|
|
| 82 |
|
| 83 |
_MODEL = YOLO(path)
|
| 84 |
_MODEL_PATH = path
|
| 85 |
+
print(f"[detection] Loaded YOLO model from: {path} (device={get_device()})")
|
| 86 |
except Exception as exc:
|
| 87 |
print(f"[detection] WARNING: Could not load YOLO model ({exc}). Falling back to OpenCV contour detection.")
|
| 88 |
_MODEL = None
|
|
|
|
| 130 |
|
| 131 |
Returns a list of dicts: {x1, y1, x2, y2, conf} in image pixel coordinates.
|
| 132 |
conf is the model confidence (0-1). OpenCV fallback sets conf=1.0.
|
| 133 |
+
|
| 134 |
+
Images larger than one tile are detected via detect_tiled() — whole-sheet
|
| 135 |
+
single-pass inference finds nothing on full drawings.
|
| 136 |
"""
|
| 137 |
model = _get_model()
|
| 138 |
|
| 139 |
if model is not None:
|
| 140 |
+
h, w = image.shape[:2]
|
| 141 |
+
if max(h, w) > TILE_SIZE:
|
| 142 |
+
return detect_tiled(image, conf=conf, iou=iou)
|
| 143 |
return _yolo_detect(model, image, conf=conf, iou=iou)
|
| 144 |
|
| 145 |
return _cv_detect(image, min_area=min_area, max_area=max_area, epsilon_factor=epsilon_factor, threshold=threshold)
|
| 146 |
|
| 147 |
|
| 148 |
+
def detect_tiled(
|
| 149 |
+
image: np.ndarray,
|
| 150 |
+
conf: float = 0.25,
|
| 151 |
+
iou: float = 0.45,
|
| 152 |
+
tile: int = TILE_SIZE,
|
| 153 |
+
stride: int = TILE_STRIDE,
|
| 154 |
+
nms_iou: float = TILE_MERGE_IOU,
|
| 155 |
+
) -> list[dict]:
|
| 156 |
+
"""
|
| 157 |
+
Sliding-window YOLO detection for full drawing sheets.
|
| 158 |
+
|
| 159 |
+
Runs the model on tile x tile windows with the given stride (windows are
|
| 160 |
+
added at the right/bottom edges so the whole sheet is covered), offsets
|
| 161 |
+
each tile's boxes back into sheet coordinates, then merges duplicates from
|
| 162 |
+
overlapping tiles with confidence-sorted greedy NMS at nms_iou.
|
| 163 |
+
|
| 164 |
+
Returns boxes sorted top-to-bottom, left-to-right.
|
| 165 |
+
"""
|
| 166 |
+
model = _get_model()
|
| 167 |
+
if model is None:
|
| 168 |
+
return []
|
| 169 |
+
|
| 170 |
+
h, w = image.shape[:2]
|
| 171 |
+
ys = list(range(0, max(h - tile, 0) + 1, stride)) or [0]
|
| 172 |
+
xs = list(range(0, max(w - tile, 0) + 1, stride)) or [0]
|
| 173 |
+
if ys[-1] + tile < h:
|
| 174 |
+
ys.append(h - tile)
|
| 175 |
+
if xs[-1] + tile < w:
|
| 176 |
+
xs.append(w - tile)
|
| 177 |
+
|
| 178 |
+
boxes: list[dict] = []
|
| 179 |
+
for oy in ys:
|
| 180 |
+
for ox in xs:
|
| 181 |
+
window = image[oy : oy + tile, ox : ox + tile]
|
| 182 |
+
for b in _yolo_detect(model, window, conf=conf, iou=iou):
|
| 183 |
+
boxes.append(
|
| 184 |
+
{
|
| 185 |
+
"x1": b["x1"] + ox,
|
| 186 |
+
"y1": b["y1"] + oy,
|
| 187 |
+
"x2": b["x2"] + ox,
|
| 188 |
+
"y2": b["y2"] + oy,
|
| 189 |
+
"conf": b["conf"],
|
| 190 |
+
}
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
boxes.sort(key=lambda b: -b["conf"])
|
| 194 |
+
kept: list[dict] = []
|
| 195 |
+
for b in boxes:
|
| 196 |
+
if all(_box_iou(b, k) < nms_iou for k in kept):
|
| 197 |
+
kept.append(b)
|
| 198 |
+
|
| 199 |
+
kept.sort(key=lambda b: (round(b["y1"], 2), round(b["x1"], 2)))
|
| 200 |
+
return kept
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
def _box_iou(a: dict, b: dict) -> float:
|
| 204 |
+
ix1, iy1 = max(a["x1"], b["x1"]), max(a["y1"], b["y1"])
|
| 205 |
+
ix2, iy2 = min(a["x2"], b["x2"]), min(a["y2"], b["y2"])
|
| 206 |
+
inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
|
| 207 |
+
if inter == 0:
|
| 208 |
+
return 0.0
|
| 209 |
+
area_a = (a["x2"] - a["x1"]) * (a["y2"] - a["y1"])
|
| 210 |
+
area_b = (b["x2"] - b["x1"]) * (b["y2"] - b["y1"])
|
| 211 |
+
return inter / (area_a + area_b - inter)
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
# ---------------------------------------------------------------------------
|
| 215 |
+
# PDF rendering + coordinate conversion
|
| 216 |
+
# ---------------------------------------------------------------------------
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def render_pdf_page(pdf_path: str, page_number: int, dpi: int = DETECTION_DPI) -> np.ndarray | None:
|
| 220 |
+
"""
|
| 221 |
+
Render one PDF page (1-indexed) to an RGB numpy array at the given DPI.
|
| 222 |
+
|
| 223 |
+
Opens the document per call and releases it immediately — safe for very
|
| 224 |
+
large drawing sets (183 MB / 53 pages) because only one page's pixmap is
|
| 225 |
+
ever held in memory.
|
| 226 |
+
"""
|
| 227 |
+
try:
|
| 228 |
+
import fitz # PyMuPDF
|
| 229 |
+
|
| 230 |
+
doc = fitz.open(pdf_path)
|
| 231 |
+
try:
|
| 232 |
+
idx = max(0, min(page_number - 1, len(doc) - 1))
|
| 233 |
+
page = doc[idx]
|
| 234 |
+
mat = fitz.Matrix(dpi / 72, dpi / 72)
|
| 235 |
+
pix = page.get_pixmap(matrix=mat, colorspace=fitz.csRGB)
|
| 236 |
+
return np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width, 3)
|
| 237 |
+
finally:
|
| 238 |
+
doc.close()
|
| 239 |
+
except Exception as exc:
|
| 240 |
+
print(f"[detection] PDF render error: {exc}")
|
| 241 |
+
return None
|
| 242 |
+
|
| 243 |
+
|
| 244 |
+
def px_to_pt(value: float, dpi: int) -> float:
|
| 245 |
+
"""Render-pixel -> PDF-point (canonical ML label space)."""
|
| 246 |
+
return value * 72.0 / dpi
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def pt_to_px(value: float, dpi: int) -> float:
|
| 250 |
+
"""PDF-point -> render-pixel at the given DPI."""
|
| 251 |
+
return value * dpi / 72.0
|
| 252 |
+
|
| 253 |
+
|
| 254 |
def _yolo_detect(
|
| 255 |
model,
|
| 256 |
image: np.ndarray,
|
|
|
|
| 262 |
conf=conf,
|
| 263 |
iou=iou,
|
| 264 |
imgsz=640,
|
| 265 |
+
device=get_device(),
|
| 266 |
verbose=False,
|
| 267 |
)
|
| 268 |
boxes: list[dict] = []
|
|
@@ -7,3 +7,4 @@ pymupdf>=1.23.0
|
|
| 7 |
ultralytics>=8.3.0
|
| 8 |
plotly>=5.18.0
|
| 9 |
pandas>=2.0.0
|
|
|
|
|
|
| 7 |
ultralytics>=8.3.0
|
| 8 |
plotly>=5.18.0
|
| 9 |
pandas>=2.0.0
|
| 10 |
+
huggingface_hub>=0.26.0
|