adffedccasfe's picture
uploading content
2da6f93 verified
Raw
History Blame Contribute Delete
32.7 kB
"""
Fluorescence Calibration & Prediction Tool
==========================================
Tab 1 β€” Calibration : upload images, type concentration next to each,
fit G/B and G/Gβ‚€ vs concentration, get equations.
Tab 2 β€” Predict : upload one unknown image, get predicted concentration
plotted on the calibration curve.
Tab 3 β€” Data & Export: full table, residuals, CSV download.
References
[1] Stern & Volmer, Physik. Z., 1919, 20, 183-188.
[2] arXiv:2603.27118, Eq. 2 (G/B ratio)
[3] Han et al., Molecules 2024, DOI: 10.3390/molecules29071658
Run: streamlit run app.py
"""
import io
import numpy as np
import pandas as pd
import streamlit as st
from PIL import Image, ImageDraw
from scipy import ndimage, stats
# ── Page config ───────────────────────────────────────────────────────────────
st.set_page_config(
page_title="Fluorescence Calibration Tool",
page_icon="πŸ”¬",
layout="wide",
)
# ── Constants ──────────────────────────────────────────────────────────────────
G0_DEFAULT = 135.0
G0_IMAGE_NAME = "fluorescence_0159.jpg"
# ── Session state init ────────────────────────────────────────────────────────
for key, val in {
"calibration_done": False,
"calib_gb": {"m": None, "b": None, "r2": None, "p": None},
"calib_gog0": {"m": None, "b": None, "r2": None, "p": None},
"calib_df": None,
"g0_used": G0_DEFAULT,
}.items():
if key not in st.session_state:
st.session_state[key] = val
# ══════════════════════════════════════════════════════════════════════════════
# CORE FUNCTIONS
# ══════════════════════════════════════════════════════════════════════════════
def detect_cuvette(arr, green_thresh_pct=0.40, padding=15, max_crop_frac=0.30):
h, w = arr.shape[:2]
green = arr[:, :, 1]
for pct in [green_thresh_pct,
green_thresh_pct + 0.10,
green_thresh_pct + 0.20,
green_thresh_pct + 0.30]:
pct = min(pct, 0.90)
mask = (green > green.max() * pct).astype(np.uint8)
if mask.sum() < 50:
continue
labeled, n = ndimage.label(mask)
if n == 0:
continue
sizes = ndimage.sum(mask, labeled, range(1, n + 1))
lbl = int(np.argmax(sizes)) + 1
ys, xs = np.where(labeled == lbl)
x1 = max(0, int(xs.min()) - padding)
y1 = max(0, int(ys.min()) - padding)
x2 = min(w - 1, int(xs.max()) + padding)
y2 = min(h - 1, int(ys.max()) + padding)
if (x2 - x1) <= w * max_crop_frac and (y2 - y1) <= h * max_crop_frac:
return (x1, y1, x2, y2), f"auto (G>{pct:.0%})"
box = (w // 4, h // 4, 3 * w // 4, 3 * h // 4)
return box, "center (fallback)"
def analyze_image(img: Image.Image, g0: float,
region_mode: str = "Auto-detect (green channel)",
green_thresh: float = 0.40) -> dict:
rgb = img.convert("RGB")
arr = np.array(rgb, dtype=np.float32)
h, w = arr.shape[:2]
if region_mode == "Auto-detect (green channel)":
box, crop_used = detect_cuvette(arr, green_thresh)
elif region_mode == "Center 50%":
box, crop_used = (w//4, h//4, 3*w//4, 3*h//4), "center"
else:
box, crop_used = (0, 0, w, h), "full"
x1, y1, x2, y2 = box
crop = arr[y1:y2, x1:x2]
g_arr = crop[:, :, 1]
b_arr = crop[:, :, 2]
r_arr = crop[:, :, 0]
g_m = float(np.mean(g_arr))
b_m = float(np.mean(b_arr))
r_m = float(np.mean(r_arr))
return dict(
G_mean = round(g_m, 2),
G0 = round(g0, 2),
G_over_G0 = round(g_m / g0, 4),
delta_G_G0 = round((g0 - g_m) / g0, 4),
Quench_pct = round((g0 - g_m) / g0 * 100, 2),
G_B_ratio = round(g_m / b_m, 4) if b_m > 0 else None,
G_median = round(float(np.median(g_arr)), 2),
G_std = round(float(np.std(g_arr)), 2),
HEX = "#{:02X}{:02X}{:02X}".format(int(r_m), int(g_m), int(b_m)),
Dominant = ["R","G","B"][int(np.argmax([r_m, g_m, b_m]))],
Brightness = round(0.299*r_m + 0.587*g_m + 0.114*b_m, 2),
Crop_used = crop_used,
Pixels = crop.shape[0] * crop.shape[1],
_box = box,
)
def linear_fit(x, y):
"""Return slope, intercept, RΒ², p-value."""
s, b, r, p, _ = stats.linregress(x, y)
return round(s, 6), round(b, 4), round(r**2, 4), round(p, 4)
def predict_concentration(metric_val, m, b):
"""Invert linear model: C = (y - b) / m"""
if m and m != 0:
return round((metric_val - b) / m, 2)
return None
# ── Drawing helpers ───────────────────────────────────────────────────────────
def draw_box(img, box, color=(255, 60, 60)):
out = img.convert("RGB").copy()
draw = ImageDraw.Draw(out)
lw = max(3, img.width // 300)
draw.rectangle(box, outline=color, width=lw)
return out
def resize_display(img, max_w=480):
if img.width > max_w:
r = max_w / img.width
img = img.resize((max_w, int(img.height * r)), Image.LANCZOS)
return img
def color_swatch(hex_code, size=60):
r = int(hex_code[1:3], 16)
g = int(hex_code[3:5], 16)
b = int(hex_code[5:7], 16)
img = Image.new("RGB", (size, size), (r, g, b))
draw = ImageDraw.Draw(img)
draw.rectangle([0, 0, size-1, size-1], outline=(100,100,100), width=2)
return img
def df_to_csv(df):
buf = io.StringIO()
df.to_csv(buf, index=False)
return buf.getvalue().encode()
# ── Sidebar ───────────────────────────────────────────────────────────────────
with st.sidebar:
st.title("πŸ”¬ Fluorescence Tool")
st.divider()
region_mode = st.radio(
"ROI detection",
["Auto-detect (green channel)", "Center 50%", "Full image"],
)
green_thresh = 0.40
if region_mode == "Auto-detect (green channel)":
green_thresh = st.slider("Green threshold", 0.20, 0.70, 0.40, 0.05)
st.divider()
g0_value = st.number_input(
"Gβ‚€ β€” blank reference",
min_value=1.0, max_value=255.0,
value=G0_DEFAULT, step=0.1,
help=f"From {G0_IMAGE_NAME} β€” carbon dots only, no analyte.",
)
st.caption(f"Default: {G0_DEFAULT} from `{G0_IMAGE_NAME}`")
st.divider()
if st.session_state.calibration_done:
st.success("βœ… Calibration ready")
gb = st.session_state.calib_gb
gg0 = st.session_state.calib_gog0
st.caption(
f"**G/B:** y = {gb['m']}x + {gb['b']}\n"
f"RΒ² = {gb['r2']} p = {gb['p']}\n\n"
f"**G/Gβ‚€:** y = {gg0['m']}x + {gg0['b']}\n"
f"RΒ² = {gg0['r2']} p = {gg0['p']}"
)
if st.button("πŸ—‘οΈ Clear calibration"):
st.session_state.calibration_done = False
st.session_state.calib_df = None
st.rerun()
else:
st.info("No calibration yet.\nGo to **Calibration** tab.")
st.divider()
st.caption(
"**References**\n"
"[1] Stern & Volmer 1919\n"
"[2] arXiv:2603.27118 Eq.2\n"
"[3] Han et al. Molecules 2024"
)
# ── Tabs ──────────────────────────────────────────────────────────────────────
tab1, tab2, tab3 = st.tabs([
"πŸ“Š Calibration",
"πŸ” Predict Unknown",
"πŸ“‹ Data & Export",
])
# ══════════════════════════════════════════════════════════════════════════════
# TAB 1 β€” CALIBRATION
# ══════════════════════════════════════════════════════════════════════════════
with tab1:
st.header("Calibration β€” Build your concentration curve")
st.markdown(
"Upload your **known concentration** images. "
"Type the concentration next to each image. "
"Click **Run Calibration** to fit the equations."
)
uploaded_cal = st.file_uploader(
"Drop calibration images here (JPG / PNG / BMP / TIFF)",
type=["jpg","jpeg","png","bmp","tiff","tif"],
accept_multiple_files=True,
key="cal_uploader",
)
if not uploaded_cal:
st.info("⬆️ Upload calibration images to get started.")
else:
st.subheader(f"Step 1 β€” Enter concentration for each image ({len(uploaded_cal)} uploaded)")
st.caption("Type polystyrene concentration in ppm next to each image thumbnail.")
conc_inputs = {}
# Show images in rows of 4 with concentration input below each
cols_per_row = 4
for row_start in range(0, len(uploaded_cal), cols_per_row):
batch = uploaded_cal[row_start : row_start + cols_per_row]
cols = st.columns(cols_per_row)
for col, f in zip(cols, batch):
with col:
img_thumb = Image.open(f).convert("RGB")
# Quick crop for thumbnail
arr_t = np.array(img_thumb, dtype=np.float32)
box_t, _ = detect_cuvette(arr_t, green_thresh)
x1,y1,x2,y2 = box_t
crop_t = img_thumb.crop((x1,y1,x2,y2))
# show thumbnail
st.image(resize_display(crop_t, 160),
caption=f.name[:20], use_container_width=True)
conc = st.number_input(
"Concentration (ppm)",
min_value=0.0, max_value=100000.0,
value=0.0, step=10.0,
key=f"conc_{f.name}",
label_visibility="collapsed",
)
st.caption(f"ppm: {conc:.0f}")
conc_inputs[f.name] = conc
st.divider()
# ── Run calibration button ─────────────────────────────────────────
if st.button("πŸš€ Run Calibration", type="primary", use_container_width=True):
with st.spinner("Analysing images and fitting calibration curves..."):
cal_rows = []
for f in uploaded_cal:
f.seek(0)
img = Image.open(f)
res = analyze_image(img, g0_value, region_mode, green_thresh)
box = res.pop("_box")
res["Filename"] = f.name
res["Concentration_ppm"] = conc_inputs[f.name]
res["_box"] = box
res["_img"] = img
cal_rows.append(res)
cal_df = pd.DataFrame(cal_rows)
# Only use rows with concentration > 0 for fitting (exclude pure blank)
fit_df = cal_df[cal_df["Concentration_ppm"] > 0].copy()
if len(fit_df) < 2:
st.error("Need at least 2 images with concentration > 0 to fit a calibration curve.")
else:
concs = fit_df["Concentration_ppm"].values
gb_vals = fit_df["G_B_ratio"].dropna().values
gg0_vals = fit_df["G_over_G0"].values
# Fit G/B
if len(gb_vals) == len(concs):
m_gb, b_gb, r2_gb, p_gb = linear_fit(concs, gb_vals)
else:
m_gb, b_gb, r2_gb, p_gb = None, None, None, None
# Fit G/Gβ‚€
m_gg0, b_gg0, r2_gg0, p_gg0 = linear_fit(concs, gg0_vals)
# Save to session state
st.session_state.calib_gb = {"m":m_gb, "b":b_gb, "r2":r2_gb, "p":p_gb}
st.session_state.calib_gog0 = {"m":m_gg0, "b":b_gg0, "r2":r2_gg0, "p":p_gg0}
st.session_state.calib_df = cal_df
st.session_state.calibration_done = True
st.session_state.g0_used = g0_value
st.success("βœ… Calibration complete!")
# ── Show calibration results if done ──────────────────────────────
if st.session_state.calibration_done and st.session_state.calib_df is not None:
cal_df = st.session_state.calib_df
gb = st.session_state.calib_gb
gg0 = st.session_state.calib_gog0
st.subheader("Step 2 β€” Calibration results")
# Equation cards
c1, c2 = st.columns(2)
with c1:
st.markdown("#### G/B Calibration")
if gb["m"]:
st.metric("RΒ²", gb["r2"])
st.metric("p-value", gb["p"])
st.code(f"G/B = {gb['m']} Γ— C + {gb['b']}", language=None)
st.code(f"C = (G/B βˆ’ {gb['b']}) / {gb['m']}", language=None)
sig = "βœ… Significant" if gb["p"] and gb["p"] < 0.05 else "⚠️ Not significant"
st.caption(sig)
with c2:
st.markdown("#### G/Gβ‚€ Calibration")
st.metric("RΒ²", gg0["r2"])
st.metric("p-value", gg0["p"])
st.code(f"G/Gβ‚€ = {gg0['m']} Γ— C + {gg0['b']}", language=None)
st.code(f"C = (G/Gβ‚€ βˆ’ {gg0['b']}) / {gg0['m']}", language=None)
sig2 = "βœ… Significant" if gg0["p"] < 0.05 else "⚠️ Not significant"
st.caption(sig2)
st.divider()
st.subheader("Step 3 β€” Calibration curves")
fit_df = cal_df[cal_df["Concentration_ppm"] > 0].copy()
concs = fit_df["Concentration_ppm"].values
chart_c1, chart_c2 = st.columns(2)
# ── G/B scatter chart ──────────────────────────────────────────
with chart_c1:
st.markdown(f"**G/B ratio vs Concentration** (RΒ²={gb['r2']}, p={gb['p']})")
if gb["m"]:
gb_vals = fit_df["G_B_ratio"].values
xfit = np.linspace(0, max(concs)*1.1, 200)
yfit = gb["m"]*xfit + gb["b"]
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(5.5, 3.8), facecolor='#0A1628')
ax.set_facecolor('#0A1628')
ax.plot(xfit, yfit, color='#BA7517', linestyle='--', lw=1.8)
ax.scatter(concs, gb_vals, color='#BA7517', s=80, zorder=4)
# annotate each point
for cx, cy in zip(concs, gb_vals):
ax.annotate(f"{cx:.0f}", (cx, cy),
textcoords="offset points", xytext=(4, 4),
fontsize=7, color='#F5C080')
ax.text(min(concs)*0.05 + max(concs)*0.05,
max(gb_vals)*0.98,
f"G/B = {gb['m']}Β·C + {gb['b']}\nRΒ²={gb['r2']} p={gb['p']}",
fontsize=8, color='#F5C080', style='italic', va='top')
ax.set_xlabel("Polystyrene concentration (ppm)", color='#7EC8C8', fontsize=9)
ax.set_ylabel("G/B ratio", color='#7EC8C8', fontsize=9)
ax.tick_params(colors='#7EC8C8', labelsize=8)
for sp in ax.spines.values(): sp.set_edgecolor('#1D4060')
ax.grid(color='#1D3050', lw=0.5, alpha=0.6)
plt.tight_layout(pad=0.4)
st.pyplot(fig, use_container_width=True)
plt.close(fig)
# ── G/Gβ‚€ scatter chart ─────────────────────────────────────────
with chart_c2:
st.markdown(f"**G/Gβ‚€ vs Concentration** (RΒ²={gg0['r2']}, p={gg0['p']})")
gg0_vals = fit_df["G_over_G0"].values
xfit = np.linspace(0, max(concs)*1.1, 200)
yfit_gg0 = gg0["m"]*xfit + gg0["b"]
fig2, ax2 = plt.subplots(figsize=(5.5, 3.8), facecolor='#0A1628')
ax2.set_facecolor('#0A1628')
ax2.plot(xfit, yfit_gg0, color='#1D9E75', linestyle='--', lw=1.8)
ax2.scatter(concs, gg0_vals, color='#1D9E75', s=80, zorder=4)
for cx, cy in zip(concs, gg0_vals):
ax2.annotate(f"{cx:.0f}", (cx, cy),
textcoords="offset points", xytext=(4, 4),
fontsize=7, color='#90E0B0')
ax2.text(min(concs)*0.05 + max(concs)*0.05,
max(gg0_vals)*0.98,
f"G/Gβ‚€ = {gg0['m']}Β·C + {gg0['b']}\nRΒ²={gg0['r2']} p={gg0['p']}",
fontsize=8, color='#90E0B0', style='italic', va='top')
ax2.set_xlabel("Polystyrene concentration (ppm)", color='#7EC8C8', fontsize=9)
ax2.set_ylabel("G / Gβ‚€", color='#7EC8C8', fontsize=9)
ax2.tick_params(colors='#7EC8C8', labelsize=8)
for sp in ax2.spines.values(): sp.set_edgecolor('#1D4060')
ax2.grid(color='#1D3050', lw=0.5, alpha=0.6)
plt.tight_layout(pad=0.4)
st.pyplot(fig2, use_container_width=True)
plt.close(fig2)
st.divider()
st.subheader("Calibration data table")
display_cols = ["Filename","Concentration_ppm","G_mean","G_B_ratio",
"G_over_G0","delta_G_G0","Quench_pct","Crop_used"]
st.dataframe(
cal_df[[c for c in display_cols if c in cal_df.columns]],
use_container_width=True,
height=min(400, 60 + 35*len(cal_df)),
)
# ══════════════════════════════════════════════════════════════════════════════
# TAB 2 β€” PREDICT UNKNOWN
# ══════════════════════════════════════════════════════════════════════════════
with tab2:
st.header("Predict Unknown Concentration")
if not st.session_state.calibration_done:
st.warning("⚠️ No calibration loaded. Go to the **Calibration** tab first and run the calibration.")
st.stop()
gb = st.session_state.calib_gb
gg0 = st.session_state.calib_gog0
g0 = st.session_state.g0_used
cdf = st.session_state.calib_df
st.info(
f"Using calibration: "
f"**G/B:** y = {gb['m']}x + {gb['b']} (RΒ²={gb['r2']}) | "
f"**G/Gβ‚€:** y = {gg0['m']}x + {gg0['b']} (RΒ²={gg0['r2']})"
)
unk_file = st.file_uploader(
"Upload unknown sample image",
type=["jpg","jpeg","png","bmp","tiff","tif"],
key="unknown_uploader",
)
if not unk_file:
st.info("⬆️ Upload the unknown sample image.")
else:
unk_img = Image.open(unk_file)
unk_res = analyze_image(unk_img, g0, region_mode, green_thresh)
unk_box = unk_res.pop("_box")
unk_gb_val = unk_res["G_B_ratio"]
unk_gg0_val = unk_res["G_over_G0"]
# Predict concentration
pred_from_gb = predict_concentration(unk_gb_val, gb["m"], gb["b"])
pred_from_gg0 = predict_concentration(unk_gg0_val, gg0["m"], gg0["b"])
# ── Result cards ──────────────────────────────────────────────────
st.subheader("Prediction result")
rc1, rc2, rc3, rc4 = st.columns(4)
with rc1:
st.metric("Measured G/B", f"{unk_gb_val:.4f}")
with rc2:
st.metric("Predicted (G/B model)",
f"{pred_from_gb} ppm" if pred_from_gb else "β€”")
with rc3:
st.metric("Measured G/Gβ‚€", f"{unk_gg0_val:.4f}")
with rc4:
st.metric("Predicted (G/Gβ‚€ model)",
f"{pred_from_gg0} ppm" if pred_from_gg0 else "β€”")
st.divider()
# ── Image + crop ──────────────────────────────────────────────────
img_col, chart_col = st.columns([1.4, 2])
with img_col:
disp = draw_box(unk_img.convert("RGB"), unk_box)
st.image(resize_display(disp, 440),
caption=f"Unknown: {unk_file.name}", use_container_width=True)
x1,y1,x2,y2 = unk_box
crop_img = unk_img.convert("RGB").crop((x1,y1,x2,y2))
scale = min(200/max(crop_img.width,1), 200/max(crop_img.height,1), 1.0)
if scale < 1:
crop_img = crop_img.resize(
(int(crop_img.width*scale), int(crop_img.height*scale)), Image.LANCZOS)
st.image(crop_img, caption=f"ROI | G/B={unk_gb_val} G/Gβ‚€={unk_gg0_val}", width=200)
st.markdown("**Colour swatch**")
st.image(color_swatch(unk_res["HEX"], 60), width=60)
st.code(unk_res["HEX"], language=None)
# ── Calibration plot with unknown overlaid ────────────────────────
with chart_col:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
fit_df = cdf[cdf["Concentration_ppm"] > 0].copy()
cal_concs = fit_df["Concentration_ppm"].values
xmax = max(max(cal_concs)*1.15,
(pred_from_gb or 0)*1.15,
(pred_from_gg0 or 0)*1.15, 50)
xfit = np.linspace(0, xmax, 300)
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(10, 4.2), facecolor='#0A1628')
for ax, vals_col, model, color, label_y, pred_val, title in [
(ax1, "G_B_ratio", gb, '#BA7517', 'G/B ratio', pred_from_gb, 'G/B'),
(ax2, "G_over_G0", gg0, '#1D9E75', 'G / Gβ‚€', pred_from_gg0, 'G/Gβ‚€'),
]:
ax.set_facecolor('#0A1628')
cal_y = fit_df[vals_col].values
if model["m"]:
yfit = model["m"]*xfit + model["b"]
ax.plot(xfit, yfit, color=color, linestyle='--', lw=1.8, zorder=1)
ax.scatter(cal_concs, cal_y, color=color, s=80, zorder=4,
label='Calibration points')
# Unknown point
unk_y_val = unk_gb_val if vals_col == "G_B_ratio" else unk_gg0_val
if pred_val is not None and model["m"]:
# drop lines
ax.plot([0, pred_val], [unk_y_val, unk_y_val],
color='#4DA6FF', linestyle=':', lw=1.5, zorder=2)
ax.plot([pred_val, pred_val], [min(cal_y)*0.98, unk_y_val],
color='#4DA6FF', linestyle=':', lw=1.5, zorder=2)
ax.scatter([pred_val], [unk_y_val], color='#4DA6FF',
s=140, marker='*', zorder=5,
label=f'Unknown β†’ {pred_val} ppm')
ax.annotate(f" {pred_val} ppm",
(pred_val, min(cal_y)*0.98),
fontsize=8, color='#4DA6FF')
ax.set_xlabel("Polystyrene concentration (ppm)", color='#7EC8C8', fontsize=9)
ax.set_ylabel(label_y, color='#7EC8C8', fontsize=9)
ax.set_title(f"{title} calibration curve", color='white', fontsize=10)
ax.tick_params(colors='#7EC8C8', labelsize=8)
for sp in ax.spines.values(): sp.set_edgecolor('#1D4060')
ax.grid(color='#1D3050', lw=0.5, alpha=0.6)
ax.legend(fontsize=7.5, framealpha=0.2,
facecolor='#152840', edgecolor='#1D4060',
labelcolor='#B0CCE0')
plt.tight_layout(pad=0.5)
st.pyplot(fig, use_container_width=True)
plt.close(fig)
# ── Step-by-step calculation ───────────────────────────────────────
st.divider()
st.subheader("Step-by-step calculation")
calc1, calc2 = st.columns(2)
with calc1:
st.markdown("**G/B model**")
if gb["m"]:
st.code(
f"Calibration: G/B = {gb['m']} Γ— C + {gb['b']}\n"
f"Invert: C = (G/B βˆ’ {gb['b']}) / {gb['m']}\n\n"
f"Measured G/B = {unk_gb_val}\n"
f"C = ({unk_gb_val} βˆ’ {gb['b']}) / {gb['m']}\n"
f"C = {round(unk_gb_val - gb['b'], 6)} / {gb['m']}\n"
f"C = {pred_from_gb} ppm",
language=None,
)
with calc2:
st.markdown("**G/Gβ‚€ model**")
st.code(
f"Calibration: G/Gβ‚€ = {gg0['m']} Γ— C + {gg0['b']}\n"
f"Invert: C = (G/Gβ‚€ βˆ’ {gg0['b']}) / {gg0['m']}\n\n"
f"Measured G/Gβ‚€ = {unk_gg0_val}\n"
f"C = ({unk_gg0_val} βˆ’ {gg0['b']}) / {gg0['m']}\n"
f"C = {round(unk_gg0_val - gg0['b'], 6)} / {gg0['m']}\n"
f"C = {pred_from_gg0} ppm",
language=None,
)
# ══════════════════════════════════════════════════════════════════════════════
# TAB 3 β€” DATA & EXPORT
# ══════════════════════════════════════════════════════════════════════════════
with tab3:
st.header("Data & Export")
if not st.session_state.calibration_done or st.session_state.calib_df is None:
st.info("Run a calibration first (Tab 1) to see data here.")
else:
cal_df = st.session_state.calib_df
gb = st.session_state.calib_gb
gg0 = st.session_state.calib_gog0
# ── Summary equations ──────────────────────────────────────────────
st.subheader("Calibration equations")
eq1, eq2 = st.columns(2)
with eq1:
st.markdown("**G/B model**")
if gb["m"]:
st.latex(
rf"\frac{{G}}{{B}} = {gb['m']} \times C + {gb['b']}"
)
st.latex(
rf"C = \frac{{G/B - {gb['b']}}}{{{gb['m']}}}"
)
st.markdown(f"RΒ² = **{gb['r2']}** | p = **{gb['p']}**")
with eq2:
st.markdown("**G/Gβ‚€ model**")
st.latex(
rf"\frac{{G}}{{G_0}} = {gg0['m']} \times C + {gg0['b']}"
)
st.latex(
rf"C = \frac{{G/G_0 - {gg0['b']}}}{{{gg0['m']}}}"
)
st.markdown(f"RΒ² = **{gg0['r2']}** | p = **{gg0['p']}**")
st.divider()
# ── Full data table ────────────────────────────────────────────────
st.subheader("Full calibration dataset")
export_cols = ["Filename","Concentration_ppm","G_mean","G0",
"G_B_ratio","G_over_G0","delta_G_G0","Quench_pct",
"G_median","G_std","HEX","Dominant","Brightness","Crop_used","Pixels"]
export_df = cal_df[[c for c in export_cols if c in cal_df.columns]].copy()
st.dataframe(export_df, use_container_width=True,
height=min(500, 60 + 35*len(export_df)))
# ── Residuals table ────────────────────────────────────────────────
st.divider()
st.subheader("Residuals table")
fit_df = cal_df[cal_df["Concentration_ppm"] > 0].copy()
if gb["m"]:
fit_df["GB_predicted"] = gb["m"] * fit_df["Concentration_ppm"] + gb["b"]
fit_df["GB_residual"] = fit_df["G_B_ratio"] - fit_df["GB_predicted"]
fit_df["GG0_predicted"] = gg0["m"] * fit_df["Concentration_ppm"] + gg0["b"]
fit_df["GG0_residual"] = fit_df["G_over_G0"] - fit_df["GG0_predicted"]
res_cols = ["Filename","Concentration_ppm",
"G_B_ratio","GB_predicted","GB_residual",
"G_over_G0","GG0_predicted","GG0_residual"]
st.dataframe(
fit_df[[c for c in res_cols if c in fit_df.columns]].round(4),
use_container_width=True,
)
# ── Downloads ─────────────────────────────────────────────────────
st.divider()
d1, d2 = st.columns(2)
with d1:
st.download_button(
"⬇️ Download calibration CSV",
data=df_to_csv(export_df),
file_name="calibration_data.csv",
mime="text/csv",
use_container_width=True,
)
with d2:
# Summary CSV
summary = pd.DataFrame([{
"Model": "G/B",
"Slope_m": gb["m"],
"Intercept_b":gb["b"],
"R2": gb["r2"],
"p_value": gb["p"],
"Equation": f"G/B = {gb['m']}*C + {gb['b']}",
"Invert": f"C = (G/B - {gb['b']}) / {gb['m']}",
"G0": st.session_state.g0_used,
}, {
"Model": "G/G0",
"Slope_m": gg0["m"],
"Intercept_b":gg0["b"],
"R2": gg0["r2"],
"p_value": gg0["p"],
"Equation": f"G/G0 = {gg0['m']}*C + {gg0['b']}",
"Invert": f"C = (G/G0 - {gg0['b']}) / {gg0['m']}",
"G0": st.session_state.g0_used,
}])
st.download_button(
"⬇️ Download equations summary",
data=df_to_csv(summary),
file_name="calibration_equations.csv",
mime="text/csv",
use_container_width=True,
)
st.divider()
st.caption(
"**References:** "
"[1] Stern & Volmer, *Physik. Z.*, 1919, 20, 183 β€” G/Gβ‚€ quenching formula | "
"[2] *arXiv:2603.27118*, Eq. 2 β€” G/B ratiometric formula | "
"[3] Han et al., *Molecules* 2024, DOI: 10.3390/molecules29071658"
)