Precision Imaging for Intraindividual Investigation of the Reward Response.
The 7 matches · 1 of them tie a paragraph to a whole file, not to given lines: a weak match, whose lines are not tinted
- [1] § Methods › Neuroimaging Acquisition ↔ code/audit_neuromelanin.py, lines 746–875 · score 0.92 · magnetization transfer, partial Fourier, flip angle, gradient echo, echo sequence, coil
- [2] § Methods › fMRI Tasks ↔ stimuli/sharedreward/genTrialList.m, the whole file · a weak match · score 0.70 · decision phase, 2550 ms, 850 ms, stranger, punishment, computer
- [3] § Methods › fMRI Tasks ↔ stimuli/SharedReward_5button.py, lines 266–335 · score 0.68 · 1000–4000 ms, outcome phase, Shared Reward, card, money, feedback
- [4] § Methods ↔ code/audit_neuromelanin.py, lines 1444–1555 · score 0.56 · OpenNeuro, Night Owls, NOSC, BIDS, Scan
- [5] § Methods › fMRI Tasks ↔ stimuli/MID_5button.py, lines 520–587 · score 0.55 · Trial earnings, reward trials, cue, money, win, MID
- [6] § Methods › fMRI Tasks ↔ stimuli/MID_practice.py, lines 485–548 · score 0.55 · Trial earnings, reward trials, cue, money, win, MID
- [7] § Methods › fMRI Analysis and Moderating Factors ↔ masks/resample_to_study_mni_grid.sh, lines 1–77 · score 0.50 · NAcc, binarized, binary, thresholded, map, mask
Paper
Loaded from Europe PMC by your browser, not stored by OSCR: doi.org · Europe PMC
The paper is loaded when this pane is shown.
The authors' code
Python · 1,677 lines · 63 KB · MIT · 2 matches
- #!/usr/bin/env python3
- """Audit raw Night Owls neuromelanin DICOM series without modifying source data.
- The script reads one representative header from every raw DICOM series, identifies
- neuromelanin candidates through both names and acquisition signatures, fingerprints
- scientifically meaningful settings, and reconciles raw series against BIDS scans.tsv
- inventories. Pixel data are never read. DICOM UIDs, dates, times, paths, and patient
- fields are never written; dates and times are used only transiently for reconciliation.
- """
- from __future__ import annotations
- import argparse
- import csv
- import datetime as dt
- import hashlib
- import json
- import logging
- import math
- import os
- import re
- import sys
- from collections import Counter, defaultdict
- from pathlib import Path
- from typing import Any, Iterable, Mapping, Sequence
- try:
- import pydicom
- except ImportError: # pragma: no cover - exercised by CLI setup failures
- pydicom = None
- LOG = logging.getLogger("neuromelanin_audit")
- NA = "n/a"
- # Deliberately excludes the generic token "NM", which has many false positives
- # unless it is supported by acquisition parameters.
- DEFAULT_NM_PATTERN = re.compile(
- r"neuromelanin|neuro[\W_]*melanin|\bnmri\b|substantia[\W_]*nigra",
- re.IGNORECASE,
- )
- PLAUSIBLE_PATTERN = re.compile(
- r"\bt2[\W_]*star\b|t2\*|magnetization[\W_]*transfer|"
- r"\bmt[\W_]*(?:gre|flash|tfl)\b",
- re.IGNORECASE,
- )
- GENERIC_NM_PATTERN = re.compile(r"(?:^|[\W_])nm(?:$|[\W_])", re.IGNORECASE)
- LIKELY_INTENTIONAL_EXCLUSION_PATTERN = re.compile(
- r"localizer|scout|phoenix|survey|t1[\W_]*fl2d|setter",
- re.IGNORECASE,
- )
- SOURCE_SESSION_PATTERN = re.compile(
- r"Smith-NOSC-(?P<participant>[A-Za-z0-9]+)-SES(?P<session>[A-Za-z0-9]+)",
- re.IGNORECASE,
- )
- BIDS_SESSION_PATTERN = re.compile(
- r"sub-(?P<participant>[A-Za-z0-9]+).*?ses-(?P<session>[A-Za-z0-9]+)",
- re.IGNORECASE,
- )
- MANIFEST_FIELDS = [
- "participant_id",
- "session_id",
- "series_number",
- "protocol_name",
- "series_description",
- "source_series_name",
- "candidate_reason",
- "sequence_version",
- "sequence_fingerprint_sha256",
- "n_dicoms",
- "expected_even_session",
- "audit_status",
- "manufacturer",
- "manufacturer_model",
- "software_versions",
- "magnetic_field_strength_T",
- "sequence_name",
- "pulse_sequence_name",
- "echo_pulse_sequence",
- "steady_state_pulse_sequence",
- "scanning_sequence",
- "sequence_variant",
- "scan_options",
- "mr_acquisition_type",
- "repetition_time_ms",
- "echo_time_ms",
- "inversion_time_ms",
- "flip_angle_deg",
- "echo_train_length",
- "rf_echo_train_length",
- "gradient_echo_train_length",
- "number_of_averages",
- "slice_thickness_mm",
- "spacing_between_slices_mm",
- "pixel_spacing_row_mm",
- "pixel_spacing_col_mm",
- "rows",
- "columns",
- "acquisition_matrix",
- "acquisition_frequency_encoding_steps",
- "acquisition_phase_encoding_steps_in_plane",
- "acquisition_phase_encoding_steps_out_of_plane",
- "percent_sampling",
- "percent_phase_fov",
- "pixel_bandwidth_hz",
- "receive_coil_name",
- "in_plane_phase_encoding_direction",
- "images_in_acquisition",
- "number_of_frames",
- "number_of_slices",
- "estimated_slice_coverage_mm",
- "number_of_volumes",
- "number_of_multiframe_instances",
- "parallel_reduction_factor_in_plane",
- "parallel_reduction_factor_out_of_plane",
- "parallel_acquisition_technique",
- "partial_fourier",
- "partial_fourier_direction",
- "magnetization_transfer",
- "siemens_mt_pulse_count",
- "siemens_mt_frequency_offset_hz",
- "siemens_mt_flip_angle_deg",
- "siemens_mt_duration_us",
- ]
- FINGERPRINT_FIELDS = [
- "sequence_name",
- "pulse_sequence_name",
- "echo_pulse_sequence",
- "steady_state_pulse_sequence",
- "scanning_sequence",
- "sequence_variant",
- "scan_options",
- "mr_acquisition_type",
- "repetition_time_ms",
- "echo_time_ms",
- "inversion_time_ms",
- "flip_angle_deg",
- "echo_train_length",
- "rf_echo_train_length",
- "gradient_echo_train_length",
- "number_of_averages",
- "slice_thickness_mm",
- "spacing_between_slices_mm",
- "pixel_spacing_row_mm",
- "pixel_spacing_col_mm",
- "rows",
- "columns",
- "acquisition_matrix",
- "acquisition_frequency_encoding_steps",
- "acquisition_phase_encoding_steps_in_plane",
- "acquisition_phase_encoding_steps_out_of_plane",
- "percent_sampling",
- "percent_phase_fov",
- "pixel_bandwidth_hz",
- "in_plane_phase_encoding_direction",
- "number_of_slices",
- "estimated_slice_coverage_mm",
- "number_of_volumes",
- "number_of_multiframe_instances",
- "parallel_reduction_factor_in_plane",
- "parallel_reduction_factor_out_of_plane",
- "parallel_acquisition_technique",
- "partial_fourier",
- "partial_fourier_direction",
- "magnetization_transfer",
- "siemens_mt_pulse_count",
- "siemens_mt_frequency_offset_hz",
- "siemens_mt_flip_angle_deg",
- "siemens_mt_duration_us",
- ]
- SUMMARY_FIELDS = [
- "sequence_version",
- "sequence_fingerprint_sha256",
- "n_series",
- "n_sessions",
- "n_participants",
- "participants",
- "sessions",
- "protocol_names",
- "series_descriptions",
- ] + [
- field
- for field in MANIFEST_FIELDS
- if field
- in {
- "manufacturer",
- "manufacturer_model",
- "software_versions",
- "magnetic_field_strength_T",
- *FINGERPRINT_FIELDS,
- "receive_coil_name",
- "images_in_acquisition",
- "number_of_frames",
- }
- ]
- INVENTORY_FIELDS = [
- "participant_id",
- "session_id",
- "session_parity",
- "nm_expected_from_design",
- "nm_present",
- "n_nm_series",
- "sequence_version",
- "status",
- "notes",
- ]
- REVIEW_FIELDS = [
- "participant_id",
- "session_id",
- "series_number",
- "protocol_name",
- "series_description",
- "source_series_name",
- "review_reason",
- "nm_signature_score",
- "signature_matches",
- "bids_counterpart_status",
- "software_versions",
- "sequence_name",
- "pulse_sequence_name",
- "scanning_sequence",
- "mr_acquisition_type",
- "repetition_time_ms",
- "echo_time_ms",
- "flip_angle_deg",
- "slice_thickness_mm",
- "spacing_between_slices_mm",
- "pixel_spacing_row_mm",
- "pixel_spacing_col_mm",
- "rows",
- "columns",
- "number_of_slices",
- "estimated_slice_coverage_mm",
- "number_of_volumes",
- "number_of_multiframe_instances",
- ]
- RECONCILIATION_FIELDS = [
- "participant_id",
- "session_id",
- "session_parity",
- "series_number",
- "source_series_name",
- "protocol_name",
- "series_description",
- "n_dicoms",
- "bids_counterpart_status",
- "bids_match_evidence",
- "likely_intentional_exclusion",
- "nm_name_match",
- "nm_signature_score",
- "signature_matches",
- "software_versions",
- "sequence_name",
- "pulse_sequence_name",
- "scanning_sequence",
- "sequence_variant",
- "scan_options",
- "mr_acquisition_type",
- "repetition_time_ms",
- "echo_time_ms",
- "flip_angle_deg",
- "slice_thickness_mm",
- "spacing_between_slices_mm",
- "pixel_spacing_row_mm",
- "pixel_spacing_col_mm",
- "rows",
- "columns",
- "number_of_slices",
- "estimated_slice_coverage_mm",
- "number_of_volumes",
- "number_of_multiframe_instances",
- ]
- OMISSION_SUMMARY_FIELDS = [
- "software_versions",
- "protocol_name",
- "series_description",
- "source_series_labels",
- "sequence_name",
- "pulse_sequence_name",
- "repetition_time_ms",
- "echo_time_ms",
- "flip_angle_deg",
- "slice_thickness_mm",
- "spacing_between_slices_mm",
- "rows",
- "columns",
- "number_of_slices",
- "estimated_slice_coverage_mm",
- "number_of_volumes",
- "number_of_multiframe_instances",
- "nm_signature_score",
- "signature_matches",
- "likely_intentional_exclusion",
- "n_series",
- "n_sessions",
- "n_even_sessions",
- "n_odd_sessions",
- "participants",
- "sessions",
- ]
- PRIVATE_MT_KEYS = {
- "siemens_mt_pulse_count": (
- "sPrepPulses.lNoOfMTCPulses",
- "sPrepPulses.lNoOfMtcPulses",
- ),
- "siemens_mt_frequency_offset_hz": (
- "sPrepPulses.dMTCFrequencyOffset",
- "sPrepPulses.dMTFrequencyOffset",
- ),
- "siemens_mt_flip_angle_deg": (
- "sPrepPulses.dMTFlipAngle",
- "sPrepPulses.dMTCFlipAngle",
- ),
- "siemens_mt_duration_us": (
- "sPrepPulses.dMTDuration",
- "sPrepPulses.dMTCDuration",
- ),
- }
- def natural_key(value: str) -> tuple[Any, ...]:
- """Return a deterministic human-friendly key for identifiers and labels."""
- return tuple(int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", value))
- def clean_text(value: Any) -> str:
- """Convert a DICOM value to one safe, single-line TSV cell."""
- if value is None or value == "":
- return NA
- if isinstance(value, bytes):
- value = value.decode("latin-1", errors="replace")
- if isinstance(value, (list, tuple)) or value.__class__.__name__ == "MultiValue":
- value = "\\".join(str(item) for item in value)
- text = str(value).replace("\t", " ").replace("\r", " ").replace("\n", " ")
- text = re.sub(r"\s+", " ", text).strip()
- return text if text else NA
- def normalized_number(value: Any, digits: int = 6) -> str:
- """Normalize numerical DICOM values so trivial float formatting does not split versions."""
- if value is None or value == "":
- return NA
- try:
- number = float(value)
- except (TypeError, ValueError):
- return clean_text(value)
- if not math.isfinite(number):
- return NA
- if abs(number - round(number)) < 10 ** (-digits):
- return str(int(round(number)))
- return f"{number:.{digits}f}".rstrip("0").rstrip(".")
- def normalize_identifier(prefix: str, value: str) -> str:
- value = re.sub(rf"^{re.escape(prefix)}-", "", value, flags=re.IGNORECASE)
- if value.isdigit():
- value = value.zfill(2 if prefix == "ses" else 3)
- return f"{prefix}-{value}"
- def parse_session_from_path(path: Path) -> tuple[str, str] | None:
- joined = "/".join(path.parts)
- match = SOURCE_SESSION_PATTERN.search(joined) or BIDS_SESSION_PATTERN.search(joined)
- if not match:
- return None
- return (
- normalize_identifier("sub", match.group("participant")),
- normalize_identifier("ses", match.group("session")),
- )
- def session_number(session_id: str) -> int | None:
- match = re.search(r"(\d+)$", session_id)
- return int(match.group(1)) if match else None
- def expected_even(session_id: str) -> bool | None:
- number = session_number(session_id)
- return None if number is None else number % 2 == 0
- def yes_no(value: bool | None) -> str:
- if value is None:
- return NA
- return "yes" if value else "no"
- def number(value: str) -> float | None:
- if value == NA:
- return None
- try:
- result = float(value)
- except (TypeError, ValueError):
- return None
- return result if math.isfinite(result) else None
- def near(value: str, target: float, tolerance: float) -> bool:
- parsed = number(value)
- return parsed is not None and abs(parsed - target) <= tolerance
- def acquisition_clock_seconds(dataset: Any) -> float | None:
- """Return month/day/time as a year-independent scalar for private in-memory matching."""
- date_value = get_value(dataset, "AcquisitionDate") or get_value(dataset, "SeriesDate")
- time_value = get_value(dataset, "AcquisitionTime") or get_value(dataset, "SeriesTime")
- combined = clean_text(get_value(dataset, "AcquisitionDateTime"))
- if combined != NA and len(re.sub(r"\D", "", combined.split(".", 1)[0])) >= 14:
- date_value = combined[:8]
- time_value = combined[8:]
- if not date_value or not time_value:
- return None
- date_digits = re.sub(r"\D", "", str(date_value))
- time_text = str(time_value).strip()
- match = re.match(r"(?P<h>\d{2})(?P<m>\d{2})(?P<s>\d{2}(?:\.\d+)?)", time_text)
- if len(date_digits) < 8 or not match:
- return None
- try:
- anchor = dt.datetime(2000, int(date_digits[4:6]), int(date_digits[6:8]))
- seconds = (
- int(match.group("h")) * 3600
- + int(match.group("m")) * 60
- + float(match.group("s"))
- )
- except (TypeError, ValueError):
- return None
- return (anchor - dt.datetime(2000, 1, 1)).days * 86400 + seconds
- def bids_clock_seconds(value: str) -> float | None:
- try:
- parsed = dt.datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
- anchor = dt.datetime(2000, parsed.month, parsed.day)
- except (TypeError, ValueError):
- return None
- return (
- (anchor - dt.datetime(2000, 1, 1)).days * 86400
- + parsed.hour * 3600
- + parsed.minute * 60
- + parsed.second
- + parsed.microsecond / 1_000_000
- )
- def neuromelanin_signature(row: Mapping[str, str]) -> tuple[int, str]:
- """Score name-independent similarity to the observed E11/XA30 NM acquisition."""
- matches: list[str] = []
- score = 0
- if near(row.get("repetition_time_ms", NA), 641, 25):
- score += 2
- matches.append("TR_about_641ms")
- if near(row.get("echo_time_ms", NA), 3.97, 0.35):
- score += 2
- matches.append("TE_about_3.97ms")
- if near(row.get("flip_angle_deg", NA), 50, 5):
- score += 2
- matches.append("flip_angle_about_50deg")
- row_spacing = number(row.get("pixel_spacing_row_mm", NA))
- col_spacing = number(row.get("pixel_spacing_col_mm", NA))
- if (
- row_spacing is not None
- and col_spacing is not None
- and 0.55 <= row_spacing <= 0.75
- and 0.55 <= col_spacing <= 0.75
- ):
- score += 1
- matches.append("in_plane_resolution_about_0.64mm")
- sequence_text = " ".join(
- row.get(field, NA)
- for field in (
- "sequence_name",
- "pulse_sequence_name",
- "scanning_sequence",
- "mr_acquisition_type",
- )
- ).lower()
- if "fl2d1" in sequence_text or (
- "gradient" in sequence_text and "2d" in sequence_text
- ):
- score += 1
- matches.append("2D_gradient_echo")
- if any(row.get(field, NA) == "6" for field in (
- "number_of_averages",
- "number_of_volumes",
- "number_of_multiframe_instances",
- )):
- score += 1
- matches.append("six_repeats")
- if row.get("number_of_slices", NA) == "24":
- score += 1
- matches.append("24_slices")
- return score, "+".join(matches) if matches else NA
- def estimated_slice_coverage(row: Mapping[str, str]) -> str:
- """Estimate first-to-last outer-edge coverage from slice count and spacing."""
- slices = number(row.get("number_of_slices", NA))
- thickness = number(row.get("slice_thickness_mm", NA))
- spacing = number(row.get("spacing_between_slices_mm", NA))
- if slices is None or slices < 1 or thickness is None:
- return NA
- step = spacing if spacing is not None else thickness
- return normalized_number(thickness + max(0, slices - 1) * step)
- def source_series_label(path: Path) -> str:
- """Return a scan-folder label for matching only; it is never emitted to output."""
- parts = list(path.parts)
- lowered = [part.lower() for part in parts]
- if "scans" in lowered:
- index = len(lowered) - 1 - lowered[::-1].index("scans")
- if index + 1 < len(parts):
- return parts[index + 1]
- return path.parent.parent.parent.name if len(parts) >= 4 else path.name
- def discover_series(source_root: Path) -> tuple[list[tuple[Path, list[Path]]], set[tuple[str, str]]]:
- """Discover historical XNAT DICOM resource directories and all source sessions."""
- series: list[tuple[Path, list[Path]]] = []
- sessions: set[tuple[str, str]] = set()
- for current, dirs, files in os.walk(source_root):
- current_path = Path(current)
- parsed = parse_session_from_path(current_path)
- if parsed:
- sessions.add(parsed)
- if not files:
- continue
- parent_names = [part.lower() for part in current_path.parts[-3:]]
- is_dicom_resource = (
- current_path.name.lower() == "files"
- and "dicom" in parent_names
- ) or current_path.name.lower() == "dicom"
- if not is_dicom_resource:
- continue
- paths = sorted(
- (current_path / name for name in files if (current_path / name).is_file()),
- key=lambda item: natural_key(item.name),
- )
- if paths:
- series.append((current_path, paths))
- dirs[:] = []
- series.sort(key=lambda item: natural_key(str(item[0])))
- return series, sessions
- def read_representative(files: Sequence[Path]) -> tuple[Any | None, int]:
- """Read the first valid DICOM header and return it with the unreadable-file count."""
- unreadable = 0
- for path in files:
- try:
- dataset = pydicom.dcmread(path, stop_before_pixels=True, force=True)
- except Exception:
- unreadable += 1
- continue
- if any(
- getattr(dataset, keyword, None)
- for keyword in ("Modality", "SOPClassUID", "ProtocolName", "SeriesDescription")
- ):
- return dataset, unreadable
- unreadable += 1
- return None, unreadable
- def get_value(dataset: Any, keyword: str) -> Any:
- value = getattr(dataset, keyword, None)
- return value.value if hasattr(value, "value") else value
- def get_tag_value(dataset: Any, group: int, element: int) -> Any:
- entry = dataset.get((group, element))
- return entry.value if entry is not None else None
- def functional_group_roots(dataset: Any) -> Iterable[Any]:
- """Yield shared and one representative per-frame functional-group item."""
- shared = get_value(dataset, "SharedFunctionalGroupsSequence")
- if shared:
- yield shared[0]
- per_frame = get_value(dataset, "PerFrameFunctionalGroupsSequence")
- if per_frame:
- yield per_frame[0]
- def acquisition_value(
- dataset: Any,
- keyword: str,
- sequence_keywords: Sequence[str] = (),
- ) -> Any:
- """Read a top-level value or its Enhanced MR functional-group equivalent."""
- value = get_value(dataset, keyword)
- if value not in (None, ""):
- return value
- for root in functional_group_roots(dataset):
- for sequence_keyword in sequence_keywords:
- sequence = get_value(root, sequence_keyword)
- if not sequence:
- continue
- value = get_value(sequence[0], keyword)
- if value not in (None, ""):
- return value
- return None
- def private_text(dataset: Any) -> str:
- """Read only Siemens CSA blobs in memory; never return or write the full contents."""
- chunks: list[str] = []
- for tag in ((0x0029, 0x1010), (0x0029, 0x1020)):
- value = get_tag_value(dataset, *tag)
- if isinstance(value, bytes):
- chunks.append(value.decode("latin-1", errors="ignore"))
- elif value:
- chunks.append(str(value))
- return "\n".join(chunks)
- def extract_private_mt(dataset: Any) -> dict[str, str]:
- text = private_text(dataset)
- values = {field: NA for field in PRIVATE_MT_KEYS}
- if not text:
- return values
- for field, keys in PRIVATE_MT_KEYS.items():
- for key in keys:
- match = re.search(
- rf"(?m){re.escape(key)}\s*=\s*([^\x00\r\n]+)",
- text,
- )
- if match:
- raw = match.group(1).split("#", 1)[0].strip()
- values[field] = normalized_number(raw)
- break
- return values
- def acquisition_matrix(dataset: Any) -> str:
- value = acquisition_value(dataset, "AcquisitionMatrix", ("MRFOVGeometrySequence",))
- if value is None:
- return NA
- try:
- return "x".join(str(int(item)) for item in value)
- except (TypeError, ValueError):
- return clean_text(value)
- def pixel_spacing(dataset: Any) -> tuple[str, str]:
- value = acquisition_value(dataset, "PixelSpacing", ("PixelMeasuresSequence",))
- try:
- return normalized_number(value[0]), normalized_number(value[1])
- except (TypeError, IndexError):
- return NA, NA
- def infer_number_of_slices(dataset: Any, n_dicoms: int) -> str:
- images = get_value(dataset, "ImagesInAcquisition")
- frames = get_value(dataset, "NumberOfFrames")
- if images not in (None, ""):
- return normalized_number(images)
- if frames not in (None, "") and normalized_number(frames) != "1":
- return normalized_number(frames)
- return NA
- def infer_multiframe_instances(dataset: Any, n_dicoms: int) -> str:
- frames = normalized_number(get_value(dataset, "NumberOfFrames"))
- if frames not in (NA, "1"):
- return str(n_dicoms)
- return NA
- def infer_series_layout(files: Sequence[Path], dataset: Any) -> dict[str, str]:
- """Infer slice and volume counts without treating all 2D instances as slices."""
- frames = normalized_number(get_value(dataset, "NumberOfFrames"))
- if frames not in (NA, "1"):
- return {
- "number_of_slices": frames,
- "number_of_volumes": str(len(files)),
- "number_of_multiframe_instances": str(len(files)),
- }
- positions: set[tuple[float, ...]] = set()
- slice_locations: set[float] = set()
- temporal_positions: set[str] = set()
- tags = [
- "ImagePositionPatient",
- "SliceLocation",
- "TemporalPositionIdentifier",
- "AcquisitionNumber",
- ]
- for path in files:
- try:
- header = pydicom.dcmread(
- path,
- stop_before_pixels=True,
- force=True,
- specific_tags=tags,
- )
- except Exception:
- continue
- position = get_value(header, "ImagePositionPatient")
- if position:
- try:
- positions.add(tuple(round(float(value), 4) for value in position))
- except (TypeError, ValueError):
- pass
- location = get_value(header, "SliceLocation")
- if location not in (None, ""):
- try:
- slice_locations.add(round(float(location), 4))
- except (TypeError, ValueError):
- pass
- temporal = get_value(header, "TemporalPositionIdentifier")
- if temporal in (None, ""):
- temporal = get_value(header, "AcquisitionNumber")
- if temporal not in (None, ""):
- temporal_positions.add(clean_text(temporal))
- n_slices = len(positions) or len(slice_locations)
- if not n_slices:
- return {
- "number_of_slices": infer_number_of_slices(dataset, len(files)),
- "number_of_volumes": (
- str(len(temporal_positions)) if temporal_positions else NA
- ),
- "number_of_multiframe_instances": NA,
- }
- if temporal_positions:
- n_volumes = len(temporal_positions)
- elif len(files) % n_slices == 0:
- n_volumes = len(files) // n_slices
- else:
- n_volumes = 0
- return {
- "number_of_slices": str(n_slices),
- "number_of_volumes": str(n_volumes) if n_volumes else NA,
- "number_of_multiframe_instances": NA,
- }
- def extract_metadata(dataset: Any, n_dicoms: int) -> dict[str, str]:
- row_spacing, col_spacing = pixel_spacing(dataset)
- timing_sequences = ("MRTimingAndRelatedParametersSequence",)
- echo_sequences = ("MREchoSequence",)
- modifier_sequences = ("MRModifierSequence", "MRImagingModifierSequence")
- fov_sequences = ("MRFOVGeometrySequence",)
- imaging_modifier_sequences = (
- "MRImagingModifierSequence",
- "MRModifierSequence",
- "MRFOVGeometrySequence",
- )
- metadata = {
- "series_number": normalized_number(get_value(dataset, "SeriesNumber")),
- "protocol_name": clean_text(get_value(dataset, "ProtocolName")),
- "series_description": clean_text(get_value(dataset, "SeriesDescription")),
- "manufacturer": clean_text(get_value(dataset, "Manufacturer")),
- "manufacturer_model": clean_text(get_value(dataset, "ManufacturerModelName")),
- "software_versions": clean_text(get_value(dataset, "SoftwareVersions")),
- "magnetic_field_strength_T": normalized_number(get_value(dataset, "MagneticFieldStrength")),
- "sequence_name": clean_text(get_value(dataset, "SequenceName")),
- "pulse_sequence_name": clean_text(
- acquisition_value(dataset, "PulseSequenceName", modifier_sequences)
- ),
- "echo_pulse_sequence": clean_text(
- acquisition_value(dataset, "EchoPulseSequence", modifier_sequences)
- ),
- "steady_state_pulse_sequence": clean_text(
- acquisition_value(dataset, "SteadyStatePulseSequence", modifier_sequences)
- ),
- "scanning_sequence": clean_text(get_value(dataset, "ScanningSequence")),
- "sequence_variant": clean_text(get_value(dataset, "SequenceVariant")),
- "scan_options": clean_text(get_value(dataset, "ScanOptions")),
- "mr_acquisition_type": clean_text(get_value(dataset, "MRAcquisitionType")),
- "repetition_time_ms": normalized_number(
- acquisition_value(dataset, "RepetitionTime", timing_sequences)
- ),
- "echo_time_ms": normalized_number(
- acquisition_value(dataset, "EchoTime", echo_sequences)
- or acquisition_value(dataset, "EffectiveEchoTime", echo_sequences)
- ),
- "inversion_time_ms": normalized_number(
- acquisition_value(dataset, "InversionTime", modifier_sequences)
- or acquisition_value(dataset, "InversionTimes", modifier_sequences)
- ),
- "flip_angle_deg": normalized_number(
- acquisition_value(dataset, "FlipAngle", timing_sequences)
- ),
- "echo_train_length": normalized_number(
- acquisition_value(dataset, "EchoTrainLength", timing_sequences)
- ),
- "rf_echo_train_length": normalized_number(
- acquisition_value(dataset, "RFEchoTrainLength", timing_sequences)
- ),
- "gradient_echo_train_length": normalized_number(
- acquisition_value(dataset, "GradientEchoTrainLength", timing_sequences)
- ),
- "number_of_averages": normalized_number(
- acquisition_value(dataset, "NumberOfAverages", ("MRAveragesSequence",))
- ),
- "slice_thickness_mm": normalized_number(
- acquisition_value(dataset, "SliceThickness", ("PixelMeasuresSequence",))
- ),
- "spacing_between_slices_mm": normalized_number(
- acquisition_value(dataset, "SpacingBetweenSlices", ("PixelMeasuresSequence",))
- ),
- "pixel_spacing_row_mm": row_spacing,
- "pixel_spacing_col_mm": col_spacing,
- "rows": normalized_number(get_value(dataset, "Rows")),
- "columns": normalized_number(get_value(dataset, "Columns")),
- "acquisition_matrix": acquisition_matrix(dataset),
- "acquisition_frequency_encoding_steps": normalized_number(
- acquisition_value(dataset, "MRAcquisitionFrequencyEncodingSteps", fov_sequences)
- ),
- "acquisition_phase_encoding_steps_in_plane": normalized_number(
- acquisition_value(dataset, "MRAcquisitionPhaseEncodingStepsInPlane", fov_sequences)
- ),
- "acquisition_phase_encoding_steps_out_of_plane": normalized_number(
- acquisition_value(dataset, "MRAcquisitionPhaseEncodingStepsOutOfPlane", fov_sequences)
- ),
- "percent_sampling": normalized_number(
- acquisition_value(dataset, "PercentSampling", fov_sequences)
- ),
- "percent_phase_fov": normalized_number(
- acquisition_value(dataset, "PercentPhaseFieldOfView", fov_sequences)
- ),
- "pixel_bandwidth_hz": normalized_number(get_value(dataset, "PixelBandwidth")),
- "receive_coil_name": clean_text(
- acquisition_value(dataset, "ReceiveCoilName", ("MRReceiveCoilSequence",))
- ),
- "in_plane_phase_encoding_direction": clean_text(
- acquisition_value(dataset, "InPlanePhaseEncodingDirection", fov_sequences)
- ),
- "images_in_acquisition": normalized_number(get_value(dataset, "ImagesInAcquisition")),
- "number_of_frames": normalized_number(get_value(dataset, "NumberOfFrames")),
- "number_of_slices": infer_number_of_slices(dataset, n_dicoms),
- "number_of_volumes": NA,
- "number_of_multiframe_instances": infer_multiframe_instances(dataset, n_dicoms),
- "parallel_reduction_factor_in_plane": normalized_number(
- acquisition_value(
- dataset,
- "ParallelReductionFactorInPlane",
- imaging_modifier_sequences,
- )
- ),
- "parallel_reduction_factor_out_of_plane": normalized_number(
- acquisition_value(
- dataset,
- "ParallelReductionFactorOutOfPlane",
- imaging_modifier_sequences,
- )
- ),
- "parallel_acquisition_technique": clean_text(
- acquisition_value(
- dataset,
- "ParallelAcquisitionTechnique",
- imaging_modifier_sequences,
- )
- ),
- "partial_fourier": normalized_number(
- acquisition_value(dataset, "PartialFourier", imaging_modifier_sequences)
- ),
- "partial_fourier_direction": clean_text(
- acquisition_value(dataset, "PartialFourierDirection", imaging_modifier_sequences)
- ),
- "magnetization_transfer": clean_text(
- acquisition_value(dataset, "MagnetizationTransfer", modifier_sequences)
- ),
- }
- metadata.update(extract_private_mt(dataset))
- return metadata
- def candidate_reason(protocol: str, description: str, source_label: str) -> str | None:
- sources = []
- if DEFAULT_NM_PATTERN.search(protocol):
- sources.append("protocol_name")
- if DEFAULT_NM_PATTERN.search(description):
- sources.append("series_description")
- if DEFAULT_NM_PATTERN.search(source_label):
- sources.append("source_series_name")
- return "+".join(sources) if sources else None
- def fingerprint(row: Mapping[str, str]) -> tuple[str, str]:
- content = {field: row.get(field, NA) for field in FINGERPRINT_FIELDS}
- serialized = json.dumps(content, sort_keys=True, separators=(",", ":"))
- return serialized, hashlib.sha256(serialized.encode("utf-8")).hexdigest()
- def discover_bids_sessions(bids_root: Path) -> set[tuple[str, str]]:
- sessions: set[tuple[str, str]] = set()
- if not bids_root.exists():
- LOG.warning("BIDS root does not exist; source-only inventory will be generated")
- return sessions
- for participant_dir in bids_root.glob("sub-*"):
- if not participant_dir.is_dir():
- continue
- for session_dir in participant_dir.glob("ses-*"):
- if session_dir.is_dir():
- sessions.add((participant_dir.name, session_dir.name))
- return sessions
- def audit_series(
- source_root: Path,
- ) -> tuple[
- list[dict[str, str]],
- set[tuple[str, str]],
- int,
- int,
- ]:
- discovered, source_sessions = discover_series(source_root)
- if not discovered:
- raise RuntimeError("No raw DICOM series directories were found under source root")
- rows: list[dict[str, str]] = []
- unreadable_series = 0
- unparsed_series = 0
- for index, (directory, files) in enumerate(discovered, start=1):
- if index % 100 == 0:
- LOG.info("Inspected %d/%d series", index, len(discovered))
- parsed = parse_session_from_path(directory)
- dataset, unreadable_files = read_representative(files)
- label = source_series_label(directory)
- if dataset is None:
- unreadable_series += 1
- participant_id, session_id = parsed if parsed else (NA, NA)
- row = {field: NA for field in MANIFEST_FIELDS}
- row.update(
- {
- "participant_id": participant_id,
- "session_id": session_id,
- "candidate_reason": (
- "source_series_name" if DEFAULT_NM_PATTERN.search(label) else NA
- ),
- "source_series_name": label,
- "n_dicoms": str(len(files)),
- "expected_even_session": (
- yes_no(expected_even(session_id)) if parsed else NA
- ),
- "audit_status": "header_unreadable",
- "_series_instance_uid": NA,
- "_acquisition_clock_seconds": None,
- }
- )
- rows.append(row)
- continue
- metadata = extract_metadata(dataset, len(files))
- metadata.update(infer_series_layout(files, dataset))
- metadata["estimated_slice_coverage_mm"] = estimated_slice_coverage(metadata)
- protocol = metadata["protocol_name"]
- description = metadata["series_description"]
- if not parsed:
- unparsed_series += 1
- participant_id, session_id = parsed if parsed else (NA, NA)
- reason = candidate_reason(protocol, description, label)
- row = {field: NA for field in MANIFEST_FIELDS}
- row.update(metadata)
- row.update(
- {
- "participant_id": participant_id,
- "session_id": session_id,
- "candidate_reason": reason or NA,
- "source_series_name": label,
- "n_dicoms": str(len(files)),
- "expected_even_session": (
- yes_no(expected_even(session_id)) if parsed else NA
- ),
- "audit_status": (
- "ok" if unreadable_files == 0 else "representative_read_after_error"
- ),
- "_series_instance_uid": clean_text(
- get_value(dataset, "SeriesInstanceUID")
- ),
- "_acquisition_clock_seconds": acquisition_clock_seconds(dataset),
- }
- )
- score, matches = neuromelanin_signature(row)
- row["nm_signature_score"] = str(score)
- row["signature_matches"] = matches
- rows.append(row)
- rows.sort(
- key=lambda row: (
- natural_key(row["participant_id"]),
- natural_key(row["session_id"]),
- natural_key(row["series_number"]),
- row["protocol_name"].lower(),
- )
- )
- LOG.info(
- "Inspected %d series without a parseable participant/session",
- unparsed_series,
- )
- return rows, source_sessions, unreadable_series, unparsed_series
- def deduplicate_resource_copies(
- rows: Sequence[dict[str, str]],
- ) -> tuple[list[dict[str, str]], int, int]:
- """Collapse archive-resource copies sharing a Series Instance UID in one session.
- UIDs are used only in memory and are removed before any output is written. When
- copies differ in file count, the most complete resource is retained and flagged.
- """
- grouped: dict[tuple[str, str, str], list[dict[str, str]]] = defaultdict(list)
- ungrouped: list[dict[str, str]] = []
- for row in rows:
- uid = row.get("_series_instance_uid", NA)
- if uid == NA:
- ungrouped.append(row)
- else:
- grouped[(row["participant_id"], row["session_id"], uid)].append(row)
- retained = list(ungrouped)
- duplicate_resources = 0
- incomplete_copy_groups = 0
- for group in grouped.values():
- counts = {int(row["n_dicoms"]) for row in group}
- ranked = sorted(
- group,
- key=lambda row: (
- -int(row["n_dicoms"]),
- -sum(row.get(field, NA) != NA for field in FINGERPRINT_FIELDS),
- json.dumps(
- {field: row.get(field, NA) for field in MANIFEST_FIELDS},
- sort_keys=True,
- ),
- ),
- )
- selected = ranked[0]
- if len(group) > 1:
- duplicate_resources += len(group) - 1
- statuses = [] if selected["audit_status"] == "ok" else selected["audit_status"].split(";")
- statuses.append(f"duplicate_source_resources_excluded={len(group) - 1}")
- if len(counts) > 1:
- incomplete_copy_groups += 1
- statuses.append("incomplete_source_copy_excluded")
- selected["audit_status"] = ";".join(dict.fromkeys(statuses))
- retained.append(selected)
- for row in retained:
- row.pop("_series_instance_uid", None)
- retained.sort(
- key=lambda row: (
- natural_key(row["participant_id"]),
- natural_key(row["session_id"]),
- natural_key(row["series_number"]),
- row["protocol_name"].lower(),
- )
- )
- return retained, duplicate_resources, incomplete_copy_groups
- def discover_bids_scan_index(
- bids_root: Path,
- ) -> dict[tuple[str, str], list[tuple[float, str]]]:
- """Index deidentified BIDS scan timestamps without emitting dates or times."""
- index: dict[tuple[str, str], list[tuple[float, str]]] = defaultdict(list)
- if not bids_root.exists():
- return index
- for path in sorted(bids_root.glob("sub-*/ses-*/*_scans.tsv")):
- parsed_session = parse_session_from_path(path)
- if not parsed_session:
- continue
- try:
- with path.open(encoding="utf-8", newline="") as stream:
- for item in csv.DictReader(stream, delimiter="\t"):
- clock = bids_clock_seconds(item.get("acq_time", ""))
- filename = item.get("filename", "")
- if clock is not None:
- index[parsed_session].append((clock, filename))
- except OSError:
- LOG.warning("Could not read one BIDS scans.tsv inventory")
- return index
- def reconcile_source_to_bids(
- rows: Sequence[dict[str, str]],
- bids_sessions: set[tuple[str, str]],
- bids_index: Mapping[tuple[str, str], Sequence[tuple[float, str]]],
- tolerance_seconds: float = 2.0,
- ) -> None:
- """Annotate raw series using time only in memory; never write the time itself."""
- for row in rows:
- participant_id = row["participant_id"]
- session_id = row["session_id"]
- session = (participant_id, session_id)
- source_text = " ".join(
- row.get(field, NA)
- for field in ("protocol_name", "series_description", "source_series_name")
- )
- row["session_parity"] = (
- "even" if expected_even(session_id) is True
- else "odd" if expected_even(session_id) is False
- else NA
- )
- row["likely_intentional_exclusion"] = yes_no(
- bool(LIKELY_INTENTIONAL_EXCLUSION_PATTERN.search(source_text))
- )
- row["nm_name_match"] = yes_no(row.get("candidate_reason", NA) != NA)
- if participant_id == NA or session_id == NA:
- row["bids_counterpart_status"] = "session_unparseable"
- row["bids_match_evidence"] = NA
- continue
- if session not in bids_sessions:
- row["bids_counterpart_status"] = "bids_session_absent"
- row["bids_match_evidence"] = NA
- continue
- candidates = bids_index.get(session, ())
- if not candidates:
- row["bids_counterpart_status"] = "bids_scan_inventory_unavailable"
- row["bids_match_evidence"] = NA
- continue
- clock = row.get("_acquisition_clock_seconds")
- if clock is None:
- row["bids_counterpart_status"] = "match_indeterminate_no_source_time"
- row["bids_match_evidence"] = NA
- continue
- matched = [
- filename for candidate_clock, filename in candidates
- if abs(candidate_clock - float(clock)) <= tolerance_seconds
- ]
- if matched:
- datatypes = sorted(
- {filename.split("/", 1)[0] for filename in matched if "/" in filename},
- key=natural_key,
- )
- row["bids_counterpart_status"] = "converted_or_listed"
- row["bids_match_evidence"] = (
- f"{len(matched)}_BIDS_file(s);datatype={','.join(datatypes) or NA}"
- )
- else:
- row["bids_counterpart_status"] = "no_bids_counterpart"
- row["bids_match_evidence"] = "no_scan_inventory_time_within_2s"
- def build_candidate_sets(
- rows: Sequence[dict[str, str]],
- ) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
- confirmed: list[dict[str, str]] = []
- review: list[dict[str, str]] = []
- for source_row in rows:
- row = dict(source_row)
- name_reason = row.get("candidate_reason", NA)
- score = int(row.get("nm_signature_score", "0"))
- source_text = " ".join(
- row.get(field, NA)
- for field in ("protocol_name", "series_description", "source_series_name")
- )
- if name_reason != NA and row["participant_id"] != NA and row["session_id"] != NA:
- confirmed.append(row)
- continue
- reasons: list[str] = []
- if name_reason != NA:
- reasons.append("neuromelanin_candidate_without_parseable_session")
- if score >= 6:
- reasons.append("acquisition_signature_score_ge_6")
- if PLAUSIBLE_PATTERN.search(source_text):
- reasons.append("plausible_name_without_neuromelanin_label")
- if GENERIC_NM_PATTERN.search(source_text) and score >= 2:
- reasons.append("generic_NM_label_supported_by_signature")
- if not reasons:
- continue
- review_row = {field: row.get(field, NA) for field in REVIEW_FIELDS}
- review_row["review_reason"] = ";".join(reasons)
- review.append(review_row)
- confirmed.sort(
- key=lambda row: (
- natural_key(row["participant_id"]),
- natural_key(row["session_id"]),
- natural_key(row["series_number"]),
- )
- )
- review.sort(
- key=lambda row: (
- natural_key(row["participant_id"]),
- natural_key(row["session_id"]),
- natural_key(row["series_number"]),
- )
- )
- return confirmed, review
- def build_omission_summary(
- rows: Sequence[dict[str, str]],
- ) -> list[dict[str, str]]:
- descriptive_fields = [
- field
- for field in OMISSION_SUMMARY_FIELDS
- if field
- not in {
- "source_series_labels",
- "n_series",
- "n_sessions",
- "n_even_sessions",
- "n_odd_sessions",
- "participants",
- "sessions",
- }
- ]
- grouped: dict[tuple[str, ...], list[dict[str, str]]] = defaultdict(list)
- for row in rows:
- if row.get("bids_counterpart_status") != "no_bids_counterpart":
- continue
- key = tuple(row.get(field, NA) for field in descriptive_fields)
- grouped[key].append(row)
- summary: list[dict[str, str]] = []
- for key, group in grouped.items():
- item = dict(zip(descriptive_fields, key))
- sessions = sorted(
- {
- f"{row['participant_id']}/{row['session_id']}"
- for row in group
- if row["participant_id"] != NA and row["session_id"] != NA
- },
- key=natural_key,
- )
- participants = sorted(
- {row["participant_id"] for row in group if row["participant_id"] != NA},
- key=natural_key,
- )
- item.update(
- {
- "source_series_labels": aggregate_values(group, "source_series_name"),
- "n_series": str(len(group)),
- "n_sessions": str(len(sessions)),
- "n_even_sessions": str(sum(row["session_parity"] == "even" for row in group)),
- "n_odd_sessions": str(sum(row["session_parity"] == "odd" for row in group)),
- "participants": ",".join(participants) if participants else NA,
- "sessions": ",".join(sessions) if sessions else NA,
- }
- )
- summary.append(item)
- summary.sort(
- key=lambda row: (
- -int(row["n_sessions"]),
- -int(row["nm_signature_score"]),
- natural_key(row["software_versions"]),
- natural_key(row["protocol_name"]),
- )
- )
- return summary
- def assign_versions(rows: list[dict[str, str]]) -> None:
- serialized_to_hash: dict[str, str] = {}
- for row in rows:
- if row["audit_status"] == "header_unreadable":
- continue
- serialized, digest = fingerprint(row)
- serialized_to_hash[serialized] = digest
- row["sequence_fingerprint_sha256"] = digest
- versions = {
- serialized: f"NM_v{index:02d}"
- for index, serialized in enumerate(sorted(serialized_to_hash), start=1)
- }
- for row in rows:
- if row["audit_status"] == "header_unreadable":
- continue
- serialized, _ = fingerprint(row)
- row["sequence_version"] = versions[serialized]
- counts = Counter((row["participant_id"], row["session_id"]) for row in rows)
- for row in rows:
- statuses = [] if row["audit_status"] == "ok" else row["audit_status"].split(";")
- if expected_even(row["session_id"]) is False:
- statuses.append("unexpected_odd_session")
- if counts[(row["participant_id"], row["session_id"])] > 1:
- statuses.append("multiple_nm_series")
- row["audit_status"] = ";".join(dict.fromkeys(statuses)) if statuses else "ok"
- def aggregate_values(rows: Sequence[Mapping[str, str]], field: str) -> str:
- values = sorted({row.get(field, NA) for row in rows}, key=natural_key)
- return " | ".join(values)
- def build_summary(rows: Sequence[dict[str, str]]) -> list[dict[str, str]]:
- grouped: dict[str, list[dict[str, str]]] = defaultdict(list)
- for row in rows:
- if row["sequence_version"] != NA:
- grouped[row["sequence_version"]].append(row)
- summary: list[dict[str, str]] = []
- for version in sorted(grouped, key=natural_key):
- group = grouped[version]
- participants = sorted({row["participant_id"] for row in group}, key=natural_key)
- sessions = sorted(
- {f"{row['participant_id']}/{row['session_id']}" for row in group},
- key=natural_key,
- )
- item = {field: NA for field in SUMMARY_FIELDS}
- item.update(
- {
- "sequence_version": version,
- "sequence_fingerprint_sha256": group[0]["sequence_fingerprint_sha256"],
- "n_series": str(len(group)),
- "n_sessions": str(len(sessions)),
- "n_participants": str(len(participants)),
- "participants": ",".join(participants),
- "sessions": ",".join(sessions),
- "protocol_names": aggregate_values(group, "protocol_name"),
- "series_descriptions": aggregate_values(group, "series_description"),
- }
- )
- for field in SUMMARY_FIELDS[9:]:
- item[field] = aggregate_values(group, field)
- summary.append(item)
- return summary
- def build_inventory(
- rows: Sequence[dict[str, str]],
- source_sessions: set[tuple[str, str]],
- bids_sessions: set[tuple[str, str]],
- ) -> list[dict[str, str]]:
- by_session: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
- for row in rows:
- by_session[(row["participant_id"], row["session_id"])].append(row)
- inventory = []
- for participant_id, session_id in sorted(
- source_sessions | bids_sessions,
- key=lambda item: (natural_key(item[0]), natural_key(item[1])),
- ):
- scans = by_session[(participant_id, session_id)]
- versions = sorted(
- {row["sequence_version"] for row in scans if row["sequence_version"] != NA},
- key=natural_key,
- )
- expected = expected_even(session_id)
- in_source = (participant_id, session_id) in source_sessions
- in_bids = (participant_id, session_id) in bids_sessions
- notes = []
- if in_source and not in_bids:
- status = "bids_session_missing"
- notes.append("source session present; corresponding BIDS session absent")
- elif in_bids and not in_source:
- status = "source_missing"
- notes.append("BIDS session present; corresponding source session absent")
- elif len(scans) > 1:
- status = "multiple_nm_series"
- notes.append("multiple neuromelanin series detected")
- if expected is False:
- notes.append("neuromelanin not expected in odd session")
- elif scans and expected is True:
- status = "expected_found"
- elif scans and expected is False:
- status = "unexpected_found"
- elif not scans and expected is True:
- status = "expected_missing"
- elif not scans and expected is False:
- status = "not_expected_not_found"
- else:
- status = "other"
- notes.append("session parity is not numeric")
- inventory.append(
- {
- "participant_id": participant_id,
- "session_id": session_id,
- "session_parity": (
- "even" if expected is True else "odd" if expected is False else NA
- ),
- "nm_expected_from_design": yes_no(expected),
- "nm_present": yes_no(bool(scans)),
- "n_nm_series": str(len(scans)),
- "sequence_version": ",".join(versions) if versions else NA,
- "status": status,
- "notes": "; ".join(notes) if notes else NA,
- }
- )
- return inventory
- def write_tsv(path: Path, rows: Sequence[Mapping[str, str]], fields: Sequence[str]) -> None:
- path.parent.mkdir(parents=True, exist_ok=True)
- with path.open("w", encoding="utf-8", newline="") as stream:
- writer = csv.DictWriter(stream, fieldnames=fields, delimiter="\t", lineterminator="\n")
- writer.writeheader()
- writer.writerows({field: row.get(field, NA) for field in fields} for row in rows)
- def differing_fields(summary: Sequence[Mapping[str, str]]) -> list[str]:
- descriptive = []
- for field in FINGERPRINT_FIELDS:
- values = {row.get(field, NA) for row in summary}
- if len(values) > 1:
- descriptive.append(field)
- return descriptive
- def markdown_table(summary: Sequence[Mapping[str, str]], fields: Sequence[str]) -> str:
- columns = ["sequence_version", "n_series", "n_sessions", *fields]
- header = "| " + " | ".join(columns) + " |"
- rule = "| " + " | ".join("---" for _ in columns) + " |"
- body = []
- for row in summary:
- cells = [str(row.get(column, NA)).replace("|", "\\|") for column in columns]
- body.append("| " + " | ".join(cells) + " |")
- return "\n".join((header, rule, *body))
- def status_sessions(inventory: Sequence[Mapping[str, str]], status: str) -> str:
- values = [
- f"{row['participant_id']}/{row['session_id']}"
- for row in inventory
- if row["status"] == status
- ]
- return ", ".join(values) if values else "None."
- def label_parameter_relationships(rows: Sequence[Mapping[str, str]]) -> list[str]:
- notes: list[str] = []
- by_label: dict[tuple[str, str], set[str]] = defaultdict(set)
- by_version: dict[str, set[tuple[str, str]]] = defaultdict(set)
- for row in rows:
- label = (row["protocol_name"], row["series_description"])
- version = row["sequence_version"]
- if version == NA:
- continue
- by_label[label].add(version)
- by_version[version].add(label)
- concealed = [label for label, versions in by_label.items() if len(versions) > 1]
- shared = [version for version, labels in by_version.items() if len(labels) > 1]
- if concealed:
- notes.append(
- f"{len(concealed)} protocol/series label combination(s) conceal acquisition-parameter differences."
- )
- if shared:
- notes.append(
- f"{len(shared)} acquisition version(s) occur under multiple protocol/series labels."
- )
- if not notes:
- notes.append("Protocol/series labels and acquisition-parameter fingerprints are one-to-one.")
- return notes
- def write_report(
- output_path: Path,
- rows: Sequence[dict[str, str]],
- source_rows: Sequence[dict[str, str]],
- summary: Sequence[dict[str, str]],
- inventory: Sequence[dict[str, str]],
- review: Sequence[dict[str, str]],
- omission_summary: Sequence[dict[str, str]],
- unreadable_series: int,
- unparsed_series: int,
- duplicate_resources: int,
- incomplete_copy_groups: int,
- ) -> None:
- participants = {row["participant_id"] for row in inventory}
- expected_count = sum(row["nm_expected_from_design"] == "yes" for row in inventory)
- present_count = sum(row["nm_present"] == "yes" for row in inventory)
- changed = differing_fields(summary)
- table_fields = changed or [
- "repetition_time_ms",
- "echo_time_ms",
- "flip_angle_deg",
- "pixel_spacing_row_mm",
- "pixel_spacing_col_mm",
- "slice_thickness_mm",
- "number_of_slices",
- ]
- relationships = "\n".join(f"- {note}" for note in label_parameter_relationships(rows))
- changed_text = ", ".join(changed) if changed else "none among fingerprinted fields"
- no_counterpart = sum(
- row.get("bids_counterpart_status") == "no_bids_counterpart" for row in source_rows
- )
- indeterminate = sum(
- row.get("bids_counterpart_status", "").startswith("match_indeterminate")
- or row.get("bids_counterpart_status") in {
- "bids_scan_inventory_unavailable",
- "bids_session_absent",
- "session_unparseable",
- }
- for row in source_rows
- )
- signature_candidates = sum(
- "acquisition_signature" in row.get("review_reason", "") for row in review
- )
- xa60_candidates = sum(
- "XA60" in row.get("software_versions", "") for row in review
- )
- text = f"""# Night Owls neuromelanin acquisition audit
- ## Purpose
- This audit inventories unreleased neuromelanin MRI scans from the Night Owls project in preparation for possible future BIDS conversion and OpenNeuro release. It does not modify the raw source archive or current BIDS dataset.
- ## Data sources
- The audit examined the local NOSC sourcedata archive at the series-header level and cross-checked it against both the repository's BIDS participant/session directories and its `scans.tsv` inventories. Dates and times are used transiently to link raw series to converted BIDS entries, but raw paths, DICOM identifiers, dates, times, and patient fields are deliberately omitted from every output.
- Confirmed-series detection requires a case-insensitive match for `neuromelanin`, a punctuation/spacing variant of `neuro-melanin`, `nmri`, or `substantia nigra` in `ProtocolName`, `SeriesDescription`, or the source series name. Independently, every readable series is scored against the observed NM acquisition signature (approximately TR 641 ms, TE 3.97 ms, flip angle 50 degrees, 0.64 mm in-plane resolution, 2D gradient echo, six repeats, and 24 slices). High-scoring series, generic `NM` labels supported by metadata, and T2-star/MT-like labels are surfaced for review but not silently promoted to confirmed NM.
- ## Overall inventory
- - Participants examined: {len(participants)}
- - Study sessions in the union of source and BIDS inventories: {len(inventory)}
- - Sessions expected to contain NM from the even-session design: {expected_count}
- - Sessions actually containing NM: {present_count}
- - Confirmed NM series: {len(rows)}
- - Unique readable acquisition versions: {len(summary)}
- - Plausible non-confirmed series requiring manual review: {len(review)}
- - Name-independent high-signature candidates: {signature_candidates}
- - Review candidates whose software reports XA60: {xa60_candidates}
- - Deduplicated raw series reconciled: {len(source_rows)}
- - Raw series with no time-matched BIDS scan entry: {no_counterpart}
- - Raw/BIDS matches that remain indeterminate: {indeterminate}
- ## Acquisition versions
- Version labels are assigned deterministically by sorting normalized acquisition fingerprints; subject/session identifiers, labels, scanner/software metadata, UIDs, paths, dates, and times are excluded from the fingerprint. The fingerprint parameters that differ across versions are: {changed_text}.
- {markdown_table(summary, table_fields) if summary else "No readable confirmed NM series were available for version comparison."}
- Label-versus-parameter checks:
- {relationships}
- ## Completeness and problems
- - Expected even sessions with no confirmed NM data: {status_sessions(inventory, "expected_missing")}
- - Odd sessions with confirmed NM data: {status_sessions(inventory, "unexpected_found")}
- - Sessions with multiple confirmed NM series: {status_sessions(inventory, "multiple_nm_series")}
- - BIDS sessions absent from source: {status_sessions(inventory, "source_missing")}
- - Source sessions absent from BIDS: {status_sessions(inventory, "bids_session_missing")}
- - Raw series with no readable representative DICOM header: {unreadable_series}
- - Readable series without a parseable participant/session path: {unparsed_series}
- - Duplicate source-resource copies excluded from series counts: {duplicate_resources}
- - Series with a less-complete duplicate source copy: {incomplete_copy_groups}
- - Plausible but uncertain candidate series: {len(review)} (see `neuromelanin_candidate_review.tsv`)
- - Recurrent unmatched source-series patterns: {len(omission_summary)} (see `source_to_bids_omission_summary.tsv`)
- ## Source-to-BIDS reconciliation
- `source_to_bids_series_reconciliation.tsv` contains one PHI-safe row per deduplicated raw series. A series is `converted_or_listed` when its private source acquisition clock matches one or more entries in that session's deidentified BIDS `scans.tsv` within two seconds. `no_bids_counterpart` means a BIDS scan inventory exists for the session but no such time match was found. Missing scan inventories or source times are reported as indeterminate, not as omissions.
- `source_to_bids_omission_summary.tsv` groups only `no_bids_counterpart` rows by scanner software, labels, and acquisition geometry, then reports how often each pattern recurs and in which public subject/session identifiers. Localizers and other recognizable setup scans are flagged as likely intentional exclusions rather than removed from the table. This is the primary table for finding a consistently skipped XA60 or differently named NM protocol.
- ## Interpretation
- The sequence versions are defined by normalized, scientifically meaningful acquisition parameters rather than protocol labels. Parameters that vary are shown in the compact table above; complete acquisition metadata and session membership are in `neuromelanin_sequence_summary.tsv`. Scanner model and software version are reported but do not independently define versions. Confirmed counts remain label-conservative; the candidate and reconciliation tables are required for the completeness conclusion.
- ## BIDS implications — preliminary only
- The historical HeuDiConv heuristic maps any protocol containing `neuromelanin` to `_acq-NM_T2star`. Phase 2 should verify the appropriate suffix, entities, and required metadata against the current BIDS specification for each empirical acquisition variant. It should also determine how magnetization-transfer preparation is represented, whether separate `acq-` labels are warranted, and how to handle missing, unexpected, or repeated series before conversion. No BIDS or OpenNeuro data were changed by this audit.
- """
- output_path.write_text(text, encoding="utf-8")
- def validate_outputs(
- rows: Sequence[dict[str, str]],
- summary: Sequence[dict[str, str]],
- inventory: Sequence[dict[str, str]],
- ) -> None:
- if len({id(row) for row in rows}) != len(rows):
- raise RuntimeError("Internal manifest row duplication detected")
- summary_count = sum(int(row["n_series"]) for row in summary)
- readable_count = sum(row["sequence_version"] != NA for row in rows)
- if summary_count != readable_count:
- raise RuntimeError("Sequence summary counts do not reconcile with manifest")
- inventory_count = sum(int(row["n_nm_series"]) for row in inventory)
- if inventory_count != len(rows):
- raise RuntimeError("Session inventory counts do not reconcile with manifest")
- def privacy_check(output_dir: Path) -> None:
- forbidden = [
- re.compile(r"(?:Study|Series|SOP)InstanceUID", re.IGNORECASE),
- re.compile(r"Patient(?:Name|Birth|Address|ID)", re.IGNORECASE),
- re.compile(r"Acquisition(?:Date|Time|DateTime)", re.IGNORECASE),
- re.compile(r"/Users/|/home/|/ZPOOL/|[A-Za-z]:\\\\"),
- re.compile(r"\b\d+(?:\.\d+){3,}\b"),
- re.compile(r"\b(?:19|20)\d{6}\b"),
- re.compile(r"\b(?:[01]\d|2[0-3])[0-5]\d[0-5]\d(?:\.\d+)?\b"),
- ]
- for path in sorted(output_dir.glob("*")):
- if path.suffix not in {".tsv", ".md"}:
- continue
- text = path.read_text(encoding="utf-8")
- for pattern in forbidden:
- if pattern.search(text):
- raise RuntimeError(
- f"Privacy check rejected {path.name}: matched forbidden pattern {pattern.pattern!r}"
- )
- def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--source-root", type=Path, required=True, help="Raw NOSC source root")
- parser.add_argument("--bids-root", type=Path, required=True, help="Current BIDS dataset root")
- parser.add_argument("--output-dir", type=Path, required=True, help="Directory for audit outputs")
- parser.add_argument("--verbose", action="store_true", help="Enable progress messages")
- return parser.parse_args(argv)
- def main(argv: Sequence[str] | None = None) -> int:
- args = parse_args(argv)
- logging.basicConfig(
- level=logging.INFO if args.verbose else logging.WARNING,
- format="%(levelname)s: %(message)s",
- )
- if pydicom is None:
- LOG.error("pydicom is required; install code/requirements-neuromelanin-audit.txt")
- return 2
- source_root = args.source_root.resolve()
- bids_root = args.bids_root.resolve()
- output_dir = args.output_dir.resolve()
- if not source_root.is_dir():
- LOG.error("Source root is not a readable directory")
- return 2
- try:
- source_rows, source_sessions, unreadable_series, unparsed_series = audit_series(source_root)
- source_rows, duplicate_resources, incomplete_copy_groups = deduplicate_resource_copies(
- source_rows
- )
- bids_sessions = discover_bids_sessions(bids_root)
- bids_index = discover_bids_scan_index(bids_root)
- reconcile_source_to_bids(source_rows, bids_sessions, bids_index)
- rows, review = build_candidate_sets(source_rows)
- assign_versions(rows)
- summary = build_summary(rows)
- inventory = build_inventory(rows, source_sessions, bids_sessions)
- omission_summary = build_omission_summary(source_rows)
- validate_outputs(rows, summary, inventory)
- output_dir.mkdir(parents=True, exist_ok=True)
- write_tsv(output_dir / "neuromelanin_scan_manifest.tsv", rows, MANIFEST_FIELDS)
- write_tsv(output_dir / "neuromelanin_sequence_summary.tsv", summary, SUMMARY_FIELDS)
- write_tsv(output_dir / "neuromelanin_session_inventory.tsv", inventory, INVENTORY_FIELDS)
- write_tsv(output_dir / "neuromelanin_candidate_review.tsv", review, REVIEW_FIELDS)
- write_tsv(
- output_dir / "source_to_bids_series_reconciliation.tsv",
- source_rows,
- RECONCILIATION_FIELDS,
- )
- write_tsv(
- output_dir / "source_to_bids_omission_summary.tsv",
- omission_summary,
- OMISSION_SUMMARY_FIELDS,
- )
- write_report(
- output_dir / "README.md",
- rows,
- source_rows,
- summary,
- inventory,
- review,
- omission_summary,
- unreadable_series,
- unparsed_series,
- duplicate_resources,
- incomplete_copy_groups,
- )
- privacy_check(output_dir)
- except (OSError, RuntimeError) as error:
- LOG.error("%s", error)
- return 1
- LOG.info(
- "Wrote %d confirmed series, %d review candidates, %d versions, %d sessions, and %d source/BIDS rows",
- len(rows),
- len(review),
- len(summary),
- len(inventory),
- len(source_rows),
- )
- return 0
- if __name__ == "__main__":
- sys.exit(main())
audit_neuromelanin.py at commit ec5197e, under MIT · at the source
Overview
Abstract
The abstract is not reproduced here: the paper's license (CC BY-NC-ND) does not allow it. Read it in the paper, at the publisher or on Europe PMC.
Repository
Its files are read in the Code ↔ Paper reader above, with 7 matches between paragraphs and lines of code.
DVS-Lab/night-owls
ec5197ed1dbf74717497ce69452974f096246dc7, 9 September 2026Availability: 1 check, the latest on 28 September 2026: the link answers
- 28 September 2026: the link answers
164 files
- code/
BIDSto3col.sh , Shell, 307 lines - code/
L1stats-loop.sh , Shell, 155 lines - code/
L1statsSingleTrial-mid.s , Shell, 99 linesh - code/
L1statsSingleTrial-mid_F , Shell, 90 linesLOBS.sh - code/
L1statsSingleTrial-share , Shell, 114 linesdreward.sh - code/
L1statsSingleTrial-share , Shell, 98 linesdreward_FLOBS.sh - code/
L1stats_FIR.sh , Shell, 129 lines - code/
L1stats_FLOBS.sh , Shell, 129 lines - code/
L2stats-hpc-subj-qc.sh , Shell, 232 lines - code/
L2stats-hpc-subj.sh , Shell, 169 lines - code/
L2stats-hpc-t1w.sh , Shell, 114 lines - code/
L2stats-hpc.sh , Shell, 132 lines - code/
L3-trend.sh , Shell, 167 lines - code/
L3-ttest.sh , Shell, 227 lines - code/
MakeConfounds.py , Python, 62 lines - code/
MakeSingleTrialsEV_MID.m , MATLAB, 61 lines - code/
MakeSingleTrialsEV_SR.m , MATLAB, 84 lines - code/
Mattoni2025/ , R, 483 linesInternalConsistency.R - code/
Mattoni2025/ , R, 403 linesInternalConsistency_RelD if.R - code/
Mattoni2025/ , R, 188 linesIntraindividual.R - code/
Mattoni2025/ , R, 97 linesMoodRatings.R - code/
Mattoni2025/ , R, 99 linesOrganize_BehavData.R - code/
Mattoni2025/ , R, 235 linesRetestReliability.R - code/
addIntendedFor_fieldmap- , Python, 68 lineshpc.py - code/
addUnits_func.py , Python, 32 lines - code/
audit_neuromelanin.py , Python, 1,677 lines, 2 matches - code/
check_FIR_outputs.py , Python, 212 lines - code/
convertSharedReward2BIDS , MATLAB, 117 linesevents.m - code/
count_lss_completion.sh , Shell, 104 lines - code/
downloadXNAT.py , Python, 45 lines - code/
events_generation.R , R, 188 lines - code/
extractData-LSS.sh , Shell, 167 lines - code/
extractData-cope.sh , Shell, 180 lines - code/
extractData.sh , Shell, 183 lines - code/
extractData_L1_FLOBS_EVm , Python, 633 linesetrics.py - code/
extractData_LSS_FLOBS.py , Python, 548 lines - code/
extractSmoothness.sh , Shell, 148 lines - code/
extract_event_metrics_ra , Python, 546 linesw_and_fir.py - code/
extract_fir_roi_means.py , Python, 236 lines - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-101_ses-01. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-101_ses-02. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-101_ses-03. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-101_ses-06. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-101_ses-07. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-101_ses-08. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-101_ses-09. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-101_ses-10. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-101_ses-11. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-103_ses-01. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-103_ses-02. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-103_ses-03. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-103_ses-04. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-103_ses-05. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-103_ses-06. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-103_ses-07. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-103_ses-08. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-103_ses-09. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-103_ses-10. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-103_ses-11. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-103_ses-12. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-104_ses-01. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-104_ses-02. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-104_ses-03. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-104_ses-04. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-104_ses-05. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-104_ses-06. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-104_ses-07. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-104_ses-08. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-104_ses-09. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-104_ses-10. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-104_ses-11. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-104_ses-12. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-105_ses-01. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-105_ses-02. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-105_ses-03. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-105_ses-04. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-105_ses-05. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-105_ses-06. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-105_ses-07. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-105_ses-08. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-105_ses-09. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-105_ses-10. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-105_ses-11. sh - code/
fmriprep-anat/ , Shell, 43 linesfmriprep_sub-105_ses-12. sh - code/
fmriprep-hpc-anat.sh , Shell, 63 lines - code/
fmriprepOrganize.sh , Shell, 46 lines - code/
gen3colfiles.sh , Shell, 32 lines - code/
genTedanaMultiSes.py , Python, 89 lines - code/
gen_MRIQC-outputs.py , Python, 97 lines - code/
gen_MRIQC_anat.py , Python, 86 lines - code/
gen_fmriprep_anat.sh , Shell, 107 lines - code/
heuristics_XA30.py , Python, 66 lines - code/
imageSimilarity.py , Python, 56 lines - code/
make_zero_duration.sh , Shell, 48 lines - code/
mriqc-hpc.sh , Shell, 79 lines - code/
normEcho2.sh , Shell, 101 lines - code/
plot_fir_timecourses.py , Python, 108 lines - code/
prepdata.sh , Shell, 69 lines - code/
resample_sub-101_ses-03_ , Shell, 98 linestask-mid.sh - code/
run_L1stats-qsub.sh , Shell, 19 lines - code/
run_L1stats-qsub_FIR.sh , Shell, 23 lines - code/
run_L1stats-qsub_FLOBS.s , Shell, 23 linesh - code/
run_L1stats-qsub_LSS-FLO , Shell, 31 linesBS.sh - code/
run_L1stats-qsub_LSS.sh , Shell, 31 lines - code/
run_L2stats-hpc.sh , Shell, 30 lines - code/
run_L3stats.sh , Shell, 27 lines - code/
run_fmriprep-hpc-anat.sh , Shell, 19 lines - code/
run_fmriprep-qsub.sh , Shell, 11 lines - code/
run_gen3colfiles.sh , Shell, 25 lines - code/
run_imageSimilarity.sh , Shell, 17 lines - code/
run_mriqc-hpc.sh , Shell, 18 lines - code/
run_prepdata.sh , Shell, 20 lines - code/
run_smoothing-qsub.sh , Shell, 19 lines - code/
run_tedana-hpc.sh , Shell, 18 lines - code/
run_warpkit-hpc.sh , Shell, 22 lines - code/
shiftdates.py , Python, 53 lines - code/
smooth-3dBlurToFWHM.sh , Shell, 49 lines - code/
striatum_MNItoT1w.sh , Shell, 118 lines - code/
sub-103_mriqc.sh , Shell, 53 lines - code/
sub-103_ses-02_replaceRu , Shell, 21 linesn2.sh - code/
tedana-hpc.sh , Shell, 91 lines - code/
tests/ , Python, 337 linestest_audit_neuromelanin. py - code/
vs_timecourse-mid.py , Python, 329 lines - code/
vs_timecourse-sr.py , Python, 189 lines - code/
vs_timecourse_FIR.py , Python, 350 lines - code/
vs_timecourse_unified.py , Python, 828 lines - code/
vs_timecourse_unified_wu , Python, 757 lines.py - code/
warpkit-hpc.sh , Shell, 124 lines - masks/
resample_to_study_mni_gr , Shell, 93 lines, 1 matchid.sh - stimuli/
MID_5button.py , Python, 794 lines, 1 match - stimuli/
MID_practice.py , Python, 726 lines, 1 match - stimuli/
MoodInduction_5button.py , Python, 304 lines - stimuli/
MoodRatings_5button.py , Python, 195 lines - stimuli/
SharedReward_5button.py , Python, 444 lines, 1 match - stimuli/
SharedReward_practice.py , Python, 399 lines - stimuli/
mid/ , MATLAB, 149 linesscripts/ KbWait2.m - stimuli/
mid/ , MATLAB, 218 linesscripts/ SRM_test.m - stimuli/
mid/ , R, 42 linesscripts/ generate_MIDtrials.R - stimuli/
mid/ , MATLAB, 18 linesscripts/ getBoxNumber.m - stimuli/
mid/ , MATLAB, 37 linesscripts/ getKey.m - stimuli/
mid/ , MATLAB, 12 linesscripts/ getKeyboardNumber.m - stimuli/
mid/ , MATLAB, 111 linesscripts/ recordKeys.m - stimuli/
mid/ , MATLAB, 117 linesscripts/ recordKeysNoBT.m - stimuli/
mid/ , MATLAB, 32 linesscripts/ ret_listfile.m - stimuli/
mid/ , MATLAB, 43 linesscripts/ screenscratch.m - stimuli/
mid/ , MATLAB, 36 linesscripts/ set_MID_threshold.m - stimuli/
mid/ , MATLAB, 113 linesscripts/ waitForKeys.m - stimuli/
mid/ , Jupyter, 493 lineszOld/ Data Exploration.ipynb - stimuli/
mid/ , Python, 612 lineszOld/ MID_ext.py - stimuli/
mid/ , Python, 675 lineszOld/ MID_prac.py - stimuli/
mid/ , MATLAB, 241 lineszOld/ mid.m - stimuli/
mid/ , MATLAB, 277 lineszOld/ mid_noloss.m - stimuli/
mid/ , MATLAB, 307 lineszOld/ mid_noloss_practice.m - stimuli/
mid/ , MATLAB, 305 lineszOld/ mid_practice.m - stimuli/
mid/ , MATLAB, 56 lineszOld/ timing/ mid_nosc_rungeneration.m - stimuli/
ratings_prac.py , Python, 147 lines - stimuli/
rest_neuromelanin.py , Python, 50 lines - stimuli/
sharedreward/ , Python, 389 linesAlternate_Button_Box/ SharedReward_4button.py - stimuli/
sharedreward/ , Python, 89 linesbutton_test/ button_box_test.py - stimuli/
sharedreward/ , MATLAB, 131 lines, 1 matchgenTrialList.m - stimuli/
sharedreward/ , Python, 73 linesneurodesign/ ndtest.py - stimuli/
sharedreward/ , Shell, 27 linesneurodesign/ optseq2_cmd.sh - LICENSE, License, 21 lines
- README.md, Text, 71 lines
Tracing map
Proposed by the machine: these links were found in the paper and verified at the source, without human review. The map will receive a Zenodo DOI once one of the paper's authors has validated it with their ORCID.
What the map holds:
- 1 repository of the authors' code, each at its verified commit, with its license and how the link was found in the paper;
- 162 scripts, each with its path and the digest of its content;
- 7 matches between paragraphs of the paper and lines of the code (method lexical-v1);
- neither the text of the paper nor the code itself.
Its JSON (tracing-map.json) is deposited on Zenodo with its DOI once the map is validated.
Data
Datasets cited
- doi:10.18112/
openneuro.ds006707.v1.1. , at OpenNeuro; found in the references0 - doi:10.18112/
openneuro.ds006707.v1.1. , at OpenNeuro; found in the text, “Methods”1 - doi:10.18112/
openneuro.ds006707.v1.1. , at OpenNeuro; found in “Data Availability Statement”1.3 - doi:10.18112/
openneuro.ds006707.v1.1. , at OpenNeuro; found in the text, “Methods”3 - neurovault.org/
images/ , at neurovault.org; found in the text, “fMRI Analysis and Moderating Factors”775976 - osf:df5t9, at OSF; found in the text, “Methods”
Data availability statement
The paper has a data availability statement. Its license (CC BY-NC-ND) does not allow reproducing it here; in short, from what the harvester recognized in it:
- it points to a dataset: OpenNeuro 10.18112/
openneuro.ds006707.v1.1. 1.3
Read it in the paper: doi.org/10.1002/hbm.70512.
Versions
The history of this record: each version stored by the harvester or made by a correction of its authors or of the maintainers of its code, and what changed in its facts. The texts of the paper (its abstract, its availability statements) are not part of it; versions that changed only those are not listed.
Version 1, 28 September 2026: the first record
Recorded: type, language, journal, volume, issue, pages, dates, 5 authors, 13 MeSH terms, 4 funders, 68 references, 1 RRID.
Cite
This paper
Mattoni, M., Wang, S., Sharp, C. J., Olino, T. M., & Smith, D. V. (2026). Precision Imaging for Intraindividual Investigation of the Reward Response. Human brain mapping, 47(5), e70512. https://
BibTeX
@article{mattoni2026prec
author = {Mattoni, Matthew and Wang, Shenghan and Sharp, Cooper J and Olino, Thomas M and Smith, David V},
title = {{Precision Imaging for Intraindividual Investigation of the Reward Response}},
journal = {Human brain mapping},
year = {2026},
month = apr,
volume = {47},
number = {5},
pages = {e70512},
publisher = {Wiley},
issn = {1065-9471},
doi = {10.1002/
url = {https://
pmid = {41877495},
pmcid = {PMC13140683}
}
RIS
TY - JOUR
AU - Mattoni, Matthew
AU - Wang, Shenghan
AU - Sharp, Cooper J
AU - Olino, Thomas M
AU - Smith, David V
TI - Precision Imaging for Intraindividual Investigation of the Reward Response
T2 - Human brain mapping
J2 - Hum Brain Mapp
PY - 2026
DA - 2026/
VL - 47
IS - 5
SP - e70512
SN - 1065-9471
PB - Wiley
DO - 10.1002/
UR - https://
LA - en
ER -
CSL-JSON
{
"id": "10.1002/
"type": "article-journal",
"title": "Precision Imaging for Intraindividual Investigation of the Reward Response",
"container-title": "Human brain mapping",
"author": [
{
"family": "Mattoni",
"given": "Matthew"
},
{
"family": "Wang",
"given": "Shenghan"
},
{
"family": "Sharp",
"given": "Cooper J"
},
{
"family": "Olino",
"given": "Thomas M"
},
{
"family": "Smith",
"given": "David V"
}
],
"container-title-short":
"volume": "47",
"issue": "5",
"page": "e70512",
"DOI": "10.1002/
"PMID": "41877495",
"PMCID": "PMC13140683",
"ISSN": "1065-9471",
"publisher": "Wiley",
"URL": "https://
"language": "en",
"issued": {
"date-parts": [
[
2026,
4,
1
]
]
}
}
The tracing map gets a citation of its own once an author has validated it and it has a DOI.
Similar papers
The papers with a page that share the most with this one: the tools found in their code, their categories, datasets, cited references and authors, the rarest counting most.
- [1] doi:10.1162/netn.a.547 [code]
- An evaluation of the efficacy of single-echo and multi-echo fMRI denoising strategies.Journal: Network neuroscience (Cambridge, Mass.)In common: fMRIPrep, dcm2niix, ANTs, 5 other tools, fMRI, 6 references
- [2] doi:10.1162/imag.a.1245 [code]
- Towards precision EEG connectomics: Evaluating the benefits of dense sampling.Journal: Imaging neuroscience (Cambridge, Mass.)In common: AFNI, psych, ANTs, 10 other tools, 2 references
- [3] doi:10.1073/pnas.2603114123 [code]
- The human hippocampus can pattern separate memories by meaning.Journal: Proceedings of the National Academy of Sciences of the United States of AmericaIn common: PsychoPy, psych, easystats, 10 other tools, 2 references
- [4] doi:10.1162/imag.a.1347 [code]
- Neural and behavioural correlates of theory of mind reasoning in five-year-old children born preterm.Journal: Imaging neuroscience (Cambridge, Mass.)In common: fMRIPrep, easystats, ANTs, 10 other tools, fMRI, 1 reference
- [5] doi:10.1016/j.neuron.2026.04.011 [code]
- Precision fMRI reveals densely interdigitated network patches with conserved motifs in the lateral prefrontal cortex.Journal: NeuronIn common: AFNI, ANTs, FSL, 3 other tools, fMRI, 8 references
- [6] doi:10.1002/hbm.70621 [code]
- Optimising 7T-fMRI for Imaging Regions of Magnetic Susceptibility.Journal: Human brain mappingIn common: HeuDiConv, fMRIPrep, AFNI, 2 other tools, fMRI, 6 references
- [7] doi:10.1038/s41467-026-74565-0 [code]
- The functional neurobiology of dispositions towards negative emotions.Journal: Nature communicationsIn common: AFNI, psych, easystats, 7 other tools, 4 references
- [8] doi:10.1162/imag.a.1198 [code]
- MEPrep: A robust pipeline for multi-echo fMRI denoising and preprocessing.Journal: Imaging neuroscience (Cambridge, Mass.)In common: fMRIPrep, AFNI, FSL, 4 other tools, fMRI, methods / tools, 6 references
- [9] doi:10.1016/j.dcn.2026.101765 [code]
- Fusiform face area development correlates with development in higher-order social brain regions.Journal: Developmental cognitive neuroscienceIn common: fMRIPrep, ANTs, lmerTest, 8 other tools, fMRI, 2 references
- [10] doi:10.1038/s41467-026-73072-6 [code]
- Mapping the spatiotemporal continuum of structural connectivity development across the human connectome in youth.Journal: Nature communicationsIn common: psych, easystats, lmerTest, 7 other tools, 4 references
Contribute
The authors of this paper can claim it, correct its record and validate its tracing map, and the maintainers of its code (its owner, or a public member of its organization) correct what it says of their repository; anyone signed in can ask for its removal. Every request goes to OSCR's own machine, which answers it; your account page follows them.
Sign in with ORCID to claim this paper as one of its authors, correct its record or validate its tracing map: when the paper's metadata lists your ORCID iD, you are recognized at once. Maintainers of its code: sign in with GitHub, then claim the repository on your account page.
Claim this paper
Correct its record
Say what each link of this record is, remove the ones that are not the paper's, add the ones that are missing. The correction becomes a new version of the record, in its Versions section.
Validate its tracing map
You validate the map as this page shows it: 1 repository of the authors' code, each at its verified commit and with its license, 162 scripts, and 7 matches between paragraphs and code (see the Code and Map sections). It then receives a DOI on Zenodo, with you (your ORCID iD) and OSCR as its creators; the code itself is not deposited.
The map's fingerprint: sha256:14d517f38c028a9a…
Add the badge to its README
The badge links the code to this page. Copy one of these into the README of the paper's code: only you decide where it goes, and nothing is changed for you.
Markdown
[, paste the snippet at the top, then “Commit changes…” and, to review it first, “Create a new branch and start a pull request”. You open the pull request; OSCR asks for no permission.
Request its removal
To ask OSCR to remove this record, the copies of its authors' scripts or its tracing map, use the removal request page: signed in, you say who you are, what to remove and why, then review and confirm the request. Published rules decide every request (how).
Discussion, reproductions, activity
Discussion: questions and error reports about this paper and its code, from signed-in readers and its authors. It opens with sign-in.
Reproductions: reports from readers who ran the authors' code: what they reproduced, with which environment, commit and data. It opens with sign-in.
Activity: what happens around this paper: new versions of its record, its map's validation, discussions and reproductions. It opens with sign-in.
