Spaces:
Running
Running
Deploy Takeoff AI Annotator
Browse files
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 =
|
| 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 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 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 • click box to select • drag to move • 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,
|
| 1098 |
|
| 1099 |
anns.forEach(function(ann, i) {
|
| 1100 |
-
|
|
|
|
| 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(
|
| 1129 |
ctx.setLineDash([]);
|
| 1130 |
ctx.fillStyle = 'rgba(37,99,235,0.1)';
|
| 1131 |
-
ctx.fillRect(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1132 |
}
|
| 1133 |
}
|
| 1134 |
|
|
@@ -1155,38 +1312,58 @@ _CANVAS_JS = """
|
|
| 1155 |
hoveredIdx = -1;
|
| 1156 |
}
|
| 1157 |
|
| 1158 |
-
// ββ Hit testing βββββββββββββββββββββββββββββββββββββ
|
| 1159 |
-
function hitHandle(ann,
|
| 1160 |
-
var
|
| 1161 |
var pts = [
|
| 1162 |
-
[ann.x1
|
| 1163 |
-
[ann.x2
|
| 1164 |
];
|
| 1165 |
for (var i = 0; i < pts.length; i++) {
|
| 1166 |
-
if (Math.abs(
|
| 1167 |
return pts[i][2];
|
| 1168 |
}
|
| 1169 |
return null;
|
| 1170 |
}
|
| 1171 |
|
| 1172 |
-
function hitBox(ann,
|
| 1173 |
-
|
| 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
|
| 1180 |
-
var
|
| 1181 |
-
|
|
|
|
| 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 =
|
| 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:
|
| 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 `C` copy `Del` delete "
|
| 1456 |
-
"`β β` pages β 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 • right-click + drag to pan • double-click to reset view<br>Drag on canvas to draw • click box to select • drag to move • 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 `R` reject `H` hide/show rejected `C` copy `Del` delete "
|
| 1668 |
+
"`β β` pages β 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) π **Find More** = re-detect at low confidence, "
|
| 1671 |
+
"adds only boxes you don't already have \n"
|
| 1672 |
+
"π **Scroll wheel = zoom** **right-click + drag = pan** "
|
| 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],
|