Coverage for src/crackmnist_eccv/run_experiment.py: 0%

133 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-10-19 08:00 +0000

1# SPDX-FileCopyrightText: 2026 German Aerospace Center (DLR) 

2# SPDX-License-Identifier: GPL-3.0-or-later 

3 

4"""Run CrackMNIST pollution experiments across seeds, intensities, and splits. 

5 

6Trains a UNet model under controlled input pollution and evaluates average 

7Dice loss on train/val/test splits across multiple random seeds. Results are 

8written incrementally to a CSV file named after the experiment configuration. 

9""" 

10 

11import argparse 

12import ast 

13import random 

14import time 

15 

16from crackmnist import CrackMNIST 

17from crackmnist.loss import DiceLoss 

18from crackmnist.nets import UNet 

19from crackmnist.transforms import InputNormalization, NumpyToTensor 

20import numpy as np 

21import pandas as pd 

22import torch 

23from torch import optim 

24from torch.utils import data 

25from torchvision import transforms 

26 

27from src.crackmnist_eccv.utility import apply_per_image_pollution, enlarge_crack_tip 

28 

29 

30############################################################################## 

31# FIXED HYPERPARAMETERS 

32############################################################################## 

33NUM_EPOCHS = 10 

34BATCH_SIZE = 32 

35LR = 1e-4 

36 

37############################################################################## 

38# PARAM RANGES 

39############################################################################## 

40TEST_PERC_RANGE = [0.0, 1.0] 

41NUM_SEEDS = 10 

42SIZE = "S" 

43 

44 

45############################################################################## 

46# COMMAND-LINE ARGUMENTS 

47############################################################################## 

48def parse_python_literal(text: str, expected_type: type) -> list: 

49 """Convert a string like '[0.1,0.2]' or '[(0.1,0.2)]' to a Python object. 

50 

51 Args: 

52 text: String representation of a Python literal. 

53 expected_type: The expected Python type (e.g. list). 

54 

55 Returns: 

56 Parsed Python object of the expected type. 

57 

58 Raises: 

59 ArgumentTypeError: If parsing fails or type does not match. 

60 TypeError: If the parsed value is not an instance of expected_type. 

61 """ 

62 try: 

63 value = ast.literal_eval(text) 

64 if not isinstance(value, expected_type): 

65 raise TypeError 

66 return value 

67 except Exception as err: 

68 raise argparse.ArgumentTypeError( 

69 f"Value '{text}' is not a valid {expected_type.__name__}" 

70 ) from err 

71 

72 

73parser = argparse.ArgumentParser( 

74 description="Run CrackMNIST experiments with flexible settings." 

75) 

76 

77parser.add_argument( 

78 "pollution_type", 

79 nargs="?", 

80 default="blob", 

81 help="gaussian | salt_pepper | gaussian_blur | blob", 

82) 

83parser.add_argument( 

84 "intensity_range", 

85 nargs="?", 

86 default="[(0.1,0.2),(0.3,0.4),(0.5,0.6)]", 

87 help='Python literal, e.g. "[0.1,0.3,0.5]" or "[(0.1,0.2)]"', 

88) 

89parser.add_argument( 

90 "--pixels", 

91 type=int, 

92 default=64, 

93 help="Image size fed into CrackMNIST (default: 64)", 

94) 

95parser.add_argument( 

96 "--train_range", 

97 type=lambda s: parse_python_literal(s, list), 

98 default=[0.05, 0.1, 0.15, 0.2], 

99 help='Training-pollution percentages, e.g. "[0.0,0.25,0.5]"', 

100) 

101parser.add_argument( 

102 "--radius", 

103 type=int, 

104 default=8, 

105 help=("Blob-pollution radius (only used when pollution_type == 'blob'; default: 8)"), 

106) 

107args = parser.parse_args() 

108 

109############################################################################## 

110# SET VARIABLES FROM ARGS 

111############################################################################## 

112SELECTED_POLLUTION_TYPE = args.pollution_type 

113INTENSITY_RANGE = parse_python_literal(args.intensity_range, list) 

114PIXELS = args.pixels 

115TRAIN_PERC_RANGE = args.train_range 

116RADIUS = args.radius 

117 

118NAMING_CONSTANT = f"{PIXELS}_F9_{SELECTED_POLLUTION_TYPE}_radius{RADIUS}" 

119 

120 

121############################################################################## 

122# UTILITY: FIX RANDOM SEEDS 

123############################################################################## 

124def fix_random_seeds(seed: int) -> None: 

125 """Fix all relevant random seeds for reproducibility. 

126 

127 Args: 

128 seed: Integer seed value. 

129 """ 

130 random.seed(seed) 

131 np.random.seed(seed) 

132 torch.manual_seed(seed) 

133 torch.use_deterministic_algorithms(True) 

134 

135 

136############################################################################## 

137# DEVICE 

138############################################################################## 

139device = torch.device("cuda" if torch.cuda.is_available() else "cpu") 

