mat12322 commited on
Commit
8a66464
Β·
verified Β·
1 Parent(s): 767f9c4

Deploy Takeoff AI Annotator

Browse files
Files changed (1) hide show
  1. app.py +268 -41
app.py CHANGED
@@ -118,7 +118,7 @@ def _load_image_for_detect(image_path, pdf_path, pdf_page):
118
  # ---------------------------------------------------------------------------
119
  # Canvas helpers β€” HTML5 canvas replaces Plotly for the Review & Edit tab
120
  # ---------------------------------------------------------------------------
121
- _CANVAS_MAX_PX = 1400 # max dimension sent to canvas (keeps base64 small)
122
 
123
 
124
  def _build_canvas_json(img_rgb, annotations, selected=-1):
@@ -171,23 +171,33 @@ def _build_canvas_json(img_rgb, annotations, selected=-1):
171
  )
172
 
173
 
 
 
 
 
 
 
 
 
174
  def _build_review_df(annotations):
175
  if not PANDAS_SUPPORT or not annotations:
176
  return []
177
- return pd.DataFrame(
178
- [
179
- {
180
- "#": i + 1,
181
- "x1": round(ann["x1"]),
182
- "y1": round(ann["y1"]),
183
- "x2": round(ann["x2"]),
184
- "y2": round(ann["y2"]),
185
- "conf": f"{ann.get('conf', 1.0):.2f}",
186
- "status": ann.get("status", "pending"),
187
- }
188
- for i, ann in enumerate(annotations)
189
- ]
190
- )
 
 
191
 
192
 
193
  # ---------------------------------------------------------------------------
@@ -773,6 +783,68 @@ def on_rv_detect(rv_pdf, rv_page, rv_annotations, rv_conf):
773
  )
774
 
775
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
776
  def on_rv_detect_all(rv_pdf, rv_page, rv_total, rv_annotations, rv_conf):
777
  if rv_pdf is None:
778
  return _build_canvas_json(None, []), _build_review_df([]), rv_annotations, "No PDF loaded."
@@ -938,6 +1010,30 @@ def on_rv_approve_selected(rv_pdf, rv_page, rv_annotations, ledger, selected_idx
938
  )
939
 
940
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
941
  def on_rv_delete_selected(rv_pdf, rv_page, rv_annotations, ledger, selected_idx):
942
  anns = rv_annotations.get(rv_page, [])
943
  if rv_pdf is None or not anns or selected_idx is None or selected_idx < 0:
@@ -1015,9 +1111,12 @@ _model_version = get_model_version()
1015
 
1016
  _CANVAS_HTML = """
1017
  <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;">
 
 
 
1018
  <canvas id="rv-ann-canvas" style="display:block;width:100%;cursor:crosshair;touch-action:none;"></canvas>
1019
  <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;">
1020
- 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>
1021
  </div>
1022
  </div>
1023
  <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>
@@ -1037,6 +1136,9 @@ _CANVAS_JS = """
1037
  var anns = [], selected = -1;
1038
  var drag = null;
1039
  var lastRender = '';
 
 
 
1040
 
1041
  // ── Boot ────────────────────────────────────────────────────────────────
1042
  var _iv = setInterval(function() {
@@ -1048,6 +1150,12 @@ _CANVAS_JS = """
1048
  canvas.addEventListener('mousemove', onMove);
1049
  canvas.addEventListener('mouseup', onUp);
1050
  canvas.addEventListener('mouseleave', function(e) { hideTooltip(); if (drag) { onUp(e); } });
 
 
 
 
 
 
1051
  new ResizeObserver(refit).observe(canvas.parentElement);
1052
  setInterval(checkRender, 150);
1053
  }
@@ -1068,6 +1176,7 @@ _CANVAS_JS = """
1068
  var msg = document.getElementById('rv-canvas-msg');
1069
  if (d.image && d.image !== cachedImgSrc) {
1070
  imgW = d.imageW; imgH = d.imageH;
 
1071
  var im = new Image();
1072
  im.onload = function() { cachedImg = im; cachedImgSrc = d.image; refit(); };
1073
  im.onerror = function() { console.warn('Canvas: failed to load image'); };
@@ -1084,20 +1193,56 @@ _CANVAS_JS = """
1084
  var w = canvas.parentElement.clientWidth || 800;
1085
  canvas.width = Math.round(w);
1086
  canvas.height = Math.round(imgH * (w / imgW));
 
1087
  draw();
1088
  }
1089
 
1090
- function s() { return canvas && imgW ? canvas.width / imgW : 1; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1091
 
1092
  // ── Draw ─────────────────────────────────────────────────────────────────
1093
  function draw() {
1094
  if (!canvas) return;
1095
  var sc = s();
1096
  ctx.clearRect(0, 0, canvas.width, canvas.height);
1097
- if (cachedImg) ctx.drawImage(cachedImg, 0, 0, canvas.width, canvas.height);
1098
 
1099
  anns.forEach(function(ann, i) {
1100
- var x1 = ann.x1*sc, y1 = ann.y1*sc, x2 = ann.x2*sc, y2 = ann.y2*sc;
 
1101
  var c = COLORS[ann.status] || COLORS.pending;
1102
  var sel = (i === selected);
1103
 
@@ -1121,14 +1266,26 @@ _CANVAS_JS = """
1121
  }
1122
  });
