NIfTI to a DICOM series in Python is a reverse write: nibabel.load, get_fdata, stamp each slice with convertNsave, loop with nifti2dicom_1file. This page is that how-to. It is not DICOM → NIfTI, and it is not JPEG/PNG → DICOM.
If you meant DICOM → NIfTI → how to convert DICOM to NIfTI. If you meant JPG/PNG → DICOM → JPEG to DICOM conversion with Python. If you meant write extra header fields → store metadata in NIfTI and NRRD.
Why a template DICOM
A DICOM file is pixels plus a header. The cheap way to get a legal header is to start from a template .dcm and overwrite the fields that must match the new slice. The GitHub repo ships a sample template if you do not have one.
Pixels-only is not enough. Each instance needs its own SOPInstanceUID and InstanceNumber, a shared SeriesInstanceUID, and geometry (ImagePositionPatient, PixelSpacing, ImageOrientationPatient) taken from the NIfTI affine. Skip those and a viewer stacks the slices in the wrong place — or refuses the series.
| Field | Where it comes from | Why |
|---|---|---|
InstanceNumber |
slice index + 1 | Order inside the series |
SOPInstanceUID |
generate_uid() per file |
One UID per instance; never reuse the template’s |
SeriesInstanceUID |
generate_uid() once per NIfTI |
Keeps the slices in one series |
ImagePositionPatient |
affine @ [0, 0, k, 1] |
World origin of that slice |
PixelSpacing / thickness |
column lengths of the affine | mm between voxels |
ImageOrientationPatient |
unit vectors of affine columns 0 and 1 | Row / column direction cosines |
Environment
python -m venv nifti2dicom
source nifti2dicom/bin/activate # Windows: nifti2dicomScriptsactivate
pip install nibabel pydicom numpy tqdm
Sanity check: import nibabel, pydicom, numpy; from pydicom.uid import generate_uid. Docs: nibabel, pydicom. Full script: amine0110/nifti2dicom.
Load the volume
import nibabel
nifti_file = nibabel.load(nifti_dir)
nifti_array = nifti_file.get_fdata()
affine = nifti_file.affine
nibabel.load opens the file. get_fdata() is the 3D array. Slices in this script are nifti_array[:, :, k] — axis 2. If your volume is stored on another axis, transpose first; do not pass a 3D block to PixelData.
convertNsave: one slice, including geometry
import os
import numpy as np
import pydicom
from pydicom.uid import generate_uid
def voxel_sizes(affine):
return np.sqrt((affine[:3, :3] ** 2).sum(axis=0))
def convertNsave(arr, file_dir, index, affine, series_uid, template_path="images/dcmimage.dcm"):
dicom_file = pydicom.dcmread(template_path)
arr = np.clip(np.asarray(arr), 0, None).astype(np.uint16)
dicom_file.Rows = arr.shape[0]
dicom_file.Columns = arr.shape[1]
dicom_file.PhotometricInterpretation = "MONOCHROME2"
dicom_file.SamplesPerPixel = 1
dicom_file.BitsStored = 16
dicom_file.BitsAllocated = 16
dicom_file.HighBit = 15
dicom_file.PixelRepresentation = 0
dicom_file.PixelData = arr.tobytes()
dicom_file.InstanceNumber = index + 1
dicom_file.SOPInstanceUID = generate_uid()
dicom_file.SeriesInstanceUID = series_uid
if getattr(dicom_file, "file_meta", None) is not None:
dicom_file.file_meta.MediaStorageSOPInstanceUID = dicom_file.SOPInstanceUID
xyz = affine @ np.array([0.0, 0.0, float(index), 1.0])
dicom_file.ImagePositionPatient = [float(xyz[0]), float(xyz[1]), float(xyz[2])]
spacing = voxel_sizes(affine)
dicom_file.PixelSpacing = [float(spacing[1]), float(spacing[0])] # row, col
dicom_file.SliceThickness = float(spacing[2])
dicom_file.SpacingBetweenSlices = float(spacing[2])
col_dir = affine[:3, 0] / (spacing[0] or 1.0)
row_dir = affine[:3, 1] / (spacing[1] or 1.0)
dicom_file.ImageOrientationPatient = [
float(col_dir[0]), float(col_dir[1]), float(col_dir[2]),
float(row_dir[0]), float(row_dir[1]), float(row_dir[2]),
]
os.makedirs(file_dir, exist_ok=True)
dicom_file.save_as(os.path.join(file_dir, f"slice{index}.dcm"))
dcmread loads the template. Intensities become uint16 after a clip of negatives — rescale or window first if your NIfTI is float in a range that is not already storage units. PixelData = arr.tobytes() is the slice.
InstanceNumber is 1-based so viewers sort the series. SOPInstanceUID is new on every file; copying the template’s UID makes every slice look like the same instance. SeriesInstanceUID is minted once in nifti2dicom_1file and passed in, so the folder is one series.
ImagePositionPatient is the world coordinate of voxel (0, 0, k): multiply the affine by [0, 0, k, 1]. PixelSpacing is row then column (NIfTI j, then i). Thickness is the length of affine column 2. Orientation is the unit vectors of columns 0 and 1. That is the geometry a PACS uses to stack the series. A template that still has the donor’s ImagePositionPatient will place every slice on the same plane.
nifti2dicom_1file: one volume
from tqdm import tqdm
def nifti2dicom_1file(nifti_dir, out_dir):
nifti_file = nibabel.load(nifti_dir)
nifti_array = nifti_file.get_fdata()
affine = nifti_file.affine
series_uid = generate_uid()
number_slices = nifti_array.shape[2]
os.makedirs(out_dir, exist_ok=True)
for slice_ in tqdm(range(number_slices)):
convertNsave(nifti_array[:, :, slice_], out_dir, slice_, affine, series_uid)
The loop is axis 2. convertNsave writes slice{k}.dcm. One SeriesInstanceUID for the whole file.
nifti2dicom_mfiles: a folder of volumes
def nifti2dicom_mfiles(nifti_dir, out_dir=""):
files = os.listdir(nifti_dir)
for file in files:
in_path = os.path.join(nifti_dir, file)
if not os.path.isfile(in_path):
continue
out_path = os.path.join(out_dir, file)
os.makedirs(out_path, exist_ok=True)
nifti2dicom_1file(in_path, out_path)
Each NIfTI becomes its own output folder. Skip directories so a stray subfolder does not get passed to nibabel.load. Catch FileNotFoundError / nibabel read errors per file if you do not want one bad volume to stop the batch.
Repo with the template DICOM and the original functions: amine0110/nifti2dicom.
PYCAD builds the imaging side of products that have to write these series for real. Case studies.