class ScanSerializer(serializers.HyperlinkedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name="mri:scan-detail") dicom = serializers.HyperlinkedRelatedField( view_name="dicom:series-detail", queryset=Series.objects.all() ) subject = serializers.HyperlinkedRelatedField( view_name="research:subject-detail", queryset=get_subject_model().objects.all(), required=False, ) nifti = serializers.HyperlinkedRelatedField( view_name="mri:nifti-detail", queryset=NIfTI.objects.all(), required=False, allow_null=True, ) study_groups = serializers.HyperlinkedRelatedField( view_name="research:group-detail", queryset=get_group_model().objects.all(), many=True, required=False, ) sequence_type = serializers.HyperlinkedRelatedField( view_name="mri:sequencetype-detail", queryset=SequenceType.objects.all(), required=False, allow_null=True, ) class Meta: model = Scan fields = ( "id", "url", "dicom", "subject", "nifti", "study_groups", "sequence_type", "institution_name", "time", "description", "number", "echo_time", "repetition_time", "inversion_time", "spatial_resolution", "comments", ) def create(self, validated_data): scan, created = Scan.objects.get_or_create(**validated_data) if created and scan.dicom and len(validated_data) == 1: scan.update_fields_from_dicom() scan.save() return scan
from django_mri.utils.utils import ( get_group_model, get_measurement_model, get_study_model, get_subject_model, ) from rest_framework import serializers Group = get_group_model() Measurement = get_measurement_model() Subject = get_subject_model() Study = get_study_model() class MiniStudySerializer(serializers.ModelSerializer): """ Minified serializer class for the :class:`Study` model. """ class Meta: model = Study fields = "id", "title", "description" class MiniGroupSerializer(serializers.ModelSerializer): """ Minified serializer class for the :class:`Group` model. """ study = MiniStudySerializer()
class Scan(TimeStampedModel): """ A model used to represent an MRI scan independently from the file-format in which it is saved. This model handles any conversions between formats in case they are required, and allows for easy querying of MRI scans based on universal attributes. """ #: The institution in which this scan was acquired. institution_name = models.CharField(max_length=64, blank=True, null=True) #: Acquisition datetime. time = models.DateTimeField(blank=True, null=True, help_text=help_text.SCAN_TIME) #: Short description of the scan's acquisition parameters. description = models.CharField( max_length=100, blank=True, null=True, help_text=help_text.SCAN_DESCRIPTION, ) #: The relative number of this scan in the session in which it was #: acquired. number = models.IntegerField( blank=True, null=True, validators=[MinValueValidator(0)], help_text=help_text.SCAN_NUMBER, ) #: The time between the application of the radio-frequency excitation pulse #: and the peak of the signal induced in the coil (in milliseconds). echo_time = models.FloatField( blank=True, null=True, validators=[MinValueValidator(0)], help_text=help_text.SCAN_ECHO_TIME, ) #: The time between two successive RF pulses (in milliseconds). repetition_time = models.FloatField( blank=True, null=True, validators=[MinValueValidator(0)], help_text=help_text.SCAN_REPETITION_TIME, ) #: The time between the 180-degree inversion pulse and the following #: spin-echo (SE) sequence (in milliseconds). inversion_time = models.FloatField( blank=True, null=True, validators=[MinValueValidator(0)], help_text=help_text.SCAN_INVERSION_TIME, ) #: The spatial resolution of the image in millimeters. spatial_resolution = ArrayField(models.FloatField(), size=3, blank=True, null=True) #: Any other comments about this scan. comments = models.TextField( max_length=1000, blank=True, null=True, help_text=help_text.SCAN_COMMENTS, ) #: If this instance's origin is a DICOM file, or it was saved as one, this #: field stores the association with the appropriate #: :class`django_dicom.models.series.Series` instance. dicom = models.OneToOneField( "django_dicom.Series", on_delete=models.PROTECT, blank=True, null=True, related_name="scan", verbose_name="DICOM Series", ) #: Keeps track of whether we've updated the instance's fields from DICOM #: header data or not. is_updated_from_dicom = models.BooleanField(default=False) #: If converted to NIfTI, keep a reference to the resulting instance. #: The reason it is suffixed with an underline is to allow for "nifti" #: to be used as a property that automatically returns an existing instance #: or creates one. _nifti = models.OneToOneField("django_mri.NIfTI", on_delete=models.SET_NULL, blank=True, null=True) #: Individual scans may be associated with multiple `Group` instances. #: This is meant to provide flexibility in managing access to data between #: researchers working on different studies. #: The `Group` model is expected to be specified as `STUDY_GROUP_MODEL` in #: the project's settings. study_groups = models.ManyToManyField(get_group_model(), related_name="mri_scan_set", blank=True) #: Keeps a record of the user that added this scan. added_by = models.ForeignKey( get_user_model(), related_name="mri_uploads", blank=True, null=True, on_delete=models.SET_NULL, ) #: Associates this scan with some session of a subject. session = models.ForeignKey( "django_mri.Session", on_delete=models.CASCADE, ) REPRESENTATIONS = ( "dicom_representation", "nifti_representation", "mif_representation", ) DERIVATIVE_QUERY = {FileInput: "value", ListInput: "value__contains"} objects = ScanQuerySet.as_manager() class Meta: unique_together = ("number", "session") ordering = ("-time", ) def __str__(self) -> str: """ Returns the string representation of this instance. Returns ------- str String representation of this instance """ formatted_time = self.time.strftime("%Y-%m-%d %H:%M:%S") return f"{self.description} from {formatted_time}" def save(self, *args, **kwargs) -> None: """ Overrides the model's :meth:`~django.db.models.Model.save` method to provide custom validation. Hint ---- For more information, see Django's documentation on `overriding model methods`_. .. _overriding model methods: https://docs.djangoproject.com/en/3.0/topics/db/models/#overriding-model-methods """ if self.dicom and not self.is_updated_from_dicom: self.update_fields_from_dicom() super().save(*args, **kwargs) def update_fields_from_dicom(self) -> None: """ Sets instance fields from related DICOM series. Raises ------ AttributeError If not DICOM series is related to this scan """ if self.dicom: self.institution_name = self.dicom.institution_name self.number = self.dicom.number self.time = self.dicom.datetime self.description = self.dicom.description self.echo_time = self.dicom.echo_time self.inversion_time = self.dicom.inversion_time self.repetition_time = self.dicom.repetition_time self.spatial_resolution = self.dicom.spatial_resolution self.is_updated_from_dicom = True else: raise AttributeError( f"No DICOM data associated with MRI scan {self.id}!") def infer_sequence_type_from_dicom(self) -> SequenceType: """ Returns the appropriate :class:`django_mri.SequenceType` instance according to the scan's "*ScanningSequence*" and "*SequenceVariant*" header values. Returns ------- SequenceType The inferred sequence type """ try: sequence = self.dicom.scanning_sequence or [] variant = self.dicom.sequence_variant or [] sequence_definition = SequenceTypeDefinition.objects.get( scanning_sequence__contains=sequence, sequence_variant__contains=variant, scanning_sequence__contained_by=sequence, sequence_variant__contained_by=variant, ) except models.ObjectDoesNotExist: pass else: return sequence_definition.sequence_type def infer_sequence_type(self) -> SequenceType: """ Tries to infer the sequence type using associated data. Returns ------- SequenceType The inferred sequence type """ if self.dicom: return self.infer_sequence_type_from_dicom() def get_default_nifti_dir(self) -> Path: """ Returns the default location for the creation of a NIfTI version of the scan. Currently only conversion from DICOM is supported. Returns ------- str Default location for conversion output """ if self.dicom: path = str(self.dicom.path).replace("DICOM", "NIfTI") return Path(path) def get_default_nifti_name(self) -> str: """ Returns the default file name for a NIfTI version of this scan. Returns ------- str Default file name """ return str(self.id) def get_default_nifti_destination(self) -> Path: """ Returns the default path for a NIfTI version of this scan. Returns ------- str Default path for NIfTI file """ directory = self.get_default_nifti_dir() name = self.get_default_nifti_name() return directory / name def get_bids_destination(self) -> Path: """ Returns the BIDS-compatible destination of this scan's associated :class:`~django_mri.models.nifti.NIfTI` file. Returns ------- pathlib.Path BIDS-compatible NIfTI file destination """ try: bids_path = Bids().compose_bids_path(self) except ValueError as e: print(e.args) return None return bids_path def compile_to_bids(self, bids_path: Path): """ Fix some BIDS related issues after NIfTI coversion. Parameters ---------- bids_path : Path Scan's BIDS path """ bids = Bids() bids.clean_unwanted_files(bids_path) bids.fix_functional_json(bids_path) if "fmap" in bids_path.parent.name: bids.modify_fieldmaps(bids_path.with_suffix(".json")) if "func" in bids_path.parent.name: bids.fix_sbref(bids_path) def dicom_to_nifti( self, destination: Path = None, compressed: bool = True, generate_json: bool = True, ) -> NIfTI: """ Convert this scan from DICOM to NIfTI using _dcm2niix. .. _dcm2niix: https://github.com/rordenlab/dcm2niix Parameters ---------- destination : Path, optional The desired path for conversion output (the default is None, which will create the file in some default location) Raises ------ AttributeError If no DICOM series is related to this scan Returns ------- NIfTI A :class:`django_mri.NIfTI` instance referencing the conversion output """ if self.sequence_type and self.sequence_type.title == "Localizer": warnings.warn(messages.NO_LOCALIZER_NIFTI) elif self.dicom: bids = False if destination is None: destination = self.get_bids_destination() if destination is None: destination = self.get_default_nifti_destination() else: bids = True elif not isinstance(destination, Path): destination = Path(destination) destination.parent.mkdir(exist_ok=True, parents=True) nifti_path = Dcm2niix().convert( self.dicom.path, destination, compressed=compressed, generate_json=generate_json, ) if bids: self.compile_to_bids(destination) nifti = NIfTI.objects.create(path=nifti_path, is_raw=True) return nifti else: message = messages.DICOM_TO_NIFTI_NO_DICOM.format(scan_id=self.id) raise AttributeError(message) def warn_subject_mismatch(self, subject): """ Warns the user regarding a mismatch in subject identity. Parameters ---------- subject : django.db.models.Model Suggested subject identity """ message = messages.SUBJECT_MISMATCH.format( scan_id=self.id, existing_subject_id=self.session.subject.id, assigned_subject_id=subject.id, ) warnings.warn(message) def convert_to_mif(self) -> Path: """ Creates a *.mif* version of this scan using mrconvert_. .. _mrconvert: https://mrtrix.readthedocs.io/en/latest/reference/commands/mrconvert.html Returns ------- Path Created file path """ from django_mri.analysis.utils.get_mrconvert_node import ( get_mrconvert_node, ) node, created = get_mrconvert_node() out_file = self.get_default_mif_path() if not out_file.parent.exists(): out_file.parent.mkdir() return node.run( inputs={ "in_file": self.nifti, "out_file": out_file, "in_bval": self.nifti.b_value_file, "in_bvec": self.nifti.b_vector_file, }) def get_default_mif_path(self) -> Path: """ Returns the default *.mif* path for this scan. Returns ------- Path Default *.mif* path """ return get_mri_root() / "mif" / f"{self.id}.mif" def get_dicom_representation(self) -> str: """ Returns the expected DICOM representation of this scan as part of any analysis' input specification. Returns ------- str Input value DICOM representation of this scan See Also -------- :property:`dicom_representation` """ if self.dicom: return str(self.dicom.path) def get_nifti_representation(self) -> str: """ Returns the expected NIfTI representation of this scan as part of any analysis' input specification. Returns ------- str Input value NIfTI representation of this scan See Also -------- :property:`nifti_representation` """ if self._nifti: return str(self._nifti.path) def get_mif_representation(self) -> str: """ Returns the expected *.mif* representation of this scan as part of any analysis' input specification. Returns ------- str Input value NIfTI representation of this scan See Also -------- :property:`mif_representation` """ destination = self.get_default_mif_path() if destination.exists(): return str(destination) def query_input_set(self) -> models.QuerySet: """ Returns a queryset of :class:`~django_analyses.models.input.input.Input` subclass instances in which this scan is represented. Returns ------- models.QuerySet Input queryset """ all_input_ids = [] for InputClass, filter_key in self.DERIVATIVE_QUERY.items(): query = models.Q() for representation in self.REPRESENTATIONS: value = getattr(self, representation, None) if value is not None: query |= models.Q(**{filter_key: value}) inputs = InputClass.objects.filter(query) input_ids = list(inputs.values_list("id", flat=True)) all_input_ids += input_ids return Input.objects.filter(id__in=all_input_ids).select_subclasses() def query_run_set(self) -> models.QuerySet: """ Returns a queryset of :class:`~django_analyses.models.run.Run` instances in which this scan was included in the inputs. Returns ------- models.QuerySet Input queryset """ run_ids = self.input_set.values_list("run", flat=True) return Run.objects.filter(id__in=run_ids) def query_derivatives(self) -> Dict[Run, Dict[str, Any]]: """ Returns a dictionary of associated runs and their outputs. Returns ------- Dict[Run, Dict[str, Any]] Derivatives """ return {run: run.output_configuration for run in self.query_run_set()} def html_plot(self): # First make sure an associated NIfTI instance exists or create it. nii = self._nifti if not nii: try: nii = self.nifti except RuntimeError as e: # In case NIfTI conversion fails, return exception message. message = messages.NIFTI_CONVERSION_FAILURE_HTML.format( scan_id=self.id, exception=e) return message else: if not isinstance(nii, NIfTI): e = "NIfTI format generation failure" message = messages.NIFTI_CONVERSION_FAILURE_HTML.format( scan_id=self.id, exception=e) return message # Determine the number of dimensions. has_3d_flag = any( [flag in self.description.lower() for flag in FLAG_3D]) has_4d_flag = any( [flag in self.description.lower() for flag in FLAG_4D]) if not (has_3d_flag or has_4d_flag): data = nii.get_data() ndim = data.ndim else: ndim = 3 if has_3d_flag else 4 # 3D parameters. if ndim == 3: image = str(nii.path) title = self.description # 4D parameters. elif ndim == 4: image = mean_img(str(nii.path)) title = f"{self.description} (Mean Image)" return view_img( image, bg_img=False, cmap=cm.black_blue, symmetric_cmap=False, title=title, ) @property def mif(self) -> Path: """ Returns the *.mif* version of this scan, creating it if it doesn't exist. Returns ------- Path *.mif* file path See Also -------- * :meth:`convert_to_mif` """ destination = self.get_default_mif_path() if not destination.exists(): self.convert_to_mif() return destination @property def mif_representation(self) -> str: """ Returns the expected *.mif* representation of this scan as part of any analysis' input specification. Returns ------- str Input value NIfTI representation of this scan See Also -------- :func:`get_mif_representation` """ return self.get_mif_representation() @property def sequence_type(self) -> SequenceType: """ Returns the sequence type instance fitting this scan if one exists. See Also -------- * :meth:`infer_sequence_type` * :class:`django_mri.models.sequence_type.SequenceType` Returns ------- SequenceType Inferred sequence type """ return self.infer_sequence_type() @property def nifti(self) -> NIfTI: """ Returns the associated :class:`~django_mri.models.nifti.NIfTI` instance if one exists, or tries to create one if it doesn't. Returns ------- NIfTI Associated NIfTI instance """ if not isinstance(self._nifti, NIfTI): self._nifti = self.dicom_to_nifti() self.save() return self._nifti @property def nifti_representation(self) -> str: """ Returns the expected NIfTI representation of this scan as part of any analysis' input specification. Returns ------- str Input value NIfTI representation of this scan See Also -------- :func:`get_nifti_representation` """ return self.get_nifti_representation() @property def dicom_representation(self) -> str: """ Returns the expected DICOM representation of this scan as part of any analysis' input specification. Returns ------- str Input value DICOM representation of this scan See Also -------- :func:`get_dicom_representation` """ return self.get_dicom_representation() @property def subject_age(self) -> float: """ Returns the subject's age in years at the time of the scan. If the subject's date of birth or the scan's acquisition time are not available, returns `None`. Returns ------- float Subject age in years at the time of the scan's acquisition """ conditions = (self.time and self.session and self.session.subject and self.session.subject.date_of_birth) if conditions: delta = self.time.date() - self.session.subject.date_of_birth return delta.total_seconds() / (60 * 60 * 24 * 365) @property def input_set(self) -> models.QuerySet: """ Returns a queryset of :class:`~django_analyses.models.input.input.Input` subclass instances in which this scan is represented. Returns ------- models.QuerySet Input queryset See Also -------- :func:`query_input_set` """ return self.query_input_set()
def test_get_group_model(self): result = utils.get_group_model() self.assertEqual(result, Group)