coderuday21 Cursor commited on
Commit
8c0e957
·
1 Parent(s): 15e9574

Improve detection accuracy: TTA for AdaptFormer, hysteresis thresholding, robust logits and LBP borders.

Browse files
Files changed (2) hide show
  1. app/detection_engine.py +43 -5
  2. app/model_inference.py +91 -14
app/detection_engine.py CHANGED
@@ -506,18 +506,48 @@ def compute_ssim_change_map(img1, img2, win_size=11):
506
  # ---------------------------------------------------------------------------
507
 
508
  def compute_lbp(gray, radius=1, n_points=8):
509
- """Compute simplified Local Binary Pattern texture descriptor."""
510
- h, w = gray.shape
 
 
 
 
 
511
  lbp = np.zeros_like(gray, dtype=np.float32)
 
512
  for i in range(n_points):
513
  angle = 2 * np.pi * i / n_points
514
  dx = int(round(radius * np.cos(angle)))
515
  dy = int(round(-radius * np.sin(angle)))
516
- shifted = np.roll(np.roll(gray, dy, axis=0), dx, axis=1)
517
  lbp += (shifted >= gray).astype(np.float32)
518
  return lbp / n_points
519
 
520
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
521
  def compute_texture_change(img1, img2):
522
  """Compute texture difference using LBP."""
523
  gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY).astype(np.float32)
