Shape-index / app.py
Kung-Hsun's picture
Update app.py
d331642 verified
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()