SlideBank / scripts /export_ui_tree.py
gexinh's picture
Add preloaded evidence workbench with ZeroGPU question answering
9d48b61 verified
Raw History Blame Contribute Delete
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()