Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add support for high bit depth multichannel images #8224

Open
wants to merge 30 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
1c2d465
Add tests for opening 2-5 layer uint16 greyscale TIFFs.
mairsbw Apr 19, 2016
e0bb623
Add open() support for 2-5 band uint16 TIFFs.
mairsbw Apr 19, 2016
9cbb840
Merge PR1839 into hack202406
yoursunny Jun 1, 2024
90840ae
XXX disable PyImaging_MapBuffer
yoursunny Jun 1, 2024
03df357
TIFF sample format table
yoursunny Jun 1, 2024
936439b
introduce multi-band format (TIFF only)
yoursunny Jun 1, 2024
a4fab13
re-enable PyImaging_MapBuffer
yoursunny Jun 1, 2024
76d336d
TIFF more entries in OPEN_INFO
yoursunny Jun 1, 2024
ed15ed9
cgetpixel_mb constant size buffer
yoursunny Jun 1, 2024
fb7702f
mb_shuffle big endian
yoursunny Jun 1, 2024
86e7fc6
copy() with multi-band format
yoursunny Jun 2, 2024
587bb98
crop() with multi-band format
yoursunny Jun 2, 2024
0df0935
ImagingNew2Dirty update mismatch condition
yoursunny Jun 2, 2024
c4434df
FLIP_LEFT_RIGHT with multi-band format
yoursunny Jun 2, 2024
c5ebc81
explain how mode=MB is stored
yoursunny Jun 2, 2024
e0a5d81
hack202406 - requested changes
aclark4life Jun 20, 2024
44da8b6
Added type hints
radarhere Jun 21, 2024
5bdda4c
Declare variables at start of function
radarhere Jun 22, 2024
df98223
transpose() with multi-band format
yoursunny Jun 22, 2024
2426e57
Merge remote-tracking branch 'upstream/main' into multiband
yoursunny Jul 10, 2024
3cf311b
fix mypy warning in test_open_tiff_uint16_multiband()
yoursunny Jul 10, 2024
b6ce6ab
Merge branch 'main' into multiband
radarhere Jul 19, 2024
d5053fb
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Jul 19, 2024
d5844a9
Merge branch 'main' into multiband
radarhere Aug 28, 2024
bd2543e
Merge branch 'main' into multiband
radarhere Sep 9, 2024
0cbc265
Merge branch 'main' into multiband
radarhere Oct 5, 2024
a9e1dcf
Merge branch 'main' into multiband
radarhere Oct 14, 2024
282e7ec
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Oct 14, 2024
605c408
Added type hint
radarhere Oct 14, 2024
e79b88e
Use mb_config when creating core images
radarhere Oct 14, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added Tests/images/uint16_2_4660.tif
Binary file not shown.
Binary file added Tests/images/uint16_3_4660.tif
Binary file not shown.
Binary file added Tests/images/uint16_4_4660.tif
Binary file not shown.
Binary file added Tests/images/uint16_5_4660.tif
Binary file not shown.
49 changes: 49 additions & 0 deletions Tests/test_file_tiff.py
Original file line number Diff line number Diff line change
Expand Up @@ -892,6 +892,55 @@
with Image.open(test_file):
pass

def test_open_tiff_uint16_multiband(self) -> None:
"""Test opening multiband TIFFs and reading all channels."""

def check_pixel(
im: Image.Image, expected_pixel: tuple[int, ...], pos: tuple[int, int]
) -> None:
actual_pixel = im.getpixel(pos)
if actual_pixel is None:
actual_pixel = (-1,)

Check warning on line 903 in Tests/test_file_tiff.py

View check run for this annotation

Codecov / codecov/patch

Tests/test_file_tiff.py#L903

Added line #L903 was not covered by tests
elif not isinstance(actual_pixel, tuple):
actual_pixel = (int(actual_pixel),)
assert actual_pixel == expected_pixel

def check_image(
im: Image.Image, width: int, height: int, expected_pixel: tuple[int, ...]
) -> None:
assert im.width == width
assert im.height == height
for x in range(im.width):
for y in range(im.height):
check_pixel(im, expected_pixel, (x, y))

base_value = 4660
for i in range(1, 6):
pixel = tuple(base_value + j for j in range(i))
infile = f"Tests/images/uint16_{i}_{base_value}.tif"
im = Image.open(infile)

im.load()
check_image(im, 10, 10, pixel)