140print(f"Using device: {device}") 

141 

142############################################################################## 

143# LOAD DATASETS 

144############################################################################## 

145data_transform = transforms.Compose([ 

146 NumpyToTensor(), 

147 InputNormalization(), 

148]) 

149target_transform = transforms.Compose([ 

150 NumpyToTensor(), 

151]) 

152 

153train_dataset_full = CrackMNIST( 

154 split="train", 

155 pixels=PIXELS, 

156 size=SIZE, 

157 transform=data_transform, 

158 target_transform=target_transform, 

159) 

160 

161val_dataset_full = CrackMNIST( 

162 split="val", 

163 pixels=PIXELS, 

164 size=SIZE, 

165 transform=data_transform, 

166 target_transform=target_transform, 

167) 

168 

169test_dataset_full = CrackMNIST( 

170 split="test", 

171 pixels=PIXELS, 

172 size=SIZE, 

173 transform=data_transform, 

174 target_transform=target_transform, 

175) 

176 

177############################################################################## 

178# ENLARGE THE ANNOTATED TIP REGION TO 3x3 

179############################################################################## 

180train_dataset_full.masks = np.array([ 

181 enlarge_crack_tip(mask) for mask in train_dataset_full.masks 

182]) 

183val_dataset_full.masks = np.array([ 

184 enlarge_crack_tip(mask) for mask in val_dataset_full.masks 

185]) 

186test_dataset_full.masks = np.array([ 

187 enlarge_crack_tip(mask) for mask in test_dataset_full.masks 

188]) 

189 

190print("Dataset loaded") 

191train_loader_full = data.DataLoader( 

192 train_dataset_full, batch_size=BATCH_SIZE, shuffle=False, num_workers=4 

193) 

194val_loader_full = data.DataLoader( 

195 val_dataset_full, batch_size=BATCH_SIZE, shuffle=False, num_workers=4 

196) 

197test_loader_full = data.DataLoader( 

198 test_dataset_full, batch_size=BATCH_SIZE, shuffle=False, num_workers=4 

199) 

200 

201 

202############################################################################## 

203# HELPER: CONFIG & EVALUATION FUNCTION 

204############################################################################## 

205def get_pollution_config( 

206 pollution_type: str, intensity: float | tuple[float, float] 

207) -> dict: 

208 """Return keyword arguments for apply_per_image_pollution. 

209 

210 Args: 

211 pollution_type: One of 'blob', 'gaussian', 'salt_pepper', 

212 'gaussian_blur'. 

213 intensity: Float or (min, max) tuple depending on pollution type. 

214 

215 Returns: 

216 Dict with 'pollution_type' and 'pollution_kwargs' keys. 

217 

218 Raises: 

219 ValueError: If the pollution type is unsupported or intensity format 

220 is wrong. 

221 """ 

222 if pollution_type == "blob": 

223 if not isinstance(intensity, tuple) or len(intensity) != 2: 

224 raise ValueError("Blob pollution requires intensity as a (min, max) tuple") 

225 return { 

226 "pollution_type": pollution_type, 

227 "pollution_kwargs": { 

228 "radius": RADIUS, 

229 "target_min_oar": intensity[0], 

230 "target_max_oar": intensity[1], 

231 }, 

232 } 

233 elif pollution_type in ["gaussian", "salt_pepper", "gaussian_blur"]: 

234 if not isinstance(intensity, (float, int)): 

235 raise ValueError(f"{pollution_type} requires intensity as a float or int") 

236 return { 

237 "pollution_type": pollution_type, 

238 "pollution_kwargs": {"intensity": intensity}, 

239 } 

240 else: 

241 raise ValueError(f"Unsupported pollution type: {pollution_type}") 

242 

243 

244def evaluate( 

245 model: torch.nn.Module, 

246 loader: data.DataLoader, 

247 pollution_ratio: float, 

248 pollution_type: str, 

249 pollution_kwargs: dict, 

250) -> float: 

251 """Run model in eval mode and return average DiceLoss over the loader. 

252 

253 Args: 

254 model: Trained PyTorch model. 

255 loader: DataLoader to evaluate on. 

256 pollution_ratio: Fraction of images to pollute. 

257 pollution_type: Type of pollution to apply. 

258 pollution_kwargs: Additional keyword arguments for the pollution 

259 function. 

260 

261 Returns: 

262 Average DiceLoss as a float. 

263 """ 

264 model.eval() 

265 criterion = DiceLoss() 

266 total_loss, total_count = 0.0, 0 

267 

268 with torch.no_grad(): 

269 for inputs, targets in loader: 

270 inputs = apply_per_image_pollution( 

271 inputs, 

272 pollution_ratio=pollution_ratio, 

273 pollution_type=pollution_type, 

274 **pollution_kwargs, 

275 ) 

276 inputs, targets = inputs.to(device), targets.to(device) 

277 outputs = model(inputs) 

