Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions ocular-health-dashboard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Ocular Health Visualization Suite

This project extends the research narrative around the **ODIR-5K** eye disease dataset and the Schwann cell regeneration studies in the cornea. It demonstrates how curated biomedical tables can be transformed into actionable visuals and interactive dashboards.

## Project structure

```
ocular-health-dashboard/
├── analysis.py # Generates static plots (heatmaps, box/violin plots, clustering)
├── dashboard_app.py # Plotly Dash interface for real-time exploration
├── data/
│ └── ocular_health_sample.csv # Lightweight demo subset inspired by ODIR-5K
├── data_utils.py # Shared helpers for loading data and clustering
├── outputs/ # Saved figures after running analysis.py
└── requirements.txt # Optional dependency pinning
```

## How to use the analysis workflow

1. (Optional) create and activate a virtual environment.
2. Install dependencies:

```bash
pip install -r requirements.txt
```

3. Generate the static figures discussed in the manuscript:

```bash
python analysis.py
```

The following plots are saved into the `outputs/` directory:

* `correlation_heatmap.png` – correlation heatmap between demographics, Schwann metrics, and diagnoses.
* `age_by_disease_boxplot.png` – box/strip plot of patient ages per diagnosis.
* `schwann_density_violin.png` – violin plot of Schwann cell density vs. diagnosis.
* `age_vs_regeneration.png` – scatter plot of age vs. corneal nerve regeneration rate.
* `cluster_scatter.png` – PCA projection of k-means clusters for patient subgroup discovery.

## Interactive dashboard (Plotly Dash)

Launch the dashboard to explore the dataset live and reproduce the figures from the paper:

```bash
python dashboard_app.py
```

The app exposes:

* **Correlation heatmap** for demographics vs. disease burden.
* **Age filter & disease selector** to drive the age boxplot, Schwann cell violin plot, and age vs. regeneration scatter.
* **Cluster explorer** that highlights PCA components and the patient subgroups obtained from k-means.

Because the demo dataset is small, the dashboard starts instantly but the layout is ready to scale to the full ODIR-5K cohort.
107 changes: 107 additions & 0 deletions ocular-health-dashboard/analysis.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
"""Generate exploratory plots for the ocular health project."""
from __future__ import annotations

from pathlib import Path

import matplotlib.pyplot as plt
import pandas as pd
import seaborn as sns

from data_utils import (
DISEASE_COLUMNS,
correlation_columns,
load_ocular_data,
melt_disease_long,
run_clustering,
)

OUTPUT_DIR = Path(__file__).parent / "outputs"
OUTPUT_DIR.mkdir(exist_ok=True)

sns.set_theme(style="whitegrid")


def _save_current(fig: plt.Figure, filename: str) -> None:
path = OUTPUT_DIR / filename
fig.tight_layout()
fig.savefig(path, dpi=300)
plt.close(fig)
print(f"Saved {path}")


def create_correlation_heatmap(df: pd.DataFrame) -> None:
fig, ax = plt.subplots(figsize=(8, 6))
corr = df[correlation_columns()].corr()
sns.heatmap(corr, annot=True, cmap="coolwarm", center=0, ax=ax)
ax.set_title("Demographic & Disease Correlations")
_save_current(fig, "correlation_heatmap.png")


def create_age_boxplot(long_df: pd.DataFrame) -> None:
fig, ax = plt.subplots(figsize=(9, 5))
sns.boxplot(data=long_df, x="Disease", y="Age", ax=ax)
sns.stripplot(data=long_df, x="Disease", y="Age", color="black", size=3, alpha=0.6, ax=ax)
ax.set_title("Age distribution for each diagnosis")
_save_current(fig, "age_by_disease_boxplot.png")


def create_schwann_violin(long_df: pd.DataFrame) -> None:
fig, ax = plt.subplots(figsize=(9, 5))
sns.violinplot(
data=long_df,
x="Disease",
y="SchwannCellDensity",
ax=ax,
inner="quartile",
cut=0,
)
ax.set_ylabel("Schwann Cell Density (cells/mm)")
ax.set_title("Schwann cell distribution by diagnosis")
_save_current(fig, "schwann_density_violin.png")