copy = im.copy()
check_image(copy, 10, 10, pixel)

cropped = im.crop((2, 2, 8, 7))
check_image(cropped, 6, 5, pixel)

for method, [w, h] in {
Image.Transpose.FLIP_LEFT_RIGHT: (6, 5),
Image.Transpose.FLIP_TOP_BOTTOM: (6, 5),
Image.Transpose.ROTATE_90: (5, 6),
Image.Transpose.ROTATE_180: (6, 5),
Image.Transpose.ROTATE_270: (5, 6),
Image.Transpose.TRANSPOSE: (5, 6),
Image.Transpose.TRANSVERSE: (5, 6),
}.items():
transposed = cropped.transpose(method)
check_image(transposed, w, h, pixel)


@pytest.mark.skipif(not is_win32(), reason="Windows only")
class TestFileTiffW32:
Expand Down
6 changes: 4 additions & 2 deletions src/PIL/ImageFile.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ def __init__(self, fp=None, filename=None):

self.readonly = 1 # until we know better

self.mb_config = ()

self.decoderconfig = ()
self.decodermaxblock = MAXBLOCK

Expand Down Expand Up @@ -227,7 +229,7 @@ def load(self):
msg = "buffer is not large enough"
raise OSError(msg)
self.im = Image.core.map_buffer(
self.map, self.size, decoder_name, offset, args
self.map, self.size, decoder_name, offset, args, *self.mb_config
)
readonly = 1
# After trashing self.im,
Expand Down Expand Up @@ -316,7 +318,7 @@ def load(self):
def load_prepare(self) -> None:
# create image memory if necessary
if not self.im or self.im.mode != self.mode or self.im.size != self.size:
self.im = Image.core.new(self.mode, self.size)
self.im = Image.core.new(self.mode, self.size, *self.mb_config)
# create palette (optional)
if self.mode == "P":
Image.Image.load(self)
Expand Down
57 changes: 49 additions & 8 deletions src/PIL/TiffImagePlugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,43 @@
(II, 1, (1,), 1, (12,), ()): ("I;16", "I;12"),
(II, 0, (1,), 1, (16,), ()): ("I;16", "I;16"),
(II, 1, (1,), 1, (16,), ()): ("I;16", "I;16"),
(II, 1, (1, 1), 1, (16, 16), (0,)): ("MB", "MB"),
(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This formatting is actively bad.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's forced by make lint.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well then, make lint is wrong. ;>

This may just be my long term fight against minless conformity, but sometimes black's formatting obscures the code. And this is one of the cases.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would suggest just # noqa around whatever you don't want formatted for now … also the kids are using ruff these days 😄

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

II,
1,
(1, 1, 1),
1,
(16, 16, 16),
(
0,
0,
),
): ("MB", "MB"),
(
II,
1,
(1, 1, 1, 1),
1,
(16, 16, 16, 16),
(
0,
0,
0,
),
): ("MB", "MB"),
(
II,
1,
(1, 1, 1, 1, 1),
1,
(16, 16, 16, 16, 16),
(
0,
0,
0,
0,
),
): ("MB", "MB"),
(MM, 1, (1,), 1, (16,), ()): ("I;16B", "I;16B"),
(II, 1, (1,), 2, (16,), ()): ("I;16", "I;16R"),
(II, 1, (2,), 1, (16,), ()): ("I", "I;16S"),
Expand All @@ -196,7 +233,9 @@
(II, 1, (1,), 1, (8, 8), (2,)): ("LA", "LA"),
(MM, 1, (1,), 1, (8, 8), (2,)): ("LA", "LA"),
(II, 2, (1,), 1, (8, 8, 8), ()): ("RGB", "RGB"),
(II, 2, (1, 1, 1), 1, (8, 8, 8), ()): ("RGB", "RGB"),
(MM, 2, (1,), 1, (8, 8, 8), ()): ("RGB", "RGB"),
(MM, 2, (1, 1, 1), 1, (8, 8, 8), ()): ("RGB", "RGB"),
(II, 2, (1,), 2, (8, 8, 8), ()): ("RGB", "RGB;R"),
(MM, 2, (1,), 2, (8, 8, 8), ()): ("RGB", "RGB;R"),
(II, 2, (1,), 1, (8, 8, 8, 8), ()): ("RGBA", "RGBA"), # missing ExtraSamples
Expand All @@ -209,11 +248,13 @@
(MM, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (0, 0, 0)): ("RGB", "RGBXXX"),
(II, 2, (1,), 1, (8, 8, 8, 8), (1,)): ("RGBA", "RGBa"),
(MM, 2, (1,), 1, (8, 8, 8, 8), (1,)): ("RGBA", "RGBa"),
(MM, 2, (1, 1, 1, 1), 1, (8, 8, 8, 8), (1,)): ("RGBA", "RGBa"),
(II, 2, (1,), 1, (8, 8, 8, 8, 8), (1, 0)): ("RGBA", "RGBaX"),
(MM, 2, (1,), 1, (8, 8, 8, 8, 8), (1, 0)): ("RGBA", "RGBaX"),
(II, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (1, 0, 0)): ("RGBA", "RGBaXX"),
(MM, 2, (1,), 1, (8, 8, 8, 8, 8, 8), (1, 0, 0)): ("RGBA", "RGBaXX"),
(II, 2, (1,), 1, (8, 8, 8, 8), (2,)): ("RGBA", "RGBA"),
(II, 2, (1, 1, 1, 1), 1, (8, 8, 8, 8), (2,)): ("RGBA", "RGBA"),
(MM, 2, (1,), 1, (8, 8, 8, 8), (2,)): ("RGBA", "RGBA"),
(II, 2, (1,), 1, (8, 8, 8, 8, 8), (2, 0)): ("RGBA", "RGBAX"),
(MM, 2, (1,), 1, (8, 8, 8, 8, 8), (2, 0)): ("RGBA", "RGBAX"),
Expand All @@ -222,13 +263,16 @@
(II, 2, (1,), 1, (8, 8, 8, 8), (999,)): ("RGBA", "RGBA"), # Corel Draw 10
(MM, 2, (1,), 1, (8, 8, 8, 8), (999,)): ("RGBA", "RGBA"), # Corel Draw 10
(II, 2, (1,), 1, (16, 16, 16), ()): ("RGB", "RGB;16L"),
(II, 2, (1, 1, 1), 1, (16, 16, 16), ()): ("RGB", "RGB;16L"),
(MM, 2, (1,), 1, (16, 16, 16), ()): ("RGB", "RGB;16B"),
(II, 2, (1,), 1, (16, 16, 16, 16), ()): ("RGBA", "RGBA;16L"),
(MM, 2, (1,), 1, (16, 16, 16, 16), ()): ("RGBA", "RGBA;16B"),
(II, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGB", "RGBX;16L"),
(MM, 2, (1,), 1, (16, 16, 16, 16), (0,)): ("RGB", "RGBX;16B"),
(II, 2, (1,), 1, (16, 16, 16, 16), (1,)): ("RGBA", "RGBa;16L"),
(II, 2, (1, 1, 1, 1), 1, (16, 16, 16, 16), (1,)): ("RGBA", "RGBa;16L"),
(MM, 2, (1,), 1, (16, 16, 16, 16), (1,)): ("RGBA", "RGBa;16B"),
(MM, 2, (1, 1, 1, 1), 1, (16, 16, 16, 16), (1,)): ("RGBA", "RGBa;16B"),
(II, 2, (1,), 1, (16, 16, 16, 16), (2,)): ("RGBA", "RGBA;16L"),
(MM, 2, (1,), 1, (16, 16, 16, 16), (2,)): ("RGBA", "RGBA;16B"),
(II, 3, (1,), 1, (1,), ()): ("P", "P;1"),
Expand Down Expand Up @@ -262,9 +306,11 @@
# JPEG compressed images handled by LibTiff and auto-converted to RGBX
# Minimal Baseline TIFF requires YCbCr images to have 3 SamplesPerPixel
(II, 6, (1,), 1, (8, 8, 8), ()): ("RGB", "RGBX"),
(II, 6, (1, 1, 1), 1, (8, 8, 8), ()): ("RGB", "RGBX"),
(MM, 6, (1,), 1, (8, 8, 8), ()): ("RGB", "RGBX"),
(II, 8, (1,), 1, (8, 8, 8), ()): ("LAB", "LAB"),
(MM, 8, (1,), 1, (8, 8, 8), ()): ("LAB", "LAB"),
# XXX hack202406 these entries allow all TIFF tests to pass, but more may be needed
}

