coderuday21 commited on
Commit
82f4285
·
1 Parent(s): c9b0675

Improve detection accuracy: border exclusion, threshold floors, fill-ratio filter, NMS, tighter bboxes

Browse files
Files changed (1) hide show
  1. app/detection_engine.py +171 -50
app/detection_engine.py CHANGED
@@ -223,9 +223,11 @@ def image_difference_method(img1, img2, threshold=0.25, blur_size=5):
223
  )
224
  delta_e = delta_e / delta_e.max() if delta_e.max() > 0 else delta_e
225
 
226
- # Adaptive threshold using Otsu on the change map
227
  delta_uint8 = (delta_e * 255).astype(np.uint8)
228
- _, change_mask = cv2.threshold(delta_uint8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
 
 
 
229
 
230
  change_mask = _clean_mask(change_mask)
231
  return change_mask
@@ -357,13 +359,14 @@ def ai_deep_learning_method(img1, img2):
357
  fused = fused / (fused.max() + 1e-8)
358
  fused_uint8 = (fused * 255).astype(np.uint8)
359
 
360
- # Adaptive threshold: Otsu + refinement
361
- _, change_mask = cv2.threshold(fused_uint8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
 
 
362
 
363
- # Post-process
364
  change_mask = _clean_mask(change_mask)
365
 
366
- # Edge-preserving smoothing on the mask
367
  change_mask = cv2.bilateralFilter(change_mask, 9, 75, 75)
368
  _, change_mask = cv2.threshold(change_mask, 127, 255, cv2.THRESH_BINARY)
369
 
@@ -386,7 +389,8 @@ def hybrid_method(img1, img2):
386
  0.5 * ai_mask.astype(np.float32)
387
  )
388
 
389
- _, final_mask = cv2.threshold(combined.astype(np.uint8), 127, 255, cv2.THRESH_BINARY)
 
390
  final_mask = _clean_mask(final_mask)
391
  return final_mask
392
 
@@ -395,25 +399,52 @@ def hybrid_method(img1, img2):
395
  # 8. Robust post-processing
396
  # ---------------------------------------------------------------------------
397
 
398
- def _clean_mask(mask, sensitivity=0.5):
399
- """Adaptive morphological cleaning: close gaps, remove noise, fill holes."""
400
- # Close small gaps
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
401
  close_size = max(3, int(7 * (1 - sensitivity)))
402
  if close_size % 2 == 0:
403
  close_size += 1
404
- kernel_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (close_size, close_size))
405
- mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel_close)
406
-
407
- # Remove small noise
408
- open_size = 3
409
- kernel_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (open_size, open_size))
410
- mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel_open)
411
 
412
- # Fill small holes inside detected regions
413
  contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
414
  filled = np.zeros_like(mask)
415
  cv2.drawContours(filled, contours, -1, 255, thickness=cv2.FILLED)
416
 
 
 
 
 
 
417
  return filled
418
 
419
 
@@ -463,38 +494,51 @@ def visualize_changes(img1, img2, change_mask, regions=None, total_pixels=None):
463
  mask_bool = change_mask > 127
464
  mask_float = mask_bool.astype(np.float32)
465
 
466
- # Red overlay for all detected change pixels
467
  red_layer = np.zeros_like(img2, dtype=np.float32)
468
  red_layer[:, :, 0] = 255
469
- alpha = 0.50
470
  for c in range(3):
471
- overlay[:, :, c] = overlay[:, :, c] * (1 - mask_float * alpha) + red_layer[:, :, c] * mask_float * alpha
 
472
 
 
473
  total_px = total_pixels if total_pixels is not None else (img2.shape[0] * img2.shape[1])
474
 
475
  if regions:
476
- overlay_uint8 = np.clip(overlay, 0, 255).astype(np.uint8)
 
 
 
477
  for r in regions:
478
  x, y, w, h = r["bbox"]
479
  severity = r.get("severity") or _severity_from_region(r, total_px)
480
  color = _SEVERITY_COLORS.get(severity, (255, 255, 255))
481
 
482
- # Color-coded bounding box (thicker for visibility)
483
- cv2.rectangle(overlay_uint8, (x, y), (x + w, y + h), color, 2)
 
 
484
 
485
- # Numbered label matching summary table (region ID)
 
 
 
486
  rid = r.get("id", 0)
487
  label = str(rid)
488
  font = cv2.FONT_HERSHEY_SIMPLEX
489
- font_scale = max(0.4, min(0.7, w / 150))
490
- thickness = 2
491
  (tw, th), _ = cv2.getTextSize(label, font, font_scale, thickness)
492
- lx, ly = x, max(th + 4, y - 4)
493
- cv2.rectangle(overlay_uint8, (lx, ly - th - 4), (lx + tw + 8, ly + 2), (0, 0, 0), cv2.FILLED)
494
- cv2.putText(overlay_uint8, label, (lx + 4, ly - 2), font, font_scale, (255, 255, 255), thickness, cv2.LINE_AA)
495
- return overlay_uint8
 
 
 
496
 
497
- return np.clip(overlay, 0, 255).astype(np.uint8)
498
 
499
 
500
  # ---------------------------------------------------------------------------
@@ -1362,29 +1406,101 @@ def analyze_building_3d(before_img, after_img, region, features):
1362
  # 14. Region analysis
1363
  # ---------------------------------------------------------------------------
1364
 
1365
- def analyze_change_regions(change_mask, image, min_area=200, use_ensemble=True,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1366
  before_img=None):
1367
  """
1368
- Find connected change regions, classify as ground-level changes only.
1369
- Transient objects (people, cars, animals) are filtered out.
1370
- Building regions get enriched with 3D analysis (stories, height, stage).
 
 
 
1371
  """
1372
- num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(change_mask, connectivity=8)
 
1373
  change_regions = []
1374
  region_id = 0
1375
 
 
 
 
1376
  for i in range(1, num_labels):
1377
- area = stats[i, cv2.CC_STAT_AREA]
1378
- if area < min_area:
 
 
 
 
 
 
 
 
 
 
 
1379
  continue
1380
 
1381
- x = stats[i, cv2.CC_STAT_LEFT]
1382
- y = stats[i, cv2.CC_STAT_TOP]
1383
- w = stats[i, cv2.CC_STAT_WIDTH]
1384
- h = stats[i, cv2.CC_STAT_HEIGHT]
1385
  cx, cy = centroids[i]
1386
 
1387
- if use_ensemble and area > 500:
1388
  object_type, confidence = classify_with_ensemble(image, (x, y, w, h))
1389
  else:
1390
  object_type, confidence = classify_object_type(image, (x, y, w, h))
@@ -1395,11 +1511,12 @@ def analyze_change_regions(change_mask, image, min_area=200, use_ensemble=True,
1395
  region_id += 1
1396
  region = {
1397
  "id": region_id,
1398
- "area": area,
1399
  "bbox": (x, y, w, h),
1400
  "center": (int(cx), int(cy)),
1401
  "object_type": object_type,
1402
  "confidence": confidence,
 
1403
  "sub_type": None,
1404
  "sub_type_confidence": None,
1405
  "estimated_stories": None,
@@ -1407,7 +1524,6 @@ def analyze_change_regions(change_mask, image, min_area=200, use_ensemble=True,
1407
  "construction_stage": None,
1408
  }
1409
 
1410
- # Sub-classification and 3D analysis require before image
1411
  if before_img is not None:
1412
  if object_type in _VEGETATION_TYPES:
1413
  sub, sub_conf = classify_vegetation_subtype(
@@ -1421,7 +1537,6 @@ def analyze_change_regions(change_mask, image, min_area=200, use_ensemble=True,
1421
  region["sub_type"] = sub
1422
  region["sub_type_confidence"] = sub_conf
1423
 
1424
- # 3D analysis for building/construction regions
1425
  if object_type in _BUILDING_TYPES:
1426
  pad = 5
1427
  ry1 = max(0, y - pad)
@@ -1434,10 +1549,16 @@ def analyze_change_regions(change_mask, image, min_area=200, use_ensemble=True,
1434
 
1435
  change_regions.append(region)
1436
 
 
1437
  change_regions.sort(key=lambda r: r["area"], reverse=True)
 
 
 
 
 
 
1438
 
1439
- # Assign severity for color-coded display and table summary
1440
- total_px = change_mask.shape[0] * change_mask.shape[1]
1441
  for r in change_regions:
1442
  r["severity"] = _severity_from_region(r, total_px)
1443
 
@@ -1469,7 +1590,7 @@ def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
1469
  change_mask = hybrid_method(before_array, after_array)
1470
 
1471
  change_regions = analyze_change_regions(
1472
- change_mask, after_array, min_area=200, before_img=before_array
1473
  )
1474
 
1475
  total_pixels = int(change_mask.shape[0] * change_mask.shape[1])
 
223
  )
224
  delta_e = delta_e / delta_e.max() if delta_e.max() > 0 else delta_e
225
 
 
226
  delta_uint8 = (delta_e * 255).astype(np.uint8)
227
+ otsu_val, change_mask = cv2.threshold(delta_uint8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
228
+ # Floor: if Otsu picks a very low threshold the mask is mostly noise
229
+ if otsu_val < 30:
230
+ _, change_mask = cv2.threshold(delta_uint8, 30, 255, cv2.THRESH_BINARY)
231
 
232
  change_mask = _clean_mask(change_mask)
233
  return change_mask
 
359
  fused = fused / (fused.max() + 1e-8)
360
  fused_uint8 = (fused * 255).astype(np.uint8)
361
 
362
+ # Otsu with a minimum floor to reject near-zero thresholds on similar images
363
+ otsu_val, change_mask = cv2.threshold(fused_uint8, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
364
+ if otsu_val < 25:
365
+ _, change_mask = cv2.threshold(fused_uint8, 25, 255, cv2.THRESH_BINARY)
366
 
 
367
  change_mask = _clean_mask(change_mask)
368
 
369
+ # Bilateral filter preserves sharp change boundaries while smoothing noise
370
  change_mask = cv2.bilateralFilter(change_mask, 9, 75, 75)
371
  _, change_mask = cv2.threshold(change_mask, 127, 255, cv2.THRESH_BINARY)
372
 
 
389
  0.5 * ai_mask.astype(np.float32)
390
  )
391
 
392
+ # Use a higher threshold: a pixel must be flagged by multiple methods
393
+ _, final_mask = cv2.threshold(combined.astype(np.uint8), 140, 255, cv2.THRESH_BINARY)
394
  final_mask = _clean_mask(final_mask)
395
  return final_mask
396
 
 
399
  # 8. Robust post-processing
400
  # ---------------------------------------------------------------------------
401
 
402
+ def _clean_mask(mask, sensitivity=0.5, border_margin=12):
403
+ """
404
+ Robust morphological cleaning:
405
+ 1. Zero-out border pixels (registration artifacts)
406
+ 2. Median filter to kill salt-and-pepper noise
407
+ 3. Opening to remove small specks
408
+ 4. Closing to bridge tiny gaps
409
+ 5. Fill holes inside regions
410
+ 6. Erode-then-dilate to break thin noise bridges between separate changes
411
+ """
412
+ h, w = mask.shape[:2]
413
+
414
+ # 1. Remove false positives along image border (common with registration)
415
+ if border_margin > 0:
416
+ mask[:border_margin, :] = 0
417
+ mask[-border_margin:, :] = 0
418
+ mask[:, :border_margin] = 0
419
+ mask[:, -border_margin:] = 0
420
+
421
+ # 2. Median to remove isolated noise pixels
422
+ mask = cv2.medianBlur(mask, 5)
423
+
424
+ # 3. Opening (erosion then dilation) removes small specks
425
+ open_size = max(3, int(5 * (1 - sensitivity * 0.5)))
426
+ if open_size % 2 == 0:
427
+ open_size += 1
428
+ k_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (open_size, open_size))
429
+ mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k_open)
430
+
431
+ # 4. Closing to bridge small internal gaps
432
  close_size = max(3, int(7 * (1 - sensitivity)))
433
  if close_size % 2 == 0:
434
  close_size += 1
435
+ k_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (close_size, close_size))
436
+ mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_close)
 
 
 
 
 
437
 
438
+ # 5. Fill holes inside regions
439
  contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
440
  filled = np.zeros_like(mask)
441
  cv2.drawContours(filled, contours, -1, 255, thickness=cv2.FILLED)
442
 
443
+ # 6. Erode to break thin noise bridges, then dilate back
444
+ k_break = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
445
+ filled = cv2.erode(filled, k_break, iterations=1)
446
+ filled = cv2.dilate(filled, k_break, iterations=1)
447
+
448
  return filled
449
 
450
 
 
494
  mask_bool = change_mask > 127
495
  mask_float = mask_bool.astype(np.float32)
496
 
497
+ # Lighter red overlay (35% alpha) so the image stays readable
498
  red_layer = np.zeros_like(img2, dtype=np.float32)
499
  red_layer[:, :, 0] = 255
500
+ alpha = 0.35
501
  for c in range(3):
502
+ overlay[:, :, c] = (overlay[:, :, c] * (1 - mask_float * alpha)
503
+ + red_layer[:, :, c] * mask_float * alpha)
504
 
505
+ overlay_uint8 = np.clip(overlay, 0, 255).astype(np.uint8)
506
  total_px = total_pixels if total_pixels is not None else (img2.shape[0] * img2.shape[1])
507
 
508
  if regions:
509
+ # Scale line thickness to image size so boxes are visible at any resolution
510
+ diag = np.sqrt(img2.shape[0]**2 + img2.shape[1]**2)
511
+ line_thickness = max(2, int(diag / 400))
512
+
513
  for r in regions:
514
  x, y, w, h = r["bbox"]
515
  severity = r.get("severity") or _severity_from_region(r, total_px)
516
  color = _SEVERITY_COLORS.get(severity, (255, 255, 255))
517
 
518
+ # Semi-transparent filled rect behind the box for contrast
519
+ box_overlay = overlay_uint8.copy()
520
+ cv2.rectangle(box_overlay, (x, y), (x + w, y + h), color, cv2.FILLED)
521
+ cv2.addWeighted(box_overlay, 0.12, overlay_uint8, 0.88, 0, overlay_uint8)
522
 
523
+ # Color-coded bounding box
524
+ cv2.rectangle(overlay_uint8, (x, y), (x + w, y + h), color, line_thickness)
525
+
526
+ # Numbered label
527
  rid = r.get("id", 0)
528
  label = str(rid)
529
  font = cv2.FONT_HERSHEY_SIMPLEX
530
+ font_scale = max(0.45, min(0.8, w / 120))
531
+ thickness = max(1, line_thickness - 1)
532
  (tw, th), _ = cv2.getTextSize(label, font, font_scale, thickness)
533
+ lx = x
534
+ ly = max(th + 6, y - 6)
535
+ cv2.rectangle(overlay_uint8,
536
+ (lx, ly - th - 6), (lx + tw + 10, ly + 2),
537
+ color, cv2.FILLED)
538
+ cv2.putText(overlay_uint8, label, (lx + 5, ly - 2),
539
+ font, font_scale, (255, 255, 255), thickness, cv2.LINE_AA)
540
 
541
+ return overlay_uint8
542
 
543
 
544
  # ---------------------------------------------------------------------------
 
1406
  # 14. Region analysis
1407
  # ---------------------------------------------------------------------------
1408
 
1409
+ def _tight_bbox(labels, label_id, stats_row):
1410
+ """
1411
+ Compute a tighter bounding box using actual changed pixels.
1412
+ Falls back to the connected-component bbox if the mask is dense enough.
1413
+ """
1414
+ x = stats_row[cv2.CC_STAT_LEFT]
1415
+ y = stats_row[cv2.CC_STAT_TOP]
1416
+ w = stats_row[cv2.CC_STAT_WIDTH]
1417
+ h = stats_row[cv2.CC_STAT_HEIGHT]
1418
+ area = stats_row[cv2.CC_STAT_AREA]
1419
+
1420
+ fill_ratio = area / max(w * h, 1)
1421
+
1422
+ # If the component fills less than 20% of its bbox, compute a tighter fit
1423
+ if fill_ratio < 0.20 and area > 100:
1424
+ ys, xs = np.where(labels == label_id)
1425
+ if len(xs) > 0:
1426
+ x = int(np.min(xs))
1427
+ y = int(np.min(ys))
1428
+ w = int(np.max(xs) - x + 1)
1429
+ h = int(np.max(ys) - y + 1)
1430
+ fill_ratio = area / max(w * h, 1)
1431
+
1432
+ return x, y, w, h, fill_ratio
1433
+
1434
+
1435
+ def _iou(boxA, boxB):
1436
+ """Intersection-over-union for two (x,y,w,h) boxes."""
1437
+ ax1, ay1, aw, ah = boxA
1438
+ bx1, by1, bw, bh = boxB
1439
+ ax2, ay2 = ax1 + aw, ay1 + ah
1440
+ bx2, by2 = bx1 + bw, by1 + bh
1441
+
1442
+ ix1, iy1 = max(ax1, bx1), max(ay1, by1)
1443
+ ix2, iy2 = min(ax2, bx2), min(ay2, by2)
1444
+ inter = max(0, ix2 - ix1) * max(0, iy2 - iy1)
1445
+ union = aw * ah + bw * bh - inter
1446
+ return inter / max(union, 1)
1447
+
1448
+
1449
+ def _nms_regions(regions, iou_thresh=0.45):
1450
+ """Non-maximum suppression: keep the highest-area box when two overlap."""
1451
+ if len(regions) < 2:
1452
+ return regions
1453
+ keep = []
1454
+ used = set()
1455
+ for i, r in enumerate(regions):
1456
+ if i in used:
1457
+ continue
1458
+ keep.append(r)
1459
+ for j in range(i + 1, len(regions)):
1460
+ if j in used:
1461
+ continue
1462
+ if _iou(r["bbox"], regions[j]["bbox"]) > iou_thresh:
1463
+ used.add(j)
1464
+ return keep
1465
+
1466
+
1467
+ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
1468
  before_img=None):
1469
  """
1470
+ Find connected change regions with strict quality filters:
1471
+ - Higher min_area (400) to reject noise
1472
+ - Fill-ratio filter: reject boxes that are mostly empty
1473
+ - Tighter bounding boxes computed from actual pixel coordinates
1474
+ - NMS to remove overlapping/duplicate boxes
1475
+ - Max 60 regions cap to avoid flooding the UI
1476
  """
1477
+ num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(
1478
+ change_mask, connectivity=8)
1479
  change_regions = []
1480
  region_id = 0
1481
 
1482
+ img_h, img_w = change_mask.shape[:2]
1483
+ img_area = img_h * img_w
1484
+
1485
  for i in range(1, num_labels):
1486
+ raw_area = stats[i, cv2.CC_STAT_AREA]
1487
+ if raw_area < min_area:
1488
+ continue
1489
+
1490
+ x, y, w, h, fill_ratio = _tight_bbox(labels, i, stats[i])
1491
+
1492
+ # Reject very sparse regions (bbox is mostly empty)
1493
+ if fill_ratio < 0.10:
1494
+ continue
1495
+
1496
+ # Reject regions that cover more than 40% of the image (likely a global
1497
+ # illumination shift, not a real change)
1498
+ if (w * h) > img_area * 0.40:
1499
  continue
1500
 
 
 
 
 
1501
  cx, cy = centroids[i]
1502
 
1503
+ if use_ensemble and raw_area > 500:
1504
  object_type, confidence = classify_with_ensemble(image, (x, y, w, h))
1505
  else:
1506
  object_type, confidence = classify_object_type(image, (x, y, w, h))
 
1511
  region_id += 1
1512
  region = {
1513
  "id": region_id,
1514
+ "area": int(raw_area),
1515
  "bbox": (x, y, w, h),
1516
  "center": (int(cx), int(cy)),
1517
  "object_type": object_type,
1518
  "confidence": confidence,
1519
+ "fill_ratio": round(fill_ratio, 3),
1520
  "sub_type": None,
1521
  "sub_type_confidence": None,
1522
  "estimated_stories": None,
 
1524
  "construction_stage": None,
1525
  }
1526
 
 
1527
  if before_img is not None:
1528
  if object_type in _VEGETATION_TYPES:
1529
  sub, sub_conf = classify_vegetation_subtype(
 
1537
  region["sub_type"] = sub
1538
  region["sub_type_confidence"] = sub_conf
1539
 
 
1540
  if object_type in _BUILDING_TYPES:
1541
  pad = 5
1542
  ry1 = max(0, y - pad)
 
1549
 
1550
  change_regions.append(region)
1551
 
1552
+ # Sort by area descending, apply NMS, cap at 60
1553
  change_regions.sort(key=lambda r: r["area"], reverse=True)
1554
+ change_regions = _nms_regions(change_regions, iou_thresh=0.45)
1555
+ change_regions = change_regions[:60]
1556
+
1557
+ # Re-number after filtering
1558
+ for idx, r in enumerate(change_regions, start=1):
1559
+ r["id"] = idx
1560
 
1561
+ total_px = img_area
 
1562
  for r in change_regions:
1563
  r["severity"] = _severity_from_region(r, total_px)
1564
 
 
1590
  change_mask = hybrid_method(before_array, after_array)
1591
 
1592
  change_regions = analyze_change_regions(
1593
+ change_mask, after_array, min_area=400, before_img=before_array
1594
  )
1595
 
1596
  total_pixels = int(change_mask.shape[0] * change_mask.shape[1])