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

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +151 -185
main.py CHANGED
@@ -160,15 +160,13 @@ class WatermelonProcessor:
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,8 +178,7 @@ class WatermelonProcessor:
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,7 +196,7 @@ class WatermelonProcessor:
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,7 +206,7 @@ class WatermelonProcessor:
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,32 +272,23 @@ class WatermelonProcessor:
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,56 +325,61 @@ class WatermelonProcessor:
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) -> ProcessResult:
 
 
 
 
390
  timings = {}
391
  stage_t = time.perf_counter()
392
 
@@ -406,10 +399,8 @@ class WatermelonProcessor:
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,14 +422,12 @@ class WatermelonProcessor:
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,38 +436,29 @@ class WatermelonProcessor:
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,35 +468,37 @@ class WatermelonProcessor:
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
- try:
506
- popt, _ = curve_fit(
507
- self.watermelon_model, t_data, r_raw / scale,
508
- p0=[1.0, 1.1, 0.0, 0.05, 3.0, 0.05, 3.0, 0.0, 0.0, 0.0],
509
- 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]),
510
- max_nfev=3000,
511
- )
512
- except Exception as exc:
513
- mark("fit")
514
- return fail(f"Fit failed: {exc}", rind_source=rind_source, rind_overlap_ratio=rind_overlap_ratio)
 
515
 
516
- r2 = 1 - (np.sum((r_raw / scale - self.watermelon_model(t_data, *popt)) ** 2) / np.sum((r_raw / scale - 1) ** 2))
517
- t_fit = np.linspace(-np.pi, np.pi, 500)
518
- r_fit = self.watermelon_model(t_fit, *popt) * scale
519
- fit_pts = np.array([[r * np.cos(t) + cx, cy - r * np.sin(t)] for t, r in zip(t_fit, r_fit)])
 
 
 
 
 
 
520
 
521
  width_px = float(np.max(fit_pts[:, 0]) - np.min(fit_pts[:, 0]))
522
  height_px = float(np.max(fit_pts[:, 1]) - np.min(fit_pts[:, 1]))
@@ -526,85 +508,79 @@ class WatermelonProcessor:
526
  flesh_area_ratio = float(flesh_area_px / total_area_px) if total_area_px > 0 else None
527
  elongation_factor = self.elongation_from_points(fit_pts)
528
  circularity = float((4.0 * np.pi * total_area_px) / (perimeter_px ** 2)) if perimeter_px > 0 and total_area_px > 0 else None
529
- midline = self.get_dual_mask_midline(flesh_l_m, flesh_r_m, rind_cnt, fit_pts, cx, cy)
 
 
530
  asymmetry_score = self.split_asymmetry(target_rind_mask, midline)
531
  flesh_asymmetry_score = self.split_asymmetry(flesh_combined, midline, thickness=3)
532
  midline_curvature = self.midline_curvature_score(midline)
533
 
534
  if cm_per_px is not None:
535
- measurement_unit = "cm"
536
- area_unit = "cm2"
537
- scale_source = "color_checker"
538
  area_scale = cm_per_px ** 2
539
- width_val = float(width_px * cm_per_px)
540
- height_val = float(height_px * cm_per_px)
541
- perimeter_val = float(perimeter_px * cm_per_px)
542
  else:
543
- measurement_unit = "px"
544
- area_unit = "px2"
545
- scale_source = "original_pixels"
546
  orig_scale = 1.0 / scale_ratio
547
  area_scale = orig_scale ** 2
548
- width_val = float(width_px * orig_scale)
549
- height_val = float(height_px * orig_scale)
550
- perimeter_val = float(perimeter_px * orig_scale)
551
- total_area = float(total_area_px * area_scale)
552
- flesh_area = float(flesh_area_px * area_scale)
553
  mark("fit")
554
 
555
  # --- 4. DRAWING ---
556
  img_base64 = None
557
- if include_image:
558
- # Color coding: Green=chosen rind, Blue=Left Flesh, Red=Right Flesh.
559
- output = image.copy().astype(np.float32)
560
- alpha = 0.42
561
- output[..., 0] = np.where(target_rind_mask > 0, output[..., 0] * (1 - alpha) + 0.0 * alpha, output[..., 0])
562
- output[..., 1] = np.where(target_rind_mask > 0, output[..., 1] * (1 - alpha) + 170.0 * alpha, output[..., 1])
563
- output[..., 2] = np.where(target_rind_mask > 0, output[..., 2] * (1 - alpha) + 0.0 * alpha, output[..., 2])
564
-
565
- output[..., 0] = np.where(flesh_l_m > 0, output[..., 0] * (1 - alpha) + 255.0 * alpha, output[..., 0])
566
- output[..., 1] = np.where(flesh_l_m > 0, output[..., 1] * (1 - alpha) + 0.0 * alpha, output[..., 1])
567
- output[..., 2] = np.where(flesh_l_m > 0, output[..., 2] * (1 - alpha) + 0.0 * alpha, output[..., 2])
568
-
569
- output[..., 0] = np.where(flesh_r_m > 0, output[..., 0] * (1 - alpha) + 0.0 * alpha, output[..., 0])
570
- output[..., 1] = np.where(flesh_r_m > 0, output[..., 1] * (1 - alpha) + 0.0 * alpha, output[..., 1])
571
- output[..., 2] = np.where(flesh_r_m > 0, output[..., 2] * (1 - alpha) + 255.0 * alpha, output[..., 2])
572
-
573
- output = np.clip(output, 0, 255).astype(np.uint8)
574
-
575
- if checker_corners is not None:
576
- cv2.polylines(output, [np.int32(checker_corners)], True, (0, 165, 255), 4)
577
-
578
- if len(midline) > 1:
579
- cv2.polylines(output, [midline.astype(np.int32)], False, (0, 255, 255), 3)
580
- pt_top = (int(midline[0][0]), int(midline[0][1]))
581
- pt_bot = (int(midline[-1][0]), int(midline[-1][1]))
582
- cv2.circle(output, pt_top, 10, (0, 0, 0), 2)
583
- cv2.circle(output, pt_top, 8, (255, 255, 255), -1)
584
- cv2.circle(output, pt_bot, 10, (0, 0, 0), 2)
585
- cv2.circle(output, pt_bot, 8, (255, 255, 255), -1)
586
-
587
- cv2.polylines(output, [fit_pts.astype(np.int32)], True, (0, 255, 0), 3)
588
- _, buffer = cv2.imencode(".jpg", output, [cv2.IMWRITE_JPEG_QUALITY, 85])
589
- img_base64 = base64.b64encode(buffer).decode("utf-8")
 
 
 
590
  mark("render")
591
 
592
  return ProcessResult(
593
- success=True, message="Success", r2_score=float(r2),
594
  width_val=width_val, height_val=height_val, perimeter_val=perimeter_val,
595
  total_area=total_area, flesh_area=flesh_area, flesh_area_ratio=flesh_area_ratio,
596
  elongation_factor=elongation_factor, circularity=circularity,
597
  asymmetry_score=asymmetry_score, flesh_asymmetry_score=flesh_asymmetry_score,
598
- midline_curvature=midline_curvature,
599
- delta_e_initial=dE_initial, delta_e_final=dE_final,
600
- image_base64=img_base64, filename=source_name,
601
- measurement_unit=measurement_unit, area_unit=area_unit, scale_source=scale_source,
602
- color_checker_found=checker_corners is not None,
603
  rind_source=rind_source, rind_overlap_ratio=rind_overlap_ratio,
604
  warnings=warnings or None, timings_ms=timings
605
  )
606
 
607
-
608
  app = FastAPI()
609
 
610
  app.add_middleware(
@@ -617,23 +593,18 @@ processor = WatermelonProcessor(MODEL_PATH)
617
  def read_root(): return {"status": "Watermelon API is awake and running!"}
618
 
619
  @app.post("/process_single")
620
- async def process_single(file: UploadFile = File(...), include_image: bool = Query(True)):
621
  request_t = time.perf_counter()
622
- contents = None
623
- img = None
624
 
625
  try:
626
  contents = await file.read()
627
  if not contents:
628
- res = ProcessResult(success=False, message="Empty upload.", filename=file.filename)
629
- res.processing_ms = int(round((time.perf_counter() - request_t) * 1000))
630
- return res.__dict__
631
 
632
  img = cv2.imdecode(np.frombuffer(contents, np.uint8), cv2.IMREAD_COLOR)
633
  if img is None:
634
- res = ProcessResult(success=False, message="Could not decode image.", filename=file.filename)
635
- res.processing_ms = int(round((time.perf_counter() - request_t) * 1000))
636
- return res.__dict__
637
 
638
  scale_ratio = 1.0
639
  h, w = img.shape[:2]
@@ -641,20 +612,15 @@ async def process_single(file: UploadFile = File(...), include_image: bool = Que
641
  scale_ratio = MAX_IMAGE_SIZE / float(max(h, w))
642
  img = cv2.resize(img, (int(w * scale_ratio), int(h * scale_ratio)), interpolation=cv2.INTER_AREA)
643
 
644
- res = processor.process_image(img, file.filename, scale_ratio, include_image=include_image)
 
645
  res.processing_ms = int(round((time.perf_counter() - request_t) * 1000))
646
  return res.__dict__
647
 
648
  except Exception as exc:
649
  traceback.print_exc()
650
- res = ProcessResult(
651
- success=False,
652
- message=f"Server error: {type(exc).__name__}: {exc}",
653
- filename=file.filename,
654
- processing_ms=int(round((time.perf_counter() - request_t) * 1000)),
655
- )
656
- return res.__dict__
657
 
658
  finally:
659
  del img, contents
660
- gc.collect()
 
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
  @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
 
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
  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
 
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
  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
  **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
  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
  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
  # --- 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,
484
+ p0=[1.0, 1.1, 0.0, 0.05, 3.0, 0.05, 3.0, 0.0, 0.0, 0.0],
485
+ 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]),
486
+ max_nfev=3000,
487
+ )
488
+ except Exception as exc:
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]))
504
  height_px = float(np.max(fit_pts[:, 1]) - np.min(fit_pts[:, 1]))
 
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(
 
593
  def read_root(): return {"status": "Watermelon API is awake and running!"}
594
 
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
  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
626
+ gc.collect()