def create_age_regeneration_scatter(df: pd.DataFrame) -> None:
fig, ax = plt.subplots(figsize=(7, 5))
sns.scatterplot(
data=df,
x="Age",
y="RegenerationRate",
hue="PrimaryDiagnosis",
style="Sex",
ax=ax,
)
ax.set_ylabel("Corneal nerve regeneration rate")
ax.set_title("Age vs. regeneration speed")
_save_current(fig, "age_vs_regeneration.png")


def create_cluster_plot(cluster_df: pd.DataFrame) -> None:
fig, ax = plt.subplots(figsize=(7, 5))
sns.scatterplot(
data=cluster_df,
x="PC1",
y="PC2",
hue="Cluster",
style="PrimaryDiagnosis",
palette="Set2",
s=80,
ax=ax,
)
ax.set_title("Patient subgroups (PCA + k-means)")
_save_current(fig, "cluster_scatter.png")


def main() -> None:
df = load_ocular_data()
long_df = melt_disease_long(df)
cluster_df, _pca, _model = run_clustering(df)

create_correlation_heatmap(df)
create_age_boxplot(long_df)
create_schwann_violin(long_df)
create_age_regeneration_scatter(df)
create_cluster_plot(cluster_df)


if __name__ == "__main__":
main()
144 changes: 144 additions & 0 deletions ocular-health-dashboard/dashboard_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
"""Plotly Dash application for real-time ocular health exploration."""
from __future__ import annotations

import dash
from dash import Dash, Input, Output, dcc, html
import pandas as pd
import plotly.express as px

from data_utils import DISEASE_COLUMNS, correlation_columns, load_ocular_data, melt_disease_long, run_clustering


def build_figures(df: pd.DataFrame) -> tuple:
corr = df[correlation_columns()].corr()
corr_fig = px.imshow(
corr,
text_auto=".2f",
color_continuous_scale="RdBu",
zmin=-1,
zmax=1,
aspect="auto",
title="Correlation matrix",
)

cluster_df, _pca, _model = run_clustering(df)
cluster_fig = px.scatter(
cluster_df,
x="PC1",
y="PC2",
color="Cluster",
symbol="Sex",
hover_data=["PatientID", "Age", "PrimaryDiagnosis"],
title="Patient subgroup structure",
)
return corr_fig, cluster_fig, cluster_df


def build_app() -> Dash:
df = load_ocular_data()
long_df = melt_disease_long(df)
corr_fig, cluster_fig, cluster_df = build_figures(df)

app = dash.Dash(__name__)
app.title = "Ocular Health Dashboard"

min_age = df["Age"].min()
max_age = df["Age"].max()

app.layout = html.Div(
[
html.H1("Schwann Cell & Ocular Disease Explorer"),
html.P(
"Interactively explore demographics, diagnoses, and Schwann cell metrics derived from the ODIR-5K study."
),
html.Div(
[
html.Label("Filter patients by age"),
dcc.RangeSlider(
id="age-range",
min=min_age,
max=max_age,
step=1,
allowCross=False,
value=[min_age, max_age],
marks={int(min_age): str(int(min_age)), int(max_age): str(int(max_age))},
),
],
className="control-panel",
),
html.Div(
[
html.Label("Highlight a diagnosis"),
dcc.Dropdown(
id="disease-select",
options=[{"label": d, "value": d} for d in DISEASE_COLUMNS],
value=DISEASE_COLUMNS[0],
clearable=False,
),
]
),
dcc.Graph(id="correlation-heatmap", figure=corr_fig),
html.Div(
[
dcc.Graph(id="age-boxplot"),
dcc.Graph(id="schwann-violin"),
],
className="split-row",
),
dcc.Graph(id="age-regeneration-scatter"),
dcc.Graph(id="cluster-graph", figure=cluster_fig),
],
className="container",
)