@@ -768,7 +798,9 @@ def fuse_dl_and_classical(dl_score, classical_score, img1, img2, sensitivity=0.5
768
  final_score = np.where(veg_boost, np.maximum(final_score, classical_score), final_score)
769
 
770
  fused_thr = 0.45 + (1.0 - sens) * 0.15
771
- change_mask = (final_score >= fused_thr).astype(np.uint8) * 255
 
 
772
  change_mask = _clean_mask(change_mask, sensitivity=sens)
773
 
774
  debug = {
@@ -777,6 +809,7 @@ def fuse_dl_and_classical(dl_score, classical_score, img1, img2, sensitivity=0.5
777
  "T_cl_percentile_q": q,
778
  "T_cl_score": T_cl,
779
  "fused_threshold": fused_thr,
 
780
  "dl_changed_px": int(np.sum(dl_score >= med_dl)),
781
  "classical_changed_px": int(np.sum(classical_score >= T_cl)),
782
  "fused_changed_px": int(np.sum(change_mask > 127)),
@@ -793,7 +826,10 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5, registration_ok=True):
793
  # Looser percentile than gated fusion — keeps recall for multi-region detection
794
  q = float(np.clip(0.93 - (sens - 0.5) * 0.06, 0.85, 0.96))
795
  thr_score = float(np.quantile(classical_score, q))
796
- change_mask = (classical_score >= thr_score).astype(np.uint8) * 255
 
 
 
797
  change_mask = _clean_mask(change_mask, sensitivity=sens)
798
 
799
  debug = {
@@ -801,6 +837,8 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5, registration_ok=True):
801
  "threshold_used": int(thr_score * 255),
802
  "threshold_percentile_q": q,
803
  "threshold_score": thr_score,
 
 
804
  "sensitivity": float(sensitivity),
805
  "channel_weights": {
806
  "color": round(weights[0], 4),
 
506
  # ---------------------------------------------------------------------------
507
 
508
  def compute_lbp(gray, radius=1, n_points=8):
509
+ """Compute simplified Local Binary Pattern texture descriptor.
510
+
511
+ Uses reflect-padded shifts (not np.roll) so opposite image borders do not
512
+ wrap into each other and create spurious texture-change along the frame.
513
+ """
514
+ pad = max(1, int(radius))
515
+ padded = cv2.copyMakeBorder(gray, pad, pad, pad, pad, cv2.BORDER_REFLECT)
516
  lbp = np.zeros_like(gray, dtype=np.float32)
517
+ h, w = gray.shape
518
  for i in range(n_points):
519
  angle = 2 * np.pi * i / n_points
520
  dx = int(round(radius * np.cos(angle)))
521
  dy = int(round(-radius * np.sin(angle)))
522
+ shifted = padded[pad + dy:pad + dy + h, pad + dx:pad + dx + w]
523
  lbp += (shifted >= gray).astype(np.float32)
524
  return lbp / n_points
525
 
526
 
527
+ def _hysteresis_threshold(score, high_thr, low_thr):
528
+ """Two-level (hysteresis) thresholding on a [0,1] score map.
529
+
530
+ Keeps every low-threshold connected component that contains at least one
531
+ high-threshold "seed" pixel, and drops the rest. This recovers complete
532
+ change blobs (less fragmentation) while removing isolated weak speckle that
533
+ a single hard threshold would either cut or admit.
534
+ """
535
+ score = score.astype(np.float32)
536
+ high = (score >= high_thr).astype(np.uint8)
537
+ if int(high.sum()) == 0:
538
+ return (high * 255).astype(np.uint8)
539
+ low = (score >= min(low_thr, high_thr)).astype(np.uint8)
540
+ num, labels = cv2.connectedComponents(low, connectivity=8)
541
+ if num <= 1:
542
+ return (high * 255).astype(np.uint8)
543
+ seed_labels = np.unique(labels[high > 0])
544
+ seed_labels = seed_labels[seed_labels != 0]
545
+ if seed_labels.size == 0:
546
+ return (high * 255).astype(np.uint8)
547
+ keep = np.isin(labels, seed_labels)
548
+ return np.where(keep, 255, 0).astype(np.uint8)
549
+
550
+
551
  def compute_texture_change(img1, img2):
552
  """Compute texture difference using LBP."""
553
  gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY).astype(np.float32)
 
798
  final_score = np.where(veg_boost, np.maximum(final_score, classical_score), final_score)
799
 
800
  fused_thr = 0.45 + (1.0 - sens) * 0.15
801
+ # Hysteresis grow keeps complete blobs while pruning isolated weak responses
802
+ fused_low = max(0.25, fused_thr - 0.12)
803
+ change_mask = _hysteresis_threshold(final_score, fused_thr, fused_low)
804
  change_mask = _clean_mask(change_mask, sensitivity=sens)
805
 
806
  debug = {
 
809
  "T_cl_percentile_q": q,
810
  "T_cl_score": T_cl,
811
  "fused_threshold": fused_thr,
812
+ "fused_low_threshold": fused_low,
813
  "dl_changed_px": int(np.sum(dl_score >= med_dl)),
814
  "classical_changed_px": int(np.sum(classical_score >= T_cl)),
815
  "fused_changed_px": int(np.sum(change_mask > 127)),
 
826
  # Looser percentile than gated fusion — keeps recall for multi-region detection
827
  q = float(np.clip(0.93 - (sens - 0.5) * 0.06, 0.85, 0.96))
828
  thr_score = float(np.quantile(classical_score, q))
829
+ # Hysteresis: grow seeds down to a lower percentile to recover full change blobs
830
+ q_low = float(np.clip(q - 0.06, 0.78, q))
831
+ low_score = float(np.quantile(classical_score, q_low))
832
+ change_mask = _hysteresis_threshold(classical_score, thr_score, low_score)
833
  change_mask = _clean_mask(change_mask, sensitivity=sens)
834
 
835
  debug = {
 
837
  "threshold_used": int(thr_score * 255),
838
  "threshold_percentile_q": q,
839
  "threshold_score": thr_score,
840
+ "hysteresis_low_q": q_low,
841
+ "hysteresis_low_score": low_score,
842
  "sensitivity": float(sensitivity),
843
  "channel_weights": {
844
  "color": round(weights[0], 4),
app/model_inference.py CHANGED
@@ -112,23 +112,68 @@ def get_model_status() -> dict:
112
  "available": available,
113
  "detectionMode": mode,
114
  "device": str(_DEVICE) if _DEVICE is not None else None,
 
115
  "error": _LOAD_ERROR,
116
  }
117
 
118
 
119
- def predict_change_mask(img1, img2, threshold=0.5):
120
- """
121
- Run AdaptFormer inference on two RGB numpy arrays (H, W, 3).
122
- Returns (uint8 mask [0 or 255], float32 score map [0-1]).
123
- Use threshold > 1.0 to obtain score map only (empty mask).
 
124
  """
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
  torch, _, _ = _try_import()
126
  model, processor = _load_model()
127
  from PIL import Image as PILImage
128
 
129
- if img1.shape != img2.shape:
130
- img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
131
-
132
  h, w = img1.shape[:2]
133
  tile = _TILE_SIZE
134
  overlap = tile // 4
@@ -152,8 +197,8 @@ def predict_change_mask(img1, img2, threshold=0.5):
152
  with torch.no_grad():
153
  for y0 in range(0, ph - tile + 1, stride):
154
  for x0 in range(0, pw - tile + 1, stride):
155
- t1 = img1[y0:y0+tile, x0:x0+tile]
156
- t2 = img2[y0:y0+tile, x0:x0+tile]
157
 
158
  pil1 = PILImage.fromarray(t1)
159
  pil2 = PILImage.fromarray(t2)
@@ -163,9 +208,7 @@ def predict_change_mask(img1, img2, threshold=0.5):
163
 
164
  outputs = model(**inputs)
165
  logits = outputs.logits
166
- probs = torch.softmax(logits, dim=1)
167
-
168
- prob_map = probs[0, 1].cpu().numpy()
169
 
170
  out_h, out_w = prob_map.shape
171
  if out_h != tile or out_w != tile:
@@ -177,7 +220,41 @@ def predict_change_mask(img1, img2, threshold=0.5):
177
 
178
  count = np.maximum(count, 1e-6)
179
  avg_score = score_sum / count
180
- avg_score = avg_score[:h, :w]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
181
 
182
  mask = (avg_score >= threshold).astype(np.uint8) * 255
183
  return mask, avg_score
 
112
  "available": available,
113
  "detectionMode": mode,
114
  "device": str(_DEVICE) if _DEVICE is not None else None,
115
+ "tta": [op or "identity" for op in _resolve_tta_ops()],
116
  "error": _LOAD_ERROR,
117
  }
118
 
119
 
120
+ def _logits_to_change_prob(logits, torch):
121
+ """Robustly convert model logits to a single-channel change probability tile.
122
+
123
+ Handles 1-channel (sigmoid), 2+-channel (softmax, change=last channel) and
124
+ already-2D outputs so a change in the upstream model head does not silently
125
+ break inference.
126
  """
127
+ t = logits
128
+ if t.dim() == 4: # (N, C, H, W)
129
+ c = t.shape[1]
130
+ if c == 1:
131
+ return torch.sigmoid(t)[0, 0]
132
+ return torch.softmax(t, dim=1)[0, -1]
133
+ if t.dim() == 3: # (C, H, W) or (N, H, W)
134
+ if t.shape[0] == 1:
135
+ return torch.sigmoid(t)[0]
136
+ # ambiguous: treat as (N,H,W) single map
137
+ return torch.sigmoid(t[0])
138
+ return torch.sigmoid(t)
139
+
140
+
141
+ def _resolve_tta_ops():
142
+ """Choose test-time augmentation flips. Env DETECTION_TTA: off|0 | hflip | full | auto."""
143
+ mode = os.environ.get("DETECTION_TTA", "auto").strip().lower()
144
+ if mode in ("0", "off", "none", "false"):
145
+ return [None]
146
+ if mode in ("h", "hflip"):
147
+ return [None, "h"]
148
+ if mode in ("full", "all"):
149
+ return [None, "h", "v"]
150
+ # auto: lighter on CPU (hflip only), fuller on GPU
151
+ on_cuda = _DEVICE is not None and str(_DEVICE).startswith("cuda")
152
+ return [None, "h", "v"] if on_cuda else [None, "h"]
153
+
154
+
155
+ def _apply_flip(arr, op):
156
+ if op == "h":
157
+ return arr[:, ::-1]
158
+ if op == "v":
159
+ return arr[::-1, :]
160
+ return arr
161
+
162
+
163
+ def _unflip_map(score, op):
164
+ if op == "h":
165
+ return score[:, ::-1]
166
+ if op == "v":
167
+ return score[::-1, :]
168
+ return score
169
+
170
+
171
+ def _infer_score_map(img1, img2):
172
+ """Single-pass tiled inference returning a float32 change-probability map at (h, w)."""
173
  torch, _, _ = _try_import()
174
  model, processor = _load_model()
175
  from PIL import Image as PILImage
176
 
 
 
 
177
  h, w = img1.shape[:2]
178
  tile = _TILE_SIZE
179
  overlap = tile // 4
 
197
  with torch.no_grad():
198
  for y0 in range(0, ph - tile + 1, stride):
199
  for x0 in range(0, pw - tile + 1, stride):
200
+ t1 = np.ascontiguousarray(img1[y0:y0+tile, x0:x0+tile])
201
+ t2 = np.ascontiguousarray(img2[y0:y0+tile, x0:x0+tile])
202
 
203
  pil1 = PILImage.fromarray(t1)
204
  pil2 = PILImage.fromarray(t2)
 
208
 
209
  outputs = model(**inputs)
210
  logits = outputs.logits
211
+ prob_map = _logits_to_change_prob(logits, torch).cpu().numpy()
 
 
212
 
213
  out_h, out_w = prob_map.shape
214
  if out_h != tile or out_w != tile:
 
220
 
221
  count = np.maximum(count, 1e-6)
222
  avg_score = score_sum / count
223
+ return avg_score[:h, :w]
224
+
225
+
226
+ def predict_change_mask(img1, img2, threshold=0.5):
227
+ """
228
+ Run AdaptFormer inference on two RGB numpy arrays (H, W, 3).
229
+ Averages predictions over test-time augmentation flips (DETECTION_TTA) for
230
+ higher-accuracy, less boundary-sensitive change maps.
231
+ Returns (uint8 mask [0 or 255], float32 score map [0-1]).
232
+ Use threshold > 1.0 to obtain score map only (empty mask).
233
+ """
234
+ _load_model()
235
+
236
+ if img1.shape != img2.shape:
237
+ img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
238
+
239
+ h, w = img1.shape[:2]
240
+ ops = _resolve_tta_ops()
241
+
242
+ acc = np.zeros((h, w), dtype=np.float32)
243
+ n = 0
244
+ for op in ops:
245
+ a1 = np.ascontiguousarray(_apply_flip(img1, op))
246
+ a2 = np.ascontiguousarray(_apply_flip(img2, op))
247
+ try:
248
+ s = _infer_score_map(a1, a2)
249
+ except Exception as exc:
250
+ logger.warning("TTA pass %s failed: %s", op, exc)
251
+ continue
252
+ acc += _unflip_map(s, op)
253
+ n += 1
254
+
255
+ if n == 0:
256
+ raise RuntimeError("AdaptFormer inference produced no predictions")
257
+ avg_score = acc / float(n)
258
 
259
  mask = (avg_score >= threshold).astype(np.uint8) * 255
260
  return mask, avg_score