| # SPDX-License-Identifier: Apache-2.0 |
| # ----------------------------------------------------------------------------- |
| # Copyright 2019-2020 Arm Limited |
| # |
| # Licensed under the Apache License, Version 2.0 (the "License"); you may not |
| # use this file except in compliance with the License. You may obtain a copy |
| # of the License at: |
| # |
| # http://www.apache.org/licenses/LICENSE-2.0 |
| # |
| # Unless required by applicable law or agreed to in writing, software |
| # distributed under the License is distributed on an "AS IS" BASIS, WITHOUT |
| # WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the |
| # License for the specific language governing permissions and limitations |
| # under the License. |
| # ----------------------------------------------------------------------------- |
| """ |
| This module contains code for loading image metadata from a file path on disk. |
| |
| The directory path is structured: |
| |
| TestSetName/TestFormat/FileName |
| |
| ... and the file name is structured: |
| |
| colorProfile-colorFormat-name[-flags].extension |
| """ |
| |
| from collections.abc import Iterable |
| import os |
| import re |
| import subprocess as sp |
| |
| from PIL import Image as PILImage |
| |
| import testlib.misc as misc |
| |
| |
| CONVERT_BINARY = ["convert"] |
| |
| |
| g_ConvertVersion = None |
| |
| |
| def get_convert_version(): |
| """ |
| Get the major/minor version of ImageMagick on the system. |
| """ |
| global g_ConvertVersion |
| |
| if g_ConvertVersion is None: |
| command = list(CONVERT_BINARY) |
| command += ["--version"] |
| result = sp.run(command, stdout=sp.PIPE, stderr=sp.PIPE, |
| check=True, encoding="utf-8") |
| |
| # Version is top row |
| version = result.stdout.splitlines()[0] |
| # ... third token |
| version = re.split(" ", version)[2] |
| # ... major/minor/patch/subpatch |
| version = re.split("\\.|-", version) |
| |
| numericVersion = float(version[0]) |
| numericVersion += float(version[1]) / 10.0 |
| |
| g_ConvertVersion = numericVersion |
| |
| return g_ConvertVersion |
| |
| |
| class ImageException(Exception): |
| """ |
| Exception thrown for bad image specification. |
| """ |
| |
| |
| class TestImage(): |
| """ |
| Objects of this type contain metadata for a single test image on disk. |
| |
| Attributes: |
| filePath: The path of the file on disk. |
| outFilePath: The path of the output file on disk. |
| testSet: The name of the test set. |
| testFormat: The test format group. |
| testFile: The test file name. |
| colorProfile: The image compression color profile. |
| colorFormat: The image color format. |
| name: The image human name. |
| is3D: True if the image is 3D, else False. |
| isMask: True if the image is a non-correlated mask texture, else False. |
| isAlphaScaled: True if the image wants alpha scaling, else False. |
| TEST_EXTS: Expected test image extensions. |
| PROFILES: Tuple of valid color profile values. |
| FORMATS: Tuple of valid color format values. |
| FLAGS: Map of valid flags (key) and their meaning (value). |
| """ |
| TEST_EXTS = (".jpg", ".png", ".tga", ".dds", ".hdr") |
| |
| PROFILES = ("ldr", "ldrs", "hdr") |
| |
| FORMATS = ("l", "la", "xy", "rgb", "rgba") |
| |
| FLAGS = { |
| # Flags for image compression control |
| "3": "3D image", |
| "m": "Mask image", |
| "a": "Alpha scaled image" |
| } |
| |
| def __init__(self, filePath): |
| """ |
| Create a new image definition, based on a structured file path. |
| |
| Args: |
| filePath (str): The path of the image on disk. |
| |
| Raises: |
| ImageException: The image couldn't be found or is unstructured. |
| """ |
| self.filePath = os.path.abspath(filePath) |
| if not os.path.exists(self.filePath): |
| raise ImageException("Image doesn't exist (%s)" % filePath) |
| |
| # Decode the path |
| scriptDir = os.path.dirname(__file__) |
| rootInDir = os.path.join(scriptDir, "..", "Images") |
| partialPath = os.path.relpath(self.filePath, rootInDir) |
| parts = misc.path_splitall(partialPath) |
| if len(parts) != 3: |
| raise ImageException("Image path not path triplet (%s)" % parts) |
| self.testSet = parts[0] |
| self.testFormat = parts[1] |
| self.testFile = parts[2] |
| |
| # Decode the file name |
| self.decode_file_name(self.testFile) |
| |
| # Output file path (store base without extension) |
| rootOutDir = os.path.join(scriptDir, "..", "..", "TestOutput") |
| outFilePath = os.path.join(rootOutDir, partialPath) |
| outFilePath = os.path.abspath(outFilePath) |
| outFilePath = os.path.splitext(outFilePath)[0] |
| self.outFilePath = outFilePath |
| |
| def decode_file_name(self, fileName): |
| """ |
| Utility function to decode metadata from an encoded file name. |
| |
| Args: |
| fileName (str): The file name to tokenize. |
| |
| Raises: |
| ImageException: The image file path is badly structured. |
| """ |
| # Strip off the extension |
| rootName = os.path.splitext(fileName)[0] |
| |
| parts = rootName.split("-") |
| |
| # Decode the mandatory fields |
| if len(parts) >= 3: |
| self.colorProfile = parts[0] |
| if self.colorProfile not in self.PROFILES: |
| raise ImageException("Unknown color profile (%s)" % parts[0]) |
| |
| self.colorFormat = parts[1] |
| if self.colorFormat not in self.FORMATS: |
| raise ImageException("Unknown color format (%s)" % parts[1]) |
| |
| # Consistency check between directory and file names |
| reencode = "%s-%s" % (self.colorProfile, self.colorFormat) |
| compare = self.testFormat.lower() |
| if reencode != compare: |
| dat = (self.testFormat, reencode) |
| raise ImageException("Mismatched test and image (%s:%s)" % dat) |
| |
| self.name = parts[2] |
| |
| # Set default values for the optional fields |
| self.is3D = False |
| self.isMask = False |
| self.isAlphaScaled = False |
| |
| # Decode the flags field if present |
| if len(parts) >= 4: |
| flags = parts[3] |
| seenFlags = set() |
| for flag in flags: |
| if flag in seenFlags: |
| raise ImageException("Duplicate flag (%s)" % flag) |
| if flag not in self.FLAGS: |
| raise ImageException("Unknown flag (%s)" % flag) |
| seenFlags.add(flag) |
| |
| self.is3D = "3" in seenFlags |
| self.isMask = "m" in seenFlags |
| self.isAlphaScaled = "a" in seenFlags |
| |
| def get_size(self): |
| """ |
| Get the dimensions of this test image, if format is known. |
| |
| Known cases today where the format is not known: |
| |
| * 3D .dds files. |
| * Any .ktx, .hdr, .exr, or .astc file. |
| |
| Returns: |
| tuple(int, int): The dimensions of a 2D image, or ``None`` if PIL |
| could not open the file. |
| """ |
| try: |
| img = PILImage.open(self.filePath) |
| except IOError: |
| # HDR files |
| return None |
| except NotImplementedError: |
| # DDS files |
| return None |
| |
| return (img.size[0], img.size[1]) |
| |
| |
| class Image(): |
| """ |
| Wrapper around an image on the file system. |
| """ |
| |
| # TODO: We don't support KTX yet, as ImageMagick doesn't. |
| SUPPORTED_LDR = ["bmp", "jpg", "png", "tga"] |
| SUPPORTED_HDR = ["exr", "hdr"] |
| |
| @classmethod |
| def is_format_supported(cls, fileFormat, profile=None): |
| """ |
| Test if a given file format is supported by the library. |
| |
| Args: |
| fileFormat (str): The file extension (excluding the "."). |
| profile (str or None): The profile (ldr or hdr) of the image. |
| |
| Returns: |
| bool: `True` if the image is supported, `False` otherwise. |
| """ |
| assert profile in [None, "ldr", "hdr"] |
| |
| if profile == "ldr": |
| return fileFormat in cls.SUPPORTED_LDR |
| |
| if profile == "hdr": |
| return fileFormat in cls.SUPPORTED_HDR |
| |
| return fileFormat in cls.SUPPORTED_LDR or \ |
| fileFormat in cls.SUPPORTED_HDR |
| |
| def __init__(self, filePath): |
| """ |
| Construct a new Image. |
| |
| Args: |
| filePath (str): The path to the image on disk. |
| """ |
| convert = get_convert_version() |
| |
| # ImageMagick 7 started to use .tga file origin information. By default |
| # TGA files store data from bottom up, and define the origin as bottom |
| # left. We want our color samples to always use a top left origin, even |
| # if the data is stored in alternative layout. |
| self.invertYCoords = (convert >= 7.0) and filePath.endswith(".tga") |
| |
| self.filePath = filePath |
| self.proxyPath = None |
| |
| def get_colors(self, coords): |
| """ |
| Get the image colors at the given coordinate. |
| |
| Args: |
| coords (tuple or list): A single coordinate, or a list of |
| coordinates to sample. |
| |
| Returns: |
| tuple: A single sample color (if `coords` was a coordinate). |
| list: A list of sample colors (if `coords` was a list). |
| |
| Colors are returned as float values between 0.0 and 1.0 for LDR, |
| and float values which may exceed 1.0 for HDR. |
| """ |
| colors = [] |
| |
| # We accept both a list of positions and a single position; |
| # canonicalize here so the main processing only handles lists |
| isList = len(coords) != 0 and isinstance(coords[0], Iterable) |
| |
| if not isList: |
| coords = [coords] |
| |
| for (x, y) in coords: |
| command = list(CONVERT_BINARY) |
| command += [self.filePath] |
| |
| # Invert coordinates if the format needs it |
| if self.invertYCoords: |
| command += ["-flip"] |
| |
| command += [ |
| "-format", "%%[pixel:p{%u,%u}]" % (x, y), |
| "info:" |
| ] |
| |
| if os.name == 'nt': |
| command.insert(0, "magick") |
| |
| result = sp.run(command, stdout=sp.PIPE, stderr=sp.PIPE, |
| check=True, universal_newlines=True) |
| |
| rawcolor = result.stdout.strip() |
| |
| # Decode ImageMagick's annoying named color outputs. Note that this |
| # only handles "known" cases triggered by our test images, we don't |
| # support the entire ImageMagick named color table. |
| if rawcolor == "black": |
| colors.append([0.0, 0.0, 0.0, 1.0]) |
| elif rawcolor == "white": |
| colors.append([1.0, 1.0, 1.0, 1.0]) |
| elif rawcolor == "red": |
| colors.append([1.0, 0.0, 0.0, 1.0]) |
| elif rawcolor == "blue": |
| colors.append([0.0, 0.0, 1.0, 1.0]) |
| |
| # Decode ImageMagick's format tuples |
| elif rawcolor.startswith("srgba"): |
| rawcolor = rawcolor[6:] |
| rawcolor = rawcolor[:-1] |
| channels = rawcolor.split(",") |
| for i, channel in enumerate(channels): |
| if (i < 3) and channel.endswith("%"): |
| channels[i] = float(channel[:-1]) / 100.0 |
| elif (i < 3) and not channel.endswith("%"): |
| channels[i] = float(channel) / 255.0 |
| else: |
| channels[i] = float(channel) |
| colors.append(channels) |
| elif rawcolor.startswith("srgb"): |
| rawcolor = rawcolor[5:] |
| rawcolor = rawcolor[:-1] |
| channels = rawcolor.split(",") |
| for i, channel in enumerate(channels): |
| if (i < 3) and channel.endswith("%"): |
| channels[i] = float(channel[:-1]) / 100.0 |
| if (i < 3) and not channel.endswith("%"): |
| channels[i] = float(channel) / 255.0 |
| channels.append(1.0) |
| colors.append(channels) |
| elif rawcolor.startswith("rgba"): |
| rawcolor = rawcolor[5:] |
| rawcolor = rawcolor[:-1] |
| channels = rawcolor.split(",") |
| for i, channel in enumerate(channels): |
| if (i < 3) and channel.endswith("%"): |
| channels[i] = float(channel[:-1]) / 100.0 |
| elif (i < 3) and not channel.endswith("%"): |
| channels[i] = float(channel) / 255.0 |
| else: |
| channels[i] = float(channel) |
| colors.append(channels) |
| elif rawcolor.startswith("rgb"): |
| rawcolor = rawcolor[4:] |
| rawcolor = rawcolor[:-1] |
| channels = rawcolor.split(",") |
| for i, channel in enumerate(channels): |
| if (i < 3) and channel.endswith("%"): |
| channels[i] = float(channel[:-1]) / 100.0 |
| if (i < 3) and not channel.endswith("%"): |
| channels[i] = float(channel) / 255.0 |
| channels.append(1.0) |
| colors.append(channels) |
| else: |
| print(x, y) |
| print(rawcolor) |
| assert False |
| |
| # ImageMagick decodes DDS files as BGRA not RGBA; manually correct |
| if self.filePath.endswith("dds"): |
| for color in colors: |
| tmp = color[0] |
| color[0] = color[2] |
| color[2] = tmp |
| |
| # ImageMagick decodes EXR files with premult alpha; manually correct |
| if self.filePath.endswith("exr"): |
| for color in colors: |
| color[0] /= color[3] |
| color[1] /= color[3] |
| color[2] /= color[3] |
| |
| # Undo list canonicalization if we were given a single scalar coord |
| if not isList: |
| return colors[0] |
| |
| return colors |