crabbly commited on
Commit
5d5ea2f
·
verified ·
1 Parent(s): a1ced6f

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +263 -224
main.py CHANGED
@@ -115,23 +115,37 @@ def apply_color_pipeline(target_bgr, ref24, tgt24):
115
  class ProcessResult:
116
  success: bool
117
  message: str
118
- r2_score: Optional[float] = None
119
- width_val: Optional[float] = None
120
- height_val: Optional[float] = None
121
- perimeter_val: Optional[float] = None
122
- rind_thickness_val: Optional[float] = None
123
- rind_thickness_ratio: Optional[float] = None
124
- total_area: Optional[float] = None
125
- flesh_area: Optional[float] = None
126
- flesh_area_ratio: Optional[float] = None
127
- elongation_factor: Optional[float] = None
128
- circularity: Optional[float] = None
129
- asymmetry_score: Optional[float] = None
130
- flesh_asymmetry_score: Optional[float] = None
 
 
 
 
 
 
 
 
 
 
 
 
 
131
  midline_curvature: Optional[float] = None
132
  delta_e_initial: Optional[float] = None
133
  delta_e_final: Optional[float] = None
134
- image_base64: Optional[str] = None
 
135
  filename: Optional[str] = None
136
  measurement_unit: Optional[str] = None
137
  area_unit: Optional[str] = None
@@ -358,23 +372,16 @@ class WatermelonProcessor:
358
  return float(max(0.0, (path_len / chord_len) - 1.0))
359
 
360
  @staticmethod
