• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

colour-science / colour / 14923567987

09 May 2025 07:14AM UTC coverage: 99.347% (+0.01%) from 99.335%
14923567987

Pull #1340

github

KelSolaar
Disable `xdist`.
Pull Request #1340: PR: Make *OpenImageIO* required and *ImageIO* optional.

14 of 14 new or added lines in 3 files covered. (100.0%)

10 existing lines in 3 files now uncovered.

41666 of 41940 relevant lines covered (99.35%)

0.99 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

99.36
/colour/io/image.py
1
"""
2
Image Input / Output Utilities
3
==============================
4

5
Define the image related input / output utilities objects.
6
"""
7

8
from __future__ import annotations
1✔
9

10
import typing
1✔
11
from dataclasses import dataclass, field
1✔
12

13
import numpy as np
1✔
14
from OpenImageIO import DOUBLE  # pyright: ignore
1✔
15
from OpenImageIO import FLOAT  # pyright: ignore
1✔
16
from OpenImageIO import HALF  # pyright: ignore
1✔
17
from OpenImageIO import UINT8  # pyright: ignore
1✔
18
from OpenImageIO import UINT16  # pyright: ignore
1✔
19
from OpenImageIO import ImageInput  # pyright: ignore
1✔
20
from OpenImageIO import ImageOutput  # pyright: ignore
1✔
21
from OpenImageIO import ImageSpec  # pyright: ignore
1✔
22

23
if typing.TYPE_CHECKING:
24
    from colour.hints import (
25
        Any,
26
        ArrayLike,
27
        DTypeReal,
28
        Literal,
29
        NDArrayFloat,
30
        PathLike,
31
        Sequence,
32
        Tuple,
33
        Type,
34
    )
35

36
from colour.hints import NDArrayReal, cast
1✔
37
from colour.utilities import (
1✔
38
    CanonicalMapping,
39
    as_float_array,
40
    as_int_array,
41
    attest,
42
    filter_kwargs,
43
    is_imageio_installed,
44
    optional,
45
    required,
46
    tstack,
47
    usage_warning,
48
    validate_method,
49
)
50
from colour.utilities.deprecation import handle_arguments_deprecation
1✔
51

52
__author__ = "Colour Developers"
1✔
53
__copyright__ = "Copyright 2013 Colour Developers"
1✔
54
__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause"
1✔
55
__maintainer__ = "Colour Developers"
1✔
56
__email__ = "colour-developers@colour-science.org"
1✔
57
__status__ = "Production"
1✔
58

59
__all__ = [
1✔
60
    "Image_Specification_BitDepth",
61
    "Image_Specification_Attribute",
62
    "MAPPING_BIT_DEPTH",
63
    "add_attributes_to_image_specification_OpenImageIO",
64
    "image_specification_OpenImageIO",
65
    "convert_bit_depth",
66
    "read_image_OpenImageIO",
67
    "read_image_Imageio",
68
    "READ_IMAGE_METHODS",
69
    "read_image",
70
    "write_image_OpenImageIO",
71
    "write_image_Imageio",
72
    "WRITE_IMAGE_METHODS",
73
    "write_image",
74
    "as_3_channels_image",
75
]
76

77

78
@dataclass(frozen=True)
1✔
79
class Image_Specification_BitDepth:
1✔
80
    """
81
    Define a bit-depth specification.
82

83
    Parameters
84
    ----------
85
    name
86
        Attribute name.
87
    numpy
88
        Object representing the *Numpy* bit-depth.
89
    openimageio
90
        Object representing the *OpenImageIO* bit-depth.
91
    """
92

93
    name: str
1✔
94
    numpy: Type[DTypeReal]
1✔
95
    openimageio: Any
1✔
96

97

98
@dataclass
1✔
99
class Image_Specification_Attribute:
1✔
100
    """
101
    Define an image specification attribute.
102

103
    Parameters
104
    ----------
105
    name
106
        Attribute name.
107
    value
108
        Attribute value.
109
    type_
110
        Attribute type as an *OpenImageIO* :class:`TypeDesc` class instance.
111
    """
112

113
    name: str
1✔
114
    value: Any
