MathewKasbarian commited on
Commit
280468b
Β·
1 Parent(s): c640d68

fix(annotator): execute review-canvas JS via Blocks head and add edit autosave

Browse files

The canvas <script> was injected with gr.HTML, which Gradio mounts via
innerHTML β€” browsers never execute scripts inserted that way, so the
Review & Edit canvas was dead in every browser. Serve it through
gr.Blocks(head=...) instead.

Autosave checkpoints every annotated page to data/working_set/autosave/
on each mutating action (canonical label JSON, per-box status preserved
for exact resume; pages with unreviewed machine boxes stay
audit_status=machine). Disabled automatically off-repo (HF Space).

Files changed (1) hide show
  1. app.py +108 -31
app.py CHANGED
@@ -1,6 +1,8 @@
1
  import base64
2
  import hashlib
3
  import json
 
 
4
  import tempfile
5
  import uuid
6
  import zipfile
@@ -404,6 +406,33 @@ def detect_page_api(pdf_file, page_number=1, dpi=DETECTION_DPI, conf=0.25):
404
  # ---------------------------------------------------------------------------
405
  _LABEL_DISPLAY_DPI = 100 # the Review tab canvas coordinate space
406
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
407
 
408
  def _source_tag(ann, model_version):
409
  src = ann.get("source", "unknown")
@@ -414,45 +443,74 @@ def _source_tag(ann, model_version):
414
  return src
415
 
416
 
