MathewKasbarian Claude Sonnet 4.6 commited on
Commit
470f937
·
0 Parent(s):

feat(ai): migrate annotator tool and training scripts into infra repo

Browse files

- tools/annotator/ — Gradio annotation UI (app.py, detection.py,
requirements.txt, packages.txt) previously hosted as a standalone
HF Space repo. Deploy to Infin8-AI/Annotation via git subtree push.
- scripts/build_dataset_from_events.py — build YOLO dataset from events.jsonl
- scripts/generate_training_data.py — auto-label PDFs via vector grid detection
- scripts/train_facade_detector.py — train YOLO11n with CAD-optimised params
- pyproject.toml — add per-file-ignores for tools/ and training scripts

The from-annotation backend endpoint is already present in
apps/tenant-core-api-lambda/src/features/frame_instances/router.py.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

Files changed (6) hide show
  1. CLAUDE.md +34 -0
  2. README.md +78 -0
  3. app.py +1360 -0
  4. detection.py +187 -0
  5. packages.txt +1 -0
  6. requirements.txt +9 -0
CLAUDE.md ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Annotator Tool
2
+
3
+ Gradio annotation tool for facade frame detection. Deployed to HF Spaces as `Infin8-AI/Annotation`.
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:
16
+
17
+ ```bash
18
+ # Add remote once
19
+ git remote add hf https://YOUR_HF_TOKEN@huggingface.co/spaces/Infin8-AI/Annotation
20
+
21
+ # Push this subdirectory as the Space root
22
+ git subtree push --prefix=tools/annotator hf main
23
+ ```
24
+
25
+ ## Training loop
26
+
27
+ See `scripts/build_dataset_from_events.py` and `scripts/train_facade_detector.py` at the repo root.
28
+
29
+ ```bash
30
+ # From repo root — after exporting events.jsonl from the Gradio tool
31
+ python scripts/build_dataset_from_events.py --events events.jsonl --pdf source.pdf
32
+ python scripts/train_facade_detector.py --version v2
33
+ cp models/yolo_facade_v2/best.pt tools/annotator/best.pt
34
+ ```
README.md ADDED
@@ -0,0 +1,78 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Takeoff AI Annotator
2
+
3
+ A self-improving annotation tool for detecting facade frame (curtain wall panel) units on architectural construction drawings. Every human correction becomes a training signal that feeds back into the YOLO model over time.
4
+
5
+ Deployed as a Hugging Face Space: `Infin8-AI/Annotation`
6
+
7
+ ## Tabs
8
+
9
+ ### Detect
10
+ Upload a construction plan image (PNG/JPG) or a PDF page and click **Detect Panels**. The detected bounding boxes are drawn on the image and a JSON payload is shown for review.
11
+
12
+ ### Review & Edit
13
+ Upload a PDF and use the HTML5 canvas to inspect detections page by page:
14
+
15
+ | Action | Effect | Training signal logged |
16
+ |--------|--------|----------------------|
17
+ | **Detect This Page** | Run YOLO/OpenCV on the current page | — |
18
+ | **Approve All** | Mark all boxes green | N × `accepted` events |
19
+ | **Reject All** | Mark all boxes red | N × `rejected` events |
20
+ | **Approve Selected** | Click a table row, then approve | 1 × `accepted` |
21
+ | **Delete Selected** | Remove a single box | 1 × `rejected` |
22
+ | **Add Box Manually** | Draw a new ground-truth box | 1 × `created` |
23
+ | **Export Events (JSON)** | Download `events.jsonl` with full before/after coords | — |
24
+ | **Export Dataset (YOLO)** | Download a zip of 640×640 tiles + YOLO labels | — |
25
+
26
+ Box colours: orange = pending, green = approved, red = rejected.
27
+
28
+ ### Send to Backend
29
+ POST approved detections to a running tenant-core-api instance using the `from-annotation` endpoint.
30
+
31
+ Endpoint: `POST /api/v1/tenants/documents/{document_id}/frame-instances/from-annotation`
32
+
33
+ ## Continuous learning loop
34
+
35
+ ```
36
+ Gradio Review UI
37
+ ↓ every approve / reject / add / delete
38
+ events.jsonl (exported from the Review tab)
39
+ ↓
40
+ python scripts/build_dataset_from_events.py --events events.jsonl --pdf source.pdf
41
+ ↓
42
+ python scripts/train_facade_detector.py --version v2
43
+ ↓
44
+ cp models/yolo_facade_v2/best.pt tools/annotator/best.pt
45
+ ↓
46
+ Restart Gradio — model version shown in header
47
+ ```
48
+
49
+ ## Detection engine
50
+
51
+ The app loads `best.pt` (YOLO11n weights) from the same directory at startup. If not found, it falls back to OpenCV contour detection automatically. The status bar shows which engine is active.
52
+
53
+ To point to a custom model path:
54
+ ```bash
55
+ FACADE_MODEL_PATH=/path/to/best.pt python app.py
56
+ ```
57
+
58
+ ## Local development
59
+
60
+ ```bash
61
+ cd tools/annotator
62
+ pip install -r requirements.txt
63
+ python app.py
64
+ ```
65
+
66
+ PDF rendering uses PyMuPDF — no Poppler required locally (packages.txt installs it on HF Spaces / Linux).
67
+
68
+ ## Deploying to HF Spaces
69
+
70
+ The Space (`Infin8-AI/Annotation`) expects the root of the repo to contain `app.py`. Use git subtree to push just this subdirectory:
71
+
72
+ ```bash
73
+ # First time — add the remote
74
+ git remote add hf https://YOUR_HF_TOKEN@huggingface.co/spaces/Infin8-AI/Annotation
75
+
76
+ # Push (maps this subdirectory to the Space root)
77
+ git subtree push --prefix=tools/annotator hf main
78
+ ```
app.py ADDED
@@ -0,0 +1,1360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import hashlib
3
+ import json
4
+ import tempfile
5
+ import uuid
6
+ import zipfile
7
+ from datetime import UTC, datetime
8
+ from io import BytesIO
9
+ from pathlib import Path
10
+
11
+ 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
19
+ try:
20
+ import gradio_client.utils as _gcu
21
+
22
+ _orig_get_type = _gcu.get_type
23
+
24
+ def _safe_get_type(schema):
25
+ if not isinstance(schema, dict):
26
+ return "Any"
27
+ return _orig_get_type(schema)
28
+
29
+ _gcu.get_type = _safe_get_type
30
+ _orig_j2p = _gcu._json_schema_to_python_type
31
+
32
+ def _safe_j2p(schema, defs=None):
33
+ if not isinstance(schema, dict):
34
+ return "Any"
35
+ try:
36
+ return _orig_j2p(schema, defs)
37
+ except (TypeError, AttributeError):
38
+ return "Any"
39
+
40
+ _gcu._json_schema_to_python_type = _safe_j2p
41
+ _orig_j2p_pub = _gcu.json_schema_to_python_type
42
+
43
+ def _safe_j2p_pub(schema):
44
+ try:
45
+ return _orig_j2p_pub(schema)
46
+ except (TypeError, AttributeError):
47
+ return "Any"
48
+
49
+ _gcu.json_schema_to_python_type = _safe_j2p_pub
50
+ except Exception:
51
+ pass
52
+
53
+ try:
54
+ import fitz
55
+
56
+ PDF_SUPPORT = True
57
+ except ImportError:
58
+ PDF_SUPPORT = False
59
+
60
+ try:
61
+ import pandas as pd
62
+
63
+ PANDAS_SUPPORT = True
64
+ except ImportError:
65
+ PANDAS_SUPPORT = False
66
+
67
+
68
+ def _pil_to_bgr(pil_img):
69
+ return cv2.cvtColor(np.array(pil_img.convert("RGB")), cv2.COLOR_RGB2BGR)
70
+
71
+
72
+ def _render_pdf_page(pdf_path, page_number, dpi=100):
73
+ if not PDF_SUPPORT:
74
+ return None
75
+ try:
76
+ doc = fitz.open(pdf_path)
77
+ idx = max(0, min(page_number - 1, len(doc) - 1))
78
+ page = doc[idx]
79
+ mat = fitz.Matrix(dpi / 72, dpi / 72)
80
+ pix = page.get_pixmap(matrix=mat, colorspace=fitz.csRGB)
81
+ return np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width, 3)
82
+ except Exception as exc:
83
+ print(f"[app] PDF render error: {exc}")
84
+ return None
85
+
86
+
87
+ def _count_pdf_pages(pdf_path):
88
+ if not PDF_SUPPORT:
89
+ return 1
90
+ try:
91
+ return len(fitz.open(pdf_path))
92
+ except Exception:
93
+ return 1
94
+
95
+
96
+ def _load_image_for_detect(image_path, pdf_path, pdf_page):
97
+ if image_path:
98
+ img = cv2.imread(image_path)
99
+ if img is None:
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
+
107
+
108
+ # ---------------------------------------------------------------------------
109
+ # Canvas helpers — HTML5 canvas replaces Plotly for the Review & Edit tab
110
+ # ---------------------------------------------------------------------------
111
+ _CANVAS_MAX_PX = 1400 # max dimension sent to canvas (keeps base64 small)
112
+
113
+
114
+ def _build_canvas_json(img_rgb, annotations, selected=-1):
115
+ """
116
+ Return JSON string sent to the HTML canvas via rv_canvas_render_txt.
117
+ Pass img_rgb=None for annotations-only updates (JS keeps its cached image).
118
+ Annotation coords are always in ORIGINAL image pixels; JS scales for display.
119
+ """
120
+ anns_data = [
121
+ {
122
+ "x1": float(a["x1"]),
123
+ "y1": float(a["y1"]),
124
+ "x2": float(a["x2"]),
125
+ "y2": float(a["y2"]),
126
+ "conf": float(a.get("conf", 1.0)),
127
+ "status": a.get("status", "pending"),
128
+ "source": a.get("source", "unknown"),
129
+ }
130
+ for a in annotations
131
+ ]
132
+ if img_rgb is None:
133
+ return json.dumps(
134
+ {
135
+ "image": None,
136
+ "imageW": 0,
137
+ "imageH": 0,
138
+ "annotations": anns_data,
139
+ "selected": selected,
140
+ }
141
+ )
142
+ H, W = img_rgb.shape[:2]
143
+ # Scale down for transfer — keep annotation coords in original pixels
144
+ scale = min(1.0, _CANVAS_MAX_PX / max(W, H))
145
+ if scale < 1.0:
146
+ nw, nh = int(W * scale), int(H * scale)
147
+ pil = Image.fromarray(img_rgb).resize((nw, nh), Image.LANCZOS)
148
+ else:
149
+ pil = Image.fromarray(img_rgb)
150
+ buf = BytesIO()
151
+ pil.save(buf, format="JPEG", quality=80)
152
+ b64 = base64.b64encode(buf.getvalue()).decode()
153
+ return json.dumps(
154
+ {
155
+ "image": f"data:image/jpeg;base64,{b64}",
156
+ "imageW": W,
157
+ "imageH": H, # original dimensions so JS scales coords correctly
158
+ "annotations": anns_data,
159
+ "selected": selected,
160
+ }
161
+ )
162
+
163
+
164
+ def _build_review_df(annotations):
165
+ if not PANDAS_SUPPORT or not annotations:
166
+ return []
167
+ return pd.DataFrame(
168
+ [
169
+ {
170
+ "#": i + 1,
171
+ "x1": round(ann["x1"]),
172
+ "y1": round(ann["y1"]),
173
+ "x2": round(ann["x2"]),
174
+ "y2": round(ann["y2"]),
175
+ "conf": f"{ann.get('conf', 1.0):.2f}",
176
+ "status": ann.get("status", "pending"),
177
+ }
178
+ for i, ann in enumerate(annotations)
179
+ ]
180
+ )
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # Ground Truth Ledger helpers
185
+ # ---------------------------------------------------------------------------
186
+ def _now():
187
+ return datetime.now(UTC).isoformat()
188
+
189
+
190
+ def _make_box(ann):
191
+ return {"x1": ann["x1"], "y1": ann["y1"], "x2": ann["x2"], "y2": ann["y2"], "conf": ann.get("conf", 1.0)}
192
+
193
+
194
+ def _ledger_summary(ledger):
195
+ counts = {"accepted": 0, "rejected": 0, "modified": 0, "created": 0}
196
+ for e in ledger:
197
+ t = e.get("event_type", "")
198
+ if t in counts:
199
+ counts[t] += 1
200
+ return (
201
+ f"{len(ledger)} events — "
202
+ f"{counts['accepted']} accepted, {counts['rejected']} rejected, "
203
+ f"{counts['modified']} modified, {counts['created']} created"
204
+ )
205
+
206
+
207
+ def _append_events(ledger, events):
208
+ return list(ledger) + events
209
+
210
+
211
+ def _make_event(page, ann, event_type, before=None, after=None):
212
+ return {
213
+ "event_id": str(uuid.uuid4()),
214
+ "timestamp": _now(),
215
+ "page_number": page,
216
+ "annotation_id": ann.get("id", -1),
217
+ "source": ann.get("source", "unknown"),
218
+ "model_version": get_model_version(),
219
+ "event_type": event_type,
220
+ "before": before,
221
+ "after": after,
222
+ }
223
+
224
+
225
+ # ---------------------------------------------------------------------------
226
+ # Export helpers
227
+ # ---------------------------------------------------------------------------
228
+ def _image_hash(img_rgb):
229
+ return hashlib.sha256(img_rgb.tobytes()).hexdigest()[:16]
230
+
231
+
232
+ def export_events_jsonl(ledger):
233
+ if not ledger:
234
+ return None
235
+ tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".jsonl", delete=False, encoding="utf-8")
236
+ for event in ledger:
237
+ tmp.write(json.dumps(event) + "\n")
238
+ tmp.close()
239
+ return tmp.name
240
+
241
+
242
+ def export_yolo_dataset_zip(ledger, rv_pdf):
243
+ if not ledger or not rv_pdf or not PDF_SUPPORT:
244
+ return None
245
+ by_page = {}
246
+ for ev in ledger:
247
+ pg = ev.get("page_number", 1)
248
+ by_page.setdefault(pg, []).append(ev)
249
+ tile_size = 640
250
+ tmp_dir = Path(tempfile.mkdtemp())
251
+ img_dir = tmp_dir / "images"
252
+ lbl_dir = tmp_dir / "labels"
253
+ img_dir.mkdir()
254
+ lbl_dir.mkdir()
255
+ written = 0
256
+ for page_num, events in by_page.items():
257
+ img_rgb = _render_pdf_page(rv_pdf, page_num, dpi=100)
258
+ if img_rgb is None:
259
+ continue
260
+ H, W = img_rgb.shape[:2]
261
+ img_hash = _image_hash(img_rgb)
262
+ for ev in events:
263
+ ev_type = ev.get("event_type", "")
264
+ box = ev.get("after") or ev.get("before")
265
+ if box is None:
266
+ continue
267
+ cx = (box["x1"] + box["x2"]) / 2
268
+ cy = (box["y1"] + box["y2"]) / 2
269
+ tx = max(0, min(int(cx - tile_size / 2), W - tile_size))
270
+ ty = max(0, min(int(cy - tile_size / 2), H - tile_size))
271
+ tx2 = min(tx + tile_size, W)
272
+ ty2 = min(ty + tile_size, H)
273
+ crop = img_rgb[ty:ty2, tx:tx2]
274
+ pad_h = tile_size - crop.shape[0]
275
+ pad_w = tile_size - crop.shape[1]
276
+ if pad_h > 0 or pad_w > 0:
277
+ crop = np.pad(crop, ((0, pad_h), (0, pad_w), (0, 0)), mode="constant", constant_values=255)
278
+ stem = f"p{page_num}_{img_hash}_{ev['event_id'][:8]}"
279
+ Image.fromarray(crop).save(img_dir / f"{stem}.jpg", quality=90)
280
+ lbl_path = lbl_dir / f"{stem}.txt"
281
+ if ev_type in ("accepted", "created", "modified"):
282
+ bx1 = max(box["x1"] - tx, 0)
283
+ by1 = max(box["y1"] - ty, 0)
284
+ bx2 = min(box["x2"] - tx, tile_size)
285
+ by2 = min(box["y2"] - ty, tile_size)
286
+ bw = bx2 - bx1
287
+ bh = by2 - by1
288
+ if bw > 0 and bh > 0:
289
+ bcx = (bx1 + bx2) / 2 / tile_size
290
+ bcy = (by1 + by2) / 2 / tile_size
291
+ lbl_path.write_text(f"0 {bcx:.6f} {bcy:.6f} {bw / tile_size:.6f} {bh / tile_size:.6f}\n")
292
+ else:
293
+ lbl_path.write_text("")
294
+ else:
295
+ lbl_path.write_text("")
296
+ written += 1
297
+ if written == 0:
298
+ return None
299
+ (tmp_dir / "data.yaml").write_text("path: .\ntrain: images\nval: images\nnc: 1\nnames: ['facade_frame']\n")
300
+ zip_path = tmp_dir.parent / "facade_annotations.zip"
301
+ with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
302
+ for f in tmp_dir.rglob("*"):
303
+ if f.is_file():
304
+ zf.write(f, f.relative_to(tmp_dir))
305
+ return str(zip_path)
306
+
307
+
308
+ # ---------------------------------------------------------------------------
309
+ # Detect tab handlers
310
+ # ---------------------------------------------------------------------------
311
+ def run_detection(
312
+ image_path, pdf_file, pdf_page, page_number, conf_threshold, min_area, max_area, epsilon_factor, threshold
313
+ ):
314
+ pdf_path = (pdf_file if isinstance(pdf_file, str) else pdf_file.name) if pdf_file is not None else None
315
+ img = _load_image_for_detect(image_path, pdf_path, int(pdf_page))
316
+ if img is None:
317
+ return None, {}, [], "No image provided."
318
+ from detection import _get_model
319
+
320
+ using_yolo = _get_model() is not None
321
+ boxes = detect_rectangles(
322
+ img,
323
+ conf=float(conf_threshold),
324
+ min_area=int(min_area),
325
+ max_area=int(max_area),
326
+ epsilon_factor=float(epsilon_factor),
327
+ threshold=int(threshold),
328
+ )
329
+ annotated_rgb = cv2.cvtColor(draw_detections(img, boxes), cv2.COLOR_BGR2RGB)
330
+ shapes = [
331
+ {"page_number": int(page_number), "x1": b["x1"], "y1": b["y1"], "x2": b["x2"], "y2": b["y2"], "conf": b["conf"]}
332
+ for b in boxes
333
+ ]
334
+ payload = {"frame_template_id": 1, "shapes": shapes, "color": None, "quantity_multiplier": 1}
335
+ engine = "YOLO" if using_yolo else "OpenCV (YOLO model not found)"
336
+ return annotated_rgb, payload, shapes, f"[{engine}] Detected {len(boxes)} panel(s)."
337
+
338
+
339
+ # ---------------------------------------------------------------------------
340
+ # Send to Backend handler
341
+ # ---------------------------------------------------------------------------
342
+ def send_to_backend(
343
+ rv_annotations, backend_url, document_id, frame_template_id, x_tenant_id, x_user_id, color, quantity_multiplier
344
+ ):
345
+ shapes = []
346
+ for page_num, anns in sorted((rv_annotations or {}).items()):
347
+ for ann in anns:
348
+ if ann.get("status") == "approved":
349
+ shapes.append(
350
+ {
351
+ "page_number": page_num,
352
+ "x1": ann["x1"],
353
+ "y1": ann["y1"],
354
+ "x2": ann["x2"],
355
+ "y2": ann["y2"],
356
+ }
357
+ )
358
+ if not shapes:
359
+ return {"error": "No approved annotations. Go to Review & Edit, detect + approve boxes first."}
360
+ if not backend_url:
361
+ return {"error": "Backend URL is required."}
362
+ url = f"{backend_url.rstrip('/')}/api/v1/tenants/documents/{int(document_id)}/frame-instances/from-annotation"
363
+ payload = {
364
+ "frame_template_id": int(frame_template_id),
365
+ "shapes": shapes,
366
+ "quantity_multiplier": int(quantity_multiplier),
367
+ }
368
+ if color and color.strip():
369
+ payload["color"] = color.strip()
370
+ headers = {
371
+ "Content-Type": "application/json",
372
+ "x-tenant-id": str(int(x_tenant_id)),
373
+ "x-user-id": str(int(x_user_id)),
374
+ }
375
+ try:
376
+ resp = requests.post(url, json=payload, headers=headers, timeout=15)
377
+ try:
378
+ body = resp.json()
379
+ except Exception:
380
+ body = resp.text
381
+ return {"status_code": resp.status_code, "response": body}
382
+ except requests.exceptions.RequestException as exc:
383
+ return {"error": str(exc)}
384
+
385
+
386
+ # ---------------------------------------------------------------------------
387
+ # Review & Edit tab handlers
388
+ # ---------------------------------------------------------------------------
389
+ def on_rv_pdf_upload(pdf_file):
390
+ if pdf_file is None or not PDF_SUPPORT:
391
+ return (_build_canvas_json(None, []), _build_review_df([]), "No PDF loaded", None, 1, 1, {}, [])
392
+ pdf_path = pdf_file if isinstance(pdf_file, str) else pdf_file.name
393
+ total = _count_pdf_pages(pdf_path)
394
+ img_rgb = _render_pdf_page(pdf_path, 1, dpi=100)
395
+ return (_build_canvas_json(img_rgb, []), _build_review_df([]), f"Page 1 / {total}", pdf_path, 1, total, {}, [])
396
+
397
+
398
+ def _navigate(rv_pdf, rv_page, rv_total, rv_annotations, delta):
399
+ if rv_pdf is None:
400
+ return _build_canvas_json(None, []), _build_review_df([]), "No PDF loaded", rv_page
401
+ new_page = max(1, min(rv_total, rv_page + delta))
402
+ anns = rv_annotations.get(new_page, [])
403
+ img_rgb = _render_pdf_page(rv_pdf, new_page, dpi=100)
404
+ return (
405
+ _build_canvas_json(img_rgb, anns),
406
+ _build_review_df(anns),
407
+ f"Page {new_page} / {rv_total} — {len(anns)} box(es)",
408
+ new_page,
409
+ )
410
+
411
+
412
+ def on_rv_prev(rv_pdf, rv_page, rv_total, rv_annotations):
413
+ return _navigate(rv_pdf, rv_page, rv_total, rv_annotations, -1)
414
+
415
+
416
+ def on_rv_next(rv_pdf, rv_page, rv_total, rv_annotations):
417
+ return _navigate(rv_pdf, rv_page, rv_total, rv_annotations, +1)
418
+
419
+
420
+ def on_rv_goto_page(page_str, rv_pdf, rv_total, rv_annotations):
421
+ try:
422
+ page_num = max(1, min(int(rv_total), int(str(page_str).strip())))
423
+ except (ValueError, TypeError):
424
+ return gr.update(), gr.update(), gr.update(), gr.update()
425
+ if rv_pdf is None:
426
+ return _build_canvas_json(None, []), _build_review_df([]), "No PDF loaded", 1
427
+ anns = rv_annotations.get(page_num, [])
428
+ img_rgb = _render_pdf_page(rv_pdf, page_num, dpi=100)
429
+ return (
430
+ _build_canvas_json(img_rgb, anns),
431
+ _build_review_df(anns),
432
+ f"Page {page_num} / {rv_total} — {len(anns)} box(es)",
433
+ page_num,
434
+ )
435
+
436
+
437
+ def on_canvas_events(events_json, rv_pdf, rv_page, rv_annotations, rv_ledger):
438
+ """Process annotation changes sent from the JS canvas."""
439
+ if not events_json or not events_json.strip():
440
+ return gr.update(), rv_annotations, rv_ledger, gr.update(), -1
441
+ try:
442
+ data = json.loads(events_json)
443
+ except Exception:
444
+ return gr.update(), rv_annotations, rv_ledger, gr.update(), -1
445
+
446
+ raw_anns = data.get("annotations", [])
447
+ selected = int(data.get("selected", -1))
448
+ action = data.get("action", "sync")
449
+
450
+ old_anns = list(rv_annotations.get(rv_page, []))
451
+
452
+ new_anns = []
453
+ for i, a in enumerate(raw_anns):
454
+ new_anns.append(
455
+ {
456
+ "id": i,
457
+ "x1": float(a["x1"]),
458
+ "y1": float(a["y1"]),
459
+ "x2": float(a["x2"]),
460
+ "y2": float(a["y2"]),
461
+ "conf": float(a.get("conf", 1.0)),
462
+ "status": a.get("status", "pending"),
463
+ "source": a.get("source", "human_added"),
464
+ }
465
+ )
466
+
467
+ rv_annotations = dict(rv_annotations)
468
+ rv_annotations[rv_page] = new_anns
469
+
470
+ events = []
471
+ if action == "created" and new_anns:
472
+ ann = new_anns[-1]
473
+ events.append(_make_event(rv_page, ann, "created", before=None, after=_make_box(ann)))
474
+ elif action in ("moved", "resized") and 0 <= selected < len(new_anns):
475
+ if selected < len(old_anns):
476
+ events.append(
477
+ _make_event(
478
+ rv_page,
479
+ new_anns[selected],
480
+ "modified",
481
+ before=_make_box(old_anns[selected]),
482
+ after=_make_box(new_anns[selected]),
483
+ )
484
+ )
485
+ rv_ledger = _append_events(rv_ledger, events)
486
+
487
+ canvas_json = _build_canvas_json(None, new_anns, selected=selected)
488
+ return canvas_json, rv_annotations, rv_ledger, _build_review_df(new_anns), selected
489
+
490
+
491
+ def on_rv_detect(rv_pdf, rv_page, rv_annotations, rv_conf):
492
+ if rv_pdf is None:
493
+ return _build_canvas_json(None, []), _build_review_df([]), rv_annotations, "No PDF loaded."
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",
512
+ }
513
+ for i, b in enumerate(boxes)
514
+ ]
515
+ rv_annotations = dict(rv_annotations)
516
+ rv_annotations[rv_page] = annotations
517
+ engine = "YOLO" if using_yolo else "OpenCV"
518
+ return (
519
+ _build_canvas_json(img_rgb, annotations),
520
+ _build_review_df(annotations),
521
+ rv_annotations,
522
+ f"[{engine}] Found {len(boxes)} panel(s) on page {rv_page}.",
523
+ )
524
+
525
+
526
+ def on_rv_detect_all(rv_pdf, rv_page, rv_total, rv_annotations, rv_conf):
527
+ if rv_pdf is None:
528
+ return _build_canvas_json(None, []), _build_review_df([]), rv_annotations, "No PDF loaded."
529
+ if not PDF_SUPPORT:
530
+ return _build_canvas_json(None, []), _build_review_df([]), rv_annotations, "PDF support unavailable."
531
+ from detection import _get_model
532
+
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)
540
+ for page_num in range(1, rv_total + 1):
541
+ try:
542
+ page = doc[page_num - 1]
543
+ pix = page.get_pixmap(matrix=mat, colorspace=fitz.csRGB)
544
+ img_rgb = np.frombuffer(pix.samples, dtype=np.uint8).reshape(pix.height, pix.width, 3)
545
+ except Exception:
546
+ continue
547
+ img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
548
+ boxes = detect_rectangles(img_bgr, conf=float(rv_conf))
549
+ # Scale coords back to 100-DPI space so they match the display image
550
+ scale = 100.0 / detect_dpi
551
+ all_anns[page_num] = [
552
+ {
553
+ "id": i,
554
+ "x1": b["x1"] * scale,
555
+ "y1": b["y1"] * scale,
556
+ "x2": b["x2"] * scale,
557
+ "y2": b["y2"] * scale,
558
+ "conf": b["conf"],
559
+ "status": "pending",
560
+ "source": "yolo" if using_yolo else "opencv",
561
+ }
562
+ for i, b in enumerate(boxes)
563
+ ]
564
+ total_found += len(boxes)
565
+ doc.close()
566
+ except Exception as exc:
567
+ return _build_canvas_json(None, []), _build_review_df([]), rv_annotations, f"Error: {exc}"
568
+ img_rgb = _render_pdf_page(rv_pdf, rv_page, dpi=100)
569
+ anns = all_anns.get(rv_page, [])
570
+ engine = "YOLO" if using_yolo else "OpenCV"
571
+ return (
572
+ _build_canvas_json(img_rgb, anns),
573
+ _build_review_df(anns),
574
+ all_anns,
575
+ f"[{engine}] Detected {total_found} panel(s) across {rv_total} page(s).",
576
+ )
577
+
578
+
579
+ def on_fill_edit_form(selected_idx, rv_page, rv_annotations):
580
+ anns = rv_annotations.get(rv_page, [])
581
+ idx = int(selected_idx) if selected_idx is not None else -1
582
+ if idx < 0 or idx >= len(anns):
583
+ return None, None, None, None
584
+ a = anns[idx]
585
+ return a["x1"], a["y1"], a["x2"], a["y2"]
586
+
587
+
588
+ def on_rv_update_box(rv_pdf, rv_page, rv_annotations, ledger, selected_idx, x1, y1, x2, y2):
589
+ anns = rv_annotations.get(rv_page, [])
590
+ if rv_pdf is None or not anns or selected_idx is None or int(selected_idx) < 0:
591
+ return gr.update(), _build_review_df(anns), rv_annotations, ledger, "Select a box first."
592
+ idx = int(selected_idx)
593
+ if idx >= len(anns):
594
+ return gr.update(), _build_review_df(anns), rv_annotations, ledger, "Invalid selection."
595
+ try:
596
+ x1, y1, x2, y2 = float(x1), float(y1), float(x2), float(y2)
597
+ except (TypeError, ValueError):
598
+ return gr.update(), _build_review_df(anns), rv_annotations, ledger, "Invalid coordinates."
599
+ if x2 <= x1 or y2 <= y1:
600
+ return gr.update(), _build_review_df(anns), rv_annotations, ledger, "x2>x1 and y2>y1 required."
601
+ rv_annotations = dict(rv_annotations)
602
+ new_anns = list(anns)
603
+ old = new_anns[idx]
604
+ before = _make_box(old)
605
+ new_anns[idx] = dict(old, x1=x1, y1=y1, x2=x2, y2=y2)
606
+ rv_annotations[rv_page] = new_anns
607
+ event = _make_event(rv_page, old, "modified", before=before, after=_make_box(new_anns[idx]))
608
+ ledger = _append_events(ledger, [event])
609
+ img_rgb = _render_pdf_page(rv_pdf, rv_page, dpi=100)
610
+ return (
611
+ _build_canvas_json(img_rgb, new_anns, selected=idx),
612
+ _build_review_df(new_anns),
613
+ rv_annotations,
614
+ ledger,
615
+ f"Updated box #{idx + 1}. {_ledger_summary(ledger)}",
616
+ )
617
+
618
+
619
+ def on_rv_approve_all(rv_pdf, rv_page, rv_annotations, ledger):
620
+ if rv_pdf is None or not rv_annotations.get(rv_page):
621
+ return gr.update(), _build_review_df([]), rv_annotations, ledger, "No boxes on this page."
622
+ rv_annotations = dict(rv_annotations)
623
+ old_anns = rv_annotations[rv_page]
624
+ new_anns = [dict(a, status="approved") for a in old_anns]
625
+ rv_annotations[rv_page] = new_anns
626
+ events = [
627
+ _make_event(rv_page, a, "accepted", before=_make_box(a), after=_make_box(a))
628
+ for a in old_anns
629
+ if a.get("status") != "approved"
630
+ ]
631
+ ledger = _append_events(ledger, events)
632
+ return (
633
+ _build_canvas_json(None, new_anns),
634
+ _build_review_df(new_anns),
635
+ rv_annotations,
636
+ ledger,
637
+ f"Approved {len(new_anns)} box(es). {_ledger_summary(ledger)}",
638
+ )
639
+
640
+
641
+ def on_rv_reject_all(rv_pdf, rv_page, rv_annotations, ledger):
642
+ if rv_pdf is None or not rv_annotations.get(rv_page):
643
+ return gr.update(), _build_review_df([]), rv_annotations, ledger, "No boxes on this page."
644
+ rv_annotations = dict(rv_annotations)
645
+ old_anns = rv_annotations[rv_page]
646
+ new_anns = [dict(a, status="rejected") for a in old_anns]
647
+ rv_annotations[rv_page] = new_anns
648
+ events = [
649
+ _make_event(rv_page, a, "rejected", before=_make_box(a), after=None)
650
+ for a in old_anns
651
+ if a.get("status") != "rejected"
652
+ ]
653
+ ledger = _append_events(ledger, events)
654
+ return (
655
+ _build_canvas_json(None, new_anns),
656
+ _build_review_df(new_anns),
657
+ rv_annotations,
658
+ ledger,
659
+ f"Rejected {len(new_anns)} box(es). {_ledger_summary(ledger)}",
660
+ )
661
+
662
+
663
+ def on_rv_approve_selected(rv_pdf, rv_page, rv_annotations, ledger, selected_idx):
664
+ anns = rv_annotations.get(rv_page, [])
665
+ if rv_pdf is None or not anns or selected_idx is None or selected_idx < 0:
666
+ return gr.update(), _build_review_df(anns), rv_annotations, ledger, "Select a box first."
667
+ idx = int(selected_idx)
668
+ if idx >= len(anns):
669
+ return gr.update(), _build_review_df(anns), rv_annotations, ledger, "Invalid selection."
670
+ rv_annotations = dict(rv_annotations)
671
+ new_anns = list(anns)
672
+ old = new_anns[idx]
673
+ new_anns[idx] = dict(old, status="approved")
674
+ rv_annotations[rv_page] = new_anns
675
+ event = _make_event(rv_page, old, "accepted", before=_make_box(old), after=_make_box(new_anns[idx]))
676
+ ledger = _append_events(ledger, [event])
677
+ return (
678
+ _build_canvas_json(None, new_anns, selected=idx),
679
+ _build_review_df(new_anns),
680
+ rv_annotations,
681
+ ledger,
682
+ f"Approved box #{idx + 1}. {_ledger_summary(ledger)}",
683
+ )
684
+
685
+
686
+ def on_rv_delete_selected(rv_pdf, rv_page, rv_annotations, ledger, selected_idx):
687
+ anns = rv_annotations.get(rv_page, [])
688
+ if rv_pdf is None or not anns or selected_idx is None or selected_idx < 0:
689
+ return gr.update(), _build_review_df(anns), rv_annotations, ledger, "Select a box first.", selected_idx
690
+ idx = int(selected_idx)
691
+ if idx >= len(anns):
692
+ return gr.update(), _build_review_df(anns), rv_annotations, ledger, "Invalid selection.", selected_idx
693
+ rv_annotations = dict(rv_annotations)
694
+ removed = anns[idx]
695
+ new_anns = [a for i, a in enumerate(anns) if i != idx]
696
+ for j, a in enumerate(new_anns):
697
+ a["id"] = j
698
+ rv_annotations[rv_page] = new_anns
699
+ event = _make_event(rv_page, removed, "rejected", before=_make_box(removed), after=None)
700
+ ledger = _append_events(ledger, [event])
701
+ new_sel = min(idx, len(new_anns) - 1) if new_anns else -1
702
+ return (
703
+ _build_canvas_json(None, new_anns, selected=new_sel),
704
+ _build_review_df(new_anns),
705
+ rv_annotations,
706
+ ledger,
707
+ f"Deleted box #{idx + 1}. {_ledger_summary(ledger)}",
708
+ new_sel,
709
+ )
710
+
711
+
712
+ def on_rv_copy_selected(rv_pdf, rv_page, rv_annotations, ledger, selected_idx):
713
+ anns = rv_annotations.get(rv_page, [])
714
+ if not anns or selected_idx is None or selected_idx < 0:
715
+ return gr.update(), _build_review_df(anns), rv_annotations, ledger, "Select a box first."
716
+ idx = int(selected_idx)
717
+ if idx >= len(anns):
718
+ return gr.update(), _build_review_df(anns), rv_annotations, ledger, "Invalid selection."
719
+ rv_annotations = dict(rv_annotations)
720
+ src = anns[idx]
721
+ new_ann = dict(
722
+ src, id=len(anns), x1=src["x1"] + 15, y1=src["y1"] + 15, x2=src["x2"] + 15, y2=src["y2"] + 15, status="pending"
723
+ )
724
+ new_anns = [*list(anns), new_ann]
725
+ rv_annotations[rv_page] = new_anns
726
+ new_sel = len(new_anns) - 1
727
+ event = _make_event(rv_page, new_ann, "created", before=None, after=_make_box(new_ann))
728
+ ledger = _append_events(ledger, [event])
729
+ return (
730
+ _build_canvas_json(None, new_anns, selected=new_sel),
731
+ _build_review_df(new_anns),
732
+ rv_annotations,
733
+ ledger,
734
+ f"Copied to box #{new_sel + 1}. {_ledger_summary(ledger)}",
735
+ )
736
+
737
+
738
+ def on_row_select(evt: gr.SelectData):
739
+ return evt.index[0] if evt and evt.index else -1
740
+
741
+
742
+ def on_export_events(ledger):
743
+ if not ledger:
744
+ return None
745
+ return export_events_jsonl(ledger)
746
+
747
+
748
+ def on_export_dataset(ledger, rv_pdf):
749
+ if not ledger:
750
+ return None
751
+ return export_yolo_dataset_zip(ledger, rv_pdf)
752
+
753
+
754
+ # ---------------------------------------------------------------------------
755
+ # Gradio UI
756
+ # ---------------------------------------------------------------------------
757
+ _model_version = get_model_version()
758
+
759
+ _CANVAS_HTML = """
760
+ <div id="rv-canvas-outer" style="width:100%;min-height:600px;background:#0f0f0f;border-radius:8px;overflow:hidden;position:relative;user-select:none;-webkit-user-select:none;">
761
+ <canvas id="rv-ann-canvas" style="display:block;width:100%;cursor:crosshair;touch-action:none;"></canvas>
762
+ <div id="rv-canvas-msg" style="position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);color:#555;font-family:monospace;font-size:13px;pointer-events:none;text-align:center;line-height:1.8;">
763
+ Upload a PDF to begin<br><span style="color:#333;font-size:11px;">Drag on canvas to draw &bull; click box to select &bull; drag to move &bull; drag corner to resize</span>
764
+ </div>
765
+ </div>
766
+ <div id="rv-ann-tooltip" style="position:fixed;display:none;background:rgba(20,20,20,0.93);color:#fff;border-radius:6px;padding:8px 12px;font-family:monospace;font-size:12px;pointer-events:none;z-index:9999;box-shadow:0 2px 10px rgba(0,0,0,0.5);line-height:1.7;max-width:280px;"></div>
767
+ """
768
+
769
+ _CANVAS_JS = """
770
+ <script>
771
+ (function() {
772
+ 'use strict';
773
+
774
+ var COLORS = {pending:'#F97316', approved:'#22C55E', rejected:'#EF4444'};
775
+ var HANDLE = 9;
776
+
777
+ var canvas = null, ctx = null;
778
+ var cachedImg = null, cachedImgSrc = '';
779
+ var imgW = 0, imgH = 0;
780
+ var anns = [], selected = -1;
781
+ var drag = null;
782
+ var lastRender = '';
783
+
784
+ // ── Boot ────────────────────────────────────────────────────────────────
785
+ var _iv = setInterval(function() {
786
+ if (document.getElementById('rv-ann-canvas')) {
787
+ clearInterval(_iv);
788
+ canvas = document.getElementById('rv-ann-canvas');
789
+ ctx = canvas.getContext('2d');
790
+ canvas.addEventListener('mousedown', onDown);
791
+ canvas.addEventListener('mousemove', onMove);
792
+ canvas.addEventListener('mouseup', onUp);
793
+ canvas.addEventListener('mouseleave', function(e) { hideTooltip(); if (drag) { onUp(e); } });
794
+ new ResizeObserver(refit).observe(canvas.parentElement);
795
+ setInterval(checkRender, 150);
796
+ }
797
+ }, 200);
798
+
799
+ // ── Python → JS sync ────────────────────────────────────────────────────
800
+ function checkRender() {
801
+ var el = document.querySelector('#rv-canvas-render textarea');
802
+ if (!el || !el.value || el.value === lastRender) return;
803
+ lastRender = el.value;
804
+ try { applyRender(JSON.parse(el.value)); } catch(e) {}
805
+ }
806
+
807
+ function applyRender(d) {
808
+ anns = (d.annotations || []).map(function(a) { return Object.assign({}, a); });
809
+ selected = (d.selected !== undefined && d.selected !== null) ? parseInt(d.selected, 10) : -1;
810
+ if (isNaN(selected)) selected = -1;
811
+ var msg = document.getElementById('rv-canvas-msg');
812
+ if (d.image && d.image !== cachedImgSrc) {
813
+ imgW = d.imageW; imgH = d.imageH;
814
+ var im = new Image();
815
+ im.onload = function() { cachedImg = im; cachedImgSrc = d.image; refit(); };
816
+ im.onerror = function() { console.warn('Canvas: failed to load image'); };
817
+ im.src = d.image;
818
+ if (msg) msg.style.display = 'none';
819
+ } else {
820
+ draw();
821
+ if (msg && cachedImg) msg.style.display = 'none';
822
+ }
823
+ }
824
+
825
+ function refit() {
826
+ if (!canvas || !imgW || !imgH) return;
827
+ var w = canvas.parentElement.clientWidth || 800;
828
+ canvas.width = Math.round(w);
829
+ canvas.height = Math.round(imgH * (w / imgW));
830
+ draw();
831
+ }
832
+
833
+ function s() { return canvas && imgW ? canvas.width / imgW : 1; }
834
+
835
+ // ── Draw ─────────────────────────────────────────────────────────────────
836
+ function draw() {
837
+ if (!canvas) return;
838
+ var sc = s();
839
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
840
+ if (cachedImg) ctx.drawImage(cachedImg, 0, 0, canvas.width, canvas.height);
841
+
842
+ anns.forEach(function(ann, i) {
843
+ var x1 = ann.x1*sc, y1 = ann.y1*sc, x2 = ann.x2*sc, y2 = ann.y2*sc;
844
+ var c = COLORS[ann.status] || COLORS.pending;
845
+ var sel = (i === selected);
846
+
847
+ // Fill
848
+ ctx.fillStyle = c + (sel ? '55' : '22');
849
+ ctx.fillRect(x1, y1, x2-x1, y2-y1);
850
+
851
+ // Border
852
+ ctx.strokeStyle = c;
853
+ ctx.lineWidth = sel ? 3 : 2;
854
+ ctx.strokeRect(x1, y1, x2-x1, y2-y1);
855
+
856
+ // Resize handles on selected box
857
+ if (sel) {
858
+ [[x1,y1],[x2,y1],[x2,y2],[x1,y2]].forEach(function(pt) {
859
+ ctx.fillStyle = '#fff';
860
+ ctx.fillRect(pt[0]-HANDLE/2, pt[1]-HANDLE/2, HANDLE, HANDLE);
861
+ ctx.strokeStyle = c; ctx.lineWidth = 2;
862
+ ctx.strokeRect(pt[0]-HANDLE/2, pt[1]-HANDLE/2, HANDLE, HANDLE);
863
+ });
864
+ }
865
+ });
866
+
867
+ // Draw-in-progress preview
868
+ if (drag && drag.mode === 'draw') {
869
+ ctx.strokeStyle = '#2563EB'; ctx.lineWidth = 2;
870
+ ctx.setLineDash([5, 4]);
871
+ ctx.strokeRect(drag.ox, drag.oy, drag.cx - drag.ox, drag.cy - drag.oy);
872
+ ctx.setLineDash([]);
873
+ ctx.fillStyle = 'rgba(37,99,235,0.1)';
874
+ ctx.fillRect(drag.ox, drag.oy, drag.cx - drag.ox, drag.cy - drag.oy);
875
+ }
876
+ }
877
+
878
+ // ── Tooltip ──────────────────────────────────────────────────────────────
879
+ var hoveredIdx = -1;
880
+
881
+ function showTooltip(ann, i, clientX, clientY) {
882
+ var tip = document.getElementById('rv-ann-tooltip');
883
+ if (!tip) return;
884
+ tip.innerHTML =
885
+ '<b>Box #' + (i + 1) + '</b><br>' +
886
+ 'conf: ' + ((ann.conf || 1) * 1).toFixed(2) + '<br>' +
887
+ 'status: ' + ann.status + '<br>' +
888
+ '(' + Math.round(ann.x1) + ', ' + Math.round(ann.y1) + ') &rarr; ' +
889
+ '(' + Math.round(ann.x2) + ', ' + Math.round(ann.y2) + ')';
890
+ tip.style.display = 'block';
891
+ tip.style.left = (clientX + 14) + 'px';
892
+ tip.style.top = (clientY + 14) + 'px';
893
+ }
894
+
895
+ function hideTooltip() {
896
+ var tip = document.getElementById('rv-ann-tooltip');
897
+ if (tip) tip.style.display = 'none';
898
+ hoveredIdx = -1;
899
+ }
900
+
901
+ // ── Hit testing ──────────────────────────────────────────────────────────
902
+ function hitHandle(ann, cx, cy) {
903
+ var sc = s(), h = HANDLE;
904
+ var pts = [
905
+ [ann.x1*sc, ann.y1*sc, 'nw'], [ann.x2*sc, ann.y1*sc, 'ne'],
906
+ [ann.x2*sc, ann.y2*sc, 'se'], [ann.x1*sc, ann.y2*sc, 'sw'],
907
+ ];
908
+ for (var i = 0; i < pts.length; i++) {
909
+ if (Math.abs(cx - pts[i][0]) <= h && Math.abs(cy - pts[i][1]) <= h)
910
+ return pts[i][2];
911
+ }
912
+ return null;
913
+ }
914
+
915
+ function hitBox(ann, cx, cy) {
916
+ var sc = s();
917
+ return cx >= ann.x1*sc && cx <= ann.x2*sc && cy >= ann.y1*sc && cy <= ann.y2*sc;
918
+ }
919
+
920
+ function pos(e) {
921
+ var r = canvas.getBoundingClientRect();
922
+ var sx = canvas.width / r.width;
923
+ var sy = canvas.height / r.height;
924
+ return { x: (e.clientX - r.left) * sx, y: (e.clientY - r.top) * sy };
925
+ }
926
+
927
+ function fi(v) { return v / s(); }
928
+
929
+ // ── Mouse ───────────────────────────────────────────────────��────────────
930
+ function onDown(e) {
931
+ hideTooltip();
932
+ var p = pos(e);
933
+ // Resize handles on selected box take priority
934
+ if (selected >= 0 && selected < anns.length) {
935
+ var dir = hitHandle(anns[selected], p.x, p.y);
936
+ if (dir) {
937
+ drag = { mode:'resize', dir:dir, idx:selected,
938
+ orig:Object.assign({}, anns[selected]), ox:p.x, oy:p.y };
939
+ return;
940
+ }
941
+ }
942
+ // Click inside any box to select + move
943
+ for (var i = anns.length - 1; i >= 0; i--) {
944
+ if (hitBox(anns[i], p.x, p.y)) {
945
+ selected = i;
946
+ drag = { mode:'move', idx:i,
947
+ orig:Object.assign({}, anns[i]), ox:p.x, oy:p.y };
948
+ draw();
949
+ sync('select');
950
+ return;
951
+ }
952
+ }
953
+ // Empty area — start drawing new box
954
+ selected = -1;
955
+ drag = { mode:'draw', ox:p.x, oy:p.y, cx:p.x, cy:p.y };
956
+ draw();
957
+ }
958
+
959
+ function onMove(e) {
960
+ var p = pos(e);
961
+ // Tooltip on hover (only when not dragging)
962
+ if (!drag) {
963
+ var hovered = -1;
964
+ for (var j = anns.length - 1; j >= 0; j--) {
965
+ if (hitBox(anns[j], p.x, p.y)) { hovered = j; break; }
966
+ }
967
+ if (hovered >= 0) {
968
+ showTooltip(anns[hovered], hovered, e.clientX, e.clientY);
969
+ } else {
970
+ hideTooltip();
971
+ }
972
+ return;
973
+ }
974
+ var dx = fi(p.x - drag.ox), dy = fi(p.y - drag.oy);
975
+ if (drag.mode === 'move') {
976
+ var o = drag.orig;
977
+ anns[drag.idx] = Object.assign({}, o,
978
+ { x1:o.x1+dx, y1:o.y1+dy, x2:o.x2+dx, y2:o.y2+dy });
979
+ } else if (drag.mode === 'resize') {
980
+ var o = drag.orig, a = Object.assign({}, o), d = drag.dir;
981
+ if (d.indexOf('e') >= 0) a.x2 = o.x2 + dx;
982
+ if (d.indexOf('w') >= 0) a.x1 = o.x1 + dx;
983
+ if (d.indexOf('s') >= 0) a.y2 = o.y2 + dy;
984
+ if (d.indexOf('n') >= 0) a.y1 = o.y1 + dy;
985
+ anns[drag.idx] = a;
986
+ } else if (drag.mode === 'draw') {
987
+ drag.cx = p.x; drag.cy = p.y;
988
+ }
989
+ draw();
990
+ }
991
+
992
+ function onUp(e) {
993
+ if (!drag) return;
994
+ var action = drag.mode;
995
+ if (drag.mode === 'draw') {
996
+ var p = pos(e);
997
+ var x1c = Math.min(drag.ox, p.x), y1c = Math.min(drag.oy, p.y);
998
+ var x2c = Math.max(drag.ox, p.x), y2c = Math.max(drag.oy, p.y);
999
+ if (x2c - x1c > 5 && y2c - y1c > 5) {
1000
+ anns.push({ x1:fi(x1c), y1:fi(y1c), x2:fi(x2c), y2:fi(y2c),
1001
+ conf:1.0, status:'pending', source:'human_added' });
1002
+ selected = anns.length - 1;
1003
+ action = 'created';
1004
+ } else {
1005
+ drag = null; draw(); return;
1006
+ }
1007
+ } else {
1008
+ action = drag.mode === 'resize' ? 'resized' : 'moved';
1009
+ }
1010
+ drag = null;
1011
+ draw();
1012
+ sync(action);
1013
+ }
1014
+
1015
+ // ── JS-side copy (C key / canvas copy shortcut) ──────────────────────────
1016
+ function copySelected() {
1017
+ if (selected < 0 || selected >= anns.length) return;
1018
+ var src = anns[selected];
1019
+ anns.push(Object.assign({}, src, {
1020
+ x1: src.x1+15, y1: src.y1+15, x2: src.x2+15, y2: src.y2+15, status:'pending'
1021
+ }));
1022
+ selected = anns.length - 1;
1023
+ draw();
1024
+ sync('created');
1025
+ }
1026
+
1027
+ function deleteSelected() {
1028
+ if (selected < 0 || selected >= anns.length) return;
1029
+ anns.splice(selected, 1);
1030
+ selected = anns.length > 0 ? Math.min(selected, anns.length - 1) : -1;
1031
+ draw();
1032
+ sync('deleted');
1033
+ }
1034
+
1035
+ // ── JS → Python sync ────────────────────────────────────────────────────
1036
+ function sync(action) {
1037
+ var tb = document.querySelector('#rv-canvas-events textarea');
1038
+ if (!tb) return;
1039
+ var payload = JSON.stringify({ annotations:anns, selected:selected, action:action || 'sync' });
1040
+ tb.value = payload;
1041
+ tb.dispatchEvent(new Event('input', { bubbles:true }));
1042
+ tb.dispatchEvent(new Event('change', { bubbles:true }));
1043
+ }
1044
+
1045
+ // ── Keyboard ─────────────────────────────────────────────────────────────
1046
+ function triggerBtn(id) {
1047
+ var el = document.getElementById(id);
1048
+ if (!el) return;
1049
+ var btn = el.tagName === 'BUTTON' ? el : el.querySelector('button');
1050
+ if (btn) btn.click();
1051
+ }
1052
+
1053
+ document.addEventListener('keydown', function(e) {
1054
+ var tag = e.target.tagName;
1055
+ if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return;
1056
+ switch (e.key) {
1057
+ case 'c': case 'C': copySelected(); break;
1058
+ case 'Delete': case 'Backspace': deleteSelected(); break;
1059
+ case 'a': case 'A': triggerBtn('rv-approve-sel'); break;
1060
+ case 'ArrowLeft': e.preventDefault(); triggerBtn('rv-prev'); break;
1061
+ case 'ArrowRight': e.preventDefault(); triggerBtn('rv-next'); break;
1062
+ }
1063
+ });
1064
+ })();
1065
+ </script>
1066
+ """
1067
+
1068
+ with gr.Blocks(title="Takeoff AI Annotator") as demo:
1069
+ gr.Markdown(f"# Takeoff AI Annotator \n_Model: `{_model_version}`_")
1070
+
1071
+ shapes_state = gr.State([])
1072
+
1073
+ rv_pdf_state = gr.State(None)
1074
+ rv_page_state = gr.State(1)
1075
+ rv_total_state = gr.State(1)
1076
+ rv_annotations_state = gr.State({})
1077
+ rv_ledger_state = gr.State([])
1078
+ rv_selected_state = gr.State(-1)
1079
+
1080
+ with gr.Tabs():
1081
+ # ------------------------------------------------------------------ #
1082
+ # Tab 1: Detect #
1083
+ # ------------------------------------------------------------------ #
1084
+ with gr.Tab("Detect"):
1085
+ with gr.Row():
1086
+ with gr.Column():
1087
+ image_input = gr.Image(label="Upload Image (PNG/JPG)", type="filepath")
1088
+ if PDF_SUPPORT:
1089
+ pdf_input = gr.File(label="Or Upload PDF", file_types=[".pdf"])
1090
+ pdf_page_input = gr.Number(label="PDF Page Number", value=1, minimum=1, precision=0)
1091
+ else:
1092
+ pdf_input = gr.State(None)
1093
+ pdf_page_input = gr.State(1)
1094
+ gr.Markdown("_PDF support unavailable._")
1095
+ page_number_input = gr.Number(
1096
+ label="Page Number (sent to backend)",
1097
+ value=1,
1098
+ minimum=1,
1099
+ precision=0,
1100
+ )
1101
+ with gr.Accordion("Detection Parameters", open=True):
1102
+ conf_slider = gr.Slider(0.05, 0.95, value=0.25, step=0.05, label="Confidence Threshold (YOLO)")
1103
+ gr.Markdown("_Sliders below are OpenCV fallback only._")
1104
+ min_area_slider = gr.Slider(100, 10_000, value=500, step=100, label="Min Area (px²)")
1105
+ max_area_slider = gr.Slider(1_000, 2_000_000, value=500_000, step=1_000, label="Max Area (px²)")
1106
+ epsilon_slider = gr.Slider(0.005, 0.1, value=0.02, step=0.005, label="Epsilon Factor")
1107
+ threshold_slider = gr.Slider(0, 255, value=127, step=1, label="Binarization Threshold")
1108
+ detect_btn = gr.Button("Detect Panels", variant="primary")
1109
+ status_output = gr.Textbox(label="Status", interactive=False)
1110
+ with gr.Column():
1111
+ annotated_output = gr.Image(label="Detected Rectangles")
1112
+ json_output = gr.JSON(label="Payload Preview")
1113
+ detect_btn.click(
1114
+ fn=run_detection,
1115
+ inputs=[
1116
+ image_input,
1117
+ pdf_input,
1118
+ pdf_page_input,
1119
+ page_number_input,
1120
+ conf_slider,
1121
+ min_area_slider,
1122
+ max_area_slider,
1123
+ epsilon_slider,
1124
+ threshold_slider,
1125
+ ],
1126
+ outputs=[annotated_output, json_output, shapes_state, status_output],
1127
+ )
1128
+
1129
+ # ------------------------------------------------------------------ #
1130
+ # Tab 2: Review & Edit #
1131
+ # ------------------------------------------------------------------ #
1132
+ with gr.Tab("Review & Edit"):
1133
+ rv_pdf_input = gr.File(label="Upload PDF", file_types=[".pdf"])
1134
+
1135
+ with gr.Row():
1136
+ rv_prev_btn = gr.Button("◀", scale=1, min_width=48, elem_id="rv-prev")
1137
+ rv_page_lbl = gr.Textbox(value="Upload a PDF to begin", show_label=False, interactive=False, scale=4)
1138
+ rv_next_btn = gr.Button("▶", scale=1, min_width=48, elem_id="rv-next")
1139
+ rv_page_jump = gr.Textbox(value="", show_label=False, scale=1, min_width=80, placeholder="Go to page…")
1140
+ rv_conf_slider = gr.Slider(0.05, 0.95, value=0.25, step=0.05, label="Conf", scale=3)
1141
+ rv_detect_btn = gr.Button("Detect Page", variant="primary", scale=2)
1142
+ rv_detect_all_btn = gr.Button("Detect All", scale=2)
1143
+
1144
+ with gr.Row():
1145
+ with gr.Column(scale=7):
1146
+ gr.HTML(_CANVAS_HTML)
1147
+ rv_canvas_render_txt = gr.Textbox(visible=False, elem_id="rv-canvas-render")
1148
+ rv_canvas_events_txt = gr.Textbox(visible=False, elem_id="rv-canvas-events")
1149
+
1150
+ with gr.Column(scale=3):
1151
+ rv_status = gr.Textbox(show_label=False, interactive=False, placeholder="Status", lines=1)
1152
+
1153
+ gr.Markdown("**Selected box**")
1154
+ rv_selected_lbl = gr.Textbox(value="— none —", show_label=False, interactive=False)
1155
+ with gr.Row():
1156
+ rv_approve_sel_btn = gr.Button(
1157
+ "✓ Approve", variant="secondary", elem_id="rv-approve-sel", scale=2
1158
+ )
1159
+ rv_copy_sel_btn = gr.Button("⧉ Copy", scale=1)
1160
+ rv_delete_sel_btn = gr.Button("🗑 Delete", variant="stop", elem_id="rv-delete-sel", scale=2)
1161
+
1162
+ gr.Markdown("**Edit coords** _(type values → Update)_")
1163
+ with gr.Row():
1164
+ edit_x1 = gr.Number(label="x1", precision=0, min_width=80)
1165
+ edit_y1 = gr.Number(label="y1", precision=0, min_width=80)
1166
+ with gr.Row():
1167
+ edit_x2 = gr.Number(label="x2", precision=0, min_width=80)
1168
+ edit_y2 = gr.Number(label="y2", precision=0, min_width=80)
1169
+ update_box_btn = gr.Button("↩ Update Box", variant="primary")
1170
+
1171
+ gr.Markdown("**Page actions**")
1172
+ with gr.Row():
1173
+ rv_approve_all_btn = gr.Button("✓ Approve All", variant="secondary")
1174
+ rv_reject_all_btn = gr.Button("✗ Reject All", variant="stop")
1175
+
1176
+ rv_df = gr.Dataframe(
1177
+ headers=["#", "x1", "y1", "x2", "y2", "conf", "status"],
1178
+ label="Detections — click row to select",
1179
+ interactive=False,
1180
+ wrap=False,
1181
+ max_height=220,
1182
+ )
1183
+
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 &nbsp; `C` copy &nbsp; `Del` delete &nbsp; "
1191
+ "`← →` pages &nbsp; — draw box by dragging on empty canvas"
1192
+ )
1193
+
1194
+ # ── Wire events ─────────────────────────────────────────────────
1195
+ rv_pdf_input.upload(
1196
+ fn=on_rv_pdf_upload,
1197
+ inputs=[rv_pdf_input],
1198
+ outputs=[
1199
+ rv_canvas_render_txt,
1200
+ rv_df,
1201
+ rv_page_lbl,
1202
+ rv_pdf_state,
1203
+ rv_page_state,
1204
+ rv_total_state,
1205
+ rv_annotations_state,
1206
+ rv_ledger_state,
1207
+ ],
1208
+ )
1209
+ rv_prev_btn.click(
1210
+ fn=on_rv_prev,
1211
+ inputs=[rv_pdf_state, rv_page_state, rv_total_state, rv_annotations_state],
1212
+ outputs=[rv_canvas_render_txt, rv_df, rv_page_lbl, rv_page_state],
1213
+ )
1214
+ rv_next_btn.click(
1215
+ fn=on_rv_next,
1216
+ inputs=[rv_pdf_state, rv_page_state, rv_total_state, rv_annotations_state],
1217
+ outputs=[rv_canvas_render_txt, rv_df, rv_page_lbl, rv_page_state],
1218
+ )
1219
+ rv_page_jump.submit(
1220
+ fn=on_rv_goto_page,
1221
+ inputs=[rv_page_jump, rv_pdf_state, rv_total_state, rv_annotations_state],
1222
+ outputs=[rv_canvas_render_txt, rv_df, rv_page_lbl, rv_page_state],
1223
+ )
1224
+ rv_canvas_events_txt.change(
1225
+ fn=on_canvas_events,
1226
+ inputs=[rv_canvas_events_txt, rv_pdf_state, rv_page_state, rv_annotations_state, rv_ledger_state],
1227
+ outputs=[rv_canvas_render_txt, rv_annotations_state, rv_ledger_state, rv_df, rv_selected_state],
1228
+ ).then(
1229
+ fn=lambda idx: f"Box #{idx + 1}" if idx >= 0 else "— none —",
1230
+ inputs=[rv_selected_state],
1231
+ outputs=[rv_selected_lbl],
1232
+ ).then(
1233
+ fn=on_fill_edit_form,
1234
+ inputs=[rv_selected_state, rv_page_state, rv_annotations_state],
1235
+ outputs=[edit_x1, edit_y1, edit_x2, edit_y2],
1236
+ )
1237
+ rv_detect_btn.click(
1238
+ fn=on_rv_detect,
1239
+ inputs=[rv_pdf_state, rv_page_state, rv_annotations_state, rv_conf_slider],
1240
+ outputs=[rv_canvas_render_txt, rv_df, rv_annotations_state, rv_status],
1241
+ )
1242
+ rv_detect_all_btn.click(
1243
+ fn=on_rv_detect_all,
1244
+ inputs=[rv_pdf_state, rv_page_state, rv_total_state, rv_annotations_state, rv_conf_slider],
1245
+ outputs=[rv_canvas_render_txt, rv_df, rv_annotations_state, rv_status],
1246
+ )
1247
+ rv_approve_all_btn.click(
1248
+ fn=on_rv_approve_all,
1249
+ inputs=[rv_pdf_state, rv_page_state, rv_annotations_state, rv_ledger_state],
1250
+ outputs=[rv_canvas_render_txt, rv_df, rv_annotations_state, rv_ledger_state, rv_status],
1251
+ )
1252
+ rv_reject_all_btn.click(
1253
+ fn=on_rv_reject_all,
1254
+ inputs=[rv_pdf_state, rv_page_state, rv_annotations_state, rv_ledger_state],
1255
+ outputs=[rv_canvas_render_txt, rv_df, rv_annotations_state, rv_ledger_state, rv_status],
1256
+ )
1257
+ rv_df.select(
1258
+ fn=on_row_select,
1259
+ inputs=None,
1260
+ outputs=[rv_selected_state],
1261
+ ).then(
1262
+ fn=lambda idx: f"Box #{idx + 1}" if idx >= 0 else "— none —",
1263
+ inputs=[rv_selected_state],
1264
+ outputs=[rv_selected_lbl],
1265
+ ).then(
1266
+ fn=on_fill_edit_form,
1267
+ inputs=[rv_selected_state, rv_page_state, rv_annotations_state],
1268
+ outputs=[edit_x1, edit_y1, edit_x2, edit_y2],
1269
+ )
1270
+ rv_approve_sel_btn.click(
1271
+ fn=on_rv_approve_selected,
1272
+ inputs=[rv_pdf_state, rv_page_state, rv_annotations_state, rv_ledger_state, rv_selected_state],
1273
+ outputs=[rv_canvas_render_txt, rv_df, rv_annotations_state, rv_ledger_state, rv_status],
1274
+ )
1275
+ rv_copy_sel_btn.click(
1276
+ fn=on_rv_copy_selected,
1277
+ inputs=[rv_pdf_state, rv_page_state, rv_annotations_state, rv_ledger_state, rv_selected_state],
1278
+ outputs=[rv_canvas_render_txt, rv_df, rv_annotations_state, rv_ledger_state, rv_status],
1279
+ )
1280
+ rv_delete_sel_btn.click(
1281
+ fn=on_rv_delete_selected,
1282
+ inputs=[rv_pdf_state, rv_page_state, rv_annotations_state, rv_ledger_state, rv_selected_state],
1283
+ outputs=[
1284
+ rv_canvas_render_txt,
1285
+ rv_df,
1286
+ rv_annotations_state,
1287
+ rv_ledger_state,
1288
+ rv_status,
1289
+ rv_selected_state,
1290
+ ],
1291
+ ).then(
1292
+ fn=lambda idx: f"Box #{idx + 1}" if idx >= 0 else "— none —",
1293
+ inputs=[rv_selected_state],
1294
+ outputs=[rv_selected_lbl],
1295
+ )
1296
+ update_box_btn.click(
1297
+ fn=on_rv_update_box,
1298
+ inputs=[
1299
+ rv_pdf_state,
1300
+ rv_page_state,
1301
+ rv_annotations_state,
1302
+ rv_ledger_state,
1303
+ rv_selected_state,
1304
+ edit_x1,
1305
+ edit_y1,
1306
+ edit_x2,
1307
+ edit_y2,
1308
+ ],
1309
+ outputs=[rv_canvas_render_txt, rv_df, rv_annotations_state, rv_ledger_state, rv_status],
1310
+ )
1311
+ export_events_btn.click(
1312
+ fn=on_export_events,
1313
+ inputs=[rv_ledger_state],
1314
+ outputs=[export_file_out],
1315
+ )
1316
+ export_dataset_btn.click(
1317
+ fn=on_export_dataset,
1318
+ inputs=[rv_ledger_state, rv_pdf_state],
1319
+ outputs=[export_file_out],
1320
+ )
1321
+
1322
+ # ------------------------------------------------------------------ #
1323
+ # Tab 3: Send to Backend #
1324
+ # ------------------------------------------------------------------ #
1325
+ with gr.Tab("Send to Backend"):
1326
+ gr.Markdown(
1327
+ "Sends **approved** annotations from the **Review & Edit** tab to your backend. "
1328
+ "Detect pages, approve the boxes you want, then click **Send to Backend**."
1329
+ )
1330
+ with gr.Row():
1331
+ with gr.Column():
1332
+ backend_url_input = gr.Textbox(label="Backend Base URL", value="http://localhost:8000")
1333
+ document_id_input = gr.Number(label="Document ID", value=1, precision=0)
1334
+ frame_template_id_input = gr.Number(label="Frame Template ID", value=1, precision=0)
1335
+ tenant_id_input = gr.Number(label="x-tenant-id", value=1, precision=0)
1336
+ user_id_input = gr.Number(label="x-user-id", value=1, precision=0)
1337
+ color_input = gr.Textbox(label="Color (optional)", placeholder="#FF5500")
1338
+ quantity_input = gr.Number(label="Quantity Multiplier", value=1, minimum=1, precision=0)
1339
+ send_btn = gr.Button("Send to Backend", variant="primary")
1340
+ with gr.Column():
1341
+ response_output = gr.JSON(label="Backend Response")
1342
+ send_btn.click(
1343
+ fn=send_to_backend,
1344
+ inputs=[
1345
+ rv_annotations_state,
1346
+ backend_url_input,
1347
+ document_id_input,
1348
+ frame_template_id_input,
1349
+ tenant_id_input,
1350
+ user_id_input,
1351
+ color_input,
1352
+ quantity_input,
1353
+ ],
1354
+ outputs=[response_output],
1355
+ )
1356
+
1357
+ gr.HTML(_CANVAS_JS)
1358
+
1359
+ if __name__ == "__main__":
1360
+ demo.launch()
detection.py ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
12
+
13
+ import os
14
+ from pathlib import Path
15
+
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:
36
+ from ultralytics import YOLO
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
44
+
45
+ return _MODEL
46
+
47
+
48
+ def get_model_version() -> str:
49
+ """Return the version tag from versions.json if present, else 'unversioned'."""
50
+ versions_path = Path(__file__).parent / "versions.json"
51
+ if versions_path.exists():
52
+ try:
53
+ import json
54
+
55
+ data = json.loads(versions_path.read_text())
56
+ if isinstance(data, list) and data:
57
+ return data[-1].get("version", "unversioned")
58
+ if isinstance(data, dict):
59
+ return data.get("version", "unversioned")
60
+ except Exception:
61
+ pass
62
+ return "unversioned"
63
+
64
+
65
+ # ---------------------------------------------------------------------------
66
+ # Detection — returns list[dict] with keys x1, y1, x2, y2, conf
67
+ # ---------------------------------------------------------------------------
68
+
69
+
70
+ def detect_rectangles(
71
+ image: np.ndarray,
72
+ conf: float = 0.25,
73
+ iou: float = 0.45,
74
+ # Legacy parameters — only used when YOLO is not available (OpenCV fallback)
75
+ min_area: int = 500,
76
+ max_area: int = 500_000,
77
+ epsilon_factor: float = 0.02,
78
+ threshold: int = 127,
79
+ ) -> list[dict]:
80
+ """
81
+ Detect facade frame panels in a construction plan image.
82
+
83
+ Tries YOLO first; falls back to OpenCV contour detection if the model
84
+ is unavailable.
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,
100
+ conf: float,
101
+ iou: float,
102
+ ) -> list[dict]:
103
+ results = model.predict(
104
+ source=image,
105
+ conf=conf,
106
+ iou=iou,
107
+ imgsz=640,
108
+ device="cpu",
109
+ verbose=False,
110
+ )
111
+ boxes: list[dict] = []
112
+ for result in results:
113
+ if result.boxes is None:
114
+ continue
115
+ for box in result.boxes:
116
+ x1, y1, x2, y2 = box.xyxy[0].tolist()
117
+ c = float(box.conf[0])
118
+ boxes.append({"x1": float(x1), "y1": float(y1), "x2": float(x2), "y2": float(y2), "conf": round(c, 4)})
119
+ return boxes
120
+
121
+
122
+ def _cv_detect(
123
+ image: np.ndarray,
124
+ min_area: int,
125
+ max_area: int,
126
+ epsilon_factor: float,
127
+ threshold: int,
128
+ ) -> list[dict]:
129
+ """OpenCV fallback — original contour-based detection, conf fixed at 1.0."""
130
+ gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) == 3 else image.copy()
131
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
132
+
133
+ _, binary = cv2.threshold(blurred, threshold, 255, cv2.THRESH_BINARY_INV)
134
+ edges = cv2.Canny(blurred, 50, 150)
135
+ combined = cv2.bitwise_or(binary, edges)
136
+
137
+ contours, _ = cv2.findContours(combined, cv2.RETR_TREE, cv2.CHAIN_APPROX_SIMPLE)
138
+
139
+ results: list[dict] = []
140
+ seen: set[tuple[int, int, int, int]] = set()
141
+
142
+ for contour in contours:
143
+ area = cv2.contourArea(contour)
144
+ if area < min_area or area > max_area:
145
+ continue
146
+
147
+ peri = cv2.arcLength(contour, True)
148
+ approx = cv2.approxPolyDP(contour, epsilon_factor * peri, True)
149
+
150
+ if len(approx) != 4:
151
+ continue
152
+
153
+ x, y, w, h = cv2.boundingRect(approx)
154
+ key = (x, y, x + w, y + h)
155
+ if key in seen:
156
+ continue
157
+ seen.add(key)
158
+
159
+ results.append({"x1": float(x), "y1": float(y), "x2": float(x + w), "y2": float(y + h), "conf": 1.0})
160
+
161
+ return results
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Visualisation helper
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ def draw_detections(image: np.ndarray, boxes: list[dict]) -> np.ndarray:
170
+ """Draw detection boxes on image. boxes is list[dict] with x1,y1,x2,y2,conf."""
171
+ annotated = image.copy()
172
+ for i, box in enumerate(boxes):
173
+ x1, y1, x2, y2 = int(box["x1"]), int(box["y1"]), int(box["x2"]), int(box["y2"])
174
+ conf = box.get("conf", 1.0)
175
+ cv2.rectangle(annotated, (x1, y1), (x2, y2), (0, 200, 0), 2)
176
+ label = f"{i + 1} {conf:.2f}"
177
+ cv2.putText(
178
+ annotated,
179
+ label,
180
+ (x1 + 4, y1 + 16),
181
+ cv2.FONT_HERSHEY_SIMPLEX,
182
+ 0.5,
183
+ (0, 200, 0),
184
+ 1,
185
+ cv2.LINE_AA,
186
+ )
187
+ return annotated
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ poppler-utils
requirements.txt ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ gradio>=5.9.1,<5.10.0
2
+ opencv-python-headless>=4.8.0
3
+ numpy>=1.24.0
4
+ Pillow>=10.0.0
5
+ requests>=2.31.0
6
+ pymupdf>=1.23.0
7
+ ultralytics>=8.3.0
8
+ plotly>=5.18.0
9
+ pandas>=2.0.0