1✔
115
    type_: OpenImageIO.TypeDesc | None = field(  # noqa: F821, RUF100 # pyright: ignore # noqa: F821
1✔
116
        default_factory=lambda: None
117
    )
118

119

120
MAPPING_BIT_DEPTH: CanonicalMapping = CanonicalMapping(
1✔
121
    {
122
        "uint8": Image_Specification_BitDepth("uint8", np.uint8, UINT8),
123
        "uint16": Image_Specification_BitDepth("uint16", np.uint16, UINT16),
124
        "float16": Image_Specification_BitDepth("float16", np.float16, HALF),
125
        "float32": Image_Specification_BitDepth("float32", np.float32, FLOAT),
126
        "float64": Image_Specification_BitDepth("float64", np.float64, DOUBLE),
127
    }
128
)
129
if not typing.TYPE_CHECKING and hasattr(np, "float128"):  # pragma: no cover
130
    MAPPING_BIT_DEPTH["float128"] = Image_Specification_BitDepth(
131
        "float128", np.float128, DOUBLE
132
    )
133

134

135
def add_attributes_to_image_specification_OpenImageIO(
1✔
136
    image_specification: ImageSpec, attributes: Sequence
137
) -> ImageSpec:
138
    """
139
    Add given attributes to given *OpenImageIO* image specification.
140

141
    Parameters
142
    ----------
143
    image_specification
144
        *OpenImageIO* image specification.
145
    attributes
146
        An array of :class:`colour.io.Image_Specification_Attribute` class
147
        instances used to set attributes of the image.
148

149
    Returns
150
    -------
151
    :class:`ImageSpec`
152
        *OpenImageIO*. image specification.
153

154
    Examples
155
    --------
156
    >>> image_specification = image_specification_OpenImageIO(
157
    ...     1920, 1080, 3, "float16"
158
    ... )  # doctest: +SKIP
159
    >>> compression = Image_Specification_Attribute("Compression", "none")
160
    >>> image_specification = add_attributes_to_image_specification_OpenImageIO(
161
    ...     image_specification, [compression]
162
    ... )  # doctest: +SKIP
163
    >>> image_specification.extra_attribs[0].value  # doctest: +SKIP
164
    'none'
165
    """  # noqa: D405, D407, D410, D411
166

167
    for attribute in attributes:
1✔
168
        name = str(attribute.name)
1✔
169
        value = (
1✔
170
            str(attribute.value)
171
            if isinstance(attribute.value, str)
172
            else attribute.value
173
        )
174
        type_ = attribute.type_
1✔
175
        if attribute.type_ is None:
1✔
176
            image_specification.attribute(name, value)
1✔
177
        else:
178
            image_specification.attribute(name, type_, value)
1✔
179

180
    return image_specification
1✔
181

182

183
def image_specification_OpenImageIO(
1✔
184
    width: int,
185
    height: int,
186
    channels: int,
187
    bit_depth: Literal[
188
        "uint8", "uint16", "float16", "float32", "float64", "float128"
189
    ] = "float32",
190
    attributes: Sequence | None = None,
191
) -> ImageSpec:
192
    """
193
    Create an *OpenImageIO* image specification.
194

195
    Parameters
196
    ----------
197
    width
198
        Image width.
199
    height
200
        Image height.
201
    channels
202
        Image channel count.
203
    bit_depth
204
        Bit-depth to create the image with, the bit-depth conversion behaviour is
205
        ruled directly by *OpenImageIO*.
206
    attributes
207
        An array of :class:`colour.io.Image_Specification_Attribute` class
208
        instances used to set attributes of the image.
209

210
    Returns
211
    -------
212
    :class:`ImageSpec`
213
        *OpenImageIO*. image specification.
214

215
    Examples
216
    --------
217
    >>> compression = Image_Specification_Attribute("Compression", "none")
218
    >>> image_specification_OpenImageIO(
219
    ...     1920, 1080, 3, "float16", [compression]
220
    ... )  # doctest: +SKIP
221
    <OpenImageIO.ImageSpec object at 0x...>
222
    """  # noqa: D405, D407, D410, D411
223

224
    from OpenImageIO import ImageSpec  # pyright: ignore
1✔
225

226
    attributes = cast(list, optional(attributes, []))
1✔
227

