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.