#!/usr/bin/env python """ Context-CrackNet — Pavement Crack Analyzer Gradio Web Application Upload a pavement image, select a trained model, and get: • Segmentation mask overlay • Skeleton + width heatmap • Per-crack annotation • Pixel-space measurements: area, length, width, count, coverage """ import os import sys import warnings import tempfile import csv import uuid from pathlib import Path import numpy as np import cv2 import torch import albumentations as A from albumentations.pytorch import ToTensorV2 import matplotlib matplotlib.use('Agg') import matplotlib.pyplot as plt import matplotlib.gridspec as gridspec from PIL import Image import gradio as gr # ZeroGPU support — graceful fallback for local development try: import spaces except ImportError: import types as _types spaces = _types.ModuleType("spaces") def _noop_gpu(fn=None, *, duration=60): if fn is not None: return fn return lambda f: f spaces.GPU = _noop_gpu sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) from src.models import Context_CrackNet, create_model try: from skimage.morphology import skeletonize HAS_SKIMAGE = True except ImportError: HAS_SKIMAGE = False warnings.warn("scikit-image not found. Using morphological fallback for skeleton.") try: from scipy.ndimage import distance_transform_edt HAS_SCIPY = True except ImportError: HAS_SCIPY = False try: from huggingface_hub import hf_hub_download HAS_HF_HUB = True except ImportError: HAS_HF_HUB = False # --------------------------------------------------------------------------- # Configuration # --------------------------------------------------------------------------- # Set this to your HuggingFace model repo if hosting weights there. # e.g. "Blessing988/Context-CrackNet-weights" # Checkpoint structure in the repo: # {DATASET}/{ARCHITECTURE}/best_attention.pth HF_MODEL_REPO = os.environ.get("HF_MODEL_REPO", "Blessing988/context-cracknet-weights") ALL_ARCHITECTURES = [ "Context_CrackNet", "Unet", "UnetPlusPlus", "PSPNet", "PAN", "MAnet", "Linknet", "FPN", "DeepLabV3Plus", "DeepLabV3", ] ALL_DATASETS = [ "CFD", "DeepCrack", "CRACK500", "cracktree200", "Eugen_Muller", "forest", "GAPS384", "Rissbilder", "Sylvie", "Volker", ] DEFAULT_ARCHITECTURE = "Context_CrackNet" DEFAULT_DATASET = os.environ.get("DEFAULT_DATASET", "cracktree200") DEFAULT_CHECKPOINT_NAME = os.environ.get("DEFAULT_CHECKPOINT_NAME", "best_attention.pth") SEVERITY_THRESHOLDS = { "Low": (0, 5, "#27ae60"), # green "Moderate": (5, 15, "#f39c12"), # amber "High": (15, 30, "#e67e22"), # orange "Severe": (30, 100, "#c0392b"), # red } # Global model cache {(arch, dataset): (model, img_size)} _MODEL_CACHE: dict = {} # --------------------------------------------------------------------------- # CSS # --------------------------------------------------------------------------- CSS = """ /* ── Global ── */ body { font-family: 'Segoe UI', Arial, sans-serif; } /* ── Header ── */ #header { background: linear-gradient(135deg, #0d2137 0%, #1a5276 60%, #2a7da8 100%); border-radius: 12px; padding: 28px 32px 20px 32px; margin-bottom: 8px; box-shadow: 0 4px 20px rgba(0,0,0,0.35); } #header h1 { color: #ffffff !important; font-size: 2rem !important; font-weight: 700 !important; margin: 0 0 6px 0 !important; letter-spacing: -0.5px; } #header p { color: #a8d4f0 !important; margin: 0 !important; font-size: 0.92rem !important; line-height: 1.5; } #header .badge { display: inline-block; background: rgba(255,255,255,0.12); border: 1px solid rgba(255,255,255,0.25); border-radius: 20px; padding: 3px 12px; font-size: 0.78rem; color: #d6eaf8; margin-top: 10px; margin-right: 6px; } /* ── Section labels ── */ .section-label { font-size: 0.78rem; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: #5d6d7e; margin-bottom: 4px; } /* ── Metric cards ── */ .metric-row { display: flex; gap: 10px; margin: 12px 0 4px 0; flex-wrap: wrap; } .metric-card { flex: 1; min-width: 110px; background: #fff; border-radius: 10px; padding: 14px 16px 12px 16px; box-shadow: 0 2px 8px rgba(0,0,0,0.09); border-top: 4px solid #ccc; text-align: center; } .metric-card .m-label { font-size: 0.72rem; color: #7f8c8d; font-weight: 600; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; } .metric-card .m-value { font-size: 1.6rem; font-weight: 700; color: #1a252f; line-height: 1; } .metric-card .m-unit { font-size: 0.7rem; color: #95a5a6; margin-top: 2px; } .metric-card.blue { border-top-color: #2980b9; } .metric-card.purple { border-top-color: #8e44ad; } .metric-card.teal { border-top-color: #16a085; } .metric-card.orange { border-top-color: #d35400; } .metric-card.severity-low { border-top-color: #27ae60; } .metric-card.severity-moderate { border-top-color: #f39c12; } .metric-card.severity-high { border-top-color: #e67e22; } .metric-card.severity-severe { border-top-color: #c0392b; } .m-value.severity-low { color: #27ae60; } .m-value.severity-moderate { color: #f39c12; } .m-value.severity-high { color: #e67e22; } .m-value.severity-severe { color: #c0392b; } /* ── Status pill ── */ .status-pill { display: inline-flex; align-items: center; gap: 6px; border-radius: 20px; padding: 5px 14px; font-size: 0.82rem; font-weight: 600; } .status-pill.ready { background: #eafaf1; color: #1e8449; border: 1px solid #a9dfbf; } .status-pill.waiting { background: #fef9e7; color: #9a7d0a; border: 1px solid #f9e79f; } .status-pill.error { background: #fdedec; color: #922b21; border: 1px solid #f5b7b1; } /* ── Analyze button ── */ #analyze-btn { background: linear-gradient(135deg, #1a5276 0%, #2e86c1 100%) !important; color: #fff !important; font-size: 1rem !important; font-weight: 600 !important; border-radius: 8px !important; padding: 14px 0 !important; border: none !important; box-shadow: 0 3px 12px rgba(30,80,130,0.35) !important; transition: all 0.2s !important; } #analyze-btn:hover { transform: translateY(-1px) !important; box-shadow: 0 5px 18px rgba(30,80,130,0.45) !important; } /* ── Load model button ── */ #load-btn { background: #eafaf1 !important; color: #1e8449 !important; font-weight: 600 !important; border: 1.5px solid #a9dfbf !important; border-radius: 8px !important; } /* ── Tab styling ── */ .tab-nav button { font-weight: 600 !important; font-size: 0.88rem !important; } /* ── Visualization panel ── */ #vis-panel img { border-radius: 8px; } /* ── Table ── */ .crack-table table { font-size: 0.82rem; } .crack-table th { background: #1a5276 !important; color: white !important; font-weight: 600 !important; } .crack-table tr:nth-child(even) { background: #f2f7fb !important; } /* ── Footer ── */ #footer { text-align: center; color: #7f8c8d; font-size: 0.78rem; margin-top: 16px; padding: 12px; border-top: 1px solid #eaecee; } """ # --------------------------------------------------------------------------- # Measurement helpers (identical logic to real_world_eval.py) # --------------------------------------------------------------------------- def _morphological_skeleton(binary: np.ndarray) -> np.ndarray: img = binary.copy() skel = np.zeros_like(img) kernel = cv2.getStructuringElement(cv2.MORPH_CROSS, (3, 3)) while True: eroded = cv2.erode(img, kernel) temp = cv2.subtract(img, cv2.dilate(eroded, kernel)) skel = cv2.bitwise_or(skel, temp) img = eroded.copy() if cv2.countNonZero(img) == 0: break return skel def measure_cracks(mask_255: np.ndarray) -> dict: binary = (mask_255 > 127).astype(np.uint8) H, W = binary.shape total_pixels = H * W area_px2 = int(binary.sum()) coverage_pct = (area_px2 / total_pixels * 100) if total_pixels > 0 else 0.0 num_labels, labels = cv2.connectedComponents(binary, connectivity=8) crack_count = max(0, num_labels - 1) if HAS_SCIPY and area_px2 > 0: dist = distance_transform_edt(binary).astype(np.float32) else: dist = cv2.distanceTransform(binary, cv2.DIST_L2, 5).astype(np.float32) skeleton_img = np.zeros_like(binary, dtype=np.uint8) length_px = 0 mean_width_px = 0.0 max_width_px = 0.0 if area_px2 > 0: skel = (skeletonize(binary.astype(bool)).astype(np.uint8) if HAS_SKIMAGE else _morphological_skeleton(binary)) skeleton_img = skel * 255 skel_pixels = skel > 0 length_px = int(skel_pixels.sum()) if length_px > 0: widths = dist[skel_pixels] * 2.0 mean_width_px = float(np.mean(widths)) max_width_px = float(np.max(widths)) else: max_width_px = float(dist.max()) * 2.0 return dict( area_px2=area_px2, length_px=length_px, mean_width_px=round(mean_width_px, 2), max_width_px=round(max_width_px, 2), crack_count=crack_count, coverage_pct=round(coverage_pct, 3), skeleton=skeleton_img, dist_transform=dist, labels=labels, ) def measure_per_crack(labels: np.ndarray, dist: np.ndarray) -> list: results = [] for lbl in range(1, labels.max() + 1): comp = (labels == lbl).astype(np.uint8) area = int(comp.sum()) if area == 0: continue ys, xs = np.where(comp) x1, y1 = int(xs.min()), int(ys.min()) x2, y2 = int(xs.max()), int(ys.max()) skel = (skeletonize(comp.astype(bool)).astype(np.uint8) if HAS_SKIMAGE else _morphological_skeleton(comp)) skel_mask = skel > 0 length = int(skel_mask.sum()) if length > 0: widths = dist[skel_mask] * 2.0 mean_w = round(float(np.mean(widths)), 2) max_w = round(float(np.max(widths)), 2) else: mean_w = 0.0 max_w = round(float(dist[comp > 0].max()) * 2.0, 2) if area > 0 else 0.0 results.append(dict( crack_id=lbl, area_px2=area, length_px=length, mean_width_px=mean_w, max_width_px=max_w, bbox_x=x1, bbox_y=y1, bbox_w=x2 - x1, bbox_h=y2 - y1, )) results.sort(key=lambda r: r['area_px2'], reverse=True) return results # --------------------------------------------------------------------------- # Model helpers # --------------------------------------------------------------------------- def _infer_img_size(state_dict: dict) -> int: key = 'linformer.self_attn.proj_E' if key in state_dict: seq_len = state_dict[key].shape[0] return int(round(seq_len ** 0.5)) * 16 return 448 def load_model_from_file(checkpoint_path: str, architecture: str): """Load any architecture from a .pth checkpoint file.""" state_dict = torch.load(checkpoint_path, map_location='cpu') if architecture == 'Context_CrackNet': img_size = _infer_img_size(state_dict) model = Context_CrackNet( in_channels=3, out_channels=1, img_size=img_size, num_heads=8, ff_dim=2048, linformer_k=256, pretrained=False ) else: img_size = None model = create_model( architecture=architecture, encoder_name='resnet50', in_channels=3, num_classes=1, encoder_weights=None ) model.load_state_dict(state_dict) model.eval() return model, img_size def load_model_from_hf(architecture: str, dataset: str): """Download checkpoint from HF Hub model repo and load it.""" if not HAS_HF_HUB: raise RuntimeError("huggingface_hub not installed.") if not HF_MODEL_REPO: raise RuntimeError("HF_MODEL_REPO environment variable not set.") path = f"{dataset}/{architecture}/{DEFAULT_CHECKPOINT_NAME}" try: local = hf_hub_download(repo_id=HF_MODEL_REPO, filename=path) except Exception as e: raise RuntimeError( f"Hosted checkpoint not found at {HF_MODEL_REPO}/{path}. " "Upload it to the model repo or provide a manual checkpoint override." ) from e return load_model_from_file(local, architecture) def get_or_load_model(architecture: str, dataset: str, checkpoint_file=None): """ Return (model, img_size). Priority: uploaded file → HF Hub → error. Results are cached by (arch, dataset) key. """ cache_key = (architecture, dataset, checkpoint_file) if cache_key in _MODEL_CACHE: return _MODEL_CACHE[cache_key] if checkpoint_file is not None: model, img_size = load_model_from_file(checkpoint_file, architecture) elif HF_MODEL_REPO: model, img_size = load_model_from_hf(architecture, dataset) else: raise RuntimeError( "No checkpoint provided. Upload a .pth file or configure HF_MODEL_REPO." ) _MODEL_CACHE[cache_key] = (model, img_size) return model, img_size # --------------------------------------------------------------------------- # Inference # --------------------------------------------------------------------------- def get_transforms(): return A.Compose([ A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)), ToTensorV2(), ]) TRANSFORMS = get_transforms() @spaces.GPU(duration=120) def _gpu_forward(model, tensor): """GPU-isolated forward pass — decorated for ZeroGPU allocation.""" device = torch.device('cuda' if torch.cuda.is_available() else 'cpu') model.to(device) with torch.no_grad(): out = model(tensor.to(device)) if isinstance(out, tuple): out = out[0] model.cpu() return out.cpu() def predict_mask(model, image_rgb: np.ndarray, img_size, threshold: float) -> np.ndarray: orig_h, orig_w = image_rgb.shape[:2] inp = cv2.resize(image_rgb, (img_size, img_size)) if img_size else image_rgb tensor = TRANSFORMS(image=inp)['image'].unsqueeze(0) out = _gpu_forward(model, tensor) prob = torch.sigmoid(out) binary = (prob > threshold).squeeze().cpu().numpy().astype(np.uint8) mask = binary * 255 if img_size and (orig_h != img_size or orig_w != img_size): mask = cv2.resize(mask, (orig_w, orig_h), interpolation=cv2.INTER_NEAREST) return mask # --------------------------------------------------------------------------- # Visualization # --------------------------------------------------------------------------- OVERLAY_ALPHA = 0.45 def make_overlay(image_rgb, mask_255): overlay = image_rgb.copy().astype(np.float32) crack = mask_255 > 127 overlay[crack] = overlay[crack] * (1 - OVERLAY_ALPHA) + np.array([255, 30, 30]) * OVERLAY_ALPHA return np.clip(overlay, 0, 255).astype(np.uint8) def make_combined_panel(image_rgb, mask, skeleton, dist) -> np.ndarray: """ Returns a single 1×2 panel figure as an RGB numpy array: [Crack Overlay] [Skel+Width] """ fig = plt.figure(figsize=(14, 5.5), facecolor='#0d1b2a') gs = gridspec.GridSpec(1, 2, figure=fig, wspace=0.06, left=0.02, right=0.98, top=0.9, bottom=0.04) panel_cfg = [ (0, 0, make_overlay(image_rgb, mask), "Crack Segmentation Overlay", None), (0, 1, None, "Skeleton + Crack Width (px)", "skeleton"), ] for row, col, img, title, special in panel_cfg: ax = fig.add_subplot(gs[row, col]) ax.set_facecolor('#0d1b2a') if special == "skeleton": ax.imshow(image_rgb) skel_pts = np.where(skeleton > 0) if len(skel_pts[0]) > 0: widths = dist[skel_pts] * 2.0 sc = ax.scatter(skel_pts[1], skel_pts[0], c=widths, cmap='plasma', s=1.5, linewidths=0, vmin=0, vmax=max(widths.max(), 1)) cb = fig.colorbar(sc, ax=ax, fraction=0.035, pad=0.01) cb.set_label('Width (px)', color='#a8d4f0', fontsize=7) cb.ax.yaxis.set_tick_params(color='#a8d4f0', labelsize=6) plt.setp(cb.ax.yaxis.get_ticklabels(), color='#a8d4f0') else: ax.imshow(img) ax.set_title(title, color='#d6eaf8', fontsize=9, fontweight='bold', pad=5) ax.axis('off') fig.canvas.draw() if hasattr(fig.canvas, "buffer_rgba"): # Matplotlib 3.10+ exposes the RGBA buffer API on Agg canvases. buf = np.asarray(fig.canvas.buffer_rgba(), dtype=np.uint8)[..., :3].copy() else: buf = np.frombuffer(fig.canvas.tostring_rgb(), dtype=np.uint8) buf = buf.reshape(fig.canvas.get_width_height()[::-1] + (3,)) plt.close(fig) return buf # --------------------------------------------------------------------------- # Severity helper # --------------------------------------------------------------------------- def get_severity(coverage_pct: float) -> tuple[str, str]: for label, (lo, hi, color) in SEVERITY_THRESHOLDS.items(): if lo <= coverage_pct < hi: return label, color return "Severe", "#c0392b" # --------------------------------------------------------------------------- # HTML builders # --------------------------------------------------------------------------- def build_metric_cards(stats: dict) -> str: sev_label, sev_color = get_severity(stats['coverage_pct']) sev_class = f"severity-{sev_label.lower()}" cards = [ ("blue", "🔢 Crack Count", stats['crack_count'], "instances", ""), ("purple", "📐 Crack Area", f"{stats['area_px2']:,}", "px²", ""), ("teal", "📏 Crack Length", f"{stats['length_px']:,}", "px", ""), ("orange", "↔ Mean Width", f"{stats['mean_width_px']:.1f}", "px", ""), ("orange", "↕ Max Width", f"{stats['max_width_px']:.1f}", "px", ""), (sev_class,"📊 Coverage", f"{stats['coverage_pct']:.2f}", "%", f'
No cracks detected.
' cols = ['Crack #', 'Area (px²)', 'Length (px)', 'Mean Width (px)', 'Max Width (px)', 'BBox X', 'BBox Y', 'BBox W', 'BBox H'] keys = ['crack_id', 'area_px2', 'length_px', 'mean_width_px', 'max_width_px', 'bbox_x', 'bbox_y', 'bbox_w', 'bbox_h'] hdr = "".join(f'
A Context-Aware Framework for Precise Segmentation of Tiny Cracks in Pavement Images
Construction and Building Materials · Volume 484 · 2025 · DOI: 10.1016/j.conbuildmat.2025.141583
{DEFAULT_DATASET}/{DEFAULT_ARCHITECTURE}/{DEFAULT_CHECKPOINT_NAME}'
f'{HF_MODEL_REPO} when no override is provided