OSCR

Precision Imaging for Intraindividual Investigation of the Reward Response.

Code ↔ Paper

7 matches between paragraphs of the paper and lines of its authors' code, computed by the harvester (lexical-v1). Click a colored paragraph or line to see its counterpart.

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. [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. [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. [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. [4] § Methods ↔ code/audit_neuromelanin.py, lines 1444–1555 · score 0.56 · OpenNeuro, Night Owls, NOSC, BIDS, Scan
  5. [5] § Methods › fMRI Tasks ↔ stimuli/MID_5button.py, lines 520–587 · score 0.55 · Trial earnings, reward trials, cue, money, win, MID
  6. [6] § Methods › fMRI Tasks ↔ stimuli/MID_practice.py, lines 485–548 · score 0.55 · Trial earnings, reward trials, cue, money, win, MID
  7. [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

  1. #!/usr/bin/env python3
  2. """Audit raw Night Owls neuromelanin DICOM series without modifying source data.
  3. The script reads one representative header from every raw DICOM series, identifies
  4. neuromelanin candidates through both names and acquisition signatures, fingerprints
  5. scientifically meaningful settings, and reconciles raw series against BIDS scans.tsv
  6. inventories. Pixel data are never read. DICOM UIDs, dates, times, paths, and patient
  7. fields are never written; dates and times are used only transiently for reconciliation.
  8. """
  9. from __future__ import annotations
  10. import argparse
  11. import csv
  12. import datetime as dt
  13. import hashlib
  14. import json
  15. import logging
  16. import math
  17. import os
  18. import re
  19. import sys
  20. from collections import Counter, defaultdict
  21. from pathlib import Path
  22. from typing import Any, Iterable, Mapping, Sequence
  23. try:
  24. import pydicom
  25. except ImportError: # pragma: no cover - exercised by CLI setup failures
  26. pydicom = None
  27. LOG = logging.getLogger("neuromelanin_audit")
  28. NA = "n/a"
  29. # Deliberately excludes the generic token "NM", which has many false positives
  30. # unless it is supported by acquisition parameters.
  31. DEFAULT_NM_PATTERN = re.compile(
  32. r"neuromelanin|neuro[\W_]*melanin|\bnmri\b|substantia[\W_]*nigra",
  33. re.IGNORECASE,
  34. )
  35. PLAUSIBLE_PATTERN = re.compile(
  36. r"\bt2[\W_]*star\b|t2\*|magnetization[\W_]*transfer|"
  37. r"\bmt[\W_]*(?:gre|flash|tfl)\b",
  38. re.IGNORECASE,
  39. )
  40. GENERIC_NM_PATTERN = re.compile(r"(?:^|[\W_])nm(?:$|[\W_])", re.IGNORECASE)
  41. LIKELY_INTENTIONAL_EXCLUSION_PATTERN = re.compile(
  42. r"localizer|scout|phoenix|survey|t1[\W_]*fl2d|setter",
  43. re.IGNORECASE,
  44. )
  45. SOURCE_SESSION_PATTERN = re.compile(
  46. r"Smith-NOSC-(?P<participant>[A-Za-z0-9]+)-SES(?P<session>[A-Za-z0-9]+)",
  47. re.IGNORECASE,
  48. )
  49. BIDS_SESSION_PATTERN = re.compile(
  50. r"sub-(?P<participant>[A-Za-z0-9]+).*?ses-(?P<session>[A-Za-z0-9]+)",
  51. re.IGNORECASE,
  52. )
  53. MANIFEST_FIELDS = [
  54. "participant_id",
  55. "session_id",
  56. "series_number",
  57. "protocol_name",
  58. "series_description",
  59. "source_series_name",
  60. "candidate_reason",
  61. "sequence_version",
  62. "sequence_fingerprint_sha256",
  63. "n_dicoms",
  64. "expected_even_session",
  65. "audit_status",
  66. "manufacturer",
  67. "manufacturer_model",
  68. "software_versions",
  69. "magnetic_field_strength_T",
  70. "sequence_name",
  71. "pulse_sequence_name",
  72. "echo_pulse_sequence",
  73. "steady_state_pulse_sequence",
  74. "scanning_sequence",
  75. "sequence_variant",
  76. "scan_options",
  77. "mr_acquisition_type",
  78. "repetition_time_ms",
  79. "echo_time_ms",
  80. "inversion_time_ms",
  81. "flip_angle_deg",
  82. "echo_train_length",
  83. "rf_echo_train_length",
  84. "gradient_echo_train_length",
  85. "number_of_averages",
  86. "slice_thickness_mm",
  87. "spacing_between_slices_mm",
  88. "pixel_spacing_row_mm",
  89. "pixel_spacing_col_mm",
  90. "rows",
  91. "columns",
  92. "acquisition_matrix",
  93. "acquisition_frequency_encoding_steps",
  94. "acquisition_phase_encoding_steps_in_plane",
  95. "acquisition_phase_encoding_steps_out_of_plane",
  96. "percent_sampling",
  97. "percent_phase_fov",
  98. "pixel_bandwidth_hz",
  99. "receive_coil_name",
  100. "in_plane_phase_encoding_direction",
  101. "images_in_acquisition",
  102. "number_of_frames",
  103. "number_of_slices",
  104. "estimated_slice_coverage_mm",
  105. "number_of_volumes",
  106. "number_of_multiframe_instances",
  107. "parallel_reduction_factor_in_plane",
  108. "parallel_reduction_factor_out_of_plane",
  109. "parallel_acquisition_technique",
  110. "partial_fourier",
  111. "partial_fourier_direction",
  112. "magnetization_transfer",
  113. "siemens_mt_pulse_count",
  114. "siemens_mt_frequency_offset_hz",
  115. "siemens_mt_flip_angle_deg",
  116. "siemens_mt_duration_us",
  117. ]
  118. FINGERPRINT_FIELDS = [
  119. "sequence_name",
  120. "pulse_sequence_name",
  121. "echo_pulse_sequence",
  122. "steady_state_pulse_sequence",
  123. "scanning_sequence",
  124. "sequence_variant",
  125. "scan_options",
  126. "mr_acquisition_type",
  127. "repetition_time_ms",
  128. "echo_time_ms",
  129. "inversion_time_ms",
  130. "flip_angle_deg",
  131. "echo_train_length",
  132. "rf_echo_train_length",
  133. "gradient_echo_train_length",
  134. "number_of_averages",
  135. "slice_thickness_mm",
  136. "spacing_between_slices_mm",
  137. "pixel_spacing_row_mm",
  138. "pixel_spacing_col_mm",
  139. "rows",
  140. "columns",
  141. "acquisition_matrix",
  142. "acquisition_frequency_encoding_steps",
  143. "acquisition_phase_encoding_steps_in_plane",
  144. "acquisition_phase_encoding_steps_out_of_plane",
  145. "percent_sampling",
  146. "percent_phase_fov",
  147. "pixel_bandwidth_hz",
  148. "in_plane_phase_encoding_direction",
  149. "number_of_slices",
  150. "estimated_slice_coverage_mm",
  151. "number_of_volumes",
  152. "number_of_multiframe_instances",
  153. "parallel_reduction_factor_in_plane",
  154. "parallel_reduction_factor_out_of_plane",
  155. "parallel_acquisition_technique",
  156. "partial_fourier",
  157. "partial_fourier_direction",
  158. "magnetization_transfer",
  159. "siemens_mt_pulse_count",
  160. "siemens_mt_frequency_offset_hz",
  161. "siemens_mt_flip_angle_deg",
  162. "siemens_mt_duration_us",
  163. ]
  164. SUMMARY_FIELDS = [
  165. "sequence_version",
  166. "sequence_fingerprint_sha256",
  167. "n_series",
  168. "n_sessions",
  169. "n_participants",
  170. "participants",
  171. "sessions",
  172. "protocol_names",
  173. "series_descriptions",
  174. ] + [
  175. field
  176. for field in MANIFEST_FIELDS
  177. if field
  178. in {
  179. "manufacturer",
  180. "manufacturer_model",
  181. "software_versions",
  182. "magnetic_field_strength_T",
  183. *FINGERPRINT_FIELDS,
  184. "receive_coil_name",
  185. "images_in_acquisition",
  186. "number_of_frames",
  187. }
  188. ]
  189. INVENTORY_FIELDS = [
  190. "participant_id",
  191. "session_id",
  192. "session_parity",
  193. "nm_expected_from_design",
  194. "nm_present",
  195. "n_nm_series",
  196. "sequence_version",
  197. "status",
  198. "notes",
  199. ]
  200. REVIEW_FIELDS = [
  201. "participant_id",
  202. "session_id",
  203. "series_number",
  204. "protocol_name",
  205. "series_description",
  206. "source_series_name",
  207. "review_reason",
  208. "nm_signature_score",
  209. "signature_matches",
  210. "bids_counterpart_status",
  211. "software_versions",
  212. "sequence_name",
  213. "pulse_sequence_name",
  214. "scanning_sequence",
  215. "mr_acquisition_type",
  216. "repetition_time_ms",
  217. "echo_time_ms",
  218. "flip_angle_deg",
  219. "slice_thickness_mm",
  220. "spacing_between_slices_mm",
  221. "pixel_spacing_row_mm",
  222. "pixel_spacing_col_mm",
  223. "rows",
  224. "columns",
  225. "number_of_slices",
  226. "estimated_slice_coverage_mm",
  227. "number_of_volumes",
  228. "number_of_multiframe_instances",
  229. ]
  230. RECONCILIATION_FIELDS = [
  231. "participant_id",
  232. "session_id",
  233. "session_parity",
  234. "series_number",
  235. "source_series_name",
  236. "protocol_name",
  237. "series_description",
  238. "n_dicoms",
  239. "bids_counterpart_status",
  240. "bids_match_evidence",
  241. "likely_intentional_exclusion",
  242. "nm_name_match",
  243. "nm_signature_score",
  244. "signature_matches",
  245. "software_versions",
  246. "sequence_name",
  247. "pulse_sequence_name",
  248. "scanning_sequence",
  249. "sequence_variant",
  250. "scan_options",
  251. "mr_acquisition_type",
  252. "repetition_time_ms",
  253. "echo_time_ms",
  254. "flip_angle_deg",
  255. "slice_thickness_mm",
  256. "spacing_between_slices_mm",
  257. "pixel_spacing_row_mm",
  258. "pixel_spacing_col_mm",
  259. "rows",
  260. "columns",
  261. "number_of_slices",
  262. "estimated_slice_coverage_mm",
  263. "number_of_volumes",
  264. "number_of_multiframe_instances",
  265. ]
  266. OMISSION_SUMMARY_FIELDS = [
  267. "software_versions",
  268. "protocol_name",
  269. "series_description",
  270. "source_series_labels",
  271. "sequence_name",
  272. "pulse_sequence_name",
  273. "repetition_time_ms",
  274. "echo_time_ms",
  275. "flip_angle_deg",
  276. "slice_thickness_mm",
  277. "spacing_between_slices_mm",
  278. "rows",
  279. "columns",
  280. "number_of_slices",
  281. "estimated_slice_coverage_mm",
  282. "number_of_volumes",
  283. "number_of_multiframe_instances",
  284. "nm_signature_score",
  285. "signature_matches",
  286. "likely_intentional_exclusion",
  287. "n_series",
  288. "n_sessions",
  289. "n_even_sessions",
  290. "n_odd_sessions",
  291. "participants",
  292. "sessions",
  293. ]
  294. PRIVATE_MT_KEYS = {
  295. "siemens_mt_pulse_count": (
  296. "sPrepPulses.lNoOfMTCPulses",
  297. "sPrepPulses.lNoOfMtcPulses",
  298. ),
  299. "siemens_mt_frequency_offset_hz": (
  300. "sPrepPulses.dMTCFrequencyOffset",
  301. "sPrepPulses.dMTFrequencyOffset",
  302. ),
  303. "siemens_mt_flip_angle_deg": (
  304. "sPrepPulses.dMTFlipAngle",
  305. "sPrepPulses.dMTCFlipAngle",
  306. ),
  307. "siemens_mt_duration_us": (
  308. "sPrepPulses.dMTDuration",
  309. "sPrepPulses.dMTCDuration",
  310. ),
  311. }
  312. def natural_key(value: str) -> tuple[Any, ...]:
  313. """Return a deterministic human-friendly key for identifiers and labels."""
  314. return tuple(int(part) if part.isdigit() else part.lower() for part in re.split(r"(\d+)", value))
  315. def clean_text(value: Any) -> str:
  316. """Convert a DICOM value to one safe, single-line TSV cell."""
  317. if value is None or value == "":
  318. return NA
  319. if isinstance(value, bytes):
  320. value = value.decode("latin-1", errors="replace")
  321. if isinstance(value, (list, tuple)) or value.__class__.__name__ == "MultiValue":
  322. value = "\\".join(str(item) for item in value)
  323. text = str(value).replace("\t", " ").replace("\r", " ").replace("\n", " ")
  324. text = re.sub(r"\s+", " ", text).strip()
  325. return text if text else NA
  326. def normalized_number(value: Any, digits: int = 6) -> str:
  327. """Normalize numerical DICOM values so trivial float formatting does not split versions."""
  328. if value is None or value == "":
  329. return NA
  330. try:
  331. number = float(value)
  332. except (TypeError, ValueError):
  333. return clean_text(value)
  334. if not math.isfinite(number):
  335. return NA
  336. if abs(number - round(number)) < 10 ** (-digits):
  337. return str(int(round(number)))
  338. return f"{number:.{digits}f}".rstrip("0").rstrip(".")
  339. def normalize_identifier(prefix: str, value: str) -> str:
  340. value = re.sub(rf"^{re.escape(prefix)}-", "", value, flags=re.IGNORECASE)
  341. if value.isdigit():
  342. value = value.zfill(2 if prefix == "ses" else 3)
  343. return f"{prefix}-{value}"
  344. def parse_session_from_path(path: Path) -> tuple[str, str] | None:
  345. joined = "/".join(path.parts)
  346. match = SOURCE_SESSION_PATTERN.search(joined) or BIDS_SESSION_PATTERN.search(joined)
  347. if not match:
  348. return None
  349. return (
  350. normalize_identifier("sub", match.group("participant")),
  351. normalize_identifier("ses", match.group("session")),
  352. )
  353. def session_number(session_id: str) -> int | None:
  354. match = re.search(r"(\d+)$", session_id)
  355. return int(match.group(1)) if match else None
  356. def expected_even(session_id: str) -> bool | None:
  357. number = session_number(session_id)
  358. return None if number is None else number % 2 == 0
  359. def yes_no(value: bool | None) -> str:
  360. if value is None:
  361. return NA
  362. return "yes" if value else "no"
  363. def number(value: str) -> float | None:
  364. if value == NA:
  365. return None
  366. try:
  367. result = float(value)
  368. except (TypeError, ValueError):
  369. return None
  370. return result if math.isfinite(result) else None
  371. def near(value: str, target: float, tolerance: float) -> bool:
  372. parsed = number(value)
  373. return parsed is not None and abs(parsed - target) <= tolerance
  374. def acquisition_clock_seconds(dataset: Any) -> float | None:
  375. """Return month/day/time as a year-independent scalar for private in-memory matching."""
  376. date_value = get_value(dataset, "AcquisitionDate") or get_value(dataset, "SeriesDate")
  377. time_value = get_value(dataset, "AcquisitionTime") or get_value(dataset, "SeriesTime")
  378. combined = clean_text(get_value(dataset, "AcquisitionDateTime"))
  379. if combined != NA and len(re.sub(r"\D", "", combined.split(".", 1)[0])) >= 14:
  380. date_value = combined[:8]
  381. time_value = combined[8:]
  382. if not date_value or not time_value:
  383. return None
  384. date_digits = re.sub(r"\D", "", str(date_value))
  385. time_text = str(time_value).strip()
  386. match = re.match(r"(?P<h>\d{2})(?P<m>\d{2})(?P<s>\d{2}(?:\.\d+)?)", time_text)
  387. if len(date_digits) < 8 or not match:
  388. return None
  389. try:
  390. anchor = dt.datetime(2000, int(date_digits[4:6]), int(date_digits[6:8]))
  391. seconds = (
  392. int(match.group("h")) * 3600
  393. + int(match.group("m")) * 60
  394. + float(match.group("s"))
  395. )
  396. except (TypeError, ValueError):
  397. return None
  398. return (anchor - dt.datetime(2000, 1, 1)).days * 86400 + seconds
  399. def bids_clock_seconds(value: str) -> float | None:
  400. try:
  401. parsed = dt.datetime.fromisoformat(value.strip().replace("Z", "+00:00"))
  402. anchor = dt.datetime(2000, parsed.month, parsed.day)
  403. except (TypeError, ValueError):
  404. return None
  405. return (
  406. (anchor - dt.datetime(2000, 1, 1)).days * 86400
  407. + parsed.hour * 3600
  408. + parsed.minute * 60
  409. + parsed.second
  410. + parsed.microsecond / 1_000_000
  411. )
  412. def neuromelanin_signature(row: Mapping[str, str]) -> tuple[int, str]:
  413. """Score name-independent similarity to the observed E11/XA30 NM acquisition."""
  414. matches: list[str] = []
  415. score = 0
  416. if near(row.get("repetition_time_ms", NA), 641, 25):
  417. score += 2
  418. matches.append("TR_about_641ms")
  419. if near(row.get("echo_time_ms", NA), 3.97, 0.35):
  420. score += 2
  421. matches.append("TE_about_3.97ms")
  422. if near(row.get("flip_angle_deg", NA), 50, 5):
  423. score += 2
  424. matches.append("flip_angle_about_50deg")
  425. row_spacing = number(row.get("pixel_spacing_row_mm", NA))
  426. col_spacing = number(row.get("pixel_spacing_col_mm", NA))
  427. if (
  428. row_spacing is not None
  429. and col_spacing is not None
  430. and 0.55 <= row_spacing <= 0.75
  431. and 0.55 <= col_spacing <= 0.75
  432. ):
  433. score += 1
  434. matches.append("in_plane_resolution_about_0.64mm")
  435. sequence_text = " ".join(
  436. row.get(field, NA)
  437. for field in (
  438. "sequence_name",
  439. "pulse_sequence_name",
  440. "scanning_sequence",
  441. "mr_acquisition_type",
  442. )
  443. ).lower()
  444. if "fl2d1" in sequence_text or (
  445. "gradient" in sequence_text and "2d" in sequence_text
  446. ):
  447. score += 1
  448. matches.append("2D_gradient_echo")
  449. if any(row.get(field, NA) == "6" for field in (
  450. "number_of_averages",
  451. "number_of_volumes",
  452. "number_of_multiframe_instances",
  453. )):
  454. score += 1
  455. matches.append("six_repeats")
  456. if row.get("number_of_slices", NA) == "24":
  457. score += 1
  458. matches.append("24_slices")
  459. return score, "+".join(matches) if matches else NA
  460. def estimated_slice_coverage(row: Mapping[str, str]) -> str:
  461. """Estimate first-to-last outer-edge coverage from slice count and spacing."""
  462. slices = number(row.get("number_of_slices", NA))
  463. thickness = number(row.get("slice_thickness_mm", NA))
  464. spacing = number(row.get("spacing_between_slices_mm", NA))
  465. if slices is None or slices < 1 or thickness is None:
  466. return NA
  467. step = spacing if spacing is not None else thickness
  468. return normalized_number(thickness + max(0, slices - 1) * step)
  469. def source_series_label(path: Path) -> str:
  470. """Return a scan-folder label for matching only; it is never emitted to output."""
  471. parts = list(path.parts)
  472. lowered = [part.lower() for part in parts]
  473. if "scans" in lowered:
  474. index = len(lowered) - 1 - lowered[::-1].index("scans")
  475. if index + 1 < len(parts):
  476. return parts[index + 1]
  477. return path.parent.parent.parent.name if len(parts) >= 4 else path.name
  478. def discover_series(source_root: Path) -> tuple[list[tuple[Path, list[Path]]], set[tuple[str, str]]]:
  479. """Discover historical XNAT DICOM resource directories and all source sessions."""
  480. series: list[tuple[Path, list[Path]]] = []
  481. sessions: set[tuple[str, str]] = set()
  482. for current, dirs, files in os.walk(source_root):
  483. current_path = Path(current)
  484. parsed = parse_session_from_path(current_path)
  485. if parsed:
  486. sessions.add(parsed)
  487. if not files:
  488. continue
  489. parent_names = [part.lower() for part in current_path.parts[-3:]]
  490. is_dicom_resource = (
  491. current_path.name.lower() == "files"
  492. and "dicom" in parent_names
  493. ) or current_path.name.lower() == "dicom"
  494. if not is_dicom_resource:
  495. continue
  496. paths = sorted(
  497. (current_path / name for name in files if (current_path / name).is_file()),
  498. key=lambda item: natural_key(item.name),
  499. )
  500. if paths:
  501. series.append((current_path, paths))
  502. dirs[:] = []
  503. series.sort(key=lambda item: natural_key(str(item[0])))
  504. return series, sessions
  505. def read_representative(files: Sequence[Path]) -> tuple[Any | None, int]:
  506. """Read the first valid DICOM header and return it with the unreadable-file count."""
  507. unreadable = 0
  508. for path in files:
  509. try:
  510. dataset = pydicom.dcmread(path, stop_before_pixels=True, force=True)
  511. except Exception:
  512. unreadable += 1
  513. continue
  514. if any(
  515. getattr(dataset, keyword, None)
  516. for keyword in ("Modality", "SOPClassUID", "ProtocolName", "SeriesDescription")
  517. ):
  518. return dataset, unreadable
  519. unreadable += 1
  520. return None, unreadable
  521. def get_value(dataset: Any, keyword: str) -> Any:
  522. value = getattr(dataset, keyword, None)
  523. return value.value if hasattr(value, "value") else value
  524. def get_tag_value(dataset: Any, group: int, element: int) -> Any:
  525. entry = dataset.get((group, element))
  526. return entry.value if entry is not None else None
  527. def functional_group_roots(dataset: Any) -> Iterable[Any]:
  528. """Yield shared and one representative per-frame functional-group item."""
  529. shared = get_value(dataset, "SharedFunctionalGroupsSequence")
  530. if shared:
  531. yield shared[0]
  532. per_frame = get_value(dataset, "PerFrameFunctionalGroupsSequence")
  533. if per_frame:
  534. yield per_frame[0]
  535. def acquisition_value(
  536. dataset: Any,
  537. keyword: str,
  538. sequence_keywords: Sequence[str] = (),
  539. ) -> Any:
  540. """Read a top-level value or its Enhanced MR functional-group equivalent."""
  541. value = get_value(dataset, keyword)
  542. if value not in (None, ""):
  543. return value
  544. for root in functional_group_roots(dataset):
  545. for sequence_keyword in sequence_keywords:
  546. sequence = get_value(root, sequence_keyword)
  547. if not sequence:
  548. continue
  549. value = get_value(sequence[0], keyword)
  550. if value not in (None, ""):
  551. return value
  552. return None
  553. def private_text(dataset: Any) -> str:
  554. """Read only Siemens CSA blobs in memory; never return or write the full contents."""
  555. chunks: list[str] = []
  556. for tag in ((0x0029, 0x1010), (0x0029, 0x1020)):
  557. value = get_tag_value(dataset, *tag)
  558. if isinstance(value, bytes):
  559. chunks.append(value.decode("latin-1", errors="ignore"))
  560. elif value:
  561. chunks.append(str(value))
  562. return "\n".join(chunks)
  563. def extract_private_mt(dataset: Any) -> dict[str, str]:
  564. text = private_text(dataset)
  565. values = {field: NA for field in PRIVATE_MT_KEYS}
  566. if not text:
  567. return values
  568. for field, keys in PRIVATE_MT_KEYS.items():
  569. for key in keys:
  570. match = re.search(
  571. rf"(?m){re.escape(key)}\s*=\s*([^\x00\r\n]+)",
  572. text,
  573. )
  574. if match:
  575. raw = match.group(1).split("#", 1)[0].strip()
  576. values[field] = normalized_number(raw)
  577. break
  578. return values
  579. def acquisition_matrix(dataset: Any) -> str:
  580. value = acquisition_value(dataset, "AcquisitionMatrix", ("MRFOVGeometrySequence",))
  581. if value is None:
  582. return NA
  583. try:
  584. return "x".join(str(int(item)) for item in value)
  585. except (TypeError, ValueError):
  586. return clean_text(value)
  587. def pixel_spacing(dataset: Any) -> tuple[str, str]:
  588. value = acquisition_value(dataset, "PixelSpacing", ("PixelMeasuresSequence",))
  589. try:
  590. return normalized_number(value[0]), normalized_number(value[1])
  591. except (TypeError, IndexError):
  592. return NA, NA
  593. def infer_number_of_slices(dataset: Any, n_dicoms: int) -> str:
  594. images = get_value(dataset, "ImagesInAcquisition")
  595. frames = get_value(dataset, "NumberOfFrames")
  596. if images not in (None, ""):
  597. return normalized_number(images)
  598. if frames not in (None, "") and normalized_number(frames) != "1":
  599. return normalized_number(frames)
  600. return NA
  601. def infer_multiframe_instances(dataset: Any, n_dicoms: int) -> str:
  602. frames = normalized_number(get_value(dataset, "NumberOfFrames"))
  603. if frames not in (NA, "1"):
  604. return str(n_dicoms)
  605. return NA
  606. def infer_series_layout(files: Sequence[Path], dataset: Any) -> dict[str, str]:
  607. """Infer slice and volume counts without treating all 2D instances as slices."""
  608. frames = normalized_number(get_value(dataset, "NumberOfFrames"))
  609. if frames not in (NA, "1"):
  610. return {
  611. "number_of_slices": frames,
  612. "number_of_volumes": str(len(files)),
  613. "number_of_multiframe_instances": str(len(files)),
  614. }
  615. positions: set[tuple[float, ...]] = set()
  616. slice_locations: set[float] = set()
  617. temporal_positions: set[str] = set()
  618. tags = [
  619. "ImagePositionPatient",
  620. "SliceLocation",
  621. "TemporalPositionIdentifier",
  622. "AcquisitionNumber",
  623. ]
  624. for path in files:
  625. try:
  626. header = pydicom.dcmread(
  627. path,
  628. stop_before_pixels=True,
  629. force=True,
  630. specific_tags=tags,
  631. )
  632. except Exception:
  633. continue
  634. position = get_value(header, "ImagePositionPatient")
  635. if position:
  636. try:
  637. positions.add(tuple(round(float(value), 4) for value in position))
  638. except (TypeError, ValueError):
  639. pass
  640. location = get_value(header, "SliceLocation")
  641. if location not in (None, ""):
  642. try:
  643. slice_locations.add(round(float(location), 4))
  644. except (TypeError, ValueError):
  645. pass
  646. temporal = get_value(header, "TemporalPositionIdentifier")
  647. if temporal in (None, ""):
  648. temporal = get_value(header, "AcquisitionNumber")
  649. if temporal not in (None, ""):
  650. temporal_positions.add(clean_text(temporal))
  651. n_slices = len(positions) or len(slice_locations)
  652. if not n_slices:
  653. return {
  654. "number_of_slices": infer_number_of_slices(dataset, len(files)),
  655. "number_of_volumes": (
  656. str(len(temporal_positions)) if temporal_positions else NA
  657. ),
  658. "number_of_multiframe_instances": NA,
  659. }
  660. if temporal_positions:
  661. n_volumes = len(temporal_positions)
  662. elif len(files) % n_slices == 0:
  663. n_volumes = len(files) // n_slices
  664. else:
  665. n_volumes = 0
  666. return {
  667. "number_of_slices": str(n_slices),
  668. "number_of_volumes": str(n_volumes) if n_volumes else NA,
  669. "number_of_multiframe_instances": NA,
  670. }
  671. def extract_metadata(dataset: Any, n_dicoms: int) -> dict[str, str]:
  672. row_spacing, col_spacing = pixel_spacing(dataset)
  673. timing_sequences = ("MRTimingAndRelatedParametersSequence",)
  674. echo_sequences = ("MREchoSequence",)
  675. modifier_sequences = ("MRModifierSequence", "MRImagingModifierSequence")
  676. fov_sequences = ("MRFOVGeometrySequence",)
  677. imaging_modifier_sequences = (
  678. "MRImagingModifierSequence",
  679. "MRModifierSequence",
  680. "MRFOVGeometrySequence",
  681. )
  682. metadata = {
  683. "series_number": normalized_number(get_value(dataset, "SeriesNumber")),
  684. "protocol_name": clean_text(get_value(dataset, "ProtocolName")),
  685. "series_description": clean_text(get_value(dataset, "SeriesDescription")),
  686. "manufacturer": clean_text(get_value(dataset, "Manufacturer")),
  687. "manufacturer_model": clean_text(get_value(dataset, "ManufacturerModelName")),
  688. "software_versions": clean_text(get_value(dataset, "SoftwareVersions")),
  689. "magnetic_field_strength_T": normalized_number(get_value(dataset, "MagneticFieldStrength")),
  690. "sequence_name": clean_text(get_value(dataset, "SequenceName")),
  691. "pulse_sequence_name": clean_text(
  692. acquisition_value(dataset, "PulseSequenceName", modifier_sequences)
  693. ),
  694. "echo_pulse_sequence": clean_text(
  695. acquisition_value(dataset, "EchoPulseSequence", modifier_sequences)
  696. ),
  697. "steady_state_pulse_sequence": clean_text(
  698. acquisition_value(dataset, "SteadyStatePulseSequence", modifier_sequences)
  699. ),
  700. "scanning_sequence": clean_text(get_value(dataset, "ScanningSequence")),
  701. "sequence_variant": clean_text(get_value(dataset, "SequenceVariant")),
  702. "scan_options": clean_text(get_value(dataset, "ScanOptions")),
  703. "mr_acquisition_type": clean_text(get_value(dataset, "MRAcquisitionType")),
  704. "repetition_time_ms": normalized_number(
  705. acquisition_value(dataset, "RepetitionTime", timing_sequences)
  706. ),
  707. "echo_time_ms": normalized_number(
  708. acquisition_value(dataset, "EchoTime", echo_sequences)
  709. or acquisition_value(dataset, "EffectiveEchoTime", echo_sequences)
  710. ),
  711. "inversion_time_ms": normalized_number(
  712. acquisition_value(dataset, "InversionTime", modifier_sequences)
  713. or acquisition_value(dataset, "InversionTimes", modifier_sequences)
  714. ),
  715. "flip_angle_deg": normalized_number(
  716. acquisition_value(dataset, "FlipAngle", timing_sequences)
  717. ),
  718. "echo_train_length": normalized_number(
  719. acquisition_value(dataset, "EchoTrainLength", timing_sequences)
  720. ),
  721. "rf_echo_train_length": normalized_number(
  722. acquisition_value(dataset, "RFEchoTrainLength", timing_sequences)
  723. ),
  724. "gradient_echo_train_length": normalized_number(
  725. acquisition_value(dataset, "GradientEchoTrainLength", timing_sequences)
  726. ),
  727. "number_of_averages": normalized_number(
  728. acquisition_value(dataset, "NumberOfAverages", ("MRAveragesSequence",))
  729. ),
  730. "slice_thickness_mm": normalized_number(
  731. acquisition_value(dataset, "SliceThickness", ("PixelMeasuresSequence",))
  732. ),
  733. "spacing_between_slices_mm": normalized_number(
  734. acquisition_value(dataset, "SpacingBetweenSlices", ("PixelMeasuresSequence",))
  735. ),
  736. "pixel_spacing_row_mm": row_spacing,
  737. "pixel_spacing_col_mm": col_spacing,
  738. "rows": normalized_number(get_value(dataset, "Rows")),
  739. "columns": normalized_number(get_value(dataset, "Columns")),
  740. "acquisition_matrix": acquisition_matrix(dataset),
  741. "acquisition_frequency_encoding_steps": normalized_number(
  742. acquisition_value(dataset, "MRAcquisitionFrequencyEncodingSteps", fov_sequences)
  743. ),
  744. "acquisition_phase_encoding_steps_in_plane": normalized_number(
  745. acquisition_value(dataset, "MRAcquisitionPhaseEncodingStepsInPlane", fov_sequences)
  746. ),
  747. "acquisition_phase_encoding_steps_out_of_plane": normalized_number(
  748. acquisition_value(dataset, "MRAcquisitionPhaseEncodingStepsOutOfPlane", fov_sequences)
  749. ),
  750. "percent_sampling": normalized_number(
  751. acquisition_value(dataset, "PercentSampling", fov_sequences)
  752. ),
  753. "percent_phase_fov": normalized_number(
  754. acquisition_value(dataset, "PercentPhaseFieldOfView", fov_sequences)
  755. ),
  756. "pixel_bandwidth_hz": normalized_number(get_value(dataset, "PixelBandwidth")),
  757. "receive_coil_name": clean_text(
  758. acquisition_value(dataset, "ReceiveCoilName", ("MRReceiveCoilSequence",))
  759. ),
  760. "in_plane_phase_encoding_direction": clean_text(
  761. acquisition_value(dataset, "InPlanePhaseEncodingDirection", fov_sequences)
  762. ),
  763. "images_in_acquisition": normalized_number(get_value(dataset, "ImagesInAcquisition")),
  764. "number_of_frames": normalized_number(get_value(dataset, "NumberOfFrames")),
  765. "number_of_slices": infer_number_of_slices(dataset, n_dicoms),
  766. "number_of_volumes": NA,
  767. "number_of_multiframe_instances": infer_multiframe_instances(dataset, n_dicoms),
  768. "parallel_reduction_factor_in_plane": normalized_number(
  769. acquisition_value(
  770. dataset,
  771. "ParallelReductionFactorInPlane",
  772. imaging_modifier_sequences,
  773. )
  774. ),
  775. "parallel_reduction_factor_out_of_plane": normalized_number(
  776. acquisition_value(
  777. dataset,
  778. "ParallelReductionFactorOutOfPlane",
  779. imaging_modifier_sequences,
  780. )
  781. ),
  782. "parallel_acquisition_technique": clean_text(
  783. acquisition_value(
  784. dataset,
  785. "ParallelAcquisitionTechnique",
  786. imaging_modifier_sequences,
  787. )
  788. ),
  789. "partial_fourier": normalized_number(
  790. acquisition_value(dataset, "PartialFourier", imaging_modifier_sequences)
  791. ),
  792. "partial_fourier_direction": clean_text(
  793. acquisition_value(dataset, "PartialFourierDirection", imaging_modifier_sequences)
  794. ),
  795. "magnetization_transfer": clean_text(
  796. acquisition_value(dataset, "MagnetizationTransfer", modifier_sequences)
  797. ),
  798. }
  799. metadata.update(extract_private_mt(dataset))
  800. return metadata
  801. def candidate_reason(protocol: str, description: str, source_label: str) -> str | None:
  802. sources = []
  803. if DEFAULT_NM_PATTERN.search(protocol):
  804. sources.append("protocol_name")
  805. if DEFAULT_NM_PATTERN.search(description):
  806. sources.append("series_description")
  807. if DEFAULT_NM_PATTERN.search(source_label):
  808. sources.append("source_series_name")
  809. return "+".join(sources) if sources else None
  810. def fingerprint(row: Mapping[str, str]) -> tuple[str, str]:
  811. content = {field: row.get(field, NA) for field in FINGERPRINT_FIELDS}
  812. serialized = json.dumps(content, sort_keys=True, separators=(",", ":"))
  813. return serialized, hashlib.sha256(serialized.encode("utf-8")).hexdigest()
  814. def discover_bids_sessions(bids_root: Path) -> set[tuple[str, str]]:
  815. sessions: set[tuple[str, str]] = set()
  816. if not bids_root.exists():
  817. LOG.warning("BIDS root does not exist; source-only inventory will be generated")
  818. return sessions
  819. for participant_dir in bids_root.glob("sub-*"):
  820. if not participant_dir.is_dir():
  821. continue
  822. for session_dir in participant_dir.glob("ses-*"):
  823. if session_dir.is_dir():
  824. sessions.add((participant_dir.name, session_dir.name))
  825. return sessions
  826. def audit_series(
  827. source_root: Path,
  828. ) -> tuple[
  829. list[dict[str, str]],
  830. set[tuple[str, str]],
  831. int,
  832. int,
  833. ]:
  834. discovered, source_sessions = discover_series(source_root)
  835. if not discovered:
  836. raise RuntimeError("No raw DICOM series directories were found under source root")
  837. rows: list[dict[str, str]] = []
  838. unreadable_series = 0
  839. unparsed_series = 0
  840. for index, (directory, files) in enumerate(discovered, start=1):
  841. if index % 100 == 0:
  842. LOG.info("Inspected %d/%d series", index, len(discovered))
  843. parsed = parse_session_from_path(directory)
  844. dataset, unreadable_files = read_representative(files)
  845. label = source_series_label(directory)
  846. if dataset is None:
  847. unreadable_series += 1
  848. participant_id, session_id = parsed if parsed else (NA, NA)
  849. row = {field: NA for field in MANIFEST_FIELDS}
  850. row.update(
  851. {
  852. "participant_id": participant_id,
  853. "session_id": session_id,
  854. "candidate_reason": (
  855. "source_series_name" if DEFAULT_NM_PATTERN.search(label) else NA
  856. ),
  857. "source_series_name": label,
  858. "n_dicoms": str(len(files)),
  859. "expected_even_session": (
  860. yes_no(expected_even(session_id)) if parsed else NA
  861. ),
  862. "audit_status": "header_unreadable",
  863. "_series_instance_uid": NA,
  864. "_acquisition_clock_seconds": None,
  865. }
  866. )
  867. rows.append(row)
  868. continue
  869. metadata = extract_metadata(dataset, len(files))
  870. metadata.update(infer_series_layout(files, dataset))
  871. metadata["estimated_slice_coverage_mm"] = estimated_slice_coverage(metadata)
  872. protocol = metadata["protocol_name"]
  873. description = metadata["series_description"]
  874. if not parsed:
  875. unparsed_series += 1
  876. participant_id, session_id = parsed if parsed else (NA, NA)
  877. reason = candidate_reason(protocol, description, label)
  878. row = {field: NA for field in MANIFEST_FIELDS}
  879. row.update(metadata)
  880. row.update(
  881. {
  882. "participant_id": participant_id,
  883. "session_id": session_id,
  884. "candidate_reason": reason or NA,
  885. "source_series_name": label,
  886. "n_dicoms": str(len(files)),
  887. "expected_even_session": (
  888. yes_no(expected_even(session_id)) if parsed else NA
  889. ),
  890. "audit_status": (
  891. "ok" if unreadable_files == 0 else "representative_read_after_error"
  892. ),
  893. "_series_instance_uid": clean_text(
  894. get_value(dataset, "SeriesInstanceUID")
  895. ),
  896. "_acquisition_clock_seconds": acquisition_clock_seconds(dataset),
  897. }
  898. )
  899. score, matches = neuromelanin_signature(row)
  900. row["nm_signature_score"] = str(score)
  901. row["signature_matches"] = matches
  902. rows.append(row)
  903. rows.sort(
  904. key=lambda row: (
  905. natural_key(row["participant_id"]),
  906. natural_key(row["session_id"]),
  907. natural_key(row["series_number"]),
  908. row["protocol_name"].lower(),
  909. )
  910. )
  911. LOG.info(
  912. "Inspected %d series without a parseable participant/session",
  913. unparsed_series,
  914. )
  915. return rows, source_sessions, unreadable_series, unparsed_series
  916. def deduplicate_resource_copies(
  917. rows: Sequence[dict[str, str]],
  918. ) -> tuple[list[dict[str, str]], int, int]:
  919. """Collapse archive-resource copies sharing a Series Instance UID in one session.
  920. UIDs are used only in memory and are removed before any output is written. When
  921. copies differ in file count, the most complete resource is retained and flagged.
  922. """
  923. grouped: dict[tuple[str, str, str], list[dict[str, str]]] = defaultdict(list)
  924. ungrouped: list[dict[str, str]] = []
  925. for row in rows:
  926. uid = row.get("_series_instance_uid", NA)
  927. if uid == NA:
  928. ungrouped.append(row)
  929. else:
  930. grouped[(row["participant_id"], row["session_id"], uid)].append(row)
  931. retained = list(ungrouped)
  932. duplicate_resources = 0
  933. incomplete_copy_groups = 0
  934. for group in grouped.values():
  935. counts = {int(row["n_dicoms"]) for row in group}
  936. ranked = sorted(
  937. group,
  938. key=lambda row: (
  939. -int(row["n_dicoms"]),
  940. -sum(row.get(field, NA) != NA for field in FINGERPRINT_FIELDS),
  941. json.dumps(
  942. {field: row.get(field, NA) for field in MANIFEST_FIELDS},
  943. sort_keys=True,
  944. ),
  945. ),
  946. )
  947. selected = ranked[0]
  948. if len(group) > 1:
  949. duplicate_resources += len(group) - 1
  950. statuses = [] if selected["audit_status"] == "ok" else selected["audit_status"].split(";")
  951. statuses.append(f"duplicate_source_resources_excluded={len(group) - 1}")
  952. if len(counts) > 1:
  953. incomplete_copy_groups += 1
  954. statuses.append("incomplete_source_copy_excluded")
  955. selected["audit_status"] = ";".join(dict.fromkeys(statuses))
  956. retained.append(selected)
  957. for row in retained:
  958. row.pop("_series_instance_uid", None)
  959. retained.sort(
  960. key=lambda row: (
  961. natural_key(row["participant_id"]),
  962. natural_key(row["session_id"]),
  963. natural_key(row["series_number"]),
  964. row["protocol_name"].lower(),
  965. )
  966. )
  967. return retained, duplicate_resources, incomplete_copy_groups
  968. def discover_bids_scan_index(
  969. bids_root: Path,
  970. ) -> dict[tuple[str, str], list[tuple[float, str]]]:
  971. """Index deidentified BIDS scan timestamps without emitting dates or times."""
  972. index: dict[tuple[str, str], list[tuple[float, str]]] = defaultdict(list)
  973. if not bids_root.exists():
  974. return index
  975. for path in sorted(bids_root.glob("sub-*/ses-*/*_scans.tsv")):
  976. parsed_session = parse_session_from_path(path)
  977. if not parsed_session:
  978. continue
  979. try:
  980. with path.open(encoding="utf-8", newline="") as stream:
  981. for item in csv.DictReader(stream, delimiter="\t"):
  982. clock = bids_clock_seconds(item.get("acq_time", ""))
  983. filename = item.get("filename", "")
  984. if clock is not None:
  985. index[parsed_session].append((clock, filename))
  986. except OSError:
  987. LOG.warning("Could not read one BIDS scans.tsv inventory")
  988. return index
  989. def reconcile_source_to_bids(
  990. rows: Sequence[dict[str, str]],
  991. bids_sessions: set[tuple[str, str]],
  992. bids_index: Mapping[tuple[str, str], Sequence[tuple[float, str]]],
  993. tolerance_seconds: float = 2.0,
  994. ) -> None:
  995. """Annotate raw series using time only in memory; never write the time itself."""
  996. for row in rows:
  997. participant_id = row["participant_id"]
  998. session_id = row["session_id"]
  999. session = (participant_id, session_id)
  1000. source_text = " ".join(
  1001. row.get(field, NA)
  1002. for field in ("protocol_name", "series_description", "source_series_name")
  1003. )
  1004. row["session_parity"] = (
  1005. "even" if expected_even(session_id) is True
  1006. else "odd" if expected_even(session_id) is False
  1007. else NA
  1008. )
  1009. row["likely_intentional_exclusion"] = yes_no(
  1010. bool(LIKELY_INTENTIONAL_EXCLUSION_PATTERN.search(source_text))
  1011. )
  1012. row["nm_name_match"] = yes_no(row.get("candidate_reason", NA) != NA)
  1013. if participant_id == NA or session_id == NA:
  1014. row["bids_counterpart_status"] = "session_unparseable"
  1015. row["bids_match_evidence"] = NA
  1016. continue
  1017. if session not in bids_sessions:
  1018. row["bids_counterpart_status"] = "bids_session_absent"
  1019. row["bids_match_evidence"] = NA
  1020. continue
  1021. candidates = bids_index.get(session, ())
  1022. if not candidates:
  1023. row["bids_counterpart_status"] = "bids_scan_inventory_unavailable"
  1024. row["bids_match_evidence"] = NA
  1025. continue
  1026. clock = row.get("_acquisition_clock_seconds")
  1027. if clock is None:
  1028. row["bids_counterpart_status"] = "match_indeterminate_no_source_time"
  1029. row["bids_match_evidence"] = NA
  1030. continue
  1031. matched = [
  1032. filename for candidate_clock, filename in candidates
  1033. if abs(candidate_clock - float(clock)) <= tolerance_seconds
  1034. ]
  1035. if matched:
  1036. datatypes = sorted(
  1037. {filename.split("/", 1)[0] for filename in matched if "/" in filename},
  1038. key=natural_key,
  1039. )
  1040. row["bids_counterpart_status"] = "converted_or_listed"
  1041. row["bids_match_evidence"] = (
  1042. f"{len(matched)}_BIDS_file(s);datatype={','.join(datatypes) or NA}"
  1043. )
  1044. else:
  1045. row["bids_counterpart_status"] = "no_bids_counterpart"
  1046. row["bids_match_evidence"] = "no_scan_inventory_time_within_2s"
  1047. def build_candidate_sets(
  1048. rows: Sequence[dict[str, str]],
  1049. ) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
  1050. confirmed: list[dict[str, str]] = []
  1051. review: list[dict[str, str]] = []
  1052. for source_row in rows:
  1053. row = dict(source_row)
  1054. name_reason = row.get("candidate_reason", NA)
  1055. score = int(row.get("nm_signature_score", "0"))
  1056. source_text = " ".join(
  1057. row.get(field, NA)
  1058. for field in ("protocol_name", "series_description", "source_series_name")
  1059. )
  1060. if name_reason != NA and row["participant_id"] != NA and row["session_id"] != NA:
  1061. confirmed.append(row)
  1062. continue
  1063. reasons: list[str] = []
  1064. if name_reason != NA:
  1065. reasons.append("neuromelanin_candidate_without_parseable_session")
  1066. if score >= 6:
  1067. reasons.append("acquisition_signature_score_ge_6")
  1068. if PLAUSIBLE_PATTERN.search(source_text):
  1069. reasons.append("plausible_name_without_neuromelanin_label")
  1070. if GENERIC_NM_PATTERN.search(source_text) and score >= 2:
  1071. reasons.append("generic_NM_label_supported_by_signature")
  1072. if not reasons:
  1073. continue
  1074. review_row = {field: row.get(field, NA) for field in REVIEW_FIELDS}
  1075. review_row["review_reason"] = ";".join(reasons)
  1076. review.append(review_row)
  1077. confirmed.sort(
  1078. key=lambda row: (
  1079. natural_key(row["participant_id"]),
  1080. natural_key(row["session_id"]),
  1081. natural_key(row["series_number"]),
  1082. )
  1083. )
  1084. review.sort(
  1085. key=lambda row: (
  1086. natural_key(row["participant_id"]),
  1087. natural_key(row["session_id"]),
  1088. natural_key(row["series_number"]),
  1089. )
  1090. )
  1091. return confirmed, review
  1092. def build_omission_summary(
  1093. rows: Sequence[dict[str, str]],
  1094. ) -> list[dict[str, str]]:
  1095. descriptive_fields = [
  1096. field
  1097. for field in OMISSION_SUMMARY_FIELDS
  1098. if field
  1099. not in {
  1100. "source_series_labels",
  1101. "n_series",
  1102. "n_sessions",
  1103. "n_even_sessions",
  1104. "n_odd_sessions",
  1105. "participants",
  1106. "sessions",
  1107. }
  1108. ]
  1109. grouped: dict[tuple[str, ...], list[dict[str, str]]] = defaultdict(list)
  1110. for row in rows:
  1111. if row.get("bids_counterpart_status") != "no_bids_counterpart":
  1112. continue
  1113. key = tuple(row.get(field, NA) for field in descriptive_fields)
  1114. grouped[key].append(row)
  1115. summary: list[dict[str, str]] = []
  1116. for key, group in grouped.items():
  1117. item = dict(zip(descriptive_fields, key))
  1118. sessions = sorted(
  1119. {
  1120. f"{row['participant_id']}/{row['session_id']}"
  1121. for row in group
  1122. if row["participant_id"] != NA and row["session_id"] != NA
  1123. },
  1124. key=natural_key,
  1125. )
  1126. participants = sorted(
  1127. {row["participant_id"] for row in group if row["participant_id"] != NA},
  1128. key=natural_key,
  1129. )
  1130. item.update(
  1131. {
  1132. "source_series_labels": aggregate_values(group, "source_series_name"),
  1133. "n_series": str(len(group)),
  1134. "n_sessions": str(len(sessions)),
  1135. "n_even_sessions": str(sum(row["session_parity"] == "even" for row in group)),
  1136. "n_odd_sessions": str(sum(row["session_parity"] == "odd" for row in group)),
  1137. "participants": ",".join(participants) if participants else NA,
  1138. "sessions": ",".join(sessions) if sessions else NA,
  1139. }
  1140. )
  1141. summary.append(item)
  1142. summary.sort(
  1143. key=lambda row: (
  1144. -int(row["n_sessions"]),
  1145. -int(row["nm_signature_score"]),
  1146. natural_key(row["software_versions"]),
  1147. natural_key(row["protocol_name"]),
  1148. )
  1149. )
  1150. return summary
  1151. def assign_versions(rows: list[dict[str, str]]) -> None:
  1152. serialized_to_hash: dict[str, str] = {}
  1153. for row in rows:
  1154. if row["audit_status"] == "header_unreadable":
  1155. continue
  1156. serialized, digest = fingerprint(row)
  1157. serialized_to_hash[serialized] = digest
  1158. row["sequence_fingerprint_sha256"] = digest
  1159. versions = {
  1160. serialized: f"NM_v{index:02d}"
  1161. for index, serialized in enumerate(sorted(serialized_to_hash), start=1)
  1162. }
  1163. for row in rows:
  1164. if row["audit_status"] == "header_unreadable":
  1165. continue
  1166. serialized, _ = fingerprint(row)
  1167. row["sequence_version"] = versions[serialized]
  1168. counts = Counter((row["participant_id"], row["session_id"]) for row in rows)
  1169. for row in rows:
  1170. statuses = [] if row["audit_status"] == "ok" else row["audit_status"].split(";")
  1171. if expected_even(row["session_id"]) is False:
  1172. statuses.append("unexpected_odd_session")
  1173. if counts[(row["participant_id"], row["session_id"])] > 1:
  1174. statuses.append("multiple_nm_series")
  1175. row["audit_status"] = ";".join(dict.fromkeys(statuses)) if statuses else "ok"
  1176. def aggregate_values(rows: Sequence[Mapping[str, str]], field: str) -> str:
  1177. values = sorted({row.get(field, NA) for row in rows}, key=natural_key)
  1178. return " | ".join(values)
  1179. def build_summary(rows: Sequence[dict[str, str]]) -> list[dict[str, str]]:
  1180. grouped: dict[str, list[dict[str, str]]] = defaultdict(list)
  1181. for row in rows:
  1182. if row["sequence_version"] != NA:
  1183. grouped[row["sequence_version"]].append(row)
  1184. summary: list[dict[str, str]] = []
  1185. for version in sorted(grouped, key=natural_key):
  1186. group = grouped[version]
  1187. participants = sorted({row["participant_id"] for row in group}, key=natural_key)
  1188. sessions = sorted(
  1189. {f"{row['participant_id']}/{row['session_id']}" for row in group},
  1190. key=natural_key,
  1191. )
  1192. item = {field: NA for field in SUMMARY_FIELDS}
  1193. item.update(
  1194. {
  1195. "sequence_version": version,
  1196. "sequence_fingerprint_sha256": group[0]["sequence_fingerprint_sha256"],
  1197. "n_series": str(len(group)),
  1198. "n_sessions": str(len(sessions)),
  1199. "n_participants": str(len(participants)),
  1200. "participants": ",".join(participants),
  1201. "sessions": ",".join(sessions),
  1202. "protocol_names": aggregate_values(group, "protocol_name"),
  1203. "series_descriptions": aggregate_values(group, "series_description"),
  1204. }
  1205. )
  1206. for field in SUMMARY_FIELDS[9:]:
  1207. item[field] = aggregate_values(group, field)
  1208. summary.append(item)
  1209. return summary
  1210. def build_inventory(
  1211. rows: Sequence[dict[str, str]],
  1212. source_sessions: set[tuple[str, str]],
  1213. bids_sessions: set[tuple[str, str]],
  1214. ) -> list[dict[str, str]]:
  1215. by_session: dict[tuple[str, str], list[dict[str, str]]] = defaultdict(list)
  1216. for row in rows:
  1217. by_session[(row["participant_id"], row["session_id"])].append(row)
  1218. inventory = []
  1219. for participant_id, session_id in sorted(
  1220. source_sessions | bids_sessions,
  1221. key=lambda item: (natural_key(item[0]), natural_key(item[1])),
  1222. ):
  1223. scans = by_session[(participant_id, session_id)]
  1224. versions = sorted(
  1225. {row["sequence_version"] for row in scans if row["sequence_version"] != NA},
  1226. key=natural_key,
  1227. )
  1228. expected = expected_even(session_id)
  1229. in_source = (participant_id, session_id) in source_sessions
  1230. in_bids = (participant_id, session_id) in bids_sessions
  1231. notes = []
  1232. if in_source and not in_bids:
  1233. status = "bids_session_missing"
  1234. notes.append("source session present; corresponding BIDS session absent")
  1235. elif in_bids and not in_source:
  1236. status = "source_missing"
  1237. notes.append("BIDS session present; corresponding source session absent")
  1238. elif len(scans) > 1:
  1239. status = "multiple_nm_series"
  1240. notes.append("multiple neuromelanin series detected")
  1241. if expected is False:
  1242. notes.append("neuromelanin not expected in odd session")
  1243. elif scans and expected is True:
  1244. status = "expected_found"
  1245. elif scans and expected is False:
  1246. status = "unexpected_found"
  1247. elif not scans and expected is True:
  1248. status = "expected_missing"
  1249. elif not scans and expected is False:
  1250. status = "not_expected_not_found"
  1251. else:
  1252. status = "other"
  1253. notes.append("session parity is not numeric")
  1254. inventory.append(
  1255. {
  1256. "participant_id": participant_id,
  1257. "session_id": session_id,
  1258. "session_parity": (
  1259. "even" if expected is True else "odd" if expected is False else NA
  1260. ),
  1261. "nm_expected_from_design": yes_no(expected),
  1262. "nm_present": yes_no(bool(scans)),
  1263. "n_nm_series": str(len(scans)),
  1264. "sequence_version": ",".join(versions) if versions else NA,
  1265. "status": status,
  1266. "notes": "; ".join(notes) if notes else NA,
  1267. }
  1268. )
  1269. return inventory
  1270. def write_tsv(path: Path, rows: Sequence[Mapping[str, str]], fields: Sequence[str]) -> None:
  1271. path.parent.mkdir(parents=True, exist_ok=True)
  1272. with path.open("w", encoding="utf-8", newline="") as stream:
  1273. writer = csv.DictWriter(stream, fieldnames=fields, delimiter="\t", lineterminator="\n")
  1274. writer.writeheader()
  1275. writer.writerows({field: row.get(field, NA) for field in fields} for row in rows)
  1276. def differing_fields(summary: Sequence[Mapping[str, str]]) -> list[str]:
  1277. descriptive = []
  1278. for field in FINGERPRINT_FIELDS:
  1279. values = {row.get(field, NA) for row in summary}
  1280. if len(values) > 1:
  1281. descriptive.append(field)
  1282. return descriptive
  1283. def markdown_table(summary: Sequence[Mapping[str, str]], fields: Sequence[str]) -> str:
  1284. columns = ["sequence_version", "n_series", "n_sessions", *fields]
  1285. header = "| " + " | ".join(columns) + " |"
  1286. rule = "| " + " | ".join("---" for _ in columns) + " |"
  1287. body = []
  1288. for row in summary:
  1289. cells = [str(row.get(column, NA)).replace("|", "\\|") for column in columns]
  1290. body.append("| " + " | ".join(cells) + " |")
  1291. return "\n".join((header, rule, *body))
  1292. def status_sessions(inventory: Sequence[Mapping[str, str]], status: str) -> str:
  1293. values = [
  1294. f"{row['participant_id']}/{row['session_id']}"
  1295. for row in inventory
  1296. if row["status"] == status
  1297. ]
  1298. return ", ".join(values) if values else "None."
  1299. def label_parameter_relationships(rows: Sequence[Mapping[str, str]]) -> list[str]:
  1300. notes: list[str] = []
  1301. by_label: dict[tuple[str, str], set[str]] = defaultdict(set)
  1302. by_version: dict[str, set[tuple[str, str]]] = defaultdict(set)
  1303. for row in rows:
  1304. label = (row["protocol_name"], row["series_description"])
  1305. version = row["sequence_version"]
  1306. if version == NA:
  1307. continue
  1308. by_label[label].add(version)
  1309. by_version[version].add(label)
  1310. concealed = [label for label, versions in by_label.items() if len(versions) > 1]
  1311. shared = [version for version, labels in by_version.items() if len(labels) > 1]
  1312. if concealed:
  1313. notes.append(
  1314. f"{len(concealed)} protocol/series label combination(s) conceal acquisition-parameter differences."
  1315. )
  1316. if shared:
  1317. notes.append(
  1318. f"{len(shared)} acquisition version(s) occur under multiple protocol/series labels."
  1319. )
  1320. if not notes:
  1321. notes.append("Protocol/series labels and acquisition-parameter fingerprints are one-to-one.")
  1322. return notes
  1323. def write_report(
  1324. output_path: Path,
  1325. rows: Sequence[dict[str, str]],
  1326. source_rows: Sequence[dict[str, str]],
  1327. summary: Sequence[dict[str, str]],
  1328. inventory: Sequence[dict[str, str]],
  1329. review: Sequence[dict[str, str]],
  1330. omission_summary: Sequence[dict[str, str]],
  1331. unreadable_series: int,
  1332. unparsed_series: int,
  1333. duplicate_resources: int,
  1334. incomplete_copy_groups: int,
  1335. ) -> None:
  1336. participants = {row["participant_id"] for row in inventory}
  1337. expected_count = sum(row["nm_expected_from_design"] == "yes" for row in inventory)
  1338. present_count = sum(row["nm_present"] == "yes" for row in inventory)
  1339. changed = differing_fields(summary)
  1340. table_fields = changed or [
  1341. "repetition_time_ms",
  1342. "echo_time_ms",
  1343. "flip_angle_deg",
  1344. "pixel_spacing_row_mm",
  1345. "pixel_spacing_col_mm",
  1346. "slice_thickness_mm",
  1347. "number_of_slices",
  1348. ]
  1349. relationships = "\n".join(f"- {note}" for note in label_parameter_relationships(rows))
  1350. changed_text = ", ".join(changed) if changed else "none among fingerprinted fields"
  1351. no_counterpart = sum(
  1352. row.get("bids_counterpart_status") == "no_bids_counterpart" for row in source_rows
  1353. )
  1354. indeterminate = sum(
  1355. row.get("bids_counterpart_status", "").startswith("match_indeterminate")
  1356. or row.get("bids_counterpart_status") in {
  1357. "bids_scan_inventory_unavailable",
  1358. "bids_session_absent",
  1359. "session_unparseable",
  1360. }
  1361. for row in source_rows
  1362. )
  1363. signature_candidates = sum(
  1364. "acquisition_signature" in row.get("review_reason", "") for row in review
  1365. )
  1366. xa60_candidates = sum(
  1367. "XA60" in row.get("software_versions", "") for row in review
  1368. )
  1369. text = f"""# Night Owls neuromelanin acquisition audit
  1370. ## Purpose
  1371. 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.
  1372. ## Data sources
  1373. 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.
  1374. 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.
  1375. ## Overall inventory
  1376. - Participants examined: {len(participants)}
  1377. - Study sessions in the union of source and BIDS inventories: {len(inventory)}
  1378. - Sessions expected to contain NM from the even-session design: {expected_count}
  1379. - Sessions actually containing NM: {present_count}
  1380. - Confirmed NM series: {len(rows)}
  1381. - Unique readable acquisition versions: {len(summary)}
  1382. - Plausible non-confirmed series requiring manual review: {len(review)}
  1383. - Name-independent high-signature candidates: {signature_candidates}
  1384. - Review candidates whose software reports XA60: {xa60_candidates}
  1385. - Deduplicated raw series reconciled: {len(source_rows)}
  1386. - Raw series with no time-matched BIDS scan entry: {no_counterpart}
  1387. - Raw/BIDS matches that remain indeterminate: {indeterminate}
  1388. ## Acquisition versions
  1389. 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}.
  1390. {markdown_table(summary, table_fields) if summary else "No readable confirmed NM series were available for version comparison."}
  1391. Label-versus-parameter checks:
  1392. {relationships}
  1393. ## Completeness and problems
  1394. - Expected even sessions with no confirmed NM data: {status_sessions(inventory, "expected_missing")}
  1395. - Odd sessions with confirmed NM data: {status_sessions(inventory, "unexpected_found")}
  1396. - Sessions with multiple confirmed NM series: {status_sessions(inventory, "multiple_nm_series")}
  1397. - BIDS sessions absent from source: {status_sessions(inventory, "source_missing")}
  1398. - Source sessions absent from BIDS: {status_sessions(inventory, "bids_session_missing")}
  1399. - Raw series with no readable representative DICOM header: {unreadable_series}
  1400. - Readable series without a parseable participant/session path: {unparsed_series}
  1401. - Duplicate source-resource copies excluded from series counts: {duplicate_resources}
  1402. - Series with a less-complete duplicate source copy: {incomplete_copy_groups}
  1403. - Plausible but uncertain candidate series: {len(review)} (see `neuromelanin_candidate_review.tsv`)
  1404. - Recurrent unmatched source-series patterns: {len(omission_summary)} (see `source_to_bids_omission_summary.tsv`)
  1405. ## Source-to-BIDS reconciliation
  1406. `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.
  1407. `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.
  1408. ## Interpretation
  1409. 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.
  1410. ## BIDS implications — preliminary only
  1411. 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.
  1412. """
  1413. output_path.write_text(text, encoding="utf-8")
  1414. def validate_outputs(
  1415. rows: Sequence[dict[str, str]],
  1416. summary: Sequence[dict[str, str]],
  1417. inventory: Sequence[dict[str, str]],
  1418. ) -> None:
  1419. if len({id(row) for row in rows}) != len(rows):
  1420. raise RuntimeError("Internal manifest row duplication detected")
  1421. summary_count = sum(int(row["n_series"]) for row in summary)
  1422. readable_count = sum(row["sequence_version"] != NA for row in rows)
  1423. if summary_count != readable_count:
  1424. raise RuntimeError("Sequence summary counts do not reconcile with manifest")
  1425. inventory_count = sum(int(row["n_nm_series"]) for row in inventory)
  1426. if inventory_count != len(rows):
  1427. raise RuntimeError("Session inventory counts do not reconcile with manifest")
  1428. def privacy_check(output_dir: Path) -> None:
  1429. forbidden = [
  1430. re.compile(r"(?:Study|Series|SOP)InstanceUID", re.IGNORECASE),
  1431. re.compile(r"Patient(?:Name|Birth|Address|ID)", re.IGNORECASE),
  1432. re.compile(r"Acquisition(?:Date|Time|DateTime)", re.IGNORECASE),
  1433. re.compile(r"/Users/|/home/|/ZPOOL/|[A-Za-z]:\\\\"),
  1434. re.compile(r"\b\d+(?:\.\d+){3,}\b"),
  1435. re.compile(r"\b(?:19|20)\d{6}\b"),
  1436. re.compile(r"\b(?:[01]\d|2[0-3])[0-5]\d[0-5]\d(?:\.\d+)?\b"),
  1437. ]
  1438. for path in sorted(output_dir.glob("*")):
  1439. if path.suffix not in {".tsv", ".md"}:
  1440. continue
  1441. text = path.read_text(encoding="utf-8")
  1442. for pattern in forbidden:
  1443. if pattern.search(text):
  1444. raise RuntimeError(
  1445. f"Privacy check rejected {path.name}: matched forbidden pattern {pattern.pattern!r}"
  1446. )
  1447. def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
  1448. parser = argparse.ArgumentParser(description=__doc__)
  1449. parser.add_argument("--source-root", type=Path, required=True, help="Raw NOSC source root")
  1450. parser.add_argument("--bids-root", type=Path, required=True, help="Current BIDS dataset root")
  1451. parser.add_argument("--output-dir", type=Path, required=True, help="Directory for audit outputs")
  1452. parser.add_argument("--verbose", action="store_true", help="Enable progress messages")
  1453. return parser.parse_args(argv)
  1454. def main(argv: Sequence[str] | None = None) -> int:
  1455. args = parse_args(argv)
  1456. logging.basicConfig(
  1457. level=logging.INFO if args.verbose else logging.WARNING,
  1458. format="%(levelname)s: %(message)s",
  1459. )
  1460. if pydicom is None:
  1461. LOG.error("pydicom is required; install code/requirements-neuromelanin-audit.txt")
  1462. return 2
  1463. source_root = args.source_root.resolve()
  1464. bids_root = args.bids_root.resolve()
  1465. output_dir = args.output_dir.resolve()
  1466. if not source_root.is_dir():
  1467. LOG.error("Source root is not a readable directory")
  1468. return 2
  1469. try:
  1470. source_rows, source_sessions, unreadable_series, unparsed_series = audit_series(source_root)
  1471. source_rows, duplicate_resources, incomplete_copy_groups = deduplicate_resource_copies(
  1472. source_rows
  1473. )
  1474. bids_sessions = discover_bids_sessions(bids_root)
  1475. bids_index = discover_bids_scan_index(bids_root)
  1476. reconcile_source_to_bids(source_rows, bids_sessions, bids_index)
  1477. rows, review = build_candidate_sets(source_rows)
  1478. assign_versions(rows)
  1479. summary = build_summary(rows)
  1480. inventory = build_inventory(rows, source_sessions, bids_sessions)
  1481. omission_summary = build_omission_summary(source_rows)
  1482. validate_outputs(rows, summary, inventory)
  1483. output_dir.mkdir(parents=True, exist_ok=True)
  1484. write_tsv(output_dir / "neuromelanin_scan_manifest.tsv", rows, MANIFEST_FIELDS)
  1485. write_tsv(output_dir / "neuromelanin_sequence_summary.tsv", summary, SUMMARY_FIELDS)
  1486. write_tsv(output_dir / "neuromelanin_session_inventory.tsv", inventory, INVENTORY_FIELDS)
  1487. write_tsv(output_dir / "neuromelanin_candidate_review.tsv", review, REVIEW_FIELDS)
  1488. write_tsv(
  1489. output_dir / "source_to_bids_series_reconciliation.tsv",
  1490. source_rows,
  1491. RECONCILIATION_FIELDS,
  1492. )
  1493. write_tsv(
  1494. output_dir / "source_to_bids_omission_summary.tsv",
  1495. omission_summary,
  1496. OMISSION_SUMMARY_FIELDS,
  1497. )
  1498. write_report(
  1499. output_dir / "README.md",
  1500. rows,
  1501. source_rows,
  1502. summary,
  1503. inventory,
  1504. review,
  1505. omission_summary,
  1506. unreadable_series,
  1507. unparsed_series,
  1508. duplicate_resources,
  1509. incomplete_copy_groups,
  1510. )
  1511. privacy_check(output_dir)
  1512. except (OSError, RuntimeError) as error:
  1513. LOG.error("%s", error)
  1514. return 1
  1515. LOG.info(
  1516. "Wrote %d confirmed series, %d review candidates, %d versions, %d sessions, and %d source/BIDS rows",
  1517. len(rows),
  1518. len(review),
  1519. len(summary),
  1520. len(inventory),
  1521. len(source_rows),
  1522. )
  1523. return 0
  1524. if __name__ == "__main__":
  1525. sys.exit(main())

audit_neuromelanin.py at commit ec5197e, under MIT · at the source

Overview

Authors: Matthew Mattoni1, Shenghan Wang1, Cooper J Sharp1, Thomas M Olino1, David V Smith1
  1. Department of Psychology and Neuroscience, Temple University, Philadelphia, Pennsylvania, USA
Institutions: Temple University (United States)
Journal: Human brain mapping, volume 47, issue 5, article e70512
Dates: received 20 October 2025; accepted 11 March 2026; published online 24 March 2026; in print April 2026
Type: Research article · Language: English
License: CC BY-NC-ND
Identifiers: DOI 10.1002/hbm.70512 · PMID 41877495 · PMCID PMC13140683 · OpenAlex W7140217718
Open access: gold, a free copy (OpenAlex)
Status: code verified
Categories: fMRI (modality), human (organism), methods / tools (subfield)
Methods: Connectivity, Preprocessing, fMRI & imaging, Statistics
MeSH: Brain*, Brain Mapping*, Individuality*, Magnetic Resonance Imaging*, Reward*, Adult, Affect, Female, Humans, Image Processing, Computer-Assisted, Male, Reproducibility of Results, Young Adult (* major topic)
Topic: Functional Brain Connectivity Studies (Cognitive Neuroscience, Neuroscience), according to OpenAlex
Funding: National Institute of Mental Health (F31MH134533); NIH HHS (R01AG067011); NIMH NIH HHS (F31MH134533); National Institutes of Health (R01AG067011)
Citations: cited by 1 paper (Europe PMC); 70 references in the paper
Research resources: which is based on Nipype 1.7.0 RRID:SCR_002502

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

License: MIT
State: the link answers, verified on 28 September 2026
Evidence: files inventoried
Commit: ec5197ed1dbf74717497ce69452974f096246dc7, 9 September 2026
Languages: Shell (97), Python (36), MATLAB (20), R (8), Jupyter (1)
Size: 1,838 files, 162 scripts
Software Heritage: not archived
Found in: the text, “Methods”
Holds: README, license file, environment (code/requirements-neuromelanin-audit.txt), tests, 1 notebook
Not found: CITATION.cff, continuous integration, documentation
Tools: FSL (62 files), fMRIPrep (46 files), NumPy (19 files), pandas (14 files), PsychoPy (12 files), Psychtoolbox (12 files), NiBabel (8 files), tidyverse (7 files), Matplotlib (6 files), ANTs (5 files), psych (4 files), lme4 (3 files), lmerTest (3 files), AFNI (2 files), easystats (2 files), ggplot2 (2 files), patchwork (2 files), pydicom (2 files), dcm2niix (1 file), HeuDiConv (1 file)
Availability: 1 check, the latest on 28 September 2026: the link answers
  • 28 September 2026: the link answers
164 files

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

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:

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://doi.org/10.1002/hbm.70512

BibTeX

@article{mattoni2026precision,
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/hbm.70512},
url = {https://doi.org/10.1002/hbm.70512},
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/04/01
VL - 47
IS - 5
SP - e70512
SN - 1065-9471
PB - Wiley
DO - 10.1002/hbm.70512
UR - https://doi.org/10.1002/hbm.70512
LA - en
ER -

CSL-JSON

{
"id": "10.1002/hbm.70512",
"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": "Hum Brain Mapp",
"volume": "47",
"issue": "5",
"page": "e70512",
"DOI": "10.1002/hbm.70512",
"PMID": "41877495",
"PMCID": "PMC13140683",
"ISSN": "1065-9471",
"publisher": "Wiley",
"URL": "https://doi.org/10.1002/hbm.70512",
"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/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
[2] 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 America
In common: PsychoPy, psych, easystats, 10 other tools, 2 references
[3] 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: Neuron
In common: AFNI, ANTs, FSL, 3 other tools, fMRI, 8 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, ANTs, easystats, 10 other tools, fMRI, 1 reference
[5] doi:10.1002/hbm.70621 [code]
Optimising 7T-fMRI for Imaging Regions of Magnetic Susceptibility.
Journal: Human brain mapping
In common: HeuDiConv, fMRIPrep, AFNI, 2 other tools, fMRI, 6 references
[6] doi:10.1038/s41467-026-74565-0 [code]
The functional neurobiology of dispositions towards negative emotions.
Journal: Nature communications
In common: AFNI, psych, easystats, 7 other tools, 4 references
[7] 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
[8] doi:10.1016/j.dcn.2026.101765 [code]
Fusiform face area development correlates with development in higher-order social brain regions.
Journal: Developmental cognitive neuroscience
In common: fMRIPrep, ANTs, lmerTest, 8 other tools, fMRI, 2 references
[9] doi:10.1038/s41467-026-73072-6 [code]
Mapping the spatiotemporal continuum of structural connectivity development across the human connectome in youth.
Journal: Nature communications
In common: psych, easystats, lmerTest, 7 other tools, 4 references
[10] doi:10.1162/imag.a.1262 [code]
Frame-wise multi-echo distortion correction for superior functional MRI.
Journal: Imaging neuroscience (Cambridge, Mass.)
In common: HeuDiConv, pydicom, FSL, 4 other tools, fMRI, 5 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.

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.