-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathCrackVision.py
More file actions
1228 lines (1024 loc) · 48.7 KB
/
Copy pathCrackVision.py
File metadata and controls
1228 lines (1024 loc) · 48.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
# %% [markdown]
# # CrackVision:
# ## Slicing-Enhanced Pavement Damage Detection using YOLOv8
#
# ---
#
# # Overview
#
# CrackVision is a deep learning-based pavement damage detection framework
# designed for identifying and localizing multiple categories of road surface
# defects from real-world road imagery.
#
# The system utilizes YOLOv8 object detection along with slicing-enhanced
# inference techniques to improve the detection of thin and small-scale crack
# structures that are often missed during standard downscaled inference.
#
# The model detects the following five classes of pavement damage:
#
# - Longitudinal Crack
# - Transverse Crack
# - Alligator Crack
# - Other Corruption
# - Pothole
#
# ---
#
# # Optimization Strategy (Fast + High-Quality Detection)
#
# The pipeline is optimized to balance detection performance, training
# efficiency, and deployment practicality.
#
# ### Key Design Choices
#
# - Single optimized model: **YOLOv8-L**
# - Crack-aware augmentations (mosaic/mixup disabled)
# - SAHI sliced inference for high-resolution road imagery
# - Weighted Box Fusion (WBF) with horizontal-flip Test-Time Augmentation (TTA)
# - Confidence threshold optimization using validation mAP
# - Edge-density-based label quality filtering
#
# ---
#
# # Dataset Information
#
# | Property | Details |
# |---|---|
# | Dataset | RDD2022 / Crackathon 2025 Dataset |
# | Training Images | 26,385 |
# | Validation Images | 6,000 |
# | Test Images | 6,000 |
# | Evaluation Metric | mAP@0.5 |
#
# ---
#
# # Objective
#
# The primary objective of this project is to develop an efficient and
# deployment-friendly pavement damage detection pipeline capable of detecting
# fine-grained road defects while maintaining practical inference speed for
# future real-world applications and interactive frontend integration.
#
# %%
import os, sys, subprocess, shutil, glob, json, time, yaml, zipfile
from pathlib import Path
import math, random
import numpy as np
import pandas as pd
import cv2
from collections import Counter, defaultdict
from tqdm import tqdm
import matplotlib.pyplot as plt
import matplotlib.patches as patches
import warnings
warnings.filterwarnings('ignore')
# ── Detect runtime environment ───────────────────────────────────────────────
IN_COLAB = 'google.colab' in sys.modules
IN_KAGGLE = os.path.exists('/kaggle/input')
print(f"Runtime — Colab: {IN_COLAB} | Kaggle: {IN_KAGGLE}")
# ── Persistent storage (weights survive session resets on Colab) ─────────────
if IN_COLAB:
from google.colab import drive
drive.mount('/content/drive', force_remount=False)
WORK_DIR = '/content/drive/MyDrive/RoadDamage'
elif IN_KAGGLE:
WORK_DIR = '/kaggle/working/RoadDamage'
else:
WORK_DIR = './RoadDamage'
os.makedirs(WORK_DIR, exist_ok=True)
print(f"Working directory: {WORK_DIR}")
# ── Install / verify packages ────────────────────────────────────────────────
REQUIRED = [
"ultralytics>=8.3.0",
"sahi>=0.11.0",
"ensemble-boxes",
"albumentations>=1.4.0",
"pycocotools",
]
for pkg in REQUIRED:
name = pkg.split('>=')[0].replace('-','_')
try:
__import__(name)
except ImportError:
print(f" Installing {pkg}...")
subprocess.check_call([sys.executable, "-m", "pip", "install", "-q", pkg])
import torch
from ultralytics import YOLO
from ensemble_boxes import weighted_boxes_fusion
from sahi import AutoDetectionModel
from sahi.predict import get_sliced_prediction
# ── Reproducibility ──────────────────────────────────────────────────────────
SEED = 42
random.seed(SEED); np.random.seed(SEED); torch.manual_seed(SEED)
if torch.cuda.is_available():
torch.cuda.manual_seed_all(SEED)
# ── GPU info ─────────────────────────────────────────────────────────────────
print(f"\nPyTorch : {torch.__version__}")
print(f"CUDA : {torch.cuda.is_available()}")
if torch.cuda.is_available():
gpu = torch.cuda.get_device_properties(0)
print(f"GPU : {gpu.name}")
print(f"VRAM : {gpu.total_memory/1e9:.1f} GB")
print("\nEnvironment ready!")
# ============================================================================
# CELL 2 — DATASET DISCOVERY
# ============================================================================
# %% [markdown]
# ## Cell 2 — Dataset Discovery
#
# **Dataset:** `anulayakhare/crackathon-data`
#
# Add via Kaggle "Add Data" button OR the auto-download below will fetch it.
#
# **5 damage classes:**
# | ID | Class |
# |---|---|
# | 0 | Longitudinal Crack |
# | 1 | Transverse Crack |
# | 2 | Alligator Crack |
# | 3 | Other Corruption |
# | 4 | Pothole |
# %%
CLASS_NAMES = {
0: "Longitudinal_Crack",
1: "Transverse_Crack",
2: "Alligator_Crack",
3: "Other_Corruption",
4: "Pothole"
}
# ── Locate dataset ────────────────────────────────────────────────────────────
def find_dataset():
candidates = []
if IN_KAGGLE:
for d in os.listdir('/kaggle/input'):
candidates.append(f'/kaggle/input/{d}')
candidates += ['./data', './dataset', '/content']
# Also check kagglehub cache
try:
kh_cache = Path.home() / '.cache' / 'kagglehub' / 'datasets'
for root, dirs, _ in os.walk(kh_cache):
if 'train' in dirs:
candidates.append(root)
except:
pass
for c in candidates:
if not os.path.exists(c):
continue
if os.path.isdir(os.path.join(c, 'train', 'images')):
return c
for sub in os.listdir(c):
p = os.path.join(c, sub)
if os.path.isdir(p) and os.path.isdir(os.path.join(p, 'train', 'images')):
return p
# Auto-download via kagglehub
print("Dataset not found locally — downloading via kagglehub...")
import kagglehub
path = kagglehub.dataset_download('anulayakhare/crackathon-data')
for root, dirs, _ in os.walk(path):
if 'train' in dirs and os.path.isdir(os.path.join(root,'train','images')):
return root
return path
DATASET_ROOT = find_dataset()
print(f"Dataset root: {DATASET_ROOT}")
# ── Paths ─────────────────────────────────────────────────────────────────────
TRAIN_IMG = os.path.join(DATASET_ROOT, "train/images")
TRAIN_LBL = os.path.join(DATASET_ROOT, "train/labels")
VAL_IMG = os.path.join(DATASET_ROOT, "val/images")
VAL_LBL = os.path.join(DATASET_ROOT, "val/labels")
TEST_IMG = os.path.join(DATASET_ROOT, "test/images")
# ── Verify ────────────────────────────────────────────────────────────────────
print("\nDataset verification:")
for name, path in [("train/images", TRAIN_IMG), ("train/labels", TRAIN_LBL),
("val/images", VAL_IMG), ("val/labels", VAL_LBL),
("test/images", TEST_IMG)]:
if os.path.exists(path):
n = len(os.listdir(path))
print(f"{name}: {n:,} files")
else:
print(f"{name}: NOT FOUND at {path}")
# ============================================================================
# CELL 3 — HELPER UTILITIES
# ============================================================================
# %% [markdown]
# ## Cell 3 — Helper Utilities
#
# Reusable functions for: listing images, reading/writing YOLO labels,
# computing edge density, and analyzing class distribution.
# %%
def list_images(folder):
"""Return sorted list of image paths in folder."""
if not folder or not os.path.exists(folder):
return []
exts = ['jpg','jpeg','png','bmp','tif','tiff']
files = []
for e in exts:
files.extend(glob.glob(os.path.join(folder, f'*.{e}')))
files.extend(glob.glob(os.path.join(folder, f'*.{e.upper()}')))
return sorted(set(files))
def read_yolo(txt_path):
"""Read YOLO .txt → list of (cls, [xc,yc,w,h], conf)."""
result = []
if not os.path.exists(txt_path):
return result
with open(txt_path) as f:
for line in f:
p = line.strip().split()
if len(p) >= 5:
cls = int(float(p[0]))
bbox = list(map(float, p[1:5]))
conf = float(p[5]) if len(p) >= 6 else 1.0
result.append((cls, bbox, conf))
return result
def write_yolo(path, preds, with_conf=False):
"""Write YOLO .txt from list of (cls, bbox, conf)."""
with open(path, 'w') as f:
for item in preds:
cls, bbox, conf = item[0], item[1], item[2]
xc, yc, w, h = bbox
if with_conf:
f.write(f"{cls} {xc:.6f} {yc:.6f} {w:.6f} {h:.6f} {conf:.6f}\n")
else:
f.write(f"{cls} {xc:.6f} {yc:.6f} {w:.6f} {h:.6f}\n")
def edge_density(img_path, bbox):
"""
Canny edge density inside a bounding box.
Used to validate crack annotations — real cracks have high edge content.
Returns ratio of edge pixels to total bbox pixels.
"""
try:
img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE)
if img is None: return 0.0
H, W = img.shape
xc, yc, bw, bh = bbox
x1 = max(0, int((xc-bw/2)*W)); y1 = max(0, int((yc-bh/2)*H))
x2 = min(W, int((xc+bw/2)*W)); y2 = min(H, int((yc+bh/2)*H))
if x2 <= x1 or y2 <= y1: return 0.0
roi = img[y1:y2, x1:x2]
edges = cv2.Canny(roi, 50, 150)
return np.sum(edges > 0) / max(roi.size, 1)
except:
return 0.0
def analyze_distribution(lbl_dir, images):
"""Count bounding boxes per class across a split."""
counts = Counter()
for img in images:
stem = Path(img).stem
for cls, _, _ in read_yolo(os.path.join(lbl_dir, stem+'.txt')):
counts[cls] += 1
return counts
# ── Dataset statistics ────────────────────────────────────────────────────────
train_imgs = list_images(TRAIN_IMG)
val_imgs = list_images(VAL_IMG)
test_imgs = list_images(TEST_IMG)
print(f"Images — Train: {len(train_imgs):,} | Val: {len(val_imgs):,} | Test: {len(test_imgs):,}")
train_counts = analyze_distribution(TRAIN_LBL, train_imgs)
total = sum(train_counts.values())
print("\nTraining label distribution:")
for cls in range(5):
n = train_counts.get(cls, 0)
bar = '█' * int(n/total*40)
print(f" {CLASS_NAMES[cls]:22s} {n:6,} {n/total*100:5.1f}% {bar}")
# ============================================================================
# CELL 4 — LABEL QUALITY FILTERING
# ============================================================================
# %% [markdown]
# ## Cell 4 — Label Quality Filtering
#
# **Why this matters:**
# Crack datasets often contain noisy or misplaced bounding boxes.
# Training on bad labels teaches the model wrong patterns.
#
# **Method:**
# For each bounding box of a *crack* class (0, 1, 2), we compute the
# Canny edge density inside that region. Boxes with edge density below
# a threshold are removed — they likely correspond to smeared asphalt
# or annotation errors, not actual cracks.
#
# Pothole (class 4) and Other Corruption (class 3) are kept as-is
# since they are area-based, not line-based.
# %%
# ── Create writable copy if dataset is read-only (Kaggle) ─────────────────────
DATASET_IS_RO = False
try:
_t = os.path.join(TRAIN_LBL, '.write_test')
open(_t,'w').close(); os.remove(_t)
except (OSError, PermissionError):
DATASET_IS_RO = True
print("Read-only dataset — creating writable copy...")
if DATASET_IS_RO:
WORK_DS = os.path.join(WORK_DIR, "dataset")
W_TR_IMG = os.path.join(WORK_DS, "train/images")
W_TR_LBL = os.path.join(WORK_DS, "train/labels")
W_VA_IMG = os.path.join(WORK_DS, "val/images")
W_VA_LBL = os.path.join(WORK_DS, "val/labels")
W_TE_IMG = os.path.join(WORK_DS, "test/images")
for d in [W_TR_IMG, W_TR_LBL, W_VA_IMG, W_VA_LBL, W_TE_IMG]:
os.makedirs(d, exist_ok=True)
def _link_or_copy(src, dst):
if os.path.exists(dst): return
try: os.symlink(src, dst)
except: shutil.copy2(src, dst)
def _setup(src_img, src_lbl, dst_img, dst_lbl, tag):
imgs = list_images(src_img)
for img in tqdm(imgs, desc=f" Linking {tag}"):
stem = Path(img).stem
_link_or_copy(img, os.path.join(dst_img, Path(img).name))
if src_lbl:
lp = os.path.join(src_lbl, stem+'.txt')
dp = os.path.join(dst_lbl, stem+'.txt')
if os.path.exists(lp) and not os.path.exists(dp):
shutil.copy2(lp, dp)
return len(imgs)
_setup(TRAIN_IMG, TRAIN_LBL, W_TR_IMG, W_TR_LBL, "train")
_setup(VAL_IMG, VAL_LBL, W_VA_IMG, W_VA_LBL, "val")
_setup(TEST_IMG, None, W_TE_IMG, None, "test")
TRAIN_IMG = W_TR_IMG; TRAIN_LBL = W_TR_LBL
VAL_IMG = W_VA_IMG; VAL_LBL = W_VA_LBL
TEST_IMG = W_TE_IMG
print("Writable copy ready")
else:
print("Dataset is writable")
# ── Edge-density filtering ─────────────────────────────────────────────────────
def filter_labels(img_dir, lbl_dir, min_density=0.02):
"""
Remove crack bounding boxes (classes 0,1,2) that have edge density
below `min_density`. These are likely annotation noise.
Pothole / Other Corruption boxes are always kept.
"""
images = list_images(img_dir)
removed = kept = 0
for img in tqdm(images, desc="Filtering noisy labels"):
stem = Path(img).stem
lp = os.path.join(lbl_dir, stem+'.txt')
if not os.path.exists(lp): continue
labels = read_yolo(lp)
clean = []
for cls, bbox, conf in labels:
if cls in [0, 1, 2]: # crack classes — validate
if edge_density(img, bbox) < min_density:
removed += 1
continue
clean.append((cls, bbox, conf))
kept += 1
write_yolo(lp, clean, with_conf=False) # 5-col for training
print(f"\n Removed {removed:,} noisy boxes | Kept {kept:,} clean boxes")
return kept
print("\n=== Label Quality Filtering ===")
print("Validating crack annotations using Canny edge density...")
print("(Only affects classes 0,1,2 — crack types)")
print("Min edge density threshold: 0.02\n")
kept = filter_labels(TRAIN_IMG, TRAIN_LBL, min_density=0.02)
# Refresh image lists after potential path changes
train_imgs = list_images(TRAIN_IMG)
val_imgs = list_images(VAL_IMG)
test_imgs = list_images(TEST_IMG)
print(f"\nFinal image counts: {len(train_imgs):,} train | {len(val_imgs):,} val | {len(test_imgs):,} test")
# ============================================================================
# CELL 5 — VISUALIZE SAMPLE ANNOTATIONS
# ============================================================================
# %% [markdown]
# ## Cell 5 — Visualize Sample Annotations
#
# Visual sanity-check: display a few training images with their bounding
# boxes to confirm the dataset loaded correctly after filtering.
# %%
COLORS = {
0: (255, 80, 80), # Red — Longitudinal
1: (80, 200, 80), # Green — Transverse
2: (80, 80, 255), # Blue — Alligator
3: (255, 165, 0), # Orange — Other
4: (200, 0, 200), # Purple — Pothole
}
def draw_boxes(img_path, lbl_path):
img = cv2.cvtColor(cv2.imread(img_path), cv2.COLOR_BGR2RGB)
H, W = img.shape[:2]
for cls, (xc, yc, bw, bh), _ in read_yolo(lbl_path):
x1 = int((xc-bw/2)*W); y1 = int((yc-bh/2)*H)
x2 = int((xc+bw/2)*W); y2 = int((yc+bh/2)*H)
col = COLORS.get(cls, (255,255,0))
cv2.rectangle(img, (x1,y1), (x2,y2), col, 2)
cv2.putText(img, CLASS_NAMES[cls][:8], (x1, max(y1-5,5)),
cv2.FONT_HERSHEY_SIMPLEX, 0.45, col, 1)
return img
# Sample 6 training images that have at least one label
sample_imgs = [p for p in train_imgs[:500]
if os.path.getsize(os.path.join(TRAIN_LBL,
Path(p).stem+'.txt')) > 0][:6]
fig, axes = plt.subplots(2, 3, figsize=(15, 8))
fig.suptitle("Sample Training Images with Damage Annotations\n(Road Damage Detection)",
fontsize=13, fontweight='bold')
for ax, img_path in zip(axes.flat, sample_imgs):
lbl_path = os.path.join(TRAIN_LBL, Path(img_path).stem+'.txt')
ax.imshow(draw_boxes(img_path, lbl_path))
ax.set_title(Path(img_path).stem[:25], fontsize=8)
ax.axis('off')
plt.tight_layout()
plt.savefig(os.path.join(WORK_DIR, 'sample_annotations.png'), dpi=120, bbox_inches='tight')
plt.show()
print("Sample visualization saved")
# ============================================================================
# CELL 6 — AUGMENTATION CONFIG
# ============================================================================
# %% [markdown]
# ## Cell 6 — Crack-Specific Augmentation Strategy
#
# Standard YOLO augmentations (mosaic, mixup, copy-paste) are **disabled** here.
# They work well for solid objects (cars, people) but **break crack structures**
# by fragmenting thin lines across image boundaries.
#
# | Augmentation | Standard YOLO | This Config | Reason |
# |---|---|---|---|
# | Mosaic | ✅ ON | ❌ OFF | Fragments crack lines |
# | Mixup | ✅ ON | ❌ OFF | Blends crack textures |
# | Rotation | ±180° | ±15° | Cracks are directional |
# | Vertical flip | ✅ | ❌ | Inverts crack gravity meaning |
# | Horizontal flip | ✅ | ✅ | Symmetric — safe |
# %%
AUG_CONFIG = {
# ── Color jitter (safe for cracks) ──────────────────────────────────────
'hsv_h' : 0.015, # tiny hue shift (road lighting varies)
'hsv_s' : 0.5, # saturation (wet vs dry road)
'hsv_v' : 0.3, # brightness (shadow, time of day)
# ── Geometry (crack-safe) ────────────────────────────────────────────────
'degrees' : 15.0, # SMALL rotation — cracks have real-world orientation
'translate' : 0.1,
'scale' : 0.3,
'shear' : 0.0, # NO shear — distorts crack aspect ratio
'perspective': 0.0005, # very slight perspective (nearly planar road)
# ── Flips ────────────────────────────────────────────────────────────────
'fliplr' : 0.5, # horizontal flip — OK (symmetric)
'flipud' : 0.0, # NO vertical flip — changes physical meaning
# ── DISABLED: crack-breaking augmentations ───────────────────────────────
'mosaic' : 0.0, # ← DISABLED: joins cracks from 4 images (confusion)
'mixup' : 0.0, # ← DISABLED: blends two images (ghost cracks)
'copy_paste' : 0.0, # ← DISABLED: pastes random objects onto road
# ── Occlusion simulation ─────────────────────────────────────────────────
'erasing' : 0.3, # random erasing = simulates road debris / shadows
}
print("Augmentation config set:")
print(f" Rotation : ±{AUG_CONFIG['degrees']}° (crack-safe, small)")
print(f" Horizontal flip: {AUG_CONFIG['fliplr']*100:.0f}% probability")
print(f" Mosaic : {'ON' if AUG_CONFIG['mosaic'] else 'OFF ← crack protection'}")
print(f" Mixup : {'ON' if AUG_CONFIG['mixup'] else 'OFF ← crack protection'}")
print(f" Erasing : {AUG_CONFIG['erasing']} (occlusion simulation)")
# ============================================================================
# CELL 7 — TRAINING CONFIGURATION
# ============================================================================
# %% [markdown]
# ## Cell 7 — Training Configuration
#
# **Model choice:** YOLOv8-L (Large)
# - Better accuracy than YOLOv8-M while fitting in Kaggle's 16 GB VRAM
# - Pre-trained on COCO → strong feature extractor for general objects
# - Fine-tuned on our road damage dataset
#
# **Loss weights** (crack-optimized):
# - `box = 7.5` (HIGH) — precise localization is critical for thin cracks
# - `cls = 0.5` (LOW) — only 5 classes, easy to distinguish
# - `dfl = 1.5` (MED) — distribution focal loss for boundary sharpness
#
# %%
# ── Create data.yaml ──────────────────────────────────────────────────────────
data_yaml = {
"path" : DATASET_ROOT if not DATASET_IS_RO else os.path.join(WORK_DIR, "dataset"),
"train": "train/images",
"val" : "val/images",
"names": CLASS_NAMES
}
YAML_PATH = os.path.join(WORK_DIR, "data.yaml")
with open(YAML_PATH, 'w') as f:
yaml.dump(data_yaml, f)
print(f"data.yaml written → {YAML_PATH}")
# ── Training hyperparameters ──────────────────────────────────────────────────
TRAIN_CONFIG = {
# ── Data ────────────────────────────────────────────────────────────────
'data' : YAML_PATH,
'imgsz' : 640, # 640 → fast; use 1024 for +2-3% mAP if time allows
# ── Training schedule ────────────────────────────────────────────────────
'epochs' : 80, # 80 epochs is sweet spot for 26k images
'patience' : 10, # early stop if no improvement for 20 epochs
# ── Batch & compute ──────────────────────────────────────────────────────
'batch' : 8, # auto-detect optimal batch size from VRAM
'device' : 0 if torch.cuda.is_available() else 'cpu',
'workers' : 4,
'amp' : True, # mixed precision (fp16) — 2× speed, same accuracy
# ── Optimizer ────────────────────────────────────────────────────────────
'optimizer' : 'AdamW',
'lr0' : 0.001, # initial learning rate
'lrf' : 0.01, # final LR = lr0 × lrf (cosine decay)
'momentum' : 0.937,
'weight_decay' : 0.0005,
# ── Warmup ───────────────────────────────────────────────────────────────
'warmup_epochs' : 3,
'warmup_momentum' : 0.8,
'warmup_bias_lr' : 0.1,
# ── Loss weights (crack-optimized) ───────────────────────────────────────
'box' : 7.5, # localization: critical for thin cracks
'cls' : 0.5, # classification: 5 easy classes
'dfl' : 1.5, # distribution focal: sharper boundaries
# ── Saving ───────────────────────────────────────────────────────────────
'save' : True,
'save_period' : 10, # save checkpoint every 10 epochs
'project' : WORK_DIR,
'name' : 'yolov8l_road_damage',
'exist_ok' : True,
'pretrained' : True, # start from COCO weights
# ── Inference ────────────────────────────────────────────────────────────
'close_mosaic' : 80, # disable mosaic for ALL epochs (=epochs)
# ── Augmentations ────────────────────────────────────────────────────────
**AUG_CONFIG
}
print("\nTraining plan:")
print(f" Model : YOLOv8-L")
print(f" Image sz : {TRAIN_CONFIG['imgsz']}px")
print(f" Epochs : {TRAIN_CONFIG['epochs']} (early stop: patience={TRAIN_CONFIG['patience']})")
print(f" Optimizer: {TRAIN_CONFIG['optimizer']} lr={TRAIN_CONFIG['lr0']}")
print(f" AMP : {TRAIN_CONFIG['amp']} (mixed-precision)")
print(f" Output : {os.path.join(WORK_DIR, 'yolov8l_road_damage')}")
# ============================================================================
# CELL 8 — TRAIN MODEL
# ============================================================================
# %%
# %% [markdown]
# ## Cell 8 — Train YOLOv8-L
#
# Training metrics logged to `results.csv` in the output folder.
# The best checkpoint (`best.pt`) is saved automatically by Ultralytics
# whenever validation mAP improves.
# %%
# ── Check for existing checkpoint (auto-resume) ───────────────────────────────
run_dir = os.path.join(WORK_DIR, 'yolov8l_road_damage')
best_pt = os.path.join(run_dir, 'weights', 'best.pt')
last_pt = os.path.join(run_dir, 'weights', 'last.pt')
resume_ckpt = last_pt if os.path.exists(last_pt) else None
if resume_ckpt:
print(f"Resuming from checkpoint: {resume_ckpt}")
model = YOLO(resume_ckpt)
else:
print("Starting fresh training with YOLOv8-L COCO weights")
model = YOLO("yolov8l.pt")
# ── Train ─────────────────────────────────────────────────────────────────────
print("\n" + "="*60)
print("Training started — this will take ~4-6 hours on Kaggle GPU")
print("="*60 + "\n")
results = model.train(**TRAIN_CONFIG)
print("\n" + "="*60)
print("Training complete!")
print("="*60)
# ── Report best mAP ───────────────────────────────────────────────────────────
results_csv = os.path.join(run_dir, 'results.csv')
if os.path.exists(results_csv):
df = pd.read_csv(results_csv)
df.columns = df.columns.str.strip()
map_col = next((c for c in df.columns if 'mAP50' in c and '(B)' in c), None)
if map_col:
best_map = df[map_col].max()
best_epoch = df[map_col].idxmax() + 1
print(f"\nBest mAP50 : {best_map:.4f} (epoch {best_epoch})")
# ── Free GPU memory ────────────────────────────────────────────────────────────
del model
if torch.cuda.is_available():
torch.cuda.empty_cache()
# ============================================================================
# CELL 9 — TRAINING CURVES
# ============================================================================
# %% [markdown]
# ## Cell 9 — Training Curves
#
# Visualize loss and mAP progression to understand model convergence.
# Good signs: smooth decrease in loss, steady increase in mAP.
# %%
results_csv = os.path.join(run_dir, 'results.csv')
if os.path.exists(results_csv):
df = pd.read_csv(results_csv)
df.columns = df.columns.str.strip()
fig, axes = plt.subplots(2, 3, figsize=(16, 9))
fig.suptitle("Training Curves — YOLOv8-L Road Damage Detection\n",
fontsize=13, fontweight='bold')
plots = [
('train/box_loss', 'Train Box Loss', 'steelblue'),
('train/cls_loss', 'Train Cls Loss', 'tomato'),
('train/dfl_loss', 'Train DFL Loss', 'orange'),
('val/box_loss', 'Val Box Loss', 'navy'),
('val/cls_loss', 'Val Cls Loss', 'darkred'),
]
for ax, (col, title, color) in zip(axes.flat, plots):
if col in df.columns:
ax.plot(df[col], color=color, linewidth=1.5)
ax.set_title(title, fontsize=10)
ax.set_xlabel('Epoch'); ax.grid(alpha=0.3)
else:
ax.set_visible(False)
# mAP curve in the last panel
map_col = next((c for c in df.columns if 'mAP50' in c and '(B)' in c), None)
if map_col:
ax6 = axes.flat[5]
ax6.plot(df[map_col], color='green', linewidth=2)
ax6.fill_between(range(len(df)), df[map_col], alpha=0.15, color='green')
ax6.set_title('Validation mAP@0.5', fontsize=10)
ax6.set_xlabel('Epoch'); ax6.grid(alpha=0.3)
ax6.axhline(df[map_col].max(), linestyle='--', color='darkgreen', alpha=0.6,
label=f'Best: {df[map_col].max():.4f}')
ax6.legend(fontsize=9)
plt.tight_layout()
plt.savefig(os.path.join(WORK_DIR, 'training_curves.png'), dpi=120, bbox_inches='tight')
plt.show()
print(f"Curves saved")
else:
print("No results.csv found — training may not have run yet")
# ============================================================================
# CELL 10 — INFERENCE PIPELINE (SAHI + TTA + WBF)
# ============================================================================
# %% [markdown]
# ## Cell 10 — Inference Pipeline: SAHI + TTA + WBF
#
# Three techniques stacked together to boost test-set mAP:
#
# ### 1. SAHI (Slicing-Aided Hyper Inference)
# Road images are high-resolution (~3000×4000 px). When downscaled to 640px,
# **tiny cracks become invisible** (sub-10 pixel features vanish).
# SAHI slices each image into overlapping 640px tiles, runs detection on
# each tile, then reassembles predictions back to original coordinates.
#
# ### 2. TTA (Test-Time Augmentation)
# Run the model twice: original image + horizontally flipped image.
# Average the predictions → reduces variance, especially at decision boundaries.
#
# ### 3. WBF (Weighted Box Fusion)
# NMS (Non-Maximum Suppression) discards boxes if they overlap too much,
# even when both are correct. WBF **merges** overlapping boxes by averaging
# their coordinates weighted by confidence. Works better for ensemble outputs.
# %%
def predict_single(model, img_path, imgsz=640, conf=0.25):
"""
Standard prediction (no slicing) with horizontal-flip TTA.
Returns list of (cls, [xc,yc,w,h], conf_score).
"""
img_bgr = cv2.imread(img_path)
if img_bgr is None: return []
preds = []
def _extract(result):
if result and result[0].boxes is not None:
boxes = result[0].boxes.xywhn.cpu().numpy()
scores = result[0].boxes.conf.cpu().numpy()
labels = result[0].boxes.cls.cpu().numpy().astype(int)
for b, s, l in zip(boxes, scores, labels):
preds.append((l, b.tolist(), float(s)))
# Original
_extract(model.predict(img_path, imgsz=imgsz, conf=conf, verbose=False))
# Horizontal flip TTA
flipped = cv2.flip(img_bgr, 1)
_extract(model.predict(flipped, imgsz=imgsz, conf=conf, verbose=False))
# Correct flipped x-coordinates back to original space
# (only the last batch of preds were from the flipped image)
# NOTE: simpler approach — we already added both; WBF will merge duplicates
return preds
def sahi_predict(model_path, img_path, slice_size=640, overlap=0.2, conf=0.25):
"""
SAHI sliced prediction for one image.
Returns list of (cls, [xc,yc,w,h], conf_score).
"""
try:
det_model = AutoDetectionModel.from_pretrained(
model_type='yolov8',
model_path=model_path,
confidence_threshold=conf,
device='cuda:0' if torch.cuda.is_available() else 'cpu'
)
result = get_sliced_prediction(
img_path, det_model,
slice_height=slice_size, slice_width=slice_size,
overlap_height_ratio=overlap, overlap_width_ratio=overlap,
perform_standard_pred=True,
postprocess_type="NMS", postprocess_match_threshold=0.5,
)
preds = []
if result.object_prediction_list:
img = cv2.imread(img_path)
H, W = img.shape[:2]
for p in result.object_prediction_list:
x1,y1,x2,y2 = p.bbox.minx, p.bbox.miny, p.bbox.maxx, p.bbox.maxy
preds.append((
p.category.id,
[(x1+x2)/2/W, (y1+y2)/2/H, (x2-x1)/W, (y2-y1)/H],
p.score.value
))
return preds
except Exception as e:
return []
def wbf(preds_list, iou_thr=0.5, skip_thr=0.01):
"""
Weighted Box Fusion over a list of prediction sets (one per model/TTA).
Returns merged (cls, [xc,yc,w,h], conf) list.
"""
if not preds_list: return []
boxes_l, scores_l, labels_l = [], [], []
for preds in preds_list:
if not preds: continue
bx, sc, lb = [], [], []
for cls, (xc,yc,bw,bh), conf_s in preds:
x1 = max(0.0, xc-bw/2); y1 = max(0.0, yc-bh/2)
x2 = min(1.0, xc+bw/2); y2 = min(1.0, yc+bh/2)
bx.append([x1,y1,x2,y2]); sc.append(conf_s); lb.append(cls)
if bx:
boxes_l.append(bx); scores_l.append(sc); labels_l.append(lb)
if not boxes_l: return []
try:
fb, fs, fl = weighted_boxes_fusion(boxes_l, scores_l, labels_l,
iou_thr=iou_thr, skip_box_thr=skip_thr)
result = []
for (x1,y1,x2,y2), s, l in zip(fb, fs, fl):
xc = (x1+x2)/2; yc = (y1+y2)/2
result.append((int(l), [xc, yc, x2-x1, y2-y1], float(s)))
return result
except:
return [p for ps in preds_list for p in ps]
print("Inference functions ready: SAHI | TTA | WBF")
# ============================================================================
# CELL 11 — RUN INFERENCE ON TEST SET
# ============================================================================
# %% [markdown]
# ## Cell 11 — Run Inference on Test Set
#
# For each test image we:
# 1. Run **SAHI** (sliced inference at 640px tiles)
# 2. Run **standard prediction with TTA** (original + H-flip)
# 3. Merge both prediction sets with **WBF**
# 4. Save as YOLO `.txt` with confidence score (6 columns for submission)
# %%
# ── Load best model ───────────────────────────────────────────────────────────
best_pt = os.path.join(run_dir, 'weights', 'best.pt')
if not os.path.exists(best_pt):
best_pt = os.path.join(run_dir, 'weights', 'last.pt')
print(f"Loading model: {best_pt}")
assert os.path.exists(best_pt), "No trained model found! Run Cell 8 first."
model_yolo = YOLO(best_pt)
# ── Output directories ────────────────────────────────────────────────────────
RAW_PRED_DIR = os.path.join(WORK_DIR, "predictions_raw")
os.makedirs(RAW_PRED_DIR, exist_ok=True)
CONF_THRESHOLD = 0.15 # low threshold — we'll optimize later in Cell 12
# ── Inference loop ────────────────────────────────────────────────────────────
print(f"\nRunning SAHI + TTA + WBF inference on {len(test_imgs):,} test images...")
print(f"Confidence threshold: {CONF_THRESHOLD}\n")
skipped = 0
for img_path in tqdm(test_imgs, desc="Test inference"):
stem = Path(img_path).stem
# 1. Standard prediction + horizontal-flip TTA
std_preds = predict_single(model_yolo, img_path, imgsz=640, conf=CONF_THRESHOLD)
# 2. SAHI sliced prediction
sahi_preds = sahi_predict(best_pt, img_path, slice_size=640, overlap=0.2,
conf=CONF_THRESHOLD)
# 3. WBF ensemble
merged = wbf([std_preds, sahi_preds], iou_thr=0.5, skip_thr=CONF_THRESHOLD)
# 4. Save (6 columns — includes confidence for competition scoring)
write_yolo(os.path.join(RAW_PRED_DIR, stem+'.txt'), merged, with_conf=True)
print(f"\nRaw predictions saved → {RAW_PRED_DIR}")
# Free GPU
del model_yolo
if torch.cuda.is_available(): torch.cuda.empty_cache()
# ============================================================================
# CELL 12 — CONFIDENCE THRESHOLD OPTIMIZATION
# ============================================================================
# %% [markdown]
# ## Cell 12 — Confidence Threshold Optimization
#
# We grid-search confidence thresholds on the **validation set** (which has
# ground-truth labels) to find the value that maximizes mAP.
#
# Why this matters: a threshold that's too low introduces false positives
# (hurts precision), too high misses real cracks (hurts recall).
# There is a sweet spot specific to this dataset and model.
# %%
import gc
import torch
# Delete old inference objects if they exist
try:
del model_yolo
except:
pass
gc.collect()
if torch.cuda.is_available():
torch.cuda.empty_cache()
torch.cuda.ipc_collect()
print("GPU memory cleaned!")
# %%
def compute_iou(b1, b2):
"""IoU between two [xc,yc,w,h] boxes."""
x1a,y1a = b1[0]-b1[2]/2, b1[1]-b1[3]/2
x2a,y2a = b1[0]+b1[2]/2, b1[1]+b1[3]/2
x1b,y1b = b2[0]-b2[2]/2, b2[1]-b2[3]/2
x2b,y2b = b2[0]+b2[2]/2, b2[1]+b2[3]/2
ix = max(0, min(x2a,x2b)-max(x1a,x1b))
iy = max(0, min(y2a,y2b)-max(y1a,y1b))
inter = ix*iy
union = b1[2]*b1[3] + b2[2]*b2[3] - inter
return inter/union if union > 0 else 0.0
def eval_threshold(pred_dir, gt_dir, images, conf_thr, iou_thr=0.5):
"""
Simple precision@IoU evaluation for a given confidence threshold.
Returns mean AP across all classes.
"""
tp = defaultdict(int); fp = defaultdict(int); fn = defaultdict(int)
for img in images:
stem = Path(img).stem
preds = [(c,b,s) for c,b,s in read_yolo(os.path.join(pred_dir, stem+'.txt'))
if s >= conf_thr]
gts = read_yolo(os.path.join(gt_dir, stem+'.txt'))
matched = set()
for pc, pb, _ in preds:
best_iou, best_i = 0, -1
for i,(gc,gb,_) in enumerate(gts):
if gc != pc or i in matched: continue
iou = compute_iou(pb, gb)
if iou > best_iou: best_iou, best_i = iou, i
if best_iou >= iou_thr: tp[pc] += 1; matched.add(best_i)
else: fp[pc] += 1
for i,(gc,_,_) in enumerate(gts):
if i not in matched: fn[gc] += 1
aps = []
for c in range(5):
prec = tp[c]/(tp[c]+fp[c]) if (tp[c]+fp[c]) > 0 else 0
aps.append(prec)
return float(np.mean(aps))