MathewKasbarian commited on
Commit
c640d68
·
1 Parent(s): 43ad530

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.

Files changed (4) hide show
  1. CLAUDE.md +37 -2
  2. app.py +236 -9
  3. detection.py +168 -11
  4. requirements.txt +1 -0
CLAUDE.md CHANGED
@@ -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:
app.py CHANGED
@@ -12,7 +12,15 @@ import cv2
12
  import gradio as gr
13
  import numpy as np
14
  import requests
15
- from detection import detect_rectangles, draw_detections, get_model_version
 
 
 
 
 
 
 
 
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=150)
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
- boxes = detect_rectangles(img_bgr, conf=float(rv_conf))
 
 
 
 
 
 
 
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 = 72 # lower DPI = smaller images = much faster inference per page
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__":
detection.py CHANGED
@@ -1,11 +1,17 @@
1
  """
2
  YOLO-based facade frame detector for Takeoff AI Annotator.
3
 
4
- The model is loaded once at startup from the path set by the FACADE_MODEL_PATH
5
- environment variable (defaults to 'best.pt' in the same directory as this
6
- file). Pass a different path at launch time:
7
-
8
- FACADE_MODEL_PATH=/path/to/best.pt python app.py
 
 
 
 
 
 
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 = os.environ.get(
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="cpu",
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] = []
requirements.txt CHANGED
@@ -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