crabbly commited on
Commit
1665d67
·
verified ·
1 Parent(s): 652f50a

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +172 -133
main.py CHANGED
@@ -160,13 +160,15 @@ class WatermelonProcessor:
160
  @staticmethod
161
  def contour_centroid(contour):
162
  M = cv2.moments(contour)
163
- if M["m00"] == 0: return None
 
164
  return np.array([M["m10"] / M["m00"], M["m01"] / M["m00"]], dtype=np.float32)
165
 
166
  @staticmethod
167
  def mask_centroid(mask):
168
  M = cv2.moments(mask)
169
- if M["m00"] == 0: return None
 
170
  return np.array([M["m10"] / M["m00"], M["m01"] / M["m00"]], dtype=np.float32)
171
 
172
  @staticmethod
@@ -178,7 +180,8 @@ class WatermelonProcessor:
178
  @staticmethod
179
  def flesh_envelope_mask(flesh_combined):
180
  ys, xs = np.where(flesh_combined > 0)
181
- if len(xs) < MIN_FLESH_PIXELS_FOR_FALLBACK: return None
 
182
 
183
  pts = np.column_stack([xs, ys]).astype(np.int32)
184
  hull = cv2.convexHull(pts.reshape(-1, 1, 2))
@@ -196,7 +199,7 @@ class WatermelonProcessor:
196
 
197
  @staticmethod
198
  def choose_target_rind_mask(rind_mask, flesh_combined):
199
- warnings =[]
200
  flesh_area = cv2.countNonZero(flesh_combined)
201
  cnts, _ = cv2.findContours(rind_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
202
 
@@ -206,7 +209,7 @@ class WatermelonProcessor:
206
  return rind_mask, "missing", None, ["No whole-watermelon mask and not enough flesh mask for fallback."]
207
  return envelope, "flesh_envelope", 1.0, ["No whole-watermelon mask; estimated perimeter from flesh masks."]
208
 
209
- scored =[]
210
  for cnt in cnts:
211
  temp = WatermelonProcessor.draw_single_contour(rind_mask.shape, cnt)
212
  overlap = cv2.countNonZero(cv2.bitwise_and(temp, flesh_combined))
@@ -272,23 +275,32 @@ class WatermelonProcessor:
272
 
273
  @staticmethod
274
  def split_asymmetry(region_mask, midline, thickness=5):
275
- if midline is None or len(midline) < 2 or cv2.countNonZero(region_mask) == 0: return None
 
 
276
  split_mask = region_mask.copy()
277
  cv2.polylines(split_mask, [midline.astype(np.int32)], False, 0, thickness)
278
  n_labels, _, stats, _ = cv2.connectedComponentsWithStats((split_mask > 0).astype(np.uint8), connectivity=8)
279
- if n_labels <= 2: return None
 
280
 
281
  areas = sorted([int(stats[i, cv2.CC_STAT_AREA]) for i in range(1, n_labels)], reverse=True)
282
- if len(areas) < 2 or areas[0] + areas[1] == 0: return None
 
 
283
  return float(abs(areas[0] - areas[1]) / (areas[0] + areas[1]))
284
 
285
  @staticmethod
286
  def midline_curvature_score(midline):
287
- if midline is None or len(midline) < 3: return None
 
 
288
  diffs = np.diff(midline.astype(np.float32), axis=0)
289
  path_len = float(np.sum(np.linalg.norm(diffs, axis=1)))
290
  chord_len = float(np.linalg.norm(midline[-1] - midline[0]))
291
- if chord_len <= 1e-6: return None
 
 
292
  return float(max(0.0, (path_len / chord_len) - 1.0))
293
 
294
  @staticmethod
@@ -325,61 +337,56 @@ class WatermelonProcessor:
325
  return (bins[:-1] + bins[1:])/2.0, median_filter(raw_r, size=7, mode="wrap"), (cx, cy), best_cnt
326
 
327
  @staticmethod
328
- def get_ray_scan_midline(flesh_mask, rind_cnt, predicted_cnt, cx, cy):
329
- h, w = flesh_mask.shape
330
- if len(rind_cnt) > 5:
331
- _, (ma, Ma), angle = cv2.fitEllipse(rind_cnt)
332
- rot_angle = angle if ma < Ma else angle + 90
333
- else: rot_angle = 0
334
 
335
  m_rot = cv2.getRotationMatrix2D((cx, cy), rot_angle, 1.0)
336
  m_inv = cv2.getRotationMatrix2D((cx, cy), -rot_angle, 1.0)
337
- f_rot = cv2.warpAffine(flesh_mask, m_rot, (w, h))
 
 
 
 
 
338
 
339
  gap_points =[]
340
- y_indices, _ = np.where(f_rot > 0)
341
- if len(y_indices) > 0:
342
- y_min, y_max = np.min(y_indices), np.max(y_indices)
343
- for y in range(y_min, y_max):
344
- row = f_rot[y, :]
345
- white_px = np.where(row > 0)[0]
346
- if len(white_px) >= 2:
347
- first, last = white_px[0], white_px[-1]
348
- blanks_in_between = np.where(row[first:last] == 0)[0] + first
349
- if len(blanks_in_between) > 0:
350
- gap_points.append([y, np.median(blanks_in_between)])
351
 
352
  gap_points = np.array(gap_points)
353
  if len(gap_points) > 10:
354
- y_min, y_max = np.min(gap_points[:, 0]), np.max(gap_points[:, 0])
355
- y_span, y_mean = max(y_max - y_min, 1), (y_max + y_min) / 2.0
356
- y_norm, x_data = (gap_points[:, 0] - y_mean) / y_span, gap_points[:, 1]
357
-
358
- def parabola(y_n, a, b, c): return a * (y_n**2) + b * y_n + c
359
-
360
  max_bend = w * 0.08
361
  try:
362
- popt_mid, _ = curve_fit(parabola, y_norm, x_data, bounds=([-max_bend, -np.inf, -np.inf], [max_bend, np.inf, np.inf]))
363
- except Exception:
364
- popt_mid = [0.0, 0.0, cx]
365
-
 
 
 
 
 
366
  ys_extrap = np.linspace(0, h, 500)
367
- xs_extrap = parabola((ys_extrap - y_mean) / y_span, *popt_mid)
368
- pts_rot = np.vstack([xs_extrap, ys_extrap, np.ones_like(xs_extrap)])
369
  else:
370
  ys_extrap = np.linspace(0, h, 500)
371
- xs_extrap = np.full_like(ys_extrap, cx)
372
- pts_rot = np.vstack([xs_extrap, ys_extrap, np.ones_like(xs_extrap)])
373
 
374
  pts_orig = (m_inv @ pts_rot).T
375
- pred_cnt_cv = predicted_cnt.reshape(-1, 1, 2).astype(np.int32)
376
- final_line =[]
377
- for pt in pts_orig:
378
- if cv2.pointPolygonTest(pred_cnt_cv, (float(pt[0]), float(pt[1])), False) >= 0:
379
- final_line.append(pt)
380
- return np.array(final_line)
381
-
382
- def process_image(self, image: np.ndarray, source_name: str, scale_ratio: float, apply_smoothing: bool = True) -> ProcessResult:
383
  timings = {}
384
  stage_t = time.perf_counter()
385
 
@@ -399,8 +406,10 @@ class WatermelonProcessor:
399
  **extra,
400
  )
