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
« 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
12from main.ui.nifti_viewer import NiftiViewer, compute_mask_numba_mm, apply_overlay_numba
14app = QApplication(sys.argv)
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)
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)
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)
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)
37 @classmethod
38 def tearDownClass(cls):
39 cls.temp_dir.cleanup()
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)
47 def tearDown(self):
48 self.viewer.close()
49 QTest.qWait(100)
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
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)
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)
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")
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")
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")
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)
92 result = apply_overlay_numba(rgba_image, overlay_mask, overlay_intensity, overlay_color)
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")
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")
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")
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)
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")
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)
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")
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")
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")
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")
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]
147 coords = self.viewer.screen_to_image_coords(0, 10.0, 10.0)
148 self.assertEqual(coords, [10, 9, 10], "Axial coordinates incorrect")
150 coords = self.viewer.screen_to_image_coords(1, 10.0, 10.0)
151 self.assertEqual(coords, [10, 10, 9], "Coronal coordinates incorrect")
153 coords = self.viewer.screen_to_image_coords(2, 10.0, 10.0)
154 self.assertEqual(coords, [10, 10, 9], "Sagittal coordinates incorrect")
156 coords = self.viewer.screen_to_image_coords(0, -1.0, 25.0)
157 self.assertEqual(coords, [0, 0, 10], "Coordinate clamping incorrect")
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")
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")
168 coords = self.viewer.screen_to_image_coords(3, 10.0, 10.0)
169 self.assertIsNone(coords, "Invalid view index should return None")
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")
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")
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")
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)
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)
205 self.viewer.automaticROI_drawing()
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")
211 # Additional tests to improve coverage
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)
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")
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()
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")
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()
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()
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")
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])
259 self.viewer.automaticROIbtn.setEnabled(True)
260 QTest.mouseClick(self.viewer.automaticROIbtn, Qt.MouseButton.LeftButton)
261 QTest.qWait(100)
263 self.assertTrue(self.viewer.automaticROI_overlay)
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)
276 self.viewer.file_path = self.test_nii_path
278 if self.viewer.context is None:
279 self.viewer.context = {}
280 self.viewer.context["workspace_path"] = self.temp_dir.name
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()
287 mock_exec.return_value = QMessageBox.StandardButton.Yes
288 mock_question.return_value = QMessageBox.StandardButton.Yes
290 self.viewer.ROI_save_btn.setEnabled(True)
291 QTest.mouseClick(self.viewer.ROI_save_btn, Qt.MouseButton.LeftButton)
293 QTest.qWait(100)
295 self.assertTrue(mock_makedirs.called, "os.makedirs dovrebbe essere chiamato")
297 self.assertTrue(mock_thread_start.called, "Il thread di salvataggio dovrebbe essere avviato")
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")
306 def test_colormap_changed(self):
307 # Simulate changing colormap
308 self.viewer.colormap_combo.setCurrentText('viridis')
309 QTest.qWait(500)
311 self.assertEqual(self.viewer.colormap, 'viridis', "Colormap should be changed")
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()
320 # Change slice for axial
321 self.viewer.slice_sliders[0].setValue(5)
322 QTest.qWait(500)
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")
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()
334 # Change time
335 self.viewer.time_slider.setValue(5)
336 QTest.qWait(500)
338 self.assertEqual(self.viewer.current_time, 5, "Time should be changed")
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()
347 # Call update_coordinates for axial view
348 self.viewer.update_coordinates(0, 10.0, 10.0)
349 QTest.qWait(500)
351 self.assertTrue(self.viewer.coord_label.text().startswith("Coordinates:"), "Coordinate label should be updated")
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()
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())
365 self.viewer.update_cross_view_lines()
366 QTest.qWait(500)
368 for mock_crosshair in mock_crosshair_positions:
369 self.assertTrue(mock_crosshair.called, "Crosshair position should be updated")
371 # Clean up mocks
372 for _ in mock_crosshair_positions:
373 patch.stopall()
375 def test_translate_ui(self):
376 # Call _translate_ui
377 self.viewer._translate_ui()
378 QTest.qWait(500)
380 self.assertEqual(self.viewer.windowTitle(), "NIfTI Image Viewer", "Window title should be translated")
382 def test_close_event(self):
383 # Simulate close event
384 mock_event = MagicMock()
385 self.viewer.closeEvent(mock_event)
386 QTest.qWait(500)
388 self.assertTrue(mock_event.accept.called, "Close event should be accepted")
389 self.assertIsNone(self.viewer.img_data, "Image data should be cleared")
392if __name__ == '__main__':
393 unittest.main()