smiler488 commited on
Commit
4cf2bd5
·
verified ·
1 Parent(s): fc3def2

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +359 -150
app.py CHANGED
@@ -11,7 +11,8 @@ import pandas as pd
11
  # -----------------------------
12
 
13
  # Maximum allowed image side (pixels) to avoid OOM / heavy CPU usage
14
- MAX_SIDE = 2048
 
15
 
16
 
17
  # -----------------------------
@@ -20,7 +21,7 @@ MAX_SIDE = 2048
20
 
21
  def downscale_bgr(img: np.ndarray) -> Tuple[np.ndarray, float]:
22
  """Downscale image so that the longest side is <= MAX_SIDE.
23
-
24
  Returns
25
  -------
26
  img_resized : np.ndarray
@@ -39,7 +40,7 @@ def downscale_bgr(img: np.ndarray) -> Tuple[np.ndarray, float]:
39
 
40
  def normalize_angle(angle: float, size_w: float, size_h: float) -> float:
41
  """Normalize OpenCV minAreaRect angle to [0, 180) degrees.
42
-
43
  OpenCV returns angles depending on whether width < height. We fix it so that
44
  the *long side* is treated as length and angle is always in [0, 180).
45
  """
@@ -54,92 +55,112 @@ def normalize_angle(angle: float, size_w: float, size_h: float) -> float:
54
  # Reference object detection
55
  # -----------------------------
56
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
57
  def detect_reference(
58
  img_bgr: np.ndarray,
59
  mode: str,
60
  ref_size_mm: Optional[float],
61
  ) -> Tuple[float, Optional[Tuple[int, int]], Optional[str], Optional[Tuple[int, int, int, int]]]:
62
- """Detect reference object (circle or square) using connected components.
63
-
64
- Assumptions:
65
- - White or near-white uniform background
66
- - A single reference object is placed in the top-left region
67
- - Reference is approximately square in its bounding box (square card or coin)
68
- - ref_size_mm is the real diameter (coin) or side length (square)
 
 
 
 
 
69
  """
70
  h, w = img_bgr.shape[:2]
71
 
72
- # 1. Estimate background color in LAB space and build "non-background" mask
73
- # This works for any solid-color background as long as the reference object
74
- # has a noticeable color difference from the background.
75
- lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB).astype(np.float32)
76
- # Use the median color of the whole image as background estimate (robust to small objects)
77
- bg_color = np.median(lab.reshape(-1, 3), axis=0)
78
-
79
- # Compute per-pixel Euclidean distance in LAB space
80
- diff = lab - bg_color # shape (H, W, 3)
81
- dist = np.sqrt(np.sum(diff * diff, axis=2)).astype(np.float32)
82
-
83
- # Threshold on color distance: pixels far from background color are foreground
84
- # You can tune 8.0 -> 6.0 or 10.0 depending on image contrast.
85
- _, mask = cv2.threshold(dist, 8.0, 255, cv2.THRESH_BINARY)
86
- mask = mask.astype(np.uint8)
87
-
88
- # 2. Small morphological opening to remove noise
89
- kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
90
- mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1)
91
 
92
- # 3. Connected components
93
  num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask)
94
 
95
- # stats[i] = [x, y, w, h, area]
96
  candidates = []
97
- for i in range(1, num_labels): # skip label 0 (background)
 
 
 
98
  x, y, ww, hh, area = stats[i]
99
- if area < 400:
100
- # too small, likely noise
 
101
  continue
102
-
103
- # Only consider objects in the upper-left region
104
- if x > w * 0.6 or y > h * 0.6:
105
  continue
106
-
107
- # Require roughly square bounding box: circles and squares both satisfy this
108
- ar = ww / float(hh + 1e-6)
109
- if ar < 0.7 or ar > 1.3:
110
  continue
111
 
112
- candidates.append((i, x, y, ww, hh, area))
113
-
114
- px_per_mm: float
115
- center: Optional[Tuple[int, int]] = None
116
- ref_type: Optional[str] = None
117
- bbox: Optional[Tuple[int, int, int, int]] = None
118
-
119
- if candidates:
120
- # 4. Pick the one closest to the top-left corner (smallest x + y)
121
- label_id, x, y, ww, hh, area = min(candidates, key=lambda t: t[1] + t[2])
122
- bbox = (int(x), int(y), int(ww), int(hh))
123
- center = (int(x + ww // 2), int(y + hh // 2))
124
 
125
- # Real-world size: diameter (coin) or side length (square)
126
- ref_mm = ref_size_mm if ref_size_mm and ref_size_mm > 0 else 20.0
127
-
128
- # For both circles and squares, the max side of the bounding box
129
- # can be treated as "diameter/side" in pixels.
130
- side_or_diam_px = float(max(ww, hh))
131
- px_per_mm = max(side_or_diam_px / ref_mm, 1e-6)
132
-
133
- # Roughly classify reference type; optional, not used in scaling
134
- ref_type = "square"
135
- else:
136
- # If no reference found, use a safe default scale to avoid division by zero.
137
  px_per_mm = 4.0
138
  center = None
139
  ref_type = None
140
  bbox = None
 
141
 
142
- return px_per_mm, center, ref_type, bbox
 
 
 
 
 
 
 
 
 
 
 
143
 
144
 
145
  # -----------------------------
@@ -153,11 +174,7 @@ def build_mask_hsv(
153
  hsv_high_h: int,
154
  color_tol: int,
155
  ) -> np.ndarray:
156
- """Build binary mask using HSV thresholds.
157
-
158
- For leaves we use a typical green range on H and stronger S/V filtering.
159
- For seeds / grains, we mainly use S/V to remove the white background.
160
- """
161
  hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
162
  h_channel = hsv[:, :, 0]
163
  s_channel = hsv[:, :, 1]
@@ -189,51 +206,157 @@ def segment(
189
  sample_type: str,
190
  hsv_low_h: int,
191
  hsv_high_h: int,
192
- color_tol: int, # currently not used, kept for future extension
193
  min_area_px: float,
194
  max_area_px: float,
195
  ) -> List[Dict[str, Any]]:
196
  """Segment objects and compute basic geometric descriptors.
197
-
198
- Returns a list of component dictionaries with contour, bounding box, etc.
199
  """
200
- mask = build_mask_hsv(img_bgr, sample_type, hsv_low_h, hsv_high_h, color_tol)
201
- cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
202
-
 
 
 
203
  components: List[Dict[str, Any]] = []
204
- for cnt in cnts:
205
- area_px = cv2.contourArea(cnt)
206
- if area_px < float(min_area_px) or area_px > float(max_area_px):
 
 
 
 
 
207
  continue
208
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
209
  rect = cv2.minAreaRect(cnt)
210
  box = cv2.boxPoints(rect).astype(np.int32)
211
- m = cv2.moments(cnt)
212
- if m["m00"] == 0:
213
- cx, cy = 0, 0
214
- else:
215
- cx = int(m["m10"] / m["m00"])
216
- cy = int(m["m01"] / m["m00"])
217
-
218
  peri = cv2.arcLength(cnt, True)
219
- hull = cv2.convexHull(cnt)
220
- hull_area = cv2.contourArea(hull)
221
- solidity = area_px / (hull_area + 1e-6)
222
- angle = normalize_angle(rect[2], rect[1][0], rect[1][1])
223
-
224
- components.append(
225
- {
226
- "contour": cnt,
227
- "rect": rect,
228
- "box": box,
229
- "area_px": area_px,
230
- "peri_px": peri,
231
- "center": (cx, cy),
232
- "angle": angle,
233
- "solidity": solidity,
234
- }
235
- )
236
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  return components
238
 
239
 
@@ -261,17 +384,18 @@ def compute_metrics(
261
  rows: List[Dict[str, Any]] = []
262
 
263
  for i, comp in enumerate(components, start=1):
264
- w_px = max(comp["rect"][1][0], comp["rect"][1][1])
265
- h_px = min(comp["rect"][1][0], comp["rect"][1][1])
266
-
267
- length_mm = w_px / px_per_mm
268
- width_mm = h_px / px_per_mm
269
  area_mm2 = comp["area_px"] / (px_per_mm * px_per_mm)
270
  perimeter_mm = comp["peri_px"] / px_per_mm
271
 
272
  aspect_ratio = length_mm / (width_mm + 1e-6)
 
 
273
  circularity = (4.0 * np.pi * area_mm2) / (perimeter_mm * perimeter_mm + 1e-6)
274
 
 
275
  mask_single = np.zeros(img_bgr.shape[:2], dtype=np.uint8)
276
  cv2.drawContours(mask_single, [comp["contour"]], -1, 255, thickness=-1)
277
  mean_r, mean_g, mean_b, h, s, v, gi, bi = compute_color_metrics(img_bgr, mask_single)
@@ -312,13 +436,15 @@ def render_overlay(
312
  df: pd.DataFrame,
313
  ref_bbox: Optional[Tuple[int, int, int, int]] = None,
314
  ) -> np.ndarray:
315
- """Draw reference + sample annotations on the image."""
 
 
316
  out = img_bgr.copy()
317
 
 
318
  ref_center, ref_type = ref
319
  if ref_bbox is not None:
320
  x, y, w, h = ref_bbox
321
- # Red rectangle for reference object
322
  cv2.rectangle(out, (int(x), int(y)), (int(x + w), int(y + h)), (0, 0, 255), 2)
323
  cv2.putText(
324
  out,
@@ -331,47 +457,94 @@ def render_overlay(
331
  cv2.LINE_AA,
332
  )
333
 
 
334
  for i, comp in enumerate(components, start=1):
335
- box = comp["box"]
336
-
337
- # Blue outlines for each sample
338
- cv2.drawContours(out, [box], 0, (255, 0, 0), 2)
339
- cv2.drawContours(out, [comp["contour"]], -1, (255, 0, 0), 1)
340
-
341
- cx, cy = comp["center"]
342
- # Keep a black circle with white S1, S2... label
343
- cv2.circle(out, (int(cx), int(cy)), 14, (0, 0, 0), -1)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  cv2.putText(
345
  out,
346
  f"s{i}",
347
- (int(cx) - 8, int(cy) + 5),
348
  cv2.FONT_HERSHEY_SIMPLEX,
349
  0.5,
350
  (255, 255, 255),
351
- 1,
352
  cv2.LINE_AA,
353
  )
354
 
355
- # Draw major (long) and minor (short) axes in blue
356
- pts = box.astype(np.float32)
357
- p0, p1, p2, p3 = pts
358
- d01 = np.linalg.norm(p0 - p1)
359
- d12 = np.linalg.norm(p1 - p2)
360
-
361
- if d01 >= d12:
362
- long_mid1 = (p0 + p1) / 2.0
363
- long_mid2 = (p2 + p3) / 2.0
364
- short_mid1 = (p1 + p2) / 2.0
365
- short_mid2 = (p3 + p0) / 2.0
366
- else:
367
- long_mid1 = (p1 + p2) / 2.0
368
- long_mid2 = (p3 + p0) / 2.0
369
- short_mid1 = (p0 + p1) / 2.0
370
- short_mid2 = (p2 + p3) / 2.0
371
-
372
- cv2.line(out, tuple(long_mid1.astype(int)), tuple(long_mid2.astype(int)), (255, 0, 0), 2)
373
- cv2.line(out, tuple(short_mid1.astype(int)), tuple(short_mid2.astype(int)), (255, 0, 0), 2)
374
-
375
  return cv2.cvtColor(out, cv2.COLOR_BGR2RGB)
376
 
377
 
@@ -387,29 +560,65 @@ def analyze(
387
  hsv_low_h: int,
388
  hsv_high_h: int,
389
  ) -> Tuple[Optional[np.ndarray], pd.DataFrame, Optional[str], List[Dict[str, Any]], Dict[str, Any]]:
 
390
  try:
391
  if image is None:
392
  return None, pd.DataFrame(), None, [], {}
 
 
393
  img_rgb = np.array(image)
394
  img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
 
 
395
  img_bgr, scale = downscale_bgr(img_bgr)
 
 
396
  px_per_mm, ref_center, ref_type, ref_bbox = detect_reference(img_bgr, ref_mode, ref_size_mm)
397
- comps = segment(img_bgr, sample_type, hsv_low_h, hsv_high_h, color_tol, min_area_px, max_area_px)
 
 
 
 
 
 
 
 
 
 
 
 
398
  if sample_type == "leaves":
399
  comps.sort(key=lambda c: c["center"][0])
400
  else:
401
  comps.sort(key=lambda c: c["center"][1] * 0.3 + c["center"][0] * 0.7)
 
 
402
  if expected_count and expected_count > 0:
403
  comps = comps[:int(expected_count)]
 
 
404
  df = compute_metrics(img_bgr, comps, px_per_mm)
405
- overlay = render_overlay(img_bgr.copy(), px_per_mm, (ref_center, ref_type), comps, df, ref_bbox)
 
 
 
 
 
 
 
 
 
 
 
406
  csv = df.to_csv(index=False)
407
  tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
408
  tmp.write(csv.encode("utf-8"))
409
  tmp.close()
 
 
410
  js = df.to_dict(orient="records")
411
-
412
- # Store state for interactive correction
413
  state_dict: Dict[str, Any] = {
414
  "img_bgr": img_bgr,
415
  "sample_type": sample_type,
@@ -421,7 +630,7 @@ def analyze(
421
  "expected_count": expected_count,
422
  "ref_size_mm": ref_size_mm,
423
  }
424
- # By default, all components are active samples
425
  state_dict["active_indices"] = list(range(len(comps)))
426
 
427
  return overlay, df, tmp.name, js, state_dict
@@ -437,7 +646,6 @@ def apply_corrections(
437
  ) -> Tuple[Dict[str, Any], Optional[np.ndarray], pd.DataFrame, Optional[str], List[Dict[str, Any]]]:
438
  """
439
  Apply interactive corrections based on a click on the annotated image.
440
-
441
  correction_mode:
442
  - "none": do nothing
443
  - "set-ref": treat the clicked object as the new reference
@@ -575,6 +783,7 @@ with gr.Blocks(theme=gr.themes.Default()) as demo:
575
  table = gr.Dataframe(label="Metrics", wrap=True)
576
  csv_out = gr.File(label="CSV export")
577
  json_out = gr.JSON(label="JSON preview")
 
578
  def _analyze(image, sample_type, expected, ref_mode, ref_size, min_area, max_area, color_tol, hsv_low, hsv_high):
579
  overlay_img, df, csv_path, js, state_dict = analyze(
580
  image, sample_type, expected, ref_mode, ref_size, min_area, max_area, color_tol, hsv_low, hsv_high
 
11
  # -----------------------------
12
 
13
  # Maximum allowed image side (pixels) to avoid OOM / heavy CPU usage
14
+ # Reduced from 2048 to 1024 for better performance (as in demo.py)
15
+ MAX_SIDE = 1024
16
 
17
 
18
  # -----------------------------
 
21
 
22
  def downscale_bgr(img: np.ndarray) -> Tuple[np.ndarray, float]:
23
  """Downscale image so that the longest side is <= MAX_SIDE.
24
+
25
  Returns
26
  -------
27
  img_resized : np.ndarray
 
40
 
41
  def normalize_angle(angle: float, size_w: float, size_h: float) -> float:
42
  """Normalize OpenCV minAreaRect angle to [0, 180) degrees.
43
+
44
  OpenCV returns angles depending on whether width < height. We fix it so that
45
  the *long side* is treated as length and angle is always in [0, 180).
46
  """
 
55
  # Reference object detection
56
  # -----------------------------
57
 
58
+ def build_foreground_mask(img_bgr: np.ndarray) -> np.ndarray:
59
+ """简单的前景掩码构建(来自demo.py)"""
60
+ h, w = img_bgr.shape[:2]
61
+
62
+ # 转换到LAB颜色空间
63
+ lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB)
64
+
65
+ # 使用四个角落估计背景颜色
66
+ corner_size = min(h, w) // 10
67
+ corners = [
68
+ lab[:corner_size, :corner_size],
69
+ lab[:corner_size, -corner_size:],
70
+ lab[-corner_size:, :corner_size],
71
+ lab[-corner_size:, -corner_size:]
72
+ ]
73
+ corner_pixels = np.vstack([c.reshape(-1, 3) for c in corners])
74
+ bg_color = np.mean(corner_pixels, axis=0)
75
+
76
+ # 计算每个像素与背景的距离
77
+ diff = lab.astype(np.float32) - bg_color
78
+ dist = np.sqrt(np.sum(diff * diff, axis=2))
79
+
80
+ # 使用Otsu阈值分割
81
+ dist_uint8 = np.clip(dist * 3, 0, 255).astype(np.uint8)
82
+ _, mask = cv2.threshold(dist_uint8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
83
+
84
+ # 形态学处理
85
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
86
+ mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel)
87
+ mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel)
88
+
89
+ return mask
90
+
91
+
92
  def detect_reference(
93
  img_bgr: np.ndarray,
94
  mode: str,
95
  ref_size_mm: Optional[float],
96
  ) -> Tuple[float, Optional[Tuple[int, int]], Optional[str], Optional[Tuple[int, int, int, int]]]:
97
+ """检测参考物:左上角第一个物体(简化版)
98
+
99
+ 参数:
100
+ img_bgr: BGR图像
101
+ mode: 参考物模式 ("auto", "coin", "square")
102
+ ref_size_mm: 参考物包围框边长(毫米)
103
+
104
+ 返回:
105
+ px_per_mm: 像素/毫米比例
106
+ ref_center: 参考物中心
107
+ ref_type: 参考物类型
108
+ ref_bbox: 参考物外接矩形
109
  """
110
  h, w = img_bgr.shape[:2]
111
 
112
+ # 使用简单的前景掩码
113
+ mask = build_foreground_mask(img_bgr)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
 
115
+ # 连通域分析
116
  num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask)
117
 
118
+ # 寻找左上角的参考物
119
  candidates = []
120
+ min_area = (h * w) // 500 # 最小面积
121
+ max_area = (h * w) // 20 # 最大面积
122
+
123
+ for i in range(1, num_labels):
124
  x, y, ww, hh, area = stats[i]
125
+
126
+ # 面积过滤
127
+ if area < min_area or area > max_area:
128
  continue
129
+
130
+ # 位置过滤:必须在左上角区域
131
+ if x > w * 0.4 or y > h * 0.4:
132
  continue
133
+
134
+ # 形状过滤:参考物应该接近正方形
135
+ aspect_ratio = max(ww, hh) / (min(ww, hh) + 1e-6)
136
+ if aspect_ratio > 3.0:
137
  continue
138
 
139
+ cx, cy = centroids[i]
140
+ # 按位置排序:越靠近左上角越好
141
+ score = x + y
142
+ candidates.append((score, i, (x, y, ww, hh), area, (int(cx), int(cy))))
 
 
 
 
 
 
 
 
143
 
144
+ if not candidates:
145
+ # 如果没有找到参考物,使用安全的默认值
 
 
 
 
 
 
 
 
 
 
146
  px_per_mm = 4.0
147
  center = None
148
  ref_type = None
149
  bbox = None
150
+ return px_per_mm, center, ref_type, bbox
151
 
152
+ # 选择最左上角的候选物
153
+ candidates.sort(key=lambda c: c[0])
154
+ score, label_idx, bbox, area, center = candidates[0]
155
+
156
+ x, y, ww, hh = bbox
157
+
158
+ # 计算像素/毫米比例
159
+ ref_size = ref_size_mm if ref_size_mm and ref_size_mm > 0 else 25.0
160
+ ref_bbox_size_px = max(ww, hh)
161
+ px_per_mm = ref_bbox_size_px / ref_size
162
+
163
+ return px_per_mm, center, "square", (x, y, ww, hh)
164
 
165
 
166
  # -----------------------------
 
174
  hsv_high_h: int,
175
  color_tol: int,
176
  ) -> np.ndarray:
177
+ """Build binary mask using HSV thresholds."""
 
 
 
 
178
  hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV)
179
  h_channel = hsv[:, :, 0]
180
  s_channel = hsv[:, :, 1]
 
206
  sample_type: str,
207
  hsv_low_h: int,
208
  hsv_high_h: int,
209
+ color_tol: int,
210
  min_area_px: float,
211
  max_area_px: float,
212
  ) -> List[Dict[str, Any]]:
213
  """Segment objects and compute basic geometric descriptors.
214
+ 采用demo.py的简化分割算法,但保留HSV参数兼容性
 
215
  """
216
+ # 使用简单的前景掩码(demo.py方法)
217
+ mask = build_foreground_mask(img_bgr)
218
+
219
+ # 连通域分析
220
+ num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask)
221
+
222
  components: List[Dict[str, Any]] = []
223
+
224
+ # 按位置排序,跳过第一个(通常是参考物)
225
+ all_objects = []
226
+ for i in range(1, num_labels):
227
+ x, y, ww, hh, area = stats[i]
228
+
229
+ # 面积过滤
230
+ if area < min_area_px or area > max_area_px:
231
  continue
232
+
233
+ cx, cy = centroids[i]
234
+ # 简单的位置评分:从左到右
235
+ score = x + y * 0.1 # 优先考虑x坐标
236
+ all_objects.append((score, i, (x, y, ww, hh), area, (int(cx), int(cy))))
237
+
238
+ if len(all_objects) == 0:
239
+ return []
240
+
241
+ # 排序并跳过第一个(参考物)
242
+ all_objects.sort(key=lambda obj: obj[0])
243
+
244
+ # 简单判断是否跳过第一个对象
245
+ skip_first = False
246
+ if len(all_objects) > 0:
247
+ _, _, (x, y, ww, hh), area, _ = all_objects[0]
248
+ h, w = img_bgr.shape[:2]
249
+
250
+ # 如果第一个对象在左上角且形状合理,跳过它
251
+ is_topleft = (x < w * 0.3 and y < h * 0.3)
252
+ aspect_ratio = max(ww, hh) / (min(ww, hh) + 1e-6)
253
+ is_reasonable_shape = aspect_ratio < 3.0
254
+
255
+ skip_first = is_topleft and is_reasonable_shape
256
+
257
+ # 处理对象
258
+ start_idx = 1 if skip_first else 0
259
+ for obj_data in all_objects[start_idx:]:
260
+ _, label_idx, bbox, area, center = obj_data
261
+
262
+ # 提取轮廓
263
+ component_mask = (labels == label_idx).astype(np.uint8) * 255
264
+ cnts, _ = cv2.findContours(component_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
265
+
266
+ if len(cnts) == 0:
267
+ continue
268
+
269
+ cnt = cnts[0]
270
+
271
+ # 计算几何特征
272
  rect = cv2.minAreaRect(cnt)
273
  box = cv2.boxPoints(rect).astype(np.int32)
274
+
 
 
 
 
 
 
275
  peri = cv2.arcLength(cnt, True)
276
+
277
+ # 修复OpenCV minAreaRect的长短轴对应问题(使用PCA)
278
+ # 提取轮廓点
279
+ contour_points = cnt.reshape(-1, 2).astype(np.float32)
280
+
281
+ # 计算质心
282
+ cx = np.mean(contour_points[:, 0])
283
+ cy = np.mean(contour_points[:, 1])
284
+
285
+ # 计算协方差矩阵
286
+ centered_points = contour_points - np.array([cx, cy])
287
+ cov_matrix = np.cov(centered_points.T)
288
+
289
+ # 计算特征值和特征向量
290
+ eigenvalues, eigenvectors = np.linalg.eigh(cov_matrix)
291
+
292
+ # 按特征值大小排序(降序)
293
+ idx = np.argsort(eigenvalues)[::-1]
294
+ eigenvalues = eigenvalues[idx]
295
+ eigenvectors = eigenvectors[:, idx]
296
+
297
+ # 主方向(最大特征值对应的特征向量)
298
+ main_direction = eigenvectors[:, 0]
299
+
300
+ # 投影到主方向和次方向
301
+ proj_main = np.dot(centered_points, main_direction)
302
+ proj_secondary = np.dot(centered_points, eigenvectors[:, 1])
303
+
304
+ # 计算投影边界
305
+ min_main = np.min(proj_main)
306
+ max_main = np.max(proj_main)
307
+ min_secondary = np.min(proj_secondary)
308
+ max_secondary = np.max(proj_secondary)
309
+
310
+ # 计算真实的长短轴长度
311
+ length_main = max_main - min_main
312
+ length_secondary = max_secondary - min_secondary
313
+
314
+ # 确保长轴对应较长的方向,并保存正确的投影边界
315
+ if length_main >= length_secondary:
316
+ w_obb = length_main
317
+ h_obb = length_secondary
318
+ angle = np.arctan2(main_direction[1], main_direction[0]) * 180.0 / np.pi
319
+ # 长轴是主方向
320
+ long_direction = main_direction
321
+ short_direction = eigenvectors[:, 1]
322
+ min_long_proj = min_main
323
+ max_long_proj = max_main
324
+ min_short_proj = min_secondary
325
+ max_short_proj = max_secondary
326
+ else:
327
+ w_obb = length_secondary
328
+ h_obb = length_main
329
+ secondary_direction = eigenvectors[:, 1]
330
+ angle = np.arctan2(secondary_direction[1], secondary_direction[0]) * 180.0 / np.pi
331
+ # 长轴是次方向
332
+ long_direction = eigenvectors[:, 1]
333
+ short_direction = main_direction
334
+ min_long_proj = min_secondary
335
+ max_long_proj = max_secondary
336
+ min_short_proj = min_main
337
+ max_short_proj = max_main
338
+
339
+ # 标准化角度到[0, 180)
340
+ angle = ((angle % 180.0) + 180.0) % 180.0
341
+
342
+ components.append({
343
+ "contour": cnt,
344
+ "rect": rect,
345
+ "box": box,
346
+ "area_px": float(area),
347
+ "peri_px": float(peri),
348
+ "center": (int(cx), int(cy)), # 使用PCA计算的质心
349
+ "pca_center": (cx, cy), # 保存精确的PCA质心
350
+ "angle": float(angle),
351
+ "length_px": float(w_obb),
352
+ "width_px": float(h_obb),
353
+ # 保存投影边界信息用于正确的包围框绘制
354
+ "min_long_proj": float(min_long_proj),
355
+ "max_long_proj": float(max_long_proj),
356
+ "min_short_proj": float(min_short_proj),
357
+ "max_short_proj": float(max_short_proj),
358
+ })
359
+
360
  return components
361
 
362
 
 
384
  rows: List[Dict[str, Any]] = []
385
 
386
  for i, comp in enumerate(components, start=1):
387
+ # 使用新的length_px和width_px字段
388
+ length_mm = comp["length_px"] / px_per_mm
389
+ width_mm = comp["width_px"] / px_per_mm
 
 
390
  area_mm2 = comp["area_px"] / (px_per_mm * px_per_mm)
391
  perimeter_mm = comp["peri_px"] / px_per_mm
392
 
393
  aspect_ratio = length_mm / (width_mm + 1e-6)
394
+
395
+ # 计算圆形度 (4π*面积/周长²)
396
  circularity = (4.0 * np.pi * area_mm2) / (perimeter_mm * perimeter_mm + 1e-6)
397
 
398
+ # 计算颜色指标
399
  mask_single = np.zeros(img_bgr.shape[:2], dtype=np.uint8)
400
  cv2.drawContours(mask_single, [comp["contour"]], -1, 255, thickness=-1)
401
  mean_r, mean_g, mean_b, h, s, v, gi, bi = compute_color_metrics(img_bgr, mask_single)
 
436
  df: pd.DataFrame,
437
  ref_bbox: Optional[Tuple[int, int, int, int]] = None,
438
  ) -> np.ndarray:
439
+ """Draw reference + sample annotations on the image.
440
+ 采用demo.py的清晰可视化方法
441
+ """
442
  out = img_bgr.copy()
443
 
444
+ # 绘制参考物(红色矩形框)
445
  ref_center, ref_type = ref
446
  if ref_bbox is not None:
447
  x, y, w, h = ref_bbox
 
448
  cv2.rectangle(out, (int(x), int(y)), (int(x + w), int(y + h)), (0, 0, 255), 2)
449
  cv2.putText(
450
  out,
 
457
  cv2.LINE_AA,
458
  )
459
 
460
+ # 绘制样品物体(完整标注)
461
  for i, comp in enumerate(components, start=1):
462
+ # 1. 绘制完整轮廓(蓝色,加粗)
463
+ cv2.drawContours(out, [comp["contour"]], -1, (255, 0, 0), 3)
464
+
465
+ # 2. 绘制修正后的OBB包围框
466
+ # 使用PCA计算的精确质心
467
+ cx, cy = comp["pca_center"]
468
+ length_px = comp["length_px"] # 长轴长度
469
+ width_px = comp["width_px"] # 短轴长度
470
+ angle_deg = comp["angle"] # 长轴角度(度)
471
+
472
+ # 转换为弧度
473
+ angle_rad = np.radians(angle_deg)
474
+
475
+ # 使用实际的投影边界构建包围框
476
+ corners = []
477
+
478
+ # 获取保存的投影边界
479
+ min_long_proj = comp["min_long_proj"]
480
+ max_long_proj = comp["max_long_proj"]
481
+ min_short_proj = comp["min_short_proj"]
482
+ max_short_proj = comp["max_short_proj"]
483
+
484
+ # 获取长轴和短轴方向向量
485
+ long_dir = np.array([np.cos(angle_rad), np.sin(angle_rad)])
486
+ short_dir = np.array([-np.sin(angle_rad), np.cos(angle_rad)])
487
+
488
+ # 使用实际投影边界构建包围框的四个角点
489
+ for long_proj, short_proj in [(max_long_proj, max_short_proj), # 右上
490
+ (min_long_proj, max_short_proj), # 左上
491
+ (min_long_proj, min_short_proj), # 左下
492
+ (max_long_proj, min_short_proj)]: # 右下
493
+ # 从质心出发,沿长轴和短轴方向移动到角点
494
+ corner_point = np.array([cx, cy]) + long_proj * long_dir + short_proj * short_dir
495
+ corners.append([int(corner_point[0]), int(corner_point[1])])
496
+
497
+ # 绘制OBB包围框
498
+ corners = np.array(corners, dtype=np.int32)
499
+ cv2.drawContours(out, [corners], -1, (255, 0, 0), 2)
500
+
501
+ # 3. 绘制长短轴(包围框的边界线)
502
+ # 计算包围框各边的中点
503
+ edge_mids = []
504
+ for edge_idx in range(4):
505
+ next_edge_idx = (edge_idx + 1) % 4
506
+ mid_x = (corners[edge_idx][0] + corners[next_edge_idx][0]) / 2
507
+ mid_y = (corners[edge_idx][1] + corners[next_edge_idx][1]) / 2
508
+ edge_mids.append((int(mid_x), int(mid_y)))
509
+
510
+ # 计算各边的长度来确定哪条是长边
511
+ edge_lengths = []
512
+ for edge_idx in range(4):
513
+ next_edge_idx = (edge_idx + 1) % 4
514
+ length = np.sqrt((corners[next_edge_idx][0] - corners[edge_idx][0])**2 + (corners[next_edge_idx][1] - corners[edge_idx][1])**2)
515
+ edge_lengths.append(length)
516
+
517
+ # 找到最长的边
518
+ max_edge_idx = np.argmax(edge_lengths)
519
+ opposite_edge_idx = (max_edge_idx + 2) % 4
520
+
521
+ # 绘制长轴(连接最长边的中点和对边中点)
522
+ long_mid1 = edge_mids[max_edge_idx]
523
+ long_mid2 = edge_mids[opposite_edge_idx]
524
+ cv2.line(out, long_mid1, long_mid2, (255, 0, 0), 3)
525
+
526
+ # 绘制短轴(连接另外两边的中点)
527
+ short_edge1_idx = (max_edge_idx + 1) % 4
528
+ short_edge2_idx = (max_edge_idx + 3) % 4
529
+ short_mid1 = edge_mids[short_edge1_idx]
530
+ short_mid2 = edge_mids[short_edge2_idx]
531
+ cv2.line(out, short_mid1, short_mid2, (255, 0, 0), 2)
532
+
533
+ # 4. 绘制中心点和标签
534
+ # 使用PCA计算的精确质心
535
+ label_cx, label_cy = comp["pca_center"]
536
+ cv2.circle(out, (int(label_cx), int(label_cy)), 15, (0, 0, 0), -1)
537
  cv2.putText(
538
  out,
539
  f"s{i}",
540
+ (int(label_cx) - 10, int(label_cy) + 5),
541
  cv2.FONT_HERSHEY_SIMPLEX,
542
  0.5,
543
  (255, 255, 255),
544
+ 2,
545
  cv2.LINE_AA,
546
  )
547
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
548
  return cv2.cvtColor(out, cv2.COLOR_BGR2RGB)
549
 
550
 
 
560
  hsv_low_h: int,
561
  hsv_high_h: int,
562
  ) -> Tuple[Optional[np.ndarray], pd.DataFrame, Optional[str], List[Dict[str, Any]], Dict[str, Any]]:
563
+ """主分析函数,整合demo.py的优化算法"""
564
  try:
565
  if image is None:
566
  return None, pd.DataFrame(), None, [], {}
567
+
568
+ # 转换为BGR
569
  img_rgb = np.array(image)
570
  img_bgr = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2BGR)
571
+
572
+ # 适度降采样
573
  img_bgr, scale = downscale_bgr(img_bgr)
574
+
575
+ # 检测参考物(左上角第一个物体)
576
  px_per_mm, ref_center, ref_type, ref_bbox = detect_reference(img_bgr, ref_mode, ref_size_mm)
577
+
578
+ # 分割所有样品
579
+ comps = segment(
580
+ img_bgr,
581
+ sample_type=sample_type,
582
+ hsv_low_h=hsv_low_h,
583
+ hsv_high_h=hsv_high_h,
584
+ color_tol=color_tol,
585
+ min_area_px=min_area_px,
586
+ max_area_px=max_area_px,
587
+ )
588
+
589
+ # 根据样品类型排序
590
  if sample_type == "leaves":
591
  comps.sort(key=lambda c: c["center"][0])
592
  else:
593
  comps.sort(key=lambda c: c["center"][1] * 0.3 + c["center"][0] * 0.7)
594
+
595
+ # 限制数量
596
  if expected_count and expected_count > 0:
597
  comps = comps[:int(expected_count)]
598
+
599
+ # 计算测量指标
600
  df = compute_metrics(img_bgr, comps, px_per_mm)
601
+
602
+ # 绘制标注图像
603
+ overlay = render_overlay(
604
+ img_bgr.copy(),
605
+ px_per_mm,
606
+ (ref_center, ref_type),
607
+ comps,
608
+ df,
609
+ ref_bbox
610
+ )
611
+
612
+ # 保存CSV
613
  csv = df.to_csv(index=False)
614
  tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".csv")
615
  tmp.write(csv.encode("utf-8"))
616
  tmp.close()
617
+
618
+ # 转换为JSON
619
  js = df.to_dict(orient="records")
620
+
621
+ # 存储状态用于交互修正
622
  state_dict: Dict[str, Any] = {
623
  "img_bgr": img_bgr,
624
  "sample_type": sample_type,
 
630
  "expected_count": expected_count,
631
  "ref_size_mm": ref_size_mm,
632
  }
633
+ # 默认所有组件都是活跃样品
634
  state_dict["active_indices"] = list(range(len(comps)))
635
 
636
  return overlay, df, tmp.name, js, state_dict
 
646
  ) -> Tuple[Dict[str, Any], Optional[np.ndarray], pd.DataFrame, Optional[str], List[Dict[str, Any]]]:
647
  """
648
  Apply interactive corrections based on a click on the annotated image.
 
649
  correction_mode:
650
  - "none": do nothing
651
  - "set-ref": treat the clicked object as the new reference
 
783
  table = gr.Dataframe(label="Metrics", wrap=True)
784
  csv_out = gr.File(label="CSV export")
785
  json_out = gr.JSON(label="JSON preview")
786
+
787
  def _analyze(image, sample_type, expected, ref_mode, ref_size, min_area, max_area, color_tol, hsv_low, hsv_high):
788
  overlay_img, df, csv_path, js, state_dict = analyze(
789
  image, sample_type, expected, ref_mode, ref_size, min_area, max_area, color_tol, hsv_low, hsv_high