Coverage for tests/ui/test_nifti_viewer.py: 99%

287 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-03 15:51 +0000

1import sys 

2import os 

3import unittest 

4import numpy as np 

5import nibabel as nib 

6import tempfile 

7from PyQt6.QtWidgets import QApplication, QMessageBox 

8from PyQt6.QtTest import QTest 

9from PyQt6.QtCore import Qt, QEventLoop, QTimer 

10from unittest.mock import patch, MagicMock 

11 

12from main.ui.nifti_viewer import NiftiViewer, compute_mask_numba_mm, apply_overlay_numba 

13 

14app = QApplication(sys.argv) 

15 

16 

17class TestNiftiViewer(unittest.TestCase): 

18 @classmethod 

19 def setUpClass(cls): 

20 cls.temp_dir = tempfile.TemporaryDirectory() 

21 os.makedirs(os.path.join(cls.temp_dir.name,'sub-01'), exist_ok=True) 

22 cls.test_nii_path = os.path.join(cls.temp_dir.name,'sub-01', 'test.nii') 

23 img = nib.Nifti1Image(np.random.rand(20, 20, 20), np.eye(4)) 

24 nib.save(img, cls.test_nii_path) 

25 

26 cls.test_4d_nii_path = os.path.join(cls.temp_dir.name,'sub-01', 'test4d.nii') 

27 img4d = nib.Nifti1Image(np.random.rand(20, 20, 20, 10), np.eye(4)) 

28 nib.save(img4d, cls.test_4d_nii_path) 

29 

30 cls.test_overlay_path = os.path.join(cls.temp_dir.name,'sub-01','overlay.nii') 

31 overlay = nib.Nifti1Image(np.random.rand(20, 20, 20), np.eye(4)) 

32 nib.save(overlay, cls.test_overlay_path) 

33 

34 cls.test_save_dir = os.path.join(cls.temp_dir.name, 'derivatives/manual_masks/sub-01/anat') 

35 os.makedirs(cls.test_save_dir, exist_ok=True) 

36 

37 @classmethod 

38 def tearDownClass(cls): 

39 cls.temp_dir.cleanup() 

40 

41 def setUp(self): 

42 self.context = {"workspace_path": self.temp_dir.name} 

43 self.viewer = NiftiViewer(context=self.context) 

44 self.viewer.show() 

45 QTest.qWaitForWindowActive(self.viewer) 

46 

47 def tearDown(self): 

48 self.viewer.close() 

49 QTest.qWait(100) 

50 

51 def test_compute_mask_numba_mm(self): 

52 img = np.ones((20, 20, 20)) * 100 

53 img[10, 10, 10] = 100 

54 x0, y0, z0 = 10, 10, 10 

55 radius_mm = 3.0 

56 voxel_sizes = (1.0, 1.0, 1.0) # Use tuple for function call 

57 seed_intensity = 100 

58 diff = 5 

59 x_min, x_max = 7, 13 

60 y_min, y_max = 7, 13 

61 z_min, z_max = 7, 13 

62 

63 mask = compute_mask_numba_mm(img, x0, y0, z0, radius_mm, voxel_sizes, 

64 seed_intensity, diff, x_min, x_max, y_min, y_max, z_min, z_max) 

65 

66 self.assertEqual(mask.dtype, np.uint8) 

67 self.assertEqual(mask.shape, (20, 20, 20)) 

68 indices = np.indices((20, 20, 20)) 

69 seed_point = np.array([x0, y0, z0]).reshape(3, 1, 1, 1) 

70 

71 

72 mask = compute_mask_numba_mm(img, x0, y0, z0, 0.0, voxel_sizes, 

73 seed_intensity, diff, x_min, x_max, y_min, y_max, z_min, z_max) 

74 self.assertEqual(np.sum(mask), 1, "Zero radius should produce single voxel mask") 

75 

76 img[10, 11, 10] = 150 

77 mask = compute_mask_numba_mm(img, x0, y0, z0, radius_mm, voxel_sizes, 

78 seed_intensity, 50, x_min, x_max, y_min, y_max, z_min, z_max) 

79 self.assertTrue(mask[10, 11, 10], "Voxel with intensity difference should be included") 

80 

