Generic selectors
Exact matches only
Search in title
Search in content
Post Type Selectors

JPEG to DICOM conversion with Python

JPEG to DICOM conversion in Python is a from-scratch write: open the raster with Pillow, build a Dataset, mint three UIDs with generate_uid, wrap it as a Secondary Capture FileDataset, put the bytes in PixelData, save_as. This page is that how-to. It is not DICOM → JPG/PNG, and it is not a converter product.

If you meant DICOM JPG/PNG → how to convert a DICOM image into JPG or PNG. If you meant NIfTI → DICOM → NIfTI to DICOM. This URL stays the from-scratch script. It is not a hosted converter.

Why a JPEG is not a DICOM

Feature JPEG / PNG DICOM
Payload Pixels (and a little EXIF) Pixels plus a header of tags
Identity Filename Study / Series / SOP Instance UID
Patient / study None Name, ID, dates, modality, SOP Class
Systems Image viewers PACS / workstations that speak DICOM

A dermatology phone photo is a JPEG. A PACS will not file it until it is a DICOM object with those tags. The conversion is the wrap, not a new camera.

Environment

python -m venv jpeg_dicom
source jpeg_dicom/bin/activate   # Windows: jpeg_dicomScriptsactivate
pip install pydicom Pillow

Sanity check: import pydicom; from PIL import Image; from pydicom.uid import generate_uid. Docs: pydicom. Work in the venv so the script is not sharing a global site-packages.

From scratch: Dataset, three UIDs, Secondary Capture

import datetime
import os

from PIL import Image
from pydicom.dataset import Dataset, FileDataset
from pydicom.uid import generate_uid
import pydicom

jpeg_file_path = "path/to/your/image.jpg"
dicom_file_path = "path/to/your/output.dcm"

jpeg_image = Image.open(jpeg_file_path)
if jpeg_image.mode == "RGBA":
    jpeg_image = jpeg_image.convert("RGB")

ds = Dataset()
ds.PatientName = "Test^Patient"
ds.PatientID = "123456"
ds.StudyDate = datetime.date.today().strftime("%Y%m%d")
ds.StudyTime = datetime.datetime.now().strftime("%H%M%S")
ds.StudyInstanceUID = generate_uid()
ds.SeriesInstanceUID = generate_uid()
ds.SOPInstanceUID = generate_uid()
ds.SOPClassUID = "1.2.840.10008.5.1.4.1.1.7"  # Secondary Capture
ds.Modality = "OT"
ds.SeriesNumber = 1
ds.InstanceNumber = 1

ds.Rows, ds.Columns = jpeg_image.height, jpeg_image.width
ds.BitsAllocated = 8
ds.BitsStored = 8
ds.HighBit = 7
ds.PixelRepresentation = 0

if jpeg_image.mode == "L":
    ds.PhotometricInterpretation = "MONOCHROME1"
    ds.SamplesPerPixel = 1
    ds.PixelData = jpeg_image.tobytes()
else:
    rgb = jpeg_image.convert("RGB")
    ds.PhotometricInterpretation = "RGB"
    ds.SamplesPerPixel = 3
    ds.PlanarConfiguration = 0
    ds.PixelData = rgb.tobytes()

file_meta = Dataset()
file_meta.MediaStorageSOPClassUID = "1.2.840.10008.5.1.4.1.1.7"  # Secondary Capture
file_meta.MediaStorageSOPInstanceUID = ds.SOPInstanceUID
file_meta.ImplementationClassUID = pydicom.uid.pydicom_implementation_class_uid
file_meta.TransferSyntaxUID = pydicom.uid.ImplicitVRLittleEndian

output_dataset = FileDataset(dicom_file_path, {}, file_meta=file_meta, preamble=b"" * 128)
output_dataset.update(ds)
output_dataset.is_little_endian = True
output_dataset.is_implicit_VR = True
output_dataset.save_as(dicom_file_path, write_like_original=False)
print("wrote", dicom_file_path)

PNG works the same path: Image.open does not care about the extension. Grayscale (mode == "L") is MONOCHROME1 and one sample per pixel. Color is RGB and three samples, PlanarConfiguration = 0 (RGBRGB…). Do not tag uncompressed Pillow bytes as YBR_FULL_422 — that photometric is for JPEG-compressed color, and a viewer will paint the channels wrong.