228
    bit_depth_specification = MAPPING_BIT_DEPTH[bit_depth]
1✔
229

230
    image_specification = ImageSpec(
1✔
231
        width, height, channels, bit_depth_specification.openimageio
232
    )
233

234
    add_attributes_to_image_specification_OpenImageIO(
1✔
235
        image_specification, attributes or []
236
    )
237

238
    return image_specification
1✔
239

240

241
def convert_bit_depth(
1✔
242
    a: ArrayLike,
243
    bit_depth: Literal[
244
        "uint8", "uint16", "float16", "float32", "float64", "float128"
245
    ] = "float32",
246
) -> NDArrayReal:
247
    """
248
    Convert given array to given bit-depth, the current bit-depth of the array
249
    is used to determine the appropriate conversion path.
250

251
    Parameters
252
    ----------
253
    a
254
        Array to convert to given bit-depth.
255
    bit_depth
256
        Bit-depth.
257

258
    Returns
259
    -------
260
    :class`numpy.ndarray`
261
        Converted array.
262

263
    Examples
264
    --------
265
    >>> a = np.array([0.0, 0.5, 1.0])
266
    >>> convert_bit_depth(a, "uint8")
267
    array([  0, 128, 255], dtype=uint8)
268
    >>> convert_bit_depth(a, "uint16")
269
    array([    0, 32768, 65535], dtype=uint16)
270
    >>> convert_bit_depth(a, "float16")
271
    array([ 0. ,  0.5,  1. ], dtype=float16)
272
    >>> a = np.array([0, 128, 255], dtype=np.uint8)
273
    >>> convert_bit_depth(a, "uint16")
274
    array([    0, 32896, 65535], dtype=uint16)
275
    >>> convert_bit_depth(a, "float32")  # doctest: +ELLIPSIS
276
    array([ 0.       ,  0.501960...,  1.       ], dtype=float32)
277
    """
278

279
    a = np.asarray(a)
1✔
280

281
    bit_depths = ", ".join(sorted(MAPPING_BIT_DEPTH.keys()))
1✔
282

283
    attest(
1✔
284
        bit_depth in bit_depths,
285
        f'Incorrect bit-depth was specified, it must be one of: "{bit_depths}"!',
286
    )
287

288
    attest(
1✔
289
        str(a.dtype) in bit_depths,
290
        f'Image bit-depth must be one of: "{bit_depths}"!',
291
    )
292

293
    source_dtype = str(a.dtype)
1✔
294
    target_dtype = MAPPING_BIT_DEPTH[bit_depth].numpy
1✔
295

296
    if source_dtype == "uint8":
1✔
297
        if bit_depth == "uint16":
1✔
298
            a = a.astype(target_dtype) * 257
1✔
299
        elif bit_depth in ("float16", "float32", "float64", "float128"):
1✔
300
            a = (a / 255).astype(target_dtype)
1✔
301
    elif source_dtype == "uint16":
1✔
302
        if bit_depth == "uint8":
1✔
303
            a = (a / 257).astype(target_dtype)
1✔
304
        elif bit_depth in ("float16", "float32", "float64", "float128"):
1✔
305
            a = (a / 65535).astype(target_dtype)
1✔
306
    elif source_dtype in ("float16", "float32", "float64", "float128"):
1✔
307
        if bit_depth == "uint8":
1✔
308
            a = np.around(a * 255).astype(target_dtype)
1✔
309
        elif bit_depth == "uint16":
1✔
310
            a = np.around(a * 65535).astype(target_dtype)
1✔
311
        elif bit_depth in ("float16", "float32", "float64", "float128"):
1✔
312
            a = a.astype(target_dtype)
1✔
313

314
    return a
1✔
315

316

317
@typing.overload
1✔
318
def read_image_OpenImageIO(
1✔
319
    path: str | PathLike,
320
    bit_depth: Literal[
321
        "uint8", "uint16", "float16", "float32", "float64", "float128"
322
    ] = ...,
323
    additional_data: Literal[True] = True,
324
    **kwargs: Any,
325
) -> Tuple[NDArrayReal, Tuple[Image_Specification_Attribute, ...]]: ...
326

327