1123
 
1124
- // Draw-in-progress preview
1125
  if (drag && drag.mode === 'draw') {
 
1126
  ctx.strokeStyle = '#2563EB'; ctx.lineWidth = 2;
1127
  ctx.setLineDash([5, 4]);
1128
- ctx.strokeRect(drag.ox, drag.oy, drag.cx - drag.ox, drag.cy - drag.oy);
1129
  ctx.setLineDash([]);
1130
  ctx.fillStyle = 'rgba(37,99,235,0.1)';
1131
- ctx.fillRect(drag.ox, drag.oy, drag.cx - drag.ox, drag.cy - drag.oy);
 
 
 
 
 
 
 
 
 
 
 
1132
  }
1133
  }
1134
 
@@ -1155,38 +1312,58 @@ _CANVAS_JS = """
1155
  hoveredIdx = -1;
1156
  }
1157
 
1158
- // ── Hit testing ──────────────────────────────────────────────────────────
1159
- function hitHandle(ann, cx, cy) {
1160
- var sc = s(), h = HANDLE;
1161
  var pts = [
1162
- [ann.x1*sc, ann.y1*sc, 'nw'], [ann.x2*sc, ann.y1*sc, 'ne'],
1163
- [ann.x2*sc, ann.y2*sc, 'se'], [ann.x1*sc, ann.y2*sc, 'sw'],
1164
  ];
1165
  for (var i = 0; i < pts.length; i++) {
1166
- if (Math.abs(cx - pts[i][0]) <= h && Math.abs(cy - pts[i][1]) <= h)
1167
  return pts[i][2];
1168
  }
1169
  return null;
1170
  }
1171
 
1172
- function hitBox(ann, cx, cy) {
1173
- var sc = s();
1174
- return cx >= ann.x1*sc && cx <= ann.x2*sc && cy >= ann.y1*sc && cy <= ann.y2*sc;
1175
  }
1176
 
 
1177
  function pos(e) {
1178
  var r = canvas.getBoundingClientRect();
1179
- var sx = canvas.width / r.width;
1180
- var sy = canvas.height / r.height;
1181
- return { x: (e.clientX - r.left) * sx, y: (e.clientY - r.top) * sy };
 
1182
  }
1183
 
1184
- function fi(v) { return v / s(); }
1185
-
1186
  // ── Mouse ────────────────────────────────────────────────────────────────
1187
  function onDown(e) {
1188
  hideTooltip();
 
 
 
 
 
 
 
 
1189
  var p = pos(e);
 
 
 
 
 
 
 
 
 
 
 
 
 
1190
  // Resize handles on selected box take priority
1191
  if (selected >= 0 && selected < anns.length) {
1192
  var dir = hitHandle(anns[selected], p.x, p.y);
@@ -1198,6 +1375,7 @@ _CANVAS_JS = """
1198
  }
1199
  // Click inside any box to select + move
1200
  for (var i = anns.length - 1; i >= 0; i--) {
 
1201
  if (hitBox(anns[i], p.x, p.y)) {
1202
  selected = i;
1203
  drag = { mode:'move', idx:i,
@@ -1214,11 +1392,20 @@ _CANVAS_JS = """
1214
  }
1215
 
1216
  function onMove(e) {
 
 
 
 
 
 
 
 
1217
  var p = pos(e);
1218
  // Tooltip on hover (only when not dragging)
1219
  if (!drag) {
1220
  var hovered = -1;
1221
  for (var j = anns.length - 1; j >= 0; j--) {
 
1222
  if (hitBox(anns[j], p.x, p.y)) { hovered = j; break; }
1223
  }
1224
  if (hovered >= 0) {
@@ -1228,7 +1415,7 @@ _CANVAS_JS = """
1228
  }
1229
  return;
1230
  }
1231
- var dx = fi(p.x - drag.ox), dy = fi(p.y - drag.oy);
1232
  if (drag.mode === 'move') {
1233
  var o = drag.orig;
1234
  anns[drag.idx] = Object.assign({}, o,
@@ -1248,13 +1435,18 @@ _CANVAS_JS = """
1248
 
1249
  function onUp(e) {
1250
  if (!drag) return;
 
 
 
 
 
1251
  var action = drag.mode;
1252
  if (drag.mode === 'draw') {
1253
  var p = pos(e);
1254
  var x1c = Math.min(drag.ox, p.x), y1c = Math.min(drag.oy, p.y);
1255
  var x2c = Math.max(drag.ox, p.x), y2c = Math.max(drag.oy, p.y);
1256
- if (x2c - x1c > 5 && y2c - y1c > 5) {
1257
- anns.push({ x1:fi(x1c), y1:fi(y1c), x2:fi(x2c), y2:fi(y2c),
1258
  conf:1.0, status:'pending', source:'human_added' });
1259
  selected = anns.length - 1;
1260
  action = 'created';
@@ -1314,8 +1506,25 @@ _CANVAS_JS = """
1314
  case 'c': case 'C': copySelected(); break;
1315
  case 'Delete': case 'Backspace': deleteSelected(); break;
1316
  case 'a': case 'A': triggerBtn('rv-approve-sel'); break;
 
 
 
 
 
 
1317
  case 'ArrowLeft': e.preventDefault(); triggerBtn('rv-prev'); break;
1318
  case 'ArrowRight': e.preventDefault(); triggerBtn('rv-next'); break;
 
 
 
 
 
 
 
 
 
 
 
1319
  }
1320
  });
