kerojohan commited on
Commit
0098daa
·
verified ·
1 Parent(s): 38b0036

Upload detect_cave.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. 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()