| """ |
| 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 |
|
|
| |
| st.set_page_config( |
| page_title="Fluorescence Calibration Tool", |
| page_icon="π¬", |
| layout="wide", |
| ) |
|
|
| |
| G0_DEFAULT = 135.0 |
| G0_IMAGE_NAME = "fluorescence_0159.jpg" |
|
|
| |
| 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 |
|
|
|
|
| |
| |
| |
|
|
| 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 |
|
|
|
|
| |
|
|
| 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() |
|
|
|
|
| |
| 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" |
| ) |
|
|
|
|
| |
| tab1, tab2, tab3 = st.tabs([ |
| "π Calibration", |
| "π Predict Unknown", |
| "π Data & Export", |
| ]) |
|
|
|
|
| |
| |
| |
| 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 = {} |
|
|
| |
| 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") |
| |
| 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)) |
| |
| 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() |
|
|
| |
| 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) |
|
|
| |
| 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 |
|
|
| |
| 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 |
|
|
| |
| m_gg0, b_gg0, r2_gg0, p_gg0 = linear_fit(concs, gg0_vals) |
|
|
| |
| 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!") |
|
|
| |
| 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") |
|
|
| |
| 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) |
|
|
| |
| 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) |
| |
| 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) |
|
|
| |
| 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)), |
| ) |
|
|
|
|
| |
| |
| |
| 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"] |
|
|
| |
| pred_from_gb = predict_concentration(unk_gb_val, gb["m"], gb["b"]) |
| pred_from_gg0 = predict_concentration(unk_gg0_val, gg0["m"], gg0["b"]) |
|
|
| |
| 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() |
|
|
| |
| 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) |
|
|
| |
| 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') |
|
|
| |
| 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"]: |
| |
| 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) |
|
|
| |
| 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, |
| ) |
|
|
|
|
| |
| |
| |
| 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 |
|
|
| |
| 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() |
|
|
| |
| 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))) |
|
|
| |
| 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, |
| ) |
|
|
| |
| 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 = 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" |
| ) |