MAX_SAMPLESPERPIXEL = max(len(key_tp[4]) for key_tp in OPEN_INFO)
Expand Down Expand Up @@ -1380,14 +1426,6 @@ def _setup(self) -> None:
logger.debug("- size: %s", self.size)

sample_format = self.tag_v2.get(SAMPLEFORMAT, (1,))
if len(sample_format) > 1 and max(sample_format) == min(sample_format) == 1:
# SAMPLEFORMAT is properly per band, so an RGB image will
# be (1,1,1). But, we don't support per band pixel types,
# and anything more than one band is a uint8. So, just
# take the first element. Revisit this if adding support
# for more exotic images.
sample_format = (1,)

bps_tuple = self.tag_v2.get(BITSPERSAMPLE, (1,))
extra_tuple = self.tag_v2.get(EXTRASAMPLES, ())
if photo in (2, 6, 8): # RGB, YCbCr, LAB
Expand Down Expand Up @@ -1443,6 +1481,9 @@ def _setup(self) -> None:

logger.debug("- raw mode: %s", rawmode)
logger.debug("- pil mode: %s", self.mode)
if self.mode == "MB":
assert max(bps_tuple) == min(bps_tuple)
self.mb_config = (max(bps_tuple), samples_per_pixel)

self.info["compression"] = self._compression

