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