Coverage for io/image.py: 72%

146 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-16 22:49 +1300

1""" 

2Image Input / Output Utilities 

3============================== 

4 

5Define image-related input/output utility objects for colour science 

6applications. 

7""" 

8 

9from __future__ import annotations 

10 

11import typing 

12from dataclasses import dataclass, field 

13 

14import numpy as np 

15 

16if typing.TYPE_CHECKING: 

17 from colour.hints import ( 

18 Any, 

19 ArrayLike, 

20 DTypeReal, 

21 Literal, 

22 NDArrayFloat, 

23 PathLike, 

24 Sequence, 

25 Tuple, 

26 Type, 

27 ) 

28 

29from colour.hints import NDArrayReal, cast 

30from colour.utilities import ( 

31 CanonicalMapping, 

32 as_float_array, 

33 as_int_array, 

34 attest, 

35 filter_kwargs, 

36 is_imageio_installed, 

37 is_openimageio_installed, 

38 optional, 

39 required, 

40 tstack, 

41 usage_warning, 

42 validate_method, 

43) 

44from colour.utilities.deprecation import handle_arguments_deprecation 

45 

46__author__ = "Colour Developers" 

47__copyright__ = "Copyright 2013 Colour Developers" 

48__license__ = "BSD-3-Clause - https://opensource.org/licenses/BSD-3-Clause" 

49__maintainer__ = "Colour Developers" 

50__email__ = "colour-developers@colour-science.org" 

51__status__ = "Production" 

52 

53__all__ = [ 

54 "Image_Specification_BitDepth", 

55 "Image_Specification_Attribute", 

56 "MAPPING_BIT_DEPTH", 

57 "add_attributes_to_image_specification_OpenImageIO", 

58 "image_specification_OpenImageIO", 

59 "convert_bit_depth", 

60 "read_image_OpenImageIO", 

61 "read_image_Imageio", 

62 "READ_IMAGE_METHODS", 

63 "read_image", 

64 "write_image_OpenImageIO", 

65 "write_image_Imageio", 

66 "WRITE_IMAGE_METHODS", 

67 "write_image", 

68 "as_3_channels_image", 

69] 

70 

71 

72@dataclass(frozen=True) 

73class Image_Specification_BitDepth: 

74 """ 

75 Define a bit-depth specification for image processing operations. 

76 

77 Parameters 

78 ---------- 

79 name 

80 Attribute name identifying the bit-depth specification. 

81 numpy 

82 Object representing the *NumPy* bit-depth data type. 

83 openimageio 

84 Object representing the *OpenImageIO* bit-depth specification. 

85 """ 

86 

87 name: str 

88 numpy: Type[DTypeReal] 

89 openimageio: Any 

90 

91 

92@dataclass 

93class Image_Specification_Attribute: 

94 """ 

95 Define an image specification attribute for OpenImageIO operations. 

96 

97 Parameters 

98 ---------- 

99 name 

100 Attribute name identifying the metadata field. 

101 value 

102 Attribute value containing the metadata content. 

103 type_ 

104 Attribute type as an *OpenImageIO* :class:`TypeDesc` class instance 

105 specifying the data type of the value. 

106 """ 

107 

108 name: str 

109 value: Any 

110 type_: OpenImageIO.TypeDesc | None = field( # noqa: F821, RUF100 # pyright: ignore # noqa: F821 

111 default_factory=lambda: None 

112 ) 

113 

114 

115if is_openimageio_installed(): # pragma: no cover 

116 from OpenImageIO import ImageSpec # pyright: ignore 

117 from OpenImageIO import DOUBLE, FLOAT, HALF, UINT8, UINT16 

118 

119 MAPPING_BIT_DEPTH: CanonicalMapping = CanonicalMapping( 

120 { 

121 "uint8": Image_Specification_BitDepth("uint8", np.uint8, UINT8), 

122 "uint16": Image_Specification_BitDepth("uint16", np.uint16, UINT16), 

123 "float16": Image_Specification_BitDepth("float16", np.float16, HALF), 

124 "float32": Image_Specification_BitDepth("float32", np.float32, FLOAT), 

125 "float64": Image_Specification_BitDepth("float64", np.float64, DOUBLE), 

126 } 

127 ) 

128 if not typing.TYPE_CHECKING and hasattr(np, "float128"): # pragma: no cover 

129 MAPPING_BIT_DEPTH["float128"] = Image_Specification_BitDepth( 

130 "float128", np.float128, DOUBLE 

131 ) 

