{"metadata":{"kernelspec":{"display_name":"Python 3","language":"python","name":"python3"},"language_info":{"name":"python","version":"3.11.13","mimetype":"text/x-python","codemirror_mode":{"name":"ipython","version":3},"pygments_lexer":"ipython3","nbconvert_exporter":"python","file_extension":".py"},"author":"Team RSNA IA","created_at":"2025-08-20T00:00:00Z","kaggle":{"accelerator":"none","dataSources":[{"sourceId":99552,"databundleVersionId":13190393,"sourceType":"competition"}],"dockerImageVersionId":31089,"isInternetEnabled":true,"language":"python","sourceType":"notebook","isGpuEnabled":false}},"nbformat_minor":4,"nbformat":4,"cells":[{"id":"997ccd26","cell_type":"markdown","source":"# Multimodal IA Dashboard — Setup\n> Load CSVs, scan series, and prepare an index with labels and slice counts.\n","metadata":{}},{"id":"32fbaf53","cell_type":"code","source":"import os, json, re\nimport numpy as np\nimport pandas as pd\nimport pydicom\nfrom IPython.display import display, HTML\nimport matplotlib.pyplot as plt\nimport ipywidgets as widgets\n\nDATA_ROOT = \"/kaggle/input/rsna-intracranial-aneurysm-detection\"\nTRAIN_CSV = os.path.join(DATA_ROOT, \"train.csv\")\nLOCS_CSV  = os.path.join(DATA_ROOT, \"train_localizers.csv\")\nSERIES_DIR = os.path.join(DATA_ROOT, \"series\")\nINDEX_PATH = \"series_index_full.parquet\"\n\n# Columns (14 targets)\nLOCATION_COLS = [\n    \"Left Infraclinoid Internal Carotid Artery\",\n    \"Right Infraclinoid Internal Carotid Artery\",\n    \"Left Supraclinoid Internal Carotid Artery\",\n    \"Right Supraclinoid Internal Carotid Artery\",\n    \"Left Middle Cerebral Artery\",\n    \"Right Middle Cerebral Artery\",\n    \"Anterior Communicating Artery\",\n    \"Left Anterior Cerebral Artery\",\n    \"Right Anterior Cerebral Artery\",\n    \"Left Posterior Communicating Artery\",\n    \"Right Posterior Communicating Artery\",\n    \"Basilar Tip\",\n    \"Other Posterior Circulation\",\n]\nPRESENT_COL = \"Aneurysm Present\"\n\ndef build_full_index(limit=None):\n    df = pd.read_csv(TRAIN_CSV)\n    if limit is not None:\n        df = df.iloc[:limit].copy()\n    series_info = []\n    for sid in df[\"SeriesInstanceUID\"].astype(str).tolist():\n        sp = os.path.join(SERIES_DIR, sid)\n        n = len(os.listdir(sp)) if os.path.exists(sp) else 0\n        ex = None\n        if n:\n            lst = os.listdir(sp)\n            ex = lst[0] if lst else None\n        series_info.append({\"SeriesInstanceUID\": sid, \"NumSlices\": n, \"ExampleFile\": ex})\n    s_df = pd.DataFrame(series_info)\n    merged = df.merge(s_df, on=\"SeriesInstanceUID\", how=\"left\")\n    return merged\n\nif os.path.exists(INDEX_PATH):\n    index_df = pd.read_parquet(INDEX_PATH)\nelse:\n    index_df = build_full_index()  # you can pass a small limit (e.g., 200) if needed\n    index_df.to_parquet(INDEX_PATH, index=False)\n\nlocs_df = pd.read_csv(LOCS_CSV)\ndisplay(index_df.head())\nprint(\"Index shape:\", index_df.shape, \"| Localizers shape:\", locs_df.shape)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-20T20:00:46.436427Z","iopub.execute_input":"2025-08-20T20:00:46.436719Z","iopub.status.idle":"2025-08-20T20:03:25.860712Z","shell.execute_reply.started":"2025-08-20T20:00:46.436696Z","shell.execute_reply":"2025-08-20T20:03:25.859881Z"}},"outputs":[],"execution_count":null},{"id":"0534209a","cell_type":"markdown","source":"# Load & Normalize\n> Read DICOMs, sort by InstanceNumber/SOP, normalize to [0,1], and build SOP/filename maps.\n","metadata":{}},{"id":"0b6b8d86","cell_type":"code","source":"def _safe_int(x, default=0):\n    try:\n        return int(x)\n    except Exception:\n        return default\n\ndef load_series(series_uid):\n    sp = os.path.join(SERIES_DIR, series_uid)\n    files = [os.path.join(sp, f) for f in os.listdir(sp)] if os.path.exists(sp) else []\n    if not files:\n        raise FileNotFoundError(f\"No DICOM files in: {sp}\")\n\n    # Read & sort\n    dsets = [pydicom.dcmread(fp) for fp in files]\n    try:\n        order = np.argsort([_safe_int(getattr(ds, \"InstanceNumber\", 0)) for ds in dsets])\n    except Exception:\n        order = np.argsort([os.path.basename(ds.filename) for ds in dsets])\n    dsets = [dsets[i] for i in order]\n    files = [files[i] for i in order]\n\n    # Build volume\n    arrs = []\n    for ds in dsets:\n        a = ds.pixel_array.astype(np.float32)\n        slope = float(getattr(ds, \"RescaleSlope\", 1.0))\n        intercept = float(getattr(ds, \"RescaleIntercept\", 0.0))\n        a = a * slope + intercept\n        arrs.append(a)\n    vol = np.stack(arrs, axis=-1)  # (H, W, Z)\n\n    # Normalize [0,1]\n    lo, hi = np.percentile(vol, 0.5), np.percentile(vol, 99.5)\n    if hi <= lo:\n        lo, hi = float(vol.min()), float(vol.max())\n    vol = np.clip((vol - lo) / (hi - lo + 1e-6), 0.0, 1.0)\n\n    # Build maps\n    sop_to_idx, name_to_idx = {}, {}\n    for i, ds in enumerate(dsets):\n        sop = str(getattr(ds, \"SOPInstanceUID\", \"\")).strip()\n        if sop:\n            sop_to_idx[sop] = i\n        stem = os.path.splitext(os.path.basename(files[i]))[0].strip()\n        if stem:\n            name_to_idx[stem] = i\n\n    meta = dsets[0]\n    return vol, meta, sop_to_idx, name_to_idx\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-20T20:03:25.861811Z","iopub.execute_input":"2025-08-20T20:03:25.862099Z","iopub.status.idle":"2025-08-20T20:03:25.871996Z","shell.execute_reply.started":"2025-08-20T20:03:25.862079Z","shell.execute_reply":"2025-08-20T20:03:25.871155Z"}},"outputs":[],"execution_count":null},{"id":"8b2c921b","cell_type":"markdown","source":"# Overlay Localizers\n> Parse coordinates (dict/list/string), auto-scale if normalized, map to slice indices, and report diagnostics.\n","metadata":{}},{"id":"e7ad439b","cell_type":"code","source":"def _parse_xy(coord):\n    \"\"\"\n    Parse coordinates from multiple formats:\n    - dict-like: {'x': 296.28, 'y': 152.54} or {\"x\":..., \"y\":...}\n    - list/tuple: [x, y], (x, y)\n    - plain strings: \"x y\", \"x, y\", \"[x y]\", \"(x, y)\"\n    Returns (x, y) as floats.\n    \"\"\"\n    if isinstance(coord, dict):\n        return float(coord.get(\"x\")), float(coord.get(\"y\"))\n    if isinstance(coord, (list, tuple)) and len(coord) >= 2:\n        return float(coord[0]), float(coord[1])\n    s = str(coord).strip()\n    if s.startswith(\"{\") and s.endswith(\"}\"):\n        s_json = re.sub(r\"'\", '\\\"', s)\n        try:\n            obj = json.loads(s_json)\n            if \"x\" in obj and \"y\" in obj:\n                return float(obj[\"x\"]), float(obj[\"y\"])\n        except Exception:\n            pass\n    for ch in \"[]()\":\n        s = s.replace(ch, \" \")\n    s = s.replace(\",\", \" \")\n    parts = s.split()\n    if len(parts) < 2:\n        raise ValueError(f\"Cannot parse coordinates from: {coord}\")\n    return float(parts[0]), float(parts[1])\n\ndef get_localizer_points(series_uid, sop_to_idx, name_to_idx, width=None, height=None, eps=1e-3):\n    sub = locs_df[locs_df[\"SeriesInstanceUID\"] == series_uid]\n    slice_points = {}\n    missing_sops, parse_fails = [], []\n    oob_count, matched = 0, 0\n\n    for _, r in sub.iterrows():\n        raw_sop = str(r[\"SOPInstanceUID\"]).strip()\n\n        # Match slice index by SOP or filename-stem\n        idx = None\n        if raw_sop in sop_to_idx:\n            idx = sop_to_idx[raw_sop]\n        else:\n            stem = os.path.splitext(os.path.basename(raw_sop))[0].strip()\n            if stem and stem in name_to_idx:\n                idx = name_to_idx[stem]\n            elif raw_sop in name_to_idx:\n                idx = name_to_idx[raw_sop]\n            else:\n                missing_sops.append(raw_sop)\n                continue\n\n        # Parse coordinates\n        try:\n            x, y = _parse_xy(r[\"coordinates\"])\n        except Exception:\n            parse_fails.append(str(r[\"coordinates\"]))\n            continue\n\n        # Auto-scale if normalized to [0,1]\n        if width is not None and height is not None:\n            if 0.0 <= x <= 1.0 and 0.0 <= y <= 1.0 and (width > 2 and height > 2):\n                x = x * (width - 1)\n                y = y * (height - 1)\n\n            # Bounds check with small tolerance, then clip\n            if not (-eps <= x <= (width - 1 + eps) and -eps <= y <= (height - 1 + eps)):\n                oob_count += 1\n                continue\n            x = float(np.clip(x, 0, width - 1))\n            y = float(np.clip(y, 0, height - 1))\n\n        lab = r.get(\"location\", \"\")\n        slice_points.setdefault(idx, []).append((x, y, lab))\n        matched += 1\n\n    print(\n        f\"Localizers: total={len(sub)} | matched={matched} | parse_fail={len(parse_fails)} | out_of_bounds={oob_count} | missing_sop={len(missing_sops)}\"\n    )\n    if parse_fails:\n        print(\"Parse-fail examples:\", parse_fails[:3])\n    if missing_sops:\n        print(\"Missing SOP examples:\", missing_sops[:3])\n\n    return slice_points\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-20T20:03:25.873092Z","iopub.execute_input":"2025-08-20T20:03:25.873402Z","iopub.status.idle":"2025-08-20T20:03:25.89888Z","shell.execute_reply.started":"2025-08-20T20:03:25.873376Z","shell.execute_reply":"2025-08-20T20:03:25.897867Z"}},"outputs":[],"execution_count":null},{"id":"44e8c5ff","cell_type":"markdown","source":"# Series Panel (Enhanced Navigation + Bounding Boxes)\n> Draw faint boxes on all slices and highlight them on lesion slices; add quick navigation.\n","metadata":{}},{"id":"6d39c1b0","cell_type":"code","source":"import matplotlib.patches as mpatches\n\ndef render_labels_card(series_uid):\n    row = index_df[index_df[\"SeriesInstanceUID\"] == series_uid].iloc[0]\n    data = {\n        \"SeriesInstanceUID\": series_uid,\n        \"Modality\": row[\"Modality\"],\n        \"Age\": row[\"PatientAge\"],\n        \"Sex\": row[\"PatientSex\"],\n        PRESENT_COL: row[PRESENT_COL],\n    }\n    for c in LOCATION_COLS:\n        data[c] = row.get(c, None)\n    display(pd.DataFrame([data]))\n\ndef _nearest_distance(idx, lesion_idxs):\n    if not lesion_idxs:\n        return None\n    import bisect\n    pos = bisect.bisect_left(lesion_idxs, idx)\n    candidates = []\n    if pos < len(lesion_idxs): candidates.append(abs(lesion_idxs[pos] - idx))\n    if pos > 0: candidates.append(abs(lesion_idxs[pos-1] - idx))\n    return min(candidates) if candidates else None\n\ndef make_bboxes(slice_points, H, W, box_px=None):\n    \"\"\"\n    Build a list of axis-aligned boxes around each localizer (same boxes shown on all slices).\n    box size defaults to ~8% of min(H,W) if not provided.\n    \"\"\"\n    if box_px is None:\n        box_px = int(max(16, 0.08 * min(H, W)))  # adaptive size, >=16px\n    boxes = []\n    seen = set()\n    for idx, pts in slice_points.items():\n        for (x, y, _lab) in pts:\n            w = h = box_px\n            x0 = float(max(0, min(W - w, x - w / 2)))\n            y0 = float(max(0, min(H - h, y - h / 2)))\n            key = (int(round(x0)), int(round(y0)), int(round(w)))\n            if key in seen:\n                continue\n            seen.add(key)\n            boxes.append({\"x0\": x0, \"y0\": y0, \"w\": w, \"h\": h})\n    return boxes\n\ndef _draw_boxes(ax, boxes, style=\"faint\"):\n    \"\"\"\n    Draw boxes on given axes.\n    style='faint' => low alpha, thin lines (for all slices)\n    style='highlight' => stronger alpha/line for current lesion slice\n    \"\"\"\n    if style == \"faint\":\n        alpha, lw, ec = 0.15, 1.5, \"yellow\"\n    else:\n        alpha, lw, ec = 0.55, 2.5, \"red\"\n    for b in boxes:\n        rect = mpatches.Rectangle((b[\"x0\"], b[\"y0\"]), b[\"w\"], b[\"h\"],\n                                  fill=False, linewidth=lw, edgecolor=ec, alpha=alpha)\n        ax.add_patch(rect)\n\n# --- PATCH: fix refresh stalling + add zoom panel on lesion slices ---\nimport matplotlib.patches as mpatches\n\ndef show_series_viewer(series_uid, zoom_box_px=None):\n    \"\"\"\n    Render a viewer with:\n      - main panel (always)\n      - a zoom panel (only when the current slice has localizer points)\n      - faint boxes on all frames + highlighted boxes on lesion frames\n      - robust refresh (display(fig); plt.close(fig))\n    \"\"\"\n    # meta + data\n    row = index_df[index_df[\"SeriesInstanceUID\"] == series_uid].iloc[0]\n    render_labels_card(series_uid)\n\n    vol, meta, sop_to_idx, name_to_idx = load_series(series_uid)\n    H, W, Z = vol.shape\n    slice_points = get_localizer_points(series_uid, sop_to_idx, name_to_idx, width=W, height=H, eps=1e-3)\n    boxes_all = make_bboxes(slice_points, H, W, box_px=None)\n\n    lesion_idxs = sorted(slice_points.keys())\n    present = int(row[PRESENT_COL]) if not pd.isna(row[PRESENT_COL]) else 0\n    if present == 1 and len(lesion_idxs) == 0:\n        print(\"Aneurysm Present = 1, but no mappable localizer points. See diagnostics above.\")\n\n    # widgets + outputs\n    fig_out = widgets.Output()\n    bar_out = widgets.Output()\n    status  = widgets.HTML()\n\n    slider = widgets.IntSlider(value=0, min=0, max=Z-1, step=1, description=\"Slice\", continuous_update=True)\n    play   = widgets.Play(interval=80, value=0, min=0, max=Z-1, step=1, description=\"\", disabled=False)\n    widgets.jslink((play, \"value\"), (slider, \"value\"))\n\n    btn_prev = widgets.Button(description=\"Prev Lesion\")\n    btn_next = widgets.Button(description=\"Next Lesion\")\n    if lesion_idxs:\n        lesion_options = [f\"slice {i} (n={len(slice_points[i])})\" for i in lesion_idxs]\n        lesion_dd = widgets.Dropdown(options=list(zip(lesion_options, lesion_idxs)), description=\"Lesions\")\n    else:\n        lesion_dd = widgets.Dropdown(options=[], description=\"Lesions\")\n\n    # zoom config\n    if zoom_box_px is None:\n        zoom_box_px = int(max(64, 0.20 * min(H, W)))  # ~20% کوتاه‌تر/بلندتر تصویر، حداقل 64px\n\n    def _crop_zoom(img2d, cx, cy, box_px):\n        half = box_px // 2\n        x0 = int(max(0, min(W-1, cx - half)))\n        y0 = int(max(0, min(H-1, cy - half)))\n        x1 = int(min(W, x0 + box_px))\n        y1 = int(min(H, y0 + box_px))\n        # اگر کنار لبه افتاد، مربع را تا جای ممکن حفظ کن\n        if (x1 - x0) < box_px and x0 > 0:\n            x0 = max(0, x1 - box_px)\n        if (y1 - y0) < box_px and y0 > 0:\n            y0 = max(0, y1 - box_px)\n        return img2d[y0:y1, x0:x1], (x0, y0, x1-x0, y1-y0)\n\n    def render_image(idx):\n        with fig_out:\n            fig_out.clear_output(wait=True)\n\n            has_pts = (idx in slice_points and len(slice_points[idx]) > 0)\n\n            # اگر ضایعه هست: 1x2 (اصلی + زوم)، وگرنه: فقط نمای اصلی\n            if has_pts:\n                fig, axes = plt.subplots(1, 2, figsize=(10, 5), gridspec_kw={\"width_ratios\": [3, 2]})\n                ax_main, ax_zoom = axes[0], axes[1]\n            else:\n                fig, ax_main = plt.subplots(1, 1, figsize=(5.8, 5))\n                ax_zoom = None\n\n            # --- main panel ---\n            ax_main.imshow(vol[..., idx], cmap=\"gray\")\n            title = f\"{series_uid} | Slice {idx+1}/{Z}\"\n\n            # faint boxes on all frames\n            if boxes_all:\n                _draw_boxes(ax_main, boxes_all, style=\"faint\")\n\n            if has_pts:\n                # highlight + hollow markers\n                _draw_boxes(ax_main, boxes_all, style=\"highlight\")\n                xs = [p[0] for p in slice_points[idx]]\n                ys = [p[1] for p in slice_points[idx]]\n                ax_main.scatter(xs, ys, s=70, facecolors='none', edgecolors='yellow', linewidths=2, marker='o')\n                for (x, y, lab) in slice_points[idx]:\n                    ax_main.text(x+4, y-6, (str(lab)[:18] if lab else \"aneurysm\"),\n                                 fontsize=9, color=\"yellow\", weight=\"bold\")\n                title += f\" | lesions: {len(xs)}\"\n\n                # --- zoom panel ---\n                if ax_zoom is not None:\n                    cx, cy = int(np.mean(xs)), int(np.mean(ys))\n                    crop, (x0, y0, w_box, h_box) = _crop_zoom(vol[..., idx], cx, cy, zoom_box_px)\n                    ax_zoom.imshow(crop, cmap=\"gray\", interpolation=\"nearest\")\n                    ax_zoom.set_title(\"Zoomed lesion\")\n                    # کراس‌هیر ساده در مرکز کراپ\n                    chx, chy = crop.shape[1]//2, crop.shape[0]//2\n                    ax_zoom.plot([chx, chx], [chy-8, chy+8], linewidth=1.2)\n                    ax_zoom.plot([chx-8, chx+8], [chy, chy], linewidth=1.2)\n                    ax_zoom.axis(\"off\")\n\n                    # برای اینکه بدانیم کجای تصویر را زوم کرده‌ایم، یک باکس روی نمای اصلی هم بگذاریم:\n                    rect_zoom = mpatches.Rectangle((x0, y0), w_box, h_box, fill=False, linewidth=1.5)\n                    ax_main.add_patch(rect_zoom)\n\n            ax_main.set_title(title)\n            ax_main.axis(\"off\")\n\n            # نمایش ایمن بدون گیر کردن کش رندر\n            display(fig)\n            plt.close(fig)\n\n    def render_timeline(idx):\n        with bar_out:\n            bar_out.clear_output(wait=True)\n            fig, ax = plt.subplots(figsize=(6.2, 1.25))\n            ax.plot([0, Z-1], [0, 0], '-')\n            if lesion_idxs:\n                ax.scatter(lesion_idxs, [0]*len(lesion_idxs), s=30)\n            ax.scatter([idx], [0], s=80)\n            ax.set_xlim(-1, Z)\n            ax.set_yticks([])\n            ax.set_xlabel(\"slice index\")\n            plt.tight_layout()\n            display(fig)\n            plt.close(fig)\n\n    def update_status(idx):\n        def _nd(i, arr):\n            if not arr: return None, None\n            import bisect\n            pos = bisect.bisect_left(arr, i)\n            cand = []\n            if pos < len(arr): cand.append(arr[pos])\n            if pos > 0: cand.append(arr[pos-1])\n            near = min(cand, key=lambda j: abs(j - i))\n            return abs(near - i), near\n        d, nearest = _nd(idx, lesion_idxs)\n        if d is None:\n            status.value = f\"<b>Slice {idx}:</b> no lesion slices in this series.\"\n        else:\n            status.value = f\"<b>Slice {idx}:</b> Δ to nearest lesion = <b>{d}</b> (nearest at slice {nearest})\"\n\n    def render_all(idx):\n        render_image(idx)\n        render_timeline(idx)\n        update_status(idx)\n\n    # events\n    def on_slide(ev):\n        if ev[\"name\"] == \"value\":\n            render_all(ev[\"new\"])\n\n    def on_prev(_):\n        if not lesion_idxs:\n            return\n        current = slider.value\n        prevs = [i for i in lesion_idxs if i < current]\n        slider.value = (prevs[-1] if prevs else lesion_idxs[-1])\n\n    def on_next(_):\n        if not lesion_idxs:\n            return\n        current = slider.value\n        nexts = [i for i in lesion_idxs if i > current]\n        slider.value = (nexts[0] if nexts else lesion_idxs[0])\n\n    def on_lesion_select(change):\n        if change[\"name\"] == \"value\" and change[\"new\"] is not None:\n            slider.value = int(change[\"new\"])\n\n    slider.observe(on_slide, names=\"value\")\n    btn_prev.on_click(on_prev)\n    btn_next.on_click(on_next)\n    lesion_dd.observe(on_lesion_select, names=\"value\")\n\n    # initial render\n    render_all(0)\n\n    # layout\n    controls_top  = widgets.HBox([play, slider])\n    controls_jump = widgets.HBox([btn_prev, btn_next, lesion_dd])\n    display(controls_top, controls_jump, status, fig_out, bar_out)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-20T20:03:25.900531Z","iopub.execute_input":"2025-08-20T20:03:25.900787Z","iopub.status.idle":"2025-08-20T20:03:25.935048Z","shell.execute_reply.started":"2025-08-20T20:03:25.900767Z","shell.execute_reply":"2025-08-20T20:03:25.93433Z"}},"outputs":[],"execution_count":null},{"id":"ffa2fd70","cell_type":"markdown","source":"# Browser Panel (Full)\n> Filter by modality/presence/localizer, search by UID, preview results, and select series for rendering.\n","metadata":{}},{"id":"c956a901","cell_type":"code","source":"def format_row(r):\n    return f\"{r['SeriesInstanceUID']} | {r['Modality']} | Age {r['PatientAge']} | {r['PatientSex']} | Slices {r['NumSlices']} | Present {r[PRESENT_COL]}\"\n\nmodality     = widgets.Dropdown(options=[\"All\"] + sorted(index_df[\"Modality\"].dropna().unique().tolist()),\n                                value=\"All\", description=\"Modality\")\npresent      = widgets.Dropdown(options=[\"All\", \"Yes\", \"No\"], value=\"All\", description=\"Aneurysm\")\nloc_filter   = widgets.Dropdown(options=[\"All\", \"Has Localizer\", \"No Localizer\"], value=\"All\", description=\"Localizer\")\nsearch_uid   = widgets.Text(value=\"\", description=\"SeriesUID\", placeholder=\"Type UID substring...\")\nmin_slices   = widgets.IntSlider(value=0, min=0, max=int(index_df[\"NumSlices\"].fillna(0).max()), step=1, description=\"MinSlices\")\nmax_rows     = widgets.IntSlider(value=30, min=5, max=200, step=5, description=\"Show rows\")\nrefresh_btn  = widgets.Button(description=\"Refresh\", button_style=\"\")\nstatus_html  = widgets.HTML()\nout_table    = widgets.Output()\n\n# Global selection widget\nseries_select = widgets.SelectMultiple(\n    options=[],\n    description=\"Series\",\n    rows=12,\n    layout=widgets.Layout(width=\"100%\")\n)\n\ndef get_candidates_df():\n    df = index_df.copy()\n    if modality.value != \"All\":\n        df = df[df[\"Modality\"] == modality.value]\n    if present.value != \"All\":\n        df = df[df[PRESENT_COL] == (1 if present.value == \"Yes\" else 0)]\n    if loc_filter.value != \"All\":\n        uids_with_loc = set(locs_df[\"SeriesInstanceUID\"].astype(str).unique().tolist())\n        if loc_filter.value == \"Has Localizer\":\n            df = df[df[\"SeriesInstanceUID\"].astype(str).isin(uids_with_loc)]\n        else:\n            df = df[~df[\"SeriesInstanceUID\"].astype(str).isin(uids_with_loc)]\n    if min_slices.value > 0:\n        df = df[df[\"NumSlices\"].fillna(0).astype(int) >= int(min_slices.value)]\n    q = search_uid.value.strip()\n    if q:\n        df = df[df[\"SeriesInstanceUID\"].astype(str).str.contains(q)]\n    df = df.sort_values([PRESENT_COL, \"Modality\", \"NumSlices\"], ascending=[False, True, False])\n    return df\n\ndef refresh_view(_=None):\n    df = get_candidates_df()\n    uid_options = df[\"SeriesInstanceUID\"].astype(str).head(int(max_rows.value)).tolist()\n    series_select.options = uid_options\n    total = len(df)\n    shown = min(total, int(max_rows.value))\n    status_html.value = f\"<b>Filtered:</b> {total} rows &nbsp;|&nbsp; <b>Shown:</b> {shown} &nbsp;|&nbsp; <b>Selected:</b> {len(series_select.value)}\"\n    with out_table:\n        out_table.clear_output(wait=True)\n        if total == 0:\n            print(\"No matches.\")\n        else:\n            display(\n                df[[\"SeriesInstanceUID\",\"Modality\",\"PatientAge\",\"PatientSex\",\"NumSlices\",PRESENT_COL]]\n                .head(int(max_rows.value))\n                .reset_index(drop=True)\n            )\n\nfor w in [modality, present, loc_filter, search_uid, min_slices, max_rows]:\n    w.observe(refresh_view, names=\"value\")\nrefresh_btn.on_click(refresh_view)\n\ndisplay(\n    widgets.VBox([\n        widgets.HBox([modality, present, loc_filter]),\n        widgets.HBox([search_uid, min_slices, max_rows, refresh_btn]),\n        status_html,\n        out_table,\n        series_select\n    ])\n)\nrefresh_view()\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-20T20:03:25.935934Z","iopub.execute_input":"2025-08-20T20:03:25.936199Z","iopub.status.idle":"2025-08-20T20:03:26.007081Z","shell.execute_reply.started":"2025-08-20T20:03:25.936179Z","shell.execute_reply":"2025-08-20T20:03:26.006336Z"}},"outputs":[],"execution_count":null},{"id":"79735191","cell_type":"markdown","source":"# Render Selection\n> Render viewers for selected series using the enhanced panel.\n","metadata":{}},{"id":"1a734646","cell_type":"code","source":"run_btn = widgets.Button(description=\"Render Selected\", button_style=\"primary\")\nout_panels = widgets.Output()\n\ndef on_run(_):\n    with out_panels:\n        out_panels.clear_output(wait=True)\n        if not series_select.value:\n            print(\"Select one or more series from the list.\")\n            return\n        for uid in series_select.value:\n            print(\"=\"*80)\n            print(\"Rendering:\", uid)\n            show_series_viewer(uid)\n\nrun_btn.on_click(on_run)\ndisplay(run_btn, out_panels)\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-20T20:03:26.007954Z","iopub.execute_input":"2025-08-20T20:03:26.008205Z","iopub.status.idle":"2025-08-20T20:03:26.019733Z","shell.execute_reply.started":"2025-08-20T20:03:26.008186Z","shell.execute_reply":"2025-08-20T20:03:26.018963Z"}},"outputs":[],"execution_count":null},{"id":"18eb1a21","cell_type":"markdown","source":"# Optional — Concatenate Series\n> Concatenate selected series and scrub through all frames as one long cine.\n","metadata":{}},{"id":"2eb5823d","cell_type":"code","source":"def concat_series(uids):\n    vols, metas = [], []\n    if not uids:\n        print(\"No UIDs provided to concat.\")\n        return None, None\n    for uid in uids:\n        try:\n            v, m, _, _ = load_series(uid)\n            vols.append(v); metas.append(m)\n        except Exception as e:\n            print(\"Skipping\", uid, \":\", e)\n    if not vols:\n        print(\"No volumes could be loaded — nothing to concatenate.\")\n        return None, None\n    H = max(v.shape[0] for v in vols); W = max(v.shape[1] for v in vols)\n    padded = []\n    for v in vols:\n        ph = H - v.shape[0]; pw = W - v.shape[1]\n        padded.append(np.pad(v, ((0,ph),(0,pw),(0,0)), mode=\"edge\"))\n    big = np.concatenate(padded, axis=-1)\n    return big, metas\n\ndef render_concatenated(uids):\n    vol, metas = concat_series(uids)\n    if vol is None:\n        return\n    fig_out = widgets.Output()\n    slider = widgets.IntSlider(value=0, min=0, max=vol.shape[-1]-1, step=1, description=\"Slice\")\n    play   = widgets.Play(interval=60, value=0, min=0, max=vol.shape[-1]-1, step=1, description=\"\", disabled=False)\n    widgets.jslink((play, 'value'), (slider, 'value'))\n\n    def render(idx):\n        with fig_out:\n            fig_out.clear_output(wait=True)\n            plt.figure(figsize=(5,5))\n            plt.imshow(vol[..., idx], cmap=\"gray\")\n            plt.title(f\"Concat cine | Frame {idx+1}/{vol.shape[-1]}\")\n            plt.axis(\"off\")\n            plt.show()\n\n    def on_slide(ev):\n        if ev[\"name\"] == \"value\":\n            render(ev[\"new\"])\n\n    slider.observe(on_slide, names=\"value\")\n    render(0)\n    display(widgets.HBox([play, slider]), fig_out)\n\n# Example usage:\n# render_concatenated(list(series_select.value))\n","metadata":{"trusted":true,"execution":{"iopub.status.busy":"2025-08-20T20:03:26.020688Z","iopub.execute_input":"2025-08-20T20:03:26.020947Z","iopub.status.idle":"2025-08-20T20:03:26.040546Z","shell.execute_reply.started":"2025-08-20T20:03:26.020929Z","shell.execute_reply":"2025-08-20T20:03:26.039663Z"}},"outputs":[],"execution_count":null}]}