401
 
402
- warnings =[]
403
- if image is None: return ProcessResult(success=False, message="Could not decode image.", filename=source_name)
 
 
404
  h, w = image.shape[:2]
405
 
406
  # --- 1. CALIBRATION & SCALING ---
@@ -422,12 +431,14 @@ class WatermelonProcessor:
422
  tgt24_corr = sample_24_patches(tgt_warped_corr)
423
  dE_final = float(np.mean(compute_deltaE_00(tgt24_corr, self.ref24)))
424
  except Exception as e:
425
- if cm_per_px is None: warnings.append("ColorChecker not found; dimensions are returned in original-image pixels.")
426
- else: warnings.append("Color correction skipped after ColorChecker detection; dimensions are still in centimeters.")
 
 
427
  print(f"Calibration skipped for {source_name}: {e}")
428
  mark("calibration")
429
 
430
- # --- 2. YOLO INFERENCE ---
431
  results = self.model(image, conf=0.25, retina_masks=True, verbose=False)
432
  mark("yolo_inference")
433
 
@@ -436,29 +447,38 @@ class WatermelonProcessor:
436
  flesh_r_contours = []
437
 
438
  if results[0].masks is None:
439
- return fail("No masks detected.", measurement_unit="cm" if cm_per_px is not None else "px",
440
- scale_source="color_checker" if cm_per_px is not None else "original_pixels",
441
- color_checker_found=checker_corners is not None)
 
 
 
442
 
443
  for mask_data, cls in zip(results[0].masks.xy, results[0].boxes.cls):
444
  contour = np.array(mask_data, dtype=np.int32)
445
  c_id = int(cls)
446
- if c_id == 0: cv2.drawContours(rind_mask, [contour], -1, 255, -1)
447
- elif c_id == 1: flesh_l_contours.append(contour)
448
- elif c_id == 2: flesh_r_contours.append(contour)
449
-
 
 
 
 
450
  if len(flesh_l_contours) >= 2 and len(flesh_r_contours) == 0:
451
  flesh_l_contours.sort(key=lambda cnt: cv2.moments(cnt)["m10"] / (cv2.moments(cnt)["m00"] + 1e-5))
452
  flesh_r_contours.append(flesh_l_contours.pop())
453
- warnings.append("Only flesh_left detected; split by x-position.")
454
  elif len(flesh_r_contours) >= 2 and len(flesh_l_contours) == 0:
455
  flesh_r_contours.sort(key=lambda cnt: cv2.moments(cnt)["m10"] / (cv2.moments(cnt)["m00"] + 1e-5))
456
  flesh_l_contours.append(flesh_r_contours.pop(0))
457
- warnings.append("Only flesh_right detected; split by x-position.")
458
 
459
  flesh_l_m, flesh_r_m = np.zeros((h, w), dtype=np.uint8), np.zeros((h, w), dtype=np.uint8)
