Coverage for tests/threads/test_nifti_utils_threads.py: 99%
757 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
1"""
2test_save_load_threads.py - Test Suite for SaveNiftiThread and ImageLoadThread
4This suite tests all functionalities of the saving and loading threads:
5- SaveNiftiThread: NIfTI saving + JSON with BIDS metadata
6- ImageLoadThread: loading and normalization of 3D/4D NIfTI
7- Error handling and progress tracking
8- Intensity normalization with percentiles
9"""
11import json
12import os
13import tempfile
14import shutil
15from unittest.mock import Mock, patch, MagicMock
16import pytest
17import numpy as np
18import nibabel as nib
20from main.threads.nifti_utils_threads import SaveNiftiThread, ImageLoadThread
23class TestSaveNiftiThreadInitialization:
24 """Tests for SaveNiftiThread initialization"""
26 def test_init_with_all_parameters(self, temp_workspace):
27 """Test initialization with all parameters"""
28 data = np.random.rand(10, 10, 10)
29 affine = np.eye(4)
30 path = os.path.join(temp_workspace, "output.nii.gz")
31 json_path = os.path.join(temp_workspace, "output.json")
32 relative_path = "sub-01/anat/T1w.nii.gz"
33 radius = 5.0
34 difference = 0.3
35 source_dict = {"radius": radius, "difference": difference}
37 thread = SaveNiftiThread(
38 data=data,
39 affine=affine,
40 path=path,
41 json_path=json_path,
42 relative_path=relative_path,
43 source_dict=source_dict
44 )
46 assert thread.data is data
47 assert np.array_equal(thread.affine, affine)
48 assert thread.path == path
49 assert thread.json_path == json_path
50 assert thread.relative_path == relative_path
51 assert thread.source_dict["radius"] == radius
52 assert thread.source_dict["difference"] == difference
54 def test_signals_exist(self, temp_workspace):
55 """Verify that the signals are correctly defined"""
56 data = np.zeros((5, 5, 5))
57 affine = np.eye(4)
58 source_dict = {"radius": 1.0, "difference": 0.1}
59 thread = SaveNiftiThread(
60 data, affine, "path.nii", "path.json", "rel/path.nii", source_dict
61 )
63 assert hasattr(thread, 'success')
64 assert hasattr(thread, 'error')
67class TestSaveNiftiThreadExecution:
68 """Tests for SaveNiftiThread execution"""
70 def test_successful_save(self, temp_workspace):
71 """Test successful saving of NIfTI and JSON"""
72 # Test data
73 data = np.random.randint(0, 255, (10, 10, 10), dtype=np.uint8)
74 affine = np.eye(4)
75 affine[0, 0] = 2.0 # Voxel size
77 nifti_path = os.path.join(temp_workspace, "mask.nii.gz")
78 json_path = os.path.join(temp_workspace, "mask.json")
79 relative_path = "sub-01/anat/T1w.nii.gz"
81 radius = 3.5
82 difference = 0.25
83 source_dict = {"radius": radius, "difference": difference}
85 thread = SaveNiftiThread(
86 data=data,
87 affine=affine,
88 path=nifti_path,
89 json_path=json_path,
90 relative_path=relative_path,
91 source_dict=source_dict
92 )
94 # Connect signal
95 success_paths = []
97 def on_success(path, json_p):
98 success_paths.append((path, json_p))
100 thread.success.connect(on_success)
102 # Execute
103 thread.run()
105 # Verify signal emitted
106 assert len(success_paths) == 1
107 assert success_paths[0][0] == nifti_path
108 assert success_paths[0][1] == json_path
110 # Verify NIfTI file created
111 assert os.path.exists(nifti_path)
112 loaded_img = nib.load(nifti_path)
113 loaded_data = loaded_img.get_fdata()
114 assert loaded_data.shape == data.shape
115 np.testing.assert_array_almost_equal(loaded_data, data, decimal=0)
117 # Verify affine
118 np.testing.assert_array_almost_equal(loaded_img.affine, affine)
120 def test_json_metadata_structure(self, temp_workspace):
121 """Test structure and content of the JSON file"""
122 data = np.ones((5, 5, 5), dtype=np.uint8)
123 affine = np.eye(4)
125 nifti_path = os.path.join(temp_workspace, "roi.nii.gz")
126 json_path = os.path.join(temp_workspace, "roi.json")
127 relative_path = "sub-02/anat/brain.nii.gz"
128 radius = 4.2
129 difference = 0.15
130 source_dict = {"radius": radius, "difference": difference}
132 thread = SaveNiftiThread(
133 data, affine, nifti_path, json_path, relative_path, source_dict
134 )
135 thread.run()
137 # Verify JSON created
138 assert os.path.exists(json_path)
140 with open(json_path, 'r') as f:
141 metadata = json.load(f)
143 # Verify structure
144 assert "Type" in metadata
145 assert metadata["Type"] == "ROI"
147 assert "Sources" in metadata
148 assert isinstance(metadata["Sources"], list)
149 assert len(metadata["Sources"]) == 1
150 assert metadata["Sources"][0] == f"bids:{relative_path}"
152 assert "Description" in metadata
153 assert "roi mask" in metadata["Description"].lower()
155 assert "Origin" in metadata
156 params = metadata["Origin"]
157 assert params["radius"] == radius
158 assert params["difference"] == difference
160 def test_json_indentation(self, temp_workspace):
161 """Test that the JSON is formatted with indentation"""
162 data = np.zeros((3, 3, 3), dtype=np.uint8)
163 affine = np.eye(4)
165 nifti_path = os.path.join(temp_workspace, "test.nii")
166 json_path = os.path.join(temp_workspace, "test.json")
168 radius = 1.0
169 difference = 0.1
170 source_dict = {"radius": radius, "difference": difference}
172 thread = SaveNiftiThread(
173 data, affine, nifti_path, json_path, "path.nii", source_dict
174 )
175 thread.run()
177 with open(json_path, 'r') as f:
178 content = f.read()
180 # Verify indentation (formatted JSON)
181 assert " " in content # 4 spaces indentation
182 assert "\n" in content # Newlines between fields
184 def test_data_type_conversion_to_uint8(self, temp_workspace):
185 """Test automatic conversion of data to uint8"""
186 # Float data
187 data = np.random.rand(5, 5, 5) * 255
188 affine = np.eye(4)
190 nifti_path = os.path.join(temp_workspace, "converted.nii.gz")
191 json_path = os.path.join(temp_workspace, "converted.json")
193 source_dict = {"radius": 1.0, "difference": 0.1}
195 thread = SaveNiftiThread(
196 data, affine, nifti_path, json_path, "rel.nii", source_dict
197 )
198 thread.run()
200 # Verify saved data type
201 loaded_img = nib.load(nifti_path)
202 loaded_data = loaded_img.get_fdata()
204 # Should be converted to uint8
205 assert loaded_data.dtype in [np.uint8, np.float32, np.float64]
206 assert loaded_data.min() >= 0
207 assert loaded_data.max() <= 255
209 def test_error_handling_invalid_path(self, temp_workspace):
210 """Test error handling with invalid path"""
211 data = np.zeros((3, 3, 3))
212 affine = np.eye(4)
214 # Invalid path (nonexistent directory)
215 invalid_path = os.path.join(temp_workspace, "nonexistent", "dir", "file.nii")
216 json_path = os.path.join(temp_workspace, "nonexistent", "dir", "file.json")
218 source_dict = {"radius": 1.0, "difference": 0.1}
220 thread = SaveNiftiThread(
221 data, affine, invalid_path, json_path, "rel.nii", source_dict
222 )
224 error_msgs = []
225 thread.error.connect(lambda msg: error_msgs.append(msg))
227 thread.run()
229 # Should emit an error
230 assert len(error_msgs) == 1
231 assert len(error_msgs[0]) > 0
233 @patch('nibabel.save')
234 def test_error_during_nifti_save(self, mock_save, temp_workspace):
235 """Test error handling during NIfTI saving"""
236 mock_save.side_effect = IOError("Disk full")
238 data = np.zeros((3, 3, 3))
239 affine = np.eye(4)
241 nifti_path = os.path.join(temp_workspace, "fail.nii")
242 json_path = os.path.join(temp_workspace, "fail.json")
244 source_dict = {"radius": 1.0, "difference": 0.1}
246 thread = SaveNiftiThread(
247 data, affine, nifti_path, json_path, "rel.nii", source_dict
248 )
250 error_msgs = []
251 thread.error.connect(lambda msg: error_msgs.append(msg))
253 thread.run()
255 assert len(error_msgs) == 1
256 assert "Disk full" in error_msgs[0]
258 def test_different_affine_matrices(self, temp_workspace):
259 """Test with different affine matrices"""
260 data = np.ones((4, 4, 4), dtype=np.uint8)
262 # Affine with different voxel sizes
263 affine = np.diag([2.5, 3.0, 3.5, 1.0])
264 affine[:3, 3] = [10, 20, 30] # Offset
266 nifti_path = os.path.join(temp_workspace, "custom_affine.nii")
267 json_path = os.path.join(temp_workspace, "custom_affine.json")
269 source_dict = {"radius": 2.0, "difference": 0.2}
271 thread = SaveNiftiThread(
272 data, affine, nifti_path, json_path, "path.nii", source_dict
273 )
274 thread.run()
276 # Verify affine saved correctly
277 loaded_img = nib.load(nifti_path)
278 np.testing.assert_array_almost_equal(loaded_img.affine, affine)
281class TestImageLoadThreadInitialization:
282 """Tests for ImageLoadThread initialization"""
284 def test_init_regular_image(self, temp_workspace):
285 """Test initialization for regular image"""
286 file_path = os.path.join(temp_workspace, "test.nii")
287 thread = ImageLoadThread(file_path, is_overlay=False)
289 assert thread.file_path == file_path
290 assert thread.is_overlay is False
292 def test_init_overlay_image(self, temp_workspace):
293 """Test initialization for overlay image"""
294 file_path = os.path.join(temp_workspace, "overlay.nii.gz")
295 thread = ImageLoadThread(file_path, is_overlay=True)
297 assert thread.file_path == file_path
298 assert thread.is_overlay is True
300 def test_signals_exist(self, temp_workspace):
301 """Verify that the signals are correctly defined"""
302 thread = ImageLoadThread("dummy.nii", False)
304 assert hasattr(thread, 'finished')
305 assert hasattr(thread, 'error')
306 assert hasattr(thread, 'progress')
309class TestImageLoadThread3D:
310 """Tests for loading 3D NIfTI images"""
312 def test_load_3d_nifti_success(self, temp_workspace):
313 """Test successful loading of 3D NIfTI"""
314 # Create 3D test image
315 data_3d = np.random.rand(20, 20, 20).astype(np.float32) * 100
316 affine = np.eye(4)
317 img = nib.Nifti1Image(data_3d, affine)
319 nifti_path = os.path.join(temp_workspace, "test_3d.nii.gz")
320 nib.save(img, nifti_path)
322 thread = ImageLoadThread(nifti_path, is_overlay=False)
324 # Connect signal
325 results = []
327 def on_finished(img_data, dims, aff, is_4d, is_overlay):
328 results.append({
329 'img_data': img_data,
330 'dims': dims,
331 'affine': aff,
332 'is_4d': is_4d,
333 'is_overlay': is_overlay
334 })
336 thread.finished.connect(on_finished)
338 # Execute
339 thread.run()
341 # Verify results
342 assert len(results) == 1
343 result = results[0]
345 assert result['dims'] == (20, 20, 20)
346 assert result['is_4d'] is False
347 assert result['is_overlay'] is False
348 assert result['img_data'].shape == (20, 20, 20)
349 assert result['img_data'].dtype == np.float32
351 # Verify normalization (values between 0 and 1)
352 assert result['img_data'].min() >= 0
353 assert result['img_data'].max() <= 1
355 def test_load_3d_progress_emissions(self, temp_workspace):
356 """Test progress emissions during 3D loading"""
357 # Create simple image
358 data = np.ones((10, 10, 10))
359 img = nib.Nifti1Image(data, np.eye(4))
361 nifti_path = os.path.join(temp_workspace, "progress_test.nii")
362 nib.save(img, nifti_path)
364 thread = ImageLoadThread(nifti_path, False)
366 progress_values = []
367 thread.progress.connect(lambda val: progress_values.append(val))
369 thread.run()
371 # Verify progress emitted
372 assert len(progress_values) > 0
373 assert 10 in progress_values
374 assert 100 in progress_values
376 # Verify monotonic increase
377 for i in range(1, len(progress_values)):
378 assert progress_values[i] >= progress_values[i - 1]
381class TestImageLoadThread4D:
382 """Tests for loading 4D NIfTI images"""
384 def test_load_4d_nifti_success(self, temp_workspace):
385 """Test successful loading of 4D NIfTI"""
386 # Create 4D image (e.g., fMRI time series)
387 data_4d = np.random.rand(15, 15, 15, 10).astype(np.float32) * 50
388 affine = np.eye(4)
389 img = nib.Nifti1Image(data_4d, affine)
391 nifti_path = os.path.join(temp_workspace, "test_4d.nii.gz")
392 nib.save(img, nifti_path)
394 thread = ImageLoadThread(nifti_path, is_overlay=True)
396 results = []
397 thread.finished.connect(
398 lambda img_data, dims, aff, is_4d, is_overlay:
399 results.append((img_data, dims))
400 )
402 thread.run()
404 assert len(results) == 1
405 img_data, dims = results[0]
406 assert dims == (15, 15, 15, 10)
407 assert img_data.shape == (15, 15, 15, 10)
409 def test_anisotropic_voxels(self, temp_workspace):
410 """Test with anisotropic voxels"""
411 data = np.random.rand(10, 20, 30).astype(np.float32)
413 # Affine with different voxel sizes for each axis
414 affine = np.diag([0.5, 1.0, 2.0, 1.0])
416 img = nib.Nifti1Image(data, affine)
417 nifti_path = os.path.join(temp_workspace, "anisotropic.nii")
418 nib.save(img, nifti_path)
420 thread = ImageLoadThread(nifti_path, False)
422 results = []
423 thread.finished.connect(
424 lambda img_data, dims, aff, is_4d, is_overlay:
425 results.append((img_data, aff))
426 )
428 thread.run()
430 assert len(results) == 1
431 img_data, loaded_affine = results[0]
433 # Dimensions preserved (or reordered after canonicalization)
434 assert img_data.ndim == 3
435 assert isinstance(loaded_affine, np.ndarray)
437 def test_different_data_types(self, temp_workspace):
438 """Test with different input dtypes"""
439 dtypes_to_test = [np.uint8, np.int16, np.float32, np.float64]
441 for dtype in dtypes_to_test:
442 data = (np.random.rand(8, 8, 8) * 100).astype(dtype)
443 img = nib.Nifti1Image(data, np.eye(4))
445 nifti_path = os.path.join(temp_workspace, f"dtype_{dtype.__name__}.nii")
446 nib.save(img, nifti_path)
448 thread = ImageLoadThread(nifti_path, False)
450 results = []
451 thread.finished.connect(
452 lambda img_data, dims, aff, is_4d, is_overlay:
453 results.append(img_data)
454 )
456 thread.run()
458 # Should always normalize to float32
459 assert len(results) == 1
460 assert results[0].dtype == np.float32
461 assert results[0].min() >= 0
462 assert results[0].max() <= 1
464 def test_negative_values_handling(self, temp_workspace):
465 """Test handling of negative values"""
466 # Data with negative values (e.g., parametric maps)
467 data = np.random.randn(15, 15, 15).astype(np.float32)
469 img = nib.Nifti1Image(data, np.eye(4))
470 nifti_path = os.path.join(temp_workspace, "negative_values.nii")
471 nib.save(img, nifti_path)
473 thread = ImageLoadThread(nifti_path, False)
475 results = []
476 thread.finished.connect(
477 lambda img_data, dims, aff, is_4d, is_overlay:
478 results.append(img_data)
479 )
481 thread.run()
483 normalized = results[0]
485 # Even with negative values, normalization must be in [0, 1]
486 assert normalized.min() >= 0
487 assert normalized.max() <= 1
490class TestSaveLoadThreadsIntegration:
491 """Integration tests between SaveNiftiThread and ImageLoadThread"""
493 def test_save_and_reload_cycle(self, temp_workspace):
494 """Full cycle test: save and reload"""
495 # Original data
496 original_data = np.random.randint(0, 255, (12, 12, 12), dtype=np.uint8)
497 affine = np.eye(4)
498 affine[0, 0] = 2.0
500 # Save
501 nifti_path = os.path.join(temp_workspace, "cycle_test.nii.gz")
502 json_path = os.path.join(temp_workspace, "cycle_test.json")
504 radius = 2.5
505 difference = 0.2
506 source_dict = {"radius": radius, "difference": difference}
508 save_thread = SaveNiftiThread(
509 data=original_data,
510 affine=affine,
511 path=nifti_path,
512 json_path=json_path,
513 relative_path="sub-01/anat/T1w.nii.gz",
514 source_dict=source_dict
515 )
517 save_success = []
518 save_thread.success.connect(
519 lambda p, j: save_success.append(True)
520 )
522 save_thread.run()
523 assert len(save_success) == 1
525 # Reload
526 load_thread = ImageLoadThread(nifti_path, is_overlay=False)
528 loaded_results = []
529 load_thread.finished.connect(
530 lambda img_data, dims, aff, is_4d, is_overlay:
531 loaded_results.append((img_data, dims, aff))
532 )
534 load_thread.run()
536 assert len(loaded_results) == 1
537 loaded_data, loaded_dims, loaded_affine = loaded_results[0]
539 # Verify dimensions
540 assert loaded_dims == (12, 12, 12)
542 # Verify affine (may slightly differ after canonicalization)
543 # but voxel size should be similar
544 assert loaded_affine.shape == (4, 4)
546 # Verify JSON
547 assert os.path.exists(json_path)
548 with open(json_path, 'r') as f:
549 metadata = json.load(f)
550 assert metadata["Type"] == "ROI"
551 assert metadata["Origin"]["radius"] == 2.5
553 def test_save_load_multiple_files(self, temp_workspace):
554 """Test saving and loading multiple files"""
555 num_files = 5
556 saved_paths = []
558 for i in range(num_files):
559 data = np.random.randint(0, 100, (8, 8, 8), dtype=np.uint8)
560 nifti_path = os.path.join(temp_workspace, f"multi_{i}.nii")
561 json_path = os.path.join(temp_workspace, f"multi_{i}.json")
563 source_dict = {"radius": float(i), "difference": float(i) * 0.1}
565 save_thread = SaveNiftiThread(
566 data, np.eye(4), nifti_path, json_path,
567 f"path_{i}.nii", source_dict
568 )
569 save_thread.run()
570 saved_paths.append(nifti_path)
572 # Reload all
573 for path in saved_paths:
574 load_thread = ImageLoadThread(path, False)
576 results = []
577 load_thread.finished.connect(
578 lambda img_data, dims, aff, is_4d, is_overlay:
579 results.append(True)
580 )
582 load_thread.run()
583 assert len(results) == 1
585 def test_save_4d_reload_as_4d(self, temp_workspace):
586 """Test saving and reloading 4D data"""
587 # Create 4D data
588 data_4d = np.random.randint(0, 255, (10, 10, 10, 5), dtype=np.uint8)
589 affine = np.eye(4)
591 nifti_path = os.path.join(temp_workspace, "test_4d_cycle.nii.gz")
592 json_path = os.path.join(temp_workspace, "test_4d_cycle.json")
594 source_dict = {"radius": 3.0, "difference": 0.15}
596 # SaveNiftiThread should also handle 4D
597 save_thread = SaveNiftiThread(
598 data_4d, affine, nifti_path, json_path,
599 "rel_4d.nii", source_dict
600 )
602 save_success = []
603 save_thread.success.connect(lambda p, j: save_success.append(True))
604 save_thread.run()
606 assert len(save_success) == 1
608 # Reload as 4D
609 load_thread = ImageLoadThread(nifti_path, False)
611 results = []
612 load_thread.finished.connect(
613 lambda img_data, dims, aff, is_4d, is_overlay:
614 results.append((dims, is_4d))
615 )
617 load_thread.run()
619 dims, is_4d = results[0]
620 assert len(dims) == 4
621 assert is_4d is True
624class TestConcurrencyAndThreadSafety:
625 """Tests for concurrency and thread safety"""
627 def test_multiple_save_threads_concurrent(self, temp_workspace):
628 """Test concurrent execution of multiple SaveNiftiThread"""
629 threads = []
630 success_count = [0]
632 def on_success(p, j):
633 success_count[0] += 1
635 for i in range(3):
636 data = np.ones((5, 5, 5), dtype=np.uint8) * i
637 nifti_path = os.path.join(temp_workspace, f"concurrent_save_{i}.nii")
638 json_path = os.path.join(temp_workspace, f"concurrent_save_{i}.json")
640 source_dict = {"radius": 1.0, "difference": 0.1}
642 thread = SaveNiftiThread(
643 data, np.eye(4), nifti_path, json_path,
644 f"path_{i}.nii", source_dict
645 )
646 thread.success.connect(on_success)
647 threads.append(thread)
649 # Start all threads
650 for thread in threads:
651 thread.run()
653 # Wait for completion
654 for thread in threads:
655 thread.wait(5000) # 5-second timeout
657 assert success_count[0] == 3
659 def test_multiple_load_threads_concurrent(self, temp_workspace):
660 """Test concurrent loading of multiple files"""
661 # Create test files
662 file_paths = []
663 for i in range(3):
664 data = np.random.rand(6, 6, 6).astype(np.float32)
665 img = nib.Nifti1Image(data, np.eye(4))
666 path = os.path.join(temp_workspace, f"concurrent_load_{i}.nii")
667 nib.save(img, path)
668 file_paths.append(path)
670 threads = []
671 finished_count = [0]
673 def on_finished(img_data, dims, aff, is_4d, is_overlay):
674 finished_count[0] += 1
676 for path in file_paths:
677 thread = ImageLoadThread(path, False)
678 thread.finished.connect(on_finished)
679 threads.append(thread)
681 # Start all
682 for thread in threads:
683 thread.run()
685 # Wait
686 for thread in threads:
687 thread.wait(5000)
689 assert finished_count[0] == 3
692class TestSpecialCases:
693 """Tests for special cases and boundary conditions"""
695 def test_save_zero_volume(self, temp_workspace):
696 """Test saving a volume with all zeros"""
697 data = np.zeros((10, 10, 10), dtype=np.uint8)
698 nifti_path = os.path.join(temp_workspace, "all_zeros.nii")
699 json_path = os.path.join(temp_workspace, "all_zeros.json")
701 source_dict = {"radius": 1.0, "difference": 0.1}
703 thread = SaveNiftiThread(
704 data, np.eye(4), nifti_path, json_path,
705 "zeros.nii", source_dict
706 )
708 success = []
709 thread.success.connect(lambda p, j: success.append(True))
710 thread.run()
712 assert len(success) == 1
713 assert os.path.exists(nifti_path)
715 def test_load_zero_volume(self, temp_workspace):
716 """Test loading a volume with all zeros"""
717 data = np.zeros((8, 8, 8))
718 img = nib.Nifti1Image(data, np.eye(4))
720 nifti_path = os.path.join(temp_workspace, "zeros_load.nii")
721 nib.save(img, nifti_path)
723 thread = ImageLoadThread(nifti_path, False)
725 results = []
726 thread.finished.connect(
727 lambda img_data, dims, aff, is_4d, is_overlay:
728 results.append(img_data)
729 )
731 thread.run()
733 # Should handle it correctly
734 assert len(results) == 1
735 normalized = results[0]
736 assert not np.any(np.isnan(normalized))
738 def test_save_binary_mask(self, temp_workspace):
739 """Test saving a binary mask"""
740 # Binary mask (0 and 1)
741 mask = np.random.randint(0, 2, (15, 15, 15), dtype=np.uint8)
743 nifti_path = os.path.join(temp_workspace, "binary_mask.nii.gz")
744 json_path = os.path.join(temp_workspace, "binary_mask.json")
746 source_dict = {"radius": 0.0, "difference": 0.0}
748 thread = SaveNiftiThread(
749 mask, np.eye(4), nifti_path, json_path,
750 "mask.nii", source_dict
751 )
753 success = []
754 thread.success.connect(lambda p, j: success.append(True))
755 thread.run()
757 assert len(success) == 1
759 # Verify integrity
760 loaded = nib.load(nifti_path)
761 loaded_data = loaded.get_fdata()
763 # Should preserve binary values
764 unique_vals = np.unique(loaded_data)
765 assert len(unique_vals) <= 2
767 def test_load_compressed_vs_uncompressed(self, temp_workspace):
768 """Test loading compressed vs uncompressed files"""
769 data = np.random.rand(10, 10, 10).astype(np.float32)
770 img = nib.Nifti1Image(data, np.eye(4))
772 # Save compressed version
773 compressed_path = os.path.join(temp_workspace, "compressed.nii.gz")
774 nib.save(img, compressed_path)
776 # Save uncompressed version
777 uncompressed_path = os.path.join(temp_workspace, "uncompressed.nii")
778 nib.save(img, uncompressed_path)
780 # Load both
781 results_compressed = []
782 thread_compressed = ImageLoadThread(compressed_path, False)
783 thread_compressed.finished.connect(
784 lambda img_data, dims, aff, is_4d, is_overlay:
785 results_compressed.append(img_data)
786 )
787 thread_compressed.run()
789 results_uncompressed = []
790 thread_uncompressed = ImageLoadThread(uncompressed_path, False)
791 thread_uncompressed.finished.connect(
792 lambda img_data, dims, aff, is_4d, is_overlay:
793 results_uncompressed.append(img_data)
794 )
795 thread_uncompressed.run()
797 # They should produce equivalent results
798 assert len(results_compressed) == 1
799 assert len(results_uncompressed) == 1
801 np.testing.assert_array_almost_equal(
802 results_compressed[0],
803 results_uncompressed[0],
804 decimal=5
805 )
807 def test_save_with_special_characters_in_path(self, temp_workspace):
808 """Test saving with special characters in the path"""
809 data = np.ones((5, 5, 5), dtype=np.uint8)
811 # Filename with spaces and special characters
812 nifti_path = os.path.join(temp_workspace, "test file (copy) #1.nii")
813 json_path = os.path.join(temp_workspace, "test file (copy) #1.json")
815 source_dict = {"radius": 1.0, "difference": 0.1}
817 thread = SaveNiftiThread(
818 data, np.eye(4), nifti_path, json_path,
819 "relative/path.nii", source_dict
820 )
822 success = []
823 thread.success.connect(lambda p, j: success.append(True))
824 thread.run()
826 assert len(success) == 1
827 assert os.path.exists(nifti_path)
830class TestParameterValidation:
831 """Tests for parameter validation"""
833 def test_save_with_extreme_radius_values(self, temp_workspace):
834 """Test with extreme values for radius"""
835 data = np.ones((5, 5, 5), dtype=np.uint8)
837 extreme_radii = [0.0, 100.0, 1e6, -5.0]
839 for radius in extreme_radii:
840 nifti_path = os.path.join(temp_workspace, f"radius_{radius}.nii")
841 json_path = os.path.join(temp_workspace, f"radius_{radius}.json")
843 difference = 0.1
844 source_dict = {"radius": radius, "difference": difference}
846 thread = SaveNiftiThread(
847 data, np.eye(4), nifti_path, json_path,
848 "path.nii", source_dict
849 )
851 success = []
852 thread.success.connect(lambda p, j: success.append(True))
853 thread.run()
855 # Should still save even with extreme values
856 assert len(success) == 1
858 # Verify JSON contains the value
859 with open(json_path, 'r') as f:
860 metadata = json.load(f)
861 assert metadata["Origin"]["radius"] == radius
863 def test_save_with_extreme_difference_values(self, temp_workspace):
864 """Test with extreme values for difference"""
865 data = np.ones((5, 5, 5), dtype=np.uint8)
867 extreme_diffs = [0.0, 1.0, 10.0, -1.0]
869 for diff in extreme_diffs:
870 nifti_path = os.path.join(temp_workspace, f"diff_{diff}.nii")
871 json_path = os.path.join(temp_workspace, f"diff_{diff}.json")
873 source_dict = {"radius": 1.0, "difference": diff}
875 thread = SaveNiftiThread(
876 data, np.eye(4), nifti_path, json_path,
877 "path.nii", source_dict
878 )
880 success = []
881 thread.success.connect(lambda p, j: success.append(True))
882 thread.run()
884 assert len(success) == 1
886 with open(json_path, 'r') as f:
887 metadata = json.load(f)
888 assert metadata["Origin"]["difference"] == diff
891class TestPerformanceAndScalability:
892 """Tests for performance and scalability"""
894 def test_large_4d_dataset(self, temp_workspace):
895 """Test with a large 4D dataset"""
896 # Simulated 4D dataset (not too large for CI)
897 data_4d = np.random.rand(40, 40, 40, 20).astype(np.float32)
898 img = nib.Nifti1Image(data_4d, np.eye(4))
900 nifti_path = os.path.join(temp_workspace, "large_4d.nii.gz")
901 nib.save(img, nifti_path)
903 thread = ImageLoadThread(nifti_path, False)
905 results = []
906 progress_updates = []
908 thread.finished.connect(
909 lambda img_data, dims, aff, is_4d, is_overlay:
910 results.append(dims)
911 )
912 thread.progress.connect(lambda val: progress_updates.append(val))
914 thread.run()
916 assert len(results) == 1
917 assert results[0] == (40, 40, 40, 20)
919 # Progress should be emitted
920 assert len(progress_updates) > 0
921 assert 100 in progress_updates
923 def test_progress_tracking_accuracy(self, temp_workspace):
924 """Test accuracy of progress tracking"""
925 data = np.random.rand(25, 25, 25).astype(np.float32)
926 img = nib.Nifti1Image(data, np.eye(4))
928 nifti_path = os.path.join(temp_workspace, "progress_track.nii")
929 nib.save(img, nifti_path)
931 thread = ImageLoadThread(nifti_path, False)
933 progress_values = []
934 thread.progress.connect(lambda val: progress_values.append(val))
936 thread.run()
938 # Verify progress sequence
939 expected_milestones = [10, 30, 50, 70, 80, 100]
940 for milestone in expected_milestones:
941 assert milestone in progress_values
943 # Verify increasing order
944 for i in range(1, len(progress_values)):
945 assert progress_values[i] >= progress_values[i - 1]
948class TestNiftiThreadParameterized:
949 """Parameterized tests and normalization tests for NIfTI threads"""
951 @pytest.mark.parametrize("shape", [
952 (5, 5, 5),
953 (10, 20, 15),
954 (3, 3, 3),
955 (50, 50, 50)
956 ])
957 def test_save_various_shapes(self, shape, temp_workspace):
958 """Parameterized test for different volume shapes"""
959 data = np.random.randint(0, 255, shape, dtype=np.uint8)
960 nifti_path = os.path.join(temp_workspace, f"shape_{'x'.join(map(str, shape))}.nii")
961 json_path = nifti_path.replace('.nii', '.json')
963 source_dict = {"radius": 1.0, "difference": 0.1}
965 thread = SaveNiftiThread(
966 data, np.eye(4), nifti_path, json_path,
967 "path.nii", source_dict
968 )
970 success = []
971 thread.success.connect(lambda p, j: success.append(True))
972 thread.run()
974 assert len(success) == 1
976 # Verify loading
977 loaded = nib.load(nifti_path)
978 assert loaded.shape == shape
980 @pytest.mark.parametrize("is_overlay", [True, False])
981 def test_load_overlay_flag(self, is_overlay, temp_workspace):
982 """Parameterized test for the overlay flag"""
983 data = np.random.rand(8, 8, 8).astype(np.float32)
984 img = nib.Nifti1Image(data, np.eye(4))
986 nifti_path = os.path.join(temp_workspace, f"overlay_{is_overlay}.nii")
987 nib.save(img, nifti_path)
989 thread = ImageLoadThread(nifti_path, is_overlay=is_overlay)
991 results = []
992 thread.finished.connect(
993 lambda img_data, dims, aff, is_4d, is_ov:
994 results.append(is_ov)
995 )
997 thread.run()
999 assert len(results) == 1
1000 assert results[0] == is_overlay
1002 def test_load_4d_normalization_per_volume(self, temp_workspace):
1003 """Test independent normalization for each 4D volume"""
1004 # Create 4D with different intensities per volume (add variability to avoid constant volumes)
1005 volume1 = np.ones((10, 10, 10)) * 100
1006 volume1[0, 0, 0] = 101 # Add variability
1007 volume2 = np.ones((10, 10, 10)) * 200
1008 volume2[0, 0, 0] = 201
1009 volume3 = np.ones((10, 10, 10)) * 50
1010 volume3[0, 0, 0] = 51
1012 data_4d = np.stack([volume1, volume2, volume3], axis=-1)
1013 img = nib.Nifti1Image(data_4d, np.eye(4))
1015 nifti_path = os.path.join(temp_workspace, "4d_norm.nii")
1016 nib.save(img, nifti_path)
1018 thread = ImageLoadThread(nifti_path, False)
1020 results = []
1021 thread.finished.connect(
1022 lambda img_data, dims, aff, is_4d, is_overlay:
1023 results.append(img_data)
1024 )
1026 thread.run()
1028 normalized_data = results[0]
1030 # Each volume should be normalized independently
1031 for i in range(3):
1032 vol = normalized_data[..., i]
1033 assert vol.min() >= 0
1034 assert vol.max() <= 1
1035 assert vol.max() - vol.min() > 0
1038class TestImageLoadThreadNormalization:
1039 """Tests for percentile-based normalization"""
1041 def test_normalize_data_matplotlib_style(self, temp_workspace):
1042 """Test normalization using percentiles"""
1043 # Create data with outliers
1044 data = np.random.randn(30, 30, 30).astype(np.float32) * 100
1045 data[0, 0, 0] = 10000 # High outlier
1046 data[1, 1, 1] = -5000 # Low outlier
1048 img = nib.Nifti1Image(data, np.eye(4))
1049 nifti_path = os.path.join(temp_workspace, "outliers.nii")
1050 nib.save(img, nifti_path)
1052 thread = ImageLoadThread(nifti_path, False)
1054 results = []
1055 thread.finished.connect(
1056 lambda img_data, dims, aff, is_4d, is_overlay:
1057 results.append(img_data)
1058 )
1060 thread.run()
1062 normalized = results[0]
1064 # Verify that outliers do not dominate normalization
1065 # Most values should be reasonably distributed
1066 assert normalized.min() >= 0
1067 assert normalized.max() <= 1
1069 # Intermediate percentiles should be well distributed
1070 p25 = np.percentile(normalized, 25)
1071 p75 = np.percentile(normalized, 75)
1072 assert 0.1 < p25 < 0.9
1073 assert 0.1 < p75 < 0.9
1075 def test_normalize_uniform_data(self, temp_workspace):
1076 """Test normalization with uniform data"""
1077 # All voxels have the same value
1078 data = np.ones((10, 10, 10)) * 42.0
1079 img = nib.Nifti1Image(data, np.eye(4))
1081 nifti_path = os.path.join(temp_workspace, "uniform.nii")
1082 nib.save(img, nifti_path)
1084 thread = ImageLoadThread(nifti_path, False)
1086 results = []
1087 thread.finished.connect(
1088 lambda img_data, dims, aff, is_4d, is_overlay:
1089 results.append(img_data)
1090 )
1092 thread.run()
1094 normalized = results[0]
1096 # With uniform data, normalization should handle it gracefully
1097 assert not np.any(np.isnan(normalized))
1098 assert not np.any(np.isinf(normalized))
1099 assert normalized.min() >= 0
1100 assert normalized.max() <= 1
1102 def test_normalize_with_nan_values(self, temp_workspace):
1103 """Test normalization with NaN values"""
1104 data = np.random.rand(10, 10, 10).astype(np.float32)
1105 data[5, 5, 5] = np.nan
1106 data[3, 3, 3] = np.inf
1108 img = nib.Nifti1Image(data, np.eye(4))
1109 nifti_path = os.path.join(temp_workspace, "with_nan.nii")
1110 nib.save(img, nifti_path)
1112 thread = ImageLoadThread(nifti_path, False)
1114 results = []
1115 thread.finished.connect(
1116 lambda img_data, dims, aff, is_4d, is_overlay:
1117 results.append(img_data)
1118 )
1120 thread.run()
1122 normalized = results[0]
1124 # Should handle NaN/Inf without propagating them
1125 finite_count = np.isfinite(normalized).sum()
1126 assert finite_count > 0 # At least some finite values
1128 def test_normalize_empty_volume(self):
1129 """Test normalization of an empty volume"""
1130 thread = ImageLoadThread("dummy.nii", False)
1132 # Direct test of the method
1133 empty_data = np.array([])
1134 result = thread.normalize_data_matplotlib_style(empty_data)
1136 assert result.size == 0
1138 def test_normalize_volume_all_invalid(self):
1139 """Test normalization with all invalid values"""
1140 thread = ImageLoadThread("dummy.nii", False)
1142 # Volume with only NaN values
1143 nan_volume = np.full((5, 5, 5), np.nan)
1144 result = thread.normalize_data_matplotlib_style(nan_volume)
1146 # Should return zeros
1147 assert result.shape == nan_volume.shape
1148 assert np.all(result == 0)
1151class TestImageLoadThreadCanonicalOrientation:
1152 """Tests for conversion to canonical RAS+ orientation"""
1154 def test_canonical_orientation_applied(self, temp_workspace):
1155 """Test that the image is converted to RAS+ orientation"""
1156 # Create an image with non-canonical orientation
1157 data = np.random.rand(10, 12, 8).astype(np.float32)
1159 # Affine with a different orientation (e.g., LAS)
1160 affine = np.array([
1161 [-2, 0, 0, 10],
1162 [0, 2, 0, 20],
1163 [0, 0, 2, 30],
1164 [0, 0, 0, 1]
1165 ])
1167 img = nib.Nifti1Image(data, affine)
1168 nifti_path = os.path.join(temp_workspace, "non_canonical.nii")
1169 nib.save(img, nifti_path)
1171 thread = ImageLoadThread(nifti_path, False)
1173 results = []
1174 thread.finished.connect(
1175 lambda img_data, dims, aff, is_4d, is_overlay:
1176 results.append((img_data, aff))
1177 )
1179 thread.run()
1181 img_data, loaded_affine = results[0]
1183 # The image should now be in canonical orientation
1184 assert img_data.shape[0] > 0
1185 assert img_data.shape[1] > 0
1186 assert img_data.shape[2] > 0
1188 # Affine should be updated
1189 assert isinstance(loaded_affine, np.ndarray)
1190 assert loaded_affine.shape == (4, 4)
1193class TestImageLoadThreadErrorHandling:
1194 """Tests for error handling"""
1196 def test_error_file_not_found(self, temp_workspace):
1197 """Test error when file does not exist"""
1198 nonexistent_path = os.path.join(temp_workspace, "does_not_exist.nii")
1199 thread = ImageLoadThread(nonexistent_path, False)
1201 error_msgs = []
1202 thread.error.connect(lambda msg: error_msgs.append(msg))
1204 thread.run()
1206 assert len(error_msgs) == 1
1207 assert len(error_msgs[0]) > 0
1209 def test_error_invalid_nifti_file(self, temp_workspace):
1210 """Test error with a non-NIfTI file"""
1211 # Create a text file instead of a NIfTI
1212 invalid_path = os.path.join(temp_workspace, "invalid.nii")
1213 with open(invalid_path, 'w') as f:
1214 f.write("This is not a NIfTI file")
1216 thread = ImageLoadThread(invalid_path, False)
1218 error_msgs = []
1219 thread.error.connect(lambda msg: error_msgs.append(msg))
1221 thread.run()
1223 assert len(error_msgs) == 1
1225 def test_error_corrupted_nifti(self, temp_workspace):
1226 """Test error with a corrupted NIfTI file"""
1227 corrupted_path = os.path.join(temp_workspace, "corrupted.nii.gz")
1229 # Create a partially valid but corrupted file
1230 with open(corrupted_path, 'wb') as f:
1231 f.write(b'corrupted data that looks like gzip')
1233 thread = ImageLoadThread(corrupted_path, False)
1235 error_msgs = []
1236 thread.error.connect(lambda msg: error_msgs.append(msg))
1238 thread.run()
1240 assert len(error_msgs) == 1
1242 @patch('nibabel.load')
1243 def test_error_during_load(self, mock_load, temp_workspace):
1244 """Test generic load error handling"""
1245 mock_load.side_effect = RuntimeError("Memory error")
1247 nifti_path = os.path.join(temp_workspace, "test.nii")
1248 thread = ImageLoadThread(nifti_path, False)
1250 error_msgs = []
1251 thread.error.connect(lambda msg: error_msgs.append(msg))
1253 thread.run()
1255 assert len(error_msgs) == 1
1256 assert "Memory error" in error_msgs[0]
1259class TestImageLoadThreadNifti2Support:
1260 """Tests for NIfTI-2 support"""
1262 def test_nifti2_image_loads(self, temp_workspace):
1263 """Test loading a NIfTI-2 image"""
1264 data = np.random.rand(8, 8, 8).astype(np.float32)
1265 affine = np.eye(4)
1266 img = nib.Nifti2Image(data, affine)
1268 nifti_path = os.path.join(temp_workspace, "test_nifti2.nii")
1269 nib.save(img, nifti_path)
1271 thread = ImageLoadThread(nifti_path, False)
1273 results = []
1274 thread.finished.connect(
1275 lambda img_data, dims, aff, is_4d, is_overlay:
1276 results.append(img_data)
1277 )
1279 thread.run()
1281 assert len(results) == 1
1282 assert results[0].shape == (8, 8, 8)
1285class TestImageLoadThreadMemoryMapping:
1286 """Tests for memory mapping"""
1288 @patch('nibabel.load')
1289 def test_memory_mapping_used(self, mock_load, temp_workspace):
1290 """Test that memory mapping is used"""
1291 # Mock to check mmap argument
1292 mock_img = Mock(spec=nib.Nifti1Image)
1293 mock_img.header.get_data_shape.return_value = (10, 10, 10)
1294 mock_img.affine = np.eye(4)
1295 mock_img.dataobj = np.zeros((10, 10, 10))
1297 mock_canonical = Mock()
1298 mock_canonical.header.get_data_shape.return_value = (10, 10, 10)
1299 mock_canonical.affine = np.eye(4)
1300 mock_canonical.dataobj = np.zeros((10, 10, 10))
1302 with patch('nibabel.as_closest_canonical', return_value=mock_canonical):
1303 mock_load.return_value = mock_img
1305 thread = ImageLoadThread("dummy.nii", False)
1306 thread.run()
1308 # Verify mmap was used
1309 mock_load.assert_called_once()
1310 call_args = mock_load.call_args
1311 assert call_args[1].get('mmap') == 'c'
1314class TestEdgeCasesAndIntegration:
1315 """Tests for edge cases and integration scenarios"""
1317 def test_very_small_image(self, temp_workspace):
1318 """Test with a very small image (1×1×1)"""
1319 data = np.array([[[42.0]]])
1320 img = nib.Nifti1Image(data, np.eye(4))
1322 nifti_path = os.path.join(temp_workspace, "tiny.nii")
1323 nib.save(img, nifti_path)
1325 thread = ImageLoadThread(nifti_path, False)
1327 results = []
1328 thread.finished.connect(
1329 lambda img_data, dims, aff, is_4d, is_overlay:
1330 results.append(img_data)
1331 )
1333 thread.run()
1335 assert len(results) == 1
1336 assert results[0].shape == (1, 1, 1)
1337 assert np.isclose(results[0][0, 0, 0], 0.0) or np.isclose(results[0][0, 0, 0], 1.0)
1339 def test_large_dimensions(self, temp_workspace):
1340 """Test with a large image"""
1341 # Simulate a large but manageable volume
1342 data = np.zeros((100, 100, 100), dtype=np.float32)
1343 data[50, 50, 50] = 100 # add high-intensity point
1345 img = nib.Nifti1Image(data, np.eye(4))
1346 nifti_path = os.path.join(temp_workspace, "large.nii.gz")
1347 nib.save(img, nifti_path)
1349 thread = ImageLoadThread(nifti_path, False)
1351 results = []
1352 thread.finished.connect(
1353 lambda img_data, dims, aff, is_4d, is_overlay:
1354 results.append((img_data, dims, is_4d))
1355 )
1357 thread.run()
1359 assert len(results) == 1
1360 img_data, dims, is_4d = results[0]
1362 assert dims == (100, 100, 100)
1363 assert not is_4d
1364 assert img_data.shape == (100, 100, 100)
1365 assert np.all((img_data >= 0) & (img_data <= 1)), "Normalized values must be between 0 and 1"
1366 assert np.isclose(img_data[50, 50, 50], 1.0, atol=1e-3)
1368 def test_invalid_file_emits_error(self, temp_workspace):
1369 """Test that a non-NIfTI file emits an error signal"""
1370 invalid_path = os.path.join(temp_workspace, "not_nifti.txt")
1371 with open(invalid_path, "w") as f:
1372 f.write("this is not an image")
1374 thread = ImageLoadThread(invalid_path, False)
1376 errors = []
1377 thread.error.connect(lambda msg: errors.append(msg))
1379 thread.run()
1381 assert len(errors) == 1
1382 assert any(keyword in errors[0] for keyword in [
1383 "Not a valid NIfTI",
1384 "No such file",
1385 "Cannot work out file type"
1386 ])
1388 def test_save_nifti_thread_success(self, temp_workspace):
1389 """Integration test: NIfTI + JSON save"""
1390 data = np.ones((5, 5, 5), dtype=np.uint8)
1391 affine = np.eye(4)
1392 nifti_path = os.path.join(temp_workspace, "roi.nii.gz")
1393 json_path = os.path.join(temp_workspace, "roi.json")
1395 radius = 2.0
1396 difference = 0.5
1397 source_dict = {"radius": radius, "difference": difference}
1399 thread = SaveNiftiThread(
1400 data=data,
1401 affine=affine,
1402 path=nifti_path,
1403 json_path=json_path,
1404 relative_path="sub-01/anat/T1w.nii.gz",
1405 source_dict=source_dict
1406 )
1408 results = []
1409 thread.success.connect(lambda npath, jpath: results.append((npath, jpath)))
1411 thread.run()
1413 # Check that files were created
1414 assert os.path.exists(nifti_path)
1415 assert os.path.exists(json_path)
1417 assert len(results) == 1
1418 npath, jpath = results[0]
1419 assert npath.endswith(".nii.gz")
1420 assert jpath.endswith(".json")
1422 # Check JSON contains correct fields
1423 import json
1424 with open(json_path) as f:
1425 meta = json.load(f)
1427 assert "Origin" in meta
1428 assert meta["Origin"]["radius"] == 2.0
1430 def test_save_nifti_thread_error(self, tmp_path):
1431 """Error test: unwritable path"""
1432 bad_path = "/invalid/path/roi.nii.gz"
1433 json_path = os.path.join(tmp_path, "roi.json")
1435 radius = 1.0
1436 difference = 0.2
1437 source_dict = {"radius": radius, "difference": difference}
1439 thread = SaveNiftiThread(
1440 data=np.ones((2, 2, 2)),
1441 affine=np.eye(4),
1442 path=bad_path,
1443 json_path=json_path,
1444 relative_path="sub-01/anat/T1w.nii.gz",
1445 source_dict=source_dict
1446 )
1448 errors = []
1449 thread.error.connect(lambda msg: errors.append(msg))
1451 thread.run()
1453 assert len(errors) == 1
1454 assert isinstance(errors[0], str)
1457if __name__ == "__main__":
1458 pytest.main([__file__, "-v"])