1321
  })();
@@ -1398,6 +1607,7 @@ with gr.Blocks(title="Takeoff AI Annotator", head=_CANVAS_JS) as demo:
1398
  rv_page_jump = gr.Textbox(value="", show_label=False, scale=1, min_width=80, placeholder="Go to page…")
1399
  rv_conf_slider = gr.Slider(0.05, 0.95, value=0.25, step=0.05, label="Conf", scale=3)
1400
  rv_detect_btn = gr.Button("Detect Page", variant="primary", scale=2)
 
1401
  rv_detect_all_btn = gr.Button("Detect All", scale=2)
1402
 
1403
  with gr.Row():
@@ -1415,6 +1625,8 @@ with gr.Blocks(title="Takeoff AI Annotator", head=_CANVAS_JS) as demo:
1415
  rv_approve_sel_btn = gr.Button(
1416
  "βœ“ Approve", variant="secondary", elem_id="rv-approve-sel", scale=2
1417
  )
 
 
1418
  rv_copy_sel_btn = gr.Button("⧉ Copy", scale=1)
1419
  rv_delete_sel_btn = gr.Button("πŸ—‘ Delete", variant="stop", elem_id="rv-delete-sel", scale=2)
1420
 
@@ -1452,8 +1664,13 @@ with gr.Blocks(title="Takeoff AI Annotator", head=_CANVAS_JS) as demo:
1452
  )
1453
 
1454
  gr.Markdown(
1455
- "`A` approve &nbsp; `C` copy &nbsp; `Del` delete &nbsp; "
1456
- "`← β†’` pages &nbsp; β€” draw box by dragging on empty canvas"
 
 
 
 
 
1457
  + (
1458
  f" \nπŸ’Ύ **Autosave on** β€” every change is checkpointed to `{_AUTOSAVE_DIR}`; "
1459
  "a refresh/crash loses nothing (re-import from there to resume)."
@@ -1510,6 +1727,11 @@ with gr.Blocks(title="Takeoff AI Annotator", head=_CANVAS_JS) as demo:
1510
  inputs=[rv_pdf_state, rv_page_state, rv_annotations_state, rv_conf_slider],
1511
  outputs=[rv_canvas_render_txt, rv_df, rv_annotations_state, rv_status],
1512
  )
 
 
 
 
 
1513
  rv_detect_all_btn.click(
1514
  fn=on_rv_detect_all,
1515
  inputs=[rv_pdf_state, rv_page_state, rv_total_state, rv_annotations_state, rv_conf_slider],
@@ -1543,6 +1765,11 @@ with gr.Blocks(title="Takeoff AI Annotator", head=_CANVAS_JS) as demo:
1543
  inputs=[rv_pdf_state, rv_page_state, rv_annotations_state, rv_ledger_state, rv_selected_state],
1544
  outputs=[rv_canvas_render_txt, rv_df, rv_annotations_state, rv_ledger_state, rv_status],
1545
  )
 
 
 
 
 
1546
  rv_copy_sel_btn.click(
1547
  fn=on_rv_copy_selected,
1548
  inputs=[rv_pdf_state, rv_page_state, rv_annotations_state, rv_ledger_state, rv_selected_state],
 
118
  # ---------------------------------------------------------------------------
119
  # Canvas helpers β€” HTML5 canvas replaces Plotly for the Review & Edit tab
120
  # ---------------------------------------------------------------------------
121
+ _CANVAS_MAX_PX = 2400 # max dimension sent to canvas (large enough to stay sharp when zoomed)
122
 
123
 
124
  def _build_canvas_json(img_rgb, annotations, selected=-1):
 
171
  )
172
 
173
 
174
+ # The detections grid is a secondary "click row to select" aid β€” the canvas is
175
+ # the primary selector. Gradio's Dataframe (Svelte Table) crashes
176
+ # ("RangeError: Too many properties to enumerate") on dense pages with 100+
177
+ # rows, which wedges the whole UI. Cap the rows it renders; all boxes still
178
+ # show on the canvas and are selectable there.
179
+ _REVIEW_DF_MAX_ROWS = 50
180
+
181
+
182
  def _build_review_df(annotations):
183
  if not PANDAS_SUPPORT or not annotations:
184
  return []
185
+ rows = [
186
+ {
187
+ "#": i + 1,
188
+ "x1": round(ann["x1"]),
189
+ "y1": round(ann["y1"]),
190
+ "x2": round(ann["x2"]),
191
+ "y2": round(ann["y2"]),
192
+ "conf": f"{ann.get('conf', 1.0):.2f}",
193
+ "status": ann.get("status", "pending"),
194
+ }
195
+ for i, ann in enumerate(annotations)
196
+ ]
197
+ if len(rows) > _REVIEW_DF_MAX_ROWS:
198
+ # Keep the grid small enough to never crash; select the rest on the canvas.
199
+ rows = rows[:_REVIEW_DF_MAX_ROWS]
200
+ return pd.DataFrame(rows)
201
 
202
 
203
  # ---------------------------------------------------------------------------
 
783
  )