132else: # pragma: no cover 

133 

134 class ImageSpec: 

135 attribute: Any 

136 

137 MAPPING_BIT_DEPTH: CanonicalMapping = CanonicalMapping( 

138 { 

139 "uint8": Image_Specification_BitDepth("uint8", np.uint8, None), 

140 "uint16": Image_Specification_BitDepth("uint16", np.uint16, None), 

141 "float16": Image_Specification_BitDepth("float16", np.float16, None), 

142 "float32": Image_Specification_BitDepth("float32", np.float32, None), 

143 "float64": Image_Specification_BitDepth("float64", np.float64, None), 

144 } 

145 ) 

146 if not typing.TYPE_CHECKING and hasattr(np, "float128"): # pragma: no cover 

147 MAPPING_BIT_DEPTH["float128"] = Image_Specification_BitDepth( 

148 "float128", np.float128, None 

149 ) 

150 

151 

152def add_attributes_to_image_specification_OpenImageIO( 

153 image_specification: ImageSpec, attributes: Sequence 

154) -> ImageSpec: 

155 """ 

156 Add the specified attributes to the specified *OpenImageIO* image 

157 specification. 

158 

159 Apply metadata attributes to an existing image specification object, 

160 enabling customization of image properties such as compression, 

161 colour space information, or other format-specific metadata. 

162 

163 Parameters 

164 ---------- 

165 image_specification 

166 *OpenImageIO* image specification to modify. 

167 attributes 

168 Sequence of :class:`colour.io.Image_Specification_Attribute` 

169 instances containing metadata to apply to the image 

170 specification. 

171 

172 Returns 

173 ------- 

174 :class:`ImageSpec` 

175 Modified *OpenImageIO* image specification with applied 

176 attributes. 

177 

178 Examples 

179 -------- 

180 >>> image_specification = image_specification_OpenImageIO( 

181 ... 1920, 1080, 3, "float16" 

182 ... ) # doctest: +SKIP 

183 >>> compression = Image_Specification_Attribute("Compression", "none") 

184 >>> image_specification = add_attributes_to_image_specification_OpenImageIO( 

185 ... image_specification, [compression] 

186 ... ) # doctest: +SKIP 

187 >>> image_specification.extra_attribs[0].value # doctest: +SKIP 

188 'none' 

189 """ 

190 

191 for attribute in attributes: 

192 name = str(attribute.name) 

193 value = ( 

194 str(attribute.value) 

195 if isinstance(attribute.value, str) 

196 else attribute.value 

197 ) 

198 type_ = attribute.type_ 

199 if attribute.type_ is None: 

200 image_specification.attribute(name, value) 

201 else: 

202 image_specification.attribute(name, type_, value) 

203 

204 return image_specification 

205 

206 

207def image_specification_OpenImageIO( 

208 width: int, 

209 height: int, 

210 channels: int, 

211 bit_depth: Literal[ 

212 "uint8", "uint16", "float16", "float32", "float64", "float128" 

213 ] = "float32", 

214 attributes: Sequence | None = None, 

215) -> ImageSpec: 

216 """ 

217 Create an *OpenImageIO* image specification. 

218 

219 Parameters 

220 ---------- 

221 width 

222 Image width. 

223 height 

224 Image height. 

225 channels 

226 Image channel count. 

227 bit_depth 

228 Bit-depth to create the image with. The bit-depth conversion 

229 behaviour is ruled directly by *OpenImageIO*. 

230 attributes 

231 An array of :class:`colour.io.Image_Specification_Attribute` 

232 class instances used to set attributes of the image. 

233 

234 Returns 

235 ------- 

236 :class:`ImageSpec` 

237 *OpenImageIO* image specification. 

238 

239 Examples 

240 -------- 

241 >>> compression = Image_Specification_Attribute("Compression", "none") 

242 >>> image_specification_OpenImageIO( 

243 ... 1920, 1080, 3, "float16", [compression] 

244 ... ) # doctest: +SKIP 

245 <OpenImageIO.ImageSpec object at 0x...> 

246 """ 

247 

248 from OpenImageIO import ImageSpec # noqa: PLC0415 

249 

250 attributes = cast("list", optional(attributes, [])) 

251 

252 bit_depth_specification = MAPPING_BIT_DEPTH[bit_depth] 

253 

254 image_specification = ImageSpec( 

255 width, height, channels, bit_depth_specification.openimageio 

256 ) 

257 

