MBG0903 commited on
Commit
6be653d
·
verified ·
1 Parent(s): 6088a7b

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +65 -14
app.py CHANGED
@@ -78,7 +78,8 @@ def align_to_reference(img: np.ndarray, ref: np.ndarray, max_size: int = 1200) -
78
  img_gray = cv2.cvtColor(img_s, cv2.COLOR_BGR2GRAY)
79
  ref_gray = cv2.cvtColor(ref_s, cv2.COLOR_BGR2GRAY)
80
 
81
- orb = cv2.ORB_create(5000)
 
82
  k1, d1 = orb.detectAndCompute(img_gray, None)
83
  k2, d2 = orb.detectAndCompute(ref_gray, None)
84
 
@@ -89,8 +90,8 @@ def align_to_reference(img: np.ndarray, ref: np.ndarray, max_size: int = 1200) -
89
  matches = bf.match(d1, d2)
90
  matches = sorted(matches, key=lambda x: x.distance)
91
 
92
- if len(matches) < 20:
93
- # not enough matches; naive resize to ref
94
  warped = cv2.resize(img, (ref.shape[1], ref.shape[0]))
95
  return AlignResult(False, warped, None, len(matches))
96
 
@@ -119,10 +120,23 @@ def build_reference(images: List[np.ndarray]) -> np.ndarray:
119
  return ref
120
 
121
 
 
 
 
 
 
 
 
 
 
 
122
  def ssim_anomaly_map(img: np.ndarray, ref: np.ndarray, win_size: int = 11, gaussian_weights: bool = True) -> np.ndarray:
123
- """Return anomaly map = 1 - SSIM per-pixel (grayscale)."""
124
  img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
125
  ref_gray = cv2.cvtColor(ref, cv2.COLOR_BGR2GRAY)
 
 
 
126
  img_blur = cv2.GaussianBlur(img_gray, (5,5), 0)
127
  ref_blur = cv2.GaussianBlur(ref_gray, (5,5), 0)
128
  score, ssim_map = ssim(ref_blur, img_blur, win_size=win_size, gaussian_weights=gaussian_weights, full=True)
@@ -143,6 +157,17 @@ def postprocess_mask(anom_map: np.ndarray, thr: float, min_area: int) -> np.ndar
143
  return mask_bool.astype(np.uint8)
144
 
145
 
 
 
 
 
 
 
 
 
 
 
 
146
  def overlay_and_boxes(img: np.ndarray, mask: np.ndarray) -> Tuple[np.ndarray, list]:
147
  overlay = img.copy()
148
  contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
@@ -157,8 +182,6 @@ def overlay_and_boxes(img: np.ndarray, mask: np.ndarray) -> Tuple[np.ndarray, li
157
  # red translucent overlay
158
  red = np.zeros_like(img)
159
  red[:] = (0, 0, 255)
160
- alpha = (mask * 120)[:, :, None] # 0..255 like weight
161
- alpha = alpha.astype(np.uint8)
162
  heat = cv2.addWeighted(img, 1.0, red, 0.35, 0)
163
  out = np.where(mask[:, :, None].astype(bool), heat, img)
164
  # draw boxes on top
@@ -173,6 +196,16 @@ def mask_stats(mask: np.ndarray) -> dict:
173
  return {"defect_area_px": area, "image_area_px": total, "defect_pct": 100.0 * area / total}
174
 
175
 
 
 
 
 
 
 
 
 
 
 
176
  # ---------------------------
177
  # Streamlit UI
178
  # ---------------------------
@@ -188,17 +221,22 @@ with st.sidebar:
188
 
189
  st.header("Detection Settings")
190
  align = st.checkbox("Auto-align to reference (ORB)", value=True)
191
- win = st.select_slider("SSIM window", options=[7,9,11,13], value=11)
192
- thr = st.slider("Anomaly threshold (1-SSIM)", 0.05, 0.80, 0.35, step=0.01)
193
- min_area = st.slider("Min defect area (px)", 10, 5000, 300, step=10)
194
- decision_pct = st.slider("Fail if defect area > (%)", 0.1, 10.0, 1.0, step=0.1)
 
195
 
196
  if 'reference' not in st.session_state:
197
  st.session_state.reference = None
198
 
199
  if build_btn and ref_files:
200
  imgs = [resize_max(read_image(f)) for f in ref_files]
201
- ref = build_reference(imgs)
 
 
 
 
202
  st.session_state.reference = ref
203
  st.success(f"Reference built from {len(imgs)} image(s). Size: {ref.shape[1]}×{ref.shape[0]}")
204
 
@@ -208,7 +246,7 @@ if ref is None:
208
  st.info("No reference yet. Upload 1–10 good images in the sidebar and click *Build / Update Reference*.")
209
  else:
210
  st.subheader("1) Golden Reference Preview")
211
- st.image(cv_to_pil(ref), caption="Golden Reference (median composite)", use_column_width=True)
212
 
213
  st.markdown("---")
214
 
@@ -241,13 +279,24 @@ if ref is not None and test_files:
241
  if img is None:
242
  st.warning(f"Could not read {getattr(f,'name','file')} — skipping.")
243
  continue
 
244
  A = align_to_reference(img, ref) if align else AlignResult(False, cv2.resize(img, (ref.shape[1], ref.shape[0])), None, 0)
245
  warped = A.warped
 
246
  anom = ssim_anomaly_map(warped, ref, win_size=win)
 
 
 
 
 
247
  mask = postprocess_mask(anom, thr=thr, min_area=min_area)
248
  overlay, boxes = overlay_and_boxes(warped, mask)
249
  stats = mask_stats(mask)
250
- label = "Defective" if stats['defect_pct'] > decision_pct else "Good"
 
 
 
 
251
 
252
  # UI
253
  st.write(f"*File:* {getattr(f,'name','image')} | Matches: {A.matches} | Alignment: {'OK' if A.ok else 'Fallback'}")
@@ -258,6 +307,7 @@ if ref is not None and test_files:
258
  st.metric("Decision", label)
259
  st.metric("Defect area %", f"{stats['defect_pct']:.2f}%")
260
  st.metric("Blobs detected", len(boxes))
 
261
 
262
  # Prepare downloadable annotated image
263
  _ok, png_bytes = cv2.imencode('.png', overlay)
@@ -271,6 +321,7 @@ if ref is not None and test_files:
271
  "alignment_matches": A.matches,
272
  "aligned": A.ok,
273
  **stats,
 
274
  "decision": label,
275
  "boxes": len(boxes)
276
  })
@@ -284,4 +335,4 @@ else:
284
  st.info("Upload test images to run detection, or click *Generate a Demo from Reference* if a reference exists.")
285
 
286
  st.markdown("---")
287
- st.caption("©️ 2025 Procelevate Consulting — Demo app. For internal demonstration only; camera pose and lighting consistency are required.")
 
78
  img_gray = cv2.cvtColor(img_s, cv2.COLOR_BGR2GRAY)
79
  ref_gray = cv2.cvtColor(ref_s, cv2.COLOR_BGR2GRAY)
80
 
81
+ # Increased keypoints for better matching on tyre textures
82
+ orb = cv2.ORB_create(10000) # was 5000
83
  k1, d1 = orb.detectAndCompute(img_gray, None)
84
  k2, d2 = orb.detectAndCompute(ref_gray, None)
85
 
 
90
  matches = bf.match(d1, d2)
91
  matches = sorted(matches, key=lambda x: x.distance)
92
 
93
+ # Require more matches before trusting homography
94
+ if len(matches) < 40: # was 20
95
  warped = cv2.resize(img, (ref.shape[1], ref.shape[0]))
96
  return AlignResult(False, warped, None, len(matches))
97
 
 
120
  return ref
121
 
122
 
123
+ # -------- New: illumination normalization helpers --------
124
+ def match_mean_std(src_gray: np.ndarray, tgt_gray: np.ndarray) -> np.ndarray:
125
+ """Match mean/std of src to tgt to reduce lighting-induced SSIM differences."""
126
+ s = src_gray.astype(np.float32); t = tgt_gray.astype(np.float32)
127
+ sm, ss = float(s.mean()), float(s.std() + 1e-6)
128
+ tm, ts = float(t.mean()), float(t.std() + 1e-6)
129
+ out = ((s - sm) / ss) * ts + tm
130
+ return np.clip(out, 0, 255).astype(np.uint8)
131
+
132
+
133
  def ssim_anomaly_map(img: np.ndarray, ref: np.ndarray, win_size: int = 11, gaussian_weights: bool = True) -> np.ndarray:
134
+ """Return anomaly map = 1 - SSIM per-pixel (grayscale) with illumination normalization."""
135
  img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
136
  ref_gray = cv2.cvtColor(ref, cv2.COLOR_BGR2GRAY)
137
+ # Normalize illumination
138
+ img_gray = match_mean_std(img_gray, ref_gray)
139
+
140
  img_blur = cv2.GaussianBlur(img_gray, (5,5), 0)
141
  ref_blur = cv2.GaussianBlur(ref_gray, (5,5), 0)
142
  score, ssim_map = ssim(ref_blur, img_blur, win_size=win_size, gaussian_weights=gaussian_weights, full=True)
 
157
  return mask_bool.astype(np.uint8)
158
 
159
 
160
+ # -------- New: tyre ROI mask to ignore background/edges --------
161
+ def center_circular_mask(shape, keep_ratio=0.9) -> np.ndarray:
162
+ """Return a central circular mask to focus on the tyre and ignore background edge shifts."""
163
+ h, w = shape[:2]
164
+ cy, cx = h//2, w//2
165
+ r = int(min(h, w) * keep_ratio / 2)
166
+ Y, X = np.ogrid[:h, :w]
167
+ mask = ((Y - cy)**2 + (X - cx)**2) <= r*r
168
+ return mask.astype(np.uint8)
169
+
170
+
171
  def overlay_and_boxes(img: np.ndarray, mask: np.ndarray) -> Tuple[np.ndarray, list]:
172
  overlay = img.copy()
173
  contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
 
182
  # red translucent overlay
183
  red = np.zeros_like(img)
184
  red[:] = (0, 0, 255)
 
 
185
  heat = cv2.addWeighted(img, 1.0, red, 0.35, 0)
186
  out = np.where(mask[:, :, None].astype(bool), heat, img)
187
  # draw boxes on top
 
196
  return {"defect_area_px": area, "image_area_px": total, "defect_pct": 100.0 * area / total}
197
 
198
 
199
+ # -------- New: global SSIM guard --------
200
+ def global_ssim(img: np.ndarray, ref: np.ndarray) -> float:
201
+ g1 = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
202
+ g2 = cv2.cvtColor(ref, cv2.COLOR_BGR2GRAY)
203
+ g1 = cv2.GaussianBlur(g1, (5,5), 0)
204
+ g2 = cv2.GaussianBlur(g2, (5,5), 0)
205
+ score, _ = ssim(g2, g1, full=True)
206
+ return float(score)
207
+
208
+
209
  # ---------------------------
210
  # Streamlit UI
211
  # ---------------------------
 
221
 
222
  st.header("Detection Settings")
223
  align = st.checkbox("Auto-align to reference (ORB)", value=True)
224
+ apply_roi = st.checkbox("Apply tyre ROI mask (ignore background/edges)", value=True)
225
+ win = st.select_slider("SSIM window", options=[7,9,11,13], value=13) # default relaxed
226
+ thr = st.slider("Anomaly threshold (1-SSIM)", 0.05, 0.80, 0.45, step=0.01) # default higher
227
+ min_area = st.slider("Min defect area (px)", 10, 10000, 1000, step=10) # default higher
228
+ decision_pct = st.slider("Fail if defect area > (%)", 0.1, 10.0, 2.5, step=0.1) # default higher
229
 
230
  if 'reference' not in st.session_state:
231
  st.session_state.reference = None
232
 
233
  if build_btn and ref_files:
234
  imgs = [resize_max(read_image(f)) for f in ref_files]
235
+ # If only one good image is provided, use it directly to avoid median-shift
236
+ if len(imgs) == 1:
237
+ ref = imgs[0]
238
+ else:
239
+ ref = build_reference(imgs)
240
  st.session_state.reference = ref
241
  st.success(f"Reference built from {len(imgs)} image(s). Size: {ref.shape[1]}×{ref.shape[0]}")
242
 
 
246
  st.info("No reference yet. Upload 1–10 good images in the sidebar and click *Build / Update Reference*.")
247
  else:
248
  st.subheader("1) Golden Reference Preview")
249
+ st.image(cv_to_pil(ref), caption="Golden Reference (median composite or single good image)", use_column_width=True)
250
 
251
  st.markdown("---")
252
 
 
279
  if img is None:
280
  st.warning(f"Could not read {getattr(f,'name','file')} — skipping.")
281
  continue
282
+
283
  A = align_to_reference(img, ref) if align else AlignResult(False, cv2.resize(img, (ref.shape[1], ref.shape[0])), None, 0)
284
  warped = A.warped
285
+
286
  anom = ssim_anomaly_map(warped, ref, win_size=win)
287
+ # Optionally restrict to tyre ROI
288
+ if apply_roi:
289
+ roi = center_circular_mask(anom.shape, keep_ratio=0.9)
290
+ anom = anom * roi
291
+
292
  mask = postprocess_mask(anom, thr=thr, min_area=min_area)
293
  overlay, boxes = overlay_and_boxes(warped, mask)
294
  stats = mask_stats(mask)
295
+
296
+ # Global SSIM guard: if overall match is very high, prefer "Good"
297
+ gssim = global_ssim(warped, ref)
298
+ decision_is_defect = (stats['defect_pct'] > decision_pct) and (gssim < 0.985)
299
+ label = "Defective" if decision_is_defect else "Good"
300
 
301
  # UI
302
  st.write(f"*File:* {getattr(f,'name','image')} | Matches: {A.matches} | Alignment: {'OK' if A.ok else 'Fallback'}")
 
307
  st.metric("Decision", label)
308
  st.metric("Defect area %", f"{stats['defect_pct']:.2f}%")
309
  st.metric("Blobs detected", len(boxes))
310
+ st.metric("Global SSIM", f"{gssim:.4f}")
311
 
312
  # Prepare downloadable annotated image
313
  _ok, png_bytes = cv2.imencode('.png', overlay)
 
321
  "alignment_matches": A.matches,
322
  "aligned": A.ok,
323
  **stats,
324
+ "global_ssim": gssim,
325
  "decision": label,
326
  "boxes": len(boxes)
327
  })
 
335
  st.info("Upload test images to run detection, or click *Generate a Demo from Reference* if a reference exists.")
336
 
337
  st.markdown("---")
338
+ st.caption("©️ 2025 Procelevate Consulting — Demo app. For internal demonstration only; camera pose and lighting consistency are required.")