784
 
785
 
786
+ def _box_iou(a, b):
787
+ ix1, iy1 = max(a["x1"], b["x1"]), max(a["y1"], b["y1"])
788
+ ix2, iy2 = min(a["x2"], b["x2"]), min(a["y2"], b["y2"])
789
+ inter = max(0.0, ix2 - ix1) * max(0.0, iy2 - iy1)
790
+ if inter <= 0:
791
+ return 0.0
792
+ area_a = (a["x2"] - a["x1"]) * (a["y2"] - a["y1"])
793
+ area_b = (b["x2"] - b["x1"]) * (b["y2"] - b["y1"])
794
+ return inter / (area_a + area_b - inter)
795
+
796
+
797
+ def on_rv_find_more(rv_pdf, rv_page, rv_annotations, rv_conf):
798
+ """Low-conf re-detect that MERGES: keeps every existing box (approved,
799
+ rejected, human, pending) and only adds detections that don't overlap one.
800
+ Lets the model fill audit gaps without the user redrawing by hand."""
801
+ anns = rv_annotations.get(rv_page, [])
802
+ if rv_pdf is None:
803
+ return gr.update(), _build_review_df(anns), rv_annotations, "No PDF loaded."
804
+ detect_rgb = _render_pdf_page(rv_pdf, rv_page, dpi=DETECTION_DPI)
805
+ if detect_rgb is None:
806
+ return gr.update(), _build_review_df(anns), rv_annotations, "Failed to render page."
807
+ from detection import _get_model
808
+
809
+ using_yolo = _get_model() is not None
810
+ detect_bgr = cv2.cvtColor(detect_rgb, cv2.COLOR_RGB2BGR)
811
+ conf = min(float(rv_conf), 0.10) # always cast a wide net regardless of the slider
812
+ boxes = detect_rectangles(detect_bgr, conf=conf)
813
+ scale = 100.0 / DETECTION_DPI
814
+ rv_annotations = dict(rv_annotations)
815
+ new_anns = list(anns)
816
+ added = 0
817
+ for b in boxes:
818
+ cand = {
819
+ "x1": b["x1"] * scale,
820
+ "y1": b["y1"] * scale,
821
+ "x2": b["x2"] * scale,
822
+ "y2": b["y2"] * scale,
823
+ }
824
+ # an existing box of ANY status wins β€” including rejected, so boxes the
825
+ # user already threw out don't come back
826
+ if any(_box_iou(cand, a) > 0.30 for a in anns):
827
+ continue
828
+ new_anns.append(
829
+ dict(
830
+ cand,
831
+ id=len(new_anns),
832
+ conf=b["conf"],
833
+ status="pending",
834
+ source="yolo" if using_yolo else "opencv",
835
+ )
836
+ )
837
+ added += 1
838
+ rv_annotations[rv_page] = new_anns
839
+ _autosave(rv_pdf, rv_annotations)
840
+ return (
841
+ _build_canvas_json(None, new_anns),
842
+ _build_review_df(new_anns),
843
+ rv_annotations,
844
+ f"Find More @conf {conf:.2f}: +{added} new box(es) (existing {len(anns)} kept).",
845
+ )
846
+
847
+
848
  def on_rv_detect_all(rv_pdf, rv_page, rv_total, rv_annotations, rv_conf):
849
  if rv_pdf is None:
850
  return _build_canvas_json(None, []), _build_review_df([]), rv_annotations, "No PDF loaded."
 
1010
  )
1011
 
1012
 