328
@typing.overload
1✔
329
def read_image_OpenImageIO(
1✔
330
    path: str | PathLike,
331
    bit_depth: Literal[
332
        "uint8", "uint16", "float16", "float32", "float64", "float128"
333
    ] = ...,
334
    *,
335
    additional_data: Literal[False],
336
    **kwargs: Any,
337
) -> NDArrayReal: ...
338

339

340
@typing.overload
1✔
341
def read_image_OpenImageIO(
1✔
342
    path: str | PathLike,
343
    bit_depth: Literal["uint8", "uint16", "float16", "float32", "float64", "float128"],
344
    additional_data: Literal[False],
345
    **kwargs: Any,
346
) -> NDArrayReal: ...
347

348

349
def read_image_OpenImageIO(
1✔
350
    path: str | PathLike,
351
    bit_depth: Literal[
352
        "uint8", "uint16", "float16", "float32", "float64", "float128"
353
    ] = "float32",
354
    additional_data: bool = False,
355
    **kwargs: Any,
356
) -> NDArrayReal | Tuple[NDArrayReal, Tuple[Image_Specification_Attribute, ...]]:
357
    """
358
    Read the image data at given path using *OpenImageIO*.
359

360
    Parameters
361
    ----------
362
    path
363
        Image path.
364
    bit_depth
365
        Returned image bit-depth, the bit-depth conversion behaviour is driven
366
        directly by *OpenImageIO*, this definition only converts to the
367
        relevant data type after reading.
368
    additional_data
369
        Whether to return additional data.
370

371
    Returns
372
    -------
373
    :class`numpy.ndarray` or :class:`tuple`
374
        Image data or tuple of image data and list of
375
        :class:`colour.io.Image_Specification_Attribute` class instances.
376

377
    Notes
378
    -----
379
    -   For convenience, single channel images are squeezed to 2D arrays.
380

381
    Examples
382
    --------
383
    >>> import os
384
    >>> import colour
385
    >>> path = os.path.join(
386
    ...     colour.__path__[0],
387
    ...     "io",
388
    ...     "tests",
389
    ...     "resources",
390
    ...     "CMS_Test_Pattern.exr",
391
    ... )
392
    >>> image = read_image_OpenImageIO(path)  # doctest: +SKIP
393
    """
394

395
    path = str(path)
1✔
396

397
    kwargs = handle_arguments_deprecation(
1✔
398
        {
399
            "ArgumentRenamed": [["attributes", "additional_data"]],
400
        },
401
        **kwargs,
402
    )
403

404
    additional_data = kwargs.get("additional_data", additional_data)
1✔
405

406
    bit_depth_specification = MAPPING_BIT_DEPTH[bit_depth]
1✔
407

408
    image_input = ImageInput.open(path)
1✔
409
    image_specification = image_input.spec()
1✔
410

411
    shape = (
1✔
412
        image_specification.height,
413
        image_specification.width,
414
        image_specification.nchannels,
415
    )
416

417
    image = image_input.read_image(bit_depth_specification.openimageio)
1✔
418
    image_input.close()
1✔
419

420
    image = np.reshape(np.array(image, dtype=bit_depth_specification.numpy), shape)
1✔
421
    image = cast(NDArrayReal, np.squeeze(image))
1✔
422

423
    if additional_data:
1✔
424
        extra_attributes = [
1✔
425
            Image_Specification_Attribute(
426
                attribute.name, attribute.value, attribute.type
427
            )
428
            for attribute in image_specification.extra_attribs
429
        ]
430

431
        return image, tuple(extra_attributes)
1✔
432

433
    return image
1✔
434

435