417
- def on_rv_export_labels(rv_pdf, rv_annotations):
418
- """Export every page with annotations as canonical label JSONs (zipped).
419
 
420
  Coordinates are converted from the 100-DPI canvas space to PDF points so
421
- labels are DPI-independent and merge with other sources downstream.
 
422
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
423
  if rv_pdf is None:
424
  return None, "No PDF loaded."
425
  pages = {p: anns for p, anns in (rv_annotations or {}).items() if anns}
426
  if not pages:
427
  return None, "No annotations to export."
428
  stem = Path(rv_pdf).stem
429
- mv = get_model_version()
430
  tmp_dir = Path(tempfile.mkdtemp(prefix="labels_"))
431
  written = []
432
  for page, anns in sorted(pages.items()):
433
- boxes, rejected = [], []
434
- for ann in anns:
435
- entry = {
436
- "id": ann.get("uid") or str(uuid.uuid4()),
437
- "x1_pt": round(px_to_pt(ann["x1"], _LABEL_DISPLAY_DPI), 3),
438
- "y1_pt": round(px_to_pt(ann["y1"], _LABEL_DISPLAY_DPI), 3),
439
- "x2_pt": round(px_to_pt(ann["x2"], _LABEL_DISPLAY_DPI), 3),
440
- "y2_pt": round(px_to_pt(ann["y2"], _LABEL_DISPLAY_DPI), 3),
441
- "source": _source_tag(ann, mv),
442
- "conf": round(float(ann.get("conf", 1.0)), 4),
443
- "history": ann.get("history", []),
444
- }
445
- (rejected if ann.get("status") == "rejected" else boxes).append(entry)
446
- data = {
447
- "doc": stem,
448
- "pdf": Path(rv_pdf).name,
449
- "page": int(page),
450
- "page_type": "",
451
- "audit_status": "audited",
452
- "audited_at": datetime.now(UTC).isoformat(),
453
- "boxes": boxes,
454
- "rejected_boxes": rejected,
455
- }
456
  out = tmp_dir / f"p{int(page):03d}.json"
457
  out.write_text(json.dumps(data, indent=2), encoding="utf-8")
458
  written.append(out)
@@ -498,13 +556,15 @@ def on_rv_import_labels(label_files, rv_pdf, rv_page, rv_annotations):
498
  "x2": pt_to_px(box["x2_pt"], _LABEL_DISPLAY_DPI),
499
  "y2": pt_to_px(box["y2_pt"], _LABEL_DISPLAY_DPI),
500
  "conf": float(box.get("conf", 1.0)),
501
- "status": status,
 
502
  "source": box.get("source", "unknown"),
503
  "history": box.get("history", []),
504
  }
505
  )
506
  rv_annotations[page] = anns
507
  loaded_pages.append(page)
 
508
  current = rv_annotations.get(rv_page, [])
509
  img_rgb = _render_pdf_page(rv_pdf, rv_page, dpi=100) if rv_pdf is not None and rv_page in loaded_pages else None
510
  return (
@@ -663,6 +723,9 @@ def on_canvas_events(events_json, rv_pdf, rv_page, rv_annotations, rv_ledger):
663
  )
664
  rv_ledger = _append_events(rv_ledger, events)
665
 
 
 
 
666
  canvas_json = _build_canvas_json(None, new_anns, selected=selected)
667
  return canvas_json, rv_annotations, rv_ledger, _build_review_df(new_anns), selected
668
 
@@ -699,6 +762,7 @@ def on_rv_detect(rv_pdf, rv_page, rv_annotations, rv_conf):
699
  ]
700
  rv_annotations = dict(rv_annotations)
701
  rv_annotations[rv_page] = annotations
 
702
  engine = "YOLO" if using_yolo else "OpenCV"
703
  return (
704
  _build_canvas_json(img_rgb, annotations),
@@ -752,6 +816,7 @@ def on_rv_detect_all(rv_pdf, rv_page, rv_total, rv_annotations, rv_conf):
752
  return _build_canvas_json(None, []), _build_review_df([]), rv_annotations, f"Error: {exc}"
753
  img_rgb = _render_pdf_page(rv_pdf, rv_page, dpi=100)
754
  anns = all_anns.get(rv_page, [])
 
755
  engine = "YOLO" if using_yolo else "OpenCV"
756
  return (
757
  _build_canvas_json(img_rgb, anns),
@@ -791,6 +856,7 @@ def on_rv_update_box(rv_pdf, rv_page, rv_annotations, ledger, selected_idx, x1,
791
  rv_annotations[rv_page] = new_anns
792
  event = _make_event(rv_page, old, "modified", before=before, after=_make_box(new_anns[idx]))
793
  ledger = _append_events(ledger, [event])
 
794
  img_rgb = _render_pdf_page(rv_pdf, rv_page, dpi=100)
795
  return (
796
  _build_canvas_json(img_rgb, new_anns, selected=idx),
@@ -814,6 +880,7 @@ def on_rv_approve_all(rv_pdf, rv_page, rv_annotations, ledger):
814
  if a.get("status") != "approved"
815
  ]
816
  ledger = _append_events(ledger, events)
 
817
  return (
818
  _build_canvas_json(None, new_anns),
819
  _build_review_df(new_anns),
@@ -836,6 +903,7 @@ def on_rv_reject_all(rv_pdf, rv_page, rv_annotations, ledger):
836
  if a.get("status") != "rejected"
837
  ]
838
  ledger = _append_events(ledger, events)
 
839
  return (
840
  _build_canvas_json(None, new_anns),
841
  _build_review_df(new_anns),
@@ -859,6 +927,7 @@ def on_rv_approve_selected(rv_pdf, rv_page, rv_annotations, ledger, selected_idx
859
  rv_annotations[rv_page] = new_anns
860
  event = _make_event(rv_page, old, "accepted", before=_make_box(old), after=_make_box(new_anns[idx]))
861
  ledger = _append_events(ledger, [event])
 
862
  return (
863
  _build_canvas_json(None, new_anns, selected=idx),
864
  _build_review_df(new_anns),
@@ -883,6 +952,7 @@ def on_rv_delete_selected(rv_pdf, rv_page, rv_annotations, ledger, selected_idx)
883
  rv_annotations[rv_page] = new_anns
884
  event = _make_event(rv_page, removed, "rejected", before=_make_box(removed), after=None)
885
  ledger = _append_events(ledger, [event])
 
886
  new_sel = min(idx, len(new_anns) - 1) if new_anns else -1
887
  return (
888
  _build_canvas_json(None, new_anns, selected=new_sel),
@@ -911,6 +981,7 @@ def on_rv_copy_selected(rv_pdf, rv_page, rv_annotations, ledger, selected_idx):
911
  new_sel = len(new_anns) - 1
912
  event = _make_event(rv_page, new_ann, "created", before=None, after=_make_box(new_ann))
913
  ledger = _append_events(ledger, [event])
 
914
  return (
915
  _build_canvas_json(None, new_anns, selected=new_sel),
916
  _build_review_df(new_anns),
@@ -1250,7 +1321,9 @@ _CANVAS_JS = """
1250
  </script>
