mlbench123 commited on
Commit
9a51cf4
Β·
verified Β·
1 Parent(s): b57f874

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +52 -50
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, so NO resize drift whatsoever.
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
- # Window must be odd and fit inside the polygon
78
- window = min(window | 1, (n - 1) | 1)
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
- # ROOT-CAUSE FIX FOR MISALIGNMENT:
107
- # Previously used results.masks.data (low-res tensor) + cv2.resize
108
- # which introduces sub-pixel drift at every grain boundary.
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 # list of (N_i, 2) arrays, original coords
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 β€” for measurements only
138
- mask_np = _polygon_to_mask(poly_xy, h, w)
139
 
140
- # Visual polygon: Hanning-smoothed β€” for display only
141
  vis_poly = _smooth_polygon(poly_xy, window=11)
142
 
143
- # Update zoom bounding box from backend mask
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
- # Draw smooth anti-aliased outlines on top
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=color, thickness=2,
170
  lineType=cv2.LINE_AA,
171
  )
172
 
173
- # Zoom to grain region
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 = max(0, all_x1 - pad)
177
- cy1 = max(0, all_y1 - pad)
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 (unchanged β€” uses backend mask_np only)
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
- "#": g["label"],
239
- "Class": g["cls_name"],
240
- f"Height ({unit})": h_val,
241
- f"Width ({unit})": w_val,
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 { margin-top: 6px; }
340
  #status-box textarea { font-size: 0.92rem; }
341
  #count-box { font-size: 0.95rem; }
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  """
343
 
344
- with gr.Blocks(theme=THEME, title="GrainVision", css=CSS) as demo:
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**. Segmentation appears first, measurements follow._")
 
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| 🌾 Total | β€” |\n| 🟒 Full | β€” |\n| πŸ”΄ Broken | β€” |",
 
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
- interactive=False,
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
- interactive=False,
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
- demo.launch()
 
 
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)