436
@required("Imageio")
1✔
437
def read_image_Imageio(
1✔
438
    path: str | PathLike,
439
    bit_depth: Literal[
440
        "uint8", "uint16", "float16", "float32", "float64", "float128"
441
    ] = "float32",
442
    **kwargs: Any,
443
) -> NDArrayReal:
444
    """
445
    Read the image data at given path using *Imageio*.
446

447
    Parameters
448
    ----------
449
    path
450
        Image path.
451
    bit_depth
452
        Returned image bit-depth, the image data is converted with
453
        :func:`colour.io.convert_bit_depth` definition after reading the
454
        image.
455

456
    Other Parameters
457
    ----------------
458
    kwargs
459
        Keywords arguments.
460

461
    Returns
462
    -------
463
    :class`numpy.ndarray`
464
        Image data.
465

466
    Notes
467
    -----
468
    -   For convenience, single channel images are squeezed to 2D arrays.
469

470
    Examples
471
    --------
472
    >>> import os
473
    >>> import colour
474
    >>> path = os.path.join(
475
    ...     colour.__path__[0],
476
    ...     "io",
477
    ...     "tests",
478
    ...     "resources",
479
    ...     "CMS_Test_Pattern.exr",
480
    ... )
481
    >>> image = read_image_Imageio(path)
482
    >>> image.shape  # doctest: +SKIP
483
    (1267, 1274, 3)
484
    >>> image.dtype
485
    dtype('float32')
486
    """
487

488
    from imageio.v2 import imread
1✔
489

490
    path = str(path)
1✔
491

492
    image = np.squeeze(imread(path, **kwargs))
1✔
493

494
    return convert_bit_depth(image, bit_depth)
1✔
495

496

497
READ_IMAGE_METHODS: CanonicalMapping = CanonicalMapping(
1✔
498
    {
499
        "Imageio": read_image_Imageio,
500
        "OpenImageIO": read_image_OpenImageIO,
501
    }
502
)
503
READ_IMAGE_METHODS.__doc__ = """
1✔
504
Supported image read methods.
505
"""
506

507

508
def read_image(
1✔
509
    path: str | PathLike,
510
    bit_depth: Literal[
511
        "uint8", "uint16", "float16", "float32", "float64", "float128"
512
    ] = "float32",
513
    method: Literal["Imageio", "OpenImageIO"] | str = "OpenImageIO",
514
    **kwargs: Any,
515
) -> NDArrayReal:
516
    """
517
    Read the image data at given path using given method.
518

519
    Parameters
520
    ----------
521
    path
522
        Image path.
523
    bit_depth
524
        Returned image bit-depth, for the *Imageio* method, the image data is
525
        converted with :func:`colour.io.convert_bit_depth` definition after
526
        reading the image, for the *OpenImageIO* method, the bit-depth
527
        conversion behaviour is driven directly by the library, this definition
528
        only converts to the relevant data type after reading.
529
    method
530
        Read method, i.e., the image library used for reading images.
531

532
    Other Parameters
533
    ----------------
534
    additional_data
535
        {:func:`colour.io.read_image_OpenImageIO`},
536
        Whether to return additional data.
537

538
    Returns
539
    -------
540
    :class`numpy.ndarray`
541
        Image data.
542

543
    Notes
544
    -----
545
    -   If the given method is *OpenImageIO* but the library is not available
546
        writing will be performed by *Imageio*.
547
    -   If the given method is *Imageio*, ``kwargs`` is passed directly to the
548
        wrapped definition.
549
    -   For convenience, single channel images are squeezed to 2D arrays.
550

551
    Examples
552
    --------
553
    >>> import os
554
    >>> import colour
555
    >>> path = os.path.join(
556
    ...     colour.__path__[0],
557
    ...     "io",
558
    ...     "tests",
559
    ...     "resources",
560
    ...     "CMS_Test_Pattern.exr",
561
    ... )
562
    >>> image = read_image(path)
563
    >>> image.shape  # doctest: +SKIP
564
    (1267, 1274, 3)
565
    >>> image.dtype
566
    dtype('float32')
567
    """
568

569
    if method.lower() == "imageio" and not is_imageio_installed():  # pragma: no cover
570
        usage_warning(
571
            '"Imageio" related API features are not available, '
572
            'switching to "OpenImageIO"!'
573
        )
574
        method = "openimageio"
575

576
    method = validate_method(method, tuple(READ_IMAGE_METHODS))
1✔
577

578
    function = READ_IMAGE_METHODS[method]
1✔
579

580
    if method == "openimageio":  # pragma: no cover
581
        kwargs = filter_kwargs(function, **kwargs)
582

583
    return function(path, bit_depth, **kwargs)
1✔
584

585

