Download scripts/export_ui_tree.py from gexinh/SlideBank: direct link, hf CLI and curl.
- Browser
- Download file 20.3 kB
-
https://huggingface.co/spaces/gexinh/SlideBank/resolve/main/scripts/export_ui_tree.py
- Command line
-
hf download hf://spaces/gexinh/SlideBank/scripts/export_ui_tree.py
-
curl -L -o export_ui_tree.py https://huggingface.co/spaces/gexinh/SlideBank/resolve/main/scripts/export_ui_tree.py
20.3 kB
| #!/usr/bin/env python | |
| from __future__ import annotations | |
| import argparse | |
| import json | |
| import re | |
| import sys | |
| from collections import Counter, defaultdict | |
| from pathlib import Path | |
| from typing import Any, Dict, Iterable, List, Optional, Tuple | |
| PROJECT_ROOT = Path(__file__).resolve().parents[1] | |
| if str(PROJECT_ROOT) not in sys.path: | |
| sys.path.insert(0, str(PROJECT_ROOT)) | |
| from slidebank.data import safe_name | |
| SCHEMA_VERSION = "slidebank-ui-tree-v1" | |
| METRIC_KEYS = [ | |
| "dominant_category", | |
| "tissue_ratio", | |
| "selected_region_count", | |
| "malignant_region_count", | |
| "suspicion_level", | |
| "suspicion_score", | |
| "tile_total_count", | |
| "tile_non_background_count", | |
| "tile_tumor_ratio", | |
| ] | |
| def parse_args() -> argparse.Namespace: | |
| parser = argparse.ArgumentParser( | |
| description="Export SlideBank readable JSONL records into a UI-friendly global/region/patch tree." | |
| ) | |
| parser.add_argument( | |
| "--readable-dir", | |
| default="", | |
| help="Directory containing per-slide readable *.jsonl files.", | |
| ) | |
| parser.add_argument( | |
| "--bank-dir", | |
| default="", | |
| help="Memory-bank directory. Used to infer --readable-dir=<bank-dir>/readable and default output dir.", | |
| ) | |
| parser.add_argument("--readable-file", default="", help="Convert one readable JSONL file.") | |
| parser.add_argument("--slide-id", default="", help="Optional exact slide id or prefix filter.") | |
| parser.add_argument("--out-dir", default="", help="Output directory for *.ui_tree.json files.") | |
| parser.add_argument("--limit", type=int, default=0, help="Maximum number of slides to export.") | |
| parser.add_argument("--compact", action="store_true", help="Write compact JSON instead of pretty JSON.") | |
| parser.add_argument( | |
| "--no-anchor-signals", | |
| action="store_true", | |
| help="Do not include per-anchor signal_screening blocks.", | |
| ) | |
| return parser.parse_args() | |
| def load_jsonl(path: Path) -> List[Dict[str, Any]]: | |
| records: List[Dict[str, Any]] = [] | |
| with path.open("r", encoding="utf-8") as handle: | |
| for line in handle: | |
| line = line.strip() | |
| if not line: | |
| continue | |
| records.append(json.loads(line)) | |
| return records | |
| def parse_json_payload(text: Any, prefix: str = "") -> Any: | |
| raw = str(text or "").strip() | |
| if prefix and raw.startswith(prefix): | |
| raw = raw[len(prefix):].strip() | |
| if not prefix and raw and raw[0] not in "[{": | |
| match = re.search(r"([\[{].*)$", raw, flags=re.S) | |
| if match: | |
| raw = match.group(1).strip() | |
| try: | |
| return json.loads(raw) | |
| except Exception: | |
| return None | |
| def record_subtype(record: Dict[str, Any]) -> str: | |
| record_id = str(record.get("id", "")) | |
| if "::global::" in record_id: | |
| return record_id.rsplit("::global::", 1)[1] | |
| if record_id.endswith("::global"): | |
| return "summary" | |
| return "" | |
| def catalog_from_records(records: List[Dict[str, Any]], subtype: str, default: Any) -> Any: | |
| for record in records: | |
| if record_subtype(record) != subtype: | |
| continue | |
| parsed = parse_json_payload(record.get("text", "")) | |
| return parsed if parsed is not None else default | |
| return default | |
| def first_record_text(records: List[Dict[str, Any]], subtype: str) -> str: | |
| for record in records: | |
| if record_subtype(record) == subtype: | |
| return str(record.get("text", "")).strip() | |
| return "" | |
| def infer_slide_id(records: List[Dict[str, Any]], path: Path) -> str: | |
| for record in records: | |
| slide_id = str(record.get("slide_id", "")).strip() | |
| if slide_id: | |
| return slide_id | |
| name = path.name | |
| if name.endswith(".jsonl"): | |
| name = name[:-6] | |
| if name.endswith(".json"): | |
| name = name[:-5] | |
| return name | |
| def normalize_path(value: Any) -> Optional[str]: | |
| text = str(value or "").strip() | |
| # Legacy banks may contain paths relative to the builder's working | |
| # directory. Resolve only files that are present there; leave other | |
| # relative paths intact so an existing portable bundle keeps its layout. | |
| if text and not Path(text).is_absolute() and Path(text).expanduser().is_file(): | |
| return str(Path(text).expanduser().resolve()) | |
| return text or None | |
| def metric_subset(metrics: Dict[str, Any]) -> Dict[str, Any]: | |
| return {key: metrics.get(key) for key in METRIC_KEYS if key in metrics} | |
| def build_signal_lookup(ontology: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: | |
| signals = ontology.get("signals", []) if isinstance(ontology, dict) else [] | |
| lookup: Dict[str, Dict[str, Any]] = {} | |
| if not isinstance(signals, list): | |
| return lookup | |
| for signal in signals: | |
| if not isinstance(signal, dict): | |
| continue | |
| signal_id = str(signal.get("signal_id", "")) | |
| if not signal_id: | |
| continue | |
| lookup[signal_id] = { | |
| "signal_id": signal_id, | |
| "signal_name": signal.get("signal_name"), | |
| "description": signal.get("description"), | |
| "weight": signal.get("weight"), | |
| } | |
| return lookup | |
| def summarize_signal_overview( | |
| signal_screenings: List[Dict[str, Any]], | |
| signal_lookup: Dict[str, Dict[str, Any]], | |
| ) -> Dict[str, Any]: | |
| status_counts: Counter[str] = Counter() | |
| present: Dict[str, Dict[str, Any]] = {} | |
| for item in signal_screenings: | |
| for signal in item.get("signal_screening", []) or []: | |
| status = str(signal.get("status", "unknown")).lower() | |
| status_counts[status] += 1 | |
| if status == "present": | |
| signal_id = str(signal.get("signal_id", "")) | |
| base = dict(signal_lookup.get(signal_id, {"signal_id": signal_id})) | |
| base["confidence"] = signal.get("confidence") | |
| base["rationale"] = signal.get("rationale") | |
| present[signal_id] = base | |
| return { | |
| "present_count": int(status_counts.get("present", 0)), | |
| "absent_count": int(status_counts.get("absent", 0)), | |
| "present_signals": list(present.values()), | |
| "candidate_signal_types": list(signal_lookup.values()), | |
| } | |
| def grid_shape_from_tiles(tiles_survey: List[Dict[str, Any]]) -> Optional[List[int]]: | |
| rows: List[int] = [] | |
| cols: List[int] = [] | |
| for item in tiles_survey: | |
| grid = item.get("grid_rc") | |
| if isinstance(grid, list) and len(grid) == 2: | |
| try: | |
| rows.append(int(grid[0])) | |
| cols.append(int(grid[1])) | |
| except Exception: | |
| pass | |
| if not rows or not cols: | |
| return None | |
| return [max(rows) + 1, max(cols) + 1] | |
| def best_signal(signal_screening: Iterable[Dict[str, Any]]) -> Optional[Dict[str, Any]]: | |
| rank = {"present": 3, "unknown": 2, "absent": 1} | |
| def score(signal: Dict[str, Any]) -> Tuple[int, float]: | |
| status_score = rank.get(str(signal.get("status", "")).lower(), 0) | |
| try: | |
| confidence = float(signal.get("confidence", 0.0)) | |
| except Exception: | |
| confidence = 0.0 | |
| return status_score, confidence | |
| signals = [signal for signal in signal_screening if isinstance(signal, dict)] | |
| return max(signals, key=score) if signals else None | |
| def candidate_signals_from_package(package: Dict[str, Any]) -> List[Dict[str, Any]]: | |
| result: List[Dict[str, Any]] = [] | |
| for signal in package.get("candidate_signals", []) or []: | |
| if not isinstance(signal, dict): | |
| continue | |
| result.append( | |
| { | |
| "signal_id": signal.get("signal_id"), | |
| "signal_name": signal.get("signal_name"), | |
| "description": signal.get("description"), | |
| "confidence": signal.get("confidence"), | |
| } | |
| ) | |
| return result | |
| def anchor_summary_text(evidence: Dict[str, Any], anchor: Dict[str, Any]) -> str: | |
| summary = str(evidence.get("summary") or "").strip() | |
| if summary: | |
| return summary | |
| desc = str(anchor.get("anchor_description") or "").strip() | |
| if not desc: | |
| return "" | |
| return desc.split(" Cues:", 1)[0].strip() | |
| def anchor_confidence( | |
| evidence: Dict[str, Any], | |
| anchor: Dict[str, Any], | |
| screening: Dict[str, Any], | |
| ) -> Any: | |
| if evidence.get("confidence") is not None: | |
| return evidence.get("confidence") | |
| values: List[float] = [] | |
| for view in evidence.get("views", []) or []: | |
| try: | |
| values.append(float(view.get("selection_confidence"))) | |
| except Exception: | |
| pass | |
| if values: | |
| return round(max(values), 4) | |
| best = best_signal(screening.get("signal_screening", []) or []) | |
| if best and best.get("confidence") is not None: | |
| return best.get("confidence") | |
| return anchor.get("focus_confidence") or anchor.get("label_score") | |
| def view_rows(evidence: Dict[str, Any], patch_by_key: Dict[Tuple[str, str], Dict[str, Any]]) -> List[Dict[str, Any]]: | |
| rows: List[Dict[str, Any]] = [] | |
| for view in evidence.get("views", []) or []: | |
| evidence_id = str(evidence.get("evidence_id", "")) | |
| view_id = str(view.get("view_id", "")) | |
| patch_meta = patch_by_key.get((evidence_id, view_id), {}) | |
| rows.append( | |
| { | |
| "view_id": view_id, | |
| "mag": view.get("mag") or patch_meta.get("mag"), | |
| "bbox_xywh_level0": view.get("patch_xywh_level0") or patch_meta.get("patch_xywh_level0"), | |
| "patch_path": normalize_path(view.get("patch_path") or patch_meta.get("patch_path")), | |
| "caption": view.get("caption", ""), | |
| "selection_confidence": view.get("selection_confidence") or patch_meta.get("selection_confidence"), | |
| } | |
| ) | |
| return rows | |
| def signal_screening_rows( | |
| screening: Dict[str, Any], | |
| signal_lookup: Dict[str, Dict[str, Any]], | |
| ) -> List[Dict[str, Any]]: | |
| rows: List[Dict[str, Any]] = [] | |
| for signal in screening.get("signal_screening", []) or []: | |
| signal_id = str(signal.get("signal_id", "")) | |
| base = dict(signal_lookup.get(signal_id, {"signal_id": signal_id})) | |
| base.update( | |
| { | |
| "status": signal.get("status"), | |
| "confidence": signal.get("confidence"), | |
| "supporting_views": signal.get("supporting_views", []), | |
| "rationale": signal.get("rationale"), | |
| } | |
| ) | |
| rows.append(base) | |
| return rows | |
| def signal_screening_summary(screening: Dict[str, Any]) -> Dict[str, Any]: | |
| rows = [row for row in screening.get("signal_screening", []) or [] if isinstance(row, dict)] | |
| counts: Counter[str] = Counter(str(row.get("status", "unknown")).lower() for row in rows) | |
| present = [] | |
| for row in rows: | |
| if str(row.get("status", "")).lower() != "present": | |
| continue | |
| present.append(row.get("signal_name") or row.get("signal_id")) | |
| return { | |
| "num_present": int(counts.get("present", 0)), | |
| "num_absent": int(counts.get("absent", 0)), | |
| "present_signals": present, | |
| } | |
| def candidate_signal_names(screening: Dict[str, Any], max_count: int = 4) -> List[str]: | |
| rows = [row for row in screening.get("signal_screening", []) or [] if isinstance(row, dict)] | |
| rows.sort( | |
| key=lambda row: ( | |
| str(row.get("status", "")).lower() == "present", | |
| float(row.get("confidence") or 0.0), | |
| ), | |
| reverse=True, | |
| ) | |
| names: List[str] = [] | |
| for row in rows: | |
| name = str(row.get("signal_name") or row.get("signal_id") or "").strip() | |
| if name and name not in names: | |
| names.append(name) | |
| if len(names) >= max_count: | |
| break | |
| return names | |
| def build_ui_tree(records: List[Dict[str, Any]], readable_path: Path, include_anchor_signals: bool = True) -> Dict[str, Any]: | |
| slide_id = infer_slide_id(records, readable_path) | |
| summary = first_record_text(records, "summary") | |
| metrics_raw = parse_json_payload(first_record_text(records, "metrics"), "Structured global metrics:") or {} | |
| qc_raw = parse_json_payload(first_record_text(records, "qc"), "Global segmentation QC:") or {} | |
| thumbnail_meta = catalog_from_records(records, "thumbnail_meta", {}) or {} | |
| tiles_survey = catalog_from_records(records, "tiles_survey", []) or [] | |
| tiles_catalog = catalog_from_records(records, "tiles_catalog", []) or [] | |
| packages = catalog_from_records(records, "tile_anchor_packages", []) or [] | |
| anchors_catalog = catalog_from_records(records, "anchors_catalog", []) or [] | |
| patch_views = catalog_from_records(records, "patch_views_catalog", []) or [] | |
| evidence_items = catalog_from_records(records, "evidence_items", []) or [] | |
| signal_screenings = catalog_from_records(records, "signal_screenings", []) or [] | |
| signal_ontology = catalog_from_records(records, "signal_ontology", {}) or {} | |
| signal_lookup = build_signal_lookup(signal_ontology) | |
| evidence_by_id = {str(item.get("evidence_id", "")): item for item in evidence_items if isinstance(item, dict)} | |
| anchor_by_id = {str(item.get("anchor_id", "")): item for item in anchors_catalog if isinstance(item, dict)} | |
| screening_by_evidence = { | |
| str(item.get("evidence_id", "")): item for item in signal_screenings if isinstance(item, dict) | |
| } | |
| patch_by_key = { | |
| (str(item.get("evidence_id", "")), str(item.get("view_id", ""))): item | |
| for item in patch_views | |
| if isinstance(item, dict) | |
| } | |
| tiles_by_id = {str(item.get("tile_id", "")): item for item in tiles_catalog if isinstance(item, dict)} | |
| packages_by_tile = {str(item.get("tile_id", "")): item for item in packages if isinstance(item, dict)} | |
| tile_ids = sorted(set(tiles_by_id) | set(packages_by_tile)) | |
| tiles_out: List[Dict[str, Any]] = [] | |
| for tile_id in tile_ids: | |
| tile = tiles_by_id.get(tile_id, {}) | |
| package = packages_by_tile.get(tile_id, {}) | |
| anchor_refs = package.get("anchors", []) or [] | |
| if not anchor_refs: | |
| anchor_refs = [a for a in anchors_catalog if a.get("tile_id") == tile_id] | |
| anchors_summary: List[Dict[str, Any]] = [] | |
| anchors_out: List[Dict[str, Any]] = [] | |
| for anchor_ref in anchor_refs: | |
| anchor_id = str(anchor_ref.get("anchor_id", "")) | |
| evidence_id = str(anchor_ref.get("evidence_id", "")) | |
| anchor = {**anchor_ref, **anchor_by_id.get(anchor_id, {})} | |
| evidence = evidence_by_id.get(evidence_id, {}) | |
| screening = screening_by_evidence.get(evidence_id, {}) | |
| views = view_rows(evidence, patch_by_key) | |
| summary_text = anchor_summary_text(evidence, anchor) | |
| confidence = anchor_confidence(evidence, anchor, screening) | |
| anchors_summary.append( | |
| { | |
| "anchor_id": anchor_id, | |
| "evidence_id": evidence_id, | |
| "anchor_bbox_xywh": anchor.get("anchor_bbox_xywh"), | |
| "summary": summary_text, | |
| "confidence": confidence, | |
| "num_views": len(views), | |
| "candidate_signals": candidate_signal_names(screening), | |
| "signal_screening_summary": signal_screening_summary(screening), | |
| } | |
| ) | |
| anchor_obj: Dict[str, Any] = { | |
| "anchor_id": anchor_id, | |
| "evidence_id": evidence_id, | |
| "anchor_bbox_xywh": anchor.get("anchor_bbox_xywh"), | |
| "views": views, | |
| } | |
| if include_anchor_signals: | |
| anchor_obj["signal_screening"] = signal_screening_rows(screening, signal_lookup) | |
| anchors_out.append(anchor_obj) | |
| tiles_out.append( | |
| { | |
| "tile_id": tile_id, | |
| "grid_rc": tile.get("grid_rc"), | |
| "bbox_xywh_level0": tile.get("bbox_level0_xywh") or package.get("tile_bbox_xywh"), | |
| "thumb_bbox_xywh": tile.get("thumb_bbox_xywh"), | |
| "tile_image_path": normalize_path(tile.get("tile_png_path")), | |
| "coarse_label": tile.get("coarse_label") or package.get("coarse_label"), | |
| "label_score": tile.get("label_score") or package.get("label_score"), | |
| "tissue_ratio": tile.get("tissue_ratio"), | |
| "tile_summary": tile.get("tile_desc") or package.get("tile_desc"), | |
| "candidate_signals": candidate_signals_from_package(package), | |
| "anchors_summary": anchors_summary, | |
| "anchors": anchors_out, | |
| } | |
| ) | |
| thumbnail = { | |
| "thumbnail_path": normalize_path(thumbnail_meta.get("thumbnail_path")), | |
| "debug_grid_overlay_path": normalize_path( | |
| thumbnail_meta.get("debug_grid_overlay_path") or qc_raw.get("debug_grid_overlay_path") | |
| ), | |
| "slide_dimensions_level0": thumbnail_meta.get("level0_size"), | |
| "grid_shape": grid_shape_from_tiles(tiles_survey), | |
| } | |
| return { | |
| "slide_id": slide_id, | |
| "schema_version": SCHEMA_VERSION, | |
| "global": { | |
| "summary": summary, | |
| "metrics": metric_subset(metrics_raw), | |
| "signal_overview": summarize_signal_overview(signal_screenings, signal_lookup), | |
| "thumbnail": thumbnail, | |
| }, | |
| "tiles": tiles_out, | |
| } | |
| def candidate_files(args: argparse.Namespace) -> List[Path]: | |
| if args.readable_file: | |
| return [Path(args.readable_file).expanduser()] | |
| readable_dir = Path(args.readable_dir).expanduser() if args.readable_dir else None | |
| if readable_dir is None and args.bank_dir: | |
| readable_dir = Path(args.bank_dir).expanduser() / "readable" | |
| if readable_dir is None: | |
| raise SystemExit("Provide --readable-dir, --readable-file, or --bank-dir.") | |
| files = sorted(readable_dir.glob("*.jsonl")) | |
| if args.slide_id: | |
| query = args.slide_id | |
| filtered = [] | |
| for path in files: | |
| name = path.name[:-6] if path.name.endswith(".jsonl") else path.name | |
| if name == query or name.startswith(query) or query in name: | |
| filtered.append(path) | |
| files = filtered | |
| if args.limit and args.limit > 0: | |
| files = files[: args.limit] | |
| return files | |
| def default_out_dir(args: argparse.Namespace) -> Path: | |
| if args.out_dir: | |
| return Path(args.out_dir).expanduser() | |
| if args.bank_dir: | |
| return Path(args.bank_dir).expanduser() / "ui_tree" | |
| if args.readable_dir: | |
| return Path(args.readable_dir).expanduser().parent / "ui_tree" | |
| return Path(args.readable_file).expanduser().parent.parent / "ui_tree" | |
| def main() -> None: | |
| args = parse_args() | |
| files = candidate_files(args) | |
| if not files: | |
| raise SystemExit("No readable JSONL files matched the request.") | |
| out_dir = default_out_dir(args) | |
| out_dir.mkdir(parents=True, exist_ok=True) | |
| indent = None if args.compact else 2 | |
| exported = [] | |
| for readable_path in files: | |
| records = load_jsonl(readable_path) | |
| tree = build_ui_tree(records, readable_path, include_anchor_signals=not args.no_anchor_signals) | |
| out_name = f"{safe_name(Path(tree['slide_id']).stem)}.ui_tree.json" | |
| out_path = out_dir / out_name | |
| out_path.write_text( | |
| json.dumps(tree, ensure_ascii=False, indent=indent, separators=(",", ":") if args.compact else None) | |
| + "\n", | |
| encoding="utf-8", | |
| ) | |
| exported.append( | |
| { | |
| "slide_id": tree["slide_id"], | |
| "out_path": str(out_path), | |
| "tiles": len(tree["tiles"]), | |
| "anchors": sum(len(tile.get("anchors", [])) for tile in tree["tiles"]), | |
| "views": sum(len(anchor.get("views", [])) for tile in tree["tiles"] for anchor in tile.get("anchors", [])), | |
| } | |
| ) | |
| print( | |
| f"exported {tree['slide_id']} -> {out_path} " | |
| f"(tiles={exported[-1]['tiles']}, anchors={exported[-1]['anchors']}, views={exported[-1]['views']})" | |
| ) | |
| summary_path = out_dir / "export_summary.json" | |
| summary_path.write_text(json.dumps({"schema_version": SCHEMA_VERSION, "exports": exported}, indent=2), encoding="utf-8") | |
| print(f"summary -> {summary_path}") | |
| if __name__ == "__main__": | |
| main() | |