crabbly commited on
Commit
c85fc32
·
verified ·
1 Parent(s): 103df15

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +96 -93
main.py CHANGED
@@ -13,18 +13,17 @@ from fastapi import FastAPI, UploadFile, File
13
  from fastapi.middleware.cors import CORSMiddleware
14
  import uvicorn
15
 
16
- # OOM PREVENTION
17
  torch.set_num_threads(1)
18
 
19
- # Import your helpers
20
  from cv_helpers import blend_mask_overlays, stem_tip_tangent_deg
21
  import color_calibration as calib
22
  import test_color_eval as eval
23
 
24
  # --- CONFIGURATION ---
25
  MODEL_PATH = "best.pt"
26
- MAX_IMAGE_SIZE = 1024
27
- CHECKER_WIDTH_MM = 63.0 # Physical width of the ColorChecker
 
28
 
29
  @dataclass
30
  class ProcessResult:
@@ -43,7 +42,6 @@ class WatermelonProcessor:
43
  def __init__(self, model_path: str):
44
  self.model = YOLO(model_path)
45
 
46
- # Load the Golden Reference once on startup
47
  self.ref24 = None
48
  if os.path.exists("reference.png"):
49
  try:
@@ -55,7 +53,7 @@ class WatermelonProcessor:
55
  except Exception as e:
56
  print(f"Failed to extract reference patches: {e}")
57
  else:
58
- print("WARNING: 'reference.png' not found. Calibration will be skipped.")
59
 
60
  @staticmethod
61
  def watermelon_model(theta, Rx, Ry, c_a, d_top, w_top, d_bot, w_bot, phi, c_skew, c_bend):
@@ -67,23 +65,26 @@ class WatermelonProcessor:
67
  return (ellipse * asymmetry) - divot_top - divot_bot + c_skew * np.sin(t) + c_bend * np.cos(t) * (np.sin(t) ** 2)
68
 
69
  @staticmethod
