Upload detect_cave.py with huggingface_hub
Browse files- detect_cave.py +981 -0
detect_cave.py
ADDED
|
@@ -0,0 +1,981 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
detect_cave.py — Automatic cave entrance detector for IR/NIR imagery.
|
| 4 |
+
|
| 5 |
+
Usage:
|
| 6 |
+
python detect_cave.py # batch: all jpg/png in current dir
|
| 7 |
+
python detect_cave.py img1.png img2.png # specific images
|
| 8 |
+
|
| 9 |
+
v4 — Improved pipeline (opencv + numpy only, no external models):
|
| 10 |
+
- IR physics depth map (darkness × multi-scale local uniformity) as new signal
|
| 11 |
+
- Texture gate: penalises textured rock/vegetation masquerading as voids
|
| 12 |
+
- Vertical centroid gate: suppresses top-of-frame artefacts
|
| 13 |
+
- GrabCut boundary refinement after candidate selection
|
| 14 |
+
- Contour smoothing in refine_mask (wrap-around Gaussian)
|
| 15 |
+
- Amber dilation ring in result visualisation
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
import cv2
|
| 19 |
+
import numpy as np
|
| 20 |
+
import os
|
| 21 |
+
import sys
|
| 22 |
+
import glob
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 26 |
+
# 1. LOAD
|
| 27 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 28 |
+
|
| 29 |
+
def load_image(path: str):
|
| 30 |
+
"""Load image → (gray_u8, gray_f32 [0..1])."""
|
| 31 |
+
img = cv2.imread(path, cv2.IMREAD_UNCHANGED)
|
| 32 |
+
if img is None:
|
| 33 |
+
raise FileNotFoundError(f"Cannot read: {path}")
|
| 34 |
+
if img.ndim == 3:
|
| 35 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 36 |
+
else:
|
| 37 |
+
gray = img.copy()
|
| 38 |
+
gray_u8 = gray.astype(np.uint8)
|
| 39 |
+
gray_f32 = gray_u8.astype(np.float32) / 255.0
|
| 40 |
+
return gray_u8, gray_f32
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 44 |
+
# 2. PREPROCESS
|
| 45 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 46 |
+
|
| 47 |
+
def preprocess_image(gray_u8, gray_f32):
|
| 48 |
+
"""
|
| 49 |
+
Gentle preprocessing:
|
| 50 |
+
- Median denoise
|
| 51 |
+
- Very-large-blur background illumination estimate
|
| 52 |
+
- Division normalisation (preserves cave darkness relative to local bg)
|
| 53 |
+
"""
|
| 54 |
+
h, w = gray_u8.shape
|
| 55 |
+
|
| 56 |
+
denoised = cv2.medianBlur(gray_u8, 5)
|
| 57 |
+
|
| 58 |
+
# Background: huge blur (40% of min dimension)
|
| 59 |
+
bg_k = max(3, int(min(h, w) * 0.40) | 1)
|
| 60 |
+
background = cv2.GaussianBlur(denoised.astype(np.float32),
|
| 61 |
+
(bg_k, bg_k), 0)
|
| 62 |
+
background = np.clip(background, 10.0, 255.0)
|
| 63 |
+
|
| 64 |
+
# Division normalisation
|
| 65 |
+
corrected_f = denoised.astype(np.float32) / background
|
| 66 |
+
corrected_u8 = np.clip(corrected_f * 170, 0, 255).astype(np.uint8)
|
| 67 |
+
|
| 68 |
+
return {
|
| 69 |
+
"denoised": denoised,
|
| 70 |
+
"background": background,
|
| 71 |
+
"corrected_u8": corrected_u8,
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 76 |
+
# 3. VALID REGION
|
| 77 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 78 |
+
|
| 79 |
+
def compute_valid_region(gray_f32):
|
| 80 |
+
"""
|
| 81 |
+
Soft weight map based on horizontal illumination profile.
|
| 82 |
+
Uses 80th percentile per column, smoothed.
|
| 83 |
+
Returns (weight_map, left_col, right_col, profile_norm).
|
| 84 |
+
"""
|
| 85 |
+
h, w = gray_f32.shape
|
| 86 |
+
|
| 87 |
+
col_profile = np.percentile(gray_f32, 80, axis=0).astype(np.float32)
|
| 88 |
+
smooth_k = max(5, int(w * 0.08) | 1)
|
| 89 |
+
profile_smooth = cv2.GaussianBlur(
|
| 90 |
+
col_profile.reshape(1, -1), (smooth_k, 1), 0
|
| 91 |
+
).flatten()
|
| 92 |
+
|
| 93 |
+
pmax = max(profile_smooth.max(), 1e-6)
|
| 94 |
+
profile_norm = profile_smooth / pmax
|
| 95 |
+
|
| 96 |
+
drop_thresh = 0.45
|
| 97 |
+
actual_left_col = 0
|
| 98 |
+
for c in range(w):
|
| 99 |
+
if profile_norm[c] >= drop_thresh:
|
| 100 |
+
actual_left_col = c
|
| 101 |
+
break
|
| 102 |
+
actual_right_col = w - 1
|
| 103 |
+
for c in range(w - 1, -1, -1):
|
| 104 |
+
if profile_norm[c] >= drop_thresh:
|
| 105 |
+
actual_right_col = c
|
| 106 |
+
break
|
| 107 |
+
|
| 108 |
+
left_col = min(actual_left_col, int(w * 0.30))
|
| 109 |
+
right_col = max(actual_right_col, int(w * 0.70))
|
| 110 |
+
|
| 111 |
+
# Soft weight map
|
| 112 |
+
weight_row = np.ones(w, dtype=np.float32)
|
| 113 |
+
for c in range(w):
|
| 114 |
+
if c < left_col:
|
| 115 |
+
weight_row[c] = 0.3 + 0.7 * c / max(left_col, 1)
|
| 116 |
+
elif c > right_col:
|
| 117 |
+
weight_row[c] = 0.3 + 0.7 * (w - 1 - c) / max(w - 1 - right_col, 1)
|
| 118 |
+
weight_row *= np.clip(profile_norm / drop_thresh, 0.3, 1.0)
|
| 119 |
+
weight_row = np.clip(weight_row, 0.0, 1.0)
|
| 120 |
+
|
| 121 |
+
weight_map = np.tile(weight_row, (h, 1))
|
| 122 |
+
return weight_map, left_col, right_col, profile_norm, actual_left_col, actual_right_col
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 126 |
+
# 4. IR PHYSICS DEPTH
|
| 127 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 128 |
+
|
| 129 |
+
def compute_ir_depth(gray_f32):
|
| 130 |
+
"""
|
| 131 |
+
Fast IR physics depth map: darkness × local_uniformity at 3 scales.
|
| 132 |
+
|
| 133 |
+
Cave voids absorb all IR → near-black AND very uniform.
|
| 134 |
+
Textured surfaces (rock, vegetation) can appear dark but non-uniform.
|
| 135 |
+
Returns a depth map in [0..1]; higher = more likely to be a deep cavity.
|
| 136 |
+
"""
|
| 137 |
+
h, w = gray_f32.shape
|
| 138 |
+
darkness = 1.0 - gray_f32
|
| 139 |
+
depths = []
|
| 140 |
+
for base_k in [15, 31, 61]:
|
| 141 |
+
ksize = max(3, min(base_k, min(h, w) // 3) | 1)
|
| 142 |
+
mean_l = cv2.GaussianBlur(gray_f32, (ksize, ksize), 0)
|
| 143 |
+
mean_sq = cv2.GaussianBlur(gray_f32 * gray_f32, (ksize, ksize), 0)
|
| 144 |
+
var_l = np.clip(mean_sq - mean_l * mean_l, 0.0, None)
|
| 145 |
+
std_l = np.sqrt(var_l)
|
| 146 |
+
# uniformity: 1 when perfectly uniform, drops toward 0 with high relative std
|
| 147 |
+
denom = np.clip(mean_l + 0.05, 0.05, None)
|
| 148 |
+
uniformity = 1.0 - np.clip(std_l / denom, 0.0, 1.0)
|
| 149 |
+
depths.append(darkness * uniformity)
|
| 150 |
+
return np.mean(depths, axis=0).astype(np.float32)
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 154 |
+
# 5. GENERATE CANDIDATES
|
| 155 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 156 |
+
|
| 157 |
+
def _extract_components(binary, min_area):
|
| 158 |
+
"""Extract connected components ≥ min_area after morphological cleaning."""
|
| 159 |
+
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
|
| 160 |
+
binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, k)
|
| 161 |
+
binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, k)
|
| 162 |
+
n, labels, stats, _ = cv2.connectedComponentsWithStats(binary, 8)
|
| 163 |
+
result = []
|
| 164 |
+
for i in range(1, n):
|
| 165 |
+
if stats[i, cv2.CC_STAT_AREA] >= min_area:
|
| 166 |
+
result.append(((labels == i) * 255).astype(np.uint8))
|
| 167 |
+
return result
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _extract_components_heavy(binary, min_area, h, w):
|
| 171 |
+
"""Extract components with HEAVY morphological bridging (large closing).
|
| 172 |
+
Bridges fragmented dark spots that belong to the same cave entrance."""
|
| 173 |
+
close_size = max(15, int(min(h, w) * 0.04) | 1)
|
| 174 |
+
close_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE,
|
| 175 |
+
(close_size, close_size))
|
| 176 |
+
bridged = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, close_k)
|
| 177 |
+
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
|
| 178 |
+
bridged = cv2.morphologyEx(bridged, cv2.MORPH_OPEN, k)
|
| 179 |
+
n, labels, stats, _ = cv2.connectedComponentsWithStats(bridged, 8)
|
| 180 |
+
result = []
|
| 181 |
+
for i in range(1, n):
|
| 182 |
+
if stats[i, cv2.CC_STAT_AREA] >= min_area:
|
| 183 |
+
result.append(((labels == i) * 255).astype(np.uint8))
|
| 184 |
+
return result
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def generate_candidates(proc, gray_f32, h, w, left_col=0, right_col=None):
|
| 188 |
+
"""
|
| 189 |
+
Multi-strategy candidate generation:
|
| 190 |
+
A. Multi-level thresholding with standard cleaning
|
| 191 |
+
B. Multi-level thresholding with heavy bridging
|
| 192 |
+
C. Iterative seed-growth from darkest pixels
|
| 193 |
+
D. Otsu thresholding
|
| 194 |
+
E. Adaptive threshold intersected with a dark base
|
| 195 |
+
F. Valid-zone-only masking (lateral shadows masked out)
|
| 196 |
+
"""
|
| 197 |
+
denoised = proc["denoised"]
|
| 198 |
+
corrected_u8 = proc["corrected_u8"]
|
| 199 |
+
|
| 200 |
+
candidates = []
|
| 201 |
+
min_area = int(h * w * 0.008) # 0.8%
|
| 202 |
+
|
| 203 |
+
# ── A. Multi-level standard thresholding ──────────────────────────────────
|
| 204 |
+
for pct in [10, 15, 20, 25, 30, 35, 40]:
|
| 205 |
+
thr = int(np.percentile(denoised, pct))
|
| 206 |
+
_, binary = cv2.threshold(denoised, thr, 255, cv2.THRESH_BINARY_INV)
|
| 207 |
+
candidates += _extract_components(binary, min_area)
|
| 208 |
+
|
| 209 |
+
# Same on corrected
|
| 210 |
+
for pct in [15, 25, 35, 45]:
|
| 211 |
+
thr = int(np.percentile(corrected_u8, pct))
|
| 212 |
+
_, binary = cv2.threshold(corrected_u8, thr, 255, cv2.THRESH_BINARY_INV)
|
| 213 |
+
candidates += _extract_components(binary, min_area)
|
| 214 |
+
|
| 215 |
+
# ── B. Multi-level with HEAVY bridging ────────────────────────────────────
|
| 216 |
+
for pct in [10, 15, 20, 25, 30, 35]:
|
| 217 |
+
thr = int(np.percentile(denoised, pct))
|
| 218 |
+
_, binary = cv2.threshold(denoised, thr, 255, cv2.THRESH_BINARY_INV)
|
| 219 |
+
candidates += _extract_components_heavy(binary, min_area, h, w)
|
| 220 |
+
|
| 221 |
+
# ── C. Iterative seed-growth ──────────────────────────────────────────────
|
| 222 |
+
p1 = int(np.percentile(denoised, 1))
|
| 223 |
+
_, seed = cv2.threshold(denoised, max(p1, 3), 255, cv2.THRESH_BINARY_INV)
|
| 224 |
+
seed_k = cv2.getStructuringElement(
|
| 225 |
+
cv2.MORPH_ELLIPSE,
|
| 226 |
+
(max(7, int(min(h, w) * 0.03) | 1),
|
| 227 |
+
max(7, int(min(h, w) * 0.03) | 1))
|
| 228 |
+
)
|
| 229 |
+
seed = cv2.morphologyEx(seed, cv2.MORPH_CLOSE, seed_k)
|
| 230 |
+
|
| 231 |
+
for pct in [5, 10, 15, 20, 25, 30, 35, 40, 50]:
|
| 232 |
+
thr = int(np.percentile(denoised, pct))
|
| 233 |
+
_, dark_level = cv2.threshold(denoised, thr, 255, cv2.THRESH_BINARY_INV)
|
| 234 |
+
grow_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
|
| 235 |
+
grown = cv2.dilate(seed, grow_k, iterations=2)
|
| 236 |
+
grown = cv2.bitwise_and(grown, dark_level)
|
| 237 |
+
grown = cv2.morphologyEx(grown, cv2.MORPH_CLOSE, seed_k)
|
| 238 |
+
seed = cv2.bitwise_or(seed, grown)
|
| 239 |
+
candidates += _extract_components(grown, min_area)
|
| 240 |
+
|
| 241 |
+
# ── D. Otsu ───────────────────────────────────────────────────────────────
|
| 242 |
+
_, th_otsu = cv2.threshold(denoised, 0, 255,
|
| 243 |
+
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
|
| 244 |
+
candidates += _extract_components(th_otsu, min_area)
|
| 245 |
+
candidates += _extract_components_heavy(th_otsu, min_area, h, w)
|
| 246 |
+
|
| 247 |
+
# ── E. Adaptive + dark base ───────────────────────────────────────────────
|
| 248 |
+
block = max(11, int(min(h, w) * 0.15) | 1)
|
| 249 |
+
th_adapt = cv2.adaptiveThreshold(
|
| 250 |
+
denoised, 255,
|
| 251 |
+
cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
|
| 252 |
+
cv2.THRESH_BINARY_INV,
|
| 253 |
+
blockSize=block, C=10
|
| 254 |
+
)
|
| 255 |
+
med_val = int(np.median(denoised))
|
| 256 |
+
_, dark_base = cv2.threshold(denoised, med_val, 255, cv2.THRESH_BINARY_INV)
|
| 257 |
+
combined = cv2.bitwise_and(th_adapt, dark_base)
|
| 258 |
+
candidates += _extract_components_heavy(combined, min_area, h, w)
|
| 259 |
+
|
| 260 |
+
# ── F. Valid-zone-only thresholding ──────────────────────────────────────
|
| 261 |
+
if right_col is None:
|
| 262 |
+
right_col = w - 1
|
| 263 |
+
if left_col > 10 or right_col < w - 11:
|
| 264 |
+
masked_den = denoised.copy()
|
| 265 |
+
masked_den[:, :left_col] = 255
|
| 266 |
+
masked_den[:, right_col+1:] = 255
|
| 267 |
+
for pct in [10, 15, 20, 25, 30, 35, 40]:
|
| 268 |
+
thr = int(np.percentile(denoised, pct))
|
| 269 |
+
_, binary = cv2.threshold(masked_den, thr, 255, cv2.THRESH_BINARY_INV)
|
| 270 |
+
candidates += _extract_components(binary, min_area)
|
| 271 |
+
candidates += _extract_components_heavy(binary, min_area, h, w)
|
| 272 |
+
|
| 273 |
+
# ── Deduplicate (IoU > 0.80) ──────────────────────────────────────────────
|
| 274 |
+
unique = []
|
| 275 |
+
for cand in candidates:
|
| 276 |
+
cand_nz = np.count_nonzero(cand)
|
| 277 |
+
is_dup = False
|
| 278 |
+
for ref in unique:
|
| 279 |
+
inter = np.count_nonzero(cand & ref)
|
| 280 |
+
union = cand_nz + np.count_nonzero(ref) - inter
|
| 281 |
+
if union > 0 and inter / union > 0.80:
|
| 282 |
+
is_dup = True
|
| 283 |
+
break
|
| 284 |
+
if not is_dup:
|
| 285 |
+
unique.append(cand)
|
| 286 |
+
|
| 287 |
+
return unique
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 291 |
+
# 6. SCORE A CANDIDATE
|
| 292 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 293 |
+
|
| 294 |
+
def score_candidate(mask, gray_f32, weight_map, left_col, right_col,
|
| 295 |
+
darkest5_mask, depth_map=None):
|
| 296 |
+
"""
|
| 297 |
+
Multi-criteria scoring with MULTIPLICATIVE gates.
|
| 298 |
+
|
| 299 |
+
Key design:
|
| 300 |
+
- Contrast vs surround is the primary additive signal
|
| 301 |
+
- IR physics depth rewards dark AND uniform regions (true voids)
|
| 302 |
+
- Texture gate (multiplicative) penalises textured rock/vegetation
|
| 303 |
+
- Vertical gate (multiplicative) suppresses top-frame artefacts
|
| 304 |
+
- Area and solidity are MULTIPLICATIVE — wrong size/shape kills score
|
| 305 |
+
"""
|
| 306 |
+
h, w = gray_f32.shape
|
| 307 |
+
img_area = h * w
|
| 308 |
+
mask_bool = mask.astype(bool)
|
| 309 |
+
area = int(mask_bool.sum())
|
| 310 |
+
if area < 10:
|
| 311 |
+
return {"total": -1.0}
|
| 312 |
+
|
| 313 |
+
# ── Geometry ──────────────────────────────────────────────────────────────
|
| 314 |
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL,
|
| 315 |
+
cv2.CHAIN_APPROX_SIMPLE)
|
| 316 |
+
if not contours:
|
| 317 |
+
return {"total": -1.0}
|
| 318 |
+
cnt = max(contours, key=cv2.contourArea)
|
| 319 |
+
cnt_area = cv2.contourArea(cnt)
|
| 320 |
+
hull_area = cv2.contourArea(cv2.convexHull(cnt))
|
| 321 |
+
solidity = cnt_area / hull_area if hull_area > 0 else 0.0
|
| 322 |
+
x, y, bw, bh = cv2.boundingRect(cnt)
|
| 323 |
+
aspect = min(bw, bh) / max(bw, bh) if max(bw, bh) > 0 else 0.0
|
| 324 |
+
area_frac = area / img_area
|
| 325 |
+
|
| 326 |
+
# ── Intensity ─────────────────────────────────────────────────────────────
|
| 327 |
+
vals_inside = gray_f32[mask_bool]
|
| 328 |
+
mean_inside = float(vals_inside.mean())
|
| 329 |
+
std_inside = float(vals_inside.std())
|
| 330 |
+
darkness = 1.0 - mean_inside
|
| 331 |
+
|
| 332 |
+
# ── 1. Contrast vs wide surround (ADDITIVE, primary) ─────────────────────
|
| 333 |
+
ring_width = max(40, int(min(h, w) * 0.08))
|
| 334 |
+
dil_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE,
|
| 335 |
+
(ring_width, ring_width))
|
| 336 |
+
dilated = cv2.dilate(mask, dil_k)
|
| 337 |
+
ring = dilated.astype(bool) & (~mask_bool)
|
| 338 |
+
if ring.sum() > 100:
|
| 339 |
+
mean_outside = float(gray_f32[ring].mean())
|
| 340 |
+
else:
|
| 341 |
+
mean_outside = float(gray_f32.mean())
|
| 342 |
+
contrast = mean_outside - mean_inside
|
| 343 |
+
contrast_score = np.clip(contrast / 0.25, 0.0, 1.0)
|
| 344 |
+
|
| 345 |
+
# ── 2. Darkness score (ADDITIVE) ──────────────────────────────────────────
|
| 346 |
+
dark_score = np.clip(darkness / 0.7, 0.0, 1.0)
|
| 347 |
+
|
| 348 |
+
# ── 3. Darkness enrichment (ADDITIVE) ─────────────────────────────────────
|
| 349 |
+
darkest5_bool = darkest5_mask.astype(bool)
|
| 350 |
+
total_darkest = max(darkest5_bool.sum(), 1)
|
| 351 |
+
contained_frac = float((mask_bool & darkest5_bool).sum()) / total_darkest
|
| 352 |
+
enrichment = contained_frac / max(area_frac, 0.001)
|
| 353 |
+
enrichment_score = np.clip((enrichment - 1.0) / 8.0, 0.0, 1.0)
|
| 354 |
+
|
| 355 |
+
# ── 4. Distance-transform depth (ADDITIVE) ───────────────────────────────
|
| 356 |
+
dist_transform = cv2.distanceTransform(mask, cv2.DIST_L2, 5)
|
| 357 |
+
max_dist = float(dist_transform.max())
|
| 358 |
+
ref_dist = min(h, w) * 0.12
|
| 359 |
+
depth_score = np.clip(max_dist / ref_dist, 0.0, 1.0)
|
| 360 |
+
|
| 361 |
+
# ── 5. IR physics depth (ADDITIVE) ────────────────────────────────────────
|
| 362 |
+
# Cave voids are dark AND spatially uniform; textured surfaces score lower
|
| 363 |
+
if depth_map is not None:
|
| 364 |
+
ir_depth_score = float(np.clip(depth_map[mask_bool].mean() / 0.5, 0.0, 1.0))
|
| 365 |
+
else:
|
| 366 |
+
ir_depth_score = dark_score * 0.5 # fallback without depth map
|
| 367 |
+
|
| 368 |
+
# ── 6. Boundary gradient (ADDITIVE) ───────────────────────────────────────
|
| 369 |
+
grad_x = cv2.Sobel(gray_f32, cv2.CV_32F, 1, 0, ksize=5)
|
| 370 |
+
grad_y = cv2.Sobel(gray_f32, cv2.CV_32F, 0, 1, ksize=5)
|
| 371 |
+
grad_mag = np.sqrt(grad_x**2 + grad_y**2)
|
| 372 |
+
thin_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
|
| 373 |
+
contour_ring = cv2.dilate(mask, thin_k) - cv2.erode(mask, thin_k)
|
| 374 |
+
contour_bool = contour_ring.astype(bool)
|
| 375 |
+
if contour_bool.sum() > 0:
|
| 376 |
+
gradient_score = np.clip(float(grad_mag[contour_bool].mean()) / 0.10,
|
| 377 |
+
0.0, 1.0)
|
| 378 |
+
else:
|
| 379 |
+
gradient_score = 0.0
|
| 380 |
+
|
| 381 |
+
# ── 7. Valid region alignment (ADDITIVE) ──────────────────────────────────
|
| 382 |
+
valid_score = float(weight_map[mask_bool].mean())
|
| 383 |
+
|
| 384 |
+
# ── 8. Aspect ratio (ADDITIVE) ────────────────────────────────────────────
|
| 385 |
+
aspect_score = 1.0 if aspect >= 0.15 else aspect / 0.15
|
| 386 |
+
|
| 387 |
+
# ── 9. Position (ADDITIVE, very mild centre bias) ─────────────────────────
|
| 388 |
+
cx = x + bw / 2.0
|
| 389 |
+
cy = y + bh / 2.0
|
| 390 |
+
dist_x = abs(cx / w - 0.5) * 2
|
| 391 |
+
dist_y = abs(cy / h - 0.5) * 2
|
| 392 |
+
position_score = 1.0 - 0.10 * dist_x - 0.05 * dist_y
|
| 393 |
+
|
| 394 |
+
# ── Additive base score ───────────────────────────────────────────────────
|
| 395 |
+
# Weights sum to 1.0:
|
| 396 |
+
# 0.24+0.14+0.06+0.12+0.10+0.09+0.06+0.03+0.04+0.12 = 1.00
|
| 397 |
+
additive = (
|
| 398 |
+
0.24 * contrast_score
|
| 399 |
+
+ 0.14 * dark_score
|
| 400 |
+
+ 0.06 * enrichment_score
|
| 401 |
+
+ 0.12 * depth_score # distance-transform depth
|
| 402 |
+
+ 0.10 * ir_depth_score # IR physics depth (darkness × uniformity)
|
| 403 |
+
+ 0.09 * gradient_score
|
| 404 |
+
+ 0.06 * valid_score
|
| 405 |
+
+ 0.03 * aspect_score
|
| 406 |
+
+ 0.04 * position_score
|
| 407 |
+
+ 0.12 * 1.0 # base
|
| 408 |
+
)
|
| 409 |
+
|
| 410 |
+
# ── MULTIPLICATIVE GATES ──────────────────────────────────────────────────
|
| 411 |
+
|
| 412 |
+
# Area gate: 8%–28% ideal; large blobs (>28%) are usually outdoor
|
| 413 |
+
# shadows, not cave entrances — penalise them more steeply than before.
|
| 414 |
+
if area_frac < 0.005:
|
| 415 |
+
area_mult = 0.05
|
| 416 |
+
elif area_frac < 0.02:
|
| 417 |
+
area_mult = 0.05 + 0.20 * (area_frac - 0.005) / 0.015
|
| 418 |
+
elif area_frac < 0.04:
|
| 419 |
+
area_mult = 0.25 + 0.25 * (area_frac - 0.02) / 0.02
|
| 420 |
+
elif area_frac < 0.08:
|
| 421 |
+
area_mult = 0.50 + 0.50 * (area_frac - 0.04) / 0.04
|
| 422 |
+
elif area_frac <= 0.28:
|
| 423 |
+
area_mult = 1.0
|
| 424 |
+
elif area_frac <= 0.45:
|
| 425 |
+
area_mult = 1.0 - 0.80 * (area_frac - 0.28) / 0.17
|
| 426 |
+
else:
|
| 427 |
+
area_mult = max(0.05, 0.20 - 0.15 * (area_frac - 0.45) / 0.55)
|
| 428 |
+
|
| 429 |
+
# Solidity gate: very non-convex (donut, tentacles) → penalised
|
| 430 |
+
if solidity >= 0.45:
|
| 431 |
+
solidity_mult = 1.0
|
| 432 |
+
elif solidity >= 0.25:
|
| 433 |
+
solidity_mult = 0.4 + 0.6 * (solidity - 0.25) / 0.20
|
| 434 |
+
else:
|
| 435 |
+
solidity_mult = 0.4
|
| 436 |
+
|
| 437 |
+
# Texture gate: cave voids are dark AND uniform; textured regions penalised.
|
| 438 |
+
# std_inside > 0.10 starts the ramp; above 0.22 caps at 0.60.
|
| 439 |
+
if std_inside <= 0.10:
|
| 440 |
+
texture_mult = 1.0
|
| 441 |
+
elif std_inside <= 0.22:
|
| 442 |
+
texture_mult = 1.0 - 0.40 * (std_inside - 0.10) / 0.12
|
| 443 |
+
else:
|
| 444 |
+
texture_mult = 0.60
|
| 445 |
+
|
| 446 |
+
# Vertical gate: entrances in the top quarter of the frame are unlikely.
|
| 447 |
+
# Penalises bright sky patches and illuminated ceiling artefacts.
|
| 448 |
+
if cy / h < 0.25:
|
| 449 |
+
vert_gate = 0.75
|
| 450 |
+
elif cy / h < 0.35:
|
| 451 |
+
vert_gate = 0.75 + 0.25 * (cy / h - 0.25) / 0.10
|
| 452 |
+
else:
|
| 453 |
+
vert_gate = 1.0
|
| 454 |
+
|
| 455 |
+
# Lateral penalty
|
| 456 |
+
lateral_pen = 1.0
|
| 457 |
+
if int(cx) < left_col or int(cx) > right_col:
|
| 458 |
+
if valid_score < 0.5:
|
| 459 |
+
lateral_pen = 0.4
|
| 460 |
+
|
| 461 |
+
total = (additive * area_mult * solidity_mult
|
| 462 |
+
* texture_mult * vert_gate * lateral_pen)
|
| 463 |
+
|
| 464 |
+
return {
|
| 465 |
+
"total": round(float(total), 4),
|
| 466 |
+
"additive": round(float(additive), 3),
|
| 467 |
+
"contrast": round(float(contrast_score), 3),
|
| 468 |
+
"dark": round(float(dark_score), 3),
|
| 469 |
+
"enrichment": round(float(enrichment_score), 3),
|
| 470 |
+
"depth": round(float(depth_score), 3),
|
| 471 |
+
"ir_depth": round(float(ir_depth_score), 3),
|
| 472 |
+
"texture": round(float(std_inside), 3),
|
| 473 |
+
"texture_mult": round(float(texture_mult), 3),
|
| 474 |
+
"vert_gate": round(float(vert_gate), 3),
|
| 475 |
+
"area_mult": round(float(area_mult), 3),
|
| 476 |
+
"area_frac": round(float(area_frac), 4),
|
| 477 |
+
"solidity": round(float(solidity), 3),
|
| 478 |
+
"sol_mult": round(float(solidity_mult), 3),
|
| 479 |
+
"gradient": round(float(gradient_score), 3),
|
| 480 |
+
"valid_score": round(float(valid_score), 3),
|
| 481 |
+
"mean_inside": round(float(mean_inside), 3),
|
| 482 |
+
"mean_outside": round(float(mean_outside), 3),
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
|
| 486 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 487 |
+
# 7. SELECT BEST
|
| 488 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 489 |
+
|
| 490 |
+
def select_best_candidate(candidates, gray_f32, weight_map,
|
| 491 |
+
left_col, right_col, depth_map=None):
|
| 492 |
+
"""Score all candidates, return (best_mask, best_scores, all_scores)."""
|
| 493 |
+
if not candidates:
|
| 494 |
+
return None, {}, []
|
| 495 |
+
|
| 496 |
+
p5 = np.percentile(gray_f32, 5)
|
| 497 |
+
darkest5_mask = (gray_f32 <= p5).astype(np.uint8) * 255
|
| 498 |
+
|
| 499 |
+
all_scores = []
|
| 500 |
+
for cand in candidates:
|
| 501 |
+
sc = score_candidate(cand, gray_f32, weight_map, left_col, right_col,
|
| 502 |
+
darkest5_mask, depth_map=depth_map)
|
| 503 |
+
all_scores.append(sc)
|
| 504 |
+
|
| 505 |
+
best_idx = max(range(len(all_scores)),
|
| 506 |
+
key=lambda i: all_scores[i]["total"])
|
| 507 |
+
return candidates[best_idx], all_scores[best_idx], all_scores
|
| 508 |
+
|
| 509 |
+
|
| 510 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 511 |
+
# 8. GRABCUT REFINE
|
| 512 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 513 |
+
|
| 514 |
+
def grabcut_refine(gray_u8, mask, conservative_mask=None, expand_ratio=2.5):
|
| 515 |
+
"""
|
| 516 |
+
Refine mask boundary using GrabCut (OpenCV graph-cut, no extra deps).
|
| 517 |
+
|
| 518 |
+
If conservative_mask is provided (the pre-expansion baseline), it is used
|
| 519 |
+
as definite FG so GrabCut anchors on the known-good core and can include
|
| 520 |
+
additional dark interior pixels without over-trimming.
|
| 521 |
+
|
| 522 |
+
Without conservative_mask: eroded core is definite FG.
|
| 523 |
+
With conservative_mask: conservative_mask is definite FG; extra pixels in
|
| 524 |
+
mask become probable FG, letting GrabCut decide which dark interior areas
|
| 525 |
+
(e.g. cave floor below a rock band) are genuinely part of the entrance.
|
| 526 |
+
"""
|
| 527 |
+
h, w = gray_u8.shape
|
| 528 |
+
area = np.count_nonzero(mask)
|
| 529 |
+
if area < 200:
|
| 530 |
+
return mask
|
| 531 |
+
|
| 532 |
+
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL,
|
| 533 |
+
cv2.CHAIN_APPROX_SIMPLE)
|
| 534 |
+
if not contours:
|
| 535 |
+
return mask
|
| 536 |
+
cnt = max(contours, key=cv2.contourArea)
|
| 537 |
+
x, y, bw, bh = cv2.boundingRect(cnt)
|
| 538 |
+
if bw < 5 or bh < 5:
|
| 539 |
+
return mask
|
| 540 |
+
|
| 541 |
+
# Expand bounding rectangle
|
| 542 |
+
ex = int(bw * (expand_ratio - 1) / 2)
|
| 543 |
+
ey = int(bh * (expand_ratio - 1) / 2)
|
| 544 |
+
x1 = max(0, x - ex); y1 = max(0, y - ey)
|
| 545 |
+
x2 = min(w, x + bw + ex); y2 = min(h, y + bh + ey)
|
| 546 |
+
if x2 - x1 < 5 or y2 - y1 < 5:
|
| 547 |
+
return mask
|
| 548 |
+
|
| 549 |
+
gc_mask = np.full((h, w), cv2.GC_BGD, dtype=np.uint8)
|
| 550 |
+
|
| 551 |
+
# Probable FG: dilated mask clipped to expanded rect
|
| 552 |
+
dil_r = max(5, int(min(bw, bh) * 0.10))
|
| 553 |
+
dil_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*dil_r+1, 2*dil_r+1))
|
| 554 |
+
prob_fg = cv2.dilate(mask, dil_k)
|
| 555 |
+
prob_fg[:y1, :] = 0; prob_fg[y2:, :] = 0
|
| 556 |
+
prob_fg[:, :x1] = 0; prob_fg[:, x2:] = 0
|
| 557 |
+
gc_mask[prob_fg > 0] = cv2.GC_PR_FGD
|
| 558 |
+
|
| 559 |
+
if conservative_mask is not None and np.count_nonzero(conservative_mask) >= 10:
|
| 560 |
+
# Definite FG: the pre-expansion mask (known-good entrance core)
|
| 561 |
+
gc_mask[conservative_mask > 0] = cv2.GC_FGD
|
| 562 |
+
else:
|
| 563 |
+
# Definite FG: eroded core of input mask
|
| 564 |
+
ero_r = max(3, int(min(bw, bh) * 0.08))
|
| 565 |
+
ero_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE,
|
| 566 |
+
(2*ero_r+1, 2*ero_r+1))
|
| 567 |
+
core = cv2.erode(mask, ero_k)
|
| 568 |
+
gc_mask[core > 0] = cv2.GC_FGD
|
| 569 |
+
|
| 570 |
+
# Probable BG: inside expanded rect but well away from mask
|
| 571 |
+
far_r = max(7, int(min(bw, bh) * 0.20))
|
| 572 |
+
far_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*far_r+1, 2*far_r+1))
|
| 573 |
+
far_dil = cv2.dilate(mask, far_k)
|
| 574 |
+
in_rect = np.zeros((h, w), np.uint8)
|
| 575 |
+
in_rect[y1:y2, x1:x2] = 255
|
| 576 |
+
prob_bg = cv2.bitwise_and(in_rect, cv2.bitwise_not(far_dil))
|
| 577 |
+
gc_mask[prob_bg > 0] = cv2.GC_PR_BGD
|
| 578 |
+
|
| 579 |
+
if (gc_mask == cv2.GC_FGD).sum() < 10:
|
| 580 |
+
return mask
|
| 581 |
+
|
| 582 |
+
bgd_model = np.zeros((1, 65), np.float64)
|
| 583 |
+
fgd_model = np.zeros((1, 65), np.float64)
|
| 584 |
+
rect = (x1, y1, x2 - x1, y2 - y1)
|
| 585 |
+
|
| 586 |
+
try:
|
| 587 |
+
vis3 = cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR)
|
| 588 |
+
cv2.grabCut(vis3, gc_mask, rect, bgd_model, fgd_model,
|
| 589 |
+
3, cv2.GC_INIT_WITH_MASK)
|
| 590 |
+
result = np.where(
|
| 591 |
+
(gc_mask == cv2.GC_FGD) | (gc_mask == cv2.GC_PR_FGD),
|
| 592 |
+
255, 0
|
| 593 |
+
).astype(np.uint8)
|
| 594 |
+
|
| 595 |
+
if np.count_nonzero(result) < area * 0.25:
|
| 596 |
+
return mask
|
| 597 |
+
|
| 598 |
+
# Keep the largest component that overlaps the original mask.
|
| 599 |
+
# (Guards against GrabCut fragmenting into many tiny pieces.)
|
| 600 |
+
n_comp, labels, stats, _ = cv2.connectedComponentsWithStats(result, 8)
|
| 601 |
+
if n_comp > 2:
|
| 602 |
+
overlap_ids = np.unique(labels[mask > 0])
|
| 603 |
+
overlap_ids = overlap_ids[overlap_ids != 0]
|
| 604 |
+
if len(overlap_ids) > 0:
|
| 605 |
+
keep_id = overlap_ids[
|
| 606 |
+
np.argmax(stats[overlap_ids, cv2.CC_STAT_AREA])
|
| 607 |
+
]
|
| 608 |
+
result = ((labels == keep_id) * 255).astype(np.uint8)
|
| 609 |
+
|
| 610 |
+
if np.count_nonzero(result) < area * 0.25:
|
| 611 |
+
return mask
|
| 612 |
+
|
| 613 |
+
return result
|
| 614 |
+
|
| 615 |
+
except Exception:
|
| 616 |
+
return mask
|
| 617 |
+
|
| 618 |
+
|
| 619 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 620 |
+
# 9. REFINE MASK
|
| 621 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 622 |
+
|
| 623 |
+
def refine_mask(mask, gray_f32):
|
| 624 |
+
"""Close gaps, fill holes, smooth boundary, keep largest component."""
|
| 625 |
+
h, w = gray_f32.shape
|
| 626 |
+
orig_area = np.count_nonzero(mask)
|
| 627 |
+
|
| 628 |
+
cs = max(11, int(min(h, w) * 0.02) | 1)
|
| 629 |
+
ck = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (cs, cs))
|
| 630 |
+
refined = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, ck)
|
| 631 |
+
|
| 632 |
+
# Fill interior holes safely using a 1-px black border.
|
| 633 |
+
bordered = np.zeros((h + 2, w + 2), np.uint8)
|
| 634 |
+
bordered[1:-1, 1:-1] = refined
|
| 635 |
+
flood = bordered.copy()
|
| 636 |
+
pad = np.zeros((h + 4, w + 4), np.uint8)
|
| 637 |
+
cv2.floodFill(flood, pad, (0, 0), 255)
|
| 638 |
+
holes = cv2.bitwise_not(flood)[1:-1, 1:-1]
|
| 639 |
+
refined = cv2.bitwise_or(refined, holes)
|
| 640 |
+
|
| 641 |
+
# Smooth
|
| 642 |
+
sk = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9))
|
| 643 |
+
refined = cv2.morphologyEx(refined, cv2.MORPH_CLOSE, sk)
|
| 644 |
+
refined = cv2.morphologyEx(refined, cv2.MORPH_OPEN, sk)
|
| 645 |
+
|
| 646 |
+
# Largest component only
|
| 647 |
+
n, labels, stats, _ = cv2.connectedComponentsWithStats(refined, 8)
|
| 648 |
+
if n > 1:
|
| 649 |
+
largest = 1 + np.argmax(stats[1:, cv2.CC_STAT_AREA])
|
| 650 |
+
refined = ((labels == largest) * 255).astype(np.uint8)
|
| 651 |
+
|
| 652 |
+
# ── Contour smoothing (wrap-around Gaussian) ──────────────────────────────
|
| 653 |
+
cnts, _ = cv2.findContours(refined, cv2.RETR_EXTERNAL,
|
| 654 |
+
cv2.CHAIN_APPROX_NONE)
|
| 655 |
+
if cnts:
|
| 656 |
+
main_cnt = max(cnts, key=cv2.contourArea)
|
| 657 |
+
pts = main_cnt.reshape(-1, 2).astype(np.float32)
|
| 658 |
+
n_pts = len(pts)
|
| 659 |
+
if n_pts > 30:
|
| 660 |
+
sigma = min(15.0, max(4.0, n_pts / 120.0))
|
| 661 |
+
ksize = max(3, int(6 * sigma) | 1)
|
| 662 |
+
pad_n = ksize // 2
|
| 663 |
+
padded = np.concatenate([pts[-pad_n:], pts, pts[:pad_n]], axis=0)
|
| 664 |
+
kernel = cv2.getGaussianKernel(ksize, sigma).flatten()
|
| 665 |
+
sx = np.convolve(padded[:, 0], kernel, mode='valid')[:n_pts]
|
| 666 |
+
sy = np.convolve(padded[:, 1], kernel, mode='valid')[:n_pts]
|
| 667 |
+
sx = np.clip(sx, 0, w - 1)
|
| 668 |
+
sy = np.clip(sy, 0, h - 1)
|
| 669 |
+
smooth_cnt = (np.stack([sx, sy], axis=1)
|
| 670 |
+
.astype(np.int32).reshape(-1, 1, 2))
|
| 671 |
+
smooth_mask = np.zeros_like(refined)
|
| 672 |
+
cv2.fillPoly(smooth_mask, [smooth_cnt], 255)
|
| 673 |
+
# Safety: don't shrink more than 30%
|
| 674 |
+
if np.count_nonzero(smooth_mask) >= np.count_nonzero(refined) * 0.70:
|
| 675 |
+
refined = smooth_mask
|
| 676 |
+
|
| 677 |
+
# Safety: revert if refinement bloated the mask beyond 2× original
|
| 678 |
+
if np.count_nonzero(refined) > max(orig_area * 2, h * w * 0.50):
|
| 679 |
+
return mask
|
| 680 |
+
|
| 681 |
+
return refined
|
| 682 |
+
|
| 683 |
+
|
| 684 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 685 |
+
# 10. DRAW RESULT
|
| 686 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 687 |
+
|
| 688 |
+
def draw_result(gray_u8, refined_mask, scores,
|
| 689 |
+
out_path, mask_path, debug_valid_path,
|
| 690 |
+
weight_map, profile_norm,
|
| 691 |
+
debug_cands_path, all_candidates, all_scores):
|
| 692 |
+
"""Save result overlay, mask, and debug images."""
|
| 693 |
+
h, w = gray_u8.shape
|
| 694 |
+
|
| 695 |
+
# ── Main result ───────────────────────────────────────────────────────────
|
| 696 |
+
vis = cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR)
|
| 697 |
+
|
| 698 |
+
# Amber dilation ring — buffer zone around detected entrance
|
| 699 |
+
dil_r = max(5, int(min(h, w) * 0.025))
|
| 700 |
+
dil_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*dil_r+1, 2*dil_r+1))
|
| 701 |
+
dil_mask = cv2.dilate(refined_mask, dil_k)
|
| 702 |
+
ring_mask = cv2.bitwise_and(dil_mask, cv2.bitwise_not(refined_mask))
|
| 703 |
+
ring_overlay = vis.copy()
|
| 704 |
+
ring_overlay[ring_mask > 0] = (30, 160, 255) # amber (BGR)
|
| 705 |
+
cv2.addWeighted(ring_overlay, 0.28, vis, 0.72, 0, vis)
|
| 706 |
+
|
| 707 |
+
# Green cave entrance overlay
|
| 708 |
+
overlay = vis.copy()
|
| 709 |
+
overlay[refined_mask > 0] = (100, 210, 60)
|
| 710 |
+
cv2.addWeighted(overlay, 0.35, vis, 0.65, 0, vis)
|
| 711 |
+
|
| 712 |
+
contours, _ = cv2.findContours(refined_mask, cv2.RETR_EXTERNAL,
|
| 713 |
+
cv2.CHAIN_APPROX_SIMPLE)
|
| 714 |
+
cv2.drawContours(vis, contours, -1, (0, 255, 80), 2)
|
| 715 |
+
|
| 716 |
+
score_val = scores.get("total", 0.0)
|
| 717 |
+
label = f"cave entrance score={score_val:.2f}"
|
| 718 |
+
if contours:
|
| 719 |
+
cnt = max(contours, key=cv2.contourArea)
|
| 720 |
+
x, y, bw, bh = cv2.boundingRect(cnt)
|
| 721 |
+
tx, ty = x + 5, max(y - 12, 25)
|
| 722 |
+
else:
|
| 723 |
+
tx, ty = 10, 30
|
| 724 |
+
|
| 725 |
+
fs = max(0.55, min(w, h) / 900)
|
| 726 |
+
th = max(1, int(fs * 2))
|
| 727 |
+
cv2.putText(vis, label, (tx+2, ty+2), cv2.FONT_HERSHEY_SIMPLEX,
|
| 728 |
+
fs, (0,0,0), th+2)
|
| 729 |
+
cv2.putText(vis, label, (tx, ty), cv2.FONT_HERSHEY_SIMPLEX,
|
| 730 |
+
fs, (0,255,120), th)
|
| 731 |
+
|
| 732 |
+
cv2.imwrite(out_path, vis)
|
| 733 |
+
cv2.imwrite(mask_path, refined_mask)
|
| 734 |
+
|
| 735 |
+
# ── Debug: valid region ───────────────────────────────────────────────────
|
| 736 |
+
dv = cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR)
|
| 737 |
+
for ch in range(3):
|
| 738 |
+
c = dv[:,:,ch].astype(np.float32)
|
| 739 |
+
if ch == 2:
|
| 740 |
+
c = c * weight_map + 180 * (1.0 - weight_map)
|
| 741 |
+
else:
|
| 742 |
+
c = c * weight_map
|
| 743 |
+
dv[:,:,ch] = np.clip(c, 0, 255).astype(np.uint8)
|
| 744 |
+
for c in range(w - 1):
|
| 745 |
+
y1 = h - 1 - int(profile_norm[c] * 59)
|
| 746 |
+
y2 = h - 1 - int(profile_norm[c + 1] * 59)
|
| 747 |
+
cv2.line(dv, (c, y1), (c+1, y2), (0,255,255), 1)
|
| 748 |
+
cv2.putText(dv, "valid region (red=penalised)", (10,25),
|
| 749 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255,255,0), 2)
|
| 750 |
+
cv2.imwrite(debug_valid_path, dv)
|
| 751 |
+
|
| 752 |
+
# ── Debug: candidates ─────────────────────────────────────────────────────
|
| 753 |
+
dc = cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR)
|
| 754 |
+
colours = [(255,80,0),(0,80,255),(200,0,200),(0,200,200),
|
| 755 |
+
(200,200,0),(0,160,80),(128,128,255),(255,128,128)]
|
| 756 |
+
indexed = sorted(range(len(all_candidates)),
|
| 757 |
+
key=lambda i: all_scores[i]["total"])
|
| 758 |
+
for rank, i in enumerate(indexed):
|
| 759 |
+
col = colours[i % len(colours)]
|
| 760 |
+
cl, _ = cv2.findContours(all_candidates[i], cv2.RETR_EXTERNAL,
|
| 761 |
+
cv2.CHAIN_APPROX_SIMPLE)
|
| 762 |
+
cv2.drawContours(dc, cl, -1, col, 1)
|
| 763 |
+
if rank >= len(indexed) - 5 and cl:
|
| 764 |
+
c0 = max(cl, key=cv2.contourArea)
|
| 765 |
+
M = cv2.moments(c0)
|
| 766 |
+
if M["m00"] > 0:
|
| 767 |
+
cx_m = int(M["m10"]/M["m00"])
|
| 768 |
+
cy_m = int(M["m01"]/M["m00"])
|
| 769 |
+
sc_v = all_scores[i]["total"]
|
| 770 |
+
cv2.putText(dc, f"{sc_v:.2f}", (cx_m, cy_m),
|
| 771 |
+
cv2.FONT_HERSHEY_SIMPLEX, 0.4, col, 1)
|
| 772 |
+
cv2.drawContours(dc, contours, -1, (255,255,255), 2)
|
| 773 |
+
cv2.putText(dc,
|
| 774 |
+
f"{len(all_candidates)} candidates (white=best, {score_val:.2f})",
|
| 775 |
+
(10,25), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255,255,255), 2)
|
| 776 |
+
cv2.imwrite(debug_cands_path, dc)
|
| 777 |
+
|
| 778 |
+
|
| 779 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 780 |
+
# 11. PROCESS ONE IMAGE
|
| 781 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 782 |
+
|
| 783 |
+
def process_image(input_path, output_dir):
|
| 784 |
+
"""Full pipeline for one image."""
|
| 785 |
+
bn = os.path.splitext(os.path.basename(input_path))[0]
|
| 786 |
+
out_r = os.path.join(output_dir, f"{bn}_result.png")
|
| 787 |
+
out_m = os.path.join(output_dir, f"{bn}_mask.png")
|
| 788 |
+
out_dv = os.path.join(output_dir, f"{bn}_debug_valid.png")
|
| 789 |
+
out_dc = os.path.join(output_dir, f"{bn}_debug_candidates.png")
|
| 790 |
+
|
| 791 |
+
gray_u8, gray_f32 = load_image(input_path)
|
| 792 |
+
h, w = gray_u8.shape
|
| 793 |
+
print(f" [{bn}] loaded {w}x{h}")
|
| 794 |
+
|
| 795 |
+
proc = preprocess_image(gray_u8, gray_f32)
|
| 796 |
+
wmap, lc, rc, pn, actual_lc, actual_rc = compute_valid_region(gray_f32)
|
| 797 |
+
depth_map = compute_ir_depth(gray_f32)
|
| 798 |
+
print(f" [{bn}] valid cols {lc}–{rc} (actual {actual_lc}–{actual_rc}, of {w})")
|
| 799 |
+
|
| 800 |
+
candidates = generate_candidates(proc, gray_f32, h, w, lc, rc)
|
| 801 |
+
print(f" [{bn}] {len(candidates)} unique candidates")
|
| 802 |
+
|
| 803 |
+
if not candidates:
|
| 804 |
+
print(f" [{bn}] WARNING: no candidates")
|
| 805 |
+
blank = np.zeros((h, w), np.uint8)
|
| 806 |
+
cv2.imwrite(out_m, blank)
|
| 807 |
+
cv2.imwrite(out_r, cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR))
|
| 808 |
+
return [out_r, out_m]
|
| 809 |
+
|
| 810 |
+
best_mask, scores, all_sc = select_best_candidate(
|
| 811 |
+
candidates, gray_f32, wmap, lc, rc, depth_map=depth_map
|
| 812 |
+
)
|
| 813 |
+
print(f" [{bn}] best score {scores['total']:.3f} "
|
| 814 |
+
f"area={scores['area_frac']*100:.1f}% "
|
| 815 |
+
f"add={scores['additive']:.2f} "
|
| 816 |
+
f"contrast={scores['contrast']:.2f} "
|
| 817 |
+
f"texture={scores['texture']:.2f}(×{scores['texture_mult']:.2f}) "
|
| 818 |
+
f"ir_depth={scores['ir_depth']:.2f} "
|
| 819 |
+
f"depth={scores['depth']:.2f} "
|
| 820 |
+
f"area_m={scores['area_mult']:.2f} "
|
| 821 |
+
f"sol={scores['solidity']:.2f}(×{scores['sol_mult']:.2f}) "
|
| 822 |
+
f"in={scores['mean_inside']:.2f}±{scores['texture']:.2f} "
|
| 823 |
+
f"out={scores['mean_outside']:.2f}")
|
| 824 |
+
|
| 825 |
+
# ── Solidity filter ───────────────────────────────────────────────────────
|
| 826 |
+
# Non-convex candidate (e.g. entrance merged with lateral IR shadow):
|
| 827 |
+
# keep only the well-illuminated weight-map portion.
|
| 828 |
+
if scores.get("solidity", 1.0) < 0.65 and np.count_nonzero(best_mask) > 100:
|
| 829 |
+
_is_dark_void = scores.get("mean_inside", 1.0) < 0.15
|
| 830 |
+
mask_weights = wmap[best_mask > 0]
|
| 831 |
+
# Dark voids use 50th pct (gentler); others use 60th pct
|
| 832 |
+
w_thresh = np.percentile(mask_weights, 50 if _is_dark_void else 60)
|
| 833 |
+
high_w = ((best_mask > 0) & (wmap >= w_thresh)).astype(np.uint8) * 255
|
| 834 |
+
sk = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11))
|
| 835 |
+
high_w = cv2.morphologyEx(high_w, cv2.MORPH_CLOSE, sk)
|
| 836 |
+
high_w = cv2.morphologyEx(high_w, cv2.MORPH_OPEN, sk)
|
| 837 |
+
n_hw, labels_hw, stats_hw, centroids_hw = cv2.connectedComponentsWithStats(
|
| 838 |
+
high_w, 8)
|
| 839 |
+
if n_hw > 1:
|
| 840 |
+
valid_comps = []
|
| 841 |
+
for ci in range(1, n_hw):
|
| 842 |
+
cx_ci = centroids_hw[ci, 0]
|
| 843 |
+
area_ci = stats_hw[ci, cv2.CC_STAT_AREA]
|
| 844 |
+
if lc <= cx_ci <= rc and area_ci >= np.count_nonzero(best_mask) * 0.10:
|
| 845 |
+
valid_comps.append((ci, area_ci))
|
| 846 |
+
if valid_comps:
|
| 847 |
+
best_ci = max(valid_comps, key=lambda x: x[1])[0]
|
| 848 |
+
best_mask = ((labels_hw == best_ci) * 255).astype(np.uint8)
|
| 849 |
+
else:
|
| 850 |
+
largest = 1 + np.argmax(stats_hw[1:, cv2.CC_STAT_AREA])
|
| 851 |
+
candidate_hw = ((labels_hw == largest) * 255).astype(np.uint8)
|
| 852 |
+
if np.count_nonzero(candidate_hw) >= np.count_nonzero(best_mask) * 0.15:
|
| 853 |
+
best_mask = candidate_hw
|
| 854 |
+
|
| 855 |
+
# ── Post-selection expansion ──────────────────────────────────────────────
|
| 856 |
+
# Grow selected mask into connected dark pixels at a relaxed threshold.
|
| 857 |
+
# Uses a dilated seed (4% reach) so nearby dark components separated by
|
| 858 |
+
# a thin lighter band are bridged.
|
| 859 |
+
# pre_expansion_mask is saved for GrabCut's conservative-FG initialisation.
|
| 860 |
+
pre_expansion_mask = best_mask.copy()
|
| 861 |
+
best_area_frac = np.count_nonzero(best_mask) / (h * w)
|
| 862 |
+
if best_area_frac < 0.25:
|
| 863 |
+
orig_mean = float(gray_f32[best_mask > 0].mean())
|
| 864 |
+
br_size = max(9, int(min(h, w) * 0.02) | 1)
|
| 865 |
+
br_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (br_size, br_size))
|
| 866 |
+
reach_r = max(15, int(min(h, w) * 0.04))
|
| 867 |
+
reach_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE,
|
| 868 |
+
(2*reach_r+1, 2*reach_r+1))
|
| 869 |
+
|
| 870 |
+
base_pct = min(50, max(30, int(scores.get("area_frac", 0.1) * 100 * 4)))
|
| 871 |
+
relax_thr = int(np.percentile(proc["denoised"], base_pct))
|
| 872 |
+
_, relax_dark = cv2.threshold(proc["denoised"], relax_thr, 255,
|
| 873 |
+
cv2.THRESH_BINARY_INV)
|
| 874 |
+
relax_dark = cv2.morphologyEx(relax_dark, cv2.MORPH_CLOSE, br_k)
|
| 875 |
+
n_rd, labels_rd, _, _ = cv2.connectedComponentsWithStats(relax_dark, 8)
|
| 876 |
+
seed_reach = cv2.dilate(best_mask, reach_k)
|
| 877 |
+
overlap_labels = set(np.unique(labels_rd[seed_reach > 0])) - {0}
|
| 878 |
+
if overlap_labels:
|
| 879 |
+
expanded = np.zeros_like(best_mask)
|
| 880 |
+
for lb in overlap_labels:
|
| 881 |
+
expanded[labels_rd == lb] = 255
|
| 882 |
+
# When the profile doesn't rise until well past the capped lc,
|
| 883 |
+
# there is a significant lateral zone → clip at the actual rise
|
| 884 |
+
# column to prevent the expansion from leaking into it.
|
| 885 |
+
clip_lc = actual_lc if actual_lc > lc else lc
|
| 886 |
+
clip_rc = actual_rc if actual_rc < rc else rc
|
| 887 |
+
if clip_lc > int(w * 0.05):
|
| 888 |
+
expanded[:, :clip_lc] = 0
|
| 889 |
+
if clip_rc < int(w * 0.95):
|
| 890 |
+
expanded[:, clip_rc+1:] = 0
|
| 891 |
+
n_exp, labels_exp, stats_exp, _ = cv2.connectedComponentsWithStats(
|
| 892 |
+
expanded, 8)
|
| 893 |
+
if n_exp > 1:
|
| 894 |
+
largest_exp = 1 + np.argmax(stats_exp[1:, cv2.CC_STAT_AREA])
|
| 895 |
+
expanded = ((labels_exp == largest_exp) * 255).astype(np.uint8)
|
| 896 |
+
exp_area_frac = np.count_nonzero(expanded) / (h * w)
|
| 897 |
+
exp_mean = float(gray_f32[expanded > 0].mean())
|
| 898 |
+
if (exp_area_frac <= 0.40
|
| 899 |
+
and exp_area_frac > best_area_frac * 0.8
|
| 900 |
+
and exp_mean < orig_mean + 0.15):
|
| 901 |
+
print(f" [{bn}] expanded {best_area_frac*100:.1f}% → "
|
| 902 |
+
f"{exp_area_frac*100:.1f}%")
|
| 903 |
+
best_mask = expanded
|
| 904 |
+
best_area_frac = exp_area_frac
|
| 905 |
+
|
| 906 |
+
# ── GrabCut boundary refinement ───────────────────────────────────────────
|
| 907 |
+
# Pass pre_expansion_mask as conservative FG when the mask has grown
|
| 908 |
+
# significantly — this anchors the definite-FG model on the clean core
|
| 909 |
+
# and lets GrabCut decide whether to include the dark interior or not.
|
| 910 |
+
pre_gc = np.count_nonzero(best_mask) / (h * w)
|
| 911 |
+
pre_exp_frac = np.count_nonzero(pre_expansion_mask) / (h * w)
|
| 912 |
+
use_conservative = (pre_gc > pre_exp_frac * 1.3)
|
| 913 |
+
gc_result = grabcut_refine(
|
| 914 |
+
gray_u8, best_mask,
|
| 915 |
+
conservative_mask=pre_expansion_mask if use_conservative else None,
|
| 916 |
+
expand_ratio=2.5
|
| 917 |
+
)
|
| 918 |
+
post_gc = np.count_nonzero(gc_result) / (h * w)
|
| 919 |
+
if post_gc > 0:
|
| 920 |
+
print(f" [{bn}] grabcut {pre_gc*100:.1f}% → {post_gc*100:.1f}%")
|
| 921 |
+
best_mask = gc_result
|
| 922 |
+
|
| 923 |
+
refined = refine_mask(best_mask, gray_f32)
|
| 924 |
+
draw_result(gray_u8, refined, scores,
|
| 925 |
+
out_r, out_m, out_dv,
|
| 926 |
+
wmap, pn,
|
| 927 |
+
out_dc, candidates, all_sc)
|
| 928 |
+
|
| 929 |
+
final_area = np.count_nonzero(refined) / (h * w)
|
| 930 |
+
print(f" [{bn}] final area {final_area*100:.1f}%")
|
| 931 |
+
outputs = [out_r, out_m, out_dv, out_dc]
|
| 932 |
+
for p in outputs:
|
| 933 |
+
print(f" [{bn}] saved: {os.path.basename(p)}")
|
| 934 |
+
return outputs
|
| 935 |
+
|
| 936 |
+
|
| 937 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 938 |
+
# 12. MAIN
|
| 939 |
+
# ──────────────────────────────────────────────────────────────────────────────
|
| 940 |
+
|
| 941 |
+
def main():
|
| 942 |
+
if len(sys.argv) >= 2:
|
| 943 |
+
# One or more explicit image paths
|
| 944 |
+
for img_path in sys.argv[1:]:
|
| 945 |
+
out_dir = os.path.dirname(os.path.abspath(img_path)) or "."
|
| 946 |
+
print(f"Processing: {os.path.basename(img_path)}")
|
| 947 |
+
process_image(img_path, out_dir)
|
| 948 |
+
print()
|
| 949 |
+
else:
|
| 950 |
+
# Batch mode: process all jpg/png in the script's directory
|
| 951 |
+
cwd = os.path.dirname(os.path.abspath(__file__))
|
| 952 |
+
patterns = ["*.jpg","*.jpeg","*.png","*.JPG","*.JPEG","*.PNG"]
|
| 953 |
+
found = []
|
| 954 |
+
for pat in patterns:
|
| 955 |
+
found.extend(glob.glob(os.path.join(cwd, pat)))
|
| 956 |
+
suffixes = ("_result.png","_mask.png","_debug_valid.png",
|
| 957 |
+
"_debug_candidates.png")
|
| 958 |
+
inputs = sorted(set(
|
| 959 |
+
f for f in found
|
| 960 |
+
if not any(os.path.basename(f).endswith(s) for s in suffixes)
|
| 961 |
+
))
|
| 962 |
+
if not inputs:
|
| 963 |
+
print("No input images found.")
|
| 964 |
+
sys.exit(1)
|
| 965 |
+
print(f"Found {len(inputs)} input image(s):")
|
| 966 |
+
for p in inputs:
|
| 967 |
+
print(f" {os.path.basename(p)}")
|
| 968 |
+
print()
|
| 969 |
+
all_out = []
|
| 970 |
+
for img in inputs:
|
| 971 |
+
print(f"Processing: {os.path.basename(img)}")
|
| 972 |
+
all_out += process_image(img, cwd)
|
| 973 |
+
print()
|
| 974 |
+
print("─" * 60)
|
| 975 |
+
print(f"Done. {len(all_out)} output files:")
|
| 976 |
+
for p in all_out:
|
| 977 |
+
print(f" {os.path.basename(p)}")
|
| 978 |
+
|
| 979 |
+
|
| 980 |
+
if __name__ == "__main__":
|
| 981 |
+
main()
|