Upload folder using huggingface_hub
Browse files- __init__.py +1 -0
- __pycache__/__init__.cpython-311.pyc +0 -0
- api/main.py +52 -0
- app/streamlit_app.py +40 -0
- config.py +1 -0
- data/__pycache__/dataset.cpython-311.pyc +0 -0
- data/__pycache__/transforms.cpython-311.pyc +0 -0
- data/dataset.py +38 -0
- data/transforms.py +16 -0
- modeling/__pycache__/losses.cpython-311.pyc +0 -0
- modeling/__pycache__/unet.cpython-311.pyc +0 -0
- modeling/infer.py +37 -0
- modeling/losses.py +22 -0
- modeling/train.py +78 -0
- modeling/unet.py +66 -0
- utils/io.py +0 -0
- utils/severity.py +43 -0
- utils/viz.py +17 -0
__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# executes when package is imported
|
__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (197 Bytes). View file
|
|
|
api/main.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI, File, UploadFile
|
| 2 |
+
from fastapi.responses import JSONResponse
|
| 3 |
+
import numpy as np
|
| 4 |
+
import cv2
|
| 5 |
+
import base64
|
| 6 |
+
|
| 7 |
+
from sentinelscan.modeling.infer import CrackModel
|
| 8 |
+
from sentinelscan.utils.viz import overlay_mask
|
| 9 |
+
from sentinelscan.utils.severity import crack_metrics, severity_from_metrics
|
| 10 |
+
|
| 11 |
+
app = FastAPI(title="SentinelScan API", version="0.1.0")
|
| 12 |
+
|
| 13 |
+
model = CrackModel(ckpt_path="models/best.pt", size=512)
|
| 14 |
+
|
| 15 |
+
def _read_image(file_bytes: bytes):
|
| 16 |
+
arr = np.frombuffer(file_bytes, np.uint8)
|
| 17 |
+
bgr = cv2.imdecode(arr, cv2.IMREAD_COLOR)
|
| 18 |
+
if bgr is None:
|
| 19 |
+
raise ValueError("Could not decode image")
|
| 20 |
+
rgb = cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
| 21 |
+
return rgb
|
| 22 |
+
|
| 23 |
+
def _to_base64_png(rgb: np.ndarray):
|
| 24 |
+
bgr = cv2.cvtColor(rgb, cv2.COLOR_RGB2BGR)
|
| 25 |
+
ok, buf = cv2.imencode(".png", bgr)
|
| 26 |
+
if not ok:
|
| 27 |
+
raise ValueError("Could not encode image")
|
| 28 |
+
return base64.b64encode(buf.tobytes()).decode("utf-8")
|
| 29 |
+
|
| 30 |
+
@app.post("/predict")
|
| 31 |
+
async def predict(file: UploadFile = File(...)):
|
| 32 |
+
try:
|
| 33 |
+
rgb = _read_image(await file.read())
|
| 34 |
+
pred = model.predict(rgb, threshold=0.5)
|
| 35 |
+
|
| 36 |
+
m = crack_metrics(pred["mask"])
|
| 37 |
+
sev = severity_from_metrics(m)
|
| 38 |
+
|
| 39 |
+
crack_detected = m["area_px"] > 50 # tiny specks ignored
|
| 40 |
+
|
| 41 |
+
overlay = overlay_mask(rgb, pred["mask"])
|
| 42 |
+
overlay_b64 = _to_base64_png(overlay)
|
| 43 |
+
|
| 44 |
+
return JSONResponse({
|
| 45 |
+
"crack_detected": bool(crack_detected),
|
| 46 |
+
"confidence": float(pred["confidence"]),
|
| 47 |
+
"severity": sev if crack_detected else "None",
|
| 48 |
+
"metrics": m,
|
| 49 |
+
"overlay_png_base64": overlay_b64,
|
| 50 |
+
})
|
| 51 |
+
except Exception as e:
|
| 52 |
+
return JSONResponse({"error": str(e)}, status_code=400)
|
app/streamlit_app.py
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import streamlit as st
|
| 2 |
+
import requests
|
| 3 |
+
import base64
|
| 4 |
+
from PIL import Image
|
| 5 |
+
import io
|
| 6 |
+
|
| 7 |
+
API_URL = "http://localhost:8000/predict"
|
| 8 |
+
|
| 9 |
+
st.set_page_config(page_title="SentinelScan", layout="centered")
|
| 10 |
+
st.title("🛰️ SentinelScan (Crack Detector v1)")
|
| 11 |
+
|
| 12 |
+
uploaded = st.file_uploader("Upload an inspection image", type=["jpg","jpeg","png"])
|
| 13 |
+
|
| 14 |
+
if uploaded:
|
| 15 |
+
st.subheader("Input")
|
| 16 |
+
st.image(uploaded, use_container_width=True)
|
| 17 |
+
|
| 18 |
+
if st.button("Analyze"):
|
| 19 |
+
files = {"file": (uploaded.name, uploaded.getvalue(), uploaded.type)}
|
| 20 |
+
with st.spinner("Running model..."):
|
| 21 |
+
r = requests.post(API_URL, files=files, timeout=60)
|
| 22 |
+
|
| 23 |
+
if r.status_code != 200:
|
| 24 |
+
st.error(r.text)
|
| 25 |
+
else:
|
| 26 |
+
out = r.json()
|
| 27 |
+
if "error" in out:
|
| 28 |
+
st.error(out["error"])
|
| 29 |
+
else:
|
| 30 |
+
st.subheader("Result")
|
| 31 |
+
st.write({
|
| 32 |
+
"crack_detected": out["crack_detected"],
|
| 33 |
+
"severity": out["severity"],
|
| 34 |
+
"confidence": out["confidence"],
|
| 35 |
+
"metrics": out["metrics"],
|
| 36 |
+
})
|
| 37 |
+
|
| 38 |
+
overlay_bytes = base64.b64decode(out["overlay_png_base64"])
|
| 39 |
+
overlay_img = Image.open(io.BytesIO(overlay_bytes))
|
| 40 |
+
st.image(overlay_img, caption="Crack overlay", use_container_width=True)
|
config.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# can work with configuration files
|
data/__pycache__/dataset.cpython-311.pyc
ADDED
|
Binary file (3.28 kB). View file
|
|
|
data/__pycache__/transforms.cpython-311.pyc
ADDED
|
Binary file (1.22 kB). View file
|
|
|
data/dataset.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from pathlib import Path
|
| 2 |
+
import cv2
|
| 3 |
+
import numpy as np
|
| 4 |
+
import torch
|
| 5 |
+
from torch.utils.data import Dataset
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
class CrackSegDataset(Dataset):
|
| 9 |
+
def __init__(self, images_dir: str, masks_dir: str, transform=None):
|
| 10 |
+
self.images_dir = Path(images_dir)
|
| 11 |
+
self.masks_dir = Path(masks_dir)
|
| 12 |
+
self.transform = transform
|
| 13 |
+
self.image_paths = sorted([p for p in self.images_dir.glob("*") if p.suffix.lower() in {".jpg",".jpeg",".png"}])
|
| 14 |
+
|
| 15 |
+
def __len__(self):
|
| 16 |
+
return len(self.image_paths)
|
| 17 |
+
|
| 18 |
+
def __getitem__(self, idx):
|
| 19 |
+
img_path = self.image_paths[idx]
|
| 20 |
+
mask_path = self.masks_dir / (img_path.stem + ".png")
|
| 21 |
+
if not mask_path.exists():
|
| 22 |
+
raise FileNotFoundError(f"Mask not found for {img_path.name}: {mask_path}")
|
| 23 |
+
|
| 24 |
+
image = cv2.imread(str(img_path), cv2.IMREAD_COLOR)
|
| 25 |
+
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
|
| 26 |
+
|
| 27 |
+
mask = cv2.imread(str(mask_path), cv2.IMREAD_GRAYSCALE)
|
| 28 |
+
mask = (mask > 127).astype(np.uint8) # binarize
|
| 29 |
+
|
| 30 |
+
if self.transform is not None:
|
| 31 |
+
augmented = self.transform(image=image, mask=mask)
|
| 32 |
+
image, mask = augmented["image"], augmented["mask"]
|
| 33 |
+
|
| 34 |
+
# albumentations returns HWC image; convert to CHW float tensor
|
| 35 |
+
image = torch.from_numpy(image).permute(2, 0, 1).float() / 255.0
|
| 36 |
+
mask = torch.from_numpy(mask).unsqueeze(0).float() # [1,H,W]
|
| 37 |
+
|
| 38 |
+
return image, mask
|
data/transforms.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import albumentations as A
|
| 2 |
+
|
| 3 |
+
def train_transforms(size=512):
|
| 4 |
+
return A.Compose([
|
| 5 |
+
A.Resize(size, size),
|
| 6 |
+
A.RandomBrightnessContrast(p=0.5),
|
| 7 |
+
A.GaussianBlur(p=0.2),
|
| 8 |
+
A.Rotate(limit=15, p=0.4),
|
| 9 |
+
A.RandomCrop(height=size, width=size, p=0.2),
|
| 10 |
+
A.GaussNoise(p=0.2),
|
| 11 |
+
])
|
| 12 |
+
|
| 13 |
+
def val_transforms(size=512):
|
| 14 |
+
return A.Compose([
|
| 15 |
+
A.Resize(size, size),
|
| 16 |
+
])
|
modeling/__pycache__/losses.cpython-311.pyc
ADDED
|
Binary file (1.95 kB). View file
|
|
|
modeling/__pycache__/unet.cpython-311.pyc
ADDED
|
Binary file (4.52 kB). View file
|
|
|
modeling/infer.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import numpy as np
|
| 3 |
+
import cv2
|
| 4 |
+
|
| 5 |
+
from sentinelscan.modeling.unet import UNet
|
| 6 |
+
|
| 7 |
+
class CrackModel:
|
| 8 |
+
def __init__(self, ckpt_path="models/best.pt", device=None, size=512):
|
| 9 |
+
self.device = device or ("cuda" if torch.cuda.is_available() else "cpu")
|
| 10 |
+
self.size = size
|
| 11 |
+
self.model = UNet().to(self.device)
|
| 12 |
+
ckpt = torch.load(ckpt_path, map_location=self.device)
|
| 13 |
+
self.model.load_state_dict(ckpt["model_state"])
|
| 14 |
+
self.model.eval()
|
| 15 |
+
|
| 16 |
+
@torch.no_grad()
|
| 17 |
+
def predict(self, rgb_image: np.ndarray, threshold=0.5):
|
| 18 |
+
# resize for model
|
| 19 |
+
img = cv2.resize(rgb_image, (self.size, self.size), interpolation=cv2.INTER_AREA)
|
| 20 |
+
x = torch.from_numpy(img).permute(2,0,1).float().unsqueeze(0) / 255.0
|
| 21 |
+
x = x.to(self.device)
|
| 22 |
+
|
| 23 |
+
logits = self.model(x)
|
| 24 |
+
probs = torch.sigmoid(logits).squeeze().cpu().numpy() # [H,W] float
|
| 25 |
+
mask = (probs > threshold).astype(np.uint8)
|
| 26 |
+
|
| 27 |
+
# confidence: mean prob over predicted crack pixels; fallback to max prob
|
| 28 |
+
if mask.sum() > 0:
|
| 29 |
+
conf = float(probs[mask == 1].mean())
|
| 30 |
+
else:
|
| 31 |
+
conf = float(probs.max())
|
| 32 |
+
|
| 33 |
+
return {
|
| 34 |
+
"probs": probs,
|
| 35 |
+
"mask": mask,
|
| 36 |
+
"confidence": conf,
|
| 37 |
+
}
|
modeling/losses.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn.functional as F
|
| 3 |
+
|
| 4 |
+
def dice_loss(logits, targets, eps=1e-6):
|
| 5 |
+
probs = torch.sigmoid(logits)
|
| 6 |
+
num = 2 * (probs * targets).sum(dim=(2,3))
|
| 7 |
+
den = (probs + targets).sum(dim=(2,3)) + eps
|
| 8 |
+
dice = num / den
|
| 9 |
+
return 1 - dice.mean()
|
| 10 |
+
|
| 11 |
+
def bce_dice_loss(logits, targets, bce_weight=0.5):
|
| 12 |
+
bce = F.binary_cross_entropy_with_logits(logits, targets)
|
| 13 |
+
d = dice_loss(logits, targets)
|
| 14 |
+
return bce_weight * bce + (1 - bce_weight) * d
|
| 15 |
+
|
| 16 |
+
@torch.no_grad()
|
| 17 |
+
def dice_score(logits, targets, threshold=0.5, eps=1e-6):
|
| 18 |
+
probs = torch.sigmoid(logits)
|
| 19 |
+
preds = (probs > threshold).float()
|
| 20 |
+
num = 2 * (preds * targets).sum(dim=(2,3))
|
| 21 |
+
den = (preds + targets).sum(dim=(2,3)) + eps
|
| 22 |
+
return (num / den).mean().item()
|
modeling/train.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
import torch
|
| 4 |
+
from torch.utils.data import DataLoader
|
| 5 |
+
from tqdm import tqdm
|
| 6 |
+
|
| 7 |
+
from sentinelscan.data.dataset import CrackSegDataset
|
| 8 |
+
from sentinelscan.data.transforms import train_transforms, val_transforms
|
| 9 |
+
from sentinelscan.modeling.unet import UNet
|
| 10 |
+
from sentinelscan.modeling.losses import bce_dice_loss, dice_score
|
| 11 |
+
|
| 12 |
+
@dataclass
|
| 13 |
+
class TrainConfig:
|
| 14 |
+
train_images: str = "data/images/train"
|
| 15 |
+
train_masks: str = "data/masks/train"
|
| 16 |
+
val_images: str = "data/images/val"
|
| 17 |
+
val_masks: str = "data/masks/val"
|
| 18 |
+
out_path: str = "models/best.pt"
|
| 19 |
+
epochs: int = 25
|
| 20 |
+
batch_size: int = 8
|
| 21 |
+
lr: float = 1e-3
|
| 22 |
+
size: int = 512
|
| 23 |
+
device: str = "cuda" if torch.cuda.is_available() else "cpu"
|
| 24 |
+
|
| 25 |
+
def train(cfg: TrainConfig):
|
| 26 |
+
os.makedirs(os.path.dirname(cfg.out_path), exist_ok=True)
|
| 27 |
+
|
| 28 |
+
train_ds = CrackSegDataset(cfg.train_images, cfg.train_masks, transform=train_transforms(cfg.size))
|
| 29 |
+
val_ds = CrackSegDataset(cfg.val_images, cfg.val_masks, transform=val_transforms(cfg.size))
|
| 30 |
+
|
| 31 |
+
train_loader = DataLoader(train_ds, batch_size=cfg.batch_size, shuffle=True, num_workers=2, pin_memory=True)
|
| 32 |
+
val_loader = DataLoader(val_ds, batch_size=cfg.batch_size, shuffle=False, num_workers=2, pin_memory=True)
|
| 33 |
+
|
| 34 |
+
model = UNet().to(cfg.device)
|
| 35 |
+
opt = torch.optim.AdamW(model.parameters(), lr=cfg.lr)
|
| 36 |
+
|
| 37 |
+
best_dice = -1.0
|
| 38 |
+
|
| 39 |
+
for epoch in range(1, cfg.epochs + 1):
|
| 40 |
+
model.train()
|
| 41 |
+
running_loss = 0.0
|
| 42 |
+
|
| 43 |
+
for images, masks in tqdm(train_loader, desc=f"Epoch {epoch}/{cfg.epochs} [train]"):
|
| 44 |
+
images = images.to(cfg.device, non_blocking=True)
|
| 45 |
+
masks = masks.to(cfg.device, non_blocking=True)
|
| 46 |
+
|
| 47 |
+
opt.zero_grad(set_to_none=True)
|
| 48 |
+
logits = model(images)
|
| 49 |
+
loss = bce_dice_loss(logits, masks)
|
| 50 |
+
loss.backward()
|
| 51 |
+
opt.step()
|
| 52 |
+
|
| 53 |
+
running_loss += loss.item()
|
| 54 |
+
|
| 55 |
+
avg_loss = running_loss / max(1, len(train_loader))
|
| 56 |
+
|
| 57 |
+
# Validation
|
| 58 |
+
model.eval()
|
| 59 |
+
dices = []
|
| 60 |
+
with torch.no_grad():
|
| 61 |
+
for images, masks in tqdm(val_loader, desc=f"Epoch {epoch}/{cfg.epochs} [val]"):
|
| 62 |
+
images = images.to(cfg.device, non_blocking=True)
|
| 63 |
+
masks = masks.to(cfg.device, non_blocking=True)
|
| 64 |
+
logits = model(images)
|
| 65 |
+
dices.append(dice_score(logits, masks))
|
| 66 |
+
|
| 67 |
+
mean_dice = sum(dices) / max(1, len(dices))
|
| 68 |
+
|
| 69 |
+
print(f"Epoch {epoch}: loss={avg_loss:.4f} val_dice={mean_dice:.4f}")
|
| 70 |
+
|
| 71 |
+
if mean_dice > best_dice:
|
| 72 |
+
best_dice = mean_dice
|
| 73 |
+
torch.save({"model_state": model.state_dict(), "cfg": cfg.__dict__}, cfg.out_path)
|
| 74 |
+
print(f"✅ Saved best model -> {cfg.out_path} (val_dice={best_dice:.4f})")
|
| 75 |
+
|
| 76 |
+
if __name__ == "__main__":
|
| 77 |
+
cfg = TrainConfig()
|
| 78 |
+
train(cfg)
|
modeling/unet.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch
|
| 2 |
+
import torch.nn as nn
|
| 3 |
+
import torch.nn.functional as F
|
| 4 |
+
|
| 5 |
+
def conv_block(in_ch, out_ch):
|
| 6 |
+
return nn.Sequential(
|
| 7 |
+
nn.Conv2d(in_ch, out_ch, 3, padding=1),
|
| 8 |
+
nn.BatchNorm2d(out_ch),
|
| 9 |
+
nn.ReLU(inplace=True),
|
| 10 |
+
nn.Conv2d(out_ch, out_ch, 3, padding=1),
|
| 11 |
+
nn.BatchNorm2d(out_ch),
|
| 12 |
+
nn.ReLU(inplace=True),
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
class UNet(nn.Module):
|
| 16 |
+
def __init__(self, in_channels=3, out_channels=1, base=32):
|
| 17 |
+
super().__init__()
|
| 18 |
+
self.enc1 = conv_block(in_channels, base)
|
| 19 |
+
self.enc2 = conv_block(base, base*2)
|
| 20 |
+
self.enc3 = conv_block(base*2, base*4)
|
| 21 |
+
self.enc4 = conv_block(base*4, base*8)
|
| 22 |
+
|
| 23 |
+
self.pool = nn.MaxPool2d(2)
|
| 24 |
+
|
| 25 |
+
self.bottleneck = conv_block(base*8, base*16)
|
| 26 |
+
|
| 27 |
+
self.up4 = nn.ConvTranspose2d(base*16, base*8, 2, stride=2)
|
| 28 |
+
self.dec4 = conv_block(base*16, base*8)
|
| 29 |
+
|
| 30 |
+
self.up3 = nn.ConvTranspose2d(base*8, base*4, 2, stride=2)
|
| 31 |
+
self.dec3 = conv_block(base*8, base*4)
|
| 32 |
+
|
| 33 |
+
self.up2 = nn.ConvTranspose2d(base*4, base*2, 2, stride=2)
|
| 34 |
+
self.dec2 = conv_block(base*4, base*2)
|
| 35 |
+
|
| 36 |
+
self.up1 = nn.ConvTranspose2d(base*2, base, 2, stride=2)
|
| 37 |
+
self.dec1 = conv_block(base*2, base)
|
| 38 |
+
|
| 39 |
+
self.head = nn.Conv2d(base, out_channels, 1)
|
| 40 |
+
|
| 41 |
+
def forward(self, x):
|
| 42 |
+
e1 = self.enc1(x)
|
| 43 |
+
e2 = self.enc2(self.pool(e1))
|
| 44 |
+
e3 = self.enc3(self.pool(e2))
|
| 45 |
+
e4 = self.enc4(self.pool(e3))
|
| 46 |
+
|
| 47 |
+
b = self.bottleneck(self.pool(e4))
|
| 48 |
+
|
| 49 |
+
d4 = self.up4(b)
|
| 50 |
+
d4 = torch.cat([d4, e4], dim=1)
|
| 51 |
+
d4 = self.dec4(d4)
|
| 52 |
+
|
| 53 |
+
d3 = self.up3(d4)
|
| 54 |
+
d3 = torch.cat([d3, e3], dim=1)
|
| 55 |
+
d3 = self.dec3(d3)
|
| 56 |
+
|
| 57 |
+
d2 = self.up2(d3)
|
| 58 |
+
d2 = torch.cat([d2, e2], dim=1)
|
| 59 |
+
d2 = self.dec2(d2)
|
| 60 |
+
|
| 61 |
+
d1 = self.up1(d2)
|
| 62 |
+
d1 = torch.cat([d1, e1], dim=1)
|
| 63 |
+
d1 = self.dec1(d1)
|
| 64 |
+
|
| 65 |
+
logits = self.head(d1)
|
| 66 |
+
return logits
|
utils/io.py
ADDED
|
File without changes
|
utils/severity.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import cv2
|
| 3 |
+
from skimage.morphology import skeletonize
|
| 4 |
+
|
| 5 |
+
def crack_metrics(mask: np.ndarray):
|
| 6 |
+
# mask: [H,W] 0/1
|
| 7 |
+
area_px = int(mask.sum())
|
| 8 |
+
|
| 9 |
+
# largest connected component
|
| 10 |
+
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(mask.astype(np.uint8), connectivity=8)
|
| 11 |
+
largest_cc = 0
|
| 12 |
+
if num_labels > 1:
|
| 13 |
+
# ignore background label 0
|
| 14 |
+
largest_cc = int(stats[1:, cv2.CC_STAT_AREA].max())
|
| 15 |
+
|
| 16 |
+
# length estimate via skeletonization
|
| 17 |
+
skel = skeletonize(mask.astype(bool))
|
| 18 |
+
length_px = int(skel.sum())
|
| 19 |
+
|
| 20 |
+
return {
|
| 21 |
+
"area_px": area_px,
|
| 22 |
+
"length_px": length_px,
|
| 23 |
+
"largest_component_px": largest_cc,
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
def severity_from_metrics(m):
|
| 27 |
+
# Tune these thresholds on your validation set
|
| 28 |
+
area = m["area_px"]
|
| 29 |
+
length = m["length_px"]
|
| 30 |
+
largest = m["largest_component_px"]
|
| 31 |
+
|
| 32 |
+
score = 0
|
| 33 |
+
if area > 1500: score += 1
|
| 34 |
+
if area > 6000: score += 1
|
| 35 |
+
if length > 600: score += 1
|
| 36 |
+
if length > 2000: score += 1
|
| 37 |
+
if largest > 2500: score += 1
|
| 38 |
+
|
| 39 |
+
if score >= 4:
|
| 40 |
+
return "High"
|
| 41 |
+
if score >= 2:
|
| 42 |
+
return "Medium"
|
| 43 |
+
return "Low"
|
utils/viz.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import cv2
|
| 3 |
+
|
| 4 |
+
def overlay_mask(rgb_image: np.ndarray, mask: np.ndarray, alpha=0.45):
|
| 5 |
+
# mask: [H,W] 0/1; resize mask to match image
|
| 6 |
+
h, w = rgb_image.shape[:2]
|
| 7 |
+
mask_rs = cv2.resize(mask.astype(np.uint8), (w, h), interpolation=cv2.INTER_NEAREST)
|
| 8 |
+
|
| 9 |
+
overlay = rgb_image.copy()
|
| 10 |
+
# red overlay where crack
|
| 11 |
+
red = np.zeros_like(rgb_image)
|
| 12 |
+
red[..., 0] = 255
|
| 13 |
+
|
| 14 |
+
overlay = np.where(mask_rs[..., None] == 1,
|
| 15 |
+
(alpha * red + (1 - alpha) * overlay).astype(np.uint8),
|
| 16 |
+
overlay)
|
| 17 |
+
return overlay
|