361
- def get_stable_perimeter_data(rind_mask, flesh_combined):
362
- cnts, _ = cv2.findContours(rind_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
 
363
  if not cnts: return None
364
- best_cnt, max_overlap = None, -1
365
- for cnt in cnts:
366
- temp = np.zeros_like(rind_mask)
367
- cv2.drawContours(temp, [cnt], -1, 255, -1)
368
- overlap = cv2.countNonZero(cv2.bitwise_and(temp, flesh_combined))
369
- if overlap > max_overlap:
370
- max_overlap, best_cnt = overlap, cnt
371
-
372
- if best_cnt is None: best_cnt = max(cnts, key=cv2.contourArea)
373
- M = cv2.moments(best_cnt)
374
  if M["m00"] == 0: return None
375
 
376
  cx, cy = M["m10"]/M["m00"], M["m01"]/M["m00"]
377
- pts = best_cnt.reshape(-1, 2)
378
  dx, dy = pts[:, 0] - cx, cy - pts[:, 1]
379
  r_vals, t_vals = np.sqrt(dx**2 + dy**2), np.arctan2(dy, dx)
380
 
@@ -382,13 +389,46 @@ class WatermelonProcessor:
382
  bins = np.linspace(-np.pi, np.pi, num_bins + 1)
383
  raw_r = np.full(num_bins, np.nan)
384
  for i in range(num_bins):
385
- mask = (t_vals >= bins[i]) & (t_vals < bins[i + 1])
386
- if np.any(mask): raw_r[i] = np.max(r_vals[mask])
387
 
388
  valid_idx = np.where(~np.isnan(raw_r))[0]
389
  if len(valid_idx) == 0: return None
390
  raw_r[np.isnan(raw_r)] = np.interp(np.where(np.isnan(raw_r))[0], valid_idx, raw_r[valid_idx], period=360)
391
- return (bins[:-1] + bins[1:])/2.0, median_filter(raw_r, size=7, mode="wrap"), (cx, cy), best_cnt
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
 
393
  @staticmethod
394
  def get_dual_mask_midline(f_left, f_right, rind_cnt, pred_cnt, cx, cy):
@@ -440,7 +480,7 @@ class WatermelonProcessor:
440
  pred_cnt_cv = pred_cnt.reshape(-1, 1, 2).astype(np.int32)
441
  return np.array([pt for pt in pts_orig if cv2.pointPolygonTest(pred_cnt_cv, (float(pt[0]), float(pt[1])), False) >= 0])
442
 
443
- def process_image(self, image: np.ndarray, source_name: str, scale_ratio: float, include_image: bool = True, apply_smoothing: bool = True) -> ProcessResult:
444
  timings = {}
445
  stage_t = time.perf_counter()
446
 
@@ -451,22 +491,14 @@ class WatermelonProcessor:
451
  stage_t = now
452
 
453
  def fail(message, **extra):
454
- return ProcessResult(
455
- success=False,
456
- message=message,
457
- filename=source_name,
458
- warnings=warnings or None,
459
- timings_ms=timings,
460
- **extra,
461
- )
462
 
463
- warnings = []
464
- if image is None:
465
- return ProcessResult(success=False, message="Could not decode image.", filename=source_name)
466
 
467
  h, w = image.shape[:2]
468
 
469
- # --- 1. CALIBRATION & SCALING ---
470
  dE_initial, dE_final, cm_per_px, checker_corners = None, None, None, None
471
  try:
472
  checker_corners = detect_checker_corners(image)
@@ -478,215 +510,223 @@ class WatermelonProcessor:
478
  tgt_warped = warp_checker(image, checker_corners)
479
  tgt24 = sample_24_patches(tgt_warped)
480
  dE_initial = float(np.mean(compute_deltaE_00(tgt24, self.ref24)))
481
-
482
  image = apply_color_pipeline(image, self.ref24, tgt24)
483
-
484
  tgt_warped_corr = warp_checker(image, checker_corners)
485
  tgt24_corr = sample_24_patches(tgt_warped_corr)
486
  dE_final = float(np.mean(compute_deltaE_00(tgt24_corr, self.ref24)))
487
  except Exception as e:
488
- if cm_per_px is None:
489
- warnings.append("ColorChecker not found; dimensions are returned in original-image pixels.")
490
- else:
491
- warnings.append("Color correction skipped after ColorChecker detection; dimensions are still in centimeters.")
492
- print(f"Calibration skipped for {source_name}: {e}")
493
  mark("calibration")
494
 
495
- # --- 2. YOLO INFERENCE (STRICTLY PARSING ALL 3 CLASSES) ---
496
  results = self.model(image, conf=0.25, retina_masks=True, verbose=False)
497
  mark("yolo_inference")
498
 
499
  rind_mask = np.zeros((h, w), dtype=np.uint8)
500
- flesh_l_contours = []
501
- flesh_r_contours = []
502
-
503
  if results[0].masks is None:
504
- return fail(
505
- "No masks detected.",
506
- measurement_unit="cm" if cm_per_px is not None else "px",
507
- scale_source="color_checker" if cm_per_px is not None else "original_pixels",
508
- color_checker_found=checker_corners is not None,
509
- )
510
 
511
  for mask_data, cls in zip(results[0].masks.xy, results[0].boxes.cls):
512
- contour = np.array(mask_data, dtype=np.int32)
513
- c_id = int(cls)
514
- if c_id == 0:
515
- cv2.drawContours(rind_mask, [contour], -1, 255, -1)
516
- elif c_id == 1:
517
- flesh_l_contours.append(contour)
518
- elif c_id == 2:
519
- flesh_r_contours.append(contour)
520
-
521
- # Failsafe: if YOLO missed one side but predicted multiple of the other.
522
- if len(flesh_l_contours) >= 2 and len(flesh_r_contours) == 0:
523
- flesh_l_contours.sort(key=lambda cnt: cv2.moments(cnt)["m10"] / (cv2.moments(cnt)["m00"] + 1e-5))
524
- flesh_r_contours.append(flesh_l_contours.pop())
525
- warnings.append("Only flesh_left was detected; split the two left detections into left/right by x-position.")
526
- elif len(flesh_r_contours) >= 2 and len(flesh_l_contours) == 0:
527
- flesh_r_contours.sort(key=lambda cnt: cv2.moments(cnt)["m10"] / (cv2.moments(cnt)["m00"] + 1e-5))
528
- flesh_l_contours.append(flesh_r_contours.pop(0))
529
- warnings.append("Only flesh_right was detected; split the two right detections into left/right by x-position.")
530
 
531
  flesh_l_m, flesh_r_m = np.zeros((h, w), dtype=np.uint8), np.zeros((h, w), dtype=np.uint8)
532
- for cnt in flesh_l_contours:
533
- cv2.drawContours(flesh_l_m, [cnt], -1, 255, -1)
534
- for cnt in flesh_r_contours:
535
- cv2.drawContours(flesh_r_m, [cnt], -1, 255, -1)
536
-
537
  flesh_combined = cv2.bitwise_or(flesh_l_m, flesh_r_m)
538
- target_rind_mask, rind_source, rind_overlap_ratio, rind_warnings = self.choose_target_rind_mask(rind_mask, flesh_combined)
539
- warnings.extend(rind_warnings)
 
 
 
 
 
540
  mark("mask_parse")
541
 
542
- # --- 3. FIT & EXTRACTION ---
543
- perimeter_data = self.get_stable_perimeter_data(target_rind_mask, flesh_combined)
544
- if perimeter_data is None:
545
- return fail(
546
- "No stable perimeter.",
547
- measurement_unit="cm" if cm_per_px is not None else "px",
548
- scale_source="color_checker" if cm_per_px is not None else "original_pixels",
549
- color_checker_found=checker_corners is not None,
550
- rind_source=rind_source,
551
- rind_overlap_ratio=rind_overlap_ratio,
552
- )
553
-
554
- t_data, r_raw, (cx, cy), rind_cnt = perimeter_data
555
- scale = np.mean(r_raw)
556
- if scale <= 0:
557
- return fail("Invalid perimeter scale.", rind_source=rind_source, rind_overlap_ratio=rind_overlap_ratio)
558
-
559
- if apply_smoothing:
560
- try:
561
- popt, _ = curve_fit(
562
- self.watermelon_model, t_data, r_raw / scale,
563
- p0=[1.0, 1.1, 0.0, 0.05, 3.0, 0.05, 3.0, 0.0, 0.0, 0.0],
564
- bounds=([0.5, 0.5, -0.4, 0.0, 0.1, 0.0, 0.1, -1.5, -0.2, -0.2], [2.0, 2.0, 0.4, 0.5, 50.0, 0.5, 50.0, 1.5, 0.2, 0.2]),
565
- max_nfev=3000,
566
- )
567
- except Exception as exc:
568
- mark("fit")
569
- return fail(f"Fit failed: {exc}", rind_source=rind_source, rind_overlap_ratio=rind_overlap_ratio)
570
 
571
- r2 = 1 - (np.sum((r_raw / scale - self.watermelon_model(t_data, *popt)) ** 2) / np.sum((r_raw / scale - 1) ** 2))
572
- t_fit = np.linspace(-np.pi, np.pi, 500)
573
- r_fit = self.watermelon_model(t_fit, *popt) * scale
574
- fit_pts = np.array([[r * np.cos(t) + cx, cy - r * np.sin(t)] for t, r in zip(t_fit, r_fit)])
575
- else:
576
- r2 = None
577
- # If smoothing is off, use the raw OpenCV contour for the perimeter
578
- fit_pts = rind_cnt.reshape(-1, 2).astype(np.float32)
579
-
580
- perimeter_px = float(np.sum(np.linalg.norm(np.diff(fit_pts, axis=0), axis=1)) + np.linalg.norm(fit_pts[-1] - fit_pts[0]))
581
 
582
- # Calculate rotation angle for axes
583
- if apply_smoothing:
584
- phi = popt[7]
585
- else:
586
- _, _, angle = cv2.fitEllipse(rind_cnt)
587
- phi = np.deg2rad(180 - angle) if angle > 90 else np.deg2rad(-angle)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
 
589
- # Call the new, faster axis calculator
590
- height_px, width_px, rind_thick_px, h_line, w_line = self.calculate_axis_metrics(
591
- cx, cy, phi, target_rind_mask, flesh_combined
592
- )
593
 
594
- total_area_px = self.contour_area_px(fit_pts)
595
- flesh_area_px = float(cv2.countNonZero(flesh_combined))
596
- flesh_area_ratio = float(flesh_area_px / total_area_px) if total_area_px > 0 else None
597
- elongation_factor = self.elongation_from_points(fit_pts)
598
- circularity = float((4.0 * np.pi * total_area_px) / (perimeter_px ** 2)) if perimeter_px > 0 and total_area_px > 0 else None
599
- midline = self.get_dual_mask_midline(flesh_l_m, flesh_r_m, rind_cnt, fit_pts, cx, cy)
600
- asymmetry_score = self.split_asymmetry(target_rind_mask, midline)
601
- flesh_asymmetry_score = self.split_asymmetry(flesh_combined, midline, thickness=3)
602
- midline_curvature = self.midline_curvature_score(midline)
603
-
604
- rind_thickness_val, rind_thickness_ratio = None, None
605
-
606
- if cm_per_px is not None:
607
- measurement_unit, area_unit, scale_source = "cm", "cm2", "color_checker"
608
- area_scale = cm_per_px ** 2
609
- width_val = float(width_px * cm_per_px)
610
- height_val = float(height_px * cm_per_px)
611
- perimeter_val = float(perimeter_px * cm_per_px)
612
- if rind_thick_px is not None:
613
- rind_thickness_val = float(rind_thick_px * cm_per_px)
614
- else:
615
- measurement_unit, area_unit, scale_source = "px", "px2", "original_pixels"
616
- orig_scale = 1.0 / scale_ratio
617
- area_scale = orig_scale ** 2
618
- width_val = float(width_px * orig_scale)
619
- height_val = float(height_px * orig_scale)
620
- perimeter_val = float(perimeter_px * orig_scale)
621
- if rind_thick_px is not None:
622
- rind_thickness_val = float(rind_thick_px * orig_scale)
623
-
624
- if rind_thick_px is not None and width_px > 0:
625
- rind_thickness_ratio = float((rind_thick_px * 2.0) / width_px)
626
-
627
- total_area = float(total_area_px * area_scale)
628
- flesh_area = float(flesh_area_px * area_scale)
629
  mark("fit")
630
 
631
- # --- 4. DRAWING ---
632
- img_base64 = None
633
- if include_image:
634
- # Color coding: Green=chosen rind, Blue=Left Flesh, Red=Right Flesh.
635
- output = image.copy().astype(np.float32)
636
- alpha = 0.42
637
- output[..., 0] = np.where(target_rind_mask > 0, output[..., 0] * (1 - alpha) + 0.0 * alpha, output[..., 0])
638
- output[..., 1] = np.where(target_rind_mask > 0, output[..., 1] * (1 - alpha) + 170.0 * alpha, output[..., 1])
639
- output[..., 2] = np.where(target_rind_mask > 0, output[..., 2] * (1 - alpha) + 0.0 * alpha, output[..., 2])
640
-
641
- output[..., 0] = np.where(flesh_l_m > 0, output[..., 0] * (1 - alpha) + 255.0 * alpha, output[..., 0])
642
- output[..., 1] = np.where(flesh_l_m > 0, output[..., 1] * (1 - alpha) + 0.0 * alpha, output[..., 1])
643
- output[..., 2] = np.where(flesh_l_m > 0, output[..., 2] * (1 - alpha) + 0.0 * alpha, output[..., 2])
644
-
645
- output[..., 0] = np.where(flesh_r_m > 0, output[..., 0] * (1 - alpha) + 0.0 * alpha, output[..., 0])
646
- output[..., 1] = np.where(flesh_r_m > 0, output[..., 1] * (1 - alpha) + 0.0 * alpha, output[..., 1])
647
- output[..., 2] = np.where(flesh_r_m > 0, output[..., 2] * (1 - alpha) + 255.0 * alpha, output[..., 2])
648
-
649
- output = np.clip(output, 0, 255).astype(np.uint8)
650
 
651
- if checker_corners is not None:
652
- cv2.polylines(output, [np.int32(checker_corners)], True, (0, 165, 255), 4)
 
653
 
654
- # Draw axis lines underneath the other features
655
- cv2.line(output, h_line[0], h_line[1], (255, 100, 255), 2) # Height (Purple)
656
- cv2.line(output, w_line[0], w_line[1], (255, 255, 100), 2) # Width (Cyan)
657
-
658
- if len(midline) > 1:
659
- cv2.polylines(output, [midline.astype(np.int32)], False, (0, 255, 255), 3)
660
- pt_top = (int(midline[0][0]), int(midline[0][1]))
661
- pt_bot = (int(midline[-1][0]), int(midline[-1][1]))
662
- cv2.circle(output, pt_top, 10, (0, 0, 0), 2)
663
- cv2.circle(output, pt_top, 8, (255, 255, 255), -1)
664
- cv2.circle(output, pt_bot, 10, (0, 0, 0), 2)
665
- cv2.circle(output, pt_bot, 8, (255, 255, 255), -1)
666
-
667
- cv2.polylines(output, [fit_pts.astype(np.int32)], True, (0, 255, 0), 3)
668
 
669
- _, buffer = cv2.imencode(".jpg", output, [cv2.IMWRITE_JPEG_QUALITY, 80])
670
- img_base64 = base64.b64encode(buffer).decode("utf-8")
671
- mark("render")
672
-
673
- return ProcessResult(
674
- success=True, message="Success", r2_score=float(r2) if r2 is not None else None,
675
- width_val=width_val, height_val=height_val, perimeter_val=perimeter_val,
676
- rind_thickness_val=rind_thickness_val, rind_thickness_ratio=rind_thickness_ratio,
677
- total_area=total_area, flesh_area=flesh_area, flesh_area_ratio=flesh_area_ratio,
678
- elongation_factor=elongation_factor, circularity=circularity,
679
- asymmetry_score=asymmetry_score, flesh_asymmetry_score=flesh_asymmetry_score,
680
- midline_curvature=midline_curvature,
681
- delta_e_initial=dE_initial, delta_e_final=dE_final,
682
- image_base64=img_base64, filename=source_name,
683
- measurement_unit=measurement_unit, area_unit=area_unit, scale_source=scale_source,
684
- color_checker_found=checker_corners is not None,
685
- rind_source=rind_source, rind_overlap_ratio=rind_overlap_ratio,
686
- warnings=warnings or None, timings_ms=timings
687
  )
688
 
689
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
690
  app = FastAPI()
691
 
692
  app.add_middleware(
@@ -702,7 +742,6 @@ def read_root(): return {"status": "Phenotyping API is awake and running!"}
702
  async def process_single(
703
  file: UploadFile = File(...),
704
  include_image: bool = Query(True),
705
- apply_smoothing: bool = Query(True),
706
  password: str = Form("")
707
  ):
708
  request_t = time.perf_counter()
@@ -733,7 +772,7 @@ async def process_single(
733
  scale_ratio = MAX_IMAGE_SIZE / float(max(h, w))
734
  img = cv2.resize(img, (int(w * scale_ratio), int(h * scale_ratio)), interpolation=cv2.INTER_AREA)
735
 
736
- res = processor.process_image(img, file.filename, scale_ratio, include_image=include_image, apply_smoothing=apply_smoothing)
737
  res.processing_ms = int(round((time.perf_counter() - request_t) * 1000))
738
  return res.__dict__
739
 
 
115
  class ProcessResult:
116
  success: bool
117
  message: str
118
+ r2_rind: Optional[float] = None
119
+ r2_flesh: Optional[float] = None
120
+ raw_width: Optional[float] = None
121
+ sm_width: Optional[float] = None
122
+ raw_height: Optional[float] = None
123
+ sm_height: Optional[float] = None
124
+ raw_perimeter: Optional[float] = None
125
+ sm_perimeter: Optional[float] = None
126
+ raw_rind_thick: Optional[float] = None
127
+ sm_rind_thick: Optional[float] = None
128
+ raw_rind_ratio: Optional[float] = None
129
+ sm_rind_ratio: Optional[float] = None
130
+ raw_total_area: Optional[float] = None
131
+ sm_total_area: Optional[float] = None
132
+ raw_flesh_area: Optional[float] = None
133
+ sm_flesh_area: Optional[float] = None
134
+ raw_flesh_ratio: Optional[float] = None
135
+ sm_flesh_ratio: Optional[float] = None
136
+ raw_elongation: Optional[float] = None
137
+ sm_elongation: Optional[float] = None
138
+ raw_asym: Optional[float] = None
139
+ sm_asym: Optional[float] = None
140
+ raw_flesh_asym: Optional[float] = None
141
+ sm_flesh_asym: Optional[float] = None
142
+ raw_circ: Optional[float] = None
143
+ sm_circ: Optional[float] = None
144
  midline_curvature: Optional[float] = None
145
  delta_e_initial: Optional[float] = None
146
  delta_e_final: Optional[float] = None
147
+ image_raw_base64: Optional[str] = None
148
+ image_sm_base64: Optional[str] = None
149
  filename: Optional[str] = None
150
  measurement_unit: Optional[str] = None
151
  area_unit: Optional[str] = None
 
372
  return float(max(0.0, (path_len / chord_len) - 1.0))
373
 
374
  @staticmethod
375
+ def get_polar_data(mask):
376
+ """Universal polar extractor for either Rind or Flesh masks."""
377
+ cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
378
  if not cnts: return None
379
+ cnt = max(cnts, key=cv2.contourArea)
380
+ M = cv2.moments(cnt)
 
 
 
 
 
 
 
 
381
  if M["m00"] == 0: return None
382
 
383
  cx, cy = M["m10"]/M["m00"], M["m01"]/M["m00"]
384
+ pts = cnt.reshape(-1, 2)
385
  dx, dy = pts[:, 0] - cx, cy - pts[:, 1]
386
  r_vals, t_vals = np.sqrt(dx**2 + dy**2), np.arctan2(dy, dx)
387
 
 
389
  bins = np.linspace(-np.pi, np.pi, num_bins + 1)
390
  raw_r = np.full(num_bins, np.nan)
391
  for i in range(num_bins):
392
+ b_mask = (t_vals >= bins[i]) & (t_vals < bins[i + 1])
393
+ if np.any(b_mask): raw_r[i] = np.max(r_vals[b_mask])
394
 
395
  valid_idx = np.where(~np.isnan(raw_r))[0]
396
  if len(valid_idx) == 0: return None
397
  raw_r[np.isnan(raw_r)] = np.interp(np.where(np.isnan(raw_r))[0], valid_idx, raw_r[valid_idx], period=360)
398
+ return (bins[:-1] + bins[1:])/2.0, median_filter(raw_r, size=7, mode="wrap"), (cx, cy), cnt
399
+
400
+ @staticmethod
401
+ def calculate_axis_metrics(cx, cy, phi, rind_mask, flesh_mask):
402
+ """Instantly finds axes and rind thickness using fast OpenCV bitwise operations."""
403
+ h, w = rind_mask.shape
404
+ def get_intersections(theta, mask):
405
+ temp = np.zeros((h, w), dtype=np.uint8)
406
+ L = max(h, w)
407
+ p1 = (int(cx + L * np.cos(theta)), int(cy - L * np.sin(theta)))
408
+ p2 = (int(cx - L * np.cos(theta)), int(cy + L * np.sin(theta)))
409
+ cv2.line(temp, p1, p2, 255, 1)
410
+
411
+ overlap = cv2.bitwise_and(mask, temp)
412
+ y_pts, x_pts = np.where(overlap > 0)
413
+ if len(x_pts) == 0: return (int(cx), int(cy)), (int(cx), int(cy)), 0.0
414
+
415
+ dx, dy = x_pts - cx, y_pts - cy
416
+ proj = dx * np.cos(theta) - dy * np.sin(theta)
417
+ idx_max, idx_min = np.argmax(proj), np.argmin(proj)
418
+ pt1 = (int(x_pts[idx_max]), int(y_pts[idx_max]))
419
+ pt2 = (int(x_pts[idx_min]), int(y_pts[idx_min]))
420
+ dist = float(np.hypot(pt1[0] - pt2[0], pt1[1] - pt2[1]))
421
+ return pt1, pt2, dist
422
+
423
+ pt_top, pt_bot, height_px = get_intersections(phi + np.pi/2, rind_mask)
424
+ pt_right, pt_left, width_px = get_intersections(phi, rind_mask)
425
+ _, _, flesh_width_px = get_intersections(phi, flesh_mask)
426
+
427
+ rind_thick_px = None
428
+ if width_px > 0 and flesh_width_px > 0:
429
+ rind_thick_px = float(max(0.0, (width_px - flesh_width_px) / 2.0))
430
+
431
+ return height_px, width_px, rind_thick_px, (pt_top, pt_bot), (pt_left, pt_right)
432
 
433
  @staticmethod
434
  def get_dual_mask_midline(f_left, f_right, rind_cnt, pred_cnt, cx, cy):
 
480
  pred_cnt_cv = pred_cnt.reshape(-1, 1, 2).astype(np.int32)
481
  return np.array([pt for pt in pts_orig if cv2.pointPolygonTest(pred_cnt_cv, (float(pt[0]), float(pt[1])), False) >= 0])
482
 
483
+ def process_image(self, image: np.ndarray, source_name: str, scale_ratio: float, include_image: bool = True) -> ProcessResult:
484
  timings = {}
485
  stage_t = time.perf_counter()
486
 
 
491
  stage_t = now
492
 
493
  def fail(message, **extra):
494
+ return ProcessResult(success=False, message=message, filename=source_name, warnings=warnings or None, timings_ms=timings, **extra)
 
 
 
 
 
 
 
495
 
496
+ warnings =[]
497
+ if image is None: return ProcessResult(success=False, message="Could not decode image.", filename=source_name)
 
498
 
499
  h, w = image.shape[:2]
500
 
501
+ # 1. CALIBRATION & SCALING
502
  dE_initial, dE_final, cm_per_px, checker_corners = None, None, None, None
503
  try:
504
  checker_corners = detect_checker_corners(image)
 
510
  tgt_warped = warp_checker(image, checker_corners)
511
  tgt24 = sample_24_patches(tgt_warped)
512
  dE_initial = float(np.mean(compute_deltaE_00(tgt24, self.ref24)))
 
513
  image = apply_color_pipeline(image, self.ref24, tgt24)
 
514
  tgt_warped_corr = warp_checker(image, checker_corners)
515
  tgt24_corr = sample_24_patches(tgt_warped_corr)
516
  dE_final = float(np.mean(compute_deltaE_00(tgt24_corr, self.ref24)))
517
  except Exception as e:
518
+ if cm_per_px is None: warnings.append("ColorChecker not found; dimensions in original-image pixels.")
519
+ else: warnings.append("Color correction skipped after ColorChecker detection.")
 
 
 
520
  mark("calibration")
521
 
522
+ # 2. YOLO INFERENCE
523
  results = self.model(image, conf=0.25, retina_masks=True, verbose=False)
524
  mark("yolo_inference")
525
 
526
  rind_mask = np.zeros((h, w), dtype=np.uint8)
527
+ f_l_cnts, f_r_cnts = [], []
 
 
528
  if results[0].masks is None:
529
+ return fail("No masks detected.", measurement_unit="cm" if cm_per_px is not None else "px", scale_source="color_checker" if cm_per_px is not None else "original_pixels", color_checker_found=checker_corners is not None)
 
 
 
 
 
530
 
531
  for mask_data, cls in zip(results[0].masks.xy, results[0].boxes.cls):
532
+ c = np.array(mask_data, dtype=np.int32)
533
+ if int(cls) == 0: cv2.drawContours(rind_mask, [c], -1, 255, -1)
534
+ elif int(cls) == 1: f_l_cnts.append(c)
535
+ elif int(cls) == 2: f_r_cnts.append(c)
536
+
537
+ if len(f_l_cnts) >= 2 and len(f_r_cnts) == 0:
538
+ f_l_cnts.sort(key=lambda cnt: cv2.moments(cnt)["m10"] / (cv2.moments(cnt)["m00"] + 1e-5))
539
+ f_r_cnts.append(f_l_cnts.pop())
540
+ warnings.append("Only flesh_left was detected; split by x-position.")
541
+ elif len(f_r_cnts) >= 2 and len(f_l_cnts) == 0:
542
+ f_r_cnts.sort(key=lambda cnt: cv2.moments(cnt)["m10"] / (cv2.moments(cnt)["m00"] + 1e-5))
543
+ f_l_cnts.append(f_r_cnts.pop(0))
544
+ warnings.append("Only flesh_right was detected; split by x-position.")
 
 
 
 
 
545
 
546
  flesh_l_m, flesh_r_m = np.zeros((h, w), dtype=np.uint8), np.zeros((h, w), dtype=np.uint8)
547
+ for c in f_l_cnts: cv2.drawContours(flesh_l_m, [c], -1, 255, -1)
548
+ for c in f_r_cnts: cv2.drawContours(flesh_r_m, [c], -1, 255, -1)
 
 
 
549
  flesh_combined = cv2.bitwise_or(flesh_l_m, flesh_r_m)
550
+
551
+ target_rind_mask, rind_source, rind_overlap_ratio, r_warn = self.choose_target_rind_mask(rind_mask, flesh_combined)
552
+ warnings.extend(r_warn)
553
+
554
+ # Bridge the gap for the unified flesh boundary (visuals and smoothed fit)
555
+ bridge_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (45, 45))
556
+ flesh_closed = cv2.morphologyEx(flesh_combined, cv2.MORPH_CLOSE, bridge_k)
557
  mark("mask_parse")
558
 
559
+ # 3. EXTRACTION
560
+ rind_data = self.get_polar_data(target_rind_mask)
561
+ flesh_data = self.get_polar_data(flesh_closed)
562
+ if rind_data is None:
563
+ return fail("No stable perimeter.", measurement_unit="cm" if cm_per_px else "px", scale_source="color_checker" if cm_per_px else "original_pixels", color_checker_found=checker_corners is not None, rind_source=rind_source, rind_overlap_ratio=rind_overlap_ratio)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
564
 
565
+ t_r, raw_r_r, (cx, cy), raw_rind_cnt = rind_data
566
+ pts_r_raw = raw_rind_cnt.reshape(-1, 2).astype(np.float32)
 
 
 
 
 
 
 
 
567
 
568
+ # 4. RAW FEATURES
569
+ _, _, r_angle = cv2.fitEllipse(raw_rind_cnt)
570
+ raw_phi = np.deg2rad(180 - r_angle) if r_angle > 90 else np.deg2rad(-r_angle)
571
+
572
+ raw_h, raw_w, raw_rt, raw_h_line, raw_w_line = self.calculate_axis_metrics(cx, cy, raw_phi, target_rind_mask, flesh_combined)
573
+
574
+ # Midline is found using flesh_combined (with gap) and clipped to the raw rind contour
575
+ midline = self.get_ray_scan_midline(flesh_combined, raw_rind_cnt, pts_r_raw, cx, cy)
576
+
577
+ raw_perim = float(cv2.arcLength(raw_rind_cnt, True))
578
+ raw_tot_a = self.contour_area_px(pts_r_raw)
579
+ raw_f_a = float(cv2.countNonZero(flesh_combined))
580
+ raw_f_rat = float(raw_f_a / raw_tot_a) if raw_tot_a > 0 else None
581
+ raw_elong = self.elongation_from_points(pts_r_raw)
582
+ raw_circ = float((4.0 * np.pi * raw_tot_a) / (raw_perim ** 2)) if raw_perim > 0 and raw_tot_a > 0 else None
583
+ raw_asym = self.split_asymmetry(target_rind_mask, midline)
584
+ raw_f_asym = self.split_asymmetry(flesh_combined, midline, thickness=3)
585
+ midline_curve = self.midline_curvature_score(midline)
586
+
587
+ # 5. SMOOTHED FEATURES
588
+ sm_w, sm_h, sm_perim, sm_rt, sm_tot_a, sm_f_a, sm_f_rat = None, None, None, None, None, None, None
589
+ sm_elong, sm_circ, sm_asym, sm_f_asym, r2_rind, r2_flesh = None, None, None, None, None, None, None
590
+ sm_rind_cnt, sm_flesh_cnt = None, None
591
+ sm_h_line, sm_w_line = None, None
592
+
593
+ try:
594
+ # Fit Rind
595
+ scale_r = np.mean(raw_r_r)
596
+ popt_r, _ = curve_fit(self.watermelon_model, t_r, raw_r_r/scale_r,
597
+ p0=[1.0, 1.1, 0.0, 0.05, 3.0, 0.05, 3.0, 0.0, 0.0, 0.0],
598
+ bounds=([0.5, 0.5, -0.4, 0.0, 0.1, 0.0, 0.1, -1.5, -0.2, -0.2], [2.0, 2.0, 0.4, 0.5, 50.0, 0.5, 50.0, 1.5, 0.2, 0.2]), max_nfev=3000)
599
+ d_r = np.sum((raw_r_r/scale_r - 1)**2)
600
+ r2_rind = 1 - (np.sum((raw_r_r/scale_r - self.watermelon_model(t_r, *popt_r))**2) / d_r) if d_r != 0 else None
601
+
602
+ t_fit = np.linspace(-np.pi, np.pi, 500)
603
+ fit_r = self.watermelon_model(t_fit, *popt_r) * scale_r
604
+ sm_rind_pts = np.array([[r*np.cos(t)+cx, cy-r*np.sin(t)] for t, r in zip(t_fit, fit_r)], dtype=np.float32)
605
+ sm_rind_cnt = sm_rind_pts.reshape(-1, 1, 2).astype(np.int32)
606
+
607
+ sm_rind_mask = np.zeros_like(target_rind_mask)
608
+ cv2.fillPoly(sm_rind_mask, [sm_rind_cnt], 255)
609
+
610
+ sm_perim = float(np.sum(np.linalg.norm(np.diff(sm_rind_pts, axis=0), axis=1)) + np.linalg.norm(sm_rind_pts[-1]-sm_rind_pts[0]))
611
+ sm_tot_a = self.contour_area_px(sm_rind_pts)
612
+ sm_elong = self.elongation_from_points(sm_rind_pts)
613
+ sm_circ = float((4.0 * np.pi * sm_tot_a) / (sm_perim ** 2)) if sm_perim > 0 and sm_tot_a > 0 else None
614
+ sm_asym = self.split_asymmetry(sm_rind_mask, midline)
615
+ sm_phi = popt_r[7]
616
+
617
+ # Fit Flesh
618
+ if flesh_data:
619
+ t_f, raw_r_f, (fcx, fcy), _ = flesh_data
620
+ scale_f = np.mean(raw_r_f)
621
+ popt_f, _ = curve_fit(self.watermelon_model, t_f, raw_r_f/scale_f,
622
+ p0=[1.0, 1.1, 0.0, 0.05, 3.0, 0.05, 3.0, 0.0, 0.0, 0.0],
623
+ bounds=([0.5, 0.5, -0.4, 0.0, 0.1, 0.0, 0.1, -1.5, -0.2, -0.2], [2.0, 2.0, 0.4, 0.5, 50.0, 0.5, 50.0, 1.5, 0.2, 0.2]), max_nfev=3000)
624
+ d_f = np.sum((raw_r_f/scale_f - 1)**2)
625
+ r2_flesh = 1 - (np.sum((raw_r_f/scale_f - self.watermelon_model(t_f, *popt_f))**2) / d_f) if d_f != 0 else None
626
+
627
+ fit_f = self.watermelon_model(t_fit, *popt_f) * scale_f
628
+ sm_flesh_pts = np.array([[r*np.cos(t)+fcx, fcy-r*np.sin(t)] for t, r in zip(t_fit, fit_f)], dtype=np.float32)
629
+ sm_flesh_cnt = sm_flesh_pts.reshape(-1, 1, 2).astype(np.int32)
630
+
631
+ sm_flesh_mask = np.zeros_like(target_rind_mask)
632
+ cv2.fillPoly(sm_flesh_mask, [sm_flesh_cnt], 255)
633
+
634
+ sm_f_a = self.contour_area_px(sm_flesh_pts)
635
+ sm_flesh_asym = self.split_asymmetry(sm_flesh_mask, midline, thickness=3)
636
+ else:
637
+ sm_flesh_mask = np.zeros_like(target_rind_mask)
638
 
639
+ # Smooth axes (using the filled smooth masks)
640
+ sm_h, sm_w, sm_rt, sm_h_line, sm_w_line = self.calculate_axis_metrics(cx, cy, sm_phi, sm_rind_mask, sm_flesh_mask)
641
+ if sm_tot_a > 0 and sm_f_a is not None:
642
+ sm_f_rat = float(sm_f_a / sm_tot_a)
643
 
644
+ except Exception as exc:
645
+ warnings.append(f"Smoothing fit failed: {exc}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
646
  mark("fit")
647
 
648
+ # 6. APPLY SCALES
649
+ sc_src = "color_checker" if cm_per_px else "original_pixels"
650
+ m_unit = "cm" if cm_per_px else "px"
651
+ a_unit = "cm2" if cm_per_px else "px2"
652
+ scaler = cm_per_px if cm_per_px else (1.0 / scale_ratio)
653
+ a_scaler = scaler ** 2
 
 
 
 
 
 
 
 
 
 
 
 
 
654
 
655
+ def s(v): return float(v * scaler) if v is not None else None
656
+ def a(v): return float(v * a_scaler) if v is not None else None
657
+ def rt_rat(thick, w): return float((thick * 2.0) / w) if thick is not None and w and w > 0 else None
658
 
659
+ res = ProcessResult(
660
+ success=True, message="Success", filename=source_name, measurement_unit=m_unit, area_unit=a_unit,
661
+ scale_source=sc_src, color_checker_found=bool(cm_per_px),
662
+ rind_source=rind_source, rind_overlap_ratio=rind_overlap_ratio, warnings=warnings or None,
663
+ r2_rind=r2_rind, r2_flesh=r2_flesh, midline_curvature=midline_curve,
664
+ delta_e_initial=dE_initial, delta_e_final=dE_final, timings_ms=timings,
 
 
 
 
 
 
 
 
665
 
666
+ raw_width=s(raw_w), sm_width=s(sm_w), raw_height=s(raw_h), sm_height=s(sm_h),
667
+ raw_perimeter=s(raw_perim), sm_perimeter=s(sm_perim),
668
+ raw_rind_thick=s(raw_rt), sm_rind_thick=s(sm_rt),
669
+ raw_rind_ratio=rt_rat(raw_rt, raw_w), sm_rind_ratio=rt_rat(sm_rt, sm_w),
670
+ raw_total_area=a(raw_tot_a), sm_total_area=a(sm_tot_a),
671
+ raw_flesh_area=a(raw_f_a), sm_flesh_area=a(sm_f_a),
672
+ raw_flesh_ratio=raw_f_rat, sm_flesh_ratio=sm_f_rat,
673
+ raw_elongation=raw_elong, sm_elongation=sm_elong,
674
+ raw_asym=raw_asym, sm_asym=sm_asym, raw_flesh_asym=raw_f_asym, sm_flesh_asym=sm_flesh_asym,
675
+ raw_circ=raw_circ, sm_circ=sm_circ
 
 
 
 
 
 
 
 
676
  )
677
 
678
+ # 7. DRAW PREVIEWS
679
+ if include_image:
680
+ def encode_img(canvas):
681
+ _, b = cv2.imencode(".jpg", canvas, [cv2.IMWRITE_JPEG_QUALITY, 85])
682
+ return base64.b64encode(b).decode("utf-8")
683
+
684
+ def draw_base(r_m, f_m):
685
+ out = image.copy().astype(np.float32)
686
+ alpha = 0.42
687
+ # Green tint for rind
688
+ out[..., 0] = np.where(r_m > 0, out[..., 0]*(1-alpha) + 0, out[..., 0])
689
+ out[..., 1] = np.where(r_m > 0, out[..., 1]*(1-alpha) + 170, out[..., 1])
690
+ out[..., 2] = np.where(r_m > 0, out[..., 2]*(1-alpha) + 0, out[..., 2])
691
+
692
+ # Orange/Flesh tint for the flesh
693
+ out[..., 0] = np.where(f_m > 0, out[..., 0]*(1-alpha) + 60, out[..., 0])
694
+ out[..., 1] = np.where(f_m > 0, out[..., 1]*(1-alpha) + 120, out[..., 1])
695
+ out[..., 2] = np.where(f_m > 0, out[..., 2]*(1-alpha) + 255, out[..., 2])
696
+
697
+ out = np.clip(out, 0, 255).astype(np.uint8)
698
+ if checker_corners is not None: cv2.polylines(out, [np.int32(checker_corners)], True, (0, 165, 255), 4)
699
+ if len(midline) > 1:
700
+ cv2.polylines(out, [midline.astype(np.int32)], False, (0, 255, 255), 3)
701
+ pt1, pt2 = tuple(midline[0].astype(int)), tuple(midline[-1].astype(int))
702
+ for pt in (pt1, pt2):
703
+ cv2.circle(out, pt, 8, (0,0,0), 2); cv2.circle(out, pt, 6, (255,255,255), -1)
704
+ return out
705
+
706
+ # RAW
707
+ out_raw = draw_base(target_rind_mask, flesh_closed)
708
+ cv2.line(out_raw, raw_h_line[0], raw_h_line[1], (255, 100, 255), 2)
709
+ cv2.line(out_raw, raw_w_line[0], raw_w_line[1], (255, 255, 100), 2)
710
+ # Flesh border first
711
+ f_cnts_raw, _ = cv2.findContours(flesh_closed, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
712
+ if f_cnts_raw: cv2.polylines(out_raw, [max(f_cnts_raw, key=cv2.contourArea)], True, (255, 50, 50), 3)
713
+ # Rind border
714
+ cv2.polylines(out_raw, [raw_rind_cnt], True, (0, 200, 0), 3)
715
+ res.image_raw_base64 = encode_img(out_raw)
716
+
717
+ # SMOOTH
718
+ if sm_rind_cnt is not None:
719
+ out_sm = draw_base(sm_rind_mask, sm_flesh_mask)
720
+ cv2.line(out_sm, sm_h_line[0], sm_h_line[1], (255, 100, 255), 2)
721
+ cv2.line(out_sm, sm_w_line[0], sm_w_line[1], (255, 255, 100), 2)
722
+
723
+ # Thinner lines, distinct bright colors
724
+ if sm_flesh_cnt is not None: cv2.polylines(out_sm, [sm_flesh_cnt], True, (255, 150, 50), 2)
725
+ cv2.polylines(out_sm, [sm_rind_cnt], True, (0, 255, 127), 2)
726
+ res.image_sm_base64 = encode_img(out_sm)
727
+
728
+ mark("render")
729
+ return res
730
  app = FastAPI()
731
 
732
  app.add_middleware(
 
742
  async def process_single(
743
  file: UploadFile = File(...),
744
  include_image: bool = Query(True),
 
745
  password: str = Form("")
746
  ):
747
  request_t = time.perf_counter()
 
772
  scale_ratio = MAX_IMAGE_SIZE / float(max(h, w))
773
  img = cv2.resize(img, (int(w * scale_ratio), int(h * scale_ratio)), interpolation=cv2.INTER_AREA)
774
 
775
+ res = processor.process_image(img, file.filename, scale_ratio, include_image=include_image)
776
  res.processing_ms = int(round((time.perf_counter() - request_t) * 1000))
777
  return res.__dict__
778