Drug_Spoilage_Detector / src /visualization.py
OpelSpeedster's picture
Connect VLM bacteria estimate to growth curve (Option B+C)
dbf2ad0
Raw
History Blame Contribute Delete
17.9 kB
"""Plotly chart generators for spoilage visualization.
Four charts:
1. Chemical composition bar chart
2. Spoilage timeline (Gantt-style)
3. Bacteria growth gauge
4. Risk radar chart
"""
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from datetime import datetime, timedelta
from src.utils import parse_quantity
RISK_COLORS = {
"safe": "#44BB44",
"caution": "#FFAA00",
"danger": "#FF4444",
"unknown": "#888888",
}
CATEGORY_COLORS = {
"active_ingredient": "#3366CC",
"preservative": "#DC3912",
"solvent": "#FF9900",
"binder": "#109618",
"filler": "#990099",
"disintegrant": "#0099C6",
"lubricant": "#DD4477",
"colorant": "#66AA00",
"sweetener": "#B82E2E",
"flavoring": "#316395",
"acidifier": "#994499",
"buffer": "#22AA99",
"ph_adjuster": "#AAAA11",
"excipient": "#6633CC",
"other": "#888888",
}
def _empty_fig(text: str, height: int = 250) -> go.Figure:
"""Return a dark-themed empty figure with a centered message."""
fig = go.Figure()
fig.add_annotation(
text=text,
xref="paper", yref="paper",
x=0.5, y=0.5,
showarrow=False,
font=dict(size=16, color="#888888"),
)
fig.update_layout(
template="plotly_dark",
paper_bgcolor="#1a1a2e",
plot_bgcolor="#1a1a2e",
height=height,
)
return fig
def create_chemical_bar_chart(chemicals: list[dict]) -> go.Figure:
"""Horizontal bar chart showing chemical name, quantity, and risk level."""
if not chemicals:
fig = go.Figure()
fig.add_annotation(
text="No chemicals detected",
xref="paper", yref="paper",
x=0.5, y=0.5,
showarrow=False,
font=dict(size=16, color="#888888"),
)
fig.update_layout(
template="plotly_dark",
paper_bgcolor="#1a1a2e",
plot_bgcolor="#1a1a2e",
height=300,
)
return fig
names = [c.get("name", "Unknown") for c in chemicals]
quantities = [c.get("quantity") or "" for c in chemicals]
categories = [c.get("category", "other") for c in chemicals]
risk_levels = [c.get("risk_level", "unknown") for c in chemicals]
colors = [RISK_COLORS.get(r, "#888888") for r in risk_levels]
values = [parse_quantity(q) for q in quantities]
labels = []
for name, qty, cat, risk in zip(names, quantities, categories, risk_levels):
qty_str = f" | {qty}" if qty else ""
labels.append(f"{name}{qty_str} [{cat}]")
fig = go.Figure(go.Bar(
x=values,
y=names,
orientation="h",
marker_color=colors,
text=labels,
textposition="inside",
textfont=dict(color="white", size=11),
))
fig.update_layout(
title=dict(text="Chemical Composition (Name → Quantity)", font=dict(color="white", size=16)),
xaxis=dict(title="Quantity", tickfont=dict(color="white", size=10), gridcolor="#333333"),
yaxis=dict(autorange="reversed", tickfont=dict(color="white", size=11)),
template="plotly_dark",
paper_bgcolor="#1a1a2e",
plot_bgcolor="#16213e",
height=max(300, len(names) * 40 + 100),
margin=dict(l=10, r=10, t=50, b=40),
showlegend=False,
)
for risk, color in RISK_COLORS.items():
if risk in risk_levels:
fig.add_trace(go.Bar(
x=[None], y=[None],
marker_color=color,
name=risk.capitalize(),
showlegend=True,
))
return fig
def create_spoilage_timeline(
mfg_date: datetime | None,
exp_date: datetime | None,
predicted_spoilage: datetime | None,
today: datetime | None = None,
) -> go.Figure:
"""Gantt-style timeline with two modes:
Mode 1 (exp_date known): Static expiry is primary — shows shelf life + remaining days.
Mode 2 (no exp_date): Visual estimate is primary — shows predicted days from spoilage score.
"""
if today is None:
today = datetime.now()
fig = go.Figure()
if exp_date:
# Mode 1: Label has dates — show full shelf life timeline
days_left = (exp_date - today).days
expired = days_left < 0
# Shelf life bar (MFG → Expiry)
if mfg_date:
fig.add_trace(go.Bar(
x=[(exp_date - mfg_date).days], y=["Shelf Life"],
orientation="h",
base=mfg_date.strftime("%Y-%m-%d"),
marker_color="#2244AA",
text=f"{mfg_date.strftime('%b %Y')}{exp_date.strftime('%b %Y')}",
textposition="inside", textfont=dict(color="white"),
showlegend=False,
))
# Remaining / Overdue bar
color = "#FF4444" if expired else "#44BB44"
label = f"Expired {abs(days_left)}d ago" if expired else f"{days_left}d left"
base = exp_date if expired else today
fig.add_trace(go.Bar(
x=[abs(days_left)], y=["Remaining"],
orientation="h",
base=base.strftime("%Y-%m-%d"),
marker_color=color,
text=label,
textposition="inside", textfont=dict(color="white", size=12),
showlegend=False,
))
else:
# Mode 2: No label dates — visual estimate only
if predicted_spoilage:
est_days = (predicted_spoilage - today).days
color = "#FF4444" if est_days < 0 else "#FFAA00"
fig.add_trace(go.Bar(
x=[max(30, abs(est_days))], y=["Visual Estimate"],
orientation="h",
base=today.strftime("%Y-%m-%d"),
marker_color=color,
text=f"Visual only — ~{max(0, est_days)} days",
textposition="inside", textfont=dict(color="white", size=12),
showlegend=False,
))
else:
return _empty_fig("No date information available", height=200)
# Today marker
fig.add_vline(
x=today.strftime("%Y-%m-%d"),
line_dash="dash", line_color="white", line_width=2,
annotation_text="Today", annotation_font=dict(color="white"),
)
# Expiry marker (Mode 1 only)
if exp_date:
exp_color = "#FF4444" if (exp_date - today).days < 0 else "#FFAA00"
fig.add_vline(
x=exp_date.strftime("%Y-%m-%d"),
line_dash="dot", line_color=exp_color,
annotation_text=f"Expiry {exp_date.strftime('%b %Y')}",
annotation_font=dict(color="#FFAA00"),
)
fig.update_layout(
title=dict(text="Expiry Timeline", font=dict(color="white", size=16)),
xaxis=dict(type="date", tickfont=dict(color="white"), gridcolor="#333"),
yaxis=dict(tickfont=dict(color="white", size=12)),
template="plotly_dark",
paper_bgcolor="#1a1a2e", plot_bgcolor="#16213e",
height=200, barmode="overlay",
margin=dict(l=110, r=20, t=50, b=30),
)
return fig
def create_bacteria_gauge(growth_level: int) -> go.Figure:
"""Gauge chart showing bacteria growth level (0-100)."""
growth_level = max(0, min(100, growth_level))
if growth_level > 60:
bar_color = "#FF4444"
elif growth_level > 30:
bar_color = "#FFAA00"
else:
bar_color = "#44BB44"
fig = go.Figure(go.Indicator(
mode="gauge+number",
value=growth_level,
number=dict(suffix="/100", font=dict(color="white", size=28)),
title=dict(text="Bacteria Growth", font=dict(color="white", size=16)),
gauge=dict(
axis=dict(range=[0, 100], tickfont=dict(color="white")),
bar=dict(color=bar_color),
bgcolor="#16213e",
borderwidth=0,
steps=[
dict(range=[0, 30], color="#1a3a1a"),
dict(range=[30, 60], color="#3a3a1a"),
dict(range=[60, 100], color="#3a1a1a"),
],
threshold=dict(
line=dict(color="white", width=2),
thickness=0.75,
value=growth_level,
),
),
))
fig.update_layout(
template="plotly_dark",
paper_bgcolor="#1a1a2e",
height=250,
margin=dict(l=20, r=20, t=60, b=10),
)
return fig
def create_risk_radar(
visual_score: int,
bacteria_score: int,
date_score: int,
chemical_risk: float,
) -> go.Figure:
"""Radar/spider chart showing multi-axis risk assessment."""
categories = ["Visual", "Bacteria", "Date Proximity", "Chemical Composition"]
values = [
max(0, min(100, visual_score)),
max(0, min(100, bacteria_score)),
max(0, min(100, date_score)),
max(0, min(100, int(chemical_risk * 100))),
]
# Close the polygon
values_closed = values + [values[0]]
categories_closed = categories + [categories[0]]
fig = go.Figure(go.Scatterpolar(
r=values_closed,
theta=categories_closed,
fill="toself",
fillcolor="rgba(255, 68, 68, 0.2)",
line=dict(color="#FF4444", width=2),
marker=dict(size=8, color="#FF4444"),
))
# Add safe zone reference
safe_values = [30, 30, 30, 30] + [30]
fig.add_trace(go.Scatterpolar(
r=safe_values,
theta=categories_closed,
fill="toself",
fillcolor="rgba(68, 187, 68, 0.1)",
line=dict(color="#44BB44", width=1, dash="dash"),
marker=dict(size=0),
name="Safe Zone",
))
fig.update_layout(
title=dict(text="Risk Assessment", font=dict(color="white", size=16)),
polar=dict(
bgcolor="#16213e",
radialaxis=dict(
visible=True,
range=[0, 100],
tickfont=dict(color="white", size=9),
gridcolor="#333333",
),
angularaxis=dict(
tickfont=dict(color="white", size=11),
gridcolor="#333333",
),
),
template="plotly_dark",
paper_bgcolor="#1a1a2e",
height=350,
margin=dict(l=40, r=40, t=60, b=20),
showlegend=True,
legend=dict(
font=dict(color="white", size=10),
x=0.8,
y=-0.1,
),
)
return fig
def create_bacteria_growth_curve(
growth_curve: dict,
current_day: int,
critical_threshold_day: int,
) -> go.Figure:
"""Line chart showing bacteria growth over time with critical threshold.
Plots:
- Theoretical growth curve (Python logistic model, calibrated by VLM)
- VLM's visual estimate as a dot at current day
- Critical threshold line
"""
if not growth_curve or "growth_curve" not in growth_curve:
fig = go.Figure()
fig.add_annotation(
text="No growth curve data available",
xref="paper", yref="paper",
x=0.5, y=0.5,
showarrow=False,
font=dict(size=16, color="#888888"),
)
fig.update_layout(
template="plotly_dark",
paper_bgcolor="#1a1a2e",
plot_bgcolor="#1a1a2e",
height=300,
)
return fig
curve_data = growth_curve["growth_curve"]
days = sorted([int(k.split("_")[1]) for k in curve_data.keys()])
values = [curve_data[f"day_{d}"] for d in days]
fig = go.Figure()
# Theoretical growth curve
fig.add_trace(go.Scatter(
x=days,
y=values,
mode="lines+markers",
name="Theoretical Growth",
line=dict(color="#FF6B6B", width=3),
marker=dict(size=8, color="#FF6B6B"),
fill="tozeroy",
fillcolor="rgba(255, 107, 107, 0.2)",
))
# VLM's visual estimate as a dot at current day
vlm_level = growth_curve.get("vlm_bacteria_level", 0)
fig.add_trace(go.Scatter(
x=[current_day],
y=[vlm_level],
mode="markers",
name="VLM Visual Estimate",
marker=dict(size=14, color="#FFD700", symbol="diamond", line=dict(color="white", width=2)),
))
# Critical threshold line
fig.add_hline(
y=60,
line_dash="dash",
line_color="#FFAA00",
annotation_text="Critical Threshold",
annotation_font_color="#FFAA00",
)
# Current day marker
fig.add_vline(
x=current_day,
line_dash="dot",
line_color="#44BB44",
annotation_text=f"Day {current_day}",
annotation_font_color="#44BB44",
)
fig.update_layout(
title=dict(text="Bacteria Growth Over Time", font=dict(color="white", size=16)),
xaxis=dict(
title="Days Since Manufacturing",
tickfont=dict(color="white", size=10),
gridcolor="#333333",
),
yaxis=dict(
title="Growth Level (0-100)",
tickfont=dict(color="white", size=10),
gridcolor="#333333",
range=[0, 100],
),
template="plotly_dark",
paper_bgcolor="#1a1a2e",
plot_bgcolor="#16213e",
height=350,
margin=dict(l=50, r=20, t=60, b=50),
showlegend=True,
legend=dict(
font=dict(color="white", size=10),
x=0.7,
y=0.95,
),
)
return fig
def create_color_degradation_timeline(
color_analysis: dict,
shelf_life_days: int,
) -> go.Figure:
"""Line chart showing color deviation over time."""
if not color_analysis or "color_deviation" not in color_analysis:
fig = go.Figure()
fig.add_annotation(
text="No color analysis data available",
xref="paper", yref="paper",
x=0.5, y=0.5,
showarrow=False,
font=dict(size=16, color="#888888"),
)
fig.update_layout(
template="plotly_dark",
paper_bgcolor="#1a1a2e",
plot_bgcolor="#1a1a2e",
height=300,
)
return fig
current_deviation = color_analysis["color_deviation"]
estimated_days = color_analysis.get("estimated_days_since_optimal", 0)
# Simulate degradation curve (exponential decay)
import numpy as np
days = np.linspace(0, shelf_life_days, 50)
# Deviation increases over time, accelerating near end
deviations = current_deviation * (days / max(estimated_days, 1)) ** 1.5
deviations = np.clip(deviations, 0, 1)
fig = go.Figure()
# Color deviation curve
fig.add_trace(go.Scatter(
x=days,
y=deviations,
mode="lines",
name="Color Deviation",
line=dict(color="#FF9900", width=3),
fill="tozeroy",
fillcolor="rgba(255, 153, 0, 0.2)",
))
# Threshold lines
fig.add_hline(y=0.3, line_dash="dash", line_color="#44BB44", annotation_text="Minor")
fig.add_hline(y=0.6, line_dash="dash", line_color="#FFAA00", annotation_text="Moderate")
fig.add_hline(y=0.8, line_dash="dash", line_color="#FF4444", annotation_text="Severe")
fig.update_layout(
title=dict(text="Color Degradation Timeline", font=dict(color="white", size=16)),
xaxis=dict(
title="Days Since Manufacturing",
tickfont=dict(color="white", size=10),
gridcolor="#333333",
),
yaxis=dict(
title="Color Deviation (0-1)",
tickfont=dict(color="white", size=10),
gridcolor="#333333",
range=[0, 1],
),
template="plotly_dark",
paper_bgcolor="#1a1a2e",
plot_bgcolor="#16213e",
height=300,
margin=dict(l=50, r=20, t=60, b=50),
)
return fig
def create_dynamic_expiry_comparison(
static_expiry_days: int,
dynamic_expiry_days: int,
adjustment_factors: dict,
) -> go.Figure:
"""Bar chart comparing static vs dynamic expiry with adjustment factors."""
if static_expiry_days is None or dynamic_expiry_days is None:
fig = go.Figure()
fig.add_annotation(
text="Insufficient data for expiry comparison",
xref="paper", yref="paper",
x=0.5, y=0.5,
showarrow=False,
font=dict(size=16, color="#888888"),
)
fig.update_layout(
template="plotly_dark",
paper_bgcolor="#1a1a2e",
plot_bgcolor="#1a1a2e",
height=300,
)
return fig
fig = go.Figure()
# Main comparison bars
fig.add_trace(go.Bar(
x=["Static Expiry", "Dynamic Expiry"],
y=[static_expiry_days, dynamic_expiry_days],
marker_color=["#3366CC", "#FF6B6B"],
text=[f"{static_expiry_days} days", f"{dynamic_expiry_days} days"],
textposition="inside",
textfont=dict(color="white", size=14),
name="Shelf Life",
))
# Add adjustment factors as annotations
if adjustment_factors:
total_reduction = sum(
f.get("days_reduced", 0) for f in adjustment_factors.values()
)
fig.add_annotation(
x=0.5, y=-0.15,
xref="paper", yref="paper",
text=f"Total Reduction: {total_reduction} days",
showarrow=False,
font=dict(color="#FFAA00", size=12),
)
fig.update_layout(
title=dict(text="Static vs Dynamic Expiry", font=dict(color="white", size=16)),
xaxis=dict(tickfont=dict(color="white", size=12)),
yaxis=dict(
title="Days Until Expiry",
tickfont=dict(color="white", size=10),
gridcolor="#333333",
),
template="plotly_dark",
paper_bgcolor="#1a1a2e",
plot_bgcolor="#16213e",
height=300,
margin=dict(l=50, r=20, t=60, b=80),
showlegend=False,
)
return fig