Select part of a cohort
You rarely hand out everything you ingested. One recipient gets the images, another gets one patient. Two questions come with that: how do you export only the patients or series you chose, and how do you find out when you asked for something the session does not hold? This tutorial answers both over the same three files as the first tutorial, in a folder of their own.
Run it yourself
Every Python block on this page runs, in order, as part of Isocenter's test suite, and every output shown is checked.
- Start in a new, empty folder. Each tutorial creates its own
tutorial.dband export folders, and running one in another tutorial's folder changes what it prints. The first block below copies the input files from pydicom intoinput/. - Paste the blocks into a Python prompt or a notebook. In a
.pyscript, put them underif __name__ == "__main__":(why). - In a block with
>>>, type what follows each>>>; the lines under it are what Python prints. A...inside a printed value stands for a part that differs on every run, such as a pseudonym or a UID. - The session also prints progress bars, status lines and
WARNINGlines as it works. They are not shown here.ISOCENTER_SHOW_PROGRESS=0turns the bars off.
import shutil
from pathlib import Path
import pydicom.data
Path("input").mkdir(exist_ok=True)
for name in ["CT_small.dcm", "MR_small.dcm", "rtdose.dcm"]:
shutil.copy(pydicom.data.get_testdata_file(name), "input")
1. Look at the cohort
Ingest the three files. Each is one patient: a CT, an MR and an RT dose grid.
from isocenter import Session
session = Session("tutorial.db")
session.ingest("input")
early = session.get_cohort_report()
get_cohort_report() returns a pandas DataFrame with one row per
instance: its patient, study, series and instance, and a few columns you
can select on.
>>> early[["PatientID", "Modality"]]
PatientID Modality
0 1CT1 CT
1 4MR1 MR
2 id11111 RTDOSE
>>> list(early.columns)
['PatientID', 'PatientName', 'StudyInstanceUID', 'StudyDate', 'SeriesInstanceUID', 'Modality', 'SOPInstanceUID', 'Manufacturer', 'Model', 'DeviceSerial']
These are still the source values. That matters later: this report was taken before anything was de-identified.
2. Choose rows with a pandas query
The report is an ordinary DataFrame, so you choose with ordinary pandas. This recipient gets the images and not the dose grid:
get_cohort_report(expand_metadata=True) adds a column for every
attribute in the files, if the column you want to select on is not among
the ten above.
3. De-identify, then export the selection
On this page, nothing leaves before it is de-identified. No configuration is loaded
here, so anonymize() applies the session's default policy (the first
tutorial shows how to write your own). With no argument it audits and
then acts on every finding.
Now pass the rows you chose as subset=. rtdose.dcm stores 32-bit
dose values, which Isocenter's JPEG 2000 encoder cannot write exactly,
so every export on this page uses use_compression=False
(#771 tracks writing
such an instance uncompressed on its own).
session.export("export-images", subset=images, use_compression=False)
session.generate_report("report-images.md")
Two small helpers read the result. exported opens every file an export
wrote, keyed by modality. grade_line prints the line of the report
that holds its grade, as in the first tutorial:
import pydicom
from pathlib import Path
def exported(folder):
"""Each exported file, keyed by its modality."""
return {ds.Modality: ds for ds in
(pydicom.dcmread(path) for path in Path(folder).rglob("*.dcm"))}
def grade_line(path):
with open(path, encoding="utf-8") as report_file:
return next(line.strip() for line in report_file
if "Validation Status" in line)
>>> sorted(exported("export-images"))
['CT', 'MR']
>>> print(grade_line("report-images.md"))
| **Validation Status** | **PASS** |
The CT and the MR were written; the dose grid was not.
Why a report from before anonymize() still selects. A DataFrame is
read by the most precise of four columns it carries: SOPInstanceUID,
then SeriesInstanceUID, StudyInstanceUID, PatientID. anonymize()
replaced every one of those UIDs in the graph, so the exported CT no
longer carries the UID the report shows:
>>> ct = exported("export-images")["CT"]
>>> ct.SOPInstanceUID == images.SOPInstanceUID[0]
False
>>> ct.SOPInstanceUID
'2.25...'
The session remembers each instance's source UIDs, so a Study, Series or
SOP Instance UID taken before anonymize() still names its entity. A
Patient ID does not, as section 5 shows.
4. Select patients by ID
To export whole patients, pass patient_ids=. After anonymize() a
patient's ID is its replacement, so read the IDs from a report taken
now, not from early:
now = session.get_cohort_report()
dose_patient = now.query("Modality == 'RTDOSE'").PatientID.tolist()
session.export("export-dose", patient_ids=dose_patient,
use_compression=False)
session.generate_report("report-dose.md")
>>> sorted(exported("export-dose"))
['RTDOSE']
>>> print(grade_line("report-dose.md"))
| **Validation Status** | **PASS** |
patient_ids takes a list, or any other iterable, of Patient IDs, and
each one must match exactly. A bare string is refused with a
TypeError, because a string is itself an iterable of characters: write
patient_ids=["ANON_..."], not patient_ids="ANON_...".
5. Check a list before you export it
A Patient ID that no patient holds selects nothing. Isocenter never
drops it silently: it is counted, by its position in your list, and
never named. The common way to get one after anonymize() is not a typo.
It is a list built from a report taken earlier, which holds the source
IDs:
No row came back. The call also logged one WARNING line: no patient
matches 3 of the 3 IDs given, and after anonymize() a patient is
selected by its replacement Patient ID. The same holds for subset= with
a frame that has only a PatientID column.
get_cohort_report() is a read. It logs the count and writes nothing to
the store, so it is the place to try a list. export() counts in the
same way, and also writes a row into the store's audit log, as the next
section shows.
6. Export with a mistyped ID, and read the grade
The steps above ended at PASS, and this one will not. A row, once
written, stays in the store for good, so this page keeps its one mistake
for last.
Say your list holds the dose patient's ID and a second ID with a typo in it:
wanted = dose_patient + ["ANON_TYPO"]
session.export("export-typo", patient_ids=wanted, use_compression=False)
session.generate_report("report-typo.md")
The patient that matched is exported as usual. The one that did not is counted, and the report says a person has to look:
>>> sorted(exported("export-typo"))
['RTDOSE']
>>> print(grade_line("report-typo.md"))
| **Validation Status** | **REVIEW_REQUIRED** |
The reason is in section 4 of the report, Exceptions & Errors. This helper counts the rows there and prints the details of each:
def exceptions(path):
with open(path, encoding="utf-8") as report_file:
text = report_file.read()
section = text.split("## 4. Exceptions & Errors")[1].split("\n## ")[0]
rows = [line.split(" | ")[-1].rstrip(" |")
for line in section.splitlines() if line.startswith("| 20")]
print(len(rows), "row(s)")
for row in rows:
print(row)
>>> exceptions("report-typo.md")
1 row(s)
DICOM export to export-typo: patient_ids: no patient in the session matches 1 of the 2 ids given (position 2, in the order given); it selects nothing. After anonymize(), a patient is selected by its replacement Patient ID.
Position 2 is "ANON_TYPO". The row names the export folder and the
position, never the value, because an ID in a list you pass may be a
source identifier.
These helpers read the report's layout, which can change
The grade values PASS and REVIEW_REQUIRED are frozen for 1.x. The
report's layout and the wording of each row are not
(API stability). grade_line and
exceptions are for reading a report, not for gating automation on
one.
The report grades every row the store holds: every row any session has
ever written into this store, not only the rows of the last export.
Exporting again with the right list writes the right files, and the
grade stays REVIEW_REQUIRED, because the typo's row is still there:
session.export("export-dose-again", patient_ids=dose_patient,
use_compression=False)
session.generate_report("report-dose-again.md")
>>> sorted(exported("export-dose-again"))
['RTDOSE']
>>> print(grade_line("report-dose-again.md"))
| **Validation Status** | **REVIEW_REQUIRED** |
>>> exceptions("report-dose-again.md")
1 row(s)
DICOM export to export-typo: patient_ids: no patient in the session matches 1 of the 2 ids given (position 2, in the order given); it selects nothing. After anonymize(), a patient is selected by its replacement Patient ID.
The one row is still the typo's, naming export-typo; the correct
export added none. That is deliberate. The report is the record of every
session run over this store, and an export that asked for someone who was
not there is part of it. The reviewer reads section 4, sees that the row
names export-typo, and decides. Nothing removes a row: only a new store
starts clean (How the grade is decided).
Section 5 is the way to avoid the row: try the list with
get_cohort_report() first.
What each selection reads
| You pass | It selects | After anonymize() |
|---|---|---|
subset=<DataFrame> |
Every instance under the value in its most precise UID column: SOPInstanceUID, then SeriesInstanceUID, StudyInstanceUID, PatientID |
A UID column taken earlier still selects. A PatientID column taken earlier selects nothing. |
subset=[uid, ...] |
Every instance under each UID, at any of the four levels | As for a DataFrame |
subset="<pandas query>" |
The rows of get_cohort_report(expand_metadata=True) that match |
The query runs against the report as it is now |
patient_ids=[id, ...] |
Every instance of each patient whose ID matches exactly | Use the replacement IDs |
A value that names nothing is counted, never named, in one WARNING
line; export() also writes one WARNING row, which grades the report
REVIEW_REQUIRED. subset= is for the dicom format; patient_ids=
works for both dicom and wfdb.
API stability states these rules as 1.x promises,
and Query-Based Export has more
examples.