Expand Down
75 changes: 61 additions & 14 deletions src/_imaging.c
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@
int bands;

/* FIXME: add primitive to libImaging to avoid extra allocation */
im = ImagingNew(mode, 0, 0);
im = ImagingNew(mode, (ImagingNewParams){0, 0});
if (!im) {
return -1;
}
Expand Down Expand Up @@ -434,6 +434,36 @@
return out[0];
}

static inline PyObject *
getpixel_mb(Imaging im, ImagingAccess access, int x, int y) {
UINT8 pixel[sizeof(INT32) * 6];
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's the 6 from here?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This used to be UINT8 pixel[im->pixelsize] but a strict compiler is unhappy.
Currently the largest entry in OPEN_INFO has 6 bands, so that it's defined as 6.
I may have to put a pre-allocated uint8[pixelsize] buffer in Imaging struct.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The C layer has to be internally safe, we can't be relying on inderict python restrictions for memory safety like that. If we can't do this statically, it's going to have to be dynamic, or there needs to be an arbitrary band limit constant somewhere in the code. (Which ultimately, may be a good idea, if only to prevent someone from creating an image with 2^32-1 bands)

assert(im->pixelsize <= sizeof(pixel));
access->get_pixel(im, x, y, &pixel);

PyObject *tuple = PyTuple_New(im->bands);
if (tuple == NULL) {
return NULL;

Check warning on line 445 in src/_imaging.c

View check run for this annotation

Codecov / codecov/patch

src/_imaging.c#L445

Added line #L445 was not covered by tests
}

UINT8 *pos = pixel;
for (int i = 0; i < im->bands; ++i) {
switch (im->depth) {
case CHAR_BIT:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Style note - CHAR_BIT isn't used elsewhere here, We're just using 8

PyTuple_SET_ITEM(tuple, i, PyLong_FromLong(*pos));
break;

Check warning on line 453 in src/_imaging.c

View check run for this annotation

Codecov / codecov/patch

src/_imaging.c#L451-L453

Added lines #L451 - L453 were not covered by tests
case 2 * CHAR_BIT:
PyTuple_SET_ITEM(tuple, i, PyLong_FromLong(*(UINT16 *)pos));
break;
case 4 * CHAR_BIT:
PyTuple_SET_ITEM(tuple, i, PyLong_FromLong(*(INT32 *)pos));
break;

Check warning on line 459 in src/_imaging.c

View check run for this annotation

Codecov / codecov/patch

src/_imaging.c#L457-L459

Added lines #L457 - L459 were not covered by tests
}
pos += im->depth / CHAR_BIT;
}

return tuple;
}