1251
  """
1252
 
1253
- with gr.Blocks(title="Takeoff AI Annotator") as demo:
 
 
1254
  gr.Markdown(f"# Takeoff AI Annotator \n_Model: `{_model_version}`_")
1255
 
1256
  shapes_state = gr.State([])
@@ -1380,6 +1453,12 @@ with gr.Blocks(title="Takeoff AI Annotator") as demo:
1380
  gr.Markdown(
1381
  "`A` approve &nbsp; `C` copy &nbsp; `Del` delete &nbsp; "
1382
  "`← β†’` pages &nbsp; β€” draw box by dragging on empty canvas"
 
 
 
 
 
 
1383
  )
1384
 
1385
  # ── Wire events ─────────────────────────────────────────────────
@@ -1581,7 +1660,5 @@ with gr.Blocks(title="Takeoff AI Annotator") as demo:
1581
  api_name="detect_page",
1582
  )
1583
 
1584
- gr.HTML(_CANVAS_JS)
1585
-
1586
  if __name__ == "__main__":
1587
  demo.launch()
 
1
  import base64
2
  import hashlib
3
  import json
4
+ import os
5
+ import re
6
  import tempfile
7
  import uuid
8
  import zipfile
 
406
  # ---------------------------------------------------------------------------
407
  _LABEL_DISPLAY_DPI = 100 # the Review tab canvas coordinate space
408
 
409
+ # Autosave: when running from the repo checkout (not the HF Space), every
410
+ # annotation change is checkpointed to disk so a browser refresh/crash loses
411
+ # nothing. Files go to a staging dir, NOT the canonical labels dir β€” the
412
+ # canonical ground truth is only ever written via merge_labels.py.
413
+ _REPO_WORKING_SET = Path(__file__).resolve().parents[2] / "data" / "working_set"
414
+
415
+
416
+ def _resolve_autosave_dir():
417
+ env = os.environ.get("FACADE_AUTOSAVE_DIR")
418
+ if env:
419
+ p = Path(env)
420
+ p.mkdir(parents=True, exist_ok=True)
421
+ return p
422
+ if _REPO_WORKING_SET.is_dir():
423
+ p = _REPO_WORKING_SET / "autosave"
424
+ p.mkdir(parents=True, exist_ok=True)
425
+ return p
426
+ return None # HF Space / standalone β€” autosave off
427
+
428
+
429
+ _AUTOSAVE_DIR = _resolve_autosave_dir()
430
+
431
+
432
+ def _slugify_doc(stem):
433
+ # must match scripts/annotation/common.py::doc_slug
434
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", stem).strip("_")[:60]
435
+
436
 
437
  def _source_tag(ann, model_version):
438
  src = ann.get("source", "unknown")
 
443
  return src
444
 
445
 
446
+ def _page_label_data(rv_pdf, page, anns, audit_status):
447
+ """Build one page's canonical label dict from canvas annotations.
448
 
449
  Coordinates are converted from the 100-DPI canvas space to PDF points so