1013
+ def on_rv_reject_selected(rv_pdf, rv_page, rv_annotations, ledger, selected_idx):
1014
+ anns = rv_annotations.get(rv_page, [])
1015
+ if rv_pdf is None or not anns or selected_idx is None or selected_idx < 0:
1016
+ return gr.update(), _build_review_df(anns), rv_annotations, ledger, "Select a box first."
1017
+ idx = int(selected_idx)
1018
+ if idx >= len(anns):
1019
+ return gr.update(), _build_review_df(anns), rv_annotations, ledger, "Invalid selection."
1020
+ rv_annotations = dict(rv_annotations)
1021
+ new_anns = list(anns)
1022
+ old = new_anns[idx]
1023
+ new_anns[idx] = dict(old, status="rejected")
1024
+ rv_annotations[rv_page] = new_anns
1025
+ event = _make_event(rv_page, old, "rejected", before=_make_box(old), after=_make_box(new_anns[idx]))
1026
+ ledger = _append_events(ledger, [event])
1027
+ _autosave(rv_pdf, rv_annotations)
1028
+ return (
1029
+ _build_canvas_json(None, new_anns, selected=idx),
1030
+ _build_review_df(new_anns),
1031
+ rv_annotations,
1032
+ ledger,
1033
+ f"Rejected box #{idx + 1}. {_ledger_summary(ledger)}",
1034
+ )
1035
+
1036
+
1037
  def on_rv_delete_selected(rv_pdf, rv_page, rv_annotations, ledger, selected_idx):
1038
  anns = rv_annotations.get(rv_page, [])
1039
  if rv_pdf is None or not anns or selected_idx is None or selected_idx < 0:
 
1111
 
