# Pixel Data - Part 2: Creation of pixel data

In part 1 of this tutorial you learned how to access the pixel data as either the raw `bytes` or a NumPy `ndarray`. In this part we’ll be creating pixel data from scratch and adding it to a `Dataset`. We’ll be creating uncompressed datasets with the following types of Pixel Data:

- Grayscale with 8-bit unsigned integers

- Multi-frame RGB with 8-bit unsigned integers

- Grayscale with 12-bit signed integers

- Grayscale with 32-bit floats (for Float Pixel Data)

Prerequisites

Installing using pip:

    python -m pip install -U pydicom numpy matplotlib pylibjpeg[all]

Installing on conda:

    conda install numpy matplotlib
    conda install -c conda-forge pydicom
    pip install pylibjpeg[all]

## Creating Pixel Data

We’ll be using NumPy to create an array containing the pixel data and converting it to little-endian ordered `bytes` using `ndarray.tobytes()`. This is the function we’ll be using to create the array:

    import numpy as np

    def draw_circle(shape: tuple[int, int], dtype: str, value: int) -> np.ndarray:
        """Return an ndarray containing a circle."""
        (rows, columns), radius = shape, min(shape) // 2

        x0, y0 = columns // 2, rows // 2
        x = np.linspace(0, columns, columns)
        y = np.linspace(0, rows, rows)[:, None]

        # Create a boolean array where values inside the radius are True
        arr = (x - x0)**2 + (y - y0)**2 <= radius**2

        # Convert to the required `dtype` and set the maximum `value`
        return arr.astype(dtype) * value

The datasets we’ll be creating don’t meet the requirements of any DICOM IOD and so aren’t conformant DICOM SOP instances, but they’re sufficient to demonstrate how to create and add pixel data to a `Dataset` using pydicom. To create pixel data for an actual dataset you should check the requirements of the specific IOD you’re working with, as many IODs place restrictions on the allowed values for elements such as Bits Stored, Photometric Interpretation and others.

### Grayscale with 8-bit unsigned integers

The first example uses a single frame of grayscale Pixel Data with 8-bit unsigned integers:

- For 8-bit pixel values Bits Stored is `8`

- Bits Allocated must be a multiple of 8 and not less than Bits Stored

- For unsigned integers Pixel Representation must be `0`

- For 8-bit unsigned integers all pixel values must be in the closed interval [0, 28 - 1]

- For pixel data that uses a single sample per pixel, Samples per Pixel is `1`

- The Photometric Interpretation should be appropriate for a single sample per pixel

- If Bits Allocated is <= 8 then Pixel Data uses a VR of OB

The VR for Pixel Data may be OB or OW depending on the value of Bits Allocated. pydicom will set this automatically when writing the `Dataset` to file as long as Bits Allocated has been set, but for completeness we’ll be setting it manually.

The example has two different sets of Pixel Data; one with an even number of bytes and one with an odd number. The DICOM Standard requires odd length Pixel Data have trailing padding sufficient to make it an even length, so the latter case demonstrates how to do so.