450
+ labels are DPI-independent and merge with other sources downstream. The
451
+ per-box "status" key is an extra field for exact import round-trips.
452
  """
453
+ mv = get_model_version()
454
+ boxes, rejected = [], []
455
+ for ann in anns:
456
+ entry = {
457
+ "id": ann.get("uid") or str(uuid.uuid4()),
458
+ "x1_pt": round(px_to_pt(ann["x1"], _LABEL_DISPLAY_DPI), 3),
459
+ "y1_pt": round(px_to_pt(ann["y1"], _LABEL_DISPLAY_DPI), 3),
460
+ "x2_pt": round(px_to_pt(ann["x2"], _LABEL_DISPLAY_DPI), 3),
461
+ "y2_pt": round(px_to_pt(ann["y2"], _LABEL_DISPLAY_DPI), 3),
462
+ "source": _source_tag(ann, mv),
463
+ "conf": round(float(ann.get("conf", 1.0)), 4),
464
+ "status": ann.get("status", "pending"),
465
+ "history": ann.get("history", []),
466
+ }
467
+ (rejected if ann.get("status") == "rejected" else boxes).append(entry)
468
+ return {
469
+ "doc": _slugify_doc(Path(rv_pdf).stem),
470
+ "pdf": Path(rv_pdf).name,
471
+ "page": int(page),
472
+ "page_type": "",
473
+ "audit_status": audit_status,
474
+ "audited_at": datetime.now(UTC).isoformat(),
475
+ "boxes": boxes,
476
+ "rejected_boxes": rejected,
477
+ }
478
+
479
+
480
+ def _autosave(rv_pdf, rv_annotations):
481
+ """Checkpoint every annotated page to the autosave dir. Never raises β€”
482
+ a failed checkpoint must not break the edit that triggered it."""
483
+ if _AUTOSAVE_DIR is None or rv_pdf is None:
484
+ return
485
+ try:
486
+ out_dir = _AUTOSAVE_DIR / _slugify_doc(Path(rv_pdf).stem)
487
+ out_dir.mkdir(parents=True, exist_ok=True)
488
+ for page, anns in (rv_annotations or {}).items():
489
+ if not anns:
490
+ continue
491
+ # A page still holding unreviewed machine boxes is not an audit
492
+ unreviewed = any(
493
+ a.get("status", "pending") == "pending" and not str(a.get("source", "")).startswith("human")
494
+ for a in anns
495
+ )
496
+ data = _page_label_data(rv_pdf, page, anns, "machine" if unreviewed else "audited")
497
+ (out_dir / f"p{int(page):03d}.json").write_text(json.dumps(data, indent=2), encoding="utf-8")
498
+ except Exception as exc:
499
+ print(f"[autosave] checkpoint failed: {exc}")
500
+
501
+
502
+ def on_rv_export_labels(rv_pdf, rv_annotations):
503
+ """Export every page with annotations as canonical label JSONs (zipped)."""
504
  if rv_pdf is None:
505
  return None, "No PDF loaded."
506
  pages = {p: anns for p, anns in (rv_annotations or {}).items() if anns}
507
  if not pages:
508
  return None, "No annotations to export."
509
  stem = Path(rv_pdf).stem
 
510
  tmp_dir = Path(tempfile.mkdtemp(prefix="labels_"))
511
  written = []
512
  for page, anns in sorted(pages.items()):
513
+ data = _page_label_data(rv_pdf, page, anns, "audited")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
514
  out = tmp_dir / f"p{int(page):03d}.json"
515
  out.write_text(json.dumps(data, indent=2), encoding="utf-8")
516
  written.append(out)
 
556
  "x2": pt_to_px(box["x2_pt"], _LABEL_DISPLAY_DPI),
557
  "y2": pt_to_px(box["y2_pt"], _LABEL_DISPLAY_DPI),
558
  "conf": float(box.get("conf", 1.0)),
559
+ # autosave files carry per-box status for exact resume
560
+ "status": "rejected" if status == "rejected" else box.get("status", status),
561
  "source": box.get("source", "unknown"),
562
  "history": box.get("history", []),
563
  }
564
  )
565
  rv_annotations[page] = anns
566
  loaded_pages.append(page)
567
+ _autosave(rv_pdf, rv_annotations)
568
  current = rv_annotations.get(rv_page, [])
569
  img_rgb = _render_pdf_page(rv_pdf, rv_page, dpi=100) if rv_pdf is not None and rv_page in loaded_pages else None
570
  return (
 
723
  )
724
  rv_ledger = _append_events(rv_ledger, events)
725
 
726
+ if action in ("created", "moved", "resized", "deleted"):
727
+ _autosave(rv_pdf, rv_annotations)
728
+
729
  canvas_json = _build_canvas_json(None, new_anns, selected=selected)
730
  return canvas_json, rv_annotations, rv_ledger, _build_review_df(new_anns), selected
731
 
 
762
  ]
763
  rv_annotations = dict(rv_annotations)
764
  rv_annotations[rv_page] = annotations
765
+ _autosave(rv_pdf, rv_annotations)
766
  engine = "YOLO" if using_yolo else "OpenCV"
767
  return (
768
  _build_canvas_json(img_rgb, annotations),
 
816
  return _build_canvas_json(None, []), _build_review_df([]), rv_annotations, f"Error: {exc}"
817
  img_rgb = _render_pdf_page(rv_pdf, rv_page, dpi=100)
818
  anns = all_anns.get(rv_page, [])
819
+ _autosave(rv_pdf, all_anns)
820
  engine = "YOLO" if using_yolo else "OpenCV"
821
  return (
822
  _build_canvas_json(img_rgb, anns),
 
856
  rv_annotations[rv_page] = new_anns
857
  event = _make_event(rv_page, old, "modified", before=before, after=_make_box(new_anns[idx]))
858
  ledger = _append_events(ledger, [event])
859
+ _autosave(rv_pdf, rv_annotations)
860
  img_rgb = _render_pdf_page(rv_pdf, rv_page, dpi=100)
861
  return (
862
  _build_canvas_json(img_rgb, new_anns, selected=idx),
 
880
  if a.get("status") != "approved"
881
  ]
882
  ledger = _append_events(ledger, events)
883
+ _autosave(rv_pdf, rv_annotations)
884
  return (
885
  _build_canvas_json(None, new_anns),
886
  _build_review_df(new_anns),
 
903
  if a.get("status") != "rejected"
904
  ]
905
  ledger = _append_events(ledger, events)
906
+ _autosave(rv_pdf, rv_annotations)
907
  return (
908
  _build_canvas_json(None, new_anns),
909
  _build_review_df(new_anns),
 
927
  rv_annotations[rv_page] = new_anns
928
  event = _make_event(rv_page, old, "accepted", before=_make_box(old), after=_make_box(new_anns[idx]))
929
  ledger = _append_events(ledger, [event])
930
+ _autosave(rv_pdf, rv_annotations)
931
  return (
932
  _build_canvas_json(None, new_anns, selected=idx),
933
  _build_review_df(new_anns),
 
952
  rv_annotations[rv_page] = new_anns
953
  event = _make_event(rv_page, removed, "rejected", before=_make_box(removed), after=None)
954
  ledger = _append_events(ledger, [event])
955
+ _autosave(rv_pdf, rv_annotations)
956
  new_sel = min(idx, len(new_anns) - 1) if new_anns else -1
957
  return (
958
  _build_canvas_json(None, new_anns, selected=new_sel),
 
981
  new_sel = len(new_anns) - 1
982
  event = _make_event(rv_page, new_ann, "created", before=None, after=_make_box(new_ann))
983
  ledger = _append_events(ledger, [event])
984
+ _autosave(rv_pdf, rv_annotations)
985
  return (
986
  _build_canvas_json(None, new_anns, selected=new_sel),
987
  _build_review_df(new_anns),
 
1321
  </script>
1322
  """
1323
 
1324
+ # _CANVAS_JS must go through head= β€” gr.HTML inserts via innerHTML, which never
1325
+ # executes <script> tags, so the canvas engine would silently not run.
1326
+ with gr.Blocks(title="Takeoff AI Annotator", head=_CANVAS_JS) as demo:
1327
  gr.Markdown(f"# Takeoff AI Annotator \n_Model: `{_model_version}`_")
1328
 
1329
  shapes_state = gr.State([])
 
1453
  gr.Markdown(
1454
  "`A` approve &nbsp; `C` copy &nbsp; `Del` delete &nbsp; "
1455
  "`← β†’` pages &nbsp; β€” draw box by dragging on empty canvas"
1456
+ + (
1457
+ f" \nπŸ’Ύ **Autosave on** β€” every change is checkpointed to `{_AUTOSAVE_DIR}`; "
1458
+ "a refresh/crash loses nothing (re-import from there to resume)."
1459
+ if _AUTOSAVE_DIR
1460
+ else ""
1461
+ )
1462
  )
1463
 
1464
  # ── Wire events ─────────────────────────────────────────────────
 
1660
  api_name="detect_page",
1661
  )
1662
 
 
 
1663
  if __name__ == "__main__":
1664
  demo.launch()