Pream912 commited on
Commit
da1790a
Β·
verified Β·
1 Parent(s): 891ce5f

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +1095 -0
app.py ADDED
@@ -0,0 +1,1095 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Blueprint Room Extractor β€” Flask Server
3
+ """
4
+ from __future__ import annotations
5
+
6
+ import base64
7
+ import io
8
+ import json
9
+ import os
10
+ import threading
11
+ import time
12
+ import traceback
13
+ import uuid
14
+ from typing import Any, Dict, List, Optional
15
+
16
+ import cv2
17
+ import numpy as np
18
+ from flask import Flask, Response, jsonify, render_template_string, request
19
+ from PIL import Image
20
+
21
+ from wall_pipeline import WallPipeline, _GPU
22
+
23
+ app = Flask(__name__)
24
+
25
+ # ── In-memory session store ──────────────────────────────────────────────────
26
+ _sessions: Dict[str, Dict] = {}
27
+ _sessions_lock = threading.Lock()
28
+
29
+
30
+ def _new_session() -> str:
31
+ sid = str(uuid.uuid4())
32
+ with _sessions_lock:
33
+ _sessions[sid] = {
34
+ "original_bgr" : None, # np.ndarray
35
+ "wall_mask" : None,
36
+ "room_mask" : None,
37
+ "rooms" : [], # list of room dicts
38
+ "stage_images" : {}, # key -> np.ndarray
39
+ "calibration" : {},
40
+ "door_lines" : [], # manual door seal lines [(x1,y1,x2,y2),...]
41
+ "log" : [],
42
+ "progress" : 0,
43
+ "status" : "idle",
44
+ }
45
+ return sid
46
+
47
+
48
+ def _get_session(sid: str) -> Optional[Dict]:
49
+ with _sessions_lock:
50
+ return _sessions.get(sid)
51
+
52
+
53
+ # ── Image helpers ─────────────────────────────────────────────────────────────
54
+ def _bgr_to_b64(img: np.ndarray, max_dim: int = 2048) -> str:
55
+ h, w = img.shape[:2]
56
+ if max(h, w) > max_dim:
57
+ scale = max_dim / max(h, w)
58
+ img = cv2.resize(img, (int(w*scale), int(h*scale)), interpolation=cv2.INTER_AREA)
59
+ _, buf = cv2.imencode(".jpg", img, [cv2.IMWRITE_JPEG_QUALITY, 88])
60
+ return base64.b64encode(buf.tobytes()).decode()
61
+
62
+
63
+ def _mask_to_b64(mask: np.ndarray, max_dim: int = 2048) -> str:
64
+ vis = cv2.cvtColor(mask, cv2.COLOR_GRAY2BGR)
65
+ return _bgr_to_b64(vis, max_dim)
66
+
67
+
68
+ def _composite_overlay(orig: np.ndarray, rooms: List[Dict],
69
+ wall_mask: np.ndarray) -> np.ndarray:
70
+ """Render original image + wall overlay + room polygons."""
71
+ vis = orig.copy()
72
+ # Overlay walls (semi-transparent blue)
73
+ if wall_mask is not None:
74
+ wm = wall_mask
75
+ if wm.shape[:2] != vis.shape[:2]:
76
+ wm = cv2.resize(wm, (vis.shape[1], vis.shape[0]),
77
+ interpolation=cv2.INTER_NEAREST)
78
+ wall_vis = np.zeros_like(vis)
79
+ wall_vis[wm > 0] = (0, 80, 220)
80
+ vis = cv2.addWeighted(vis, 0.85, wall_vis, 0.4, 0)
81
+ # Draw room fills
82
+ rng = np.random.default_rng(7)
83
+ for room in rooms:
84
+ color = rng.integers(80, 200, 3).tolist()
85
+ segs = room.get("segmentation", [])
86
+ for seg in segs:
87
+ pts = np.array(seg, dtype=np.int32).reshape(-1, 2)
88
+ if len(pts) >= 3:
89
+ overlay = vis.copy()
90
+ cv2.fillPoly(overlay, [pts], color)
91
+ vis = cv2.addWeighted(vis, 0.55, overlay, 0.45, 0)
92
+ cv2.polylines(vis, [pts], True, color, 2)
93
+ # Label
94
+ cx, cy = room.get("centroid", [0, 0])
95
+ label = room.get("label", f"#{room['id']}")
96
+ cv2.putText(vis, label, (cx-20, cy),
97
+ cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255,255,255), 2, cv2.LINE_AA)
98
+ cv2.putText(vis, str(room["id"]), (cx-6, cy+18),
99
+ cv2.FONT_HERSHEY_SIMPLEX, 0.45, (220,220,0), 1, cv2.LINE_AA)
100
+ return vis
101
+
102
+
103
+ # ── Routes ────────────────────────────────────────────────────────────────────
104
+ @app.route("/")
105
+ def index():
106
+ return render_template_string(HTML_PAGE)
107
+
108
+
109
+ @app.route("/api/session", methods=["POST"])
110
+ def create_session():
111
+ sid = _new_session()
112
+ return jsonify({"session_id": sid})
113
+
114
+
115
+ @app.route("/api/upload", methods=["POST"])
116
+ def upload():
117
+ sid = request.form.get("session_id", "")
118
+ sess = _get_session(sid)
119
+ if sess is None:
120
+ return jsonify({"error": "Invalid session"}), 400
121
+
122
+ file = request.files.get("image")
123
+ if file is None:
124
+ return jsonify({"error": "No image"}), 400
125
+
126
+ buf = np.frombuffer(file.read(), np.uint8)
127
+ img = cv2.imdecode(buf, cv2.IMREAD_COLOR)
128
+ if img is None:
129
+ return jsonify({"error": "Could not decode image"}), 400
130
+
131
+ # Downscale very large images to keep processing fast
132
+ max_px = 2400
133
+ h, w = img.shape[:2]
134
+ if max(h, w) > max_px:
135
+ scale = max_px / max(h, w)
136
+ img = cv2.resize(img, (int(w*scale), int(h*scale)), interpolation=cv2.INTER_AREA)
137
+
138
+ with _sessions_lock:
139
+ sess["original_bgr"] = img
140
+ sess["wall_mask"] = None
141
+ sess["room_mask"] = None
142
+ sess["rooms"] = []
143
+ sess["stage_images"] = {}
144
+ sess["door_lines"] = []
145
+ sess["log"] = []
146
+ sess["progress"] = 0
147
+ sess["status"] = "uploaded"
148
+
149
+ preview = _bgr_to_b64(img)
150
+ return jsonify({"preview": preview,
151
+ "width" : img.shape[1],
152
+ "height": img.shape[0]})
153
+
154
+
155
+ @app.route("/api/run", methods=["POST"])
156
+ def run_pipeline():
157
+ data = request.get_json(force=True)
158
+ sid = data.get("session_id", "")
159
+ sess = _get_session(sid)
160
+ if sess is None or sess["original_bgr"] is None:
161
+ return jsonify({"error": "No image uploaded"}), 400
162
+
163
+ def _worker():
164
+ logs = []
165
+ def progress(msg, pct):
166
+ logs.append(msg)
167
+ with _sessions_lock:
168
+ sess["log"] = logs[:]
169
+ sess["progress"] = pct
170
+
171
+ try:
172
+ with _sessions_lock:
173
+ sess["status"] = "running"
174
+
175
+ pipe = WallPipeline(progress_cb=progress)
176
+ walls, rooms_mask, cal = pipe.run(
177
+ sess["original_bgr"],
178
+ extra_door_lines=sess.get("door_lines", [])
179
+ )
180
+
181
+ # Auto-detect rooms from filtered mask
182
+ contours, _ = cv2.findContours(
183
+ rooms_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
184
+ rooms = []
185
+ for idx, cnt in enumerate(contours, 1):
186
+ area = cv2.contourArea(cnt)
187
+ bx, by, bw, bh = cv2.boundingRect(cnt)
188
+ M = cv2.moments(cnt)
189
+ cx = int(M["m10"]/M["m00"]) if M["m00"] else bx+bw//2
190
+ cy = int(M["m01"]/M["m00"]) if M["m00"] else by+bh//2
191
+ seg = cnt[:,0,:].tolist()
192
+ seg = [v for pt in seg for v in pt]
193
+ rooms.append({
194
+ "id" : idx,
195
+ "label" : f"Room {idx}",
196
+ "segmentation": [seg],
197
+ "area" : float(area),
198
+ "bbox" : [bx, by, bw, bh],
199
+ "centroid" : [cx, cy],
200
+ "confidence" : 0.95,
201
+ "isAi" : True,
202
+ })
203
+
204
+ with _sessions_lock:
205
+ sess["wall_mask"] = walls
206
+ sess["room_mask"] = rooms_mask
207
+ sess["rooms"] = rooms
208
+ sess["stage_images"] = pipe.stage_images
209
+ sess["calibration"] = cal.as_dict() if cal else {}
210
+ sess["status"] = "done"
211
+ sess["progress"] = 100
212
+
213
+ except Exception as exc:
214
+ tb = traceback.format_exc()
215
+ with _sessions_lock:
216
+ sess["status"] = "error"
217
+ sess["log"] = logs + [f"ERROR: {exc}", tb]
218
+
219
+ t = threading.Thread(target=_worker, daemon=True)
220
+ t.start()
221
+ return jsonify({"started": True})
222
+
223
+
224
+ @app.route("/api/progress", methods=["GET"])
225
+ def progress():
226
+ sid = request.args.get("session_id", "")
227
+ sess = _get_session(sid)
228
+ if sess is None:
229
+ return jsonify({"error": "Invalid session"}), 400
230
+ with _sessions_lock:
231
+ return jsonify({
232
+ "status" : sess["status"],
233
+ "progress": sess["progress"],
234
+ "log" : sess["log"][-6:] if sess["log"] else [],
235
+ })
236
+
237
+
238
+ @app.route("/api/result", methods=["GET"])
239
+ def result():
240
+ sid = request.args.get("session_id", "")
241
+ sess = _get_session(sid)
242
+ if sess is None:
243
+ return jsonify({"error": "Invalid session"}), 400
244
+
245
+ orig = sess.get("original_bgr")
246
+ walls = sess.get("wall_mask")
247
+ rooms = sess.get("rooms", [])
248
+
249
+ if orig is None:
250
+ return jsonify({"error": "No image"}), 400
251
+
252
+ composite = _composite_overlay(orig, rooms, walls)
253
+ return jsonify({
254
+ "composite" : _bgr_to_b64(composite),
255
+ "wall_mask" : _mask_to_b64(walls) if walls is not None else None,
256
+ "rooms" : rooms,
257
+ "calibration": sess.get("calibration", {}),
258
+ "gpu" : _GPU,
259
+ })
260
+
261
+
262
+ @app.route("/api/stages", methods=["GET"])
263
+ def stages():
264
+ sid = request.args.get("session_id", "")
265
+ sess = _get_session(sid)
266
+ if sess is None:
267
+ return jsonify({"error": "Invalid session"}), 400
268
+
269
+ stage_imgs = sess.get("stage_images", {})
270
+ result = {}
271
+ for key, img in stage_imgs.items():
272
+ if img is not None and isinstance(img, np.ndarray):
273
+ if len(img.shape) == 2:
274
+ disp = cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
275
+ else:
276
+ disp = img
277
+ result[key] = _bgr_to_b64(disp, max_dim=800)
278
+ return jsonify(result)
279
+
280
+
281
+ @app.route("/api/wand", methods=["POST"])
282
+ def wand():
283
+ """Click-to-segment: flood-fill from clicked pixel."""
284
+ data = request.get_json(force=True)
285
+ sid = data.get("session_id", "")
286
+ sess = _get_session(sid)
287
+ if sess is None or sess["wall_mask"] is None:
288
+ return jsonify({"error": "Run pipeline first"}), 400
289
+
290
+ click_x = int(data.get("x", 0))
291
+ click_y = int(data.get("y", 0))
292
+
293
+ pipe = WallPipeline()
294
+ new_room = pipe.wand_segment(
295
+ sess["wall_mask"], click_x, click_y, sess["rooms"]
296
+ )
297
+ if new_room is None:
298
+ return jsonify({"error": "No room found at that location"}), 400
299
+
300
+ with _sessions_lock:
301
+ sess["rooms"].append(new_room)
302
+
303
+ orig = sess["original_bgr"]
304
+ composite = _composite_overlay(orig, sess["rooms"], sess["wall_mask"])
305
+ return jsonify({
306
+ "room" : new_room,
307
+ "composite": _bgr_to_b64(composite),
308
+ "rooms" : sess["rooms"],
309
+ })
310
+
311
+
312
+ @app.route("/api/remove_room", methods=["POST"])
313
+ def remove_room():
314
+ data = request.get_json(force=True)
315
+ sid = data.get("session_id", "")
316
+ room_id = int(data.get("room_id", -1))
317
+ sess = _get_session(sid)
318
+ if sess is None:
319
+ return jsonify({"error": "Invalid session"}), 400
320
+
321
+ with _sessions_lock:
322
+ before = len(sess["rooms"])
323
+ sess["rooms"] = [r for r in sess["rooms"] if r["id"] != room_id]
324
+ removed = before - len(sess["rooms"])
325
+
326
+ if removed == 0:
327
+ return jsonify({"error": f"Room {room_id} not found"}), 404
328
+
329
+ orig = sess["original_bgr"]
330
+ composite = _composite_overlay(orig, sess["rooms"], sess["wall_mask"])
331
+ return jsonify({
332
+ "composite": _bgr_to_b64(composite),
333
+ "rooms" : sess["rooms"],
334
+ "removed" : room_id,
335
+ })
336
+
337
+
338
+ @app.route("/api/add_door_line", methods=["POST"])
339
+ def add_door_line():
340
+ data = request.get_json(force=True)
341
+ sid = data.get("session_id", "")
342
+ sess = _get_session(sid)
343
+ if sess is None:
344
+ return jsonify({"error": "Invalid session"}), 400
345
+
346
+ x1 = int(data.get("x1", 0))
347
+ y1 = int(data.get("y1", 0))
348
+ x2 = int(data.get("x2", 0))
349
+ y2 = int(data.get("y2", 0))
350
+
351
+ with _sessions_lock:
352
+ sess["door_lines"].append((x1, y1, x2, y2))
353
+
354
+ # If wall mask exists, paint immediately
355
+ orig = sess["original_bgr"]
356
+ walls = sess["wall_mask"]
357
+ if walls is not None:
358
+ stroke = sess.get("calibration", {}).get("stroke_width", 3)
359
+ lw = max(3, stroke)
360
+ cv2.line(walls, (x1, y1), (x2, y2), 255, lw)
361
+ # Re-segment rooms
362
+ pipe = WallPipeline()
363
+ rooms_mask = pipe._segment_rooms(walls)
364
+ valid_mask, contours = pipe._filter_rooms(rooms_mask, orig.shape)
365
+ rooms = []
366
+ for idx, cnt in enumerate(contours, 1):
367
+ area = cv2.contourArea(cnt)
368
+ bx_, by_, bw_, bh_ = cv2.boundingRect(cnt)
369
+ M = cv2.moments(cnt)
370
+ cx = int(M["m10"]/M["m00"]) if M["m00"] else bx_+bw_//2
371
+ cy = int(M["m01"]/M["m00"]) if M["m00"] else by_+bh_//2
372
+ seg = cnt[:,0,:].tolist()
373
+ seg = [v for pt in seg for v in pt]
374
+ rooms.append({
375
+ "id": idx, "label": f"Room {idx}",
376
+ "segmentation": [seg],
377
+ "area": float(area),
378
+ "bbox": [bx_, by_, bw_, bh_],
379
+ "centroid": [cx, cy],
380
+ "confidence": 0.95,
381
+ })
382
+ with _sessions_lock:
383
+ sess["wall_mask"] = walls
384
+ sess["room_mask"] = valid_mask
385
+ sess["rooms"] = rooms
386
+
387
+ composite = _composite_overlay(orig, sess["rooms"], sess["wall_mask"])
388
+ return jsonify({
389
+ "composite" : _bgr_to_b64(composite),
390
+ "rooms" : sess["rooms"],
391
+ "door_lines" : sess["door_lines"],
392
+ })
393
+
394
+
395
+ @app.route("/api/clear_door_lines", methods=["POST"])
396
+ def clear_door_lines():
397
+ data = request.get_json(force=True)
398
+ sid = data.get("session_id", "")
399
+ sess = _get_session(sid)
400
+ if sess is None:
401
+ return jsonify({"error": "Invalid session"}), 400
402
+ with _sessions_lock:
403
+ sess["door_lines"] = []
404
+ return jsonify({"cleared": True})
405
+
406
+
407
+ @app.route("/api/export", methods=["GET"])
408
+ def export_json():
409
+ sid = request.args.get("session_id", "")
410
+ sess = _get_session(sid)
411
+ if sess is None:
412
+ return jsonify({"error": "Invalid session"}), 400
413
+ rooms = sess.get("rooms", [])
414
+ safe = []
415
+ for r in rooms:
416
+ safe.append({k: v for k, v in r.items()
417
+ if k in ("id","label","area","bbox","centroid","confidence")})
418
+ return Response(
419
+ json.dumps({"rooms": safe, "count": len(safe)}, indent=2),
420
+ mimetype="application/json",
421
+ headers={"Content-Disposition": "attachment; filename=rooms.json"}
422
+ )
423
+
424
+
425
+ # ─────────────────────────────────────────────────────────────────────────────
426
+ # HTML / CSS / JS (single-page app)
427
+ # ─────────────────────────────────────────────────────────────────────────────
428
+ HTML_PAGE = r"""
429
+ <!DOCTYPE html>
430
+ <html lang="en">
431
+ <head>
432
+ <meta charset="UTF-8"/>
433
+ <meta name="viewport" content="width=device-width,initial-scale=1"/>
434
+ <title>Blueprint Room Extractor</title>
435
+ <style>
436
+ /* ── Reset & base ── */
437
+ *{box-sizing:border-box;margin:0;padding:0}
438
+ :root{
439
+ --bg:#0b0c10;--panel:#13151c;--panel2:#1a1d27;--border:#252836;
440
+ --accent:#00d4aa;--accent2:#6c63ff;--warn:#f59e0b;--danger:#ef4444;
441
+ --text:#e8eaf0;--muted:#8b90a0;--font:'JetBrains Mono',monospace;
442
+ }
443
+ @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@300;400;500;700&family=Syne:wght@400;600;800&display=swap');
444
+ html,body{height:100%;background:var(--bg);color:var(--text);font-family:var(--font);font-size:13px;overflow:hidden}
445
+
446
+ /* ── Layout ── */
447
+ #app{display:grid;grid-template-columns:320px 1fr 280px;grid-template-rows:56px 1fr 38px;height:100vh;gap:0}
448
+ header{grid-column:1/-1;background:var(--panel);border-bottom:1px solid var(--border);
449
+ display:flex;align-items:center;gap:16px;padding:0 20px}
450
+ header h1{font-family:'Syne',sans-serif;font-weight:800;font-size:18px;
451
+ background:linear-gradient(135deg,var(--accent),var(--accent2));
452
+ -webkit-background-clip:text;-webkit-text-fill-color:transparent;letter-spacing:-.5px}
453
+ .gpu-badge{padding:3px 10px;border-radius:20px;font-size:10px;font-weight:700;letter-spacing:.8px;
454
+ background:rgba(108,99,255,.18);color:var(--accent2);border:1px solid var(--accent2)}
455
+ .gpu-badge.on{background:rgba(0,212,170,.18);color:var(--accent);border-color:var(--accent)}
456
+
457
+ /* ── Left panel ── */
458
+ #left{background:var(--panel);border-right:1px solid var(--border);overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:14px}
459
+ .section-title{font-family:'Syne',sans-serif;font-size:11px;font-weight:600;color:var(--muted);
460
+ text-transform:uppercase;letter-spacing:1.2px;padding-bottom:6px;border-bottom:1px solid var(--border)}
461
+
462
+ /* ── Upload zone ── */
463
+ #drop-zone{border:2px dashed var(--border);border-radius:10px;padding:24px 16px;text-align:center;
464
+ cursor:pointer;transition:all .2s;color:var(--muted);position:relative}
465
+ #drop-zone:hover,#drop-zone.over{border-color:var(--accent);color:var(--accent);background:rgba(0,212,170,.04)}
466
+ #drop-zone input{position:absolute;inset:0;opacity:0;cursor:pointer}
467
+ #drop-zone .icon{font-size:28px;margin-bottom:6px}
468
+ #drop-zone p{font-size:11px}
469
+
470
+ /* ── Buttons ── */
471
+ .btn{display:flex;align-items:center;gap:6px;padding:9px 14px;border-radius:8px;border:none;cursor:pointer;
472
+ font-family:var(--font);font-size:12px;font-weight:500;transition:all .15s;width:100%;justify-content:center}
473
+ .btn-primary{background:linear-gradient(135deg,var(--accent),#00b890);color:#001a14}
474
+ .btn-primary:hover{opacity:.9;transform:translateY(-1px)}
475
+ .btn-secondary{background:var(--panel2);color:var(--text);border:1px solid var(--border)}
476
+ .btn-secondary:hover{border-color:var(--accent);color:var(--accent)}
477
+ .btn-danger{background:rgba(239,68,68,.15);color:var(--danger);border:1px solid rgba(239,68,68,.3)}
478
+ .btn-danger:hover{background:rgba(239,68,68,.25)}
479
+ .btn-warn{background:rgba(245,158,11,.13);color:var(--warn);border:1px solid rgba(245,158,11,.3)}
480
+ .btn-warn:hover{background:rgba(245,158,11,.22)}
481
+ .btn:disabled{opacity:.4;cursor:not-allowed;transform:none!important}
482
+
483
+ /* ── Tool panel ── */
484
+ .tool-group{display:flex;flex-direction:column;gap:8px}
485
+ .tool-row{display:flex;gap:7px}
486
+ .tool-btn{flex:1;padding:8px 6px;border-radius:7px;border:1px solid var(--border);background:var(--panel2);
487
+ color:var(--muted);cursor:pointer;font-size:18px;transition:all .15s;font-family:var(--font)}
488
+ .tool-btn:hover{border-color:var(--accent);color:var(--accent)}
489
+ .tool-btn.active{border-color:var(--accent);background:rgba(0,212,170,.12);color:var(--accent)}
490
+ .tool-label{font-size:10px;color:var(--muted);text-align:center;margin-top:2px}
491
+
492
+ /* ── Inputs ── */
493
+ .field{display:flex;flex-direction:column;gap:5px}
494
+ .field label{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.8px}
495
+ .field input{background:var(--panel2);border:1px solid var(--border);border-radius:6px;
496
+ color:var(--text);font-family:var(--font);font-size:12px;padding:7px 10px;width:100%}
497
+ .field input:focus{outline:none;border-color:var(--accent)}
498
+ .coord-row{display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:5px}
499
+ .coord-row input{text-align:center}
500
+
501
+ /* ── Progress ── */
502
+ #progress-bar-wrap{height:4px;background:var(--panel2);border-radius:2px;overflow:hidden;margin-top:4px}
503
+ #progress-bar{height:100%;width:0%;background:linear-gradient(90deg,var(--accent),var(--accent2));transition:width .4s}
504
+ #status-text{font-size:11px;color:var(--muted)}
505
+
506
+ /* ── Center canvas area ── */
507
+ #center{background:var(--bg);overflow:hidden;position:relative;display:flex;flex-direction:column}
508
+ #viewer-toolbar{background:var(--panel);border-bottom:1px solid var(--border);
509
+ padding:8px 14px;display:flex;align-items:center;gap:10px;flex-shrink:0}
510
+ .zoom-ctrl{display:flex;align-items:center;gap:6px}
511
+ .zoom-ctrl label{font-size:11px;color:var(--muted);min-width:38px}
512
+ .zoom-ctrl input[type=range]{width:90px;accent-color:var(--accent)}
513
+ .zoom-val{font-size:11px;color:var(--accent);min-width:38px}
514
+ #canvas-wrap{flex:1;overflow:auto;display:flex;align-items:center;justify-content:center;position:relative}
515
+ #img-layer{position:relative;transform-origin:top left;display:inline-block;line-height:0}
516
+ #img-layer img{display:block;max-width:none;cursor:crosshair;user-select:none}
517
+ #img-layer canvas{position:absolute;top:0;left:0;pointer-events:none}
518
+ .crosshair-cursor{cursor:crosshair!important}
519
+ .default-cursor{cursor:default!important}
520
+
521
+ /* ── Tab view (stages) ── */
522
+ #tabs{display:flex;gap:1px;background:var(--bg);padding:0 14px}
523
+ .tab{padding:8px 14px;font-size:11px;cursor:pointer;color:var(--muted);border-bottom:2px solid transparent;transition:all .15s}
524
+ .tab:hover{color:var(--text)}
525
+ .tab.active{color:var(--accent);border-bottom-color:var(--accent)}
526
+
527
+ /* ── Right panel ── */
528
+ #right{background:var(--panel);border-left:1px solid var(--border);overflow-y:auto;padding:14px;display:flex;flex-direction:column;gap:12px}
529
+ #rooms-list{display:flex;flex-direction:column;gap:6px}
530
+ .room-card{background:var(--panel2);border:1px solid var(--border);border-radius:8px;padding:10px 12px;
531
+ display:flex;align-items:center;gap:8px;transition:border-color .15s}
532
+ .room-card:hover{border-color:var(--accent)}
533
+ .room-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
534
+ .room-info{flex:1;min-width:0}
535
+ .room-name{font-size:12px;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
536
+ .room-meta{font-size:10px;color:var(--muted)}
537
+ .room-del{background:none;border:none;color:var(--muted);cursor:pointer;font-size:14px;padding:2px 5px;
538
+ border-radius:4px;transition:all .15s}
539
+ .room-del:hover{color:var(--danger);background:rgba(239,68,68,.1)}
540
+
541
+ /* ── Calibration display ── */
542
+ .cal-grid{display:grid;grid-template-columns:1fr 1fr;gap:5px}
543
+ .cal-item{background:var(--panel2);border-radius:6px;padding:7px 10px}
544
+ .cal-key{font-size:9px;color:var(--muted);text-transform:uppercase;letter-spacing:.7px}
545
+ .cal-val{font-size:13px;font-weight:500;color:var(--accent);margin-top:2px}
546
+
547
+ /* ── Log ── */
548
+ #log-box{background:var(--panel2);border-radius:8px;padding:10px;font-size:10px;color:var(--muted);
549
+ max-height:130px;overflow-y:auto;line-height:1.6}
550
+
551
+ /* ── Status bar ── */
552
+ footer{grid-column:1/-1;background:var(--panel);border-top:1px solid var(--border);
553
+ padding:0 16px;display:flex;align-items:center;gap:16px;font-size:10px;color:var(--muted)}
554
+ footer span{display:flex;align-items:center;gap:5px}
555
+ .dot{width:7px;height:7px;border-radius:50%;background:var(--muted)}
556
+ .dot.green{background:var(--accent)}
557
+ .dot.orange{background:var(--warn);animation:pulse .8s infinite}
558
+ .dot.red{background:var(--danger)}
559
+ @keyframes pulse{0%,100%{opacity:1}50%{opacity:.3}}
560
+
561
+ /* ── Stage grid ── */
562
+ #stages-grid{display:grid;grid-template-columns:1fr 1fr;gap:8px;padding:10px}
563
+ .stage-card{background:var(--panel2);border:1px solid var(--border);border-radius:8px;overflow:hidden}
564
+ .stage-card img{width:100%;display:block}
565
+ .stage-card-label{padding:4px 8px;font-size:10px;color:var(--muted)}
566
+
567
+ /* ── Divider ── */
568
+ .divider{height:1px;background:var(--border);margin:2px 0}
569
+
570
+ /* ── Toast ── */
571
+ #toast{position:fixed;bottom:50px;left:50%;transform:translateX(-50%) translateY(20px);
572
+ background:var(--panel);border:1px solid var(--border);border-radius:10px;
573
+ padding:10px 20px;font-size:12px;opacity:0;transition:all .3s;pointer-events:none;z-index:999}
574
+ #toast.show{opacity:1;transform:translateX(-50%) translateY(0)}
575
+ #toast.err{border-color:var(--danger);color:var(--danger)}
576
+ #toast.ok{border-color:var(--accent);color:var(--accent)}
577
+ </style>
578
+ </head>
579
+ <body>
580
+ <div id="app">
581
+
582
+ <!-- HEADER -->
583
+ <header>
584
+ <div>πŸ—οΈ</div>
585
+ <h1>Blueprint Room Extractor</h1>
586
+ <div class="gpu-badge" id="gpu-badge">CPU</div>
587
+ <div style="flex:1"></div>
588
+ <button class="btn btn-secondary" style="width:auto" onclick="exportJSON()">⬇ Export JSON</button>
589
+ </header>
590
+
591
+ <!-- LEFT PANEL -->
592
+ <div id="left">
593
+
594
+ <div class="section-title">Image</div>
595
+ <div id="drop-zone">
596
+ <input type="file" id="file-input" accept="image/*" onchange="handleFile(this.files[0])"/>
597
+ <div class="icon">πŸ–ΌοΈ</div>
598
+ <p>Drop blueprint or click to upload</p>
599
+ </div>
600
+
601
+ <button class="btn btn-primary" id="run-btn" disabled onclick="runPipeline()">
602
+ ⚑ Run Wall Extraction
603
+ </button>
604
+
605
+ <div id="progress-bar-wrap"><div id="progress-bar"></div></div>
606
+ <div id="status-text">Idle</div>
607
+
608
+ <div class="divider"></div>
609
+ <div class="section-title">Tools</div>
610
+
611
+ <div class="tool-group">
612
+ <div class="tool-row">
613
+ <div style="flex:1;text-align:center">
614
+ <button class="tool-btn" id="tool-pan" onclick="setTool('pan')" title="Pan / Zoom">πŸ”</button>
615
+ <div class="tool-label">Pan/Zoom</div>
616
+ </div>
617
+ <div style="flex:1;text-align:center">
618
+ <button class="tool-btn active" id="tool-wand" onclick="setTool('wand')" title="Magic Wand">πŸͺ„</button>
619
+ <div class="tool-label">Wand</div>
620
+ </div>
621
+ <div style="flex:1;text-align:center">
622
+ <button class="tool-btn" id="tool-door" onclick="setTool('door')" title="Door Seal">πŸšͺ</button>
623
+ <div class="tool-label">Door Line</div>
624
+ </div>
625
+ </div>
626
+ <div id="tool-hint" style="font-size:10px;color:var(--muted);text-align:center;padding:4px 0">
627
+ Wand: click image to detect room
628
+ </div>
629
+ </div>
630
+
631
+ <div class="divider"></div>
632
+
633
+ <!-- DOOR LINE manual entry -->
634
+ <div class="section-title">πŸšͺ Door Seal Line</div>
635
+ <div style="font-size:10px;color:var(--muted);margin-bottom:4px">Enter pixel coords or click on image with Door tool</div>
636
+ <div class="coord-row">
637
+ <div class="field"><label>X1</label><input id="dl-x1" type="number" value="0" min="0"/></div>
638
+ <div class="field"><label>Y1</label><input id="dl-y1" type="number" value="0" min="0"/></div>
639
+ <div class="field"><label>X2</label><input id="dl-x2" type="number" value="100" min="0"/></div>
640
+ <div class="field"><label>Y2</label><input id="dl-y2" type="number" value="0" min="0"/></div>
641
+ </div>
642
+ <button class="btn btn-warn" onclick="addDoorLine()">πŸšͺ Add Door Seal Line</button>
643
+ <button class="btn btn-secondary" onclick="clearDoorLines()" style="font-size:11px">Clear All Door Lines</button>
644
+ <div id="door-lines-list" style="font-size:10px;color:var(--muted)"></div>
645
+
646
+ <div class="divider"></div>
647
+
648
+ <!-- REMOVE ROOM -->
649
+ <div class="section-title">πŸ—‘οΈ Remove Room</div>
650
+ <div style="display:flex;gap:6px">
651
+ <div class="field" style="flex:1"><label>Room ID</label><input id="remove-id" type="number" min="1" value="1"/></div>
652
+ <button class="btn btn-danger" style="width:auto;margin-top:16px;padding:8px 12px" onclick="removeRoom()">Del</button>
653
+ </div>
654
+
655
+ </div>
656
+
657
+ <!-- CENTER: canvas + tab strip -->
658
+ <div id="center">
659
+ <div id="viewer-toolbar">
660
+ <div class="zoom-ctrl">
661
+ <label>Zoom</label>
662
+ <input type="range" id="zoom-slider" min="20" max="500" value="100" oninput="applyZoom(this.value)"/>
663
+ <span class="zoom-val" id="zoom-val">100%</span>
664
+ </div>
665
+ <div class="zoom-ctrl">
666
+ <label>Pan X</label>
667
+ <input type="range" id="pan-x" min="-2000" max="2000" value="0" oninput="applyPan()"/>
668
+ </div>
669
+ <div class="zoom-ctrl">
670
+ <label>Pan Y</label>
671
+ <input type="range" id="pan-y" min="-2000" max="2000" value="0" oninput="applyPan()"/>
672
+ </div>
673
+ <button class="btn btn-secondary" style="width:auto;padding:5px 12px;font-size:11px" onclick="resetView()">βŒ‚ Reset</button>
674
+ <div id="tabs" style="flex:1;display:flex;justify-content:flex-end">
675
+ <div class="tab active" onclick="switchTab('result')">Result</div>
676
+ <div class="tab" onclick="switchTab('walls')">Walls</div>
677
+ <div class="tab" onclick="switchTab('stages')">Stages</div>
678
+ </div>
679
+ </div>
680
+ <div id="canvas-wrap">
681
+ <!-- Result tab -->
682
+ <div id="tab-result" style="position:relative;width:100%;height:100%;display:flex;align-items:center;justify-content:center">
683
+ <div id="img-layer">
684
+ <img id="main-img" src="" alt="" style="display:none" onclick="onCanvasClick(event)"/>
685
+ <canvas id="overlay-canvas"></canvas>
686
+ </div>
687
+ </div>
688
+ <!-- Walls tab -->
689
+ <div id="tab-walls" style="display:none;width:100%;height:100%;align-items:center;justify-content:center">
690
+ <img id="walls-img" src="" style="max-width:100%;max-height:100%;border-radius:6px"/>
691
+ </div>
692
+ <!-- Stages tab -->
693
+ <div id="tab-stages" style="display:none;overflow-y:auto;width:100%;height:100%">
694
+ <div id="stages-grid"></div>
695
+ </div>
696
+ </div>
697
+ </div>
698
+
699
+ <!-- RIGHT PANEL -->
700
+ <div id="right">
701
+ <div class="section-title">Rooms (<span id="room-count">0</span>)</div>
702
+ <div id="rooms-list"><div style="color:var(--muted);font-size:11px;text-align:center;padding:20px 0">Run pipeline to detect rooms</div></div>
703
+
704
+ <div class="divider"></div>
705
+ <div class="section-title">Calibration</div>
706
+ <div class="cal-grid" id="cal-grid">
707
+ <div style="color:var(--muted);font-size:11px;grid-column:1/-1">β€”</div>
708
+ </div>
709
+
710
+ <div class="divider"></div>
711
+ <div class="section-title">Log</div>
712
+ <div id="log-box">Ready.</div>
713
+ </div>
714
+
715
+ <!-- FOOTER -->
716
+ <footer>
717
+ <span><div class="dot" id="status-dot"></div><span id="footer-status">Idle</span></span>
718
+ <span id="footer-coords" style="font-family:monospace">x:β€” y:β€”</span>
719
+ <span style="margin-left:auto" id="footer-rooms">0 rooms</span>
720
+ </footer>
721
+
722
+ </div><!-- #app -->
723
+
724
+ <div id="toast"></div>
725
+
726
+ <script>
727
+ // ── State ──────────────────────────────────────────────────────────────────
728
+ let SID = null;
729
+ let activeTool = 'wand';
730
+ let zoomLevel = 100;
731
+ let imgW = 0, imgH = 0;
732
+ let doorStart = null;
733
+ let pollingTimer = null;
734
+ let doorLines = [];
735
+
736
+ const ROOM_COLORS = ['#00d4aa','#6c63ff','#f59e0b','#ef4444','#10b981',
737
+ '#3b82f6','#ec4899','#8b5cf6','#14b8a6','#f97316'];
738
+
739
+ // ── Init ────────────────────────────────────────────────────────────────────
740
+ async function init(){
741
+ const r = await fetch('/api/session',{method:'POST'});
742
+ const d = await r.json();
743
+ SID = d.session_id;
744
+ }
745
+ init();
746
+
747
+ // ── File handling ─────────────────────────────────────────────────────────
748
+ const dz = document.getElementById('drop-zone');
749
+ dz.addEventListener('dragover', e=>{e.preventDefault();dz.classList.add('over')});
750
+ dz.addEventListener('dragleave', ()=>dz.classList.remove('over'));
751
+ dz.addEventListener('drop', e=>{e.preventDefault();dz.classList.remove('over');
752
+ if(e.dataTransfer.files[0]) handleFile(e.dataTransfer.files[0])});
753
+
754
+ async function handleFile(file){
755
+ if(!file||!SID) return;
756
+ const fd = new FormData();
757
+ fd.append('session_id', SID);
758
+ fd.append('image', file);
759
+ setStatus('Uploading...','orange');
760
+ const r = await fetch('/api/upload',{method:'POST',body:fd});
761
+ const d = await r.json();
762
+ if(d.error){toast(d.error,true);return;}
763
+ imgW = d.width; imgH = d.height;
764
+ showMainImg('data:image/jpeg;base64,'+d.preview);
765
+ document.getElementById('run-btn').disabled = false;
766
+ setStatus('Image loaded','green');
767
+ toast('Image loaded βœ“');
768
+ }
769
+
770
+ // ── Pipeline ──────────────────────────────────────────────────────────────
771
+ async function runPipeline(){
772
+ if(!SID) return;
773
+ document.getElementById('run-btn').disabled = true;
774
+ setStatus('Running...','orange');
775
+ setLog(['Starting wall extraction pipeline...']);
776
+ setProgress(0);
777
+
778
+ const r = await fetch('/api/run',{method:'POST',
779
+ headers:{'Content-Type':'application/json'},
780
+ body:JSON.stringify({session_id:SID})});
781
+ const d = await r.json();
782
+ if(d.error){toast(d.error,true);document.getElementById('run-btn').disabled=false;return;}
783
+
784
+ pollingTimer = setInterval(pollProgress, 700);
785
+ }
786
+
787
+ async function pollProgress(){
788
+ const r = await fetch('/api/progress?session_id='+SID);
789
+ const d = await r.json();
790
+ setProgress(d.progress||0);
791
+ if(d.log) setLog(d.log);
792
+ setStatus(d.status==='running'?'Processing…':d.status, d.status==='error'?'red':d.status==='done'?'green':'orange');
793
+ if(d.status==='done'||d.status==='error'){
794
+ clearInterval(pollingTimer);
795
+ if(d.status==='done') loadResult();
796
+ document.getElementById('run-btn').disabled = false;
797
+ }
798
+ }
799
+
800
+ async function loadResult(){
801
+ const r = await fetch('/api/result?session_id='+SID);
802
+ const d = await r.json();
803
+ if(d.error){toast(d.error,true);return;}
804
+
805
+ // GPU badge
806
+ const badge = document.getElementById('gpu-badge');
807
+ badge.textContent = d.gpu ? 'GPU ⚑' : 'CPU';
808
+ badge.className = 'gpu-badge'+(d.gpu?' on':'');
809
+
810
+ showMainImg('data:image/jpeg;base64,'+d.composite);
811
+ if(d.wall_mask) document.getElementById('walls-img').src='data:image/jpeg;base64,'+d.wall_mask;
812
+ updateRooms(d.rooms||[]);
813
+ updateCalibration(d.calibration||{});
814
+ loadStages();
815
+ toast(`βœ“ Detected ${(d.rooms||[]).length} rooms`,'ok');
816
+ }
817
+
818
+ async function loadStages(){
819
+ const r = await fetch('/api/stages?session_id='+SID);
820
+ const d = await r.json();
821
+ const grid = document.getElementById('stages-grid');
822
+ grid.innerHTML='';
823
+ const labels = {
824
+ '01_title_removed':'1. Title Block Removed',
825
+ '02_colors_removed':'2. Colors Removed',
826
+ '03_door_arcs':'3. Door Arcs Closed',
827
+ '04_walls_raw':'4. Walls Extracted',
828
+ '05b_no_fixtures':'5b. Fixtures Removed',
829
+ '05c_thin_removed':'5c. Thin Lines Removed',
830
+ '05d_bridged':'5d. Endpoints Bridged',
831
+ '05e_doors_closed':'5e. Door Openings Closed',
832
+ '05f_dangling_removed':'5f. Dangling Lines Removed',
833
+ '05g_large_gaps':'5g. Large Gaps Sealed',
834
+ '07_rooms':'7. Room Segmentation',
835
+ '08_rooms_filtered':'8. Filtered Rooms',
836
+ };
837
+ for(const [key, b64] of Object.entries(d)){
838
+ const card = document.createElement('div');
839
+ card.className='stage-card';
840
+ card.innerHTML=`<img src="data:image/jpeg;base64,${b64}"/>
841
+ <div class="stage-card-label">${labels[key]||key}</div>`;
842
+ grid.appendChild(card);
843
+ }
844
+ }
845
+
846
+ // ── Wand tool ─────────────────────────────────────────────────────────────
847
+ async function onCanvasClick(e){
848
+ const img = document.getElementById('main-img');
849
+ if(!img.src||img.src==='') return;
850
+
851
+ const rect = img.getBoundingClientRect();
852
+ const scaleX = imgW / (rect.width * (zoomLevel/100));
853
+ const scaleY = imgH / (rect.height * (zoomLevel/100));
854
+ const rawX = (e.clientX - rect.left) * scaleX;
855
+ const rawY = (e.clientY - rect.top) * scaleY;
856
+ const px = Math.round(rawX / (zoomLevel/100));
857
+ const py = Math.round(rawY / (zoomLevel/100));
858
+
859
+ if(activeTool==='wand'){
860
+ await doWand(Math.round(rawX), Math.round(rawY));
861
+ } else if(activeTool==='door'){
862
+ await doDoorClick(Math.round(rawX), Math.round(rawY));
863
+ }
864
+ }
865
+
866
+ function getImgCoords(e){
867
+ const img = document.getElementById('main-img');
868
+ const rect = img.getBoundingClientRect();
869
+ // account for CSS zoom transform
870
+ const zoom = zoomLevel/100;
871
+ const x = Math.round((e.clientX - rect.left) / zoom);
872
+ const y = Math.round((e.clientY - rect.top) / zoom);
873
+ return {x,y};
874
+ }
875
+
876
+ function onImgMouseMove(e){
877
+ const {x,y} = getImgCoords(e);
878
+ document.getElementById('footer-coords').textContent=`x:${x} y:${y}`;
879
+ }
880
+
881
+ async function doWand(x,y){
882
+ if(!SID){toast('Run pipeline first',true);return;}
883
+ toast('πŸͺ„ Detecting room...');
884
+ const r = await fetch('/api/wand',{method:'POST',
885
+ headers:{'Content-Type':'application/json'},
886
+ body:JSON.stringify({session_id:SID,x,y})});
887
+ const d = await r.json();
888
+ if(d.error){toast(d.error,true);return;}
889
+ showMainImg('data:image/jpeg;base64,'+d.composite);
890
+ updateRooms(d.rooms||[]);
891
+ toast(`βœ“ Added Room ${d.room.id}`);
892
+ }
893
+
894
+ // ── Door line tool ─────────────────────────────────────────────────────────
895
+ async function doDoorClick(x,y){
896
+ if(doorStart===null){
897
+ doorStart = {x,y};
898
+ document.getElementById('dl-x1').value=x;
899
+ document.getElementById('dl-y1').value=y;
900
+ toast(`Door start: (${x},${y}) β€” click end point`);
901
+ } else {
902
+ document.getElementById('dl-x2').value=x;
903
+ document.getElementById('dl-y2').value=y;
904
+ doorStart = null;
905
+ await addDoorLine();
906
+ }
907
+ }
908
+
909
+ async function addDoorLine(){
910
+ if(!SID) return;
911
+ const x1=+document.getElementById('dl-x1').value;
912
+ const y1=+document.getElementById('dl-y1').value;
913
+ const x2=+document.getElementById('dl-x2').value;
914
+ const y2=+document.getElementById('dl-y2').value;
915
+ const r = await fetch('/api/add_door_line',{method:'POST',
916
+ headers:{'Content-Type':'application/json'},
917
+ body:JSON.stringify({session_id:SID,x1,y1,x2,y2})});
918
+ const d = await r.json();
919
+ if(d.error){toast(d.error,true);return;}
920
+ doorLines = d.door_lines||[];
921
+ renderDoorLinesList();
922
+ showMainImg('data:image/jpeg;base64,'+d.composite);
923
+ updateRooms(d.rooms||[]);
924
+ toast(`βœ“ Door seal line added`);
925
+ }
926
+
927
+ async function clearDoorLines(){
928
+ if(!SID) return;
929
+ await fetch('/api/clear_door_lines',{method:'POST',
930
+ headers:{'Content-Type':'application/json'},
931
+ body:JSON.stringify({session_id:SID})});
932
+ doorLines=[];
933
+ renderDoorLinesList();
934
+ toast('Door lines cleared');
935
+ }
936
+
937
+ function renderDoorLinesList(){
938
+ const el = document.getElementById('door-lines-list');
939
+ if(!doorLines.length){el.textContent='No door lines';return;}
940
+ el.innerHTML=doorLines.map((l,i)=>
941
+ `<div style="padding:2px 0;border-bottom:1px solid var(--border)">#${i+1}: (${l[0]},${l[1]})β†’(${l[2]},${l[3]})</div>`
942
+ ).join('');
943
+ }
944
+
945
+ // ── Remove room ───────────────────────────────────────────────────────────
946
+ async function removeRoom(){
947
+ const id = +document.getElementById('remove-id').value;
948
+ if(!SID||!id) return;
949
+ const r = await fetch('/api/remove_room',{method:'POST',
950
+ headers:{'Content-Type':'application/json'},
951
+ body:JSON.stringify({session_id:SID,room_id:id})});
952
+ const d = await r.json();
953
+ if(d.error){toast(d.error,true);return;}
954
+ showMainImg('data:image/jpeg;base64,'+d.composite);
955
+ updateRooms(d.rooms||[]);
956
+ toast(`βœ“ Room ${id} removed`);
957
+ }
958
+
959
+ // ── View helpers ──────────────────────────────────────────────────────────
960
+ function showMainImg(src){
961
+ const img = document.getElementById('main-img');
962
+ img.src=src;
963
+ img.style.display='block';
964
+ img.onmousemove = onImgMouseMove;
965
+ img.onclick = onCanvasClick;
966
+ }
967
+
968
+ function applyZoom(v){
969
+ zoomLevel = +v;
970
+ document.getElementById('zoom-val').textContent=v+'%';
971
+ const layer = document.getElementById('img-layer');
972
+ const panX = document.getElementById('pan-x').value;
973
+ const panY = document.getElementById('pan-y').value;
974
+ layer.style.transform=`scale(${v/100}) translate(${panX}px,${panY}px)`;
975
+ }
976
+
977
+ function applyPan(){
978
+ applyZoom(zoomLevel);
979
+ }
980
+
981
+ function resetView(){
982
+ zoomLevel=100;
983
+ document.getElementById('zoom-slider').value=100;
984
+ document.getElementById('pan-x').value=0;
985
+ document.getElementById('pan-y').value=0;
986
+ applyZoom(100);
987
+ }
988
+
989
+ function setTool(t){
990
+ activeTool=t;
991
+ document.querySelectorAll('.tool-btn').forEach(b=>b.classList.remove('active'));
992
+ document.getElementById('tool-'+t).classList.add('active');
993
+ const hints = {
994
+ pan:'Pan/Zoom: use sliders above the canvas',
995
+ wand:'Wand: click image to detect & add a room',
996
+ door:'Door: click two points to draw a seal line'
997
+ };
998
+ document.getElementById('tool-hint').textContent=hints[t];
999
+ doorStart=null;
1000
+ }
1001
+
1002
+ function switchTab(name){
1003
+ document.querySelectorAll('.tab').forEach(t=>t.classList.remove('active'));
1004
+ event.target.classList.add('active');
1005
+ document.getElementById('tab-result').style.display=name==='result'?'flex':'none';
1006
+ document.getElementById('tab-walls').style.display =name==='walls' ?'flex':'none';
1007
+ document.getElementById('tab-stages').style.display=name==='stages'?'block':'none';
1008
+ }
1009
+
1010
+ // ── Room list ─────────────────────────────────────────────────────────────
1011
+ function updateRooms(rooms){
1012
+ const list = document.getElementById('rooms-list');
1013
+ document.getElementById('room-count').textContent=rooms.length;
1014
+ document.getElementById('footer-rooms').textContent=rooms.length+' rooms';
1015
+ if(!rooms.length){
1016
+ list.innerHTML='<div style="color:var(--muted);font-size:11px;text-align:center;padding:12px">No rooms detected</div>';
1017
+ return;
1018
+ }
1019
+ list.innerHTML=rooms.map((r,i)=>{
1020
+ const col=ROOM_COLORS[i%ROOM_COLORS.length];
1021
+ const areaPx=Math.round(r.area||0);
1022
+ return `<div class="room-card">
1023
+ <div class="room-dot" style="background:${col}"></div>
1024
+ <div class="room-info">
1025
+ <div class="room-name">#${r.id} ${r.label||''}</div>
1026
+ <div class="room-meta">${areaPx.toLocaleString()} pxΒ² Β· [${(r.bbox||[]).join(',')}]</div>
1027
+ </div>
1028
+ <button class="room-del" title="Delete room" onclick="quickDelete(${r.id})">βœ•</button>
1029
+ </div>`;
1030
+ }).join('');
1031
+ }
1032
+
1033
+ async function quickDelete(id){
1034
+ document.getElementById('remove-id').value=id;
1035
+ await removeRoom();
1036
+ }
1037
+
1038
+ // ── Calibration ───────────────────────────────────────────────────────────
1039
+ function updateCalibration(cal){
1040
+ const grid = document.getElementById('cal-grid');
1041
+ const entries=[
1042
+ ['Stroke','stroke_width','px'],
1043
+ ['Bridge gap','bridge_max_gap','px'],
1044
+ ['Door gap','door_gap','px'],
1045
+ ['Min dim','min_component_dim','px'],
1046
+ ];
1047
+ grid.innerHTML=entries.map(([label,key,unit])=>`
1048
+ <div class="cal-item">
1049
+ <div class="cal-key">${label}</div>
1050
+ <div class="cal-val">${cal[key]??'β€”'}${cal[key]!==undefined?unit:''}</div>
1051
+ </div>`).join('');
1052
+ }
1053
+
1054
+ // ── Misc ─────────────────────────────────────────────────────────────────
1055
+ function setProgress(pct){
1056
+ document.getElementById('progress-bar').style.width=pct+'%';
1057
+ document.getElementById('status-text').textContent=pct+'%';
1058
+ }
1059
+
1060
+ function setLog(lines){
1061
+ const el=document.getElementById('log-box');
1062
+ el.innerHTML=lines.map(l=>`<div>${l}</div>`).join('');
1063
+ el.scrollTop=el.scrollHeight;
1064
+ }
1065
+
1066
+ function setStatus(msg,color=''){
1067
+ document.getElementById('footer-status').textContent=msg;
1068
+ const dot=document.getElementById('status-dot');
1069
+ dot.className='dot'+(color?' '+color:'');
1070
+ }
1071
+
1072
+ function toast(msg,err=false){
1073
+ const t=document.getElementById('toast');
1074
+ t.textContent=msg;
1075
+ t.className='show'+(err?' err':' ok');
1076
+ clearTimeout(t._tid);
1077
+ t._tid=setTimeout(()=>{t.className=''},2800);
1078
+ }
1079
+
1080
+ async function exportJSON(){
1081
+ if(!SID) return;
1082
+ window.location='/api/export?session_id='+SID;
1083
+ }
1084
+ </script>
1085
+ </body>
1086
+ </html>
1087
+ """
1088
+
1089
+ if __name__ == "__main__":
1090
+ print("=" * 60)
1091
+ print(" Blueprint Room Extractor")
1092
+ print(f" GPU acceleration: {'ON (CuPy)' if _GPU else 'OFF (CPU fallback)'}")
1093
+ print(" Open: http://localhost:7860")
1094
+ print("=" * 60)
1095
+ app.run(host="0.0.0.0", port=7860, debug=False, threaded=True)