diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 00000000..b5468091 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,25 @@ +name: documentation +on: [push, pull_request, workflow_dispatch] +permissions: + contents: write + +jobs: + docs: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + - name: Install dependencies + run: | + make .venv + - name: Sphinx build + run: | + make docs/ + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + if: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' }} + with: + publish_branch: gh-pages + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_dir: docs/ + force_orphan: true diff --git a/Makefile b/Makefile index efb50493..00b7a957 100644 --- a/Makefile +++ b/Makefile @@ -1,2 +1,21 @@ +.PHONY: test + +# how to get into the python virtual environment +source_venv := source .venv/bin/activate + +docs/: .venv/ $(wildcard *.py) sphinx/conf.py $(wildcard sphinx/*.rst) + $(source_venv) && sphinx-build sphinx/ docs/ + +test: + $(source_venv) && python3 -m doctest change_header.py + +.venv/: + python -m venv .venv && $(source_venv) && pip install -r requirements.txt + + +db.sqlite: + sqlite3 < schema.sql + +# TODO: replace me actual code db.txt: ./00_build_db.bash diff --git a/change_header.py b/change_header.py new file mode 100755 index 00000000..583cd00e --- /dev/null +++ b/change_header.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +Modify protocol names. +""" +import os +from pathlib import Path +from typing import List, Optional +from itertools import chain +import pydicom + + +def change_protocol_name( + dcm_dir: Path, new_data: List[pydicom.DataElement], out_dir: Optional[Path] = None +): + """ + Change specified tags of all dicoms in a directory. Optionally make copies in out_dir. + + :param dcm_dir: input directory with dicom files (``MR*``, ``*IMA`` , or ``*dcm``) + :param new_data: list of data elements to replace + like ``[pydicom.DataElement(value="newpname", VR="LO", tag=(0x0018, 0x1030))]`` + :param out_dir: Optional. Where to save modified dicoms + :return: example modified dicom. last if out_dir, first and only if no ``out_dir``. + + sideffect: writes copies of dcm_dir dicoms inot out_dir unless out_dir is None. + + >>> new_data = [pydicom.DataElement(value="newpname", VR="LO", tag=(0x0018, 0x1030))] + >>> ex_path = Path('example/dicom/11903_20221222/HabitTask_704x752.18/') + >>> ex = change_protocol_name(ex_path, new_data) + >>> ex.ProtocolName + 'newpname' + """ + all_dicoms = chain(dcm_dir.glob("MR*"), dcm_dir.glob("*IMA"), dcm_dir.glob("*dcm")) + ex_dcm = None + for ex_dcm_file in all_dicoms: + ex_dcm = pydicom.dcmread(ex_dcm_file) + + for datum in new_data: + ex_dcm[datum.tag] = datum + + # dont need to do anything if not writing files + if out_dir is None: + return ex_dcm + + new_file = os.path.join(out_dir, os.path.basename(ex_dcm_file)) + # assume if we have one, we have them all (leave loop at first existing) + if os.path.exists(new_file): + return ex_dcm + + # and save out + os.makedirs(out_dir, exist_ok=True) + ex_dcm.save_as(new_file) + + return ex_dcm + + +if __name__ == "__main__": + new_tags = [ + # Repetition Time + pydicom.DataElement(value="1301", VR="DS", tag=(0x0018, 0x0080)), + # Patient ID + pydicom.DataElement(value="mod1", VR="PN", tag=(0x0010, 0x0010)), + pydicom.DataElement(value="mod1", VR="LO", tag=(0x0010, 0x0020)), + ## anonymize + # DOB + pydicom.DataElement(value="19991231", VR="DA", tag=(0x0010, 0x0030)), + # age + pydicom.DataElement(value="100Y", VR="AS", tag=(0x0010, 0x1010)), + # sex + pydicom.DataElement(value="20240131", VR="CS", tag=(0x0010, 0x0040)), + ] + + change_protocol_name( + Path("example/dicom/11903_20221222/HabitTask_704x752.18/"), + new_tags, + Path("example/dicom/mod1/HabitTask/"), + ) diff --git a/readme.md b/readme.md new file mode 100644 index 00000000..631b3905 --- /dev/null +++ b/readme.md @@ -0,0 +1,19 @@ +# MRRC Dicom Header Quality Assurance +Parse dicoms into a template database and alert on non-conforming sequences. + +See + * `make docs/` for building sphinx documentation + * locally in [`sphinx/index.rst`](sphinx/index.rst)) + * reference for restructured text [`sphinx docstrings`](https://sphinx-rtd-tutorial.readthedocs.io/en/latest/docstrings.html) + * `make test` for using `doctests` + * [`schema.sql`](schema.sql) for DB schema + +## Strategy + + * build sqlite db of all acquisitions with subset of parameters + * use db summary to pull out "ideal template" + * check new sessions' acquisitions against template to alert + +## Prior Art + * mrQA + * sister project https://github.com/NPACore/mrqart/ diff --git a/requirements.txt b/requirements.txt index 2c175e0d..c899ac02 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ pydicom mrQA +dicom-parser +sphinx diff --git a/schema.sql b/schema.sql new file mode 100644 index 00000000..e91c17a7 --- /dev/null +++ b/schema.sql @@ -0,0 +1,31 @@ +-- acquisition. time id +create table acq ( + param_id integer, -- join to session-consitent settings + AcqTime text, + AcqDate text, + SeiresNumber text, + SubID text, + Operator text +); + +-- acq params that should match across sessions +create table acq_param ( + is_ideal timestamp, + Project text, + SequenceName text, + -- TODO: should this be json blob? to extend easier? + iPAT text, + Comments text, + SequenceType text, + PED_major text, + TR text, + TE text, + Matrix text, + PixelResol text, + BWP text, + BWPPE text, + FA text, + TA text, + FoV text + -- TODO: add shim settings from CSA +); diff --git a/sphinx/conf.py b/sphinx/conf.py new file mode 100644 index 00000000..1056234b --- /dev/null +++ b/sphinx/conf.py @@ -0,0 +1,33 @@ +import sys +import os +sys.path.insert(0, os.path.abspath('../')) # Source code dir relative to this file +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'mrrc-hdr-qa' +copyright = '2024, EH, WF' +author = 'EH, WF' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ 'sphinx.ext.autodoc', 'sphinx.ext.autosummary'] +autodoc_default_options = { 'members': True } +autodoc_typehints = "description" +autosummary_generate = True + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store', 'docs', '.venv', 'lib'] + + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'alabaster' +html_static_path = ['_static'] diff --git a/sphinx/index.rst b/sphinx/index.rst new file mode 100644 index 00000000..7ed78b4b --- /dev/null +++ b/sphinx/index.rst @@ -0,0 +1,19 @@ +.. mrrc-hdr-qa documentation master file, created by + sphinx-quickstart on Mon Oct 21 19:00:25 2024. + You can adapt this file completely to your liking, but it should at least + contain the root `toctree` directive. + +mrrc-hdr-qa documentation +========================= + +Code to parse dicoms into a template database and alert on non-conforming sequences. + + +.. toctree:: + :caption: Contents: + +.. autosummary:: + :toctree: _autosummary + :recursive: + + change_header