586
def write_image_OpenImageIO(
1✔
587
    image: ArrayLike,
588
    path: str | PathLike,
589
    bit_depth: Literal[
590
        "uint8", "uint16", "float16", "float32", "float64", "float128"
591
    ] = "float32",
592
    attributes: Sequence | None = None,
593
) -> bool:
594
    """
595
    Write given image data at given path using *OpenImageIO*.
596

597
    Parameters
598
    ----------
599
    image
600
        Image data.
601
    path
602
        Image path.
603
    bit_depth
604
        Bit-depth to write the image at, the bit-depth conversion behaviour is
605
        ruled directly by *OpenImageIO*.
606
    attributes
607
        An array of :class:`colour.io.Image_Specification_Attribute` class
608
        instances used to set attributes of the image.
609

610
    Returns
611
    -------
612
    :class:`bool`
613
        Definition success.
614

615
    Examples
616
    --------
617
    Basic image writing:
618

619
    >>> import os
620
    >>> import colour
621
    >>> path = os.path.join(
622
    ...     colour.__path__[0],
623
    ...     "io",
624
    ...     "tests",
625
    ...     "resources",
626
    ...     "CMS_Test_Pattern.exr",
627
    ... )
628
    >>> image = read_image(path)  # doctest: +SKIP
629
    >>> path = os.path.join(
630
    ...     colour.__path__[0],
631
    ...     "io",
632
    ...     "tests",
633
    ...     "resources",
634
    ...     "CMSTestPattern.tif",
635
    ... )
636
    >>> write_image_OpenImageIO(image, path)  # doctest: +SKIP
637
    True
638

639
    Advanced image writing while setting attributes:
640

641
    >>> compression = Image_Specification_Attribute("Compression", "none")
642
    >>> write_image_OpenImageIO(image, path, "uint8", [compression])
643
    ... # doctest: +SKIP
644
    True
645

646
    Writing an "ACES" compliant "EXR" file:
647

648
    >>> if is_imageio_installed():  # doctest: +SKIP
649
    ...     from OpenImageIO import TypeDesc
650
    ...
651
    ...     chromaticities = (
652
    ...         0.7347,
653
    ...         0.2653,
654
    ...         0.0,
655
    ...         1.0,
656
    ...         0.0001,
657
    ...         -0.077,
658
    ...         0.32168,
659
    ...         0.33767,
660
    ...     )
661
    ...     attributes = [
662
    ...         Image_Specification_Attribute("acesImageContainerFlag", True),
663
    ...         Image_Specification_Attribute(
664
    ...             "chromaticities", chromaticities, TypeDesc("float[8]")
665
    ...         ),
666
    ...         Image_Specification_Attribute("compression", "none"),
667
    ...     ]
668
    ...     write_image_OpenImageIO(image, path, attributes=attributes)
669
    """  # noqa: D405, D407, D410, D411
670

671
    image = as_float_array(image)
1✔
672
    path = str(path)
1✔
673

674
    attributes = cast(list, optional(attributes, []))
1✔
675

676
    bit_depth_specification = MAPPING_BIT_DEPTH[bit_depth]
1✔
677

678
    if bit_depth_specification.numpy in [np.uint8, np.uint16]:
1✔
679
        minimum, maximum = (
1✔
680
            np.iinfo(bit_depth_specification.numpy).min,
681
            np.iinfo(bit_depth_specification.numpy).max,
682
        )
683
        image = np.clip(image * maximum, minimum, maximum)
1✔
684

685
        image = as_int_array(image, bit_depth_specification.numpy)
1✔
686

687
    image = image.astype(bit_depth_specification.numpy)
1✔
688

689
    if image.ndim == 2:
1✔
690
        height, width = image.shape
1✔
691
        channels = 1
1✔
692
    else:
693
        height, width, channels = image.shape
1✔
694

695
    image_specification = image_specification_OpenImageIO(
1✔
696
        width, height, channels, bit_depth, attributes
697
    )
698

699
    image_output = ImageOutput.create(path)
1✔
700

701
    image_output.open(path, image_specification)
1✔
702
    success = image_output.write_image(image)
1✔
703

704
    image_output.close()
1✔
705

706
    return success
1✔
707

708