278 loss = criterion(outputs, targets.unsqueeze(1).float() / 255.0) 

279 batch_size = inputs.size(0) 

280 total_loss += loss.item() * batch_size 

281 total_count += batch_size 

282 

283 return total_loss / total_count if total_count else 0.0 

284 

285 

286############################################################################## 

287# TRAIN & FINAL-EVAL 

288############################################################################## 

289def train_and_evaluate( 

290 train_perc: float, 

291 test_perc: float, 

292 intensity: float | tuple[float, float], 

293 seed: int, 

294) -> tuple[float, float, float]: 

295 """Train a UNet and evaluate on val and test sets. 

296 

297 Args: 

298 train_perc: Fraction of training images to pollute. 

299 test_perc: Fraction of test images to pollute during evaluation. 

300 intensity: Pollution intensity (float or tuple for blob). 

301 seed: Random seed for reproducibility. 

302 

303 Returns: 

304 Tuple of (final_val_loss, final_test_loss, final_train_loss). 

305 """ 

306 fix_random_seeds(seed) 

307 

308 model = UNet(in_ch=2, out_ch=1, init_features=128, dropout_prob=0.2).to(device) 

309 criterion = DiceLoss() 

310 optimizer = optim.Adam(model.parameters(), lr=LR) 

311 

312 config = get_pollution_config(SELECTED_POLLUTION_TYPE, intensity) 

313 pollution_type = config["pollution_type"] 

314 pollution_kwargs = config["pollution_kwargs"] 

315 

316 model.train() 

317 for _epoch in range(NUM_EPOCHS): 

318 for inputs, targets in train_loader_full: 

319 inputs = apply_per_image_pollution( 

320 inputs, 

321 pollution_ratio=train_perc, 

322 pollution_type=pollution_type, 

323 **pollution_kwargs, 

324 ) 

325 inputs, targets = inputs.to(device), targets.to(device) 

326 

327 optimizer.zero_grad() 

328 outputs = model(inputs) 

329 loss = criterion(outputs, targets.unsqueeze(1).float() / 255.0) 

330 loss.backward() 

331 optimizer.step() 

332 

333 final_val_loss = evaluate( 

334 model, 

335 val_loader_full, 

336 pollution_ratio=train_perc, 

337 pollution_type=pollution_type, 

338 pollution_kwargs=pollution_kwargs, 

339 ) 

340 final_test_loss = evaluate( 

341 model, 

342 test_loader_full, 

343 pollution_ratio=test_perc, 

344 pollution_type=pollution_type, 

345 pollution_kwargs=pollution_kwargs, 

346 ) 

347 final_train_loss = evaluate( 

348 model, 

349 train_loader_full, 

350 pollution_ratio=train_perc, 

351 pollution_type=pollution_type, 

352 pollution_kwargs=pollution_kwargs, 

353 ) 

354 

355 return final_val_loss, final_test_loss, final_train_loss 

356 

357 

358############################################################################## 

359# MAIN LOOP 

360############################################################################## 

361results = [] 

362 

363for train_perc in TRAIN_PERC_RANGE: 

364 for test_perc in TEST_PERC_RANGE: 

365 for intensity in INTENSITY_RANGE: 

366 val_losses_seeds = [] 

367 test_losses_seeds = [] 

368 train_losses_seeds = [] 

369 print(f"Processing: {train_perc}, {test_perc}, {intensity}") 

370 

371 for seed in range(NUM_SEEDS): 

372 print("\t seed:", seed) 

373 start = time.time() 

374 

375 val_loss, test_loss, train_loss = train_and_evaluate( 

376 train_perc, test_perc, intensity=intensity, seed=seed 

377 ) 

378 val_losses_seeds.append(val_loss) 

379 test_losses_seeds.append(test_loss) 

380 train_losses_seeds.append(train_loss) 

381 print("\t elapsed time:", time.time() - start) 

382 

383 avg_val_loss = np.mean(val_losses_seeds) 

384 std_val_loss = np.std(val_losses_seeds) 

385 avg_test_loss = np.mean(test_losses_seeds) 

386 std_test_loss = np.std(test_losses_seeds) 

387 avg_train_loss = np.mean(train_losses_seeds) 

388 std_train_loss = np.std(train_losses_seeds) 

389 

390 results.append({ 

391 "train_perc": train_perc, 

392 "test_perc": test_perc, 

393 "intensity": intensity, 

394 "avg_test_loss": avg_test_loss, 

395 "std_test_loss": std_test_loss, 

396 "avg_val_loss": avg_val_loss, 

397 "std_val_loss": std_val_loss, 

398 "avg_train_loss": avg_train_loss, 

399 "std_train_loss": std_train_loss, 

400 "test_losses_seeds": test_losses_seeds, 

401 }) 

402 

403 df = pd.DataFrame(results) 

404 df.to_csv(f"{NAMING_CONSTANT}.csv", index=False) 

405 

406print("DONE!")