소스 검색

Some code reorg

Martin Horvat 6 시간 전
부모
커밋
4281d81469
3개의 변경된 파일496개의 추가작업 그리고 495개의 파일을 삭제
  1. 0 495
      app.py
  2. 1 0
      app.py
  3. 495 0
      shiny_app/app.py

+ 0 - 495
app.py

@@ -1,495 +0,0 @@
-"""
-Interactive Shiny app for inspecting processed PET SUV NIfTI images.
-
-Run from project root:
-
-    shiny run --reload app.py
-
-Required packages:
-
-    pip install shiny shinywidgets shinyswatch plotly pandas nibabel scipy scikit-image
-
-Expected project structure:
-
-project_root/
-├── app.py
-├── data/
-│   ├── gen/
-│   └── raw/
-└── spatial_suv_charact/
-    ├── image_io.py
-    ├── metadata.py
-    ├── plotting.py
-    ├── spatial_features.py
-    └── suv_stats.py
-"""
-
-from __future__ import annotations
-
-from pathlib import Path
-import sys
-import traceback
-
-import pandas as pd
-import plotly.graph_objects as go
-
-from shiny import App, Inputs, Outputs, Session, reactive, render, ui
-from shinywidgets import output_widget, render_widget
-
-# -----------------------------------------------------------------------------
-# Project paths
-# -----------------------------------------------------------------------------
-
-APP_DIR = "."  # str(Path(__file__).resolve().parent)
-PROJECT_ROOT = APP_DIR
-
-if str(PROJECT_ROOT) not in sys.path:
-    sys.path.insert(0, str(PROJECT_ROOT))
-
-import os
-
-DATA_RAW = os.path.join(PROJECT_ROOT, "data", "raw")
-DATA_GEN = os.path.join(PROJECT_ROOT, "data", "gen")
-DEFAULT_METADATA_PATH = os.path.join(DATA_GEN, "metadata.pkl")
-
-# -----------------------------------------------------------------------------
-# Local package imports
-# -----------------------------------------------------------------------------
-
-from spatial_suv_charact.metadata import (  # noqa: E402
-    get_meta_data,
-    flag_corrupted_files,
-    flag_AE_patients,
-)
-from spatial_suv_charact.image_io import get_processed_image  # noqa: E402
-from spatial_suv_charact.plotting import (  # noqa: E402
-    plot_suv_pdf_plotly,
-    plot_hot_voxels_plotly,
-)
-from spatial_suv_charact.spatial_features import compute_tail_spatial_features  # noqa: E402
-
-
-# -----------------------------------------------------------------------------
-# Helper functions
-# -----------------------------------------------------------------------------
-
-
-def _empty_figure(message: str = "No plot available") -> go.Figure:
-    """Return an empty Plotly figure with a centered annotation."""
-    fig = go.Figure()
-    fig.add_annotation(
-        text=message,
-        xref="paper",
-        yref="paper",
-        x=0.5,
-        y=0.5,
-        showarrow=False,
-    )
-    fig.update_layout(
-        template="plotly_white",
-        xaxis={"visible": False},
-        yaxis={"visible": False},
-        height=500,
-    )
-    return fig
-
-
-def _load_metadata() -> pd.DataFrame:
-    """Load metadata table from disk or build it from DATA_RAW."""
-    if DEFAULT_METADATA_PATH.exists():
-        df_meta = pd.read_pickle(DEFAULT_METADATA_PATH)
-    else:
-        df_meta = get_meta_data(str(DATA_RAW))
-        df_meta = flag_corrupted_files(df_meta)
-        df_meta = flag_AE_patients(df_meta)
-
-        DATA_GEN.mkdir(parents=True, exist_ok=True)
-        df_meta.to_pickle(DEFAULT_METADATA_PATH)
-
-    if not isinstance(df_meta.index, pd.MultiIndex):
-        required_cols = {"patient_id", "organ", "visit"}
-        if required_cols.issubset(df_meta.columns):
-            df_meta = df_meta.set_index(["patient_id", "organ", "visit"])
-        else:
-            raise ValueError(
-                "Metadata table must have MultiIndex (patient_id, organ, visit) "
-                "or columns patient_id, organ, visit."
-            )
-
-    required_columns = {"PET_path", "SEG_path"}
-    missing = required_columns.difference(df_meta.columns)
-    if missing:
-        raise ValueError(f"Metadata table is missing required columns: {missing}")
-
-    return df_meta.sort_index()
-
-
-DF_META = _load_metadata()
-
-
-def _metadata_for_display(df_meta: pd.DataFrame) -> pd.DataFrame:
-    """Return metadata with index columns exposed and an internal row_id."""
-    df = df_meta.reset_index().copy()
-    df.insert(0, "row_id", range(len(df)))
-
-    preferred = [
-        "row_id",
-        "patient_id",
-        "organ",
-        "visit",
-        "is_AE_patient",
-        "is_corrupted",
-        "PET_filename",
-        "SEG_filename",
-        "PET_path",
-        "SEG_path",
-    ]
-    cols = [c for c in preferred if c in df.columns] + [
-        c for c in df.columns if c not in preferred
-    ]
-    return df[cols]
-
-
-DF_DISPLAY = _metadata_for_display(DF_META)
-
-
-def _format_image_id(row: pd.Series) -> str:
-    """Create a readable image identifier from a selected metadata row."""
-    return f"{row['patient_id']}_{row['organ']}_VISIT_{row['visit']}"
-
-
-def _parse_probs(prob_string: str) -> tuple[float, ...]:
-    """Parse comma-separated percentile values from UI input."""
-    values: list[float] = []
-    for part in prob_string.split(","):
-        part = part.strip()
-        if not part:
-            continue
-        values.append(float(part))
-
-    if not values:
-        raise ValueError("At least one percentile must be supplied.")
-
-    for value in values:
-        if not (0 < value < 100):
-            raise ValueError("Percentiles must be between 0 and 100.")
-
-    return tuple(values)
-
-
-def _safe_error_message(exc: BaseException) -> str:
-    """Return a readable error message for the diagnostics tab."""
-    return "\n".join(
-        [
-            f"{type(exc).__name__}: {exc}",
-            "",
-            traceback.format_exc(limit=8),
-        ]
-    )
-
-
-# -----------------------------------------------------------------------------
-# UI
-# -----------------------------------------------------------------------------
-
-app_ui = ui.page_fluid(
-    ui.h2("Spatial SUV tail-feature explorer"),
-    ui.layout_sidebar(
-        ui.sidebar(
-            ui.input_text(
-                "probs",
-                "Percentiles",
-                value="80, 90, 95",
-                placeholder="80, 90, 95",
-            ),
-            ui.input_numeric("bins", "Histogram bins", value=100, min=10, max=500),
-            ui.input_numeric("min_suv", "Minimum SUV for PDF", value=0.1, min=0),
-            ui.input_checkbox("log_x", "Log x-axis for SUV PDF", value=True),
-            ui.hr(),
-            ui.input_numeric(
-                "min_component_voxels",
-                "Minimum component voxels",
-                value=3,
-                min=1,
-                step=1,
-            ),
-            ui.input_select(
-                "component_connectivity",
-                "Component connectivity",
-                choices={"6": "6", "18": "18", "26": "26"},
-                selected="26",
-            ),
-            ui.input_select(
-                "contrast_connectivity",
-                "Contrast connectivity",
-                choices={"6": "6", "18": "18", "26": "26"},
-                selected="26",
-            ),
-            ui.input_checkbox("compute_spread", "Compute spread", value=True),
-            ui.input_checkbox("compute_local_contrast", "Compute local contrast", value=True),
-            ui.input_checkbox("compute_sphericity", "Compute sphericity", value=True),
-            ui.input_checkbox("crop_to_roi", "Crop to ROI", value=True),
-            ui.hr(),
-            ui.input_action_button("run", "Compute selected row", class_="btn-primary"),
-            width=330,
-        ),
-        ui.navset_tab(
-            ui.nav_panel(
-                "Metadata table",
-                ui.p("Select one row from the table, then click 'Compute selected row'."),
-                ui.output_data_frame("metadata_table"),
-            ),
-            ui.nav_panel(
-                "SUV PDF",
-                ui.output_ui("selected_summary_pdf"),
-                output_widget("suv_pdf_plot", height="560px"),
-                ui.h4("SUV percentiles"),
-                ui.output_data_frame("suv_percentiles_table"),
-            ),
-            ui.nav_panel(
-                "Hot voxels",
-                ui.output_ui("selected_summary_hot"),
-                ui.output_ui("hot_voxel_tabs"),
-            ),
-            ui.nav_panel(
-                "Spatial features",
-                ui.output_ui("selected_summary_features"),
-                ui.output_data_frame("features_table"),
-            ),
-            ui.nav_panel(
-                "Errors / diagnostics",
-                ui.output_text_verbatim("diagnostics"),
-            ),
-        ),
-    ),
-)
-
-
-# -----------------------------------------------------------------------------
-# Server
-# -----------------------------------------------------------------------------
-
-
-def server(input: Inputs, output: Outputs, session: Session):
-    @render.data_frame
-    def metadata_table():
-        return render.DataGrid(
-            DF_DISPLAY,
-            selection_mode="row",
-            filters=True,
-            height="650px",
-        )
-
-    @reactive.calc
-    def selected_row_display() -> pd.Series | None:
-        selected = metadata_table.cell_selection()["rows"]
-        if not selected:
-            return None
-        return DF_DISPLAY.iloc[int(selected[0])]
-
-    @reactive.calc
-    @reactive.event(input.run)
-    def analysis_result():
-        """Load selected image and compute all requested outputs once per click."""
-        row_display = selected_row_display()
-        if row_display is None:
-            return {
-                "ok": False,
-                "error": "No row selected. Select a row in the metadata table first.",
-            }
-
-        try:
-            probs = _parse_probs(input.probs())
-
-            patient_id = row_display["patient_id"]
-            organ = row_display["organ"]
-            visit = int(row_display["visit"])
-            index_key = (patient_id, organ, visit)
-
-            row_meta, processed_image = get_processed_image(
-                DF_META,
-                patient_id=patient_id,
-                organ=organ,
-                visit=visit,
-            )
-
-            image_id = _format_image_id(row_display)
-
-            pdf_fig, suv_percentiles = plot_suv_pdf_plotly(
-                processed_image,
-                percentiles=probs,
-                bins=int(input.bins()),
-                log_x=bool(input.log_x()),
-                min_suv=float(input.min_suv()),
-                title=f"SUV distribution: {image_id}",
-            )
-
-            hot_figs: dict[float, go.Figure] = {}
-            for p in probs:
-                threshold = float(
-                    suv_percentiles.loc[
-                        suv_percentiles["percentile"] == p,
-                        "suv_threshold",
-                    ].iloc[0]
-                )
-
-                hot_fig = plot_hot_voxels_plotly(
-                    processed_image,
-                    c=threshold,
-                )
-                hot_fig.update_layout(
-                    title=f"Hot voxels: {image_id}, p{p:g}, SUV ≥ {threshold:.4g}"
-                )
-                hot_figs[p] = hot_fig
-
-            features = compute_tail_spatial_features(
-                image=processed_image,
-                percentiles=probs,
-                component_connectivity=int(input.component_connectivity()),
-                contrast_connectivity=int(input.contrast_connectivity()),
-                min_component_voxels=int(input.min_component_voxels()),
-                compute_spread=bool(input.compute_spread()),
-                compute_local_contrast=bool(input.compute_local_contrast()),
-                compute_sphericity=bool(input.compute_sphericity()),
-                crop_to_roi=bool(input.crop_to_roi()),
-                image_id=index_key,
-            )
-
-            return {
-                "ok": True,
-                "row_display": row_display,
-                "row_meta": row_meta,
-                "image_id": image_id,
-                "probs": probs,
-                "pdf_fig": pdf_fig,
-                "suv_percentiles": suv_percentiles,
-                "hot_figs": hot_figs,
-                "features": features,
-                "diagnostics": f"Computed successfully for {image_id}",
-            }
-
-        except Exception as exc:
-            return {
-                "ok": False,
-                "row_display": row_display,
-                "error": _safe_error_message(exc),
-            }
-
-    def _selected_summary(result: dict) -> ui.TagList:
-        if not result.get("ok"):
-            return ui.TagList(ui.div(ui.strong("No successful computation yet.")))
-
-        row = result["row_display"]
-        return ui.TagList(
-            ui.div(
-                ui.strong("Selected image: "),
-                f"{row['patient_id']} | {row['organ']} | VISIT_{row['visit']}",
-            )
-        )
-
-    @render.ui
-    def selected_summary_pdf():
-        return _selected_summary(analysis_result())
-
-    @render.ui
-    def selected_summary_hot():
-        return _selected_summary(analysis_result())
-
-    @render.ui
-    def selected_summary_features():
-        return _selected_summary(analysis_result())
-
-    @render_widget
-    def suv_pdf_plot():
-        result = analysis_result()
-        if not result.get("ok"):
-            return _empty_figure("Select a row and click 'Compute selected row' to show the SUV PDF.")
-        return result["pdf_fig"]
-
-    @render.data_frame
-    def suv_percentiles_table():
-        result = analysis_result()
-        if not result.get("ok"):
-            return pd.DataFrame()
-        return render.DataGrid(result["suv_percentiles"], height="250px")
-
-    @render.ui
-    def hot_voxel_tabs():
-        result = analysis_result()
-        if not result.get("ok"):
-            return ui.div(ui.em("Select a row and compute to show hot-voxel plots."))
-
-        probs = list(result["probs"])
-        max_plots = 5
-
-        if len(probs) > max_plots:
-            probs = probs[:max_plots]
-
-        tabs = []
-        for i, p in enumerate(probs):
-            tabs.append(
-                ui.nav_panel(
-                    f"p{p:g}",
-                    output_widget(f"hot_voxel_plot_{i}", height="760px"),
-                )
-            )
-
-        return ui.navset_tab(*tabs)
-
-    def _hot_voxel_figure_by_index(index: int):
-        result = analysis_result()
-        if not result.get("ok"):
-            return _empty_figure("No hot-voxel plot available.")
-
-        probs = list(result["probs"])
-        if index >= len(probs):
-            return _empty_figure("No percentile assigned to this plot.")
-
-        p = probs[index]
-        fig = result["hot_figs"].get(p)
-        if fig is None:
-            return _empty_figure(f"Percentile p{p:g} was not requested.")
-        return fig
-
-    @render_widget
-    def hot_voxel_plot_0():
-        return _hot_voxel_figure_by_index(0)
-
-    @render_widget
-    def hot_voxel_plot_1():
-        return _hot_voxel_figure_by_index(1)
-
-    @render_widget
-    def hot_voxel_plot_2():
-        return _hot_voxel_figure_by_index(2)
-
-    @render_widget
-    def hot_voxel_plot_3():
-        return _hot_voxel_figure_by_index(3)
-
-    @render_widget
-    def hot_voxel_plot_4():
-        return _hot_voxel_figure_by_index(4)
-
-    @render.data_frame
-    def features_table():
-        result = analysis_result()
-        if not result.get("ok"):
-            return pd.DataFrame()
-        return render.DataGrid(result["features"], height="420px")
-
-    @render.text
-    def diagnostics():
-        result = analysis_result()
-        if result.get("ok"):
-            return result.get("diagnostics", "OK")
-        return result.get("error", "Unknown error")
-
-
-app = App(app_ui, server)
-
-
-if __name__ == "__main__":
-    from shiny import run_app
-
-    run_app("app:app", host="127.0.0.1", port=8000, reload=True)

+ 1 - 0
app.py

@@ -0,0 +1 @@
+shiny_app/app.py

+ 495 - 0
shiny_app/app.py

@@ -0,0 +1,495 @@
+"""
+Interactive Shiny app for inspecting processed PET SUV NIfTI images.
+
+Run from project root:
+
+    shiny run --reload app.py
+
+Required packages:
+
+    pip install shiny shinywidgets shinyswatch plotly pandas nibabel scipy scikit-image
+
+Expected project structure:
+
+project_root/
+├── app.py
+├── data/
+│   ├── gen/
+│   └── raw/
+└── spatial_suv_charact/
+    ├── image_io.py
+    ├── metadata.py
+    ├── plotting.py
+    ├── spatial_features.py
+    └── suv_stats.py
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+import sys
+import traceback
+
+import pandas as pd
+import plotly.graph_objects as go
+
+from shiny import App, Inputs, Outputs, Session, reactive, render, ui
+from shinywidgets import output_widget, render_widget
+
+# -----------------------------------------------------------------------------
+# Project paths
+# -----------------------------------------------------------------------------
+
+APP_DIR = "."  # str(Path(__file__).resolve().parent)
+PROJECT_ROOT = APP_DIR
+
+if str(PROJECT_ROOT) not in sys.path:
+    sys.path.insert(0, str(PROJECT_ROOT))
+
+import os
+
+DATA_RAW = os.path.join(PROJECT_ROOT, "data", "raw")
+DATA_GEN = os.path.join(PROJECT_ROOT, "data", "gen")
+DEFAULT_METADATA_PATH = os.path.join(DATA_GEN, "metadata.pkl")
+
+# -----------------------------------------------------------------------------
+# Local package imports
+# -----------------------------------------------------------------------------
+
+from spatial_suv_charact.metadata import (  # noqa: E402
+    get_meta_data,
+    flag_corrupted_files,
+    flag_AE_patients,
+)
+from spatial_suv_charact.image_io import get_processed_image  # noqa: E402
+from spatial_suv_charact.plotting import (  # noqa: E402
+    plot_suv_pdf_plotly,
+    plot_hot_voxels_plotly,
+)
+from spatial_suv_charact.spatial_features import compute_tail_spatial_features  # noqa: E402
+
+
+# -----------------------------------------------------------------------------
+# Helper functions
+# -----------------------------------------------------------------------------
+
+
+def _empty_figure(message: str = "No plot available") -> go.Figure:
+    """Return an empty Plotly figure with a centered annotation."""
+    fig = go.Figure()
+    fig.add_annotation(
+        text=message,
+        xref="paper",
+        yref="paper",
+        x=0.5,
+        y=0.5,
+        showarrow=False,
+    )
+    fig.update_layout(
+        template="plotly_white",
+        xaxis={"visible": False},
+        yaxis={"visible": False},
+        height=500,
+    )
+    return fig
+
+
+def _load_metadata() -> pd.DataFrame:
+    """Load metadata table from disk or build it from DATA_RAW."""
+    if DEFAULT_METADATA_PATH.exists():
+        df_meta = pd.read_pickle(DEFAULT_METADATA_PATH)
+    else:
+        df_meta = get_meta_data(str(DATA_RAW))
+        df_meta = flag_corrupted_files(df_meta)
+        df_meta = flag_AE_patients(df_meta)
+
+        DATA_GEN.mkdir(parents=True, exist_ok=True)
+        df_meta.to_pickle(DEFAULT_METADATA_PATH)
+
+    if not isinstance(df_meta.index, pd.MultiIndex):
+        required_cols = {"patient_id", "organ", "visit"}
+        if required_cols.issubset(df_meta.columns):
+            df_meta = df_meta.set_index(["patient_id", "organ", "visit"])
+        else:
+            raise ValueError(
+                "Metadata table must have MultiIndex (patient_id, organ, visit) "
+                "or columns patient_id, organ, visit."
+            )
+
+    required_columns = {"PET_path", "SEG_path"}
+    missing = required_columns.difference(df_meta.columns)
+    if missing:
+        raise ValueError(f"Metadata table is missing required columns: {missing}")
+
+    return df_meta.sort_index()
+
+
+DF_META = _load_metadata()
+
+
+def _metadata_for_display(df_meta: pd.DataFrame) -> pd.DataFrame:
+    """Return metadata with index columns exposed and an internal row_id."""
+    df = df_meta.reset_index().copy()
+    df.insert(0, "row_id", range(len(df)))
+
+    preferred = [
+        "row_id",
+        "patient_id",
+        "organ",
+        "visit",
+        "is_AE_patient",
+        "is_corrupted",
+        "PET_filename",
+        "SEG_filename",
+        "PET_path",
+        "SEG_path",
+    ]
+    cols = [c for c in preferred if c in df.columns] + [
+        c for c in df.columns if c not in preferred
+    ]
+    return df[cols]
+
+
+DF_DISPLAY = _metadata_for_display(DF_META)
+
+
+def _format_image_id(row: pd.Series) -> str:
+    """Create a readable image identifier from a selected metadata row."""
+    return f"{row['patient_id']}_{row['organ']}_VISIT_{row['visit']}"
+
+
+def _parse_probs(prob_string: str) -> tuple[float, ...]:
+    """Parse comma-separated percentile values from UI input."""
+    values: list[float] = []
+    for part in prob_string.split(","):
+        part = part.strip()
+        if not part:
+            continue
+        values.append(float(part))
+
+    if not values:
+        raise ValueError("At least one percentile must be supplied.")
+
+    for value in values:
+        if not (0 < value < 100):
+            raise ValueError("Percentiles must be between 0 and 100.")
+
+    return tuple(values)
+
+
+def _safe_error_message(exc: BaseException) -> str:
+    """Return a readable error message for the diagnostics tab."""
+    return "\n".join(
+        [
+            f"{type(exc).__name__}: {exc}",
+            "",
+            traceback.format_exc(limit=8),
+        ]
+    )
+
+
+# -----------------------------------------------------------------------------
+# UI
+# -----------------------------------------------------------------------------
+
+app_ui = ui.page_fluid(
+    ui.h2("Spatial SUV tail-feature explorer"),
+    ui.layout_sidebar(
+        ui.sidebar(
+            ui.input_text(
+                "probs",
+                "Percentiles",
+                value="80, 90, 95",
+                placeholder="80, 90, 95",
+            ),
+            ui.input_numeric("bins", "Histogram bins", value=100, min=10, max=500),
+            ui.input_numeric("min_suv", "Minimum SUV for PDF", value=0.1, min=0),
+            ui.input_checkbox("log_x", "Log x-axis for SUV PDF", value=True),
+            ui.hr(),
+            ui.input_numeric(
+                "min_component_voxels",
+                "Minimum component voxels",
+                value=3,
+                min=1,
+                step=1,
+            ),
+            ui.input_select(
+                "component_connectivity",
+                "Component connectivity",
+                choices={"6": "6", "18": "18", "26": "26"},
+                selected="26",
+            ),
+            ui.input_select(
+                "contrast_connectivity",
+                "Contrast connectivity",
+                choices={"6": "6", "18": "18", "26": "26"},
+                selected="26",
+            ),
+            ui.input_checkbox("compute_spread", "Compute spread", value=True),
+            ui.input_checkbox("compute_local_contrast", "Compute local contrast", value=True),
+            ui.input_checkbox("compute_sphericity", "Compute sphericity", value=True),
+            ui.input_checkbox("crop_to_roi", "Crop to ROI", value=True),
+            ui.hr(),
+            ui.input_action_button("run", "Compute selected row", class_="btn-primary"),
+            width=330,
+        ),
+        ui.navset_tab(
+            ui.nav_panel(
+                "Metadata table",
+                ui.p("Select one row from the table, then click 'Compute selected row'."),
+                ui.output_data_frame("metadata_table"),
+            ),
+            ui.nav_panel(
+                "SUV PDF",
+                ui.output_ui("selected_summary_pdf"),
+                output_widget("suv_pdf_plot", height="560px"),
+                ui.h4("SUV percentiles"),
+                ui.output_data_frame("suv_percentiles_table"),
+            ),
+            ui.nav_panel(
+                "Hot voxels",
+                ui.output_ui("selected_summary_hot"),
+                ui.output_ui("hot_voxel_tabs"),
+            ),
+            ui.nav_panel(
+                "Spatial features",
+                ui.output_ui("selected_summary_features"),
+                ui.output_data_frame("features_table"),
+            ),
+            ui.nav_panel(
+                "Errors / diagnostics",
+                ui.output_text_verbatim("diagnostics"),
+            ),
+        ),
+    ),
+)
+
+
+# -----------------------------------------------------------------------------
+# Server
+# -----------------------------------------------------------------------------
+
+
+def server(input: Inputs, output: Outputs, session: Session):
+    @render.data_frame
+    def metadata_table():
+        return render.DataGrid(
+            DF_DISPLAY,
+            selection_mode="row",
+            filters=True,
+            height="650px",
+        )
+
+    @reactive.calc
+    def selected_row_display() -> pd.Series | None:
+        selected = metadata_table.cell_selection()["rows"]
+        if not selected:
+            return None
+        return DF_DISPLAY.iloc[int(selected[0])]
+
+    @reactive.calc
+    @reactive.event(input.run)
+    def analysis_result():
+        """Load selected image and compute all requested outputs once per click."""
+        row_display = selected_row_display()
+        if row_display is None:
+            return {
+                "ok": False,
+                "error": "No row selected. Select a row in the metadata table first.",
+            }
+
+        try:
+            probs = _parse_probs(input.probs())
+
+            patient_id = row_display["patient_id"]
+            organ = row_display["organ"]
+            visit = int(row_display["visit"])
+            index_key = (patient_id, organ, visit)
+
+            row_meta, processed_image = get_processed_image(
+                DF_META,
+                patient_id=patient_id,
+                organ=organ,
+                visit=visit,
+            )
+
+            image_id = _format_image_id(row_display)
+
+            pdf_fig, suv_percentiles = plot_suv_pdf_plotly(
+                processed_image,
+                percentiles=probs,
+                bins=int(input.bins()),
+                log_x=bool(input.log_x()),
+                min_suv=float(input.min_suv()),
+                title=f"SUV distribution: {image_id}",
+            )
+
+            hot_figs: dict[float, go.Figure] = {}
+            for p in probs:
+                threshold = float(
+                    suv_percentiles.loc[
+                        suv_percentiles["percentile"] == p,
+                        "suv_threshold",
+                    ].iloc[0]
+                )
+
+                hot_fig = plot_hot_voxels_plotly(
+                    processed_image,
+                    c=threshold,
+                )
+                hot_fig.update_layout(
+                    title=f"Hot voxels: {image_id}, p{p:g}, SUV ≥ {threshold:.4g}"
+                )
+                hot_figs[p] = hot_fig
+
+            features = compute_tail_spatial_features(
+                image=processed_image,
+                percentiles=probs,
+                component_connectivity=int(input.component_connectivity()),
+                contrast_connectivity=int(input.contrast_connectivity()),
+                min_component_voxels=int(input.min_component_voxels()),
+                compute_spread=bool(input.compute_spread()),
+                compute_local_contrast=bool(input.compute_local_contrast()),
+                compute_sphericity=bool(input.compute_sphericity()),
+                crop_to_roi=bool(input.crop_to_roi()),
+                image_id=index_key,
+            )
+
+            return {
+                "ok": True,
+                "row_display": row_display,
+                "row_meta": row_meta,
+                "image_id": image_id,
+                "probs": probs,
+                "pdf_fig": pdf_fig,
+                "suv_percentiles": suv_percentiles,
+                "hot_figs": hot_figs,
+                "features": features,
+                "diagnostics": f"Computed successfully for {image_id}",
+            }
+
+        except Exception as exc:
+            return {
+                "ok": False,
+                "row_display": row_display,
+                "error": _safe_error_message(exc),
+            }
+
+    def _selected_summary(result: dict) -> ui.TagList:
+        if not result.get("ok"):
+            return ui.TagList(ui.div(ui.strong("No successful computation yet.")))
+
+        row = result["row_display"]
+        return ui.TagList(
+            ui.div(
+                ui.strong("Selected image: "),
+                f"{row['patient_id']} | {row['organ']} | VISIT_{row['visit']}",
+            )
+        )
+
+    @render.ui
+    def selected_summary_pdf():
+        return _selected_summary(analysis_result())
+
+    @render.ui
+    def selected_summary_hot():
+        return _selected_summary(analysis_result())
+
+    @render.ui
+    def selected_summary_features():
+        return _selected_summary(analysis_result())
+
+    @render_widget
+    def suv_pdf_plot():
+        result = analysis_result()
+        if not result.get("ok"):
+            return _empty_figure("Select a row and click 'Compute selected row' to show the SUV PDF.")
+        return result["pdf_fig"]
+
+    @render.data_frame
+    def suv_percentiles_table():
+        result = analysis_result()
+        if not result.get("ok"):
+            return pd.DataFrame()
+        return render.DataGrid(result["suv_percentiles"], height="250px")
+
+    @render.ui
+    def hot_voxel_tabs():
+        result = analysis_result()
+        if not result.get("ok"):
+            return ui.div(ui.em("Select a row and compute to show hot-voxel plots."))
+
+        probs = list(result["probs"])
+        max_plots = 5
+
+        if len(probs) > max_plots:
+            probs = probs[:max_plots]
+
+        tabs = []
+        for i, p in enumerate(probs):
+            tabs.append(
+                ui.nav_panel(
+                    f"p{p:g}",
+                    output_widget(f"hot_voxel_plot_{i}", height="760px"),
+                )
+            )
+
+        return ui.navset_tab(*tabs)
+
+    def _hot_voxel_figure_by_index(index: int):
+        result = analysis_result()
+        if not result.get("ok"):
+            return _empty_figure("No hot-voxel plot available.")
+
+        probs = list(result["probs"])
+        if index >= len(probs):
+            return _empty_figure("No percentile assigned to this plot.")
+
+        p = probs[index]
+        fig = result["hot_figs"].get(p)
+        if fig is None:
+            return _empty_figure(f"Percentile p{p:g} was not requested.")
+        return fig
+
+    @render_widget
+    def hot_voxel_plot_0():
+        return _hot_voxel_figure_by_index(0)
+
+    @render_widget
+    def hot_voxel_plot_1():
+        return _hot_voxel_figure_by_index(1)
+
+    @render_widget
+    def hot_voxel_plot_2():
+        return _hot_voxel_figure_by_index(2)
+
+    @render_widget
+    def hot_voxel_plot_3():
+        return _hot_voxel_figure_by_index(3)
+
+    @render_widget
+    def hot_voxel_plot_4():
+        return _hot_voxel_figure_by_index(4)
+
+    @render.data_frame
+    def features_table():
+        result = analysis_result()
+        if not result.get("ok"):
+            return pd.DataFrame()
+        return render.DataGrid(result["features"], height="420px")
+
+    @render.text
+    def diagnostics():
+        result = analysis_result()
+        if result.get("ok"):
+            return result.get("diagnostics", "OK")
+        return result.get("error", "Unknown error")
+
+
+app = App(app_ui, server)
+
+
+if __name__ == "__main__":
+    from shiny import run_app
+
+    run_app("app:app", host="127.0.0.1", port=8000, reload=True)