460
- for cnt in flesh_l_contours: cv2.drawContours(flesh_l_m, [cnt], -1, 255, -1)
461
- for cnt in flesh_r_contours: cv2.drawContours(flesh_r_m, [cnt], -1, 255, -1)
 
 
462
 
463
  flesh_combined = cv2.bitwise_or(flesh_l_m, flesh_r_m)
464
  target_rind_mask, rind_source, rind_overlap_ratio, rind_warnings = self.choose_target_rind_mask(rind_mask, flesh_combined)
@@ -468,16 +488,21 @@ class WatermelonProcessor:
468
  # --- 3. FIT & EXTRACTION ---
469
  perimeter_data = self.get_stable_perimeter_data(target_rind_mask, flesh_combined)
470
  if perimeter_data is None:
471
- return fail("No stable perimeter.", measurement_unit="cm" if cm_per_px is not None else "px",
472
- scale_source="color_checker" if cm_per_px is not None else "original_pixels",
473
- color_checker_found=checker_corners is not None, rind_source=rind_source, rind_overlap_ratio=rind_overlap_ratio)
 
 
 
 
 
474
 
475
  t_data, r_raw, (cx, cy), rind_cnt = perimeter_data
476
-
477
- # SMOOTHING TOGGLE LOGIC
 
 
478
  if apply_smoothing:
479
- scale = np.mean(r_raw)
480
- if scale <= 0: return fail("Invalid perimeter scale.", rind_source=rind_source, rind_overlap_ratio=rind_overlap_ratio)
481
  try:
482
  popt, _ = curve_fit(
483
  self.watermelon_model, t_data, r_raw / scale,
@@ -489,15 +514,13 @@ class WatermelonProcessor:
489
  mark("fit")
490
  return fail(f"Fit failed: {exc}", rind_source=rind_source, rind_overlap_ratio=rind_overlap_ratio)
491
 
492
- denom = np.sum((r_raw / scale - 1) ** 2)
493
- r2 = 1 - (np.sum((r_raw / scale - self.watermelon_model(t_data, *popt)) ** 2) / denom) if denom != 0 else None
494
-
495
  t_fit = np.linspace(-np.pi, np.pi, 500)
496
  r_fit = self.watermelon_model(t_fit, *popt) * scale
497
- fit_pts = np.array([[r * np.cos(t) + cx, cy - r * np.sin(t)] for t, r in zip(t_fit, r_fit)], dtype=np.float32)
498
  else:
499
- # If Smoothing is OFF, bypass math and use the raw OpenCV contour
500
  r2 = None
 
501
  fit_pts = rind_cnt.reshape(-1, 2).astype(np.float32)
502
 
503
  width_px = float(np.max(fit_pts[:, 0]) - np.min(fit_pts[:, 0]))
@@ -508,79 +531,85 @@ class WatermelonProcessor:
508
  flesh_area_ratio = float(flesh_area_px / total_area_px) if total_area_px > 0 else None
509
  elongation_factor = self.elongation_from_points(fit_pts)
510
  circularity = float((4.0 * np.pi * total_area_px) / (perimeter_px ** 2)) if perimeter_px > 0 and total_area_px > 0 else None
511
-
512
- midline = self.get_ray_scan_midline(flesh_combined, rind_cnt, fit_pts, cx, cy)
513
-
514
  asymmetry_score = self.split_asymmetry(target_rind_mask, midline)
515
  flesh_asymmetry_score = self.split_asymmetry(flesh_combined, midline, thickness=3)
516
  midline_curvature = self.midline_curvature_score(midline)
517
 
518
  if cm_per_px is not None:
519
- measurement_unit, area_unit, scale_source = "cm", "cm2", "color_checker"
 
 
520
  area_scale = cm_per_px ** 2
521
- width_val, height_val, perimeter_val = float(width_px * cm_per_px), float(height_px * cm_per_px), float(perimeter_px * cm_per_px)
 
 
522
  else:
523
- measurement_unit, area_unit, scale_source = "px", "px2", "original_pixels"
 
 
524
  orig_scale = 1.0 / scale_ratio
525
  area_scale = orig_scale ** 2
526
- width_val, height_val, perimeter_val = float(width_px * orig_scale), float(height_px * orig_scale), float(perimeter_px * orig_scale)
527
-
528
- total_area, flesh_area = float(total_area_px * area_scale), float(flesh_area_px * area_scale)
 
 
529
  mark("fit")
530
 
531
  # --- 4. DRAWING ---
532
  img_base64 = None
533
-
534
- output = image.copy().astype(np.float32)
535
- alpha = 0.42
536
- output[..., 0] = np.where(target_rind_mask > 0, output[..., 0] * (1 - alpha) + 0.0 * alpha, output[..., 0])
537
- output[..., 1] = np.where(target_rind_mask > 0, output[..., 1] * (1 - alpha) + 170.0 * alpha, output[..., 1])
538
- output[..., 2] = np.where(target_rind_mask > 0, output[..., 2] * (1 - alpha) + 0.0 * alpha, output[..., 2])
539
-
540
- output[..., 0] = np.where(flesh_l_m > 0, output[..., 0] * (1 - alpha) + 255.0 * alpha, output[..., 0])
541
- output[..., 1] = np.where(flesh_l_m > 0, output[..., 1] * (1 - alpha) + 0.0 * alpha, output[..., 1])
542
- output[..., 2] = np.where(flesh_l_m > 0, output[..., 2] * (1 - alpha) + 0.0 * alpha, output[..., 2])
543
-
544
- output[..., 0] = np.where(flesh_r_m > 0, output[..., 0] * (1 - alpha) + 0.0 * alpha, output[..., 0])
545
- output[..., 1] = np.where(flesh_r_m > 0, output[..., 1] * (1 - alpha) + 0.0 * alpha, output[..., 1])
546
- output[..., 2] = np.where(flesh_r_m > 0, output[..., 2] * (1 - alpha) + 255.0 * alpha, output[..., 2])
547
-
548
- output = np.clip(output, 0, 255).astype(np.uint8)
549
-
550
- if checker_corners is not None:
551
- cv2.polylines(output, [np.int32(checker_corners)], True, (0, 165, 255), 4)
552
-
553
- if len(midline) > 1:
554
- cv2.polylines(output, [midline.astype(np.int32)], False, (0, 255, 255), 3)
555
-
556
- cv2.polylines(output, [fit_pts.astype(np.int32)], True, (0, 255, 0), 3)
557
-
558
- stem = stem_tip_tangent_deg(rind_cnt, (cx, cy))
559
- if stem is not None:
560
- tx, ty, tdeg = stem
561
- L = min(w, h) * 0.08
562
- p1 = (int(round(tx)), int(round(ty)))
563
- p2 = (int(round(tx + L * np.cos(np.deg2rad(tdeg)))), int(round(ty + L * np.sin(np.deg2rad(tdeg)))))
564
- cv2.circle(output, p1, 6, (255, 0, 255), -1)
565
- cv2.line(output, p1, p2, (255, 0, 255), 2)
566
-
567
- _, buffer = cv2.imencode(".jpg", output, [cv2.IMWRITE_JPEG_QUALITY, 85])
568
- img_base64 = base64.b64encode(buffer).decode("utf-8")
569
  mark("render")
570
 
571
  return ProcessResult(
572
- success=True, message="Success", r2_score=r2,
573
  width_val=width_val, height_val=height_val, perimeter_val=perimeter_val,
574
  total_area=total_area, flesh_area=flesh_area, flesh_area_ratio=flesh_area_ratio,
575
  elongation_factor=elongation_factor, circularity=circularity,
576
  asymmetry_score=asymmetry_score, flesh_asymmetry_score=flesh_asymmetry_score,
577
- midline_curvature=midline_curvature, delta_e_initial=dE_initial, delta_e_final=dE_final,
578
- image_base64=img_base64, filename=source_name, measurement_unit=measurement_unit, area_unit=area_unit,
579
- scale_source=scale_source, color_checker_found=checker_corners is not None,
 
 
580
  rind_source=rind_source, rind_overlap_ratio=rind_overlap_ratio,
581
  warnings=warnings or None, timings_ms=timings
582
  )
583
 
 
584
  app = FastAPI()
585
 
586
  app.add_middleware(
@@ -595,16 +624,21 @@ def read_root(): return {"status": "Watermelon API is awake and running!"}
595
  @app.post("/process_single")
596
  async def process_single(file: UploadFile = File(...), include_image: bool = Query(True), apply_smoothing: bool = Query(True)):
597
  request_t = time.perf_counter()
598
- contents, img = None, None
 
599
 
600
  try:
601
  contents = await file.read()
602
  if not contents:
603
- return ProcessResult(success=False, message="Empty upload.", filename=file.filename, processing_ms=int(round((time.perf_counter() - request_t) * 1000))).__dict__
 
 
604
 
605
  img = cv2.imdecode(np.frombuffer(contents, np.uint8), cv2.IMREAD_COLOR)
606
  if img is None:
607
- return ProcessResult(success=False, message="Could not decode image.", filename=file.filename, processing_ms=int(round((time.perf_counter() - request_t) * 1000))).__dict__
 
 
608
 
609
  scale_ratio = 1.0
610
  h, w = img.shape[:2]
@@ -612,14 +646,19 @@ async def process_single(file: UploadFile = File(...), include_image: bool = Que
612
  scale_ratio = MAX_IMAGE_SIZE / float(max(h, w))
613
  img = cv2.resize(img, (int(w * scale_ratio), int(h * scale_ratio)), interpolation=cv2.INTER_AREA)
614
 
615
- # PASS THE TOGGLE INTO THE PROCESSOR
616
- res = processor.process_image(img, file.filename, scale_ratio, apply_smoothing=apply_smoothing)
617
  res.processing_ms = int(round((time.perf_counter() - request_t) * 1000))
618
  return res.__dict__
619
 
620
  except Exception as exc:
621
  traceback.print_exc()
622
- return ProcessResult(success=False, message=f"Server error: {type(exc).__name__}: {exc}", filename=file.filename, processing_ms=int(round((time.perf_counter() - request_t) * 1000))).__dict__
 
 
 
 
 
 
623
 
624
  finally:
625
  del img, contents
 
160
  @staticmethod
161
  def contour_centroid(contour):
162
  M = cv2.moments(contour)
163
+ if M["m00"] == 0:
164
+ return None
165
  return np.array([M["m10"] / M["m00"], M["m01"] / M["m00"]], dtype=np.float32)
166
 
167
  @staticmethod
168
  def mask_centroid(mask):
169
  M = cv2.moments(mask)
170
+ if M["m00"] == 0:
171
+ return None
172
  return np.array([M["m10"] / M["m00"], M["m01"] / M["m00"]], dtype=np.float32)
173
 
174
  @staticmethod
 
180
  @staticmethod
181
  def flesh_envelope_mask(flesh_combined):
182
  ys, xs = np.where(flesh_combined > 0)
183
+ if len(xs) < MIN_FLESH_PIXELS_FOR_FALLBACK:
184
+ return None
185
 
186
  pts = np.column_stack([xs, ys]).astype(np.int32)
187
  hull = cv2.convexHull(pts.reshape(-1, 1, 2))
 
199
 
200
  @staticmethod
201
  def choose_target_rind_mask(rind_mask, flesh_combined):
202
+ warnings = []
203
  flesh_area = cv2.countNonZero(flesh_combined)
204
  cnts, _ = cv2.findContours(rind_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
205
 
 
209
  return rind_mask, "missing", None, ["No whole-watermelon mask and not enough flesh mask for fallback."]
210
  return envelope, "flesh_envelope", 1.0, ["No whole-watermelon mask; estimated perimeter from flesh masks."]
211
 
212
+ scored = []
213
  for cnt in cnts:
214
  temp = WatermelonProcessor.draw_single_contour(rind_mask.shape, cnt)
215
  overlap = cv2.countNonZero(cv2.bitwise_and(temp, flesh_combined))
 
275
 
276
  @staticmethod
277
  def split_asymmetry(region_mask, midline, thickness=5):
278
+ if midline is None or len(midline) < 2 or cv2.countNonZero(region_mask) == 0:
279
+ return None
280
+
281
  split_mask = region_mask.copy()
282
  cv2.polylines(split_mask, [midline.astype(np.int32)], False, 0, thickness)
283
  n_labels, _, stats, _ = cv2.connectedComponentsWithStats((split_mask > 0).astype(np.uint8), connectivity=8)
284
+ if n_labels <= 2:
285
+ return None
286
 
287
  areas = sorted([int(stats[i, cv2.CC_STAT_AREA]) for i in range(1, n_labels)], reverse=True)
288
+ if len(areas) < 2 or areas[0] + areas[1] == 0:
289
+ return None
290
+
291
  return float(abs(areas[0] - areas[1]) / (areas[0] + areas[1]))
292
 
293
  @staticmethod
294
  def midline_curvature_score(midline):
295
+ if midline is None or len(midline) < 3:
296
+ return None
297
+
298
  diffs = np.diff(midline.astype(np.float32), axis=0)
299
  path_len = float(np.sum(np.linalg.norm(diffs, axis=1)))
300
  chord_len = float(np.linalg.norm(midline[-1] - midline[0]))
301
+ if chord_len <= 1e-6:
302
+ return None
303
+
304
  return float(max(0.0, (path_len / chord_len) - 1.0))
305
 
306
  @staticmethod
 
337
  return (bins[:-1] + bins[1:])/2.0, median_filter(raw_r, size=7, mode="wrap"), (cx, cy), best_cnt
338
 
339
  @staticmethod
340
+ def get_dual_mask_midline(f_left, f_right, rind_cnt, pred_cnt, cx, cy):
341
+ h, w = f_left.shape
342
+ _, (ma, Ma), angle = cv2.fitEllipse(rind_cnt) if len(rind_cnt) > 5 else (None, (0,0), 0)
343
+ rot_angle = angle if ma < Ma else angle + 90
 
 
344
 
345
  m_rot = cv2.getRotationMatrix2D((cx, cy), rot_angle, 1.0)
346
  m_inv = cv2.getRotationMatrix2D((cx, cy), -rot_angle, 1.0)
347
+ l_rot, r_rot = cv2.warpAffine(f_left, m_rot, (w, h)), cv2.warpAffine(f_right, m_rot, (w, h))
348
+
349
+ l_idx, r_idx = np.where(l_rot > 0)[1], np.where(r_rot > 0)[1]
350
+ if len(l_idx) > 0 and len(r_idx) > 0:
351
+ if np.mean(l_idx) > np.mean(r_idx):
352
+ l_rot, r_rot = r_rot, l_rot
353
 
354
  gap_points =[]
355
+ y_l, y_r = np.where(l_rot > 0)[0], np.where(r_rot > 0)[0]
356
+ if len(y_l) > 0 and len(y_r) > 0:
357
+ for y in range(max(np.min(y_l), np.min(y_r)), min(np.max(y_l), np.max(y_r))):
358
+ row_l, row_r = np.where(l_rot[y, :] > 0)[0], np.where(r_rot[y, :] > 0)[0]
359
+ if len(row_l) > 0 and len(row_r) > 0:
360
+ gap_points.append([y, (row_l[-1] + row_r[0]) / 2.0])
 
 
 
 
 
361
 
362
  gap_points = np.array(gap_points)
363
  if len(gap_points) > 10:
364
+ y_min_g, y_max_g = np.min(gap_points[:, 0]), np.max(gap_points[:, 0])
365
+ y_span, y_mean = max(y_max_g - y_min_g, 1), (y_max_g + y_min_g) / 2.0
366
+ def parabola(y_n, a, b, c): return a*(y_n**2) + b*y_n + c
 
 
 
367
  max_bend = w * 0.08
368
  try:
369
+ popt_mid, _ = curve_fit(
370
+ parabola,
371
+ (gap_points[:,0]-y_mean)/y_span,
372
+ gap_points[:,1],
373
+ bounds=([-max_bend, -np.inf, -np.inf],[max_bend, np.inf, np.inf]),
374
+ max_nfev=1500,
375
+ )
376
+ except: popt_mid = [0.0, 0.0, cx]
377
+
378
  ys_extrap = np.linspace(0, h, 500)
379
+ xs_extrap = parabola((ys_extrap - y_mean)/y_span, *popt_mid)
380
+ pts_rot = np.vstack([xs_extrap, ys_extrap, np.ones_like(ys_extrap)])
381
  else:
382
  ys_extrap = np.linspace(0, h, 500)
383
+ pts_rot = np.vstack([np.full_like(ys_extrap, cx), ys_extrap, np.ones_like(ys_extrap)])
 
384
 
385
  pts_orig = (m_inv @ pts_rot).T
386
+ pred_cnt_cv = pred_cnt.reshape(-1, 1, 2).astype(np.int32)
387
+ return np.array([pt for pt in pts_orig if cv2.pointPolygonTest(pred_cnt_cv, (float(pt[0]), float(pt[1])), False) >= 0])
388
+
389
+ def process_image(self, image: np.ndarray, source_name: str, scale_ratio: float, include_image: bool = True, apply_smoothing: bool = True) -> ProcessResult:
 
 
 
 
390
  timings = {}
391
  stage_t = time.perf_counter()
392
 
 
406
  **extra,
407
  )
408
 
409
+ warnings = []
410
+ if image is None:
411
+ return ProcessResult(success=False, message="Could not decode image.", filename=source_name)
412
+
413
  h, w = image.shape[:2]
414
 
415
  # --- 1. CALIBRATION & SCALING ---
 
431
  tgt24_corr = sample_24_patches(tgt_warped_corr)
432
  dE_final = float(np.mean(compute_deltaE_00(tgt24_corr, self.ref24)))
433
  except Exception as e:
434
+ if cm_per_px is None:
435
+ warnings.append("ColorChecker not found; dimensions are returned in original-image pixels.")
436
+ else:
437
+ warnings.append("Color correction skipped after ColorChecker detection; dimensions are still in centimeters.")
438
  print(f"Calibration skipped for {source_name}: {e}")
439
  mark("calibration")
440
 
441
+ # --- 2. YOLO INFERENCE (STRICTLY PARSING ALL 3 CLASSES) ---
442
  results = self.model(image, conf=0.25, retina_masks=True, verbose=False)
443
  mark("yolo_inference")
444
 
 
447
  flesh_r_contours = []
448
 
449
  if results[0].masks is None:
450
+ return fail(
451
+ "No masks detected.",
452
+ measurement_unit="cm" if cm_per_px is not None else "px",
453
+ scale_source="color_checker" if cm_per_px is not None else "original_pixels",
454
+ color_checker_found=checker_corners is not None,
455
+ )
456
 
457
  for mask_data, cls in zip(results[0].masks.xy, results[0].boxes.cls):
458
  contour = np.array(mask_data, dtype=np.int32)
459
  c_id = int(cls)
460
+ if c_id == 0:
461
+ cv2.drawContours(rind_mask, [contour], -1, 255, -1)
462
+ elif c_id == 1:
463
+ flesh_l_contours.append(contour)
464
+ elif c_id == 2:
465
+ flesh_r_contours.append(contour)
466
+
467
+ # Failsafe: if YOLO missed one side but predicted multiple of the other.
468
  if len(flesh_l_contours) >= 2 and len(flesh_r_contours) == 0:
469
  flesh_l_contours.sort(key=lambda cnt: cv2.moments(cnt)["m10"] / (cv2.moments(cnt)["m00"] + 1e-5))
470
  flesh_r_contours.append(flesh_l_contours.pop())
471
+ warnings.append("Only flesh_left was detected; split the two left detections into left/right by x-position.")
472
  elif len(flesh_r_contours) >= 2 and len(flesh_l_contours) == 0:
473
  flesh_r_contours.sort(key=lambda cnt: cv2.moments(cnt)["m10"] / (cv2.moments(cnt)["m00"] + 1e-5))
474
  flesh_l_contours.append(flesh_r_contours.pop(0))
475
+ warnings.append("Only flesh_right was detected; split the two right detections into left/right by x-position.")
476
 
477
  flesh_l_m, flesh_r_m = np.zeros((h, w), dtype=np.uint8), np.zeros((h, w), dtype=np.uint8)
478
+ for cnt in flesh_l_contours:
479
+ cv2.drawContours(flesh_l_m, [cnt], -1, 255, -1)
480
+ for cnt in flesh_r_contours:
481
+ cv2.drawContours(flesh_r_m, [cnt], -1, 255, -1)
482
 
483
  flesh_combined = cv2.bitwise_or(flesh_l_m, flesh_r_m)
484
  target_rind_mask, rind_source, rind_overlap_ratio, rind_warnings = self.choose_target_rind_mask(rind_mask, flesh_combined)
 
488
  # --- 3. FIT & EXTRACTION ---
489
  perimeter_data = self.get_stable_perimeter_data(target_rind_mask, flesh_combined)
490
  if perimeter_data is None:
491
+ return fail(
492
+ "No stable perimeter.",
493
+ measurement_unit="cm" if cm_per_px is not None else "px",
494
+ scale_source="color_checker" if cm_per_px is not None else "original_pixels",
495
+ color_checker_found=checker_corners is not None,
496
+ rind_source=rind_source,
497
+ rind_overlap_ratio=rind_overlap_ratio,
498
+ )
499
 
500
  t_data, r_raw, (cx, cy), rind_cnt = perimeter_data
501
+ scale = np.mean(r_raw)
502
+ if scale <= 0:
503
+ return fail("Invalid perimeter scale.", rind_source=rind_source, rind_overlap_ratio=rind_overlap_ratio)
504
+
505
  if apply_smoothing:
 
 
506
  try:
507
  popt, _ = curve_fit(
508
  self.watermelon_model, t_data, r_raw / scale,
 
514
  mark("fit")
515
  return fail(f"Fit failed: {exc}", rind_source=rind_source, rind_overlap_ratio=rind_overlap_ratio)
516
 
517
+ r2 = 1 - (np.sum((r_raw / scale - self.watermelon_model(t_data, *popt)) ** 2) / np.sum((r_raw / scale - 1) ** 2))
 
 
518
  t_fit = np.linspace(-np.pi, np.pi, 500)
519
  r_fit = self.watermelon_model(t_fit, *popt) * scale
520
+ fit_pts = np.array([[r * np.cos(t) + cx, cy - r * np.sin(t)] for t, r in zip(t_fit, r_fit)])
521
  else:
 
522
  r2 = None
523
+ # If smoothing is off, use the raw OpenCV contour for the perimeter
524
  fit_pts = rind_cnt.reshape(-1, 2).astype(np.float32)
525
 
526
  width_px = float(np.max(fit_pts[:, 0]) - np.min(fit_pts[:, 0]))
 
531
  flesh_area_ratio = float(flesh_area_px / total_area_px) if total_area_px > 0 else None
532
  elongation_factor = self.elongation_from_points(fit_pts)
533
  circularity = float((4.0 * np.pi * total_area_px) / (perimeter_px ** 2)) if perimeter_px > 0 and total_area_px > 0 else None
534
+ midline = self.get_dual_mask_midline(flesh_l_m, flesh_r_m, rind_cnt, fit_pts, cx, cy)
 
 
535
  asymmetry_score = self.split_asymmetry(target_rind_mask, midline)
536
  flesh_asymmetry_score = self.split_asymmetry(flesh_combined, midline, thickness=3)
537
  midline_curvature = self.midline_curvature_score(midline)
538
 
539
  if cm_per_px is not None:
540
+ measurement_unit = "cm"
541
+ area_unit = "cm2"
542
+ scale_source = "color_checker"
543
  area_scale = cm_per_px ** 2
544
+ width_val = float(width_px * cm_per_px)
545
+ height_val = float(height_px * cm_per_px)
546
+ perimeter_val = float(perimeter_px * cm_per_px)
547
  else:
548
+ measurement_unit = "px"
549
+ area_unit = "px2"
550
+ scale_source = "original_pixels"
551
  orig_scale = 1.0 / scale_ratio
552
  area_scale = orig_scale ** 2
553
+ width_val = float(width_px * orig_scale)
554
+ height_val = float(height_px * orig_scale)
555
+ perimeter_val = float(perimeter_px * orig_scale)
556
+ total_area = float(total_area_px * area_scale)
557
+ flesh_area = float(flesh_area_px * area_scale)
558
  mark("fit")
559
 
560
  # --- 4. DRAWING ---
561
  img_base64 = None
562
+ if include_image:
563
+ # Color coding: Green=chosen rind, Blue=Left Flesh, Red=Right Flesh.
564
+ output = image.copy().astype(np.float32)
565
+ alpha = 0.42
566
+ output[..., 0] = np.where(target_rind_mask > 0, output[..., 0] * (1 - alpha) + 0.0 * alpha, output[..., 0])
567
+ output[..., 1] = np.where(target_rind_mask > 0, output[..., 1] * (1 - alpha) + 170.0 * alpha, output[..., 1])
568
+ output[..., 2] = np.where(target_rind_mask > 0, output[..., 2] * (1 - alpha) + 0.0 * alpha, output[..., 2])
569
+
570
+ output[..., 0] = np.where(flesh_l_m > 0, output[..., 0] * (1 - alpha) + 255.0 * alpha, output[..., 0])
571
+ output[..., 1] = np.where(flesh_l_m > 0, output[..., 1] * (1 - alpha) + 0.0 * alpha, output[..., 1])
572
+ output[..., 2] = np.where(flesh_l_m > 0, output[..., 2] * (1 - alpha) + 0.0 * alpha, output[..., 2])
573
+
574
+ output[..., 0] = np.where(flesh_r_m > 0, output[..., 0] * (1 - alpha) + 0.0 * alpha, output[..., 0])
575
+ output[..., 1] = np.where(flesh_r_m > 0, output[..., 1] * (1 - alpha) + 0.0 * alpha, output[..., 1])
576
+ output[..., 2] = np.where(flesh_r_m > 0, output[..., 2] * (1 - alpha) + 255.0 * alpha, output[..., 2])
577
+
578
+ output = np.clip(output, 0, 255).astype(np.uint8)
579
+
580
+ if checker_corners is not None:
581
+ cv2.polylines(output, [np.int32(checker_corners)], True, (0, 165, 255), 4)
582
+
583
+ if len(midline) > 1:
584
+ cv2.polylines(output, [midline.astype(np.int32)], False, (0, 255, 255), 3)
585
+ pt_top = (int(midline[0][0]), int(midline[0][1]))
586
+ pt_bot = (int(midline[-1][0]), int(midline[-1][1]))
587
+ cv2.circle(output, pt_top, 10, (0, 0, 0), 2)
588
+ cv2.circle(output, pt_top, 8, (255, 255, 255), -1)
589
+ cv2.circle(output, pt_bot, 10, (0, 0, 0), 2)
590
+ cv2.circle(output, pt_bot, 8, (255, 255, 255), -1)
591
+
592
+ cv2.polylines(output, [fit_pts.astype(np.int32)], True, (0, 255, 0), 3)
593
+ _, buffer = cv2.imencode(".jpg", output, [cv2.IMWRITE_JPEG_QUALITY, 85])
594
+ img_base64 = base64.b64encode(buffer).decode("utf-8")
 
 
 
595
  mark("render")
596
 
597
  return ProcessResult(
598
+ success=True, message="Success", r2_score=float(r2),
599
  width_val=width_val, height_val=height_val, perimeter_val=perimeter_val,
600
  total_area=total_area, flesh_area=flesh_area, flesh_area_ratio=flesh_area_ratio,
601
  elongation_factor=elongation_factor, circularity=circularity,
602
  asymmetry_score=asymmetry_score, flesh_asymmetry_score=flesh_asymmetry_score,
603
+ midline_curvature=midline_curvature,
604
+ delta_e_initial=dE_initial, delta_e_final=dE_final,
605
+ image_base64=img_base64, filename=source_name,
606
+ measurement_unit=measurement_unit, area_unit=area_unit, scale_source=scale_source,
607
+ color_checker_found=checker_corners is not None,
608
  rind_source=rind_source, rind_overlap_ratio=rind_overlap_ratio,
609
  warnings=warnings or None, timings_ms=timings
610
  )
611
 
612
+
613
  app = FastAPI()
614
 
615
  app.add_middleware(
 
624
  @app.post("/process_single")
625
  async def process_single(file: UploadFile = File(...), include_image: bool = Query(True), apply_smoothing: bool = Query(True)):
626
  request_t = time.perf_counter()
627
+ contents = None
628
+ img = None
629
 
630
  try:
631
  contents = await file.read()
632
  if not contents:
633
+ res = ProcessResult(success=False, message="Empty upload.", filename=file.filename)
634
+ res.processing_ms = int(round((time.perf_counter() - request_t) * 1000))
635
+ return res.__dict__
636
 
637
  img = cv2.imdecode(np.frombuffer(contents, np.uint8), cv2.IMREAD_COLOR)
638
  if img is None:
639
+ res = ProcessResult(success=False, message="Could not decode image.", filename=file.filename)
640
+ res.processing_ms = int(round((time.perf_counter() - request_t) * 1000))
641
+ return res.__dict__
642
 
643
  scale_ratio = 1.0
644
  h, w = img.shape[:2]
 
646
  scale_ratio = MAX_IMAGE_SIZE / float(max(h, w))
647
  img = cv2.resize(img, (int(w * scale_ratio), int(h * scale_ratio)), interpolation=cv2.INTER_AREA)
648
 
649
+ res = processor.process_image(img, file.filename, scale_ratio, include_image=include_image, apply_smoothing=apply_smoothing)
 
650
  res.processing_ms = int(round((time.perf_counter() - request_t) * 1000))
651
  return res.__dict__
652
 
653
  except Exception as exc:
654
  traceback.print_exc()
655
+ res = ProcessResult(
656
+ success=False,
657
+ message=f"Server error: {type(exc).__name__}: {exc}",
658
+ filename=file.filename,
659
+ processing_ms=int(round((time.perf_counter() - request_t) * 1000)),
660
+ )
661
+ return res.__dict__
662
 
663
  finally:
664
  del img, contents