@app.callback(
Output("age-boxplot", "figure"),
Output("schwann-violin", "figure"),
Output("age-regeneration-scatter", "figure"),
Input("age-range", "value"),
Input("disease-select", "value"),
)
def update_patient_plots(age_range, disease): # type: ignore[override]
min_age, max_age = age_range
age_mask = (df["Age"] >= min_age) & (df["Age"] <= max_age)
filtered_df = df[age_mask]
filtered_long = long_df[(long_df["Age"] >= min_age) & (long_df["Age"] <= max_age)]

box_fig = px.box(
filtered_long,
x="Disease",
y="Age",
color="Disease",
title="Age distribution by diagnosis",
)

violin_fig = px.violin(
filtered_long[filtered_long["Disease"] == disease],
x="Disease",
y="SchwannCellDensity",
box=True,
Comment on lines +115 to +119

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Prevent callback errors when filters yield no rows

The Dash callback builds the violin plot from filtered_long[filtered_long['Disease'] == disease] without checking whether that slice contains data. If the age slider is narrowed to a range that has no patients with the selected disease (which is easy to hit with this 30‑row sample set), plotly.express.violin raises ValueError: DataFrame is empty, causing the callback to fail and the dashboard graphs to stop updating. Guard for empty frames or return a placeholder figure before calling Plotly.

Useful? React with 👍 / 👎.

points="all",
title=f"Schwann cell density • {disease}",
)

scatter_fig = px.scatter(
filtered_df,
x="Age",
y="RegenerationRate",
color="PrimaryDiagnosis",
symbol="Sex",
hover_data=["PatientID", "SchwannCellDensity"],
title="Age vs. corneal nerve regeneration",
)
return box_fig, violin_fig, scatter_fig

return app


def main() -> None:
app = build_app()
app.run_server(debug=True)


if __name__ == "__main__":
main()
31 changes: 31 additions & 0 deletions ocular-health-dashboard/data/ocular_health_sample.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
PatientID,Age,Sex,Diabetic Retinopathy,Glaucoma,Macular Degeneration,Cataract,SchwannCellDensity,RegenerationRate
P001,58,F,1,0,0,0,38.4,0.62
P002,67,M,0,1,0,1,34.1,0.55
P003,45,F,1,0,0,0,42.7,0.71
P004,72,M,0,1,1,1,30.9,0.48
P005,51,F,0,0,0,0,44.2,0.76
P006,63,M,0,0,1,1,33.5,0.59
P007,39,F,1,0,0,0,46.8,0.83
P008,55,M,0,0,1,0,37.2,0.66
P009,48,F,1,0,0,0,41.6,0.74
P010,70,M,0,1,0,1,31.8,0.52
P011,60,F,0,0,1,0,36.7,0.61
P012,42,M,1,0,0,0,45.5,0.79
P013,50,F,0,0,0,1,39.4,0.68
P014,76,M,0,1,1,1,29.8,0.44
P015,57,F,1,0,0,0,40.9,0.69
P016,64,M,0,1,0,1,32.5,0.57
P017,53,F,0,0,1,0,38.9,0.65
P018,47,M,1,0,0,0,43.7,0.77
P019,69,F,0,1,1,1,31.4,0.49
P020,44,M,0,0,0,0,45.1,0.81
P021,59,F,1,0,0,0,39.8,0.67
P022,61,M,0,1,0,1,33.2,0.56
P023,46,F,0,0,1,0,42.1,0.72
P024,52,M,1,0,0,0,41.2,0.70
P025,68,F,0,1,1,1,30.4,0.47
P026,43,M,0,0,0,0,46.1,0.82
P027,56,F,1,0,0,0,40.5,0.68
P028,62,M,0,1,0,1,32.8,0.54
P029,49,F,0,0,1,0,37.9,0.63
P030,65,M,0,1,1,1,31.1,0.51
Loading