Update app.py
Browse files
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 |
-
|
|
|
|
| 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 |
-
|
| 93 |
-
|
| 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 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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.")
|