81 mask = compute_mask_numba_mm(img, 0, 0, 0, 2.0, voxel_sizes, 

82 seed_intensity, diff, 0, 3, 0, 3, 0, 3) 

83 self.assertGreater(np.sum(mask), 0, "Boundary seed should produce non-empty mask") 

84 

85 def test_apply_overlay_numba(self): 

86 rgba_image = np.zeros((10, 10, 4), dtype=np.float64) 

87 overlay_mask = np.zeros((10, 10), dtype=bool) 

88 overlay_mask[5, 5] = True 

89 overlay_intensity = np.ones((10, 10)) * 0.5 

90 overlay_color = (1.0, 0.0, 0.0) 

91 

92 result = apply_overlay_numba(rgba_image, overlay_mask, overlay_intensity, overlay_color) 

93 

94 self.assertEqual(result[5, 5, 0], 0.5, "Red channel should reflect overlay intensity") 

95 self.assertEqual(result[5, 5, 1], 0.0, "Green channel should be zero for red overlay") 

96 self.assertEqual(result[5, 5, 2], 0.0, "Blue channel should be zero for red overlay") 

97 self.assertEqual(result[5, 5, 3], 0.0, "Alpha channel should remain zero") 

98 self.assertEqual(np.sum(result[:, :, 0] > 0), 1, "Only one pixel should be affected") 

99 

100 overlay_intensity = np.ones((10, 10)) 

101 result = apply_overlay_numba(rgba_image, overlay_mask, overlay_intensity, overlay_color) 

102 self.assertEqual(result[5, 5, 0], 1.0, "Red channel should be fully saturated") 

103 self.assertEqual(result[5, 5, 1], 0.0, "Green channel should be zero") 

104 self.assertEqual(result[5, 5, 2], 0.0, "Blue channel should be zero") 

105 

106 overlay_mask = np.zeros((10, 10), dtype=bool) 

107 result = apply_overlay_numba(rgba_image, overlay_mask, overlay_intensity, overlay_color) 

108 self.assertTrue(np.all(result == rgba_image), "No overlay should leave image unchanged") 

109 

110 rgba_image = np.zeros((10, 10, 4), dtype=np.float64) 

111 overlay_color = (0.0, 1.0, 0.0) 

112 overlay_intensity = np.ones((10, 10)) * 0.5 

113 overlay_mask[5, 5] = True 

114 result = apply_overlay_numba(rgba_image, overlay_mask, overlay_intensity, overlay_color) 

115 

116 self.assertEqual(result[5, 5, 0], 0.0, "Red channel should be zero for green overlay") 

117 self.assertEqual(result[5, 5, 1], 0.5, "Green channel should reflect overlay intensity") 

118 self.assertEqual(result[5, 5, 2], 0.0, "Blue channel should be zero for green overlay") 

119 

120 def test_pad_volume_to_shape(self): 

121 volume = np.ones((5, 5, 5)) 

122 target_shape = (7, 7, 7) 

123 padded = self.viewer.pad_volume_to_shape(volume, target_shape, constant_value=0) 

124 

125 self.assertEqual(padded.shape, target_shape, "Padded shape incorrect") 

126 self.assertEqual(np.sum(padded[1:6, 1:6, 1:6]), 125, "Core volume values incorrect") 

127 self.assertEqual(np.sum(padded == 0), 343 - 125, "Padding values incorrect") 

128 

129 padded = self.viewer.pad_volume_to_shape(volume, (5, 5, 5)) 

130 self.assertTrue(np.array_equal(volume, padded), "No padding should return original volume") 

131 

132 target_shape = (10, 8, 6) 

133 padded = self.viewer.pad_volume_to_shape(volume, target_shape) 

134 self.assertEqual(padded.shape, target_shape, "Asymmetric padded shape incorrect") 

135 self.assertEqual(np.sum(padded[2:7, 1:6, 0:5]), 125, "Asymmetric core volume values incorrect") 

136 

137 target_shape = (3, 3, 3) 

138 padded = self.viewer.pad_volume_to_shape(volume, target_shape) 

139 self.assertEqual(padded.shape, (5, 5, 5), "Smaller target shape should return original volume") 

140 self.assertEqual(np.sum(padded == 1), 125, "Smaller target volume should retain original values") 

141 

142 def test_screen_to_image_coords(self): 

143 self.viewer.img_data = np.zeros((20, 20, 20)) 

144 self.viewer.dims = (20, 20, 20) 

145 self.viewer.current_slices = [10, 10, 10] 

146 

147 coords = self.viewer.screen_to_image_coords(0, 10.0, 10.0) 

148 self.assertEqual(coords, [10, 9, 10], "Axial coordinates incorrect") 

149 

150 coords = self.viewer.screen_to_image_coords(1, 10.0, 10.0) 

151 self.assertEqual(coords, [10, 10, 9], "Coronal coordinates incorrect") 

152 

153 coords = self.viewer.screen_to_image_coords(2, 10.0, 10.0) 

154 self.assertEqual(coords, [10, 10, 9], "Sagittal coordinates incorrect") 

155 

156 coords = self.viewer.screen_to_image_coords(0, -1.0, 25.0) 

157 self.assertEqual(coords, [0, 0, 10], "Coordinate clamping incorrect") 

158 

159 self.viewer.img_data = None 

160 coords = self.viewer.screen_to_image_coords(0, 10.0, 10.0) 

161 self.assertIsNone(coords, "Should return None when no image data") 

162 

163 self.viewer.img_data = np.zeros((20, 20, 20)) 

164 self.viewer.stretch_factors[0] = (2.0, 0.5) 

165 coords = self.viewer.screen_to_image_coords(0, 20.0, 5.0) 

166 self.assertEqual(coords, [10, 9, 10], "Stretch factor coordinates incorrect") 

167 

168 coords = self.viewer.screen_to_image_coords(3, 10.0, 10.0) 

169 self.assertIsNone(coords, "Invalid view index should return None") 

170 

171 def test_format_info_text(self): 

172 long_text = "File: very_long_filename_with_details.nii.gz\nDimensions: 128x128x128" 

173 formatted = self.viewer.format_info_text(long_text, max_line_length=20) 

174 lines = formatted.split('\n') 

175 self.assertTrue(all(len(line) <= 22 for line in lines), "Formatted lines too long") 

176 self.assertIn("File:", lines[0], "File line formatting incorrect") 

177 self.assertTrue(any("very_long_filename" in line for line in lines), "Filename not found in formatted text") 

178 self.assertTrue(any("128x128x128" in line for line in lines), "Dimensions not found in formatted text") 

179 

180 short_text = "File: test.nii" 

181 formatted = self.viewer.format_info_text(short_text) 

182 self.assertEqual(formatted, short_text, "Short text should remain unchanged") 

183 

184 def test_init_ui(self): 

185 self.assertIsNotNone(self.viewer.status_bar, "Status bar not initialized") 

186 self.assertIsNotNone(self.viewer.open_btn, "Open button not initialized") 

187 self.assertIsNotNone(self.viewer.overlay_btn, "Overlay button not initialized") 

188 self.assertEqual(len(self.viewer.views), 3, "Incorrect number of views") 

189 

190 @patch('components.nifti_file_dialog.NiftiFileDialog.get_files') 

191 def test_open_file_button(self, mock_get_files): 

192 mock_get_files.return_value = [self.test_nii_path] 

193 QTest.mouseClick(self.viewer.open_btn, Qt.MouseButton.LeftButton) 

194 QTest.qWait(500) 

195 self.assertIsNotNone(self.viewer.img_data) 

196 

197 def test_automaticROI_drawing(self): 

198 self.viewer.img_data = np.ones((20, 20, 20)) * 100 

199 self.viewer.dims = (20, 20, 20) 

200 self.viewer.voxel_sizes = np.array([1.0, 1.0, 1.0]) 

201 self.viewer.automaticROI_seed_coordinates = [10, 10, 10] 

202 self.viewer.automaticROI_radius_slider.setValue(3) 

203 self.viewer.automaticROI_diff_slider.setValue(10) 

204 

205 self.viewer.automaticROI_drawing() 

206 

207 self.assertIsNotNone(self.viewer.automaticROI_data, "Overlay data not generated") 

208 self.assertEqual(self.viewer.automaticROI_data.dtype, np.uint8, "Overlay data type incorrect") 

209 self.assertGreater(np.sum(self.viewer.automaticROI_data), 0, "Overlay mask should be non-empty") 

210 

211 # Additional tests to improve coverage 

212 

213 @patch('components.nifti_file_dialog.NiftiFileDialog.get_files') 

214 def test_load_file(self, mock_get_files): 

215 mock_get_files.return_value = [self.test_nii_path] 

216 self.viewer.open_file() 

217 QTest.qWait(500) 

218 

219 self.assertIsNotNone(self.viewer.img_data, "Image data should be loaded after file load") 

220 self.assertEqual(self.viewer.dims, (20, 20, 20), "Dimensions incorrect") 

221 self.assertFalse(self.viewer.is_4d, "Should be 3D data") 

222 

223 @patch('components.nifti_file_dialog.NiftiFileDialog.get_files') 

224 def test_load_4d_file(self, mock_get_files): 

225 mock_get_files.return_value = [self.test_4d_nii_path] 

226 self.viewer.open_file() 

227 loop = QEventLoop() 

228 QTimer.singleShot(1000, loop.quit) 

229 loop.exec() 

230 

231 self.assertIsNotNone(self.viewer.img_data, "4D image data should be loaded") 

232 self.assertEqual(self.viewer.dims, (20, 20, 20, 10), "4D dimensions incorrect") 

233 self.assertTrue(self.viewer.is_4d, "Should be 4D data") 

234 self.assertTrue(self.viewer.time_group.isVisible(), "Time group should be visible for 4D") 

235 

236 @patch('components.nifti_file_dialog.NiftiFileDialog.get_files') 

237 def test_load_overlay(self, mock_get_files): 

238 # Load base first 

239 mock_get_files.return_value = [self.test_nii_path] 

240 self.viewer.open_file() 

241 loop = QEventLoop() 

242 QTimer.singleShot(1000, loop.quit) 

243 loop.exec() 

244 

245 # Load overlay 

246 mock_get_files.return_value = [self.test_overlay_path] 

247 self.viewer.open_file(is_overlay=True) 

248 QTimer.singleShot(1000, loop.quit) 

249 loop.exec() 

250 

251 self.assertIsNotNone(self.viewer.overlay_data, "Overlay data should be loaded") 

252 self.assertTrue(self.viewer.overlay_checkbox.isChecked(), "Overlay checkbox should be checked") 

253 

254 def test_automaticROI_clicked(self): 

255 self.viewer.img_data = nib.load(self.test_nii_path).get_fdata() 

256 self.viewer.dims = self.viewer.img_data.shape 

257 self.viewer.voxel_sizes = np.array([1, 1, 1]) 

258 

259 self.viewer.automaticROIbtn.setEnabled(True) 

260 QTest.mouseClick(self.viewer.automaticROIbtn, Qt.MouseButton.LeftButton) 

261 QTest.qWait(100) 

262 

263 self.assertTrue(self.viewer.automaticROI_overlay) 

264 

265 @patch('PyQt6.QtWidgets.QMessageBox.exec') 

266 @patch('PyQt6.QtWidgets.QMessageBox.question') 

267 @patch('os.makedirs') 

268 @patch( 

269 'threads.nifti_utils_threads.SaveNiftiThread.start') # Mockiamo anche il thread di salvataggio per evitare errori 

270 def test_automaticROI_save(self, mock_thread_start, mock_makedirs, mock_question, mock_exec): 

271 self.viewer.img_data = np.ones((20, 20, 20)) 

272 self.viewer.dims = (20, 20, 20) 

273 self.viewer.voxel_sizes = np.array([1.0, 1.0, 1.0]) 

274 self.viewer.affine = np.eye(4) 

275 

276 self.viewer.file_path = self.test_nii_path 

277 

278 if self.viewer.context is None: 

279 self.viewer.context = {} 

280 self.viewer.context["workspace_path"] = self.temp_dir.name 

281 

282 self.viewer.automaticROI_seed_coordinates = [10, 10, 10] 

283 self.viewer.automaticROI_overlay = True 

284 self.viewer.automaticROI_radius_slider.setValue(3) 

285 self.viewer.automaticROI_drawing() 

286 

287 mock_exec.return_value = QMessageBox.StandardButton.Yes 

288 mock_question.return_value = QMessageBox.StandardButton.Yes 

289 

290 self.viewer.ROI_save_btn.setEnabled(True) 

291 QTest.mouseClick(self.viewer.ROI_save_btn, Qt.MouseButton.LeftButton) 

292 

293 QTest.qWait(100) 

294 

295 self.assertTrue(mock_makedirs.called, "os.makedirs dovrebbe essere chiamato") 

296 

297 self.assertTrue(mock_thread_start.called, "Il thread di salvataggio dovrebbe essere avviato") 

298 

299 def test_resize_event(self): 

300 with patch.object(self.viewer.views[0], 'fitInView') as mock_fitInView: 

301 QTest.qWait(500) 

302 self.viewer.resize(1200, 800) 

303 QTest.qWait(500) 

304 self.assertTrue(mock_fitInView.called, "Views should be fitted on resize") 

305 

306 def test_colormap_changed(self): 

307 # Simulate changing colormap 

308 self.viewer.colormap_combo.setCurrentText('viridis') 

309 QTest.qWait(500) 

310 

311 self.assertEqual(self.viewer.colormap, 'viridis', "Colormap should be changed") 

312 

313 def test_slice_changed(self): 

314 # Load data 

315 self.viewer.open_file(self.test_nii_path) 

316 loop = QEventLoop() 

317 QTimer.singleShot(1000, loop.quit) 

318 loop.exec() 

319 

320 # Change slice for axial 

321 self.viewer.slice_sliders[0].setValue(5) 

322 QTest.qWait(500) 

323 

324 self.assertEqual(self.viewer.current_slices[0], 5, "Slice should be changed") 

325 self.assertEqual(self.viewer.current_coordinates[2], 5, "Coordinates should be updated") 

326 

327 def test_time_changed(self): 

328 # Load 4D data 

329 self.viewer.open_file(self.test_4d_nii_path) 

330 loop = QEventLoop() 

331 QTimer.singleShot(1000, loop.quit) 

332 loop.exec() 

333 

334 # Change time 

335 self.viewer.time_slider.setValue(5) 

336 QTest.qWait(500) 

337 

338 self.assertEqual(self.viewer.current_time, 5, "Time should be changed") 

339 

340 def test_update_coordinates(self): 

341 # Load data 

342 self.viewer.open_file(self.test_nii_path) 

343 loop = QEventLoop() 

344 QTimer.singleShot(1000, loop.quit) 

345 loop.exec() 

346 

347 # Call update_coordinates for axial view 

348 self.viewer.update_coordinates(0, 10.0, 10.0) 

349 QTest.qWait(500) 

350 

351 self.assertTrue(self.viewer.coord_label.text().startswith("Coordinates:"), "Coordinate label should be updated") 

352 

353 def test_update_cross_view_lines(self): 

354 self.viewer.open_file(self.test_nii_path) 

355 loop = QEventLoop() 

356 QTimer.singleShot(1000, loop.quit) 

357 loop.exec() 

358 

359 # Mock set_crosshair_position for all views 

360 mock_crosshair_positions = [] 

361 for view in self.viewer.views: 

362 mock_crosshair = patch.object(view, 'set_crosshair_position') 

363 mock_crosshair_positions.append(mock_crosshair.start()) 

364 

365 self.viewer.update_cross_view_lines() 

366 QTest.qWait(500) 

367 

368 for mock_crosshair in mock_crosshair_positions: 

369 self.assertTrue(mock_crosshair.called, "Crosshair position should be updated") 

370 

371 # Clean up mocks 

372 for _ in mock_crosshair_positions: 

373 patch.stopall() 

374 

375 def test_translate_ui(self): 

376 # Call _translate_ui 

377 self.viewer._translate_ui() 

378 QTest.qWait(500) 

379 

380 self.assertEqual(self.viewer.windowTitle(), "NIfTI Image Viewer", "Window title should be translated") 

381 

382 def test_close_event(self): 

383 # Simulate close event 

384 mock_event = MagicMock() 

385 self.viewer.closeEvent(mock_event) 

386 QTest.qWait(500) 

387 

388 self.assertTrue(mock_event.accept.called, "Close event should be accepted") 

389 self.assertIsNone(self.viewer.img_data, "Image data should be cleared") 

390 

391 

392if __name__ == '__main__': 

393 unittest.main()