Update app.py
Browse files
app.py
CHANGED
|
@@ -59,13 +59,13 @@ def _text_size(draw, text, font):
|
|
| 59 |
return bbox[2] - bbox[0], bbox[3] - bbox[1]
|
| 60 |
|
| 61 |
|
| 62 |
-
# βββ Visual polygon helpers βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 63 |
|
| 64 |
def _smooth_polygon(pts_xy, window=11):
|
| 65 |
"""
|
| 66 |
Hanning-weighted circular sliding-window average on polygon vertices.
|
| 67 |
pts_xy : numpy (N, 2) float β already in original image coordinates
|
| 68 |
-
from results.masks.xy
|
| 69 |
Returns : int32 array (N, 1, 2) for cv2.polylines / fillPoly.
|
| 70 |
VISUAL ONLY β backend mask uses raw polygon via _polygon_to_mask().
|
| 71 |
"""
|
|
@@ -74,10 +74,8 @@ def _smooth_polygon(pts_xy, window=11):
|
|
| 74 |
if n < 6:
|
| 75 |
return pts.astype(np.int32).reshape(-1, 1, 2)
|
| 76 |
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
half = window // 2
|
| 80 |
-
|
| 81 |
padded = np.vstack([pts[-half:], pts, pts[:half]])
|
| 82 |
weights = np.hanning(window).astype(np.float32)
|
| 83 |
weights /= weights.sum()
|
|
@@ -90,10 +88,7 @@ def _smooth_polygon(pts_xy, window=11):
|
|
| 90 |
|
| 91 |
|
| 92 |
def _polygon_to_mask(pts_xy, h, w):
|
| 93 |
-
"""
|
| 94 |
-
Rasterise raw polygon to binary uint8 mask.
|
| 95 |
-
Used ONLY for backend measurements β never for visuals.
|
| 96 |
-
"""
|
| 97 |
mask = np.zeros((h, w), dtype=np.uint8)
|
| 98 |
if len(pts_xy) >= 3:
|
| 99 |
cv2.fillPoly(mask, [pts_xy.astype(np.int32)], 1)
|
|
@@ -103,13 +98,9 @@ def _polygon_to_mask(pts_xy, h, w):
|
|
| 103 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 104 |
# STEP 1 β Segmentation + visual output
|
| 105 |
#
|
| 106 |
-
#
|
| 107 |
-
#
|
| 108 |
-
#
|
| 109 |
-
#
|
| 110 |
-
# Now uses results.masks.xy β ultralytics already maps each polygon
|
| 111 |
-
# to ORIGINAL image pixel coordinates, so alignment is exact.
|
| 112 |
-
# No resize, no drift, no displacement.
|
| 113 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 114 |
def run_segmentation(img_np):
|
| 115 |
h, w = img_np.shape[:2]
|
|
@@ -123,7 +114,7 @@ def run_segmentation(img_np):
|
|
| 123 |
all_x1, all_y1, all_x2, all_y2 = w, h, 0, 0
|
| 124 |
|
| 125 |
if results.masks is not None:
|
| 126 |
-
xy_list = results.masks.xy
|
| 127 |
|
| 128 |
for poly_xy, box in zip(xy_list, results.boxes):
|
| 129 |
if len(poly_xy) < 3:
|
|
@@ -134,13 +125,13 @@ def run_segmentation(img_np):
|
|
| 134 |
color = CLASS_COLORS.get(cls_id, (200, 200, 200))
|
| 135 |
counts[cls_name] += 1
|
| 136 |
|
| 137 |
-
# Backend mask: raw polygon fill
|
| 138 |
-
mask_np
|
| 139 |
|
| 140 |
-
# Visual polygon: Hanning-smoothed
|
| 141 |
vis_poly = _smooth_polygon(poly_xy, window=11)
|
| 142 |
|
| 143 |
-
#
|
| 144 |
ys, xs = np.where(mask_np == 1)
|
| 145 |
if len(xs) > 0:
|
| 146 |
all_x1 = min(all_x1, int(xs.min()))
|
|
@@ -148,7 +139,6 @@ def run_segmentation(img_np):
|
|
| 148 |
all_x2 = max(all_x2, int(xs.max()))
|
| 149 |
all_y2 = max(all_y2, int(ys.max()))
|
| 150 |
|
| 151 |
-
# Fill overlay with smooth visual polygon
|
| 152 |
cv2.fillPoly(overlay, [vis_poly], color)
|
| 153 |
|
| 154 |
grain_boxes.append({
|
|
@@ -161,22 +151,19 @@ def run_segmentation(img_np):
|
|
| 161 |
# Blend fill
|
| 162 |
annotated = cv2.addWeighted(annotated, 0.72, overlay, 0.28, 0)
|
| 163 |
|
| 164 |
-
#
|
| 165 |
for g in grain_boxes:
|
| 166 |
-
color = CLASS_COLORS[g["cls_id"]]
|
| 167 |
cv2.polylines(
|
| 168 |
annotated, [g["vis_poly"]],
|
| 169 |
-
isClosed=True, color=
|
| 170 |
lineType=cv2.LINE_AA,
|
| 171 |
)
|
| 172 |
|
| 173 |
-
# Zoom
|
| 174 |
if all_x2 > all_x1 and all_y2 > all_y1:
|
| 175 |
pad = max(30, int(max(all_x2 - all_x1, all_y2 - all_y1) * 0.08))
|
| 176 |
-
cx1
|
| 177 |
-
|
| 178 |
-
cx2 = min(w, all_x2 + pad)
|
| 179 |
-
cy2 = min(h, all_y2 + pad)
|
| 180 |
zoomed_pil = Image.fromarray(annotated[cy1:cy2, cx1:cx2])
|
| 181 |
else:
|
| 182 |
zoomed_pil = Image.fromarray(annotated)
|
|
@@ -185,7 +172,7 @@ def run_segmentation(img_np):
|
|
| 185 |
|
| 186 |
|
| 187 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 188 |
-
# STEP 2 β Measure grains (
|
| 189 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 190 |
def measure_grains_from_boxes(grain_boxes, img_shape, paper_dims):
|
| 191 |
paper_px = (paper_dims[0] + paper_dims[1]) / 2.0 if paper_dims else None
|
|
@@ -235,10 +222,10 @@ def build_table_data(measurements, paper_px, counts):
|
|
| 235 |
w_val = round(g["w_mm"], 2) if (has_mm and g["w_mm"]) else round(g["w_px"], 1)
|
| 236 |
area_val = round(g["area_mm2"], 2) if g["area_mm2"] else None
|
| 237 |
rows.append({
|
| 238 |
-
"#":
|
| 239 |
-
"Class":
|
| 240 |
-
f"Height ({unit})":
|
| 241 |
-
f"Width ({unit})":
|
| 242 |
"Area (mm\u00b2)" if has_mm else "Area": area_val,
|
| 243 |
})
|
| 244 |
grain_df = pd.DataFrame(rows)
|
|
@@ -326,7 +313,9 @@ def predict_stage2(image: Image.Image):
|
|
| 326 |
|
| 327 |
|
| 328 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 329 |
-
# UI
|
|
|
|
|
|
|
| 330 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 331 |
THEME = gr.themes.Soft(
|
| 332 |
primary_hue="violet",
|
|
@@ -335,13 +324,27 @@ THEME = gr.themes.Soft(
|
|
| 335 |
font=gr.themes.GoogleFont("Inter"),
|
| 336 |
)
|
| 337 |
|
|
|
|
| 338 |
CSS = """
|
| 339 |
-
#run-btn
|
| 340 |
#status-box textarea { font-size: 0.92rem; }
|
| 341 |
#count-box { font-size: 0.95rem; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
"""
|
| 343 |
|
| 344 |
-
with gr.Blocks(
|
| 345 |
|
| 346 |
gr.HTML("""
|
| 347 |
<div style="padding:18px 12px 10px 12px; background-color:#0F172A;
|
|
@@ -361,7 +364,8 @@ with gr.Blocks(theme=THEME, title="GrainVision", css=CSS) as demo:
|
|
| 361 |
inp_image = gr.Image(type="pil", label="Upload Rice Image", height=280)
|
| 362 |
run_btn = gr.Button("π Analyse Grains",
|
| 363 |
variant="primary", size="lg", elem_id="run-btn")
|
| 364 |
-
gr.Markdown("_Upload an image then press **Analyse**.
|
|
|
|
| 365 |
status_box = gr.Textbox(
|
| 366 |
label="Status", value="", interactive=False,
|
| 367 |
visible=True, max_lines=3, elem_id="status-box",
|
|
@@ -387,7 +391,8 @@ with gr.Blocks(theme=THEME, title="GrainVision", css=CSS) as demo:
|
|
| 387 |
with gr.Column(scale=1):
|
| 388 |
gr.Markdown("#### Grain Count")
|
| 389 |
count_md = gr.Markdown(
|
| 390 |
-
value="| | Count |\n|---|---|\n
|
|
|
|
| 391 |
elem_id="count-box",
|
| 392 |
)
|
| 393 |
|
|
@@ -397,18 +402,14 @@ with gr.Blocks(theme=THEME, title="GrainVision", css=CSS) as demo:
|
|
| 397 |
with gr.Column(scale=2):
|
| 398 |
gr.Markdown("#### Per-Grain Measurements")
|
| 399 |
grain_table_out = gr.DataFrame(
|
| 400 |
-
label="",
|
| 401 |
-
|
| 402 |
-
wrap=False,
|
| 403 |
-
height=500, # shows all rows; scrollable if > 500px
|
| 404 |
)
|
| 405 |
with gr.Column(scale=1):
|
| 406 |
gr.Markdown("#### Summary Statistics")
|
| 407 |
summary_table_out = gr.DataFrame(
|
| 408 |
-
label="",
|
| 409 |
-
|
| 410 |
-
wrap=False,
|
| 411 |
-
height=420, # fits all 11 summary rows without scroll
|
| 412 |
)
|
| 413 |
|
| 414 |
OUTPUTS = [seg_out, summary_box, count_md, grain_table_out, summary_table_out]
|
|
@@ -421,4 +422,5 @@ with gr.Blocks(theme=THEME, title="GrainVision", css=CSS) as demo:
|
|
| 421 |
|
| 422 |
|
| 423 |
if __name__ == "__main__":
|
| 424 |
-
|
|
|
|
|
|
| 59 |
return bbox[2] - bbox[0], bbox[3] - bbox[1]
|
| 60 |
|
| 61 |
|
| 62 |
+
# βββ Visual polygon helpers βββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 63 |
|
| 64 |
def _smooth_polygon(pts_xy, window=11):
|
| 65 |
"""
|
| 66 |
Hanning-weighted circular sliding-window average on polygon vertices.
|
| 67 |
pts_xy : numpy (N, 2) float β already in original image coordinates
|
| 68 |
+
from results.masks.xy (no resize, no drift).
|
| 69 |
Returns : int32 array (N, 1, 2) for cv2.polylines / fillPoly.
|
| 70 |
VISUAL ONLY β backend mask uses raw polygon via _polygon_to_mask().
|
| 71 |
"""
|
|
|
|
| 74 |
if n < 6:
|
| 75 |
return pts.astype(np.int32).reshape(-1, 1, 2)
|
| 76 |
|
| 77 |
+
window = min(window | 1, (n - 1) | 1)
|
| 78 |
+
half = window // 2
|
|
|
|
|
|
|
| 79 |
padded = np.vstack([pts[-half:], pts, pts[:half]])
|
| 80 |
weights = np.hanning(window).astype(np.float32)
|
| 81 |
weights /= weights.sum()
|
|
|
|
| 88 |
|
| 89 |
|
| 90 |
def _polygon_to_mask(pts_xy, h, w):
|
| 91 |
+
"""Rasterise raw polygon β binary uint8 mask. BACKEND / measurements only."""
|
|
|
|
|
|
|
|
|
|
| 92 |
mask = np.zeros((h, w), dtype=np.uint8)
|
| 93 |
if len(pts_xy) >= 3:
|
| 94 |
cv2.fillPoly(mask, [pts_xy.astype(np.int32)], 1)
|
|
|
|
| 98 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 99 |
# STEP 1 β Segmentation + visual output
|
| 100 |
#
|
| 101 |
+
# Uses results.masks.xy (polygon in original-image px coords) instead of
|
| 102 |
+
# results.masks.data (low-res tensor + resize) β zero resize drift,
|
| 103 |
+
# pixel-perfect mask alignment.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 105 |
def run_segmentation(img_np):
|
| 106 |
h, w = img_np.shape[:2]
|
|
|
|
| 114 |
all_x1, all_y1, all_x2, all_y2 = w, h, 0, 0
|
| 115 |
|
| 116 |
if results.masks is not None:
|
| 117 |
+
xy_list = results.masks.xy # list of (N_i, 2) float arrays, orig coords
|
| 118 |
|
| 119 |
for poly_xy, box in zip(xy_list, results.boxes):
|
| 120 |
if len(poly_xy) < 3:
|
|
|
|
| 125 |
color = CLASS_COLORS.get(cls_id, (200, 200, 200))
|
| 126 |
counts[cls_name] += 1
|
| 127 |
|
| 128 |
+
# Backend mask: raw polygon fill (measurements only)
|
| 129 |
+
mask_np = _polygon_to_mask(poly_xy, h, w)
|
| 130 |
|
| 131 |
+
# Visual polygon: Hanning-smoothed (display only)
|
| 132 |
vis_poly = _smooth_polygon(poly_xy, window=11)
|
| 133 |
|
| 134 |
+
# Bounding box from backend mask for zoom crop
|
| 135 |
ys, xs = np.where(mask_np == 1)
|
| 136 |
if len(xs) > 0:
|
| 137 |
all_x1 = min(all_x1, int(xs.min()))
|
|
|
|
| 139 |
all_x2 = max(all_x2, int(xs.max()))
|
| 140 |
all_y2 = max(all_y2, int(ys.max()))
|
| 141 |
|
|
|
|
| 142 |
cv2.fillPoly(overlay, [vis_poly], color)
|
| 143 |
|
| 144 |
grain_boxes.append({
|
|
|
|
| 151 |
# Blend fill
|
| 152 |
annotated = cv2.addWeighted(annotated, 0.72, overlay, 0.28, 0)
|
| 153 |
|
| 154 |
+
# Anti-aliased contour lines
|
| 155 |
for g in grain_boxes:
|
|
|
|
| 156 |
cv2.polylines(
|
| 157 |
annotated, [g["vis_poly"]],
|
| 158 |
+
isClosed=True, color=CLASS_COLORS[g["cls_id"]], thickness=2,
|
| 159 |
lineType=cv2.LINE_AA,
|
| 160 |
)
|
| 161 |
|
| 162 |
+
# Zoom
|
| 163 |
if all_x2 > all_x1 and all_y2 > all_y1:
|
| 164 |
pad = max(30, int(max(all_x2 - all_x1, all_y2 - all_y1) * 0.08))
|
| 165 |
+
cx1, cy1 = max(0, all_x1 - pad), max(0, all_y1 - pad)
|
| 166 |
+
cx2, cy2 = min(w, all_x2 + pad), min(h, all_y2 + pad)
|
|
|
|
|
|
|
| 167 |
zoomed_pil = Image.fromarray(annotated[cy1:cy2, cx1:cx2])
|
| 168 |
else:
|
| 169 |
zoomed_pil = Image.fromarray(annotated)
|
|
|
|
| 172 |
|
| 173 |
|
| 174 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 175 |
+
# STEP 2 β Measure grains (backend mask_np only β unaffected by visual changes)
|
| 176 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 177 |
def measure_grains_from_boxes(grain_boxes, img_shape, paper_dims):
|
| 178 |
paper_px = (paper_dims[0] + paper_dims[1]) / 2.0 if paper_dims else None
|
|
|
|
| 222 |
w_val = round(g["w_mm"], 2) if (has_mm and g["w_mm"]) else round(g["w_px"], 1)
|
| 223 |
area_val = round(g["area_mm2"], 2) if g["area_mm2"] else None
|
| 224 |
rows.append({
|
| 225 |
+
"#": g["label"],
|
| 226 |
+
"Class": g["cls_name"],
|
| 227 |
+
f"Height ({unit})": h_val,
|
| 228 |
+
f"Width ({unit})": w_val,
|
| 229 |
"Area (mm\u00b2)" if has_mm else "Area": area_val,
|
| 230 |
})
|
| 231 |
grain_df = pd.DataFrame(rows)
|
|
|
|
| 313 |
|
| 314 |
|
| 315 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 316 |
+
# UI β Gradio 6 compatible
|
| 317 |
+
# β’ theme / css β moved to demo.launch()
|
| 318 |
+
# β’ gr.DataFrame has no height param β use CSS to expand tables
|
| 319 |
# βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 320 |
THEME = gr.themes.Soft(
|
| 321 |
primary_hue="violet",
|
|
|
|
| 324 |
font=gr.themes.GoogleFont("Inter"),
|
| 325 |
)
|
| 326 |
|
| 327 |
+
# In Gradio 6 the CSS string is passed to launch(), not Blocks()
|
| 328 |
CSS = """
|
| 329 |
+
#run-btn { margin-top: 6px; }
|
| 330 |
#status-box textarea { font-size: 0.92rem; }
|
| 331 |
#count-box { font-size: 0.95rem; }
|
| 332 |
+
|
| 333 |
+
/* Make both measurement tables tall enough to show all rows */
|
| 334 |
+
#grain-table .table-wrap,
|
| 335 |
+
#grain-table .svelte-table,
|
| 336 |
+
#summary-table .table-wrap,
|
| 337 |
+
#summary-table .svelte-table {
|
| 338 |
+
max-height: none !important;
|
| 339 |
+
overflow-y: visible !important;
|
| 340 |
+
}
|
| 341 |
+
#grain-table,
|
| 342 |
+
#summary-table {
|
| 343 |
+
overflow: visible !important;
|
| 344 |
+
}
|
| 345 |
"""
|
| 346 |
|
| 347 |
+
with gr.Blocks(title="GrainVision") as demo:
|
| 348 |
|
| 349 |
gr.HTML("""
|
| 350 |
<div style="padding:18px 12px 10px 12px; background-color:#0F172A;
|
|
|
|
| 364 |
inp_image = gr.Image(type="pil", label="Upload Rice Image", height=280)
|
| 365 |
run_btn = gr.Button("π Analyse Grains",
|
| 366 |
variant="primary", size="lg", elem_id="run-btn")
|
| 367 |
+
gr.Markdown("_Upload an image then press **Analyse**. "
|
| 368 |
+
"Segmentation appears first, measurements follow._")
|
| 369 |
status_box = gr.Textbox(
|
| 370 |
label="Status", value="", interactive=False,
|
| 371 |
visible=True, max_lines=3, elem_id="status-box",
|
|
|
|
| 391 |
with gr.Column(scale=1):
|
| 392 |
gr.Markdown("#### Grain Count")
|
| 393 |
count_md = gr.Markdown(
|
| 394 |
+
value="| | Count |\n|---|---|\n"
|
| 395 |
+
"| πΎ Total | β |\n| π’ Full | β |\n| π΄ Broken | β |",
|
| 396 |
elem_id="count-box",
|
| 397 |
)
|
| 398 |
|
|
|
|
| 402 |
with gr.Column(scale=2):
|
| 403 |
gr.Markdown("#### Per-Grain Measurements")
|
| 404 |
grain_table_out = gr.DataFrame(
|
| 405 |
+
label="", interactive=False, wrap=False,
|
| 406 |
+
elem_id="grain-table",
|
|
|
|
|
|
|
| 407 |
)
|
| 408 |
with gr.Column(scale=1):
|
| 409 |
gr.Markdown("#### Summary Statistics")
|
| 410 |
summary_table_out = gr.DataFrame(
|
| 411 |
+
label="", interactive=False, wrap=False,
|
| 412 |
+
elem_id="summary-table",
|
|
|
|
|
|
|
| 413 |
)
|
| 414 |
|
| 415 |
OUTPUTS = [seg_out, summary_box, count_md, grain_table_out, summary_table_out]
|
|
|
|
| 422 |
|
| 423 |
|
| 424 |
if __name__ == "__main__":
|
| 425 |
+
# Gradio 6: theme and css passed here, not in gr.Blocks()
|
| 426 |
+
demo.launch(theme=THEME, css=CSS)
|