static inline PyObject *
getpixel(Imaging im, ImagingAccess access, int x, int y) {
union {
Expand All @@ -455,6 +485,10 @@
return NULL;
}

if (im->type == IMAGING_TYPE_MB) {
return getpixel_mb(im, access, x, y);
}

access->get_pixel(im, x, y, &pixel);

switch (im->type) {
Expand Down Expand Up @@ -669,7 +703,7 @@
return NULL;
}

im = ImagingNewDirty(mode, xsize, ysize);
im = ImagingNewDirty(mode, (ImagingNewParams){xsize, ysize});
if (!im) {
return NULL;
}
Expand All @@ -690,13 +724,14 @@
static PyObject *
_new(PyObject *self, PyObject *args) {
char *mode;
int xsize, ysize;
int xsize, ysize, depth = -1, bands = -1;

if (!PyArg_ParseTuple(args, "s(ii)", &mode, &xsize, &ysize)) {
if (!PyArg_ParseTuple(args, "s(ii)|ii", &mode, &xsize, &ysize, &depth, &bands)) {
return NULL;
}

return PyImagingNew(ImagingNew(mode, xsize, ysize));
return PyImagingNew(ImagingNew(mode, (ImagingNewParams){xsize, ysize, depth, bands})
);
}

static PyObject *
Expand Down Expand Up @@ -917,7 +952,9 @@
return NULL;
}

imOut = ImagingNewDirty(mode, self->image->xsize, self->image->ysize);
imOut = ImagingNewDirty(
mode, (ImagingNewParams){self->image->xsize, self->image->ysize}
);
if (!imOut) {
free(prepared_table);
return NULL;
Expand Down Expand Up @@ -1104,7 +1141,7 @@
}

imIn = self->image;
imOut = ImagingNewDirty(imIn->mode, imIn->xsize, imIn->ysize);
imOut = ImagingNewDirty(imIn->mode, (ImagingNewParams){imIn->xsize, imIn->ysize});
if (!imOut) {
return NULL;
}
Expand Down Expand Up @@ -1729,7 +1766,9 @@

if (!self->image->xsize || !self->image->ysize) {
/* no content; return an empty image */
return PyImagingNew(ImagingNew("P", self->image->xsize, self->image->ysize));
return PyImagingNew(
ImagingNew("P", (ImagingNewParams){self->image->xsize, self->image->ysize})
);
}

return PyImagingNew(ImagingQuantize(self->image, colours, method, kmeans));
Expand Down Expand Up @@ -1938,7 +1977,7 @@
a[2] = box[0];
a[5] = box[1];

imOut = ImagingNewDirty(imIn->mode, xsize, ysize);
imOut = ImagingNewDirty(imIn->mode, (ImagingNewParams){xsize, ysize});

imOut = ImagingTransform(
imOut, imIn, IMAGING_TRANSFORM_AFFINE, 0, 0, xsize, ysize, a, filter, 1
Expand Down Expand Up @@ -2120,13 +2159,19 @@
case 0: /* flip left right */
case 1: /* flip top bottom */
case 3: /* rotate 180 */
imOut = ImagingNewDirty(imIn->mode, imIn->xsize, imIn->ysize);
imOut = ImagingNewDirty(
imIn->mode,
(ImagingNewParams){imIn->xsize, imIn->ysize, imIn->depth, imIn->bands}
);
break;
case 2: /* rotate 90 */
case 4: /* rotate 270 */
case 5: /* transpose */
case 6: /* transverse */
imOut = ImagingNewDirty(imIn->mode, imIn->ysize, imIn->xsize);
imOut = ImagingNewDirty(
imIn->mode,
(ImagingNewParams){imIn->ysize, imIn->xsize, imIn->depth, imIn->bands}
);
break;
default:
PyErr_SetString(PyExc_ValueError, "No such transpose operation");
Expand Down Expand Up @@ -2175,7 +2220,7 @@
}

imIn = self->image;
imOut = ImagingNewDirty(imIn->mode, imIn->xsize, imIn->ysize);
imOut = ImagingNewDirty(imIn->mode, (ImagingNewParams){imIn->xsize, imIn->ysize});
if (!imOut) {
return NULL;
}
Expand All @@ -2200,7 +2245,7 @@
}

imIn = self->image;
imOut = ImagingNewDirty(imIn->mode, imIn->xsize, imIn->ysize);
imOut = ImagingNewDirty(imIn->mode, (ImagingNewParams){imIn->xsize, imIn->ysize});
if (!imOut) {
return NULL;
}
Expand Down Expand Up @@ -2806,7 +2851,9 @@
return NULL;
}

im = ImagingNew(self->bitmap->mode, textwidth(self, text), self->ysize);
im = ImagingNew(
self->bitmap->mode, (ImagingNewParams){textwidth(self, text), self->ysize}
);
if (!im) {
free(text);
return ImagingError_MemoryError();
Expand Down
Loading
Loading