Spaces:
Sleeping
Sleeping
| import gradio as gr | |
| import numpy as np | |
| import cv2, math, io, os | |
| import pandas as pd | |
| import matplotlib.pyplot as plt | |
| from skimage import measure, morphology, segmentation, exposure, feature | |
| from scipy import ndimage as ndi | |
| from scipy.stats import gaussian_kde | |
| from PIL import Image | |
| # ================= Core utilities ================= | |
| def auto_detect_scale_bar_um_per_px(img_gray, assumed_bar_um=100.0): | |
| h, w = img_gray.shape | |
| band = img_gray[int(h*0.86):, :] | |
| thr = (band > 240).astype(np.uint8) | |
| kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (11,3)) | |
| thr_closed = cv2.morphologyEx(thr, cv2.MORPH_CLOSE, kernel, iterations=2) | |
| num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(thr_closed, connectivity=8) | |
| if num_labels > 1: | |
| areas = stats[1:, cv2.CC_STAT_AREA] | |
| idx = np.argmax(areas) + 1 | |
| x, y, bw, bh, area = stats[idx] | |
| if bw >= 30: | |
| return assumed_bar_um / float(bw) | |
| row = band.shape[0]//2 | |
| line = band[row] | |
| bright = np.where(line > 240)[0] | |
| if bright.size > 0: | |
| bw = bright.max() - bright.min() | |
| if bw > 0: | |
| return assumed_bar_um / float(bw) | |
| return None | |
| def segment_particles(img_gray): | |
| h, w = img_gray.shape | |
| roi_h = int(h*0.86) | |
| roi = img_gray[:roi_h, :].copy() | |
| roi_eq = exposure.equalize_adapthist(roi, clip_limit=0.01) | |
| roi_u8 = (roi_eq*255).astype(np.uint8) | |
| _, th = cv2.threshold(roi_u8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) | |
| bw = th.astype(bool) | |
| bw = morphology.remove_small_objects(bw, min_size=80) | |
| bw = morphology.binary_closing(bw, morphology.disk(2)) | |
| distance = ndi.distance_transform_edt(bw) | |
| local_maxi = feature.peak_local_max(distance, footprint=np.ones((15,15)), labels=bw) | |
| markers = np.zeros_like(distance, dtype=np.int32) | |
| for i,(yy,xx) in enumerate(local_maxi, start=1): | |
| markers[yy,xx] = i | |
| markers = ndi.label(markers)[0] | |
| labels_ws = segmentation.watershed(-distance, markers, mask=bw) | |
| labels_ws = segmentation.clear_border(labels_ws) | |
| return labels_ws, roi_h | |
| def compute_metrics(labels_ws, um_per_px, min_area_um2=50.0, min_ecd_um=5.0): | |
| props = measure.regionprops(labels_ws) | |
| recs = [] | |
| for p in props: | |
| area_px = p.area | |
| perim_px = p.perimeter if p.perimeter>0 else np.nan | |
| major = p.major_axis_length | |
| minor = p.minor_axis_length if p.minor_axis_length>0 else np.nan | |
| convex_area_px = getattr(p, 'convex_area', np.nan) | |
| area_um2 = area_px * (um_per_px**2) | |
| if area_um2 < min_area_um2: | |
| continue | |
| perim_um = perim_px * um_per_px | |
| major_um = major * um_per_px | |
| minor_um = minor * um_per_px | |
| ecd_um = math.sqrt(4*area_um2/math.pi) | |
| if ecd_um < min_ecd_um: | |
| continue | |
| circ_iso = (4*math.pi*area_px)/(perim_px**2) if perim_px>0 else np.nan | |
| circ_astm = (perim_px**2)/(4*math.pi*area_px) if area_px>0 else np.nan | |
| roundness_astm = (4*area_px)/(math.pi*(major**2)) if major>0 else np.nan | |
| ar = (major/minor) if minor>0 else np.nan | |
| solidity = (area_px/convex_area_px) if (convex_area_px and convex_area_px>0) else np.nan | |
| recs.append(dict( | |
| label=int(p.label), | |
| area_um2=float(area_um2), | |
| perimeter_um=float(perim_um), | |
| ECD_um=float(ecd_um), | |
| major_um=float(major_um), | |
| minor_um=float(minor_um), | |
| aspect_ratio=float(ar), | |
| circularity_ISO=float(circ_iso), | |
| circularity_ASTM=float(circ_astm), | |
| roundness_ASTM=float(roundness_astm), | |
| solidity=float(solidity), | |
| centroid_y_px=float(p.centroid[0]), | |
| centroid_x_px=float(p.centroid[1]) | |
| )) | |
| return pd.DataFrame.from_records(recs) | |
| def _paste_legend_safe(img_bgr, legend, x=10, y=10, pad=10): | |
| H, W = legend.shape[:2] | |
| ih, iw = img_bgr.shape[:2] | |
| max_w = max(1, iw - x - pad) | |
| max_h = max(1, ih - y - pad) | |
| if W > max_w or H > max_h: | |
| sx = max_w / float(W) | |
| sy = max_h / float(H) | |
| s = min(sx, sy, 1.0) | |
| newW = max(1, int(W * s)) | |
| newH = max(1, int(H * s)) | |
| legend = cv2.resize(legend, (newW, newH), interpolation=cv2.INTER_AREA) | |
| img_bgr[y:y+legend.shape[0], x:x+legend.shape[1]] = legend | |
| return img_bgr | |
| def overlay_failures(img_gray, labels_ws, df, use_iso, circ_thr, ar_low, ar_high, roi_h): | |
| img_bgr = cv2.cvtColor(img_gray, cv2.COLOR_GRAY2BGR) | |
| props = measure.regionprops(labels_ws) | |
| metrics = df.set_index("label") if "label" in df.columns else df.set_index(df.index) | |
| for p in props: | |
| if "label" in df.columns and p.label not in metrics.index: | |
| continue | |
| m = metrics.loc[p.label] | |
| circ = m["circularity_ISO"] if use_iso else m["circularity_ASTM"] | |
| ar = m["aspect_ratio"] | |
| fail_circ = (circ < circ_thr) if use_iso else (circ > circ_thr) | |
| fail_ar = not (ar_low <= ar <= ar_high) | |
| if fail_circ or fail_ar: | |
| minr, minc, maxr, maxc = p.bbox | |
| color = (0,0,255) if (fail_circ and fail_ar) else ((0,165,255) if fail_circ else (180,0,180)) | |
| pad = 6 | |
| y1, x1 = max(minr-pad,0), max(minc-pad,0) | |
| y2, x2 = min(maxr+pad, roi_h-1), min(maxc+pad, img_gray.shape[1]-1) | |
| cv2.rectangle(img_bgr, (x1, y1), (x2, y2), color, 2) | |
| label = "C & AR" if (fail_circ and fail_ar) else ("C" if fail_circ else "AR") | |
| cy, cx = int(p.centroid[0]), int(p.centroid[1]) | |
| cv2.putText(img_bgr, label, (cx-20, cy-8), cv2.FONT_HERSHEY_SIMPLEX, 0.45, color, 1, cv2.LINE_AA) | |
| legend = np.ones((110, 380, 3), dtype=np.uint8)*255 | |
| cv2.putText(legend, "Fail legend", (10,22), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0,0,0), 1, cv2.LINE_AA) | |
| cv2.rectangle(legend, (10,40), (40,60), (0,0,255), 2); cv2.putText(legend, "C & AR", (50,56), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,0,0), 1, cv2.LINE_AA) | |
| cv2.rectangle(legend, (10,70), (40,90), (0,165,255), 2); cv2.putText(legend, "C only", (50,86), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,0,0), 1, cv2.LINE_AA) | |
| cv2.rectangle(legend, (150,70), (180,90), (180,0,180), 2); cv2.putText(legend, "AR only", (190,86), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0,0,0), 1, cv2.LINE_AA) | |
| img_bgr = _paste_legend_safe(img_bgr, legend, x=10, y=10, pad=10) | |
| return Image.fromarray(cv2.cvtColor(img_bgr, cv2.COLOR_BGR2RGB)) | |
| # ================= Plot helpers ================= | |
| def _auto_bins(x): | |
| x = np.asarray(x).ravel() | |
| if x.size < 2: | |
| return 10 | |
| q75, q25 = np.percentile(x, [75 ,25]) | |
| iqr = max(q75 - q25, 1e-9) | |
| bin_width = 2 * iqr / (x.size ** (1/3)) | |
| if bin_width <= 0: | |
| return 20 | |
| bins = int(np.clip((x.max() - x.min()) / bin_width, 8, 50)) | |
| return bins | |
| def make_plots(df, use_iso, circ_thr, ar_low, ar_high): | |
| circ_col = "circularity_ISO" if use_iso else "circularity_ASTM" | |
| vals_circ = df[circ_col].dropna() | |
| plt.figure(figsize=(5,7)) | |
| bins = _auto_bins(vals_circ) if len(vals_circ) > 1 else 10 | |
| plt.hist(vals_circ, bins=bins) | |
| plt.axvline(circ_thr, linestyle="--", linewidth=2) | |
| if use_iso: | |
| plt.xlim(0, 1) | |
| thr_text = f"threshold ≥ {circ_thr:.2f}" | |
| else: | |
| thr_text = f"threshold ≤ {circ_thr:.2f}" | |
| ylim = plt.ylim() | |
| plt.text(circ_thr, ylim[1]*0.92, thr_text, ha="left", va="top") | |
| plt.grid(True, axis="y", alpha=0.3) | |
| plt.title(f"{circ_col}\n(portrait histogram)") | |
| plt.xlabel("circularity" if use_iso else "circularity (ASTM)") | |
| plt.ylabel("count") | |
| plt.tight_layout() | |
| buf1 = io.BytesIO() | |
| plt.savefig(buf1, format="png", dpi=220); plt.close() | |
| img1 = Image.open(io.BytesIO(buf1.getvalue())) | |
| vals_ar = df["aspect_ratio"].dropna() | |
| plt.figure(figsize=(5,7)) | |
| bins_ar = _auto_bins(vals_ar) if len(vals_ar) > 1 else 10 | |
| plt.hist(vals_ar, bins=bins_ar) | |
| plt.axvline(ar_low, linestyle="--", linewidth=2) | |
| plt.axvline(ar_high, linestyle="--", linewidth=2) | |
| plt.axvspan(ar_low, ar_high, alpha=0.10) | |
| ylim = plt.ylim() | |
| plt.text(ar_high, ylim[1]*0.92, f"pass band {ar_low:.2f}–{ar_high:.2f}", ha="right", va="top") | |
| plt.grid(True, axis="y", alpha=0.3) | |
| plt.title("Aspect Ratio (major/minor)\n(portrait histogram)") | |
| plt.xlabel("aspect ratio") | |
| plt.ylabel("count") | |
| plt.tight_layout() | |
| buf2 = io.BytesIO() | |
| plt.savefig(buf2, format="png", dpi=220); plt.close() | |
| img2 = Image.open(io.BytesIO(buf2.getvalue())) | |
| return img1, img2 | |
| def make_scatter(df, use_iso, circ_thr, ar_low, ar_high): | |
| circ_col = "circularity_ISO" if use_iso else "circularity_ASTM" | |
| x = df[circ_col].values | |
| y = df["aspect_ratio"].values | |
| plt.figure(figsize=(5,7)) | |
| plt.scatter(x, y, s=8) | |
| plt.axvline(circ_thr, linestyle="--", linewidth=2) | |
| plt.axhline(ar_low, linestyle="--", linewidth=1.5) | |
| plt.axhline(ar_high, linestyle="--", linewidth=1.5) | |
| plt.axhspan(ar_low, ar_high, alpha=0.06) | |
| # Add PASS/FAIL labels | |
| xlim = plt.xlim() | |
| ylim = plt.ylim() | |
| if use_iso: | |
| # pass: x >= thr and ar_low <= y <= ar_high | |
| pass_x = (circ_thr + xlim[1]) / 2.0 | |
| else: | |
| # pass: x <= thr and ar_low <= y <= ar_high | |
| pass_x = (xlim[0] + circ_thr) / 2.0 | |
| pass_y = (ar_low + ar_high) / 2.0 | |
| plt.text(pass_x, pass_y, "PASS", ha="center", va="center") | |
| # Fail labels (three regions): below band, above band, and opposite side of threshold | |
| fail_left_x = (xlim[0] + (circ_thr if use_iso else xlim[0])) / 2.0 if use_iso else (circ_thr + xlim[1]) / 2.0 | |
| plt.text(fail_left_x, pass_y, "FAIL", ha="center", va="center") | |
| plt.text(pass_x, ylim[0] + (ar_low - ylim[0]) * 0.5, "FAIL", ha="center", va="center") | |
| plt.text(pass_x, ar_high + (ylim[1] - ar_high) * 0.5, "FAIL", ha="center", va="center") | |
| if use_iso: | |
| plt.xlim(0, 1) | |
| plt.title(f"{circ_col} vs Aspect Ratio\n(portrait scatter with pass/fail labels)") | |
| plt.xlabel("circularity" if use_iso else "circularity (ASTM)") | |
| plt.ylabel("aspect ratio") | |
| plt.grid(True, alpha=0.3) | |
| plt.tight_layout() | |
| buf = io.BytesIO() | |
| plt.savefig(buf, format="png", dpi=220); plt.close() | |
| return Image.open(io.BytesIO(buf.getvalue())) | |
| def make_density(df, use_iso, circ_thr, ar_low, ar_high): | |
| circ_col = "circularity_ISO" if use_iso else "circularity_ASTM" | |
| x = df[circ_col].values | |
| y = df["aspect_ratio"].values | |
| if len(x) < 5: | |
| # too few points; return simple message figure | |
| plt.figure(figsize=(5,7)) | |
| plt.text(0.5, 0.5, "Not enough data for density", ha="center", va="center") | |
| plt.axis("off") | |
| buf = io.BytesIO() | |
| plt.savefig(buf, format="png", dpi=220); plt.close() | |
| return Image.open(io.BytesIO(buf.getvalue())) | |
| # KDE on a grid | |
| xy = np.vstack([x, y]) | |
| kde = gaussian_kde(xy) | |
| x_min, x_max = (0.0, 1.0) if use_iso else (max(0.0, np.nanmin(x)), np.nanmax(x)) | |
| y_min, y_max = max(0.0, np.nanmin(y)), np.nanmax(y) | |
| # add margins | |
| x_pad = 0.05 * (x_max - x_min if x_max > x_min else 1.0) | |
| y_pad = 0.05 * (y_max - y_min if y_max > y_min else 1.0) | |
| x_min, x_max = x_min - x_pad, x_max + x_pad | |
| y_min, y_max = y_min - y_pad, y_max + y_pad | |
| xi, yi = np.mgrid[x_min:x_max:200j, y_min:y_max:200j] | |
| zi = kde(np.vstack([xi.flatten(), yi.flatten()])) | |
| plt.figure(figsize=(5,7)) | |
| plt.contour(xi, yi, zi.reshape(xi.shape), 8) # default levels/colors | |
| # Add thresholds and band | |
| plt.axvline(circ_thr, linestyle="--", linewidth=2) | |
| plt.axhline(ar_low, linestyle="--", linewidth=1.5) | |
| plt.axhline(ar_high, linestyle="--", linewidth=1.5) | |
| plt.axhspan(ar_low, ar_high, alpha=0.06) | |
| if use_iso: | |
| plt.xlim(0, 1) | |
| plt.title(f"{circ_col} vs Aspect Ratio\n(2D density contours)") | |
| plt.xlabel("circularity" if use_iso else "circularity (ASTM)") | |
| plt.ylabel("aspect ratio") | |
| plt.grid(True, alpha=0.3) | |
| plt.tight_layout() | |
| buf = io.BytesIO() | |
| plt.savefig(buf, format="png", dpi=220); plt.close() | |
| return Image.open(io.BytesIO(buf.getvalue())) | |
| # ================= Analysis ================= | |
| def analyze(image, standard, circ_threshold, ar_low, ar_high, um_per_px, assumed_scale_um, min_area_um2, min_ecd_um): | |
| if image is None: | |
| return None, None, "Please upload an image.", None, None, None, None, None | |
| if image.ndim == 3: | |
| img_gray = cv2.cvtColor(image, cv2.COLOR_RGB2GRAY) | |
| else: | |
| img_gray = image | |
| if um_per_px is None or um_per_px <= 0: | |
| um_per_px_auto = auto_detect_scale_bar_um_per_px(img_gray, assumed_bar_um=float(assumed_scale_um)) | |
| if um_per_px_auto is None: | |
| return None, None, "Scale not found. Please input µm per pixel manually.", None, None, None, None, None | |
| um_per_px = um_per_px_auto | |
| labels_ws, roi_h = segment_particles(img_gray) | |
| df = compute_metrics(labels_ws, um_per_px, min_area_um2=min_area_um2, min_ecd_um=min_ecd_um) | |
| use_iso = (standard == "ISO 9276-6") | |
| circ_col = "circularity_ISO" if use_iso else "circularity_ASTM" | |
| pass_circ = (df[circ_col] >= circ_threshold) if use_iso else (df[circ_col] <= circ_threshold) | |
| pass_ar = (df["aspect_ratio"] >= ar_low) & (df["aspect_ratio"] <= ar_high) | |
| pass_both = pass_circ & pass_ar | |
| n_total = len(df) | |
| pct = lambda a: (100.0*a/n_total) if n_total>0 else 0.0 | |
| summary_md = f""" | |
| **Particles analyzed**: {n_total} | |
| **Circularity standard**: {standard} | |
| - Circularity pass: **{int(pass_circ.sum())}/{n_total} ({pct(int(pass_circ.sum())):.1f}%)** (threshold {'≥' if use_iso else '≤'} {circ_threshold}) | |
| - Aspect ratio pass: **{int(pass_ar.sum())}/{n_total} ({pct(int(pass_ar.sum())):.1f}%)** (band {ar_low}–{ar_high}) | |
| - Both pass: **{int((pass_both).sum())}/{n_total} ({pct(int((pass_both).sum())):.1f}%)** | |
| **Scale**: {um_per_px:.4f} µm/px | |
| """ | |
| overlay = overlay_failures(img_gray, labels_ws, df, use_iso, circ_threshold, ar_low, ar_high, roi_h) | |
| plot1, plot2 = make_plots(df, use_iso, circ_threshold, ar_low, ar_high) | |
| scatter = make_scatter(df, use_iso, circ_threshold, ar_low, ar_high) | |
| density = make_density(df, use_iso, circ_threshold, ar_low, ar_high) | |
| out_csv = "particle_metrics.csv" | |
| df_out = df.copy() | |
| df_out["pass_circularity"] = pass_circ | |
| df_out["pass_AR"] = pass_ar | |
| df_out["pass_both"] = pass_both | |
| df_out.to_csv(out_csv, index=False) | |
| return overlay, df_out.head(50), summary_md, out_csv, plot1, plot2, scatter, density | |
| # ================= Recommended Light CSS ================= | |
| LIGHT_CSS = """ | |
| :root { | |
| --bg-page: #f7f9fc; | |
| --bg-card: #ffffff; | |
| --text-primary: #0b1021; | |
| --text-secondary: #3a4560; | |
| --accent: #2b6fff; | |
| --radius: 14px; | |
| --elev: 0 10px 28px rgba(16, 24, 40, 0.08); | |
| } | |
| .gradio-container { | |
| background: var(--bg-page); | |
| color: var(--text-primary); | |
| font-synthesis-weight: none; | |
| -webkit-font-smoothing: antialiased; | |
| -moz-osx-font-smoothing: grayscale; | |
| } | |
| button, .gr-button { | |
| border-radius: var(--radius) !important; | |
| box-shadow: var(--elev) !important; | |
| } | |
| input, textarea, .gr-textbox, .gr-number, .gr-slider, .gr-dropdown, .gr-file, .gr-image { | |
| border-radius: var(--radius) !important; | |
| box-shadow: inset 0 0 0 1px rgba(11,16,33,0.08), 0 10px 18px rgba(16, 24, 40, 0.06) !important; | |
| background: var(--bg-card) !important; | |
| color: var(--text-primary) !important; | |
| } | |
| .gr-tab, .gr-accordion, .gr-panel { | |
| border-radius: var(--radius) !important; | |
| background: var(--bg-card) !important; | |
| box-shadow: var(--elev) !important; | |
| } | |
| .markdown-body h1, .markdown-body h2, .markdown-body h3 { | |
| color: var(--text-primary); | |
| } | |
| label, .gr-input-label { | |
| color: var(--text-secondary) !important; | |
| font-weight: 600; | |
| letter-spacing: .2px; | |
| } | |
| """ | |
| # ================= Tabs-based UI ================= | |
| with gr.Blocks(theme=gr.themes.Soft(), css=LIGHT_CSS) as demo: | |
| gr.HTML("<div style='padding:10px 8px 0;'><h2 style='margin:0;font-weight:800;'>Particle Shape QC — ISO 9276-6 / ASTM F1877</h2><p style='margin:.25rem 0 0;color:var(--text-secondary)'>Light theme · Recommended defaults · High contrast for clarity</p></div>") | |
| with gr.Tabs(): | |
| with gr.Tab("Upload & Settings"): | |
| with gr.Row(): | |
| with gr.Column(scale=1): | |
| image = gr.Image(type="numpy", label="Upload image (SEM/optical)") | |
| standard = gr.Dropdown(choices=["ISO 9276-6", "ASTM F1877"], value="ISO 9276-6", label="Circularity standard") | |
| circ_threshold = gr.Slider(0.5, 2.0, value=0.85, step=0.01, label="Circularity threshold (ISO: ≥ ; ASTM: ≤)") | |
| with gr.Row(): | |
| ar_low = gr.Slider(0.5, 2.0, value=0.8, step=0.01, label="AR lower") | |
| ar_high = gr.Slider(0.8, 3.0, value=1.2, step=0.01, label="AR upper") | |
| with gr.Accordion("Scale & Filters", open=False): | |
| um_per_px = gr.Number(value=0.0, label="µm per pixel (0 = auto-detect)") | |
| assumed_scale_um = gr.Number(value=100.0, label="Assumed scale bar length (µm)") | |
| min_area_um2 = gr.Number(value=50.0, label="Min particle area (µm²)") | |
| min_ecd_um = gr.Number(value=5.0, label="Min ECD (µm)") | |
| run_btn = gr.Button("Run analysis", variant="primary") | |
| with gr.Column(scale=1): | |
| overlay = gr.Image(label="Overlay (failing particles highlighted)") | |
| summary = gr.Markdown() | |
| with gr.Tab("Results"): | |
| with gr.Row(): | |
| df_prev = gr.Dataframe(label="Preview (first 50 rows)") | |
| with gr.Row(): | |
| plot1 = gr.Image(label="Circularity histogram (portrait)") | |
| plot2 = gr.Image(label="Aspect ratio histogram (portrait)") | |
| with gr.Row(): | |
| scatter = gr.Image(label="Circularity vs AR — scatter (portrait)") | |
| with gr.Row(): | |
| density = gr.Image(label="Circularity vs AR — density contours (portrait)") | |
| with gr.Tab("Download"): | |
| csv_out = gr.File(label="Download CSV") | |
| run_btn.click( | |
| analyze, | |
| inputs=[image, standard, circ_threshold, ar_low, ar_high, um_per_px, assumed_scale_um, min_area_um2, min_ecd_um], | |
| outputs=[overlay, df_prev, summary, csv_out, plot1, plot2, scatter, density] | |
| ) | |
| if __name__ == "__main__": | |
| demo.launch() | |