258 add_attributes_to_image_specification_OpenImageIO( 

259 image_specification, # pyright: ignore 

260 attributes or [], 

261 ) 

262 

263 return image_specification # pyright: ignore 

264 

265 

266def convert_bit_depth( 

267 a: ArrayLike, 

268 bit_depth: Literal[ 

269 "uint8", "uint16", "float16", "float32", "float64", "float128" 

270 ] = "float32", 

271) -> NDArrayReal: 

272 """ 

273 Convert the specified array to the specified bit-depth. 

274 

275 The conversion path is determined by the current bit-depth of the input 

276 array and the target bit-depth. Supports conversions between unsigned 

277 integers, floating-point types, and mixed type conversions with 

278 appropriate scaling. 

279 

280 Parameters 

281 ---------- 

282 a 

283 Array to convert to the specified bit-depth. 

284 bit_depth 

285 Target bit-depth. Supported types include unsigned integers 

286 ("uint8", "uint16") and floating-point ("float16", "float32", 

287 "float64", "float128"). 

288 

289 Returns 

290 ------- 

291 :class:`numpy.ndarray` 

292 Array converted to the specified bit-depth. 

293 

294 Raises 

295 ------ 

296 AssertionError 

297 If the source or target bit-depth is not supported. 

298 

299 Examples 

300 -------- 

301 >>> a = np.array([0.0, 0.5, 1.0]) 

302 >>> convert_bit_depth(a, "uint8") 

303 array([ 0, 128, 255], dtype=uint8) 

304 >>> convert_bit_depth(a, "uint16") 

305 array([ 0, 32768, 65535], dtype=uint16) 

306 >>> convert_bit_depth(a, "float16") 

307 array([ 0. , 0.5, 1. ], dtype=float16) 

308 >>> a = np.array([0, 128, 255], dtype=np.uint8) 

309 >>> convert_bit_depth(a, "uint16") 

310 array([ 0, 32896, 65535], dtype=uint16) 

311 >>> convert_bit_depth(a, "float32") # doctest: +ELLIPSIS 

312 array([ 0. , 0.501960..., 1. ], dtype=float32) 

313 """ 

314 

315 a = np.asarray(a) 

316 

317 bit_depths = ", ".join(sorted(MAPPING_BIT_DEPTH.keys())) 

318 

319 attest( 

320 bit_depth in bit_depths, 

321 f'Incorrect bit-depth was specified, it must be one of: "{bit_depths}"!', 

322 ) 

323 

324 attest( 

325 str(a.dtype) in bit_depths, 

326 f'Image bit-depth must be one of: "{bit_depths}"!', 

327 ) 

328 

329 source_dtype = str(a.dtype) 

330 target_dtype = MAPPING_BIT_DEPTH[bit_depth].numpy 

331 

332 if source_dtype == "uint8": 

333 if bit_depth == "uint16": 

334 a = a.astype(target_dtype) * 257 

335 elif bit_depth in ("float16", "float32", "float64", "float128"): 

336 a = (a / 255).astype(target_dtype) 

337 elif source_dtype == "uint16": 

338 if bit_depth == "uint8": 

339 a = (a / 257).astype(target_dtype) 

340 elif bit_depth in ("float16", "float32", "float64", "float128"): 

341 a = (a / 65535).astype(target_dtype) 

342 elif source_dtype in ("float16", "float32", "float64", "float128"): 

343 if bit_depth == "uint8": 

344 a = np.around(a * 255).astype(target_dtype) 

345 elif bit_depth == "uint16": 

346 a = np.around(a * 65535).astype(target_dtype) 

347 elif bit_depth in ("float16", "float32", "float64", "float128"): 

348 a = a.astype(target_dtype) 

349 

350 return a 

351 

352 

353@typing.overload 

354@required("OpenImageIO") 

355def read_image_OpenImageIO( 

356 path: str | PathLike, 

357 bit_depth: Literal[ 

358 "uint8", "uint16", "float16", "float32", "float64", "float128" 

359 ] = ..., 

360 additional_data: Literal[True] = True, 

361 **kwargs: Any, 

362) -> Tuple[NDArrayReal, Tuple[Image_Specification_Attribute, ...]]: ... 

363 

364 

365@typing.overload 

366@required("OpenImageIO") 

367def read_image_OpenImageIO( 

368 path: str | PathLike, 

369 bit_depth: Literal[ 

370 "uint8", "uint16", "float16", "float32", "float64", "float128" 

371 ] = ..., 

372 *, 

373 additional_data: Literal[False], 

374 **kwargs: Any, 

375) -> NDArrayReal: ... 

376 

377 

378@typing.overload 

379@required("OpenImageIO") 

380def read_image_OpenImageIO( 

381 path: str | PathLike, 

382 bit_depth: Literal["uint8", "uint16", "float16", "float32", "float64", "float128"], 

383 additional_data: Literal[False], 

384 **kwargs: Any, 

385) -> NDArrayReal: ... 

386 

387 

388@required("OpenImageIO") 

389def read_image_OpenImageIO( 

390 path: str | PathLike, 

391 bit_depth: Literal[ 

392 "uint8", "uint16", "float16", "float32", "float64", "float128" 

393 ] = "float32", 

394 additional_data: bool = False, 

395 **kwargs: Any, 

396) -> NDArrayReal | Tuple[NDArrayReal, Tuple[Image_Specification_Attribute, ...]]: 

397 """ 

398 Read image data from the specified path using *OpenImageIO*. 

399 

400 Load image data from the file system with support for various bit-depth 

401 formats. The bit-depth conversion behaviour is controlled by 

402 *OpenImageIO*, with this function performing only the final type 

403 conversion after reading. 

404 

405 Parameters 

406 ---------- 

407 path 

408 Path to the image file. 

409 bit_depth 

410 Target bit-depth for the returned image data. The bit-depth 

411 conversion is handled by *OpenImageIO* during the read operation, 

412 with this function converting to the appropriate *NumPy* data type 

413 afterwards. 

414 additional_data 

415 Whether to return additional metadata from the image file. 

416 

417 Returns 

418 ------- 

419 :class:`numpy.ndarray` or :class:`tuple` 

420 Image data as an array when ``additional_data`` is ``False``, or a 

421 tuple containing the image data and a tuple of 

422 :class:`colour.io.Image_Specification_Attribute` instances when 

423 ``additional_data`` is ``True``. 

424 

425 Notes 

426 ----- 

427 - For convenience, single channel images are squeezed to 2D arrays. 

428 

429 Examples 

430 -------- 

431 >>> import os 

432 >>> import colour 

433 >>> path = os.path.join( 

434 ... colour.__path__[0], 

435 ... "io", 

436 ... "tests", 

437 ... "resources", 

438 ... "CMS_Test_Pattern.exr", 

439 ... ) 

440 >>> image = read_image_OpenImageIO(path) # doctest: +SKIP 

441 """ 

442 

443 from OpenImageIO import ImageInput # noqa: PLC0415 

444 

445 path = str(path) 

446 

447 kwargs = handle_arguments_deprecation( 

448 { 

449 "ArgumentRenamed": [["attributes", "additional_data"]], 

450 }, 

451 **kwargs, 

452 ) 

453 

454 additional_data = kwargs.get("additional_data", additional_data) 

455 

456 bit_depth_specification = MAPPING_BIT_DEPTH[bit_depth] 

457 

458 image_input = ImageInput.open(path) 

459 image_specification = image_input.spec() 

460 

461 shape = ( 

462 image_specification.height, 

463 image_specification.width, 

464 image_specification.nchannels, 

465 ) 

466 

467 image = image_input.read_image(bit_depth_specification.openimageio) 

468 image_input.close() 

469 

470 image = np.reshape(np.array(image, dtype=bit_depth_specification.numpy), shape) 

471 image = cast("NDArrayReal", np.squeeze(image)) 

472 

473 if additional_data: 

474 extra_attributes = [ 

475 Image_Specification_Attribute( 

476 attribute.name, attribute.value, attribute.type 

477 ) 

478 for attribute in image_specification.extra_attribs 

479 ] 

480 

481 return image, tuple(extra_attributes) 

482 

483 return image 

484 

485 

486@required("Imageio") 

487def read_image_Imageio( 

488 path: str | PathLike, 

489 bit_depth: Literal[ 

490 "uint8", "uint16", "float16", "float32", "float64", "float128" 

491 ] = "float32", 

492 **kwargs: Any, 

493) -> NDArrayReal: 

494 """ 

495 Read image data from the specified path using *Imageio*. 

496 

497 Parameters 

498 ---------- 

499 path 

500 Path to the image file. 

501 bit_depth 

502 Target bit-depth for the returned image data. The image data is 

503 converted with :func:`colour.io.convert_bit_depth` definition after 

504 reading the image. 

505 

506 Other Parameters 

507 ---------------- 

508 kwargs 

509 Keywords arguments. 

510 

511 Returns 

512 ------- 

513 :class:`numpy.ndarray` 

514 Image data. 

515 

516 Notes 

517 ----- 

518 - For convenience, single channel images are squeezed to 2D arrays. 

519 

520 Examples 

521 -------- 

522 >>> import os 

523 >>> import colour 

524 >>> path = os.path.join( 

525 ... colour.__path__[0], 

526 ... "io", 

527 ... "tests", 

528 ... "resources", 

529 ... "CMS_Test_Pattern.exr", 

530 ... ) 

531 >>> image = read_image_Imageio(path) 

532 >>> image.shape # doctest: +SKIP 

533 (1267, 1274, 3) 

534 >>> image.dtype 

535 dtype('float32') 

536 """ 

537 

538 from imageio.v2 import imread # noqa: PLC0415 

539 

540 path = str(path) 

541 

542 image = np.squeeze(imread(path, **kwargs)) 

543 

544 return convert_bit_depth(image, bit_depth) 

545 

546 

547READ_IMAGE_METHODS: CanonicalMapping = CanonicalMapping( 

548 { 

549 "Imageio": read_image_Imageio, 

550 "OpenImageIO": read_image_OpenImageIO, 

551 } 

552) 

553READ_IMAGE_METHODS.__doc__ = """ 

554Supported image reading methods. 

555""" 

556 

557 

558def read_image( 

559 path: str | PathLike, 

560 bit_depth: Literal[ 

561 "uint8", "uint16", "float16", "float32", "float64", "float128" 

562 ] = "float32", 

563 method: Literal["Imageio", "OpenImageIO"] | str = "OpenImageIO", 

564 **kwargs: Any, 

565) -> NDArrayReal: 

566 """ 

567 Read image data from the specified path. 

568 

569 Load and optionally convert image data from various formats, 

570 supporting multiple bit-depth conversions and backend libraries for 

571 flexible image I/O operations in colour science workflows. 

572 

573 Parameters 

574 ---------- 

575 path 

576 Path to the image file. 

577 bit_depth 

578 Target bit-depth for the returned image data. For the *Imageio* 

579 method, image data is converted using 

580 :func:`colour.io.convert_bit_depth` after reading. For the 

581 *OpenImageIO* method, bit-depth conversion is handled by the 

582 library with this parameter controlling only the final data type. 

583 method 

584 Image reading backend library. Defaults to *OpenImageIO* with 

585 automatic fallback to *Imageio* if unavailable. 

586 

587 Other Parameters 

588 ---------------- 

589 additional_data 

590 {:func:`colour.io.read_image_OpenImageIO`}, 

591 Whether to return additional metadata with the image data. 

592 

593 Returns 

594 ------- 

595 :class:`numpy.ndarray` 

596 Image data as a NumPy array with the specified bit-depth. 

597 

598 Notes 

599 ----- 

600 - If the specified method is *OpenImageIO* but the library is not 

601 available, reading will be performed by *Imageio*. 

602 - If the specified method is *Imageio*, ``kwargs`` is passed 

603 directly to the wrapped definition. 

604 - For convenience, single channel images are squeezed to 2D arrays. 

605 

606 Examples 

607 -------- 

608 >>> import os 

609 >>> import colour 

610 >>> path = os.path.join( 

611 ... colour.__path__[0], 

612 ... "io", 

613 ... "tests", 

614 ... "resources", 

615 ... "CMS_Test_Pattern.exr", 

616 ... ) 

617 >>> image = read_image(path) 

618 >>> image.shape # doctest: +SKIP 

619 (1267, 1274, 3) 

620 >>> image.dtype 

621 dtype('float32') 

622 """ 

623 

624 if method.lower() == "imageio" and not is_imageio_installed(): # pragma: no cover 

625 usage_warning( 

626 '"Imageio" related API features are not available, ' 

627 'switching to "OpenImageIO"!' 

628 ) 

629 method = "openimageio" 

630 

631 method = validate_method(method, tuple(READ_IMAGE_METHODS)) 

632 

633 function = READ_IMAGE_METHODS[method] 

634 

635 if method == "openimageio": # pragma: no cover 

636 kwargs = filter_kwargs(function, **kwargs) 

637 

638 return function(path, bit_depth, **kwargs) 

639 

640 

641@required("OpenImageIO") 

642def write_image_OpenImageIO( 

643 image: ArrayLike, 

644 path: str | PathLike, 

645 bit_depth: Literal[ 

646 "uint8", "uint16", "float16", "float32", "float64", "float128" 

647 ] = "float32", 

648 attributes: Sequence | None = None, 

649) -> bool: 

650 """ 

651 Write image data to the specified path using *OpenImageIO*. 

652 

653 Parameters 

654 ---------- 

655 image 

656 Image data to write. 

657 path 

658 Path to the image file. 

659 bit_depth 

660 Bit-depth to write the image at. The bit-depth conversion behaviour 

661 is ruled directly by *OpenImageIO*. 

662 attributes 

663 An array of :class:`colour.io.Image_Specification_Attribute` class 

664 instances used to set attributes of the image. 

665 

666 Returns 

667 ------- 

668 :class:`bool` 

669 Definition success. 

670 

671 Examples 

672 -------- 

673 Basic image writing: 

674 

675 >>> import os 

676 >>> import colour 

677 >>> path = os.path.join( 

678 ... colour.__path__[0], 

679 ... "io", 

680 ... "tests", 

681 ... "resources", 

682 ... "CMS_Test_Pattern.exr", 

683 ... ) 

684 >>> image = read_image(path) # doctest: +SKIP 

685 >>> path = os.path.join( 

686 ... colour.__path__[0], 

687 ... "io", 

688 ... "tests", 

689 ... "resources", 

690 ... "CMSTestPattern.tif", 

691 ... ) 

692 >>> write_image_OpenImageIO(image, path) # doctest: +SKIP 

693 True 

694 

695 Advanced image writing while setting attributes: 

696 

697 >>> compression = Image_Specification_Attribute("Compression", "none") 

698 >>> write_image_OpenImageIO(image, path, "uint8", [compression]) 

699 ... # doctest: +SKIP 

700 True 

701 

702 Writing an "ACES" compliant "EXR" file: 

703 

704 >>> from OpenImageIO import TypeDesc 

705 >>> chromaticities = ( 

706 ... 0.7347, 

707 ... 0.2653, 

708 ... 0.0, 

709 ... 1.0, 

710 ... 0.0001, 

711 ... -0.077, 

712 ... 0.32168, 

713 ... 0.33767, 

714 ... ) 

715 >>> attributes = [ 

716 ... Image_Specification_Attribute("openexr:ACESContainerPolicy", "relaxed"), 

717 ... Image_Specification_Attribute( 

718 ... "chromaticities", chromaticities, TypeDesc("float[8]") 

719 ... ), 

720 ... Image_Specification_Attribute("compression", "none"), 

721 ... ] 

722 >>> write_image_OpenImageIO(image, path, attributes=attributes) # doctest: +SKIP 

723 True 

724 

725 Notes 

726 ----- 

727 - When using ``openexr:ACESContainerPolicy`` with ``relaxed`` mode, 

728 *OpenImageIO* automatically sets the ``colorInteropId`` attribute to 

729 ``lin_ap0_scene`` for ACES-compliant files. 

730 - The ``acesImageContainerFlag`` attribute should not be set manually 

731 in *OpenImageIO* 3.1.7.0+, as it triggers strict ACES validation. 

732 Use ``openexr:ACESContainerPolicy`` instead. 

733 """ 

734 

735 from OpenImageIO import ImageOutput # noqa: PLC0415 

736 

737 image = as_float_array(image) 

738 path = str(path) 

739 

740 attributes = cast("list", optional(attributes, [])) 

741 

742 bit_depth_specification = MAPPING_BIT_DEPTH[bit_depth] 

743 

744 if bit_depth_specification.numpy in [np.uint8, np.uint16]: 

745 minimum, maximum = ( 

746 np.iinfo(bit_depth_specification.numpy).min, 

747 np.iinfo(bit_depth_specification.numpy).max, 

748 ) 

749 image = np.clip(image * maximum, minimum, maximum) 

750 

751 image = as_int_array(image, bit_depth_specification.numpy) 

752 

753 image = image.astype(bit_depth_specification.numpy) 

754 

755 if image.ndim == 2: 

756 height, width = image.shape 

757 channels = 1 

758 else: 

759 height, width, channels = image.shape 

760 

761 image_specification = image_specification_OpenImageIO( 

762 width, height, channels, bit_depth, attributes 

763 ) 

764 

765 image_output = ImageOutput.create(path) 

766 

767 image_output.open(path, image_specification) # pyright: ignore 

768 success = image_output.write_image(image) 

769 

770 image_output.close() 

771 

772 return success 

773 

774 

775@required("Imageio") 

776def write_image_Imageio( 

777 image: ArrayLike, 

778 path: str | PathLike, 

779 bit_depth: Literal[ 

780 "uint8", "uint16", "float16", "float32", "float64", "float128" 

781 ] = "float32", 

782 **kwargs: Any, 

783) -> bytes | None: 

784 """ 

785 Write image data to the specified path using *Imageio*. 

786 

787 Parameters 

788 ---------- 

789 image 

790 Image data to write. 

791 path 

792 Path to the image file. 

793 bit_depth 

794 Bit-depth to write the image at. The image data is converted with 

795 :func:`colour.io.convert_bit_depth` definition prior to writing. 

796 

797 Other Parameters 

798 ---------------- 

799 kwargs 

800 Keywords arguments passed to the underlying *Imageio* ``imwrite`` 

801 function. 

802 

803 Returns 

804 ------- 

805 :class:`bytes` or :py:data:`None` 

806 Image data written as bytes if successful, :py:data:`None` 

807 otherwise. 

808 

809 Notes 

810 ----- 

811 - Control how images are saved by the *Freeimage* backend using the 

812 ``flags`` keyword argument with desired values. See the *Load / 

813 Save flag constants* section in 

814 https://sourceforge.net/p/freeimage/svn/HEAD/tree/FreeImage/trunk/Source/FreeImage.h 

815 

816 Examples 

817 -------- 

818 >>> import os 

819 >>> import colour 

820 >>> path = os.path.join( 

821 ... colour.__path__[0], 

822 ... "io", 

823 ... "tests", 

824 ... "resources", 

825 ... "CMS_Test_Pattern.exr", 

826 ... ) 

827 >>> image = read_image(path) # doctest: +SKIP 

828 >>> path = os.path.join( 

829 ... colour.__path__[0], 

830 ... "io", 

831 ... "tests", 

832 ... "resources", 

833 ... "CMSTestPattern.tif", 

834 ... ) 

835 >>> write_image_Imageio(image, path) # doctest: +SKIP 

836 True 

837 """ 

838 

839 from imageio.v2 import imwrite # noqa: PLC0415 

840 

841 path = str(path) 

842 

843 if all( 

844 [ 

845 path.lower().endswith(".exr"), 

846 bit_depth in ("float32", "float64", "float128"), 

847 ] 

848 ): 

849 # Ensures that "OpenEXR" images are saved as "Float32" according to the 

850 # image bit-depth. 

851 kwargs["flags"] = 0x0001 

852 

853 image = convert_bit_depth(image, bit_depth) 

854 

855 return imwrite(path, image, **kwargs) 

856 

857 

858WRITE_IMAGE_METHODS: CanonicalMapping = CanonicalMapping( 

859 { 

860 "Imageio": write_image_Imageio, 

861 "OpenImageIO": write_image_OpenImageIO, 

862 } 

863) 

864WRITE_IMAGE_METHODS.__doc__ = """ 

865Supported image writing methods. 

866""" 

867 

868 

869def write_image( 

870 image: ArrayLike, 

871 path: str | PathLike, 

872 bit_depth: Literal[ 

873 "uint8", "uint16", "float16", "float32", "float64", "float128" 

874 ] = "float32", 

875 method: Literal["Imageio", "OpenImageIO"] | str = "OpenImageIO", 

876 **kwargs: Any, 

877) -> bool: 

878 """ 

879 Write image data to the specified path. 

880 

881 Parameters 

882 ---------- 

883 image 

884 Image data to write. 

885 path 

886 Path to the image file. 

887 bit_depth 

888 Bit-depth to write the image at. For the *Imageio* method, the 

889 image data is converted with :func:`colour.io.convert_bit_depth` 

890 definition prior to writing the image. 

891 method 

892 Image writing backend library. 

893 

894 Other Parameters 

895 ---------------- 

896 attributes 

897 {:func:`colour.io.write_image_OpenImageIO`}, 

898 An array of :class:`colour.io.Image_Specification_Attribute` class 

899 instances used to set attributes of the image. 

900 

901 Returns 

902 ------- 

903 :class:`bool` 

904 Definition success. 

905 

906 Notes 

907 ----- 

908 - If the specified method is *OpenImageIO* but the library is not 

909 available writing will be performed by *Imageio*. 

910 - If the specified method is *Imageio*, ``kwargs`` is passed directly 

911 to the wrapped definition. 

912 - It is possible to control how the images are saved by the 

913 *Freeimage* backend by using the ``flags`` keyword argument and 

914 passing a desired value. See the *Load / Save flag constants* 

915 section in 

916 https://sourceforge.net/p/freeimage/svn/HEAD/tree/FreeImage/trunk/Source/FreeImage.h 

917 

918 Examples 

919 -------- 

920 Basic image writing: 

921 

922 >>> import os 

923 >>> import colour 

924 >>> path = os.path.join( 

925 ... colour.__path__[0], 

926 ... "io", 

927 ... "tests", 

928 ... "resources", 

929 ... "CMS_Test_Pattern.exr", 

930 ... ) 

931 >>> image = read_image(path) # doctest: +SKIP 

932 >>> path = os.path.join( 

933 ... colour.__path__[0], 

934 ... "io", 

935 ... "tests", 

936 ... "resources", 

937 ... "CMSTestPattern.tif", 

938 ... ) 

939 >>> write_image(image, path) # doctest: +SKIP 

940 True 

941 

942 Advanced image writing while setting attributes using *OpenImageIO*: 

943 

944 >>> compression = Image_Specification_Attribute("Compression", "none") 

945 >>> write_image(image, path, bit_depth="uint8", attributes=[compression]) 

946 ... # doctest: +SKIP 

947 True 

948 """ 

949 

950 if method.lower() == "imageio" and not is_imageio_installed(): # pragma: no cover 

951 usage_warning( 

952 '"Imageio" related API features are not available, ' 

953 'switching to "OpenImageIO"!' 

954 ) 

955 method = "openimageio" 

956 

957 method = validate_method(method, tuple(WRITE_IMAGE_METHODS)) 

958 

959 function = WRITE_IMAGE_METHODS[method] 

960 

961 if method == "openimageio": # pragma: no cover 

962 kwargs = filter_kwargs(function, **kwargs) 

963 

964 return function(image, path, bit_depth, **kwargs) 

965 

966 

967def as_3_channels_image(a: ArrayLike) -> NDArrayFloat: 

968 """ 

969 Convert the specified array :math:`a` to a 3-channel image-like 

970 representation. 

971 

972 Parameters 

973 ---------- 

974 a 

975 Array :math:`a` to convert to a 3-channel image-like representation. 

976 

977 Returns 

978 ------- 

979 :class:`numpy.ndarray` 

980 3-channel image-like representation of array :math:`a`. 

981 

982 Raises 

983 ------ 

984 ValueError 

985 If the array has more than 3 dimensions or more than 1 or 3 channels. 

986 

987 Examples 

988 -------- 

989 >>> as_3_channels_image(0.18) 

990 array([[[ 0.18, 0.18, 0.18]]]) 

991 >>> as_3_channels_image([0.18]) 

992 array([[[ 0.18, 0.18, 0.18]]]) 

993 >>> as_3_channels_image([0.18, 0.18, 0.18]) 

994 array([[[ 0.18, 0.18, 0.18]]]) 

995 >>> as_3_channels_image([[0.18, 0.18, 0.18]]) 

996 array([[[ 0.18, 0.18, 0.18]]]) 

997 >>> as_3_channels_image([[[0.18, 0.18, 0.18]]]) 

998 array([[[ 0.18, 0.18, 0.18]]]) 

999 >>> as_3_channels_image([[[[0.18, 0.18, 0.18]]]]) 

1000 array([[[ 0.18, 0.18, 0.18]]]) 

1001 """ 

1002 

1003 a = np.squeeze(as_float_array(a)) 

1004 

1005 if len(a.shape) > 3: 

1006 error = ( 

1007 "Array has more than 3-dimensions and cannot be converted to a " 

1008 "3-channels image-like representation!" 

1009 ) 

1010 

1011 raise ValueError(error) 

1012 

1013 if len(a.shape) > 0 and a.shape[-1] not in (1, 3): 

1014 error = ( 

1015 "Array has more than 1 or 3 channels and cannot be converted to a " 

1016 "3-channels image-like representation!" 

1017 ) 

1018 

1019 raise ValueError(error) 

1020 

1021 if len(a.shape) == 0 or a.shape[-1] == 1: 

1022 a = tstack([a, a, a]) 

1023 

1024 if len(a.shape) == 1: 

1025 a = a[None, None, ...] 

1026 elif len(a.shape) == 2: 

1027 a = a[None, ...] 

1028 

1029 return a