Spaces:
Sleeping
Sleeping
Commit ·
8c0e957
1
Parent(s): 15e9574
Improve detection accuracy: TTA for AdaptFormer, hysteresis thresholding, robust logits and LBP borders.
Browse files- app/detection_engine.py +43 -5
- 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 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 |
-
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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
|
| 120 |
-
"""
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|