1112
  _CANVAS_HTML = """
1113
  <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;">
1114
+ <label style="position:absolute;top:8px;left:8px;z-index:10;background:rgba(0,0,0,0.6);color:#fff;font-family:monospace;font-size:12px;padding:4px 8px;border-radius:4px;cursor:pointer;">
1115
+ <input type="checkbox" id="rv-hide-rejected" style="vertical-align:middle;margin-right:4px;">hide rejected <kbd style="opacity:0.6;">H</kbd>
1116
+ </label>
1117
  <canvas id="rv-ann-canvas" style="display:block;width:100%;cursor:crosshair;touch-action:none;"></canvas>
1118
  <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;">
1119
+ Upload a PDF to begin<br><span style="color:#333;font-size:11px;">Scroll to zoom &bull; right-click + drag to pan &bull; double-click to reset view<br>Drag on canvas to draw &bull; click box to select &bull; drag to move &bull; drag corner to resize</span>
1120
  </div>
1121
  </div>
1122
  <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>
 
1136
  var anns = [], selected = -1;
1137
  var drag = null;
1138
  var lastRender = '';
1139
+ // View transform: zoom is multiplied on top of the fit-to-width base scale;
1140
+ // pan offsets are in canvas pixels. zoom 1 + pan 0 == whole page fits the column.
1141
+ var zoom = 1, panX = 0, panY = 0, spaceDown = false;
1142
 
1143
  // ── Boot ────────────────────────────────────────────────────────────────
1144
  var _iv = setInterval(function() {
 
1150
  canvas.addEventListener('mousemove', onMove);
1151
  canvas.addEventListener('mouseup', onUp);
1152
  canvas.addEventListener('mouseleave', function(e) { hideTooltip(); if (drag) { onUp(e); } });
1153
+ canvas.addEventListener('wheel', onWheel, { passive: false });
1154
+ canvas.addEventListener('dblclick', function() { resetView(); draw(); });
1155
+ canvas.addEventListener('auxclick', function(e) { e.preventDefault(); });
1156
+ canvas.addEventListener('contextmenu', function(e) { e.preventDefault(); });
1157
+ var hr = document.getElementById('rv-hide-rejected');
1158
+ if (hr) hr.addEventListener('change', draw);
1159
  new ResizeObserver(refit).observe(canvas.parentElement);
1160
  setInterval(checkRender, 150);
1161
  }
 
1176
  var msg = document.getElementById('rv-canvas-msg');
1177
  if (d.image && d.image !== cachedImgSrc) {
1178
  imgW = d.imageW; imgH = d.imageH;
1179
+ resetView();
1180
  var im = new Image();
1181
  im.onload = function() { cachedImg = im; cachedImgSrc = d.image; refit(); };
1182
  im.onerror = function() { console.warn('Canvas: failed to load image'); };
 
1193
  var w = canvas.parentElement.clientWidth || 800;
1194
  canvas.width = Math.round(w);
1195
  canvas.height = Math.round(imgH * (w / imgW));
1196
+ clampPan();
1197
  draw();
1198
  }
1199
 
1200
+ function s() { return canvas && imgW ? (canvas.width / imgW) * zoom : 1; }
1201
+
1202
+ // Rejected boxes stay in the data (training hard-negatives) but can be
1203
+ // hidden from the canvas; hidden boxes are also skipped by hit-testing.
1204
+ function rejectedHidden() {
1205
+ var cb = document.getElementById('rv-hide-rejected');
1206
+ return !!(cb && cb.checked);
1207
+ }
1208
+
1209
+ function isHidden(ann) { return rejectedHidden() && ann.status === 'rejected'; }
1210
+
1211
+ // ── Zoom / pan ───────────────────────────────────────────────────────────
1212
+ function resetView() { zoom = 1; panX = 0; panY = 0; }
1213
+
1214
+ function clampPan() {
1215
+ if (!canvas || !imgW) return;
1216
+ var sc = s();
1217
+ panX = Math.min(0, Math.max(canvas.width - imgW * sc, panX));
1218
+ panY = Math.min(0, Math.max(canvas.height - imgH * sc, panY));
1219
+ }
1220
+
1221
+ function onWheel(e) {
1222
+ if (!imgW) return;
1223
+ e.preventDefault();
1224
+ var r = canvas.getBoundingClientRect();
1225
+ var mx = (e.clientX - r.left) * (canvas.width / r.width);
1226
+ var my = (e.clientY - r.top) * (canvas.height / r.height);
1227
+ var nz = Math.min(16, Math.max(1, zoom * Math.pow(1.0015, -e.deltaY)));
1228
+ panX = mx - (mx - panX) * (nz / zoom);
1229
+ panY = my - (my - panY) * (nz / zoom);
1230
+ zoom = nz;
1231
+ if (zoom <= 1.001) resetView();
1232
+ clampPan();
1233
+ draw();
1234
+ }
1235
 
1236
  // ── Draw ─────────────────────────────────────────────────────────────────
1237
  function draw() {
1238
  if (!canvas) return;
1239
  var sc = s();
1240
  ctx.clearRect(0, 0, canvas.width, canvas.height);
1241
+ if (cachedImg) ctx.drawImage(cachedImg, panX, panY, imgW * sc, imgH * sc);
1242
 
1243
  anns.forEach(function(ann, i) {
1244
+ if (isHidden(ann)) return;
1245
+ var x1 = ann.x1*sc+panX, y1 = ann.y1*sc+panY, x2 = ann.x2*sc+panX, y2 = ann.y2*sc+panY;
1246
  var c = COLORS[ann.status] || COLORS.pending;
1247
  var sel = (i === selected);
1248
 
 
1266
  }
1267
  });
1268
 
1269
+ // Draw-in-progress preview (drag coords are in image space)
1270
  if (drag && drag.mode === 'draw') {
1271
+ var ox = drag.ox*sc+panX, oy = drag.oy*sc+panY, dcx = drag.cx*sc+panX, dcy = drag.cy*sc+panY;
1272
  ctx.strokeStyle = '#2563EB'; ctx.lineWidth = 2;
1273
  ctx.setLineDash([5, 4]);
1274
+ ctx.strokeRect(ox, oy, dcx - ox, dcy - oy);
1275
  ctx.setLineDash([]);
1276
  ctx.fillStyle = 'rgba(37,99,235,0.1)';
1277
+ ctx.fillRect(ox, oy, dcx - ox, dcy - oy);
1278
+ }
1279
+
1280
+ // Zoom badge
1281
+ if (zoom > 1.001) {
1282
+ var badge = Math.round(zoom * 100) + '% \\u00B7 right-drag = pan \\u00B7 dbl-click = reset';
1283
+ ctx.font = '12px monospace';
1284
+ var bw = ctx.measureText(badge).width + 16;
1285
+ ctx.fillStyle = 'rgba(0,0,0,0.6)';
1286
+ ctx.fillRect(canvas.width - bw - 8, 8, bw, 22);
1287
+ ctx.fillStyle = '#fff';
1288
+ ctx.fillText(badge, canvas.width - bw, 23);
1289
  }
1290
  }
1291
 
 
1312
  hoveredIdx = -1;
1313
  }
1314
 
1315
+ // ── Hit testing (image-space coords) ─────────────────────────────────────
1316
+ function hitHandle(ann, ix, iy) {
1317
+ var h = HANDLE / s();
1318
  var pts = [
1319
+ [ann.x1, ann.y1, 'nw'], [ann.x2, ann.y1, 'ne'],
1320
+ [ann.x2, ann.y2, 'se'], [ann.x1, ann.y2, 'sw'],
1321
  ];
1322
  for (var i = 0; i < pts.length; i++) {
1323
+ if (Math.abs(ix - pts[i][0]) <= h && Math.abs(iy - pts[i][1]) <= h)
1324
  return pts[i][2];
1325
  }
1326
  return null;
1327
  }
1328
 
1329
+ function hitBox(ann, ix, iy) {
1330
+ return ix >= ann.x1 && ix <= ann.x2 && iy >= ann.y1 && iy <= ann.y2;
 
1331
  }
1332
 
1333
+ // Mouse position in IMAGE pixels (pan/zoom already removed)
1334
  function pos(e) {
1335
  var r = canvas.getBoundingClientRect();
1336
+ var cx = (e.clientX - r.left) * (canvas.width / r.width);
1337
+ var cy = (e.clientY - r.top) * (canvas.height / r.height);
1338
+ var sc = s();
1339
+ return { x: (cx - panX) / sc, y: (cy - panY) / sc };
1340
  }
1341
 
 
 
1342
  // ── Mouse ────────────────────────────────────────────────────────────────
1343
  function onDown(e) {
1344
  hideTooltip();
1345
+ // Pan: right or middle mouse drag, or hold Space + left-drag
1346
+ if (e.button === 2 || e.button === 1 || spaceDown) {
1347
+ e.preventDefault();
1348
+ drag = { mode:'pan', sx:e.clientX, sy:e.clientY, px:panX, py:panY };
1349
+ canvas.style.cursor = 'grabbing';
1350
+ return;
1351
+ }
1352
+ if (e.button !== 0) return;
1353
  var p = pos(e);
1354
+ // Shift+click: stamp a copy of the selected box centered at the cursor
1355
+ // (the original stays selected so you can keep stamping down a row)
1356
+ if (e.shiftKey && selected >= 0 && selected < anns.length) {
1357
+ var tpl = anns[selected];
1358
+ var hw = (tpl.x2 - tpl.x1) / 2, hh = (tpl.y2 - tpl.y1) / 2;
1359
+ anns.push(Object.assign({}, tpl, {
1360
+ x1: p.x - hw, y1: p.y - hh, x2: p.x + hw, y2: p.y + hh,
1361
+ status: 'pending', source: 'human_added'
1362
+ }));
1363
+ draw();
1364
+ sync('created');
1365
+ return;
1366
+ }
1367
  // Resize handles on selected box take priority
1368
  if (selected >= 0 && selected < anns.length) {
1369
  var dir = hitHandle(anns[selected], p.x, p.y);
 
1375
  }
1376
  // Click inside any box to select + move
1377
  for (var i = anns.length - 1; i >= 0; i--) {
1378
+ if (isHidden(anns[i])) continue;
1379
  if (hitBox(anns[i], p.x, p.y)) {
1380
  selected = i;
1381
  drag = { mode:'move', idx:i,
 
1392
  }
1393
 
1394
  function onMove(e) {
1395
+ if (drag && drag.mode === 'pan') {
1396
+ var r = canvas.getBoundingClientRect();
1397
+ panX = drag.px + (e.clientX - drag.sx) * (canvas.width / r.width);
1398
+ panY = drag.py + (e.clientY - drag.sy) * (canvas.height / r.height);
1399
+ clampPan();
1400
+ draw();
1401
+ return;
1402
+ }
1403
  var p = pos(e);
1404
  // Tooltip on hover (only when not dragging)
1405
  if (!drag) {
1406
  var hovered = -1;
1407
  for (var j = anns.length - 1; j >= 0; j--) {
1408
+ if (isHidden(anns[j])) continue;
1409
  if (hitBox(anns[j], p.x, p.y)) { hovered = j; break; }
1410
  }
1411
  if (hovered >= 0) {
 
1415
  }
1416
  return;
1417
  }
1418
+ var dx = p.x - drag.ox, dy = p.y - drag.oy;
1419
  if (drag.mode === 'move') {
1420
  var o = drag.orig;
1421
  anns[drag.idx] = Object.assign({}, o,
 
1435
 
1436
  function onUp(e) {
1437
  if (!drag) return;
1438
+ if (drag.mode === 'pan') {
1439
+ drag = null;
1440
+ canvas.style.cursor = spaceDown ? 'grab' : 'crosshair';
1441
+ return;
1442
+ }
1443
  var action = drag.mode;
1444
  if (drag.mode === 'draw') {
1445
  var p = pos(e);
1446
  var x1c = Math.min(drag.ox, p.x), y1c = Math.min(drag.oy, p.y);
1447
  var x2c = Math.max(drag.ox, p.x), y2c = Math.max(drag.oy, p.y);
1448
+ if ((x2c - x1c) * s() > 5 && (y2c - y1c) * s() > 5) {
1449
+ anns.push({ x1:x1c, y1:y1c, x2:x2c, y2:y2c,
1450
  conf:1.0, status:'pending', source:'human_added' });
1451
  selected = anns.length - 1;
1452
  action = 'created';
 
1506
  case 'c': case 'C': copySelected(); break;
1507
  case 'Delete': case 'Backspace': deleteSelected(); break;
1508
  case 'a': case 'A': triggerBtn('rv-approve-sel'); break;
1509
+ case 'r': case 'R': triggerBtn('rv-reject-sel'); break;
1510
+ case 'h': case 'H': {
1511
+ var cb = document.getElementById('rv-hide-rejected');
1512
+ if (cb) { cb.checked = !cb.checked; draw(); }
1513
+ break;
1514
+ }
1515
  case 'ArrowLeft': e.preventDefault(); triggerBtn('rv-prev'); break;
1516
  case 'ArrowRight': e.preventDefault(); triggerBtn('rv-next'); break;
1517
+ case ' ': e.preventDefault();
1518
+ if (!spaceDown && canvas) { spaceDown = true; if (!drag) canvas.style.cursor = 'grab'; }
1519
+ break;
1520
+ case '0': resetView(); draw(); break;
1521
+ }
1522
+ });
1523
+
1524
+ document.addEventListener('keyup', function(e) {
1525
+ if (e.key === ' ') {
1526
+ spaceDown = false;
1527
+ if (canvas && !drag) canvas.style.cursor = 'crosshair';
1528
  }
1529
  });
1530
  })();
 
1607
  rv_page_jump = gr.Textbox(value="", show_label=False, scale=1, min_width=80, placeholder="Go to page…")
1608
  rv_conf_slider = gr.Slider(0.05, 0.95, value=0.25, step=0.05, label="Conf", scale=3)
1609
  rv_detect_btn = gr.Button("Detect Page", variant="primary", scale=2)
1610
+ rv_find_more_btn = gr.Button("πŸ”Ž Find More", scale=2)
1611
  rv_detect_all_btn = gr.Button("Detect All", scale=2)
1612
 
1613
  with gr.Row():
 
1625
  rv_approve_sel_btn = gr.Button(
1626
  "βœ“ Approve", variant="secondary", elem_id="rv-approve-sel", scale=2
1627
  )
1628
+ rv_reject_sel_btn = gr.Button("βœ— Reject", variant="stop", elem_id="rv-reject-sel", scale=2)
1629
+ with gr.Row():
1630
  rv_copy_sel_btn = gr.Button("⧉ Copy", scale=1)
1631
  rv_delete_sel_btn = gr.Button("πŸ—‘ Delete", variant="stop", elem_id="rv-delete-sel", scale=2)
1632
 
 
1664
  )
1665
 
1666
  gr.Markdown(
1667
+ "`A` approve &nbsp; `R` reject &nbsp; `H` hide/show rejected &nbsp; `C` copy &nbsp; `Del` delete &nbsp; "
1668
+ "`← β†’` pages &nbsp; β€” draw box by dragging on empty canvas \n"
1669
+ "⚑ **Shift+click = stamp a copy** of the selected box at the cursor (select one good box, "
1670
+ "then Shift+click each identical frame) &nbsp; πŸ”Ž **Find More** = re-detect at low confidence, "
1671
+ "adds only boxes you don't already have \n"
1672
+ "πŸ” **Scroll wheel = zoom** &nbsp; **right-click + drag = pan** &nbsp; "
1673
+ "double-click or `0` = reset view"
1674
  + (
1675
  f" \nπŸ’Ύ **Autosave on** β€” every change is checkpointed to `{_AUTOSAVE_DIR}`; "
1676
  "a refresh/crash loses nothing (re-import from there to resume)."
 
1727
  inputs=[rv_pdf_state, rv_page_state, rv_annotations_state, rv_conf_slider],
1728
  outputs=[rv_canvas_render_txt, rv_df, rv_annotations_state, rv_status],
1729
  )
1730
+ rv_find_more_btn.click(
1731
+ fn=on_rv_find_more,
1732
+ inputs=[rv_pdf_state, rv_page_state, rv_annotations_state, rv_conf_slider],
1733
+ outputs=[rv_canvas_render_txt, rv_df, rv_annotations_state, rv_status],
1734
+ )
1735
  rv_detect_all_btn.click(
1736
  fn=on_rv_detect_all,
1737
  inputs=[rv_pdf_state, rv_page_state, rv_total_state, rv_annotations_state, rv_conf_slider],
 
1765
  inputs=[rv_pdf_state, rv_page_state, rv_annotations_state, rv_ledger_state, rv_selected_state],
1766
  outputs=[rv_canvas_render_txt, rv_df, rv_annotations_state, rv_ledger_state, rv_status],
1767
  )
1768
+ rv_reject_sel_btn.click(
1769
+ fn=on_rv_reject_selected,
1770
+ inputs=[rv_pdf_state, rv_page_state, rv_annotations_state, rv_ledger_state, rv_selected_state],
1771
+ outputs=[rv_canvas_render_txt, rv_df, rv_annotations_state, rv_ledger_state, rv_status],
1772
+ )
1773
  rv_copy_sel_btn.click(
1774
  fn=on_rv_copy_selected,
1775
  inputs=[rv_pdf_state, rv_page_state, rv_annotations_state, rv_ledger_state, rv_selected_state],