Packages

Pure Elixir DICOM toolkit for P10 files, DICOM JSON, de-identification, and structured reports (all 33 PS3.16 root templates)

Current section

Files

Jump to
dicom lib mix tasks dicom_gen_sop_classes.ex
Raw

lib/mix/tasks/dicom_gen_sop_classes.ex

defmodule Mix.Tasks.Dicom.GenSopClasses do
@compile {:no_warn_undefined, :json}
@moduledoc """
Generates `Dicom.SOPClass` from innolitics sops.json and service_sop_classes.exs.
Reads `priv/sops.json` (175 storage SOP classes from innolitics/dicom-standard)
and `priv/service_sop_classes.exs` (hand-maintained non-storage SOP classes),
derives modality from SOP name, marks retired UIDs, and generates a compile-time
registry module.
## Usage
mix dicom.gen_sop_classes
"""
@shortdoc "Generate DICOM SOP Class registry from PS3.4 data"
use Mix.Task
@output_path "lib/dicom/sop_class.ex"
# ── Known retired storage SOP classes ─────────────────────────
@retired_uids MapSet.new([
# Nuclear Medicine Image Storage (Retired)
"1.2.840.10008.5.1.4.1.1.5",
# Ultrasound Image Storage (Retired)
"1.2.840.10008.5.1.4.1.1.6",
# Ultrasound Multi-frame Image Storage (Retired)
"1.2.840.10008.5.1.4.1.1.3",
# Standalone Curve Storage (Retired)
"1.2.840.10008.5.1.4.1.1.9",
# Standalone Modality LUT Storage (Retired)
"1.2.840.10008.5.1.4.1.1.10",
# Standalone VOI LUT Storage (Retired)
"1.2.840.10008.5.1.4.1.1.11",
# X-Ray Angiographic Bi-Plane Image Storage (Retired)
"1.2.840.10008.5.1.4.1.1.12.3",
# Digital X-Ray Image Storage - For Processing
"1.2.840.10008.5.1.4.1.1.1.1.1",
# Digital Mammography X-Ray Image Storage - For Processing
"1.2.840.10008.5.1.4.1.1.1.2.1",
# Digital Intra-Oral X-Ray Image Storage - For Processing
"1.2.840.10008.5.1.4.1.1.1.3.1",
# Standalone PET Curve Storage (Retired)
"1.2.840.10008.5.1.4.1.1.129",
# RT Image Storage (Retired? No, active — remove)
"1.2.840.10008.5.1.4.1.1.481.1"
])
# ── Name → modality mapping ──────────────────────────────────
# Order matters: more specific patterns first.
@modality_patterns [
# RT sub-modalities
{"RT Plan", "RTPLAN"},
{"RT Dose", "RTDOSE"},
{"RT Structure Set", "RTSTRUCT"},
{"RT Beams Treatment Record", "RTRECORD"},
{"RT Brachy Treatment Record", "RTRECORD"},
{"RT Treatment Summary Record", "RTRECORD"},
{"RT Ion Plan", "RTPLAN"},
{"RT Ion Beams Treatment Record", "RTRECORD"},
{"RT Beams Delivery Instruction", "RTPLAN"},
{"RT Physician Intent", "RTPLAN"},
{"RT Segment Annotation", "RTSTRUCT"},
{"RT Radiation Set", "RTPLAN"},
{"RT Radiation", "RTPLAN"},
{"RT Image", "RTIMAGE"},
{"Radiopharmaceutical Radiation Dose SR", "SR"},
# Imaging modalities — more specific first
{"Enhanced CT", "CT"},
{"Legacy Converted Enhanced CT", "CT"},
{"CT Defined Procedure Protocol", "CT"},
{"CT Image", "CT"},
{"CT Performed Procedure Protocol", "CT"},
{"Enhanced MR Color", "MR"},
{"Enhanced MR", "MR"},
{"Legacy Converted Enhanced MR", "MR"},
{"MR Spectroscopy", "MR"},
{"MR Image", "MR"},
{"Ultrasound Multi-frame", "US"},
{"Ultrasound Image", "US"},
{"Ultrasound", "US"},
{"Enhanced US Volume", "US"},
{"Digital X-Ray", "DX"},
{"Digital Mammography X-Ray", "MG"},
{"Digital Intra-Oral X-Ray", "IO"},
{"Mammography CAD SR", "SR"},
{"Computed Radiography", "CR"},
{"X-Ray Angiographic", "XA"},
{"XA Defined Procedure Protocol", "XA"},
{"XA Performed Procedure Protocol", "XA"},
{"XA/XRF Grayscale Softcopy Presentation State", "XA"},
{"X-Ray Radiofluoroscopic", "RF"},
{"X-Ray 3D Angiographic", "XA"},
{"X-Ray 3D Craniofacial", "DX"},
{"Breast Tomosynthesis", "MG"},
{"Breast Projection X-Ray", "MG"},
{"Wide Field Ophthalmic Photography", "OP"},
{"Ophthalmic Photography", "OP"},
{"Ophthalmic Tomography", "OPT"},
{"Ophthalmic Axial Measurements", "OPM"},
{"Ophthalmic Visual Field", "OPV"},
{"Ophthalmic Thickness Map", "OPT"},
{"Ophthalmic Optical Coherence Tomography", "OPT"},
{"Lensometry Measurements", "LEN"},
{"Autorefraction Measurements", "AR"},
{"Keratometry Measurements", "KER"},
{"Subjective Refraction Measurements", "SRF"},
{"Visual Acuity Measurements", "VA"},
{"Intraocular Lens Calculations", "IOL"},
{"Nuclear Medicine", "NM"},
{"Positron Emission Tomography", "PT"},
{"PET Image", "PT"},
{"Enhanced PET", "PT"},
{"Legacy Converted Enhanced PET", "PT"},
{"Secondary Capture", "SC"},
{"Multi-frame Single Bit Secondary Capture", "SC"},
{"Multi-frame Grayscale Byte Secondary Capture", "SC"},
{"Multi-frame Grayscale Word Secondary Capture", "SC"},
{"Multi-frame True Color Secondary Capture", "SC"},
{"VL Endoscopic", "ES"},
{"VL Microscopic", "GM"},
{"VL Slide-Coordinates Microscopic", "SM"},
{"VL Photographic", "XC"},
{"VL Whole Slide Microscopy", "SM"},
{"Video Endoscopic", "ES"},
{"Video Microscopic", "GM"},
{"Video Photographic", "XC"},
{"Dermoscopic Photography", "DMS"},
# SR sub-types
{"Acquisition Context SR", "SR"},
{"Comprehensive 3D SR", "SR"},
{"Extensible SR", "SR"},
{"Comprehensive SR", "SR"},
{"Enhanced SR", "SR"},
{"Basic Text SR", "SR"},
{"Procedure Log", "SR"},
{"Chest CAD SR", "SR"},
{"Colon CAD SR", "SR"},
{"Implantation Plan SR", "SR"},
{"Spectacle Prescription Report", "SR"},
{"Macular Grid Thickness and Volume Report", "SR"},
# Encapsulated documents
{"Encapsulated PDF", "DOC"},
{"Encapsulated CDA", "DOC"},
{"Encapsulated STL", "M3D"},
{"Encapsulated OBJ", "M3D"},
{"Encapsulated MTL", "M3D"},
# Presentation states
{"Blending Softcopy Presentation State", "PR"},
{"Color Softcopy Presentation State", "PR"},
{"Grayscale Softcopy Presentation State", "PR"},
{"Pseudo-Color Softcopy Presentation State", "PR"},
{"Advanced Blending Presentation State", "PR"},
{"Compositing Planar MPR Volumetric Presentation State", "PR"},
{"Volume Rendering Volumetric Presentation State", "PR"},
{"Softcopy Presentation State", "PR"},
{"Presentation State", "PR"},
# Structured data
{"Key Object Selection Document", "KO"},
{"Segmentation", "SEG"},
{"Surface Segmentation", "SEG"},
{"Parametric Map", "OT"},
{"Surface Scan Mesh", "M3D"},
{"Surface Scan Point Cloud", "M3D"},
{"Tractography Results", "OT"},
# Waveforms
{"12-Lead ECG Waveform", "ECG"},
{"General ECG Waveform", "ECG"},
{"Ambulatory ECG Waveform", "ECG"},
{"Hemodynamic Waveform", "HD"},
{"Cardiac Electrophysiology Waveform", "EPS"},
{"Basic Voice Audio Waveform", "AU"},
{"General Audio Waveform", "AU"},
{"Arterial Pulse Waveform", "HD"},
{"Respiratory Waveform", "RESP"},
{"Multi-channel Respiratory Waveform", "RESP"},
{"Routine Scalp Electroencephalogram Waveform", "EEG"},
{"Electromyogram Waveform", "EMG"},
{"Electrooculogram Waveform", "EOG"},
{"Sleep Electroencephalogram Waveform", "EEG"},
{"Body Position Waveform", "OT"},
# Spatial data
{"Spatial Registration", "REG"},
{"Spatial Fiducials", "FID"},
{"Deformable Spatial Registration", "REG"},
# Misc
{"Real World Value Mapping", "OT"},
{"Raw Data", "OT"},
{"Stereometric Relationship", "SMR"},
{"Content Assessment Results", "OT"},
{"Robotic-Arm Radiation", "RTPLAN"},
{"Microscopy Bulk Simple Annotations", "ANN"},
{"Confocal Microscopy", "CFM"},
{"Photoacoustic", "PA"},
{"Inventory", "OT"}
]
@impl Mix.Task
def run(_args) do
storage_path = "priv/sops.json"
service_path = "priv/service_sop_classes.exs"
unless File.exists?(storage_path) do
Mix.raise("#{storage_path} not found. Download from innolitics/dicom-standard.")
end
unless File.exists?(service_path) do
Mix.raise("#{service_path} not found.")
end
Mix.shell().info("Reading #{storage_path}...")
storage_entries = storage_path |> File.read!() |> :json.decode() |> parse_storage_entries()
Mix.shell().info("Reading #{service_path}...")
{service_entries, _} = Code.eval_file(service_path)
service_entries = parse_service_entries(service_entries)
all_entries = merge_entries(storage_entries, service_entries)
Mix.shell().info(
"Total: #{length(all_entries)} SOP classes " <>
"(#{length(storage_entries)} storage + #{length(service_entries)} service)"
)
source = generate_source(all_entries)
File.write!(@output_path, source)
Mix.shell().info("Written to #{@output_path}")
Mix.shell().info("Run `mix format #{@output_path}` to format.")
end
# ── Parse storage entries from sops.json ──────────────────────
defp parse_storage_entries(json_entries) do
Enum.map(json_entries, fn %{"id" => uid, "name" => name} ->
retired = retired?(uid, name)
modality = derive_modality(name)
%{uid: uid, name: name, type: :storage, modality: modality, retired: retired}
end)
end
# ── Parse service entries from .exs ───────────────────────────
defp parse_service_entries(entries) do
Enum.map(entries, fn {uid, name, type} ->
retired = String.contains?(name, "(Retired)")
%{uid: uid, name: name, type: type, modality: nil, retired: retired}
end)
end
# ── Merge and deduplicate ─────────────────────────────────────
defp merge_entries(storage, service) do
storage_uids = MapSet.new(storage, & &1.uid)
# Service entries that don't overlap with storage
unique_service = Enum.reject(service, fn entry -> MapSet.member?(storage_uids, entry.uid) end)
(storage ++ unique_service)
|> Enum.sort_by(& &1.uid)
end
# ── Retired detection ─────────────────────────────────────────
defp retired?(uid, name) do
MapSet.member?(@retired_uids, uid) or String.contains?(name, "(Retired)")
end
# ── Modality derivation ───────────────────────────────────────
defp derive_modality(name) do
Enum.find_value(@modality_patterns, fn {pattern, modality} ->
if String.contains?(name, pattern), do: modality
end)
end
# ── Source generation ─────────────────────────────────────────
defp generate_source(entries) do
registry_entries =
entries
|> Enum.map(&format_entry/1)
|> Enum.join(",\n")
storage_count = Enum.count(entries, fn e -> e.type == :storage end)
service_count = length(entries) - storage_count
total = length(entries)
"""
defmodule Dicom.SOPClass do
@moduledoc \"\"\"
DICOM SOP Class registry.
Provides compile-time lookup of all #{total} DICOM SOP Classes
(#{storage_count} storage + #{service_count} service/query/print/etc.).
Each SOP class has a struct with uid, name, type, modality, and retired status.
All lookups are O(1) via compile-time maps and MapSets.
Generated via `mix dicom.gen_sop_classes` from:
- `priv/sops.json` (innolitics/dicom-standard)
- `priv/service_sop_classes.exs` (hand-maintained)
Reference: DICOM PS3.4.
\"\"\"
@type sop_type ::
:storage
| :query_retrieve
| :verification
| :print
| :worklist
| :media
| :protocol
| :service
@type t :: %__MODULE__{
uid: String.t(),
name: String.t(),
type: sop_type(),
modality: String.t() | nil,
retired: boolean()
}
defstruct [:uid, :name, :type, :modality, retired: false]
@registry %{
#{registry_entries}
}
@all_classes Map.values(@registry)
@active_classes Enum.filter(@all_classes, &(not &1.retired))
@storage_classes Enum.filter(@all_classes, &(&1.type == :storage))
@storage_uids MapSet.new(@storage_classes, & &1.uid)
@by_type Enum.group_by(@all_classes, & &1.type)
@by_modality @all_classes
|> Enum.filter(& &1.modality)
|> Enum.group_by(& &1.modality)
@doc "Returns the SOP class for the given UID."
@spec from_uid(String.t()) :: {:ok, t()} | {:error, :unknown_sop_class}
def from_uid(uid) do
case Map.get(@registry, uid) do
nil -> {:error, :unknown_sop_class}
sop -> {:ok, sop}
end
end
@doc "Returns true if the UID is a known SOP class."
@spec known?(String.t()) :: boolean()
def known?(uid), do: Map.has_key?(@registry, uid)
@doc "Returns all registered SOP classes."
@spec all() :: [t()]
def all, do: @all_classes
@doc "Returns only active (non-retired) SOP classes."
@spec active() :: [t()]
def active, do: @active_classes
@doc "Returns all storage SOP classes."
@spec storage() :: [t()]
def storage, do: @storage_classes
@doc "Returns SOP classes of the given type."
@spec by_type(sop_type()) :: [t()]
def by_type(type), do: Map.get(@by_type, type, [])
@doc "Returns storage SOP classes for the given DICOM modality code."
@spec by_modality(String.t()) :: [t()]
def by_modality(modality), do: Map.get(@by_modality, modality, [])
@doc "Returns true if the UID is a storage SOP class. O(1) MapSet lookup."
@spec storage?(String.t()) :: boolean()
def storage?(uid), do: MapSet.member?(@storage_uids, uid)
@doc "Returns true if the UID is a retired SOP class."
@spec retired?(String.t()) :: boolean()
def retired?(uid) do
case from_uid(uid) do
{:ok, %{retired: retired}} -> retired
_ -> false
end
end
@doc "Returns the human-readable name of the SOP class."
@spec name(String.t()) :: {:ok, String.t()} | {:error, :unknown_sop_class}
def name(uid) do
case from_uid(uid) do
{:ok, %{name: name}} -> {:ok, name}
error -> error
end
end
end
"""
end
defp format_entry(%{uid: uid, name: name, type: type, modality: modality, retired: retired}) do
modality_str = if modality, do: inspect(modality), else: "nil"
" #{inspect(uid)} => %{__struct__: __MODULE__, uid: #{inspect(uid)}, " <>
"name: #{inspect(name)}, type: #{inspect(type)}, " <>
"modality: #{modality_str}, retired: #{inspect(retired)}}"
end
end