Because we’ll be using NumPy to create the data we need an array with a `dtype` appropriate for our chosen pixel data properties. For unsigned 8-bit integers the obvious choice is `uint8` as it can contain the values with the minimum amount of memory usage and can be converted directly to a suitable Pixel Data `bytes` value with `ndarray.tobytes()`. If instead we were to use something like `uint16` we would double the memory usage and require either setting `ds.BitsAllocated = 16` (and roughly doubling the final size of the dataset) or keeping Bits Stored as `8` and stripping out the unused bytes with `ds.PixelData == arr.tobytes()[1::2]`.

    import matplotlib.pyplot as plt

    from pydicom import Dataset, FileMetaDataset
    from pydicom.uid import ExplicitVRLittleEndian

    ds = Dataset()
    ds.file_meta = FileMetaDataset()
    ds.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian

    ds.BitsAllocated = 8  # 8-bit containers
    ds.BitsStored = 8  # 8-bits used
    ds.HighBit = ds.BitsStored - 1
    ds.PixelRepresentation = 0  # unsigned

    ds.SamplesPerPixel = 1
    ds.PhotometricInterpretation = "MONOCHROME2"

    ## Even number of bytes
    # Create a 480 x 320, 8-bit unsigned array
    arr = draw_circle((320, 480), "uint8", 255)
    assert arr.size % 2 == 0

    # No padding needed
    ds.PixelData = arr.tobytes()
    ds["PixelData"].VR = "OB"
    ds.Rows = arr.shape[0]  # 320 pixels
    ds.Columns = arr.shape[1]  # 480 pixels

    plt.imshow(ds.pixel_array)
    plt.show()

    ## Odd number of bytes
    # Create a 31 x 63, 8-bit unsigned array
    arr = draw_circle((63, 31), "uint8", 255)
    assert arr.size % 2 == 1

    # Trailing padding required to make the length an even number of bytes
    ds.PixelData = b"".join((arr.tobytes(), b"\x00"))
    ds["PixelData"].VR = "OB"
    ds.Rows = arr.shape[0]
    ds.Columns = arr.shape[1]

    plt.imshow(ds.pixel_array)
    plt.show()

Experimentation

Modify the example to use the following and see what effects they have on the displayed images:

- Set Bits Allocated and Bits Stored to `16` and `ds.Columns = arr.shape[1] // 2`

- Set `ds.Rows = arr.shape[1]` and `ds.Columns = arr.shape[0]`

### Multi-frame RGB with 8-bit unsigned integers

The second example uses multi-frame RGB Pixel Data with 8-bit unsigned integers:

- Samples per Pixel has changed to `3`, because there are 3 channels; R, G and B.

- Photometric Interpretation has changed to `"RGB"` to match the image type

- Planar Configuration has been added as it’s required when Samples per Pixel > 1

- Number of Frames has been added as it’s required when there are multiple frames

The Planar Configuration value is set as `0`, which means each pixel is encoded separately then all the encoded pixels are concatenated together. This matches how `ndarray.tobytes()` will encode an array that’s ordered as (rows, columns, samples) or (frames, rows, columns, samples).

    import matplotlib.pyplot as plt

    from pydicom import Dataset, FileMetaDataset
    from pydicom.pixels import iter_pixels
    from pydicom.uid import ExplicitVRLittleEndian

    ds = Dataset()
    ds.file_meta = FileMetaDataset()
    ds.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian

    ds.BitsAllocated = 8  # 8-bit containers
    ds.BitsStored = 8  # 8-bits used
    ds.HighBit = ds.BitsStored - 1
    ds.PixelRepresentation = 0  # unsigned

    ds.SamplesPerPixel = 3
    ds.PhotometricInterpretation = "RGB"
    ds.PlanarConfiguration = 0
    ds.NumberOfFrames = 2

    # Create 2 frames of 480 x 320 x 3, 8-bit unsigned array
    arr = np.empty((2, 320, 480, 3), dtype="uint8")
    # Frame 1
    arr[0, ..., 0] = draw_circle((320, 480), "uint8", 255)
    arr[0, ..., 1] = draw_circle((320, 480), "uint8", 127)
    arr[0, ..., 2] = draw_circle((320, 480), "uint8", 0)

    # Frame 2
    arr[1, ..., 0] = draw_circle((320, 480), "uint8", 0)
    arr[1, ..., 1] = draw_circle((320, 480), "uint8", 127)
    arr[1, ..., 2] = draw_circle((320, 480), "uint8", 255)

    ds.PixelData = b"".join((arr.tobytes(), b"\x00")) if arr.size % 2 else arr.tobytes()
    ds["PixelData"].VR = "OB"
    ds.Rows = arr.shape[1]
    ds.Columns = arr.shape[2]

    # Display the frames
    im = plt.imshow(np.zeros((ds.Rows, ds.Columns, 3), dtype="uint8"))
    for frame in iter_pixels(ds):
        im.set_data(frame)
        plt.pause(1)

