• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# SPDX-License-Identifier: Apache-2.0
2# -----------------------------------------------------------------------------
3# Copyright 2019-2020 Arm Limited
4#
5# Licensed under the Apache License, Version 2.0 (the "License"); you may not
6# use this file except in compliance with the License. You may obtain a copy
7# of the License at:
8#
9#     http://www.apache.org/licenses/LICENSE-2.0
10#
11# Unless required by applicable law or agreed to in writing, software
12# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
13# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
14# License for the specific language governing permissions and limitations
15# under the License.
16# -----------------------------------------------------------------------------
17"""
18This module contains code for loading image metadata from a file path on disk.
19
20The directory path is structured:
21
22    TestSetName/TestFormat/FileName
23
24... and the file name is structured:
25
26    colorProfile-colorFormat-name[-flags].extension
27"""
28
29from collections.abc import Iterable
30import os
31import re
32import subprocess as sp
33
34from PIL import Image as PILImage
35
36import testlib.misc as misc
37
38
39CONVERT_BINARY = ["convert"]
40
41
42g_ConvertVersion = None
43
44
45def get_convert_version():
46    """
47    Get the major/minor version of ImageMagick on the system.
48    """
49    global g_ConvertVersion
50
51    if g_ConvertVersion is None:
52        command = list(CONVERT_BINARY)
53        command += ["--version"]
54        result = sp.run(command, stdout=sp.PIPE, stderr=sp.PIPE,
55                        check=True, encoding="utf-8")
56
57        # Version is top row
58        version = result.stdout.splitlines()[0]
59        # ... third token
60        version = re.split(" ", version)[2]
61        # ... major/minor/patch/subpatch
62        version = re.split("\\.|-", version)
63
64        numericVersion = float(version[0])
65        numericVersion += float(version[1]) / 10.0
66
67        g_ConvertVersion = numericVersion
68
69    return g_ConvertVersion
70
71
72class ImageException(Exception):
73    """
74    Exception thrown for bad image specification.
75    """
76
77
78class TestImage():
79    """
80    Objects of this type contain metadata for a single test image on disk.
81
82    Attributes:
83        filePath: The path of the file on disk.
84        outFilePath: The path of the output file on disk.
85        testSet: The name of the test set.
86        testFormat: The test format group.
87        testFile: The test file name.
88        colorProfile: The image compression color profile.
89        colorFormat: The image color format.
90        name: The image human name.
91        is3D: True if the image is 3D, else False.
92        isMask: True if the image is a non-correlated mask texture, else False.
93        isAlphaScaled: True if the image wants alpha scaling, else False.
94        TEST_EXTS: Expected test image extensions.
95        PROFILES: Tuple of valid color profile values.
96        FORMATS: Tuple of valid color format values.
97        FLAGS: Map of valid flags (key) and their meaning (value).
98    """
99    TEST_EXTS = (".jpg", ".png", ".tga", ".dds", ".hdr")
100
101    PROFILES = ("ldr", "ldrs", "hdr")
102
103    FORMATS = ("l", "la", "xy", "rgb", "rgba")
104
105    FLAGS = {
106        # Flags for image compression control
107        "3": "3D image",
108        "m": "Mask image",
109        "a": "Alpha scaled image"
110    }
111
112    def __init__(self, filePath):
113        """
114        Create a new image definition, based on a structured file path.
115
116        Args:
117            filePath (str): The path of the image on disk.
118
119        Raises:
120            ImageException: The image couldn't be found or is unstructured.
121        """
122        self.filePath = os.path.abspath(filePath)
123        if not os.path.exists(self.filePath):
124            raise ImageException("Image doesn't exist (%s)" % filePath)
125
126        # Decode the path
127        scriptDir = os.path.dirname(__file__)
128        rootInDir = os.path.join(scriptDir, "..", "Images")
129        partialPath = os.path.relpath(self.filePath, rootInDir)
130        parts = misc.path_splitall(partialPath)
131        if len(parts) != 3:
132            raise ImageException("Image path not path triplet (%s)" % parts)
133        self.testSet = parts[0]
134        self.testFormat = parts[1]
135        self.testFile = parts[2]
136
137        # Decode the file name
138        self.decode_file_name(self.testFile)
139
140        # Output file path (store base without extension)
141        rootOutDir = os.path.join(scriptDir, "..", "..", "TestOutput")
142        outFilePath = os.path.join(rootOutDir, partialPath)
143        outFilePath = os.path.abspath(outFilePath)
144        outFilePath = os.path.splitext(outFilePath)[0]
145        self.outFilePath = outFilePath
146
147    def decode_file_name(self, fileName):
148        """
149        Utility function to decode metadata from an encoded file name.
150
151        Args:
152            fileName (str): The file name to tokenize.
153
154        Raises:
155            ImageException: The image file path is badly structured.
156        """
157        # Strip off the extension
158        rootName = os.path.splitext(fileName)[0]
159
160        parts = rootName.split("-")
161
162        # Decode the mandatory fields
163        if len(parts) >= 3:
164            self.colorProfile = parts[0]
165            if self.colorProfile not in self.PROFILES:
166                raise ImageException("Unknown color profile (%s)" % parts[0])
167
168            self.colorFormat = parts[1]
169            if self.colorFormat not in self.FORMATS:
170                raise ImageException("Unknown color format (%s)" % parts[1])
171
172            # Consistency check between directory and file names
173            reencode = "%s-%s" % (self.colorProfile, self.colorFormat)
174            compare = self.testFormat.lower()
175            if reencode != compare:
176                dat = (self.testFormat, reencode)
177                raise ImageException("Mismatched test and image (%s:%s)" % dat)
178
179            self.name = parts[2]
180
181        # Set default values for the optional fields
182        self.is3D = False
183        self.isMask = False
184        self.isAlphaScaled = False
185
186        # Decode the flags field if present
187        if len(parts) >= 4:
188            flags = parts[3]
189            seenFlags = set()
190            for flag in flags:
191                if flag in seenFlags:
192                    raise ImageException("Duplicate flag (%s)" % flag)
193                if flag not in self.FLAGS:
194                    raise ImageException("Unknown flag (%s)" % flag)
195                seenFlags.add(flag)
196
197            self.is3D = "3" in seenFlags
198            self.isMask = "m" in seenFlags
199            self.isAlphaScaled = "a" in seenFlags
200
201    def get_size(self):
202        """
203        Get the dimensions of this test image, if format is known.
204
205        Known cases today where the format is not known:
206
207        * 3D .dds files.
208        * Any .ktx, .hdr, .exr, or .astc file.
209
210        Returns:
211            tuple(int, int): The dimensions of a 2D image, or ``None`` if PIL
212            could not open the file.
213        """
214        try:
215            img = PILImage.open(self.filePath)
216        except IOError:
217            # HDR files
218            return None
219        except NotImplementedError:
220            # DDS files
221            return None
222
223        return (img.size[0], img.size[1])
224
225
226class Image():
227    """
228    Wrapper around an image on the file system.
229    """
230
231    # TODO: We don't support KTX yet, as ImageMagick doesn't.
232    SUPPORTED_LDR = ["bmp", "jpg", "png", "tga"]
233    SUPPORTED_HDR = ["exr", "hdr"]
234
235    @classmethod
236    def is_format_supported(cls, fileFormat, profile=None):
237        """
238        Test if a given file format is supported by the library.
239
240        Args:
241            fileFormat (str): The file extension (excluding the ".").
242            profile (str or None): The profile (ldr or hdr) of the image.
243
244        Returns:
245            bool: `True` if the image is supported, `False` otherwise.
246        """
247        assert profile in [None, "ldr", "hdr"]
248
249        if profile == "ldr":
250            return fileFormat in cls.SUPPORTED_LDR
251
252        if profile == "hdr":
253            return fileFormat in cls.SUPPORTED_HDR
254
255        return fileFormat in cls.SUPPORTED_LDR or \
256            fileFormat in cls.SUPPORTED_HDR
257
258    def __init__(self, filePath):
259        """
260        Construct a new Image.
261
262        Args:
263            filePath (str): The path to the image on disk.
264        """
265        convert = get_convert_version()
266
267        # ImageMagick 7 started to use .tga file origin information. By default
268        # TGA files store data from bottom up, and define the origin as bottom
269        # left. We want our color samples to always use a top left origin, even
270        # if the data is stored in alternative layout.
271        self.invertYCoords = (convert >= 7.0) and filePath.endswith(".tga")
272
273        self.filePath = filePath
274        self.proxyPath = None
275
276    def get_colors(self, coords):
277        """
278        Get the image colors at the given coordinate.
279
280        Args:
281            coords (tuple or list): A single coordinate, or a list of
282                coordinates to sample.
283
284        Returns:
285            tuple: A single sample color (if `coords` was a coordinate).
286            list: A list of sample colors (if `coords` was a list).
287
288            Colors are returned as float values between 0.0 and 1.0 for LDR,
289            and float values which may exceed 1.0 for HDR.
290        """
291        colors = []
292
293        # We accept both a list of positions and a single position;
294        # canonicalize here so the main processing only handles lists
295        isList = len(coords) != 0 and isinstance(coords[0], Iterable)
296
297        if not isList:
298            coords = [coords]
299
300        for (x, y) in coords:
301            command = list(CONVERT_BINARY)
302            command += [self.filePath]
303
304            # Invert coordinates if the format needs it
305            if self.invertYCoords:
306                command += ["-flip"]
307
308            command += [
309                "-format", "%%[pixel:p{%u,%u}]" % (x, y),
310                "info:"
311            ]
312
313            if os.name == 'nt':
314                command.insert(0, "magick")
315
316            result = sp.run(command, stdout=sp.PIPE, stderr=sp.PIPE,
317                            check=True, universal_newlines=True)
318
319            rawcolor = result.stdout.strip()
320
321            # Decode ImageMagick's annoying named color outputs. Note that this
322            # only handles "known" cases triggered by our test images, we don't
323            # support the entire ImageMagick named color table.
324            if rawcolor == "black":
325                colors.append([0.0, 0.0, 0.0, 1.0])
326            elif rawcolor == "white":
327                colors.append([1.0, 1.0, 1.0, 1.0])
328            elif rawcolor == "red":
329                colors.append([1.0, 0.0, 0.0, 1.0])
330            elif rawcolor == "blue":
331                colors.append([0.0, 0.0, 1.0, 1.0])
332
333            # Decode ImageMagick's format tuples
334            elif rawcolor.startswith("srgba"):
335                rawcolor = rawcolor[6:]
336                rawcolor = rawcolor[:-1]
337                channels = rawcolor.split(",")
338                for i, channel in enumerate(channels):
339                    if (i < 3) and channel.endswith("%"):
340                        channels[i] = float(channel[:-1]) / 100.0
341                    elif (i < 3) and not channel.endswith("%"):
342                        channels[i] = float(channel) / 255.0
343                    else:
344                        channels[i] = float(channel)
345                colors.append(channels)
346            elif rawcolor.startswith("srgb"):
347                rawcolor = rawcolor[5:]
348                rawcolor = rawcolor[:-1]
349                channels = rawcolor.split(",")
350                for i, channel in enumerate(channels):
351                    if (i < 3) and channel.endswith("%"):
352                        channels[i] = float(channel[:-1]) / 100.0
353                    if (i < 3) and not channel.endswith("%"):
354                        channels[i] = float(channel) / 255.0
355                channels.append(1.0)
356                colors.append(channels)
357            elif rawcolor.startswith("rgba"):
358                rawcolor = rawcolor[5:]
359                rawcolor = rawcolor[:-1]
360                channels = rawcolor.split(",")
361                for i, channel in enumerate(channels):
362                    if (i < 3) and channel.endswith("%"):
363                        channels[i] = float(channel[:-1]) / 100.0
364                    elif (i < 3) and not channel.endswith("%"):
365                        channels[i] = float(channel) / 255.0
366                    else:
367                        channels[i] = float(channel)
368                colors.append(channels)
369            elif rawcolor.startswith("rgb"):
370                rawcolor = rawcolor[4:]
371                rawcolor = rawcolor[:-1]
372                channels = rawcolor.split(",")
373                for i, channel in enumerate(channels):
374                    if (i < 3) and channel.endswith("%"):
375                        channels[i] = float(channel[:-1]) / 100.0
376                    if (i < 3) and not channel.endswith("%"):
377                        channels[i] = float(channel) / 255.0
378                channels.append(1.0)
379                colors.append(channels)
380            else:
381                print(x, y)
382                print(rawcolor)
383                assert False
384
385        # ImageMagick decodes DDS files as BGRA not RGBA; manually correct
386        if self.filePath.endswith("dds"):
387            for color in colors:
388                tmp = color[0]
389                color[0] = color[2]
390                color[2] = tmp
391
392        # ImageMagick decodes EXR files with premult alpha; manually correct
393        if self.filePath.endswith("exr"):
394            for color in colors:
395                color[0] /= color[3]
396                color[1] /= color[3]
397                color[2] /= color[3]
398
399        # Undo list canonicalization if we were given a single scalar coord
400        if not isList:
401            return colors[0]
402
403        return colors
404