Skip to content

Commit d0913e7

Browse files
authored
[DAR-4924][External] Resolving issues with import & export of NifTI annotations (#979)
* Initial commit * flip axes of legacy imports / exports for `NifTI` dataset items * Tests * Fixed import logic issue & updated docstrings
1 parent 5b84d91 commit d0913e7

File tree

9 files changed

+2338
-57
lines changed

9 files changed

+2338
-57
lines changed

darwin/dataset/remote_dataset_v2.py

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
Tuple,
1212
Union,
1313
)
14-
14+
import numpy as np
1515
from pydantic import ValidationError
1616
from requests.models import Response
1717

@@ -873,10 +873,15 @@ def register_multi_slotted(
873873
print(f"Reistration complete. Check your items in the dataset: {self.slug}")
874874
return results
875875

876-
def _get_remote_files_that_require_legacy_scaling(self) -> List[Path]:
876+
def _get_remote_files_that_require_legacy_scaling(
877+
self,
878+
) -> Dict[str, Dict[str, Any]]:
877879
"""
878880
Get all remote files that have been scaled upon upload. These files require that
879-
NifTI annotations are similarly scaled during import
881+
NifTI annotations are similarly scaled during import.
882+
883+
The in-platform affines are returned for each legacy file, as this is required
884+
to properly re-orient the annotations during import.
880885
881886
Parameters
882887
----------
@@ -885,21 +890,31 @@ def _get_remote_files_that_require_legacy_scaling(self) -> List[Path]:
885890
886891
Returns
887892
-------
888-
List[Path]
889-
A list of full remote paths of dataset items that require NifTI annotations to be scaled
893+
Dict[str, Dict[str, Any]]
894+
A dictionary of remote file full paths to their slot affine maps
890895
"""
891-
remote_files_that_require_legacy_scaling = []
896+
remote_files_that_require_legacy_scaling = {}
892897
remote_files = self.fetch_remote_files(
893898
filters={"statuses": ["new", "annotate", "review", "complete", "archived"]}
894899
)
895900
for remote_file in remote_files:
901+
if not remote_file.slots[0].get("metadata", {}).get("medical", {}):
902+
continue
896903
if not (
897904
remote_file.slots[0]
898905
.get("metadata", {})
899906
.get("medical", {})
900907
.get("handler")
901908
):
902-
remote_files_that_require_legacy_scaling.append(remote_file.full_path)
909+
slot_affine_map = {}
910+
for slot in remote_file.slots:
911+
slot_affine_map[slot["slot_name"]] = np.array(
912+
slot["metadata"]["medical"]["affine"],
913+
dtype=np.float64,
914+
)
915+
remote_files_that_require_legacy_scaling[
916+
Path(remote_file.full_path)
917+
] = slot_affine_map
903918

904919
return remote_files_that_require_legacy_scaling
905920

darwin/exporter/formats/nifti.py

Lines changed: 51 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ def _console_theme() -> Theme:
2525
console = Console(theme=_console_theme())
2626
try:
2727
import nibabel as nib
28-
from nibabel.orientations import io_orientation, ornt_transform
2928
except ImportError:
3029
import_fail_string = r"""
3130
You must install darwin-py with pip install darwin-py\[medical]
@@ -128,7 +127,11 @@ def export(
128127
polygon_annotations, slot_map, output_volumes, legacy=legacy
129128
)
130129
write_output_volume_to_disk(
131-
output_volumes, image_id=image_id, output_dir=output_dir, legacy=legacy
130+
output_volumes,
131+
image_id=image_id,
132+
output_dir=output_dir,
133+
legacy=legacy,
134+
filename=video_annotation.filename,
132135
)
133136
# Need to map raster layers to SeriesInstanceUIDs
134137
if mask_present:
@@ -161,6 +164,7 @@ def export(
161164
image_id=image_id,
162165
output_dir=output_dir,
163166
legacy=legacy,
167+
filename=video_annotation.filename,
164168
)
165169

166170

@@ -456,6 +460,7 @@ def write_output_volume_to_disk(
456460
image_id: str,
457461
output_dir: Union[str, Path],
458462
legacy: bool = False,
463+
filename: str = None,
459464
) -> None:
460465
"""Writes the given output volumes to disk.
461466
@@ -470,6 +475,8 @@ def write_output_volume_to_disk(
470475
legacy : bool, default=False
471476
If ``True``, the exporter will use the legacy calculation.
472477
If ``False``, the exporter will use the new calculation by dividing with pixdims.
478+
filename: str
479+
The filename of the dataset item
473480
474481
Returns
475482
-------
@@ -489,18 +496,10 @@ def unnest_dict_to_list(d: Dict) -> List:
489496
volumes = unnest_dict_to_list(output_volumes)
490497
for volume in volumes:
491498
img = nib.Nifti1Image(
492-
dataobj=np.flip(volume.pixel_array, (0, 1, 2)).astype(np.int16),
499+
dataobj=volume.pixel_array.astype(np.int16),
493500
affine=volume.affine,
494501
)
495-
if legacy and volume.original_affine is not None:
496-
orig_ornt = io_orientation(
497-
volume.original_affine
498-
) # Get orientation of current affine
499-
img_ornt = io_orientation(volume.affine) # Get orientation of RAS affine
500-
from_canonical = ornt_transform(
501-
img_ornt, orig_ornt
502-
) # Get transform from RAS to current affine
503-
img = img.as_reoriented(from_canonical)
502+
img = _get_reoriented_nifti_image(img, volume, legacy, filename)
504503
if volume.from_raster_layer:
505504
output_path = Path(output_dir) / f"{image_id}_{volume.class_name}_m.nii.gz"
506505
else:
@@ -510,6 +509,46 @@ def unnest_dict_to_list(d: Dict) -> List:
510509
nib.save(img=img, filename=output_path)
511510

512511

512+
def _get_reoriented_nifti_image(
513+
img: nib.Nifti1Image, volume: Dict, legacy: bool, filename: str
514+
) -> nib.Nifti1Image:
515+
"""
516+
Reorients the given NIfTI image based on the affine of the originally uploaded file.
517+
518+
Files that were uploaded before the `MED_2D_VIEWER` feature are `legacy`. Non-legacy
519+
files are uploaded and re-oriented to the `LPI` orientation. Legacy NifTI
520+
files were treated differently. These files were re-oriented to `LPI`, but their
521+
affine was stored as `RAS`, which is the opposite orientation. We therefore need to
522+
flip the axes of these images to ensure alignment.
523+
524+
Parameters
525+
----------
526+
img: nib.Nifti1Image
527+
The NIfTI image to be reoriented
528+
volume: Dict
529+
The volume containing the affine and original affine
530+
legacy: bool
531+
If ``True``, the exporter will flip all axes of the image if the dataset item
532+
is not a DICOM
533+
If ``False``, the exporter will not flip the axes
534+
filename: str
535+
The filename of the dataset item
536+
"""
537+
if volume.original_affine is not None:
538+
img_ax_codes = nib.orientations.aff2axcodes(volume.affine)
539+
orig_ax_codes = nib.orientations.aff2axcodes(volume.original_affine)
540+
img_ornt = nib.orientations.axcodes2ornt(img_ax_codes)
541+
orig_ornt = nib.orientations.axcodes2ornt(orig_ax_codes)
542+
transform = nib.orientations.ornt_transform(img_ornt, orig_ornt)
543+
img = img.as_reoriented(transform)
544+
is_dicom = filename.lower().endswith(".dcm")
545+
if legacy and not is_dicom:
546+
img = nib.Nifti1Image(
547+
np.flip(img.get_fdata(), (0, 1, 2)).astype(np.int16), img.affine
548+
)
549+
return img
550+
551+
513552
def shift_polygon_coords(
514553
polygon: List[Dict], pixdim: List[Number], legacy: bool = False
515554
) -> List:

darwin/importer/formats/nifti.py

Lines changed: 49 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import warnings
44
from collections import OrderedDict, defaultdict
55
from pathlib import Path
6-
from typing import Dict, List, Optional, Tuple
6+
from typing import Dict, List, Optional, Tuple, Any
77

88
from rich.console import Console
99

@@ -31,8 +31,7 @@
3131

3232
def parse_path(
3333
path: Path,
34-
legacy: bool = False,
35-
remote_files_that_require_legacy_scaling: Optional[List] = [],
34+
remote_files_that_require_legacy_scaling: Dict[Path, Dict[str, Any]] = {},
3635
) -> Optional[List[dt.AnnotationFile]]:
3736
"""
3837
Parses the given ``nifti`` file and returns a ``List[dt.AnnotationFile]`` with the parsed
@@ -42,9 +41,8 @@ def parse_path(
4241
----------
4342
path : Path
4443
The ``Path`` to the ``nifti`` file.
45-
legacy : bool, default: False
46-
If ``True``, the function will not attempt to resize the annotations to isotropic pixel dimensions.
47-
If ``False``, the function will resize the annotations to isotropic pixel dimensions.
44+
remote_files_that_require_legacy_scaling : Optional[Dict[Path, Dict[str, Any]]]
45+
A dictionary of remote file full paths to their slot affine maps
4846
4947
Returns
5048
-------
@@ -78,16 +76,20 @@ def parse_path(
7876
return None
7977
annotation_files = []
8078
for nifti_annotation in nifti_annotations:
81-
legacy = nifti_annotation["image"] in remote_files_that_require_legacy_scaling
79+
remote_file_path = Path(nifti_annotation["image"])
80+
if not str(remote_file_path).startswith("/"):
81+
remote_file_path = Path("/" + str(remote_file_path))
82+
8283
annotation_file = _parse_nifti(
8384
Path(nifti_annotation["label"]),
84-
nifti_annotation["image"],
85+
Path(nifti_annotation["image"]),
8586
path,
8687
class_map=nifti_annotation.get("class_map"),
8788
mode=nifti_annotation.get("mode", "image"),
8889
slot_names=nifti_annotation.get("slot_names", []),
8990
is_mpr=nifti_annotation.get("is_mpr", False),
90-
legacy=legacy,
91+
remote_file_path=remote_file_path,
92+
remote_files_that_require_legacy_scaling=remote_files_that_require_legacy_scaling,
9193
)
9294
annotation_files.append(annotation_file)
9395
return annotation_files
@@ -101,10 +103,16 @@ def _parse_nifti(
101103
mode: str,
102104
slot_names: List[str],
103105
is_mpr: bool,
104-
legacy: bool = False,
106+
remote_file_path: Path,
107+
remote_files_that_require_legacy_scaling: Dict[Path, Dict[str, Any]] = {},
105108
) -> dt.AnnotationFile:
106-
img, pixdims = process_nifti(nib.load(nifti_path))
109+
img, pixdims = process_nifti(
110+
nib.load(nifti_path),
111+
remote_file_path=remote_file_path,
112+
remote_files_that_require_legacy_scaling=remote_files_that_require_legacy_scaling,
113+
)
107114

115+
legacy = remote_file_path in remote_files_that_require_legacy_scaling
108116
processed_class_map = process_class_map(class_map)
109117
video_annotations = []
110118
if mode == "instances": # For each instance produce a video annotation
@@ -159,11 +167,12 @@ def _parse_nifti(
159167
dt.AnnotationClass(class_name, "mask", "mask")
160168
for class_name in class_map.values()
161169
}
162-
170+
remote_path = "/" if filename.parent == "." else filename.parent
171+
filename = Path(filename.name)
163172
return dt.AnnotationFile(
164173
path=json_path,
165174
filename=str(filename),
166-
remote_path="/",
175+
remote_path=str(remote_path),
167176
annotation_classes=annotation_classes,
168177
annotations=video_annotations,
169178
slots=[
@@ -353,7 +362,7 @@ def nifti_to_video_polygon_annotation(
353362
if len(all_frame_ids) == 1:
354363
segments = [[all_frame_ids[0], all_frame_ids[0] + 1]]
355364
elif len(all_frame_ids) > 1:
356-
segments = [[min(all_frame_ids), max(all_frame_ids)]]
365+
segments = [[min(all_frame_ids), max(all_frame_ids) + 1]]
357366
video_annotation = dt.make_video_annotation(
358367
frame_annotations,
359368
keyframes={f_id: True for f_id in all_frame_ids},
@@ -513,16 +522,33 @@ def correct_nifti_header_if_necessary(img_nii):
513522
def process_nifti(
514523
input_data: nib.nifti1.Nifti1Image,
515524
ornt: Optional[List[List[float]]] = [[0.0, -1.0], [1.0, -1.0], [2.0, -1.0]],
525+
remote_file_path: Path = Path("/"),
526+
remote_files_that_require_legacy_scaling: Dict[Path, Dict[str, Any]] = {},
516527
) -> Tuple[np.ndarray, Tuple[float]]:
517528
"""
518-
Converts a nifti object of any orientation to the passed ornt orientation.
529+
Converts a NifTI object of any orientation to the passed ornt orientation.
519530
The default ornt is LPI.
520531
532+
Files that were uploaded before the `MED_2D_VIEWER` feature are `legacy`. Non-legacy
533+
files are uploaded and re-oriented to the `LPI` orientation. Legacy files
534+
files were treated differently:
535+
- Legacy NifTI files were re-oriented to `LPI`, but their
536+
affine was stored as `RAS`, which is the opposite orientation. However, because
537+
their pixel data is stored in `LPI`, we can treat them the same way as non-legacy
538+
files.
539+
- Legacy DICOM files were not always re-oriented to `LPI`. We therefore use the
540+
affine of the dataset item from `slot_affine_map` to re-orient the NifTI file to
541+
be imported
542+
521543
Args:
522-
input_data: nibabel nifti object.
523-
ornt: (n,2) orientation array. It defines a transformation from RAS.
544+
input_data: nibabel NifTI object.
545+
ornt: (n,2) orientation array. It defines a transformation to LPI
524546
ornt[N,1] is a flip of axis N of the array, where 1 means no flip and -1 means flip.
525547
ornt[:,0] is the transpose that needs to be done to the implied array, as in arr.transpose(ornt[:,0]).
548+
remote_file_path: Path
549+
The full path of the remote file
550+
remote_files_that_require_legacy_scaling: Dict[Path, Dict[str, Any]]
551+
A dictionary of remote file full paths to their slot affine maps
526552
527553
Returns:
528554
data_array: pixel array with orientation ornt.
@@ -531,9 +557,14 @@ def process_nifti(
531557
img = correct_nifti_header_if_necessary(input_data)
532558
orig_ax_codes = nib.orientations.aff2axcodes(img.affine)
533559
orig_ornt = nib.orientations.axcodes2ornt(orig_ax_codes)
560+
is_dicom = remote_file_path.suffix.lower() == ".dcm"
561+
if remote_file_path in remote_files_that_require_legacy_scaling and is_dicom:
562+
slot_affine_map = remote_files_that_require_legacy_scaling[remote_file_path]
563+
affine = slot_affine_map[next(iter(slot_affine_map))] # Take the 1st slot
564+
ax_codes = nib.orientations.aff2axcodes(affine)
565+
ornt = nib.orientations.axcodes2ornt(ax_codes)
534566
transform = nib.orientations.ornt_transform(orig_ornt, ornt)
535567
reoriented_img = img.as_reoriented(transform)
536-
537568
data_array = reoriented_img.get_fdata()
538569
pixdims = reoriented_img.header.get_zooms()
539570

0 commit comments

Comments
 (0)