Experimentation

- A Planar Configuration value of `1` means each color channel is encoded separately and then the results concatenated together. Try setting `ds.PlanarConfiguration = 1` and seeing what effect it has.

- By default pydicom will return any extra frames it finds in the Pixel Data. Set `ds.NumberOfFrames = 1` and see what effect it has, then pass `allow_excess_frames=False` to `iter_pixels()` and compare the results.

### Grayscale with 12-bit signed integers

The final Pixel Data example uses a single channel of 12-bit signed integers:

- For 12-bit pixel values Bits Stored is `12` and Bits Allocated should be at least `16`

- For signed integers Pixel Representation must be `1`

- For 12-bit signed integers all pixels must have values in the closed interval [-211, 211 - 1]

- If Bits Allocated is > 8 then Pixel Data uses a VR of OW

We need a `dtype` sufficient for containing 12-bit integers, so to minimize memory usage we’ll go with `int16` and use a Bits Allocated value of `16` to match.

    import matplotlib.pyplot as plt

    from pydicom import Dataset, FileMetaDataset
    from pydicom.uid import ExplicitVRLittleEndian

    ds = Dataset()
    ds.file_meta = FileMetaDataset()
    ds.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian

    ds.BitsAllocated = 16  # 16-bits allocated
    ds.BitsStored = 12  # 12-bits used; interval is [-2048, 2047]
    ds.HighBit = ds.BitsStored - 1
    ds.PixelRepresentation = 1  # signed

    ds.SamplesPerPixel = 1
    ds.PhotometricInterpretation = "MONOCHROME2"

    # Create a 480 x 320, 16-bit signed array
    arr = draw_circle((320, 480), "int16", -2048)

    ds.PixelData = arr.tobytes()
    ds["PixelData"].VR = "OW"
    ds.Rows = arr.shape[0]
    ds.Columns = arr.shape[1]

    plt.imshow(ds.pixel_array)
    plt.show()

Experimentation

Set Pixel Representation to 0 and see what effect it has on the value of the pixels in the circle.

## Creating Float Pixel Data and Double Float Pixel Data

The creation of Float Pixel Data or Double Float Pixel Data is very similar to that of Pixel Data, the main differences being:

- Bits Allocated and Bits Stored are always 32 for Float Pixel Data and 64 for Double Float Pixel Data

- The Pixel Representation element should not be present

- The VR doesn’t need to be set manually

Element | VR | Bits Allocated | Bits Stored | `dtype`
--- | --- | --- | --- | ---
Float Pixel Data | OF | 32 | 32 | `float32`
Double Float Pixel Data | OD | 64 | 64 | `float64`

The example below demonstrates creating Float Pixel Data:

    from pydicom import Dataset, FileMetaDataset
    from pydicom.uid import ExplicitVRLittleEndian

    ds = Dataset()
    ds.file_meta = FileMetaDataset()
    ds.file_meta.TransferSyntaxUID = ExplicitVRLittleEndian

    ds.BitsAllocated = 32
    ds.BitsStored = 32
    ds.HighBit = ds.BitsStored - 1
    ds.SamplesPerPixel = 1
    ds.PhotometricInterpretation = "MONOCHROME2"

    # Create a 480 x 320, 32-bit float array
    arr = draw_circle((320, 480), "float32", 1024.58)

    ds.FloatPixelData = arr.tobytes()
    ds.Rows = arr.shape[0]
    ds.Columns = arr.shape[1]

## Conclusion and next steps

In part 2 of this tutorial you’ve learned how to create and add a variety of different pixel data to a `Dataset` using an `ndarray`. In the final part you’ll learn how to compress and decompress datasets containing pixel data.