files / tools /viewer /app.py
milkyroad's picture
Upload folder using huggingface_hub (part 5)
d13c6ce verified
Raw
History Blame Contribute Delete
15 kB
"""
Dataset F Viewer — FastAPI backend.
Serves images from the HF dataset with the EXACT same preprocessing the model sees:
1. PIL convert("RGB")
2. Letterbox to 384×384 (BICUBIC resize + zero-pad)
3. ImageNet normalize: (x - mean) / std
Three image modes:
- raw: Original 350×350 image as-is
- letterbox: After letterbox to 384×384 (before normalization) — what the model literally receives
- normalized: Letterbox + ImageNet normalize (de-normalized for display — what the model "sees")
Also joins CystoDS metadata for source C images to show the 8 NML subclasses.
"""
import io, json, base64, pathlib, sys
from collections import Counter, defaultdict
import numpy as np
import pandas as pd
from PIL import Image
from fastapi import FastAPI, Query, HTTPException
from fastapi.responses import HTMLResponse, JSONResponse, Response
from datasets import load_from_disk
# ── Paths ──
F_HF = pathlib.Path("/home/kccitadmin/jupyterlab/sandbox/datasets/F_hf_dataset")
CYSTO_META = pathlib.Path("/home/kccitadmin/jupyterlab/sandbox/data/C/data/metadata.parquet")
VIEWER_DIR = pathlib.Path("/home/kccitadmin/jupyterlab/sandbox/tools/viewer")
# ── Model preprocessing constants (from train_convnext_mlp.py) ──
SIZE = 384
IMAGENET_MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
IMAGENET_STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
# ── Label mappings ──
TARGET3_NAMES = {0: "Malignant Tumor", 1: "Non-Malignant Lesion", 2: "Non-ROI"}
CANCER_NAMES = {0: "Non-cancer", 1: "Cancer"}
GRADE_NAMES = {0: "High Grade", 1: "Low Grade", 2: "Non-cancer"}
SUBCLASS_NAMES = {0: "MT", 1: "NML", 2: "Normal", 3: "Foreign Body", 4: "Landmark"}
IMAGING_NAMES = {0: "WLI", 1: "BLC", 2: "NBI"}
SPLIT_NAMES = {"train": "Train", "validation": "Validation", "test": "Test"}
# 8 CystoDS NML subclasses
CYSTO_NML_SUBCLASSES = {
"BenignNOS": "Benign NOS (reactive changes, atypia, dysplasia)",
"InflammationNOS": "Inflammation NOS (non-specific cystitis)",
"CCG": "Cystitis cystica & glandularis",
"Denuded": "Denuded urothelium",
"UrothelialPapilloma": "Urothelial papilloma",
"SquamousMetaplasia": "Squamous metaplasia",
"NephrogenicAdenoma": "Nephrogenic adenoma",
"BenignRare": "Benign rare (malakoplakia, melanosis)",
}
# ── Load dataset ──
print("Loading F dataset...", flush=True)
f_ds = load_from_disk(str(F_HF))
# Build in-memory index for each split
print("Building index...", flush=True)
INDEX = {} # split -> list of dicts
for split in ["train", "validation", "test"]:
ds = f_ds[split]
n = len(ds)
cols = {col: ds[col] for col in ds.column_names if col != 'image'}
records = []
for i in range(n):
rec = {
"idx": i,
"split": split,
"filename": cols["original_filename"][i],
"target3": cols["target3"][i],
"cancer_label": cols["cancer_label"][i],
"grade_label": cols["grade_label"][i],
"subclass_label": cols["subclass_label"][i],
"source_dataset": cols["source_dataset"][i],
"patient_id": cols["patient_id"][i],
"imaging_type": cols["imaging_type"][i],
"track_id": cols["track_id"][i],
"cv_fold": cols["cv_fold"][i],
}
records.append(rec)
INDEX[split] = records
# Load CystoDS metadata and join subclass info for source C
print("Loading CystoDS metadata...", flush=True)
cysto_meta = pd.read_parquet(str(CYSTO_META))
cysto_meta.set_index('filename', inplace=True)
cysto_lookup = cysto_meta[['class', 'subclass', 'subclass2', 'stage', 'morphology', 'modality', 'lesion', 'visit', 'pid']].to_dict('index')
# Add CystoDS fields to records (clean NaN → None)
def _clean(v):
if v is None:
return None
try:
if pd.isna(v):
return None
except (TypeError, ValueError):
pass
return v
for split in ["train", "validation", "test"]:
for rec in INDEX[split]:
fn = rec["filename"]
if rec["source_dataset"] == "C" and fn in cysto_lookup:
c = cysto_lookup[fn]
rec["cysto_class"] = _clean(c.get("class"))
rec["cysto_subclass"] = _clean(c.get("subclass"))
rec["cysto_subclass2"] = _clean(c.get("subclass2"))
rec["cysto_stage"] = _clean(c.get("stage"))
rec["cysto_morphology"] = _clean(c.get("morphology"))
rec["cysto_modality"] = _clean(c.get("modality"))
rec["cysto_lesion"] = _clean(c.get("lesion"))
rec["cysto_visit"] = _clean(c.get("visit"))
rec["cysto_pid"] = _clean(c.get("pid"))
else:
for k in ["cysto_class","cysto_subclass","cysto_subclass2","cysto_stage",
"cysto_morphology","cysto_modality","cysto_lesion","cysto_visit","cysto_pid"]:
rec[k] = None
# All records combined for cross-split queries
ALL_RECORDS = []
for split in ["train", "validation", "test"]:
ALL_RECORDS.extend(INDEX[split])
TOTAL = len(ALL_RECORDS)
print(f"Loaded {TOTAL} records across 3 splits.", flush=True)
# ── Image preprocessing (EXACT match to training) ──
def letterbox(img, size=SIZE):
"""Aspect-ratio-preserving resize with zero-padding (same as train_convnext_mlp.py)."""
w, h = img.size
scale = size / max(w, h)
new_w = max(int(round(w * scale)), 1)
new_h = max(int(round(h * scale)), 1)
img = img.resize((new_w, new_h), Image.BICUBIC)
padded = Image.new("RGB", (size, size), (0, 0, 0))
padded.paste(img, ((size - new_w) // 2, (size - new_h) // 2))
return padded
def get_image(split, idx, mode="letterbox"):
"""Return PNG bytes for the requested image mode."""
ds = f_ds[split]
img = ds[int(idx)]["image"].convert("RGB")
if mode == "raw":
# Original image as-is
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
elif mode == "letterbox":
# After letterbox (what the model literally receives as input pixels)
img = letterbox(img, SIZE)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
elif mode == "normalized":
# Letterbox + ImageNet normalize, then de-normalize for display
img = letterbox(img, SIZE)
arr = np.array(img, dtype=np.float32) / 255.0
arr = (arr - IMAGENET_MEAN) / IMAGENET_STD
# De-normalize for display: x = arr * std + mean, clip to [0,1]
arr = arr * IMAGENET_STD + IMAGENET_MEAN
arr = np.clip(arr, 0, 1)
arr = (arr * 255).astype(np.uint8)
img = Image.fromarray(arr)
buf = io.BytesIO()
img.save(buf, format="PNG")
return buf.getvalue()
else:
raise ValueError(f"Unknown mode: {mode}")
# ── FastAPI app ──
app = FastAPI(title="Dataset F Viewer")
@app.get("/", response_class=HTMLResponse)
async def home():
html = (VIEWER_DIR / "templates" / "index.html").read_text()
return HTMLResponse(html)
@app.get("/api/stats")
async def stats():
"""Return dataset statistics for dashboard."""
# target3 distribution per split
t3_dist = {}
for split in ["train", "validation", "test"]:
t3_dist[split] = dict(Counter(r["target3"] for r in INDEX[split]))
# imaging_type distribution per split
img_dist = {}
for split in ["train", "validation", "test"]:
img_dist[split] = dict(Counter(r["imaging_type"] for r in INDEX[split]))
# source distribution per split
src_dist = {}
for split in ["train", "validation", "test"]:
src_dist[split] = dict(Counter(r["source_dataset"] for r in INDEX[split]))
# target3 x imaging_type cross-tab
cross = defaultdict(lambda: defaultdict(int))
for r in ALL_RECORDS:
cross[r["target3"]][r["imaging_type"]] += 1
cross_tab = {str(k): dict(v) for k, v in cross.items()}
# CystoDS subclass distribution (source C NML)
cysto_nml_sub = Counter()
for r in ALL_RECORDS:
if r["source_dataset"] == "C" and r["target3"] == 1 and r.get("cysto_subclass"):
cysto_nml_sub[r["cysto_subclass"]] += 1
# patient count
all_patients = set(r["patient_id"] for r in ALL_RECORDS)
# source x imaging_type
src_img = defaultdict(lambda: defaultdict(int))
for r in ALL_RECORDS:
src_img[r["source_dataset"]][r["imaging_type"]] += 1
src_img_tab = {k: dict(v) for k, v in src_img.items()}
return JSONResponse({
"total": TOTAL,
"splits": {s: len(INDEX[s]) for s in ["train", "validation", "test"]},
"target3": t3_dist,
"imaging_type": img_dist,
"source": src_dist,
"target3_x_imaging": cross_tab,
"source_x_imaging": src_img_tab,
"cysto_nml_subclass": dict(cysto_nml_sub),
"num_patients": len(all_patients),
"label_maps": {
"target3": TARGET3_NAMES,
"imaging_type": IMAGING_NAMES,
"subclass_label": SUBCLASS_NAMES,
"cancer_label": CANCER_NAMES,
"grade_label": GRADE_NAMES,
},
"cysto_nml_subclasses": CYSTO_NML_SUBCLASSES,
})
@app.get("/api/browse")
async def browse(
split: str = Query("", description="Filter by split"),
target3: str = Query("", description="Filter by target3 class"),
imaging_type: str = Query("", description="Filter by imaging type"),
source: str = Query("", description="Filter by source dataset"),
patient_id: str = Query("", description="Filter by patient ID"),
cv_fold: str = Query("", description="Filter by CV fold"),
track_id: str = Query("", description="Filter by track ID"),
subclass: str = Query("", description="Filter by CystoDS subclass (source C)"),
search: str = Query("", description="Search filename"),
sort: str = Query("idx", description="Sort field"),
page: int = Query(1, ge=1),
per_page: int = Query(50, ge=1, le=200),
):
"""Browse images with filters, return paginated results."""
records = list(ALL_RECORDS)
# Apply filters
if split:
records = [r for r in records if r["split"] == split]
if target3 != "":
records = [r for r in records if r["target3"] == int(target3)]
if imaging_type != "":
records = [r for r in records if r["imaging_type"] == int(imaging_type)]
if source:
records = [r for r in records if r["source_dataset"] == source]
if patient_id != "":
records = [r for r in records if r["patient_id"] == int(patient_id)]
if cv_fold != "":
records = [r for r in records if r["cv_fold"] == int(cv_fold)]
if track_id:
records = [r for r in records if r["track_id"] == track_id]
if subclass:
records = [r for r in records if r.get("cysto_subclass") == subclass]
if search:
records = [r for r in records if search.lower() in r["filename"].lower()]
# Sort
sort_map = {
"idx": lambda r: (r["split"], r["idx"]),
"patient": lambda r: (r["patient_id"], r["idx"]),
"filename": lambda r: (r["filename"]),
"target3": lambda r: (r["target3"], r["idx"]),
"imaging": lambda r: (r["imaging_type"], r["idx"]),
"source": lambda r: (r["source_dataset"], r["idx"]),
}
records.sort(key=sort_map.get(sort, sort_map["idx"]))
total = len(records)
start = (page - 1) * per_page
end = start + per_page
page_records = records[start:end]
return JSONResponse({
"total": total,
"page": page,
"per_page": per_page,
"pages": (total + per_page - 1) // per_page,
"records": page_records,
})
@app.get("/api/detail/{split}/{idx}")
async def detail(split: str, idx: int):
"""Get full metadata for a single image."""
if split not in INDEX or idx < 0 or idx >= len(INDEX[split]):
raise HTTPException(404, "Image not found")
return JSONResponse(INDEX[split][idx])
@app.get("/api/patient/{pid}")
async def patient_view(pid: int):
"""Get all images for a patient, grouped by track_id."""
patient_records = [r for r in ALL_RECORDS if r["patient_id"] == pid]
# Group by track_id
tracks = defaultdict(list)
for r in patient_records:
tracks[r["track_id"]].append(r)
# Sort within each track by idx
for tid in tracks:
tracks[tid].sort(key=lambda r: r["idx"])
# Get patient stats
t3_dist = Counter(r["target3"] for r in patient_records)
src_dist = Counter(r["source_dataset"] for r in patient_records)
img_dist = Counter(r["imaging_type"] for r in patient_records)
return JSONResponse({
"patient_id": pid,
"total_images": len(patient_records),
"splits": dict(Counter(r["split"] for r in patient_records)),
"target3_dist": dict(t3_dist),
"source_dist": dict(src_dist),
"imaging_dist": dict(img_dist),
"tracks": {tid: recs for tid, recs in sorted(tracks.items())},
"cysto_subclasses": dict(Counter(r["cysto_subclass"] for r in patient_records
if r.get("cysto_subclass"))),
})
@app.get("/api/filters")
async def get_filter_options():
"""Get available filter options (unique values)."""
patients = sorted(set(r["patient_id"] for r in ALL_RECORDS))
tracks = sorted(set(r["track_id"] for r in ALL_RECORDS if r["track_id"] != "NA"))
cysto_subs = sorted(set(r["cysto_subclass"] for r in ALL_RECORDS if r.get("cysto_subclass")))
return JSONResponse({
"patients": patients,
"tracks": tracks,
"cysto_subclasses": cysto_subs,
"splits": list(INDEX.keys()),
"target3": [{"value": k, "name": v} for k, v in TARGET3_NAMES.items()],
"imaging_types": [{"value": k, "name": v} for k, v in IMAGING_NAMES.items()],
"sources": ["B", "C", "D"],
"cv_folds": [0, 1, 2, 3, 4],
})
@app.get("/image/{split}/{idx}")
async def serve_image(split: str, idx: int, mode: str = Query("letterbox")):
"""Serve an image in the requested mode."""
if split not in f_ds or idx < 0 or idx >= len(f_ds[split]):
raise HTTPException(404, "Image not found")
img_bytes = get_image(split, idx, mode)
return Response(content=img_bytes, media_type="image/png")
@app.get("/thumb/{split}/{idx}")
async def serve_thumb(split: str, idx: int, mode: str = Query("letterbox")):
"""Serve a thumbnail (100×100) of an image."""
if split not in f_ds or idx < 0 or idx >= len(f_ds[split]):
raise HTTPException(404, "Image not found")
img_bytes = get_image(split, idx, mode)
img = Image.open(io.BytesIO(img_bytes))
img = img.resize((100, 100), Image.BICUBIC)
buf = io.BytesIO()
img.save(buf, format="PNG")
return Response(content=buf.getvalue(), media_type="image/png")
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8910)