709
@required("Imageio")
1✔
710
def write_image_Imageio(
1✔
711
    image: ArrayLike,
712
    path: str | PathLike,
713
    bit_depth: Literal[
714
        "uint8", "uint16", "float16", "float32", "float64", "float128"
715
    ] = "float32",
716
    **kwargs: Any,
717
) -> bytes | None:
718
    """
719
    Write given image data at given path using *Imageio*.
720

721
    Parameters
722
    ----------
723
    image
724
        Image data.
725
    path
726
        Image path.
727
    bit_depth
728
        Bit-depth to write the image at, the image data is converted with
729
        :func:`colour.io.convert_bit_depth` definition prior to writing the
730
        image.
731

732
    Other Parameters
733
    ----------------
734
    kwargs
735
        Keywords arguments.
736

737
    Returns
738
    -------
739
    :class:`bool`
740
        Definition success.
741

742
    Notes
743
    -----
744
    -   It is possible to control how the image are saved by the *Freeimage*
745
        backend by using the ``flags`` keyword argument and passing a desired
746
        value. See the *Load / Save flag constants* section in
747
        https://sourceforge.net/p/freeimage/svn/HEAD/tree/FreeImage/trunk/\
748
Source/FreeImage.h
749

750
    Examples
751
    --------
752
    >>> import os
753
    >>> import colour
754
    >>> path = os.path.join(
755
    ...     colour.__path__[0],
756
    ...     "io",
757
    ...     "tests",
758
    ...     "resources",
759
    ...     "CMS_Test_Pattern.exr",
760
    ... )
761
    >>> image = read_image(path)  # doctest: +SKIP
762
    >>> path = os.path.join(
763
    ...     colour.__path__[0],
764
    ...     "io",
765
    ...     "tests",
766
    ...     "resources",
767
    ...     "CMSTestPattern.tif",
768
    ... )
769
    >>> write_image_Imageio(image, path)  # doctest: +SKIP
770
    True
771
    """
772

773
    from imageio.v2 import imwrite
1✔
774

775
    path = str(path)
1✔
776

777
    if all(
1✔
778
        [
779
            path.lower().endswith(".exr"),
780
            bit_depth in ("float32", "float64", "float128"),
781
        ]
782
    ):
783
        # Ensures that "OpenEXR" images are saved as "Float32" according to the
784
        # image bit-depth.
785
        kwargs["flags"] = 0x0001
1✔
786

787
    image = convert_bit_depth(image, bit_depth)
1✔
788

789
    return imwrite(path, image, **kwargs)
1✔
790

791

792
WRITE_IMAGE_METHODS: CanonicalMapping = CanonicalMapping(
1✔
793
    {
794
        "Imageio": write_image_Imageio,
795
        "OpenImageIO": write_image_OpenImageIO,
796
    }
797
)
798
WRITE_IMAGE_METHODS.__doc__ = """
1✔
799
Supported image write methods.
800
"""
801

802

803
def write_image(
1✔
804
    image: ArrayLike,
805
    path: str | PathLike,
806
    bit_depth: Literal[
807
        "uint8", "uint16", "float16", "float32", "float64", "float128"
808
    ] = "float32",
809
    method: Literal["Imageio", "OpenImageIO"] | str = "OpenImageIO",
810
    **kwargs: Any,
811
) -> bool:
812
    """
813
    Write given image data at given path using given method.
814

815
    Parameters
816
    ----------
817
    image
818
        Image data.
819
    path
820
        Image path.
821
    bit_depth
822
        Bit-depth to write the image at, for the *Imageio* method, the image
823
        data is converted with :func:`colour.io.convert_bit_depth` definition
824
        prior to writing the image.
825
    method
826
        Write method, i.e., the image library used for writing images.
827

828
    Other Parameters
829
    ----------------
830
    attributes
831
        {:func:`colour.io.write_image_OpenImageIO`},
832
        An array of :class:`colour.io.Image_Specification_Attribute` class
833
        instances used to set attributes of the image.
834

835
    Returns
836
    -------
837
    :class:`bool`
838
        Definition success.
839

840
    Notes
841
    -----
842
    -   If the given method is *OpenImageIO* but the library is not available
843
        writing will be performed by *Imageio*.
844
    -   If the given method is *Imageio*, ``kwargs`` is passed directly to the
845
        wrapped definition.
846
    -   It is possible to control how the image are saved by the *Freeimage*
847
        backend by using the ``flags`` keyword argument and passing a desired
848
        value. See the *Load / Save flag constants* section in
849
        https://sourceforge.net/p/freeimage/svn/HEAD/tree/FreeImage/trunk/\
850
Source/FreeImage.h
851

852
    Examples
853
    --------
854
    Basic image writing:
855

856
    >>> import os
857
    >>> import colour
858
    >>> path = os.path.join(
859
    ...     colour.__path__[0],
860
    ...     "io",
861
    ...     "tests",
862
    ...     "resources",
863
    ...     "CMS_Test_Pattern.exr",
864
    ... )
865
    >>> image = read_image(path)  # doctest: +SKIP
866
    >>> path = os.path.join(
867
    ...     colour.__path__[0],
868
    ...     "io",
869
    ...     "tests",
870
    ...     "resources",
871
    ...     "CMSTestPattern.tif",
872
    ... )
873
    >>> write_image(image, path)  # doctest: +SKIP
874
    True
875

876
    Advanced image writing while setting attributes using *OpenImageIO*:
877

878
    >>> compression = Image_Specification_Attribute("Compression", "none")
879
    >>> write_image(image, path, bit_depth="uint8", attributes=[compression])
880
    ... # doctest: +SKIP
881
    True
882
    """  # noqa: D405, D407, D410, D411, D414
883

884
    if method.lower() == "imageio" and not is_imageio_installed():  # pragma: no cover
885
        usage_warning(
886
            '"Imageio" related API features are not available, '
887
            'switching to "Imageio"!'
888
        )
889
        method = "openimageio"
890

891
    method = validate_method(method, tuple(WRITE_IMAGE_METHODS))
1✔
892

893
    function = WRITE_IMAGE_METHODS[method]
1✔
894

895
    if method == "openimageio":  # pragma: no cover
896
        kwargs = filter_kwargs(function, **kwargs)
897

898
    return function(image, path, bit_depth, **kwargs)
1✔
899

900

901
def as_3_channels_image(a: ArrayLike) -> NDArrayFloat:
1✔
902
    """
903
    Convert given array :math:`a` to a 3-channels image-like representation.
904

905
    Parameters
906
    ----------
907
    a
908
         Array :math:`a` to convert to a 3-channels image-like representation.
909

910
    Returns
911
    -------
912
    :class`numpy.ndarray`
913
        3-channels image-like representation of array :math:`a`.
914

915
    Examples
916
    --------
917
    >>> as_3_channels_image(0.18)
918
    array([[[ 0.18,  0.18,  0.18]]])
919
    >>> as_3_channels_image([0.18])
920
    array([[[ 0.18,  0.18,  0.18]]])
921
    >>> as_3_channels_image([0.18, 0.18, 0.18])
922
    array([[[ 0.18,  0.18,  0.18]]])
923
    >>> as_3_channels_image([[0.18, 0.18, 0.18]])
924
    array([[[ 0.18,  0.18,  0.18]]])
925
    >>> as_3_channels_image([[[0.18, 0.18, 0.18]]])
926
    array([[[ 0.18,  0.18,  0.18]]])
927
    >>> as_3_channels_image([[[[0.18, 0.18, 0.18]]]])
928
    array([[[ 0.18,  0.18,  0.18]]])
929
    """
930

931
    a = np.squeeze(as_float_array(a))
1✔
932

933
    if len(a.shape) > 3:
1✔
934
        error = (
1✔
935
            "Array has more than 3-dimensions and cannot be converted to a "
936
            "3-channels image-like representation!"
937
        )
938

939
        raise ValueError(error)
1✔
940

941
    if len(a.shape) > 0 and a.shape[-1] not in (1, 3):
1✔
942
        error = (
1✔
943
            "Array has more than 1 or 3 channels and cannot be converted to a "
944
            "3-channels image-like representation!"
945
        )
946

947
        raise ValueError(error)
1✔
948

949
    if len(a.shape) == 0 or a.shape[-1] == 1:
1✔
950
        a = tstack([a, a, a])
1✔
951

952
    if len(a.shape) == 1:
1✔
953
        a = a[None, None, ...]
1✔
954
    elif len(a.shape) == 2:
1✔
UNCOV
955
        a = a[None, ...]
×
956

957
    return a
1✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc