crabbly commited on
Commit
83b5158
·
verified ·
1 Parent(s): acefccf

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +94 -74
main.py CHANGED
@@ -13,16 +13,18 @@ from fastapi import FastAPI, UploadFile, File
13
  from fastapi.middleware.cors import CORSMiddleware
14
  import uvicorn
15
 
16
- # OOM PREVENTION 1: Force PyTorch to use minimal memory overhead
17
  torch.set_num_threads(1)
18
 
19
  # Import your helpers
20
  from cv_helpers import blend_mask_overlays, stem_tip_tangent_deg
 
 
21
 
22
  # --- CONFIGURATION ---
23
  MODEL_PATH = "best.pt"
24
- PIXELS_TO_CM = 1.0
25
  MAX_IMAGE_SIZE = 1024
 
26
 
27
  @dataclass
28
  class ProcessResult:
@@ -32,12 +34,28 @@ class ProcessResult:
32
  width_val: Optional[float] = None
33
  height_val: Optional[float] = None
34
  perimeter_val: Optional[float] = None
 
 
35
  image_base64: Optional[str] = None
36
  filename: Optional[str] = None
37
 
38
  class WatermelonProcessor:
39
  def __init__(self, model_path: str):
40
  self.model = YOLO(model_path)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
 
42
  @staticmethod
43
  def watermelon_model(theta, Rx, Ry, c_a, d_top, w_top, d_bot, w_bot, phi, c_skew, c_bend):
@@ -52,7 +70,6 @@ class WatermelonProcessor:
52
  def get_stable_perimeter_data(rind_mask, flesh_mask):
53
  cnts, _ = cv2.findContours(rind_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
54
  if not cnts: return None
55
-
56
  best_cnt = None
57
  max_overlap = -1
58
  for cnt in cnts:
@@ -62,7 +79,6 @@ class WatermelonProcessor:
62
  if overlap_area > max_overlap:
63
  max_overlap = overlap_area
64
  best_cnt = cnt
65
-
66
  if best_cnt is None: best_cnt = max(cnts, key=cv2.contourArea)
67
  moments = cv2.moments(best_cnt)
68
  if moments["m00"] == 0: return None
@@ -86,8 +102,8 @@ class WatermelonProcessor:
86
  return final_theta, final_r, (cx, cy), best_cnt
87
 
88
  @staticmethod
89
- def get_dual_mask_midline(f_left, f_right, rind_cnt, predicted_cnt, cx, cy):
90
- h, w = f_left.shape
91
  if len(rind_cnt) > 5:
92
  _, (ma, Ma), angle = cv2.fitEllipse(rind_cnt)
93
  rot_angle = angle if ma < Ma else angle + 90
@@ -95,46 +111,33 @@ class WatermelonProcessor:
95
 
96
  m_rot = cv2.getRotationMatrix2D((cx, cy), rot_angle, 1.0)
97
  m_inv = cv2.getRotationMatrix2D((cx, cy), -rot_angle, 1.0)
98
-
99
- l_rot = cv2.warpAffine(f_left, m_rot, (w, h))
100
- r_rot = cv2.warpAffine(f_right, m_rot, (w, h))
101
-
102
- # Failsafe: Swap left/right if YOLO got labels crossed
103
- l_idx = np.where(l_rot > 0)[1]
104
- r_idx = np.where(r_rot > 0)[1]
105
- if len(l_idx) > 0 and len(r_idx) > 0:
106
- if np.mean(l_idx) > np.mean(r_idx):
107
- l_rot, r_rot = r_rot, l_rot
108
 
109
  gap_points =[]
110
- y_l = np.where(l_rot > 0)[0]
111
- y_r = np.where(r_rot > 0)[0]
112
-
113
- if len(y_l) > 0 and len(y_r) > 0:
114
- y_min = max(np.min(y_l), np.min(y_r))
115
- y_max = min(np.max(y_l), np.max(y_r))
116
  for y in range(y_min, y_max):
117
- row_l = np.where(l_rot[y, :] > 0)[0]
118
- row_r = np.where(r_rot[y, :] > 0)[0]
119
- if len(row_l) > 0 and len(row_r) > 0:
120
- edge_l = row_l[-1]
121
- edge_r = row_r[0]
122
- gap_points.append([y, (edge_l + edge_r) / 2.0])
 
123
 
124
  gap_points = np.array(gap_points)
125
  if len(gap_points) > 10:
126
- y_min_g, y_max_g = np.min(gap_points[:, 0]), np.max(gap_points[:, 0])
127
- y_span = max(y_max_g - y_min_g, 1)
128
- y_mean = (y_max_g + y_min_g) / 2.0
129
  y_norm = (gap_points[:, 0] - y_mean) / y_span
130
  x_data = gap_points[:, 1]
131
 
132
  def parabola(y_n, a, b, c): return a * (y_n**2) + b * y_n + c
133
- max_bend = w * 0.08
134
- try:
135
- popt_mid, _ = curve_fit(parabola, y_norm, x_data, bounds=([-max_bend, -np.inf, -np.inf],[max_bend, np.inf, np.inf]))
136
- except:
137
- popt_mid =[0.0, 0.0, cx]
138
 
139
  ys_extrap = np.linspace(0, h, 500)
140
  ys_extrap_norm = (ys_extrap - y_mean) / y_span
@@ -142,7 +145,8 @@ class WatermelonProcessor:
142
  pts_rot = np.vstack([xs_extrap, ys_extrap, np.ones_like(xs_extrap)])
143
  else:
144
  ys_extrap = np.linspace(0, h, 500)
145
- pts_rot = np.vstack([np.full_like(ys_extrap, cx), ys_extrap, np.ones_like(ys_extrap)])
 
146
 
147
  pts_orig = (m_inv @ pts_rot).T
148
  pred_cnt_cv = predicted_cnt.reshape(-1, 1, 2).astype(np.int32)
@@ -151,36 +155,55 @@ class WatermelonProcessor:
151
  def process_image(self, image: np.ndarray, source_name: str, scale_ratio: float) -> ProcessResult:
152
  if image is None: return ProcessResult(success=False, message="Could not decode image.")
153
  h, w = image.shape[:2]
 
 
 
154
 
155
- # retina_masks=True removes the plateau artifacts during inference!
156
- results = self.model(image, conf=0.25, retina_masks=True, verbose=False)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
 
158
- rind_mask = np.zeros((h, w), dtype=np.uint8)
159
- flesh_l = np.zeros((h, w), dtype=np.uint8)
160
- flesh_r = np.zeros((h, w), dtype=np.uint8)
 
 
 
 
161
 
162
  if results[0].masks is None:
163
  return ProcessResult(success=False, message="No masks detected.")
164
 
165
- # --- THE FIX: Load 0 (Whole), 1 (Left), and 2 (Right) ---
166
  for mask_data, cls in zip(results[0].masks.xy, results[0].boxes.cls):
167
  contour = np.array(mask_data, dtype=np.int32)
168
- c_id = int(cls)
169
- if c_id == 0:
170
- cv2.drawContours(rind_mask, [contour], -1, 255, -1)
171
- elif c_id == 1:
172
- cv2.drawContours(flesh_l, [contour], -1, 255, -1)
173
- elif c_id == 2:
174
- cv2.drawContours(flesh_r, [contour], -1, 255, -1)
175
-
176
- flesh_combined = cv2.bitwise_or(flesh_l, flesh_r)
177
 
178
- perimeter_data = self.get_stable_perimeter_data(rind_mask, flesh_combined)
179
  if perimeter_data is None: return ProcessResult(success=False, message="No stable perimeter.")
180
 
181
  t_data, r_raw, (cx, cy), rind_cnt = perimeter_data
182
  scale = np.mean(r_raw)
183
- if scale <= 0: return ProcessResult(success=False, message="Invalid perimeter scale.")
184
 
185
  try:
186
  popt, _ = curve_fit(
@@ -190,48 +213,48 @@ class WatermelonProcessor:
190
  )
191
  except Exception as exc: return ProcessResult(success=False, message=f"Fit failed: {exc}")
192
 
193
- denom = np.sum((r_raw / scale - 1) ** 2)
194
- if denom == 0: return ProcessResult(success=False, message="R2 denominator became zero.")
195
-
196
- r2 = 1 - (np.sum((r_raw / scale - self.watermelon_model(t_data, *popt)) ** 2) / denom)
197
  t_fit = np.linspace(-np.pi, np.pi, 500)
198
  r_fit = self.watermelon_model(t_fit, *popt) * scale
199
  fit_pts = np.array([[r * np.cos(t) + cx, cy - r * np.sin(t)] for t, r in zip(t_fit, r_fit)])
200
 
201
- orig_scale = 1.0 / scale_ratio
202
  width_px = float(np.max(fit_pts[:, 0]) - np.min(fit_pts[:, 0]))
203
  height_px = float(np.max(fit_pts[:, 1]) - np.min(fit_pts[:, 1]))
204
  diffs = np.diff(fit_pts, axis=0)
205
  perimeter_px = float(np.sum(np.linalg.norm(diffs, axis=1)) + np.linalg.norm(fit_pts[-1] - fit_pts[0]))
206
 
207
- width_val = width_px * PIXELS_TO_CM * orig_scale
208
- height_val = height_px * PIXELS_TO_CM * orig_scale
209
- perimeter_val = perimeter_px * PIXELS_TO_CM * orig_scale
210
 
211
- # --- THE FIX: Dual-Mask Midline ---
212
- midline = self.get_dual_mask_midline(flesh_l, flesh_r, rind_cnt, fit_pts, cx, cy)
213
-
214
- output = blend_mask_overlays(image, rind_mask, flesh_combined)
215
- if len(midline) > 1:
216
- cv2.polylines(output, [midline.astype(np.int32)], False, (0, 255, 255), 3)
217
  cv2.polylines(output, [fit_pts.astype(np.int32)], True, (0, 255, 0), 3)
218
 
 
 
 
 
219
  stem = stem_tip_tangent_deg(rind_cnt, (cx, cy))
220
  if stem is not None:
221
  tx, ty, tdeg = stem
222
- rad = np.deg2rad(tdeg)
223
  L = min(w, h) * 0.08
 
224
  p1 = (int(round(tx)), int(round(ty)))
225
  p2 = (int(round(tx + L * np.cos(rad))), int(round(ty + L * np.sin(rad))))
226
  cv2.circle(output, p1, 6, (255, 0, 255), -1)
227
  cv2.line(output, p1, p2, (255, 0, 255), 2)
228
 
229
- _, buffer = cv2.imencode('.jpg', output, [cv2.IMWRITE_JPEG_QUALITY, 85])
230
  img_base64 = base64.b64encode(buffer).decode('utf-8')
231
 
232
  return ProcessResult(
233
  success=True, message="Success", r2_score=float(r2),
234
  width_val=width_val, height_val=height_val, perimeter_val=perimeter_val,
 
235
  image_base64=img_base64, filename=source_name
236
  )
237
 
@@ -270,7 +293,4 @@ async def process_single(file: UploadFile = File(...)):
270
  del img, nparr, contents
271
  gc.collect()
272
 
273
- return res.__dict__
274
-
275
- if __name__ == "__main__":
276
- uvicorn.run(app, host="0.0.0.0", port=7860)
 
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:
 
34
  width_val: Optional[float] = None
35
  height_val: Optional[float] = None
36
  perimeter_val: Optional[float] = None
37
+ delta_e_initial: Optional[float] = None
38
+ delta_e_final: Optional[float] = None
39
  image_base64: Optional[str] = None
40
  filename: Optional[str] = None
41
 
42
  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:
50
+ ref_img = cv2.imread("reference.png")
51
+ ref_corners = calib.detect_checker_corners(ref_img)
52
+ ref_warped = calib.warp_checker(ref_img, ref_corners)
53
+ self.ref24 = calib.sample_24_patches(ref_warped)
54
+ print("Reference ColorChecker loaded successfully.")
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):
 
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:
 
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
 
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
 
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
 
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)
 
155
  def process_image(self, image: np.ndarray, source_name: str, scale_ratio: float) -> ProcessResult:
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
206
  scale = np.mean(r_raw)
 
207
 
208
  try:
209
  popt, _ = curve_fit(
 
213
  )
214
  except Exception as exc: return ProcessResult(success=False, message=f"Fit failed: {exc}")
215
 
216
+ r2 = 1 - (np.sum((r_raw / scale - self.watermelon_model(t_data, *popt)) ** 2) / np.sum((r_raw / scale - 1) ** 2))
 
 
 
217
  t_fit = np.linspace(-np.pi, np.pi, 500)
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(
255
  success=True, message="Success", r2_score=float(r2),
256
  width_val=width_val, height_val=height_val, perimeter_val=perimeter_val,
257
+ delta_e_initial=dE_initial, delta_e_final=dE_final,
258
  image_base64=img_base64, filename=source_name
259
  )
260
 
 
293
  del img, nparr, contents
294
  gc.collect()
295
 
296
+ return res.__dict__