generate_uid() three times: Study, Series, SOP Instance. A PACS groups on those. Reusing a donor file’s UIDs (open an old .dcm, swap PixelData, save_as) is a template swap, not this page. Secondary Capture SOP Class is 1.2.840.10008.5.1.4.1.1.7 — the image did not come off a CT or MR. Modality = "OT".

Tags a PACS actually checks

Keyword Tag Type What to put
PatientID (0010,0020) 1 The institution’s ID, not a blank
PatientName (0010,0010) 2 Last^First; empty only if unknown
StudyInstanceUID (0020,000D) 1 generate_uid() once per exam
SeriesInstanceUID (0020,000E) 1 generate_uid() once per series
SOPInstanceUID (0008,0018) 1 generate_uid() once per file
SOPClassUID (0008,0016) 1 Secondary Capture 1.2.840.10008.5.1.4.1.1.7
Modality (0008,0060) 1 OT for a wrapped JPEG
StudyDate (0008,0020) 2 YYYYMMDD
SeriesNumber / InstanceNumber (0020,0011) / (0020,0013) 2 Order inside the study / series

Type 1 must exist and have a value. Type 2 must exist; the value may be empty. Type 3 is optional. Missing Type 1 is why a “valid-looking” .dcm never shows up in the worklist.

Several photos of the same lesion share one StudyInstanceUID and one SeriesInstanceUID. Each file still gets its own SOPInstanceUID. Mint the study and series UIDs once, then loop.

A folder of JPEGs

from pathlib import Path

def jpeg_to_dicom(jpeg_path, dcm_path, study_uid, series_uid, instance_number):
    img = Image.open(jpeg_path)
    if img.mode == "RGBA":
        img = img.convert("RGB")
    ds = Dataset()
    ds.PatientName = "Test^Patient"
    ds.PatientID = "123456"
    ds.StudyInstanceUID = study_uid
    ds.SeriesInstanceUID = series_uid
    ds.SOPInstanceUID = generate_uid()
    ds.SOPClassUID = "1.2.840.10008.5.1.4.1.1.7"
    ds.Modality = "OT"
    ds.InstanceNumber = instance_number
    ds.Rows, ds.Columns = img.height, img.width
    ds.BitsAllocated = ds.BitsStored = 8
    ds.HighBit = 7
    ds.PixelRepresentation = 0
    if img.mode == "L":
        ds.PhotometricInterpretation = "MONOCHROME1"
        ds.SamplesPerPixel = 1
        ds.PixelData = img.tobytes()
    else:
        rgb = img.convert("RGB")
        ds.PhotometricInterpretation = "RGB"
        ds.SamplesPerPixel = 3
        ds.PlanarConfiguration = 0
        ds.PixelData = rgb.tobytes()
    file_meta = Dataset()
    file_meta.MediaStorageSOPClassUID = ds.SOPClassUID
    file_meta.MediaStorageSOPInstanceUID = ds.SOPInstanceUID
    file_meta.ImplementationClassUID = pydicom.uid.pydicom_implementation_class_uid
    file_meta.TransferSyntaxUID = pydicom.uid.ImplicitVRLittleEndian
    out = FileDataset(str(dcm_path), {}, file_meta=file_meta, preamble=b"" * 128)
    out.update(ds)
    out.save_as(str(dcm_path), write_like_original=False)

src = Path("path/to/jpegs")
dst = Path("path/to/dicoms")
dst.mkdir(parents=True, exist_ok=True)
study_uid = generate_uid()
series_uid = generate_uid()
for i, jpeg in enumerate(sorted(src.glob("*.jpg")), start=1):
    jpeg_to_dicom(jpeg, dst / (jpeg.stem + ".dcm"), study_uid, series_uid, i)

Catch FileNotFoundError / Pillow UnidentifiedImageError per file so one bad JPEG does not stop the folder. Log the source name, the three UIDs, and the error. Transfer syntax on this page stays Implicit VR Little Endian with uncompressed PixelData. JPEG 2000 and other compressed syntaxes are a different encode.

The conversion does not resample the pixels. tobytes() is the raster you opened. A second lossy compress inside the DICOM is optional and is how people “lose quality” on a wrap that should have been a copy of the bytes.

PYCAD builds the imaging side of products that have to write these files for real. Case studies.

We build custom medical imaging platforms — advanced DICOM viewers, AI segmentation, and the clinical systems around them.

Get in Touch

Copyright © 2026 PYCAD. All Rights Reserved.