Spaces:
Sleeping
Sleeping
Commit ·
82f4285
1
Parent(s): c9b0675
Improve detection accuracy: border exclusion, threshold floors, fill-ratio filter, NMS, tighter bboxes
Browse files- 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 361 |
-
|
|
|
|
|
|
|
| 362 |
|
| 363 |
-
# Post-process
|
| 364 |
change_mask = _clean_mask(change_mask)
|
| 365 |
|
| 366 |
-
#
|
| 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 |
-
|
|
|
|
| 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 |
-
"""
|
| 400 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
close_size = max(3, int(7 * (1 - sensitivity)))
|
| 402 |
if close_size % 2 == 0:
|
| 403 |
close_size += 1
|
| 404 |
-
|
| 405 |
-
mask = cv2.morphologyEx(mask, cv2.MORPH_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
|
| 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 |
-
#
|
| 467 |
red_layer = np.zeros_like(img2, dtype=np.float32)
|
| 468 |
red_layer[:, :, 0] = 255
|
| 469 |
-
alpha = 0.
|
| 470 |
for c in range(3):
|
| 471 |
-
overlay[:, :, c] = overlay[:, :, c] * (1 - 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 483 |
-
|
|
|
|
|
|
|
| 484 |
|
| 485 |
-
#
|
|
|
|
|
|
|
|
|
|
| 486 |
rid = r.get("id", 0)
|
| 487 |
label = str(rid)
|
| 488 |
font = cv2.FONT_HERSHEY_SIMPLEX
|
| 489 |
-
font_scale = max(0.
|
| 490 |
-
thickness =
|
| 491 |
(tw, th), _ = cv2.getTextSize(label, font, font_scale, thickness)
|
| 492 |
-
lx
|
| 493 |
-
|
| 494 |
-
cv2.
|
| 495 |
-
|
|
|
|
|
|
|
|
|
|
| 496 |
|
| 497 |
-
return
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1366 |
before_img=None):
|
| 1367 |
"""
|
| 1368 |
-
Find connected change regions
|
| 1369 |
-
|
| 1370 |
-
|
|
|
|
|
|
|
|
|
|
| 1371 |
"""
|
| 1372 |
-
num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(
|
|
|
|
| 1373 |
change_regions = []
|
| 1374 |
region_id = 0
|
| 1375 |
|
|
|
|
|
|
|
|
|
|
| 1376 |
for i in range(1, num_labels):
|
| 1377 |
-
|
| 1378 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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":
|
| 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 |
-
|
| 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=
|
| 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])
|