70
- def get_stable_perimeter_data(rind_mask, flesh_mask):
71
  cnts, _ = cv2.findContours(rind_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
72
  if not cnts: return None
 
 
73
  best_cnt = None
74
  max_overlap = -1
75
  for cnt in cnts:
76
  temp_mask = np.zeros_like(rind_mask)
77
  cv2.drawContours(temp_mask, [cnt], -1, 255, -1)
78
- overlap_area = cv2.countNonZero(cv2.bitwise_and(temp_mask, flesh_mask))
79
  if overlap_area > max_overlap:
80
  max_overlap = overlap_area
81
  best_cnt = cnt
 
82
  if best_cnt is None: best_cnt = max(cnts, key=cv2.contourArea)
83
- moments = cv2.moments(best_cnt)
84
- if moments["m00"] == 0: return None
85
 
86
- cx, cy = moments["m10"] / moments["m00"], moments["m01"] / moments["m00"]
87
  pts = best_cnt.reshape(-1, 2)
88
  dx, dy = pts[:, 0] - cx, cy - pts[:, 1]
89
  r_vals, t_vals = np.sqrt(dx**2 + dy**2), np.arctan2(dy, dx)
@@ -102,8 +103,8 @@ class WatermelonProcessor:
102
  return final_theta, final_r, (cx, cy), best_cnt
103
 
104
  @staticmethod
105
- def get_ray_scan_midline(flesh_mask, rind_cnt, predicted_cnt, cx, cy):
106
- h, w = flesh_mask.shape
107
  if len(rind_cnt) > 5:
108
  _, (ma, Ma), angle = cv2.fitEllipse(rind_cnt)
109
  rot_angle = angle if ma < Ma else angle + 90
@@ -111,42 +112,40 @@ class WatermelonProcessor:
111
 
112
  m_rot = cv2.getRotationMatrix2D((cx, cy), rot_angle, 1.0)
113
  m_inv = cv2.getRotationMatrix2D((cx, cy), -rot_angle, 1.0)
114
- f_rot = cv2.warpAffine(flesh_mask, m_rot, (w, h))
 
 
 
 
 
 
 
115
 
116
  gap_points =[]
117
- y_indices, _ = np.where(f_rot > 0)
118
- if len(y_indices) > 0:
119
- y_min, y_max = np.min(y_indices), np.max(y_indices)
120
- for y in range(y_min, y_max):
121
- row = f_rot[y, :]
122
- white_px = np.where(row > 0)[0]
123
- if len(white_px) >= 2:
124
- first, last = white_px[0], white_px[-1]
125
- blanks_in_between = np.where(row[first:last] == 0)[0] + first
126
- if len(blanks_in_between) > 0:
127
- gap_points.append([y, np.median(blanks_in_between)])
128
 
129
  gap_points = np.array(gap_points)
130
  if len(gap_points) > 10:
131
  y_min, y_max = np.min(gap_points[:, 0]), np.max(gap_points[:, 0])
132
  y_span, y_mean = max(y_max - y_min, 1), (y_max + y_min) / 2.0
133
- y_norm = (gap_points[:, 0] - y_mean) / y_span
134
- x_data = gap_points[:, 1]
135
 
136
  def parabola(y_n, a, b, c): return a * (y_n**2) + b * y_n + c
137
-
138
  max_bend = w * 0.08
139
  try: popt_mid, _ = curve_fit(parabola, y_norm, x_data, bounds=([-max_bend, -np.inf, -np.inf], [max_bend, np.inf, np.inf]))
140
  except: popt_mid = [0.0, 0.0, cx]
141
 
142
  ys_extrap = np.linspace(0, h, 500)
143
- ys_extrap_norm = (ys_extrap - y_mean) / y_span
144
- xs_extrap = parabola(ys_extrap_norm, *popt_mid)
145
  pts_rot = np.vstack([xs_extrap, ys_extrap, np.ones_like(xs_extrap)])
146
  else:
147
  ys_extrap = np.linspace(0, h, 500)
148
- xs_extrap = np.full_like(ys_extrap, cx)
149
- pts_rot = np.vstack([xs_extrap, ys_extrap, np.ones_like(xs_extrap)])
150
 
151
  pts_orig = (m_inv @ pts_rot).T
152
  pred_cnt_cv = predicted_cnt.reshape(-1, 1, 2).astype(np.int32)
@@ -156,50 +155,58 @@ class WatermelonProcessor:
156
  if image is None: return ProcessResult(success=False, message="Could not decode image.")
157
  h, w = image.shape[:2]
158
 
159
- # --- CALIBRATION & SCALING ---
160
- dE_initial, dE_final, mm_per_px, checker_corners = None, None, None, None
161
 
162
- if self.ref24 is not None:
163
- try:
164
- # 1. Detect Checker
165
- checker_corners = calib.detect_checker_corners(image)
166
-
167
- # 2. Calculate mm/px Scale
168
- # Corners:[Bottom-Left, Top-Left, Top-Right, Bottom-Right]
169
- top_width = np.linalg.norm(checker_corners[1] - checker_corners[2])
170
- bot_width = np.linalg.norm(checker_corners[0] - checker_corners[3])
171
- px_width = (top_width + bot_width) / 2.0
172
- mm_per_px = CHECKER_WIDTH_MM / px_width
173
-
174
- # 3. Apply Calibration Pipeline
175
  tgt_warped = calib.warp_checker(image, checker_corners)
176
  tgt24 = calib.sample_24_patches(tgt_warped)
177
 
178
  dE_initial = float(np.mean(eval.compute_deltaE_00(tgt24, self.ref24)))
179
- tgt24_corr = eval.apply_pipeline_to_patches(tgt24, self.ref24)
180
- dE_final = float(np.mean(eval.compute_deltaE_00(tgt24_corr, self.ref24)))
181
 
 
182
  image = calib.apply_pipeline(image, self.ref24, tgt24)
183
- except Exception as e:
184
- print(f"Calibration skipped for {source_name}: {e}")
 
 
 
 
 
185
 
186
- # Fallback to pure pixels if checker not found
187
- if mm_per_px is None:
188
- mm_per_px = 1.0 / scale_ratio
189
-
190
- # --- YOLO INFERENCE ---
191
- results = self.model(image, conf=0.25, verbose=False)
192
- rind_mask, flesh_mask = np.zeros((h, w), dtype=np.uint8), np.zeros((h, w), dtype=np.uint8)
193
-
194
- if results[0].masks is None:
195
- return ProcessResult(success=False, message="No masks detected.")
196
-
197
- for mask_data, cls in zip(results[0].masks.xy, results[0].boxes.cls):
198
- contour = np.array(mask_data, dtype=np.int32)
199
- if int(cls) == 0: cv2.drawContours(rind_mask, [contour], -1, 255, -1)
200
- elif int(cls) == 1: cv2.drawContours(flesh_mask,[contour], -1, 255, -1)
201
-
202
- perimeter_data = self.get_stable_perimeter_data(rind_mask, flesh_mask)
 
 
 
 
 
 
 
 
 
203
  if perimeter_data is None: return ProcessResult(success=False, message="No stable perimeter.")
204
 
205
  t_data, r_raw, (cx, cy), rind_cnt = perimeter_data
@@ -218,37 +225,39 @@ class WatermelonProcessor:
218
  r_fit = self.watermelon_model(t_fit, *popt) * scale
219
  fit_pts = np.array([[r * np.cos(t) + cx, cy - r * np.sin(t)] for t, r in zip(t_fit, r_fit)])
220
 
221
- # --- FEATURE EXTRACTION (IN MM) ---
 
 
 
222
  width_px = float(np.max(fit_pts[:, 0]) - np.min(fit_pts[:, 0]))
223
  height_px = float(np.max(fit_pts[:, 1]) - np.min(fit_pts[:, 1]))
224
- diffs = np.diff(fit_pts, axis=0)
225
- perimeter_px = float(np.sum(np.linalg.norm(diffs, axis=1)) + np.linalg.norm(fit_pts[-1] - fit_pts[0]))
226
-
227
- width_val = width_px * mm_per_px
228
- height_val = height_px * mm_per_px
229
- perimeter_val = perimeter_px * mm_per_px
230
 
231
- # --- DRAWING ---
232
- midline = self.get_ray_scan_midline(flesh_mask, rind_cnt, fit_pts, cx, cy)
233
- output = blend_mask_overlays(image, rind_mask, flesh_mask)
234
- if len(midline) > 1: cv2.polylines(output, [midline.astype(np.int32)], False, (0, 255, 255), 3)
235
- cv2.polylines(output, [fit_pts.astype(np.int32)], True, (0, 255, 0), 3)
236
 
237
- # Draw Color Checker Box
 
 
 
 
238
  if checker_corners is not None:
239
- cv2.polylines(output,[np.int32(checker_corners)], True, (0, 165, 255), 4)
 
 
 
240
 
241
  stem = stem_tip_tangent_deg(rind_cnt, (cx, cy))
242
  if stem is not None:
243
  tx, ty, tdeg = stem
244
  L = min(w, h) * 0.08
245
- rad = np.deg2rad(tdeg)
246
  p1 = (int(round(tx)), int(round(ty)))
247
- p2 = (int(round(tx + L * np.cos(rad))), int(round(ty + L * np.sin(rad))))
248
  cv2.circle(output, p1, 6, (255, 0, 255), -1)
249
  cv2.line(output, p1, p2, (255, 0, 255), 2)
250
 
251
- _, buffer = cv2.imencode('.jpg', output,[cv2.IMWRITE_JPEG_QUALITY, 85])
252
  img_base64 = base64.b64encode(buffer).decode('utf-8')
253
 
254
  return ProcessResult(
@@ -259,22 +268,16 @@ class WatermelonProcessor:
259
  )
260
 
261
 
262
- # --- FASTAPI APP ---
263
  app = FastAPI()
264
 
265
  app.add_middleware(
266
- CORSMiddleware,
267
- allow_origins=["*"],
268
- allow_credentials=True,
269
- allow_methods=["*"],
270
- allow_headers=["*"],
271
  )
272
 
273
  processor = WatermelonProcessor(MODEL_PATH)
274
 
275
  @app.get("/")
276
- def read_root():
277
- return {"status": "Watermelon API is awake and running!"}
278
 
279
  @app.post("/process_single")
280
  async def process_single(file: UploadFile = File(...)):
 
13
  from fastapi.middleware.cors import CORSMiddleware
14
  import uvicorn
15
 
 
16
  torch.set_num_threads(1)
17
 
 
18
  from cv_helpers import blend_mask_overlays, stem_tip_tangent_deg
19
  import color_calibration as calib
20
  import test_color_eval as eval
21
 
22
  # --- CONFIGURATION ---
23
  MODEL_PATH = "best.pt"
24
+ # HF has 16GB RAM, so we can use a higher resolution to ensure the checkerboard is detected!
25
+ MAX_IMAGE_SIZE = 2048
26
+ CHECKER_WIDTH_CM = 6.3 # 63 mm = 6.3 cm
27
 
28
  @dataclass
29
  class ProcessResult:
 
42
  def __init__(self, model_path: str):
43
  self.model = YOLO(model_path)
44
 
 
45
  self.ref24 = None
46
  if os.path.exists("reference.png"):
47
  try:
 
53
  except Exception as e:
54
  print(f"Failed to extract reference patches: {e}")
55
  else:
56
+ print("WARNING: 'reference.png' not found. Color Correction will be skipped.")
57
 
58
  @staticmethod
59
  def watermelon_model(theta, Rx, Ry, c_a, d_top, w_top, d_bot, w_bot, phi, c_skew, c_bend):
 
65
  return (ellipse * asymmetry) - divot_top - divot_bot + c_skew * np.sin(t) + c_bend * np.cos(t) * (np.sin(t) ** 2)
66
 
67
  @staticmethod
68
+ def get_stable_perimeter_data(rind_mask, flesh_combined):
69
  cnts, _ = cv2.findContours(rind_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
70
  if not cnts: return None
71
+
72
+ # Pick the rind that specifically surrounds the flesh
73
  best_cnt = None
74
  max_overlap = -1
75
  for cnt in cnts:
76
  temp_mask = np.zeros_like(rind_mask)
77
  cv2.drawContours(temp_mask, [cnt], -1, 255, -1)
78
+ overlap_area = cv2.countNonZero(cv2.bitwise_and(temp_mask, flesh_combined))
79
  if overlap_area > max_overlap:
80
  max_overlap = overlap_area
81
  best_cnt = cnt
82
+
83
  if best_cnt is None: best_cnt = max(cnts, key=cv2.contourArea)
84
+ M = cv2.moments(best_cnt)
85
+ if M["m00"] == 0: return None
86
 
87
+ cx, cy = M["m10"] / M["m00"], M["m01"] / M["m00"]
88
  pts = best_cnt.reshape(-1, 2)
89
  dx, dy = pts[:, 0] - cx, cy - pts[:, 1]
90
  r_vals, t_vals = np.sqrt(dx**2 + dy**2), np.arctan2(dy, dx)
 
103
  return final_theta, final_r, (cx, cy), best_cnt
104
 
105
  @staticmethod
106
+ def get_dual_mask_midline(f_left, f_right, rind_cnt, predicted_cnt, cx, cy):
107
+ h, w = f_left.shape
108
  if len(rind_cnt) > 5:
109
  _, (ma, Ma), angle = cv2.fitEllipse(rind_cnt)
110
  rot_angle = angle if ma < Ma else angle + 90
 
112
 
113
  m_rot = cv2.getRotationMatrix2D((cx, cy), rot_angle, 1.0)
114
  m_inv = cv2.getRotationMatrix2D((cx, cy), -rot_angle, 1.0)
115
+ l_rot = cv2.warpAffine(f_left, m_rot, (w, h))
116
+ r_rot = cv2.warpAffine(f_right, m_rot, (w, h))
117
+
118
+ l_idx = np.where(l_rot > 0)[1]
119
+ r_idx = np.where(r_rot > 0)[1]
120
+ if len(l_idx) > 0 and len(r_idx) > 0:
121
+ if np.mean(l_idx) > np.mean(r_idx):
122
+ l_rot, r_rot = r_rot, l_rot
123
 
124
  gap_points =[]
125
+ y_l, y_r = np.where(l_rot > 0)[0], np.where(r_rot > 0)[0]
126
+ if len(y_l) > 0 and len(y_r) > 0:
127
+ for y in range(max(np.min(y_l), np.min(y_r)), min(np.max(y_l), np.max(y_r))):
128
+ row_l, row_r = np.where(l_rot[y, :] > 0)[0], np.where(r_rot[y, :] > 0)[0]
129
+ if len(row_l) > 0 and len(row_r) > 0:
130
+ gap_points.append([y, (row_l[-1] + row_r[0]) / 2.0])
 
 
 
 
 
131
 
132
  gap_points = np.array(gap_points)
133
  if len(gap_points) > 10:
134
  y_min, y_max = np.min(gap_points[:, 0]), np.max(gap_points[:, 0])
135
  y_span, y_mean = max(y_max - y_min, 1), (y_max + y_min) / 2.0
136
+ y_norm, x_data = (gap_points[:, 0] - y_mean) / y_span, gap_points[:, 1]
 
137
 
138
  def parabola(y_n, a, b, c): return a * (y_n**2) + b * y_n + c
 
139
  max_bend = w * 0.08
140
  try: popt_mid, _ = curve_fit(parabola, y_norm, x_data, bounds=([-max_bend, -np.inf, -np.inf], [max_bend, np.inf, np.inf]))
141
  except: popt_mid = [0.0, 0.0, cx]
142
 
143
  ys_extrap = np.linspace(0, h, 500)
144
+ xs_extrap = parabola((ys_extrap - y_mean) / y_span, *popt_mid)
 
145
  pts_rot = np.vstack([xs_extrap, ys_extrap, np.ones_like(xs_extrap)])
146
  else:
147
  ys_extrap = np.linspace(0, h, 500)
148
+ pts_rot = np.vstack([np.full_like(ys_extrap, cx), ys_extrap, np.ones_like(ys_extrap)])
 
149
 
150
  pts_orig = (m_inv @ pts_rot).T
151
  pred_cnt_cv = predicted_cnt.reshape(-1, 1, 2).astype(np.int32)
 
155
  if image is None: return ProcessResult(success=False, message="Could not decode image.")
156
  h, w = image.shape[:2]
157
 
158
+ # --- 1. CALIBRATION & SCALING ---
159
+ dE_initial, dE_final, cm_per_px, checker_corners = None, None, None, None
160
 
161
+ try:
162
+ checker_corners = calib.detect_checker_corners(image)
163
+ top_width = np.linalg.norm(checker_corners[1] - checker_corners[2])
164
+ bot_width = np.linalg.norm(checker_corners[0] - checker_corners[3])
165
+ px_width = (top_width + bot_width) / 2.0
166
+ cm_per_px = CHECKER_WIDTH_CM / px_width
167
+
168
+ if self.ref24 is not None:
 
 
 
 
 
169
  tgt_warped = calib.warp_checker(image, checker_corners)
170
  tgt24 = calib.sample_24_patches(tgt_warped)
171
 
172
  dE_initial = float(np.mean(eval.compute_deltaE_00(tgt24, self.ref24)))
 
 
173
 
174
+ # Apply Color Correction Pipeline
175
  image = calib.apply_pipeline(image, self.ref24, tgt24)
176
+
177
+ # Re-check the corrected patches for Final dE
178
+ tgt_warped_corr = calib.warp_checker(image, checker_corners)
179
+ tgt24_corr = calib.sample_24_patches(tgt_warped_corr)
180
+ dE_final = float(np.mean(eval.compute_deltaE_00(tgt24_corr, self.ref24)))
181
+ except Exception as e:
182
+ print(f"Calibration skipped for {source_name}: {e}")
183
 
184
+ # --- 2. YOLO INFERENCE ---
185
+ results = self.model(image, conf=0.25, retina_masks=True, verbose=False)
186
+ rind_mask = np.zeros((h, w), dtype=np.uint8)
187
+ flesh_contours = []
188
+
189
+ if results[0].masks is not None:
190
+ for mask_data, cls in zip(results[0].masks.xy, results[0].boxes.cls):
191
+ contour = np.array(mask_data, dtype=np.int32)
192
+ if int(cls) == 0:
193
+ cv2.drawContours(rind_mask, [contour], -1, 255, -1)
194
+ elif int(cls) == 1:
195
+ flesh_contours.append(contour)
196
+
197
+ # Split Class 1 into Left and Right physically!
198
+ flesh_contours.sort(key=lambda cnt: cv2.moments(cnt)['m10'] / (cv2.moments(cnt)['m00'] + 1e-5))
199
+ flesh_l_m, flesh_r_m = np.zeros((h, w), dtype=np.uint8), np.zeros((h, w), dtype=np.uint8)
200
+ if len(flesh_contours) >= 2:
201
+ cv2.drawContours(flesh_l_m, [flesh_contours[0]], -1, 255, -1)
202
+ cv2.drawContours(flesh_r_m, [flesh_contours[1]], -1, 255, -1)
203
+ elif len(flesh_contours) == 1:
204
+ cv2.drawContours(flesh_l_m, [flesh_contours[0]], -1, 255, -1)
205
+
206
+ flesh_combined = cv2.bitwise_or(flesh_l_m, flesh_r_m)
207
+
208
+ # --- 3. FIT & EXTRACTION ---
209
+ perimeter_data = self.get_stable_perimeter_data(rind_mask, flesh_combined)
210
  if perimeter_data is None: return ProcessResult(success=False, message="No stable perimeter.")
211
 
212
  t_data, r_raw, (cx, cy), rind_cnt = perimeter_data
 
225
  r_fit = self.watermelon_model(t_fit, *popt) * scale
226
  fit_pts = np.array([[r * np.cos(t) + cx, cy - r * np.sin(t)] for t, r in zip(t_fit, r_fit)])
227
 
228
+ # Re-scale back to original size for true measurements
229
+ orig_scale = 1.0 / scale_ratio
230
+ if cm_per_px is None: cm_per_px = 1.0 # Fallback to pixel units if checker failed
231
+
232
  width_px = float(np.max(fit_pts[:, 0]) - np.min(fit_pts[:, 0]))
233
  height_px = float(np.max(fit_pts[:, 1]) - np.min(fit_pts[:, 1]))
234
+ 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]))
 
 
 
 
 
235
 
236
+ width_val = width_px * cm_per_px * orig_scale
237
+ height_val = height_px * cm_per_px * orig_scale
238
+ perimeter_val = perimeter_px * cm_per_px * orig_scale
 
 
239
 
240
+ # --- 4. DRAWING ---
241
+ midline = self.get_dual_mask_midline(flesh_l_m, flesh_r_m, rind_cnt, fit_pts, cx, cy)
242
+ output = blend_mask_overlays(image, rind_mask, flesh_combined)
243
+
244
+ # Color Checker Box
245
  if checker_corners is not None:
246
+ cv2.polylines(output, [np.int32(checker_corners)], True, (0, 165, 255), 4)
247
+
248
+ if len(midline) > 1: cv2.polylines(output, [midline.astype(np.int32)], False, (0, 255, 255), 3)
249
+ cv2.polylines(output,[fit_pts.astype(np.int32)], True, (0, 255, 0), 3)
250
 
251
  stem = stem_tip_tangent_deg(rind_cnt, (cx, cy))
252
  if stem is not None:
253
  tx, ty, tdeg = stem
254
  L = min(w, h) * 0.08
 
255
  p1 = (int(round(tx)), int(round(ty)))
256
+ p2 = (int(round(tx + L * np.cos(np.deg2rad(tdeg)))), int(round(ty + L * np.sin(np.deg2rad(tdeg)))))
257
  cv2.circle(output, p1, 6, (255, 0, 255), -1)
258
  cv2.line(output, p1, p2, (255, 0, 255), 2)
259
 
260
+ _, buffer = cv2.imencode('.jpg', output, [cv2.IMWRITE_JPEG_QUALITY, 85])
261
  img_base64 = base64.b64encode(buffer).decode('utf-8')
262
 
263
  return ProcessResult(
 
268
  )
269
 
270
 
 
271
  app = FastAPI()
272
 
273
  app.add_middleware(
274
+ CORSMiddleware, allow_origins=["*"], allow_credentials=True, allow_methods=["*"], allow_headers=["*"],
 
 
 
 
275
  )
276
 
277
  processor = WatermelonProcessor(MODEL_PATH)
278
 
279
  @app.get("/")
280
+ def read_root(): return {"status": "Watermelon API is awake!"}
 
281
 
282
  @app.post("/process_single")
283
  async def process_single(file: UploadFile = File(...)):