Ramanuj-Sarkar commited on
Commit
f698f1c
·
verified ·
1 Parent(s): 5438727

Upload folder using huggingface_hub

Browse files
__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