Skills › Scientific Agent Skills › pydicom › dicom_to_image.py
dicom_to_image.py
487 lines · skills/pydicom/scripts/dicom_to_image.py at 5f2bc03
1#!/usr/bin/env python3
2"""Render one bounded DICOM frame for non-diagnostic review."""
4from __future__ import annotations
6import argparse
7import io
8import sys
9from pathlib import Path
10from typing import Any
12from _common import (
13 DEFAULT_MAX_DECOMPRESSED_BYTES,
14 DEFAULT_MAX_INPUT_BYTES,
15 DEFAULT_MAX_OUTPUT_BYTES,
16 HARD_MAX_DECOMPRESSED_BYTES,
17 HARD_MAX_INPUT_BYTES,
18 HARD_MAX_OUTPUT_BYTES,
19 SCHEMA_VERSION,
20 ToolError,
21 atomic_write,
22 checked_input,
23 checked_output,
24 emit_json,
25 fail_json,
26 frame_count,
27 parse_size,
28 paths_overlap,
29 pixel_plan,
30 require_pixel_stack,
31 safe_dcmread,
32 safe_plugin_name,
33)
35TOOL = "dicom_to_image"
36METADATA_TAGS = [
37 "SOPClassUID",
38 "Modality",
39 "Rows",
40 "Columns",
41 "NumberOfFrames",
42 "SamplesPerPixel",
43 "PhotometricInterpretation",
44 "PlanarConfiguration",
45 "BitsAllocated",
46 "BitsStored",
47 "HighBit",
48 "PixelRepresentation",
49 "ModalityLUTSequence",
50 "RescaleIntercept",
51 "RescaleSlope",
52 "RescaleType",
53 "VOILUTSequence",
54 "WindowCenter",
55 "WindowWidth",
56 "VOILUTFunction",
57 "PresentationLUTShape",
58 "PresentationLUTSequence",
59 "SharedFunctionalGroupsSequence",
60 "PerFrameFunctionalGroupsSequence",
61 "RealWorldValueMappingSequence",
62 "RedPaletteColorLookupTableDescriptor",
63 "GreenPaletteColorLookupTableDescriptor",
64 "BluePaletteColorLookupTableDescriptor",
65 "RedPaletteColorLookupTableData",
66 "GreenPaletteColorLookupTableData",
67 "BluePaletteColorLookupTableData",
68 "SegmentedRedPaletteColorLookupTableData",
69 "SegmentedGreenPaletteColorLookupTableData",
70 "SegmentedBluePaletteColorLookupTableData",
71 "ICCProfile",
72 "BurnedInAnnotation",
73 "RecognizableVisualFeatures",
74]
77def _infer_format(path: str, explicit: str | None) -> str:
78 if explicit:
79 return explicit
80 suffix = Path(path).suffix.casefold()
81 mapping = {
82 ".png": "PNG",
83 ".tif": "TIFF",
84 ".tiff": "TIFF",
85 ".jpg": "JPEG",
86 ".jpeg": "JPEG",
87 }
88 if suffix not in mapping:
89 raise ToolError("output extension must be .png, .tif, .tiff, .jpg, or .jpeg")
90 return mapping[suffix]
93def _normalize(array: Any, *, numpy: Any, bit_depth: int) -> tuple[Any, dict[str, Any]]:
94 maximum = 255 if bit_depth == 8 else 65535
95 dtype = numpy.uint8 if bit_depth == 8 else numpy.uint16
96 work = numpy.asarray(array)
97 finite = numpy.isfinite(work)
98 finite_count = int(finite.sum())
99 if finite_count == 0:
100 raise ToolError("decoded frame contains no finite pixel values")
101 finite_values = work[finite]
102 low = float(finite_values.min())
103 high = float(finite_values.max())
104 converted = work.astype(numpy.float64, copy=True)
105 converted[~finite] = low
106 if high > low:
107 converted = (converted - low) / (high - low)
108 else:
109 converted.fill(0)
110 converted = numpy.clip(converted * maximum, 0, maximum).astype(dtype)
111 return converted, {
112 "finite_values": finite_count,
113 "input_max": high,
114 "input_min": low,
115 "mapping": "per-frame linear min-max",
116 }
119def _apply_grayscale_transforms(
120 array: Any,
121 dataset: Any,
122 *,
123 modality_transform: str,
124 voi: str,
125 voi_index: int,
126) -> tuple[Any, list[str]]:
127 from pydicom.pixels import (
128 apply_modality_lut,
129 apply_voi,
130 apply_voi_lut,
131 apply_windowing,
132 )
134 applied: list[str] = []
135 if ("RescaleSlope" in dataset) != ("RescaleIntercept" in dataset):
136 raise ToolError("Rescale Slope and Intercept must be supplied together")
137 if modality_transform == "none" and voi != "none" and (
138 "ModalityLUTSequence" in dataset or "RescaleSlope" in dataset
139 ):
140 raise ToolError("VOI requires the modality transform; use --voi none to bypass both")
141 if modality_transform == "auto" and (
142 "ModalityLUTSequence" in dataset
143 or "RescaleSlope" in dataset
144 or "RescaleIntercept" in dataset
145 ):
146 array = apply_modality_lut(array, dataset)
147 applied.append("modality LUT/rescale")
148 if voi == "none":
149 return array, applied
150 if voi == "auto":
151 if "VOILUTSequence" in dataset or (
152 "WindowCenter" in dataset and "WindowWidth" in dataset
153 ):
154 array = apply_voi_lut(array, dataset, index=voi_index)
155 applied.append("VOI LUT/window")
156 return array, applied
157 if voi == "window":
158 if "WindowCenter" not in dataset or "WindowWidth" not in dataset:
159 raise ToolError("window VOI requested but Window Center/Width is absent")
160 array = apply_windowing(array, dataset, index=voi_index)
161 applied.append("window")
162 return array, applied
163 if "VOILUTSequence" not in dataset:
164 raise ToolError("VOI LUT requested but VOI LUT Sequence is absent")
165 array = apply_voi(array, dataset, index=voi_index)
166 applied.append("VOI LUT")
167 return array, applied
170def render_frame(args: argparse.Namespace) -> dict[str, Any]:
171 if not args.acknowledge_pixel_phi:
172 raise ToolError(
173 "conversion requires --acknowledge-pixel-phi because pixels may identify a person"
174 )
175 max_input_bytes = parse_size(
176 args.max_input_bytes,
177 name="max_input_bytes",
178 maximum=HARD_MAX_INPUT_BYTES,
179 )
180 max_decompressed_bytes = parse_size(
181 args.max_decompressed_bytes,
182 name="max_decompressed_bytes",
183 maximum=HARD_MAX_DECOMPRESSED_BYTES,
184 )
185 max_output_bytes = parse_size(
186 args.max_output_bytes,
187 name="max_output_bytes",
188 maximum=HARD_MAX_OUTPUT_BYTES,
189 )
190 source = checked_input(
191 args.input,
192 root=args.root,
193 kind="file",
194 max_bytes=max_input_bytes,
195 )
196 destination = checked_output(args.output, root=args.root, force=args.force)
197 if paths_overlap(source, destination):
198 raise ToolError("image output must not overwrite its DICOM input")
199 image_format = _infer_format(args.output, args.format)
200 if image_format == "JPEG" and not args.allow_lossy_output:
201 raise ToolError("JPEG output requires --allow-lossy-output")
202 if image_format == "JPEG" and args.bit_depth != 8:
203 raise ToolError("JPEG output supports only --bit-depth 8")
205 metadata = safe_dcmread(
206 source,
207 stop_before_pixels=True,
208 force=False,
209 specific_tags=METADATA_TAGS,
210 )
211 if any(keyword in metadata for keyword in (
212 "SharedFunctionalGroupsSequence", "PerFrameFunctionalGroupsSequence",
213 "RealWorldValueMappingSequence",
214 )):
215 raise ToolError(
216 "functional-group or real-world-value mapping objects require a "
217 "validated frame-specific pipeline; this preview helper supports top-level transforms only"
218 )
219 frames, frame_warnings = frame_count(metadata)
220 if not 0 <= args.frame < frames:
221 raise ToolError("requested frame is outside the declared frame range")
222 plan = pixel_plan(
223 metadata,
224 max_frames=max(frames, 1),
225 max_decompressed_bytes=HARD_MAX_DECOMPRESSED_BYTES,
226 )
227 photometric = str(metadata.get("PhotometricInterpretation", ""))
228 if photometric not in {
229 "MONOCHROME1", "MONOCHROME2", "RGB", "YBR_FULL", "YBR_FULL_422", "PALETTE COLOR"
230 }:
231 raise ToolError("photometric interpretation is not supported by this preview helper")
232 processing_samples = (
233 3 if photometric == "PALETTE COLOR" else plan["samples_per_pixel"]
234 )
235 # Processing can require a float64 working copy in addition to the decoded
236 # frame, masks, and output. Bound peak working memory conservatively.
237 working_estimate = plan["bytes_per_frame"] + (
238 plan["rows"] * plan["columns"] * processing_samples * 40
239 )
240 if working_estimate > max_decompressed_bytes:
241 raise ToolError(
242 "estimated one-frame working memory exceeds the configured limit"
243 )
245 _, numpy, Image = require_pixel_stack()
246 from pydicom.pixels import apply_color_lut, apply_presentation_lut, pixel_array
248 decoding_plugin = (
249 safe_plugin_name(args.decoding_plugin) if args.decoding_plugin else ""
250 )
251 try:
252 array = pixel_array(
253 source,
254 index=args.frame,
255 raw=False,
256 decoding_plugin=decoding_plugin,
257 )
258 except Exception as exc:
259 raise ToolError(
260 "selected frame could not be decoded with installed pixel plugins"
261 ) from exc
262 if int(array.nbytes) > max_decompressed_bytes:
263 raise ToolError("decoded frame exceeds the configured byte limit")
265 samples = int(metadata.get("SamplesPerPixel", 1))
266 transforms: list[str] = []
267 color_output = samples > 1 or photometric == "PALETTE COLOR"
268 if photometric == "PALETTE COLOR":
269 try:
270 array = apply_color_lut(array, metadata)
271 except Exception as exc:
272 raise ToolError("palette color LUT could not be applied") from exc
273 transforms.append("palette color LUT")
274 elif not color_output:
275 try:
276 array, grayscale_transforms = _apply_grayscale_transforms(
277 array,
278 metadata,
279 modality_transform=args.modality_transform,
280 voi=args.voi,
281 voi_index=args.voi_index,
282 )
283 except Exception as exc:
284 if isinstance(exc, ToolError):
285 raise
286 raise ToolError(
287 "requested grayscale transform could not be applied"
288 ) from exc
289 transforms.extend(grayscale_transforms)
291 if color_output:
292 if array.ndim != 3 or array.shape[-1] not in {3, 4}:
293 raise ToolError("decoded color frame has an unexpected shape")
294 if array.dtype == numpy.uint8 and bool(numpy.isfinite(array).all()):
295 normalized = numpy.asarray(array)
296 scale_report = {
297 "finite_values": int(array.size),
298 "input_max": int(array.max()),
299 "input_min": int(array.min()),
300 "mapping": "identity uint8",
301 }
302 else:
303 normalized, scale_report = _normalize(array, numpy=numpy, bit_depth=8)
304 image = Image.fromarray(normalized)
305 if image_format == "JPEG" and image.mode == "RGBA":
306 image = image.convert("RGB")
307 transforms.append("alpha channel removed for JPEG")
308 output_bit_depth = 8
309 if photometric.startswith("YBR"):
310 transforms.append("pydicom YCbCr-to-RGB decoding")
311 else:
312 if array.ndim != 2:
313 raise ToolError("decoded grayscale frame has an unexpected shape")
314 has_presentation = (
315 "PresentationLUTSequence" in metadata or "PresentationLUTShape" in metadata
316 )
317 if has_presentation:
318 array = apply_presentation_lut(array, metadata)
319 transforms.append("presentation LUT/shape")
320 normalized, scale_report = _normalize(
321 array, numpy=numpy, bit_depth=args.bit_depth
322 )
323 if photometric == "MONOCHROME1" and not has_presentation:
324 maximum = 255 if args.bit_depth == 8 else 65535
325 normalized = maximum - normalized
326 transforms.append("MONOCHROME1 inversion")
327 image = Image.fromarray(normalized)
328 output_bit_depth = args.bit_depth
330 buffer = io.BytesIO()
331 save_options: dict[str, Any] = {}
332 if image_format == "JPEG":
333 save_options = {"quality": args.jpeg_quality, "subsampling": 0}
334 try:
335 image.save(buffer, format=image_format, **save_options)
336 except Exception as exc:
337 raise ToolError("rendered image could not be encoded") from exc
338 payload = buffer.getvalue()
339 atomic_write(
340 destination,
341 payload,
342 force=args.force,
343 max_bytes=max_output_bytes,
344 )
346 return {
347 "burned_in_annotation": str(
348 metadata.get("BurnedInAnnotation", "ABSENT")
349 ).upper(),
350 "color_output": color_output,
351 "decoded_frame_bytes": int(array.nbytes),
352 "diagnostic_use": False,
353 "frame": args.frame,
354 "frames_declared": frames,
355 "image_format": image_format,
356 "modality": str(metadata.get("Modality", "UNSPECIFIED")),
357 "network_accessed": False,
358 "ok": True,
359 "original_preserved": True,
360 "output_bit_depth": output_bit_depth,
361 "output_bytes": len(payload),
362 "photometric_interpretation": photometric,
363 "recognizable_visual_features": str(
364 metadata.get("RecognizableVisualFeatures", "ABSENT")
365 ).upper(),
366 "scale": scale_report,
367 "schema_version": SCHEMA_VERSION,
368 "tool": TOOL,
369 "transforms_applied_in_order": transforms,
370 "warnings": [
371 *frame_warnings,
372 "Rendered output is for non-diagnostic review only.",
373 "Per-frame min-max scaling is not quantitative and is not comparable across frames.",
374 "Pixels may contain burned-in PHI or recognizable visual features.",
375 "ICC/presentation-state behavior is not fully reproduced by this helper.",
376 ],
377 }
380def build_parser() -> argparse.ArgumentParser:
381 parser = argparse.ArgumentParser(
382 description=(
383 "Render one bounded DICOM frame to PNG, TIFF, or explicit lossy JPEG "
384 "for non-diagnostic review."
385 ),
386 formatter_class=argparse.RawDescriptionHelpFormatter,
387 epilog="""
388Local authorized data only. Pixel data may contain PHI, burned-in annotations,
389or recognizable anatomy. This helper is not a diagnostic viewer and does not
390establish clinical fidelity.
392Examples:
393 python dicom_to_image.py input.dcm frame.png --acknowledge-pixel-phi
394 python dicom_to_image.py multi.dcm frame5.tiff --frame 5 \\
395 --voi window --acknowledge-pixel-phi
396 python dicom_to_image.py input.dcm frame.jpg --allow-lossy-output \\
397 --acknowledge-pixel-phi
398""",
399 )
400 parser.add_argument("input", help="Local DICOM input file")
401 parser.add_argument("output", help="New .png, .tif/.tiff, or .jpg/.jpeg file")
402 parser.add_argument("--root", default=".", help="Existing local I/O root")
403 parser.add_argument("--format", choices=("PNG", "TIFF", "JPEG"))
404 parser.add_argument("--frame", type=int, default=0, help="Zero-based frame index")
405 parser.add_argument(
406 "--modality-transform",
407 choices=("auto", "none"),
408 default="auto",
409 help="Apply Modality LUT/rescale before VOI when present",
410 )
411 parser.add_argument(
412 "--voi",
413 choices=("auto", "none", "window", "lut"),
414 default="auto",
415 help="VOI policy after modality transformation",
416 )
417 parser.add_argument(
418 "--voi-index",
419 type=int,
420 default=0,
421 help="Index for multi-valued windows or VOI LUTs",
422 )
423 parser.add_argument(
424 "--bit-depth",
425 type=int,
426 choices=(8, 16),
427 default=8,
428 help="Grayscale PNG/TIFF output depth (color and JPEG are 8-bit)",
429 )
430 parser.add_argument(
431 "--decoding-plugin",
432 default="",
433 help="Optional installed pydicom decoding plugin name",
434 )
435 parser.add_argument(
436 "--allow-lossy-output",
437 action="store_true",
438 help="Required for JPEG output",
439 )
440 parser.add_argument(
441 "--jpeg-quality",
442 type=int,
443 choices=range(1, 96),
444 default=95,
445 metavar="1..95",
446 help="JPEG quality when lossy output is explicitly enabled",
447 )
448 parser.add_argument(
449 "--acknowledge-pixel-phi",
450 action="store_true",
451 help="Required acknowledgement that pixel data may identify a person",
452 )
453 parser.add_argument(
454 "--max-input-bytes",
455 default=str(DEFAULT_MAX_INPUT_BYTES),
456 help="Maximum DICOM file bytes (integer or B/KiB/MiB/GiB)",
457 )
458 parser.add_argument(
459 "--max-decompressed-bytes",
460 default=str(DEFAULT_MAX_DECOMPRESSED_BYTES),
461 help="Maximum estimated one-frame working bytes",
462 )
463 parser.add_argument(
464 "--max-output-bytes",
465 default=str(DEFAULT_MAX_OUTPUT_BYTES),
466 help="Maximum encoded image bytes",
467 )
468 parser.add_argument(
469 "--force", action="store_true", help="Overwrite image output, never input"
470 )
471 return parser
474def main(argv: list[str] | None = None) -> int:
475 args = build_parser().parse_args(argv)
476 try:
477 if args.frame < 0 or args.voi_index < 0:
478 raise ToolError("frame and VOI index must be non-negative")
479 report = render_frame(args)
480 emit_json(report)
481 return 0
482 except Exception as exc: # noqa: BLE001 - sanitize unexpected library errors
483 return fail_json(TOOL, exc)
486if __name__ == "__main__":
487 sys.exit(main())