kerojohan commited on
Commit
38b0036
·
verified ·
1 Parent(s): 3185e04

Upload app.py with huggingface_hub

Browse files
Files changed (1) hide show
  1. app.py +312 -0
app.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ app.py — CaveMark Gradio Space for Hugging Face
3
+ Wraps detect_cave.py pipeline to work in-memory (no disk I/O).
4
+ """
5
+
6
+ import cv2
7
+ import numpy as np
8
+ import gradio as gr
9
+
10
+ from detect_cave import (
11
+ preprocess_image,
12
+ compute_valid_region,
13
+ compute_ir_depth,
14
+ generate_candidates,
15
+ select_best_candidate,
16
+ grabcut_refine,
17
+ refine_mask,
18
+ )
19
+
20
+
21
+ # ──────────────────────────────────────────────────────────────────────────────
22
+ # In-memory draw helpers (mirrors draw_result but returns numpy arrays)
23
+ # ──────────────────────────────────────────────────────────────────────────────
24
+
25
+ def _draw_result_arrays(gray_u8, refined_mask, scores,
26
+ weight_map, profile_norm,
27
+ all_candidates, all_scores):
28
+ h, w = gray_u8.shape
29
+
30
+ # ── Main result overlay ───────────────────────────────────────────────────
31
+ vis = cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR)
32
+
33
+ dil_r = max(5, int(min(h, w) * 0.025))
34
+ dil_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*dil_r+1, 2*dil_r+1))
35
+ dil_mask = cv2.dilate(refined_mask, dil_k)
36
+ ring_mask = cv2.bitwise_and(dil_mask, cv2.bitwise_not(refined_mask))
37
+ ring_overlay = vis.copy()
38
+ ring_overlay[ring_mask > 0] = (30, 160, 255)
39
+ cv2.addWeighted(ring_overlay, 0.28, vis, 0.72, 0, vis)
40
+
41
+ overlay = vis.copy()
42
+ overlay[refined_mask > 0] = (100, 210, 60)
43
+ cv2.addWeighted(overlay, 0.35, vis, 0.65, 0, vis)
44
+
45
+ contours, _ = cv2.findContours(refined_mask, cv2.RETR_EXTERNAL,
46
+ cv2.CHAIN_APPROX_SIMPLE)
47
+ cv2.drawContours(vis, contours, -1, (0, 255, 80), 2)
48
+
49
+ score_val = scores.get("total", 0.0)
50
+ label = f"cave entrance score={score_val:.2f}"
51
+ if contours:
52
+ cnt = max(contours, key=cv2.contourArea)
53
+ x, y, bw, bh = cv2.boundingRect(cnt)
54
+ tx, ty = x + 5, max(y - 12, 25)
55
+ else:
56
+ tx, ty = 10, 30
57
+
58
+ fs = max(0.55, min(w, h) / 900)
59
+ th = max(1, int(fs * 2))
60
+ cv2.putText(vis, label, (tx+2, ty+2), cv2.FONT_HERSHEY_SIMPLEX,
61
+ fs, (0, 0, 0), th+2)
62
+ cv2.putText(vis, label, (tx, ty), cv2.FONT_HERSHEY_SIMPLEX,
63
+ fs, (0, 255, 120), th)
64
+
65
+ result_rgb = cv2.cvtColor(vis, cv2.COLOR_BGR2RGB)
66
+
67
+ # ── Mask ──────────────────────────────────────────────────────────────────
68
+ mask_rgb = cv2.cvtColor(refined_mask, cv2.COLOR_GRAY2RGB)
69
+
70
+ # ── Debug: valid region ───────────────────────────────────────────────────
71
+ dv = cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR)
72
+ for ch in range(3):
73
+ c = dv[:, :, ch].astype(np.float32)
74
+ if ch == 2:
75
+ c = c * weight_map + 180 * (1.0 - weight_map)
76
+ else:
77
+ c = c * weight_map
78
+ dv[:, :, ch] = np.clip(c, 0, 255).astype(np.uint8)
79
+ for col in range(w - 1):
80
+ y1 = h - 1 - int(profile_norm[col] * 59)
81
+ y2 = h - 1 - int(profile_norm[col + 1] * 59)
82
+ cv2.line(dv, (col, y1), (col+1, y2), (0, 255, 255), 1)
83
+ cv2.putText(dv, "valid region (red=penalised)", (10, 25),
84
+ cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 0), 2)
85
+ valid_rgb = cv2.cvtColor(dv, cv2.COLOR_BGR2RGB)
86
+
87
+ # ── Debug: candidates ─────────────────────────────────────────────────────
88
+ dc = cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR)
89
+ colours = [(255,80,0),(0,80,255),(200,0,200),(0,200,200),
90
+ (200,200,0),(0,160,80),(128,128,255),(255,128,128)]
91
+ indexed = sorted(range(len(all_candidates)),
92
+ key=lambda i: all_scores[i]["total"])
93
+ for rank, i in enumerate(indexed):
94
+ col = colours[i % len(colours)]
95
+ cl, _ = cv2.findContours(all_candidates[i], cv2.RETR_EXTERNAL,
96
+ cv2.CHAIN_APPROX_SIMPLE)
97
+ cv2.drawContours(dc, cl, -1, col, 1)
98
+ if rank >= len(indexed) - 5 and cl:
99
+ c0 = max(cl, key=cv2.contourArea)
100
+ M = cv2.moments(c0)
101
+ if M["m00"] > 0:
102
+ cx_m = int(M["m10"] / M["m00"])
103
+ cy_m = int(M["m01"] / M["m00"])
104
+ cv2.putText(dc, f"{all_scores[i]['total']:.2f}", (cx_m, cy_m),
105
+ cv2.FONT_HERSHEY_SIMPLEX, 0.4, col, 1)
106
+ cv2.drawContours(dc, contours, -1, (255, 255, 255), 2)
107
+ cv2.putText(dc,
108
+ f"{len(all_candidates)} candidates (white=best, {score_val:.2f})",
109
+ (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 2)
110
+ cands_rgb = cv2.cvtColor(dc, cv2.COLOR_BGR2RGB)
111
+
112
+ return result_rgb, mask_rgb, valid_rgb, cands_rgb
113
+
114
+
115
+ # ──────────────────────────────────────────────────────────────────────────────
116
+ # Full in-memory pipeline
117
+ # ──────────────────────────────────────────────────────────────────────────────
118
+
119
+ def _process_array(img_rgb: np.ndarray):
120
+ """Run the full CaveMark pipeline on a numpy RGB array."""
121
+ # Convert to grayscale
122
+ gray_u8 = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY)
123
+ gray_f32 = gray_u8.astype(np.float32) / 255.0
124
+ h, w = gray_u8.shape
125
+
126
+ proc = preprocess_image(gray_u8, gray_f32)
127
+ wmap, lc, rc, pn, actual_lc, actual_rc = compute_valid_region(gray_f32)
128
+ depth_map = compute_ir_depth(gray_f32)
129
+
130
+ candidates = generate_candidates(proc, gray_f32, h, w, lc, rc)
131
+
132
+ if not candidates:
133
+ blank = np.zeros((h, w), np.uint8)
134
+ blank_rgb = cv2.cvtColor(blank, cv2.COLOR_GRAY2RGB)
135
+ vis_rgb = cv2.cvtColor(cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR),
136
+ cv2.COLOR_BGR2RGB)
137
+ info = "No cave entrance candidates found."
138
+ return vis_rgb, blank_rgb, blank_rgb, blank_rgb, info
139
+
140
+ best_mask, scores, all_sc = select_best_candidate(
141
+ candidates, gray_f32, wmap, lc, rc, depth_map=depth_map
142
+ )
143
+
144
+ # Solidity filter
145
+ if scores.get("solidity", 1.0) < 0.65 and np.count_nonzero(best_mask) > 100:
146
+ _is_dark_void = scores.get("mean_inside", 1.0) < 0.15
147
+ mask_weights = wmap[best_mask > 0]
148
+ w_thresh = np.percentile(mask_weights, 50 if _is_dark_void else 60)
149
+ high_w = ((best_mask > 0) & (wmap >= w_thresh)).astype(np.uint8) * 255
150
+ sk = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11))
151
+ high_w = cv2.morphologyEx(high_w, cv2.MORPH_CLOSE, sk)
152
+ high_w = cv2.morphologyEx(high_w, cv2.MORPH_OPEN, sk)
153
+ n_hw, labels_hw, stats_hw, centroids_hw = cv2.connectedComponentsWithStats(
154
+ high_w, 8)
155
+ if n_hw > 1:
156
+ valid_comps = []
157
+ for ci in range(1, n_hw):
158
+ cx_ci = centroids_hw[ci, 0]
159
+ area_ci = stats_hw[ci, cv2.CC_STAT_AREA]
160
+ if lc <= cx_ci <= rc and area_ci >= np.count_nonzero(best_mask) * 0.10:
161
+ valid_comps.append((ci, area_ci))
162
+ if valid_comps:
163
+ best_ci = max(valid_comps, key=lambda x: x[1])[0]
164
+ best_mask = ((labels_hw == best_ci) * 255).astype(np.uint8)
165
+ else:
166
+ largest = 1 + np.argmax(stats_hw[1:, cv2.CC_STAT_AREA])
167
+ candidate_hw = ((labels_hw == largest) * 255).astype(np.uint8)
168
+ if np.count_nonzero(candidate_hw) >= np.count_nonzero(best_mask) * 0.15:
169
+ best_mask = candidate_hw
170
+
171
+ # Post-selection expansion
172
+ pre_expansion_mask = best_mask.copy()
173
+ best_area_frac = np.count_nonzero(best_mask) / (h * w)
174
+ if best_area_frac < 0.25:
175
+ orig_mean = float(gray_f32[best_mask > 0].mean())
176
+ br_size = max(9, int(min(h, w) * 0.02) | 1)
177
+ br_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (br_size, br_size))
178
+ reach_r = max(15, int(min(h, w) * 0.04))
179
+ reach_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE,
180
+ (2*reach_r+1, 2*reach_r+1))
181
+ base_pct = min(50, max(30, int(scores.get("area_frac", 0.1) * 100 * 4)))
182
+ relax_thr = int(np.percentile(proc["denoised"], base_pct))
183
+ _, relax_dark = cv2.threshold(proc["denoised"], relax_thr, 255,
184
+ cv2.THRESH_BINARY_INV)
185
+ relax_dark = cv2.morphologyEx(relax_dark, cv2.MORPH_CLOSE, br_k)
186
+ n_rd, labels_rd, _, _ = cv2.connectedComponentsWithStats(relax_dark, 8)
187
+ seed_reach = cv2.dilate(best_mask, reach_k)
188
+ overlap_labels = set(np.unique(labels_rd[seed_reach > 0])) - {0}
189
+ if overlap_labels:
190
+ expanded = np.zeros_like(best_mask)
191
+ for lb in overlap_labels:
192
+ expanded[labels_rd == lb] = 255
193
+ clip_lc = actual_lc if actual_lc > lc else lc
194
+ clip_rc = actual_rc if actual_rc < rc else rc
195
+ if clip_lc > int(w * 0.05):
196
+ expanded[:, :clip_lc] = 0
197
+ if clip_rc < int(w * 0.95):
198
+ expanded[:, clip_rc+1:] = 0
199
+ n_exp, labels_exp, stats_exp, _ = cv2.connectedComponentsWithStats(
200
+ expanded, 8)
201
+ if n_exp > 1:
202
+ largest_exp = 1 + np.argmax(stats_exp[1:, cv2.CC_STAT_AREA])
203
+ expanded = ((labels_exp == largest_exp) * 255).astype(np.uint8)
204
+ exp_area_frac = np.count_nonzero(expanded) / (h * w)
205
+ exp_mean = float(gray_f32[expanded > 0].mean())
206
+ if (exp_area_frac <= 0.40
207
+ and exp_area_frac > best_area_frac * 0.8
208
+ and exp_mean < orig_mean + 0.15):
209
+ best_mask = expanded
210
+ best_area_frac = exp_area_frac
211
+
212
+ # GrabCut
213
+ pre_gc = np.count_nonzero(best_mask) / (h * w)
214
+ pre_exp_frac = np.count_nonzero(pre_expansion_mask) / (h * w)
215
+ use_conservative = (pre_gc > pre_exp_frac * 1.3)
216
+ gc_result = grabcut_refine(
217
+ gray_u8, best_mask,
218
+ conservative_mask=pre_expansion_mask if use_conservative else None,
219
+ expand_ratio=2.5,
220
+ )
221
+ if np.count_nonzero(gc_result) > 0:
222
+ best_mask = gc_result
223
+
224
+ refined = refine_mask(best_mask, gray_f32)
225
+
226
+ result_rgb, mask_rgb, valid_rgb, cands_rgb = _draw_result_arrays(
227
+ gray_u8, refined, scores, wmap, pn, candidates, all_sc
228
+ )
229
+
230
+ final_area = np.count_nonzero(refined) / (h * w)
231
+ info = (
232
+ f"**Score:** {scores['total']:.2f} | "
233
+ f"**Area:** {final_area*100:.1f}% | "
234
+ f"**Contrast:** {scores['contrast']:.2f} | "
235
+ f"**IR depth:** {scores['ir_depth']:.2f} | "
236
+ f"**Darkness:** {scores['dark']:.2f} | "
237
+ f"**Texture mult:** {scores['texture_mult']:.2f} | "
238
+ f"**Candidates:** {len(candidates)}"
239
+ )
240
+ return result_rgb, mask_rgb, valid_rgb, cands_rgb, info
241
+
242
+
243
+ # ──────────────────────────────────────────────────────────────────────────────
244
+ # Gradio interface
245
+ # ──────────────────────────────────────────────────────────────────────────────
246
+
247
+ def detect(image):
248
+ if image is None:
249
+ return None, None, None, None, "No image provided."
250
+ return _process_array(image)
251
+
252
+
253
+ with gr.Blocks(title="CaveMark — Cave Entrance Detector") as demo:
254
+ gr.Markdown(
255
+ """
256
+ # CaveMark — Automatic Cave Entrance Detector
257
+
258
+ Classical computer vision pipeline (OpenCV + NumPy) that locates cave entrances
259
+ in IR/NIR monochrome imagery — **no deep learning required**.
260
+
261
+ Upload an IR or NIR image from a trail camera, security camera or similar sensor.
262
+ The pipeline runs: preprocess → valid-region → IR-depth → candidates → score →
263
+ expand → GrabCut → refine → visualise.
264
+ """
265
+ )
266
+
267
+ with gr.Row():
268
+ inp = gr.Image(label="Input image", type="numpy")
269
+ btn = gr.Button("Detect cave entrance", variant="primary")
270
+
271
+ info_box = gr.Markdown(label="Detection summary")
272
+
273
+ with gr.Row():
274
+ out_result = gr.Image(label="Result overlay")
275
+ out_mask = gr.Image(label="Binary mask")
276
+
277
+ with gr.Row():
278
+ out_valid = gr.Image(label="Valid-region weight map")
279
+ out_cands = gr.Image(label="Candidate scoring debug")
280
+
281
+ btn.click(
282
+ fn=detect,
283
+ inputs=inp,
284
+ outputs=[out_result, out_mask, out_valid, out_cands, info_box],
285
+ )
286
+
287
+ gr.Examples(
288
+ examples=[
289
+ ["examples/background.png"],
290
+ ["examples/background2.png"],
291
+ ["examples/background3.png"],
292
+ ["examples/background4.png"],
293
+ ["examples/background5.png"],
294
+ ["examples/background6.png"],
295
+ ["examples/background7.png"],
296
+ ["examples/background8.png"],
297
+ ],
298
+ inputs=inp,
299
+ outputs=[out_result, out_mask, out_valid, out_cands, info_box],
300
+ fn=detect,
301
+ cache_examples=True,
302
+ )
303
+
304
+ gr.Markdown(
305
+ """
306
+ ---
307
+ **How it works:** [GitHub repo](https://github.com/kerojohan/cavemark) · MIT License
308
+ """
309
+ )
310
+
311
+ if __name__ == "__main__":
312
+ demo.launch()