| """ |
| app.py — CaveMark Gradio Space for Hugging Face |
| Wraps detect_cave.py pipeline to work in-memory (no disk I/O). |
| """ |
|
|
| import cv2 |
| import numpy as np |
| import gradio as gr |
|
|
| from detect_cave import ( |
| preprocess_image, |
| compute_valid_region, |
| compute_ir_depth, |
| generate_candidates, |
| select_best_candidate, |
| grabcut_refine, |
| refine_mask, |
| ) |
|
|
|
|
| |
| |
| |
|
|
| def _draw_result_arrays(gray_u8, refined_mask, scores, |
| weight_map, profile_norm, |
| all_candidates, all_scores): |
| h, w = gray_u8.shape |
|
|
| |
| vis = cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR) |
|
|
| dil_r = max(5, int(min(h, w) * 0.025)) |
| dil_k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2*dil_r+1, 2*dil_r+1)) |
| dil_mask = cv2.dilate(refined_mask, dil_k) |
| ring_mask = cv2.bitwise_and(dil_mask, cv2.bitwise_not(refined_mask)) |
| ring_overlay = vis.copy() |
| ring_overlay[ring_mask > 0] = (30, 160, 255) |
| cv2.addWeighted(ring_overlay, 0.28, vis, 0.72, 0, vis) |
|
|
| overlay = vis.copy() |
| overlay[refined_mask > 0] = (100, 210, 60) |
| cv2.addWeighted(overlay, 0.35, vis, 0.65, 0, vis) |
|
|
| contours, _ = cv2.findContours(refined_mask, cv2.RETR_EXTERNAL, |
| cv2.CHAIN_APPROX_SIMPLE) |
| cv2.drawContours(vis, contours, -1, (0, 255, 80), 2) |
|
|
| score_val = scores.get("total", 0.0) |
| label = f"cave entrance score={score_val:.2f}" |
| if contours: |
| cnt = max(contours, key=cv2.contourArea) |
| x, y, bw, bh = cv2.boundingRect(cnt) |
| tx, ty = x + 5, max(y - 12, 25) |
| else: |
| tx, ty = 10, 30 |
|
|
| fs = max(0.55, min(w, h) / 900) |
| th = max(1, int(fs * 2)) |
| cv2.putText(vis, label, (tx+2, ty+2), cv2.FONT_HERSHEY_SIMPLEX, |
| fs, (0, 0, 0), th+2) |
| cv2.putText(vis, label, (tx, ty), cv2.FONT_HERSHEY_SIMPLEX, |
| fs, (0, 255, 120), th) |
|
|
| result_rgb = cv2.cvtColor(vis, cv2.COLOR_BGR2RGB) |
|
|
| |
| mask_rgb = cv2.cvtColor(refined_mask, cv2.COLOR_GRAY2RGB) |
|
|
| |
| dv = cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR) |
| for ch in range(3): |
| c = dv[:, :, ch].astype(np.float32) |
| if ch == 2: |
| c = c * weight_map + 180 * (1.0 - weight_map) |
| else: |
| c = c * weight_map |
| dv[:, :, ch] = np.clip(c, 0, 255).astype(np.uint8) |
| for col in range(w - 1): |
| y1 = h - 1 - int(profile_norm[col] * 59) |
| y2 = h - 1 - int(profile_norm[col + 1] * 59) |
| cv2.line(dv, (col, y1), (col+1, y2), (0, 255, 255), 1) |
| cv2.putText(dv, "valid region (red=penalised)", (10, 25), |
| cv2.FONT_HERSHEY_SIMPLEX, 0.6, (255, 255, 0), 2) |
| valid_rgb = cv2.cvtColor(dv, cv2.COLOR_BGR2RGB) |
|
|
| |
| dc = cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR) |
| colours = [(255,80,0),(0,80,255),(200,0,200),(0,200,200), |
| (200,200,0),(0,160,80),(128,128,255),(255,128,128)] |
| indexed = sorted(range(len(all_candidates)), |
| key=lambda i: all_scores[i]["total"]) |
| for rank, i in enumerate(indexed): |
| col = colours[i % len(colours)] |
| cl, _ = cv2.findContours(all_candidates[i], cv2.RETR_EXTERNAL, |
| cv2.CHAIN_APPROX_SIMPLE) |
| cv2.drawContours(dc, cl, -1, col, 1) |
| if rank >= len(indexed) - 5 and cl: |
| c0 = max(cl, key=cv2.contourArea) |
| M = cv2.moments(c0) |
| if M["m00"] > 0: |
| cx_m = int(M["m10"] / M["m00"]) |
| cy_m = int(M["m01"] / M["m00"]) |
| cv2.putText(dc, f"{all_scores[i]['total']:.2f}", (cx_m, cy_m), |
| cv2.FONT_HERSHEY_SIMPLEX, 0.4, col, 1) |
| cv2.drawContours(dc, contours, -1, (255, 255, 255), 2) |
| cv2.putText(dc, |
| f"{len(all_candidates)} candidates (white=best, {score_val:.2f})", |
| (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 2) |
| cands_rgb = cv2.cvtColor(dc, cv2.COLOR_BGR2RGB) |
|
|
| return result_rgb, mask_rgb, valid_rgb, cands_rgb |
|
|
|
|
| |
| |
| |
|
|
| def _process_array(img_rgb: np.ndarray): |
| """Run the full CaveMark pipeline on a numpy RGB array.""" |
| |
| gray_u8 = cv2.cvtColor(img_rgb, cv2.COLOR_RGB2GRAY) |
| gray_f32 = gray_u8.astype(np.float32) / 255.0 |
| h, w = gray_u8.shape |
|
|
| proc = preprocess_image(gray_u8, gray_f32) |
| wmap, lc, rc, pn, actual_lc, actual_rc = compute_valid_region(gray_f32) |
| depth_map = compute_ir_depth(gray_f32) |
|
|
| candidates = generate_candidates(proc, gray_f32, h, w, lc, rc) |
|
|
| if not candidates: |
| blank = np.zeros((h, w), np.uint8) |
| blank_rgb = cv2.cvtColor(blank, cv2.COLOR_GRAY2RGB) |
| vis_rgb = cv2.cvtColor(cv2.cvtColor(gray_u8, cv2.COLOR_GRAY2BGR), |
| cv2.COLOR_BGR2RGB) |
| info = "No cave entrance candidates found." |
| return vis_rgb, blank_rgb, blank_rgb, blank_rgb, info |
|
|
| best_mask, scores, all_sc = select_best_candidate( |
| candidates, gray_f32, wmap, lc, rc, depth_map=depth_map |
| ) |
|
|
| |
| if scores.get("solidity", 1.0) < 0.65 and np.count_nonzero(best_mask) > 100: |
| _is_dark_void = scores.get("mean_inside", 1.0) < 0.15 |
| mask_weights = wmap[best_mask > 0] |
| w_thresh = np.percentile(mask_weights, 50 if _is_dark_void else 60) |
| high_w = ((best_mask > 0) & (wmap >= w_thresh)).astype(np.uint8) * 255 |
| sk = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (11, 11)) |
| high_w = cv2.morphologyEx(high_w, cv2.MORPH_CLOSE, sk) |
| high_w = cv2.morphologyEx(high_w, cv2.MORPH_OPEN, sk) |
| n_hw, labels_hw, stats_hw, centroids_hw = cv2.connectedComponentsWithStats( |
| high_w, 8) |
| if n_hw > 1: |
| valid_comps = [] |
| for ci in range(1, n_hw): |
| cx_ci = centroids_hw[ci, 0] |
| area_ci = stats_hw[ci, cv2.CC_STAT_AREA] |
| if lc <= cx_ci <= rc and area_ci >= np.count_nonzero(best_mask) * 0.10: |
| valid_comps.append((ci, area_ci)) |
| if valid_comps: |
| best_ci = max(valid_comps, key=lambda x: x[1])[0] |
| best_mask = ((labels_hw == best_ci) * 255).astype(np.uint8) |
| else: |
| largest = 1 + np.argmax(stats_hw[1:, cv2.CC_STAT_AREA]) |
| candidate_hw = ((labels_hw == largest) * 255).astype(np.uint8) |
| if np.count_nonzero(candidate_hw) >= np.count_nonzero(best_mask) * 0.15: |
| best_mask = candidate_hw |
|
|
| |
| best_area_frac = np.count_nonzero(best_mask) / (h * w) |
| if best_area_frac < 0.25: |
| relax_pct = min(50, max(30, int(scores.get("area_frac", 0.1) * 100 * 4))) |
| relax_thr = int(np.percentile(proc["denoised"], relax_pct)) |
| _, relax_dark = cv2.threshold(proc["denoised"], relax_thr, 255, |
| cv2.THRESH_BINARY_INV) |
| br_k = cv2.getStructuringElement( |
| cv2.MORPH_ELLIPSE, |
| (max(9, int(min(h, w) * 0.02) | 1), max(9, int(min(h, w) * 0.02) | 1)), |
| ) |
| relax_dark = cv2.morphologyEx(relax_dark, cv2.MORPH_CLOSE, br_k) |
| n_rd, labels_rd, _, _ = cv2.connectedComponentsWithStats(relax_dark, 8) |
| overlap_labels = set(np.unique(labels_rd[best_mask > 0])) - {0} |
| if overlap_labels: |
| expanded = np.zeros_like(best_mask) |
| for lb in overlap_labels: |
| expanded[labels_rd == lb] = 255 |
| if lc > int(w * 0.05): |
| expanded[:, :lc] = 0 |
| if rc < int(w * 0.95): |
| expanded[:, rc+1:] = 0 |
| n_exp, labels_exp, stats_exp, _ = cv2.connectedComponentsWithStats( |
| expanded, 8) |
| if n_exp > 1: |
| largest_exp = 1 + np.argmax(stats_exp[1:, cv2.CC_STAT_AREA]) |
| expanded = ((labels_exp == largest_exp) * 255).astype(np.uint8) |
| exp_area_frac = np.count_nonzero(expanded) / (h * w) |
| if exp_area_frac <= 0.40 and exp_area_frac > best_area_frac * 0.8: |
| exp_mean = float(gray_f32[expanded > 0].mean()) |
| orig_mean = float(gray_f32[best_mask > 0].mean()) |
| orig_pts = np.argwhere(best_mask > 0).astype(np.float32) |
| exp_pts = np.argwhere(expanded > 0).astype(np.float32) |
| orig_cy_m, orig_cx_m = orig_pts.mean(axis=0) |
| exp_cy_m, exp_cx_m = exp_pts.mean(axis=0) |
| centroid_shift = ( |
| np.sqrt((exp_cx_m - orig_cx_m) ** 2 + (exp_cy_m - orig_cy_m) ** 2) |
| / min(h, w) |
| ) |
| if exp_mean < orig_mean + 0.15 and centroid_shift <= 0.20: |
| best_mask = expanded |
|
|
| |
| gc_result = grabcut_refine(gray_u8, best_mask, expand_ratio=2.0) |
| if np.count_nonzero(gc_result) > 0: |
| best_mask = gc_result |
|
|
| refined = refine_mask(best_mask, gray_f32) |
|
|
| if lc > int(w * 0.05): |
| refined[:, :lc] = 0 |
| if rc < int(w * 0.95): |
| refined[:, rc + 1:] = 0 |
|
|
| result_rgb, mask_rgb, valid_rgb, cands_rgb = _draw_result_arrays( |
| gray_u8, refined, scores, wmap, pn, candidates, all_sc |
| ) |
|
|
| final_area = np.count_nonzero(refined) / (h * w) |
| info = ( |
| f"**Score:** {scores['total']:.2f} | " |
| f"**Area:** {final_area*100:.1f}% | " |
| f"**Contrast:** {scores['contrast']:.2f} | " |
| f"**IR depth:** {scores['ir_depth']:.2f} | " |
| f"**Darkness:** {scores['dark']:.2f} | " |
| f"**Texture mult:** {scores['texture_mult']:.2f} | " |
| f"**Candidates:** {len(candidates)}" |
| ) |
| return result_rgb, mask_rgb, valid_rgb, cands_rgb, info |
|
|
|
|
| |
| |
| |
|
|
| def detect(image): |
| if image is None: |
| return None, None, None, None, "No image provided." |
| return _process_array(image) |
|
|
|
|
| with gr.Blocks(title="CaveMark — Cave Entrance Detector") as demo: |
| gr.Markdown( |
| """ |
| # CaveMark — Automatic Cave Entrance Detector |
| |
| Classical computer vision pipeline (OpenCV + NumPy) that locates cave entrances |
| in IR/NIR monochrome imagery — **no deep learning required**. |
| |
| Upload an IR or NIR image from a trail camera, security camera or similar sensor. |
| The pipeline runs: preprocess → valid-region → IR-depth → candidates → score → |
| expand → GrabCut → refine → visualise. |
| """ |
| ) |
|
|
| with gr.Row(): |
| inp = gr.Image(label="Input image", type="numpy") |
| btn = gr.Button("Detect cave entrance", variant="primary") |
|
|
| info_box = gr.Markdown(label="Detection summary") |
|
|
| with gr.Row(): |
| out_result = gr.Image(label="Result overlay") |
| out_mask = gr.Image(label="Binary mask") |
|
|
| with gr.Row(): |
| out_valid = gr.Image(label="Valid-region weight map") |
| out_cands = gr.Image(label="Candidate scoring debug") |
|
|
| btn.click( |
| fn=detect, |
| inputs=inp, |
| outputs=[out_result, out_mask, out_valid, out_cands, info_box], |
| ) |
|
|
| gr.Examples( |
| examples=[ |
| ["examples/background.png"], |
| ["examples/background2.png"], |
| ["examples/background3.png"], |
| ["examples/background4.png"], |
| ["examples/background5.png"], |
| ["examples/background6.png"], |
| ["examples/background7.png"], |
| ["examples/background8.png"], |
| ], |
| inputs=inp, |
| outputs=[out_result, out_mask, out_valid, out_cands, info_box], |
| fn=detect, |
| cache_examples=False, |
| ) |
|
|
| gr.Markdown( |
| """ |
| --- |
| **How it works:** [GitHub repo](https://github.com/kerojohan/cavemark) · MIT License |
| """ |
| ) |
|
|
| if __name__ == "__main__": |
| demo.launch() |
|
|