|
@@ -23,6 +23,7 @@ if str(PROJECT_ROOT) not in sys.path:
|
|
|
DATA_RAW = PROJECT_ROOT / "data" / "raw"
|
|
DATA_RAW = PROJECT_ROOT / "data" / "raw"
|
|
|
DATA_GEN = PROJECT_ROOT / "data" / "gen"
|
|
DATA_GEN = PROJECT_ROOT / "data" / "gen"
|
|
|
DEFAULT_METADATA_PATH = DATA_GEN / "metadata.pkl"
|
|
DEFAULT_METADATA_PATH = DATA_GEN / "metadata.pkl"
|
|
|
|
|
+WWW_DIR = APP_DIR / "www"
|
|
|
|
|
|
|
|
# -----------------------------------------------------------------------------
|
|
# -----------------------------------------------------------------------------
|
|
|
# Local package imports
|
|
# Local package imports
|
|
@@ -67,7 +68,11 @@ def _empty_figure(message: str = "No plot available") -> go.Figure:
|
|
|
|
|
|
|
|
|
|
|
|
|
def _load_metadata() -> pd.DataFrame:
|
|
def _load_metadata() -> pd.DataFrame:
|
|
|
- """Load metadata table from disk or build it from DATA_RAW."""
|
|
|
|
|
|
|
+ """Load metadata table from disk or build it from DATA_RAW.
|
|
|
|
|
+
|
|
|
|
|
+ If data/gen/metadata.pkl does not exist, the metadata table is recreated
|
|
|
|
|
+ from data/raw and saved to data/gen/metadata.pkl.
|
|
|
|
|
+ """
|
|
|
if DEFAULT_METADATA_PATH.exists():
|
|
if DEFAULT_METADATA_PATH.exists():
|
|
|
df_meta = pd.read_pickle(DEFAULT_METADATA_PATH)
|
|
df_meta = pd.read_pickle(DEFAULT_METADATA_PATH)
|
|
|
else:
|
|
else:
|
|
@@ -100,10 +105,18 @@ DF_META = _load_metadata()
|
|
|
|
|
|
|
|
|
|
|
|
|
def _metadata_for_display(df_meta: pd.DataFrame) -> pd.DataFrame:
|
|
def _metadata_for_display(df_meta: pd.DataFrame) -> pd.DataFrame:
|
|
|
- """Return metadata with index columns exposed and an internal row_id."""
|
|
|
|
|
|
|
+ """Return metadata for UI display.
|
|
|
|
|
+
|
|
|
|
|
+ Path-like columns are hidden from the table, but they remain available in
|
|
|
|
|
+ DF_META for loading images.
|
|
|
|
|
+ """
|
|
|
df = df_meta.reset_index().copy()
|
|
df = df_meta.reset_index().copy()
|
|
|
df.insert(0, "row_id", range(len(df)))
|
|
df.insert(0, "row_id", range(len(df)))
|
|
|
|
|
|
|
|
|
|
+ # Hide all path-like columns from the UI table.
|
|
|
|
|
+ df = df.loc[:, ~df.columns.str.contains("path", case=False)]
|
|
|
|
|
+ df = df.loc[:, ~df.columns.str.contains("filename", case=False)]
|
|
|
|
|
+
|
|
|
preferred = [
|
|
preferred = [
|
|
|
"row_id",
|
|
"row_id",
|
|
|
"patient_id",
|
|
"patient_id",
|
|
@@ -113,8 +126,6 @@ def _metadata_for_display(df_meta: pd.DataFrame) -> pd.DataFrame:
|
|
|
"is_corrupted",
|
|
"is_corrupted",
|
|
|
"PET_filename",
|
|
"PET_filename",
|
|
|
"SEG_filename",
|
|
"SEG_filename",
|
|
|
- "PET_path",
|
|
|
|
|
- "SEG_path",
|
|
|
|
|
]
|
|
]
|
|
|
cols = [c for c in preferred if c in df.columns] + [
|
|
cols = [c for c in preferred if c in df.columns] + [
|
|
|
c for c in df.columns if c not in preferred
|
|
c for c in df.columns if c not in preferred
|
|
@@ -124,6 +135,9 @@ def _metadata_for_display(df_meta: pd.DataFrame) -> pd.DataFrame:
|
|
|
|
|
|
|
|
DF_DISPLAY = _metadata_for_display(DF_META)
|
|
DF_DISPLAY = _metadata_for_display(DF_META)
|
|
|
|
|
|
|
|
|
|
+ORGAN_CHOICES = ["All"] + sorted(DF_DISPLAY["organ"].dropna().astype(str).unique().tolist())
|
|
|
|
|
+VISIT_CHOICES = ["All"] + [str(v) for v in sorted(DF_DISPLAY["visit"].dropna().unique().tolist())]
|
|
|
|
|
+
|
|
|
|
|
|
|
|
def _format_image_id(row: pd.Series) -> str:
|
|
def _format_image_id(row: pd.Series) -> str:
|
|
|
"""Create a readable image identifier from a selected metadata row."""
|
|
"""Create a readable image identifier from a selected metadata row."""
|
|
@@ -166,11 +180,67 @@ def _safe_error_message(exc: BaseException) -> str:
|
|
|
|
|
|
|
|
app_ui = ui.page_fluid(
|
|
app_ui = ui.page_fluid(
|
|
|
ui.head_content(
|
|
ui.head_content(
|
|
|
- ui.tags.link(rel="shortcut icon", href="favicon.ico", type="image/x-icon")
|
|
|
|
|
|
|
+ ui.tags.link(
|
|
|
|
|
+ rel="shortcut icon",
|
|
|
|
|
+ href="/favicon.ico?v=3",
|
|
|
|
|
+ type="image/x-icon",
|
|
|
|
|
+ )
|
|
|
),
|
|
),
|
|
|
ui.h2("Spatial SUV tail-feature explorer"),
|
|
ui.h2("Spatial SUV tail-feature explorer"),
|
|
|
ui.layout_sidebar(
|
|
ui.layout_sidebar(
|
|
|
ui.sidebar(
|
|
ui.sidebar(
|
|
|
|
|
+ ui.div(
|
|
|
|
|
+ ui.h5("Run analysis"),
|
|
|
|
|
+ ui.p("Select one row in the metadata table, then compute."),
|
|
|
|
|
+ ui.input_action_button(
|
|
|
|
|
+ "run",
|
|
|
|
|
+ "Compute selected row",
|
|
|
|
|
+ class_="btn-success btn-lg",
|
|
|
|
|
+ width="100%",
|
|
|
|
|
+ ),
|
|
|
|
|
+ style="""
|
|
|
|
|
+ position: sticky;
|
|
|
|
|
+ top: 0;
|
|
|
|
|
+ z-index: 1000;
|
|
|
|
|
+ background: white;
|
|
|
|
|
+ padding: 1rem 0 1rem 0;
|
|
|
|
|
+ border-bottom: 1px solid #ddd;
|
|
|
|
|
+ margin-bottom: 1rem;
|
|
|
|
|
+ """,
|
|
|
|
|
+ ),
|
|
|
|
|
+ ui.h4("Metadata filters"),
|
|
|
|
|
+ ui.input_text(
|
|
|
|
|
+ "filter_patient",
|
|
|
|
|
+ "Patient contains",
|
|
|
|
|
+ value="",
|
|
|
|
|
+ placeholder="e.g. A001",
|
|
|
|
|
+ ),
|
|
|
|
|
+ ui.input_select(
|
|
|
|
|
+ "filter_organ",
|
|
|
|
|
+ "Organ",
|
|
|
|
|
+ choices=ORGAN_CHOICES,
|
|
|
|
|
+ selected="All",
|
|
|
|
|
+ ),
|
|
|
|
|
+ ui.input_select(
|
|
|
|
|
+ "filter_visit",
|
|
|
|
|
+ "Visit",
|
|
|
|
|
+ choices=VISIT_CHOICES,
|
|
|
|
|
+ selected="All",
|
|
|
|
|
+ ),
|
|
|
|
|
+ ui.input_select(
|
|
|
|
|
+ "filter_ae",
|
|
|
|
|
+ "AE patient",
|
|
|
|
|
+ choices=["All", "True", "False"],
|
|
|
|
|
+ selected="All",
|
|
|
|
|
+ ),
|
|
|
|
|
+ ui.input_select(
|
|
|
|
|
+ "filter_corrupted",
|
|
|
|
|
+ "Corrupted",
|
|
|
|
|
+ choices=["All", "True", "False"],
|
|
|
|
|
+ selected="All",
|
|
|
|
|
+ ),
|
|
|
|
|
+ ui.hr(),
|
|
|
|
|
+ ui.h4("Analysis settings"),
|
|
|
ui.input_text(
|
|
ui.input_text(
|
|
|
"probs",
|
|
"probs",
|
|
|
"Percentiles",
|
|
"Percentiles",
|
|
@@ -204,14 +274,13 @@ app_ui = ui.page_fluid(
|
|
|
ui.input_checkbox("compute_local_contrast", "Compute local contrast", 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("compute_sphericity", "Compute sphericity", value=True),
|
|
|
ui.input_checkbox("crop_to_roi", "Crop to ROI", 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,
|
|
|
|
|
|
|
+ width=340,
|
|
|
),
|
|
),
|
|
|
ui.navset_tab(
|
|
ui.navset_tab(
|
|
|
ui.nav_panel(
|
|
ui.nav_panel(
|
|
|
"Metadata table",
|
|
"Metadata table",
|
|
|
- ui.p("Select one row from the table, then click 'Compute selected row'."),
|
|
|
|
|
|
|
+ ui.p("Filter and select one row, then click 'Compute selected row'."),
|
|
|
|
|
+ ui.output_ui("filter_summary"),
|
|
|
ui.output_data_frame("metadata_table"),
|
|
ui.output_data_frame("metadata_table"),
|
|
|
),
|
|
),
|
|
|
ui.nav_panel(
|
|
ui.nav_panel(
|
|
@@ -246,12 +315,54 @@ app_ui = ui.page_fluid(
|
|
|
|
|
|
|
|
|
|
|
|
|
def server(input: Inputs, output: Outputs, session: Session):
|
|
def server(input: Inputs, output: Outputs, session: Session):
|
|
|
|
|
+ @reactive.calc
|
|
|
|
|
+ def filtered_metadata_display() -> pd.DataFrame:
|
|
|
|
|
+ """Apply sidebar filters to the displayed metadata table."""
|
|
|
|
|
+ df = DF_DISPLAY.copy()
|
|
|
|
|
+
|
|
|
|
|
+ patient_text = input.filter_patient().strip()
|
|
|
|
|
+ organ = input.filter_organ()
|
|
|
|
|
+ visit = input.filter_visit()
|
|
|
|
|
+ ae = input.filter_ae()
|
|
|
|
|
+ corrupted = input.filter_corrupted()
|
|
|
|
|
+
|
|
|
|
|
+ if patient_text:
|
|
|
|
|
+ df = df[
|
|
|
|
|
+ df["patient_id"]
|
|
|
|
|
+ .astype(str)
|
|
|
|
|
+ .str.contains(patient_text, case=False, na=False)
|
|
|
|
|
+ ]
|
|
|
|
|
+
|
|
|
|
|
+ if organ != "All":
|
|
|
|
|
+ df = df[df["organ"].astype(str) == organ]
|
|
|
|
|
+
|
|
|
|
|
+ if visit != "All":
|
|
|
|
|
+ df = df[df["visit"].astype(str) == visit]
|
|
|
|
|
+
|
|
|
|
|
+ if ae != "All" and "is_AE_patient" in df.columns:
|
|
|
|
|
+ df = df[df["is_AE_patient"].astype(bool) == (ae == "True")]
|
|
|
|
|
+
|
|
|
|
|
+ if corrupted != "All" and "is_corrupted" in df.columns:
|
|
|
|
|
+ df = df[df["is_corrupted"].astype(bool) == (corrupted == "True")]
|
|
|
|
|
+
|
|
|
|
|
+ return df.reset_index(drop=True)
|
|
|
|
|
+
|
|
|
|
|
+ @render.ui
|
|
|
|
|
+ def filter_summary():
|
|
|
|
|
+ n_shown = len(filtered_metadata_display())
|
|
|
|
|
+ n_total = len(DF_DISPLAY)
|
|
|
|
|
+ return ui.div(
|
|
|
|
|
+ ui.strong("Rows shown: "),
|
|
|
|
|
+ f"{n_shown} / {n_total}",
|
|
|
|
|
+ )
|
|
|
|
|
+
|
|
|
@render.data_frame
|
|
@render.data_frame
|
|
|
def metadata_table():
|
|
def metadata_table():
|
|
|
return render.DataGrid(
|
|
return render.DataGrid(
|
|
|
- DF_DISPLAY,
|
|
|
|
|
|
|
+ filtered_metadata_display(),
|
|
|
selection_mode="row",
|
|
selection_mode="row",
|
|
|
- filters=True,
|
|
|
|
|
|
|
+ filters=False,
|
|
|
|
|
+ width="100%",
|
|
|
height="650px",
|
|
height="650px",
|
|
|
)
|
|
)
|
|
|
|
|
|
|
@@ -260,7 +371,16 @@ def server(input: Inputs, output: Outputs, session: Session):
|
|
|
selected = metadata_table.cell_selection()["rows"]
|
|
selected = metadata_table.cell_selection()["rows"]
|
|
|
if not selected:
|
|
if not selected:
|
|
|
return None
|
|
return None
|
|
|
- return DF_DISPLAY.iloc[int(selected[0])]
|
|
|
|
|
|
|
+
|
|
|
|
|
+ df = filtered_metadata_display()
|
|
|
|
|
+ if df.empty:
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ row_pos = int(selected[0])
|
|
|
|
|
+ if row_pos >= len(df):
|
|
|
|
|
+ return None
|
|
|
|
|
+
|
|
|
|
|
+ return df.iloc[row_pos]
|
|
|
|
|
|
|
|
@reactive.calc
|
|
@reactive.calc
|
|
|
@reactive.event(input.run)
|
|
@reactive.event(input.run)
|
|
@@ -464,7 +584,7 @@ def server(input: Inputs, output: Outputs, session: Session):
|
|
|
app = App(
|
|
app = App(
|
|
|
app_ui,
|
|
app_ui,
|
|
|
server,
|
|
server,
|
|
|
- static_assets=Path(__file__).resolve().parent / "www",
|
|
|
|
|
|
|
+ static_assets=WWW_DIR,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
@@ -475,5 +595,3 @@ if __name__ == "__main__":
|
|
|
# shiny run --reload shiny_app/app.py
|
|
# shiny run --reload shiny_app/app.py
|
|
|
# Direct execution via `python shiny_app/app.py` works without auto-reload.
|
|
# Direct execution via `python shiny_app/app.py` works without auto-reload.
|
|
|
run_app(app, host="127.0.0.1", port=8000, reload=False)
|
|
run_app(app, host="127.0.0.1", port=8000, reload=False)
|
|
|
-
|
|
|
|
|
-
|
|
|