Add ktx
This commit is contained in:
+427
@@ -0,0 +1,427 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# -----------------------------------------------------------------------------
|
||||
# Copyright 2019-2023 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.
|
||||
# -----------------------------------------------------------------------------
|
||||
"""
|
||||
These classes provide an abstraction around the astcenc command line tool,
|
||||
allowing the rest of the image test suite to ignore changes in the command line
|
||||
interface.
|
||||
"""
|
||||
|
||||
import os
|
||||
import re
|
||||
import subprocess as sp
|
||||
import sys
|
||||
|
||||
|
||||
class EncoderBase():
|
||||
"""
|
||||
This class is a Python wrapper for the `astcenc` binary, providing an
|
||||
abstract means to set command line options and parse key results.
|
||||
|
||||
This is an abstract base class providing some generic helper functionality
|
||||
used by concrete instantiations of subclasses.
|
||||
|
||||
Attributes:
|
||||
binary: The encoder binary path.
|
||||
variant: The encoder SIMD variant being tested.
|
||||
name: The encoder name to use in reports.
|
||||
VERSION: The encoder version or branch.
|
||||
SWITCHES: Dict of switch replacements for different color formats.
|
||||
OUTPUTS: Dict of output file extensions for different color formats.
|
||||
"""
|
||||
|
||||
VERSION = None
|
||||
SWITCHES = None
|
||||
OUTPUTS = None
|
||||
|
||||
def __init__(self, name, variant, binary):
|
||||
"""
|
||||
Create a new encoder instance.
|
||||
|
||||
Args:
|
||||
name (str): The name of the encoder.
|
||||
variant (str): The SIMD variant of the encoder.
|
||||
binary (str): The path to the binary on the file system.
|
||||
"""
|
||||
self.name = name
|
||||
self.variant = variant
|
||||
self.binary = binary
|
||||
|
||||
def build_cli(self, image, blockSize="6x6", preset="-thorough",
|
||||
keepOutput=True, threads=None):
|
||||
"""
|
||||
Build the command line needed for the given test.
|
||||
|
||||
Args:
|
||||
image (TestImage): The test image to compress.
|
||||
blockSize (str): The block size to use.
|
||||
preset (str): The quality-performance preset to use.
|
||||
keepOutput (bool): Should the test preserve output images? This is
|
||||
only a hint and discarding output may be ignored if the encoder
|
||||
version used can't do it natively.
|
||||
threads (int or None): The thread count to use.
|
||||
|
||||
Returns:
|
||||
list(str): A list of command line arguments.
|
||||
"""
|
||||
# pylint: disable=unused-argument,no-self-use,redundant-returns-doc
|
||||
assert False, "Missing subclass implementation"
|
||||
|
||||
def execute(self, command):
|
||||
"""
|
||||
Run a subprocess with the specified command.
|
||||
|
||||
Args:
|
||||
command (list(str)): The list of command line arguments.
|
||||
|
||||
Returns:
|
||||
list(str): The output log (stdout) split into lines.
|
||||
"""
|
||||
# pylint: disable=no-self-use
|
||||
try:
|
||||
result = sp.run(command, stdout=sp.PIPE, stderr=sp.PIPE,
|
||||
check=True, universal_newlines=True)
|
||||
except (OSError, sp.CalledProcessError):
|
||||
print("ERROR: Test run failed")
|
||||
print(" + %s" % " ".join(command))
|
||||
qcommand = ["\"%s\"" % x for x in command]
|
||||
print(" + %s" % ", ".join(qcommand))
|
||||
sys.exit(1)
|
||||
|
||||
return result.stdout.splitlines()
|
||||
|
||||
def parse_output(self, image, output):
|
||||
"""
|
||||
Parse the log output for PSNR and performance metrics.
|
||||
|
||||
Args:
|
||||
image (TestImage): The test image to compress.
|
||||
output (list(str)): The output log from the compression process.
|
||||
|
||||
Returns:
|
||||
tuple(float, float, float): PSNR in dB, TotalTime in seconds, and
|
||||
CodingTime in seconds.
|
||||
"""
|
||||
# Regex pattern for image quality
|
||||
patternPSNR = re.compile(self.get_psnr_pattern(image))
|
||||
patternTTime = re.compile(self.get_total_time_pattern())
|
||||
patternCTime = re.compile(self.get_coding_time_pattern())
|
||||
patternCRate = re.compile(self.get_coding_rate_pattern())
|
||||
|
||||
# Extract results from the log
|
||||
runPSNR = None
|
||||
runTTime = None
|
||||
runCTime = None
|
||||
runCRate = None
|
||||
|
||||
for line in output:
|
||||
match = patternPSNR.match(line)
|
||||
if match:
|
||||
runPSNR = float(match.group(1))
|
||||
|
||||
match = patternTTime.match(line)
|
||||
if match:
|
||||
runTTime = float(match.group(1))
|
||||
|
||||
match = patternCTime.match(line)
|
||||
if match:
|
||||
runCTime = float(match.group(1))
|
||||
|
||||
match = patternCRate.match(line)
|
||||
if match:
|
||||
runCRate = float(match.group(1))
|
||||
|
||||
stdout = "\n".join(output)
|
||||
assert runPSNR is not None, "No coding PSNR found %s" % stdout
|
||||
assert runTTime is not None, "No total time found %s" % stdout
|
||||
assert runCTime is not None, "No coding time found %s" % stdout
|
||||
assert runCRate is not None, "No coding rate found %s" % stdout
|
||||
return (runPSNR, runTTime, runCTime, runCRate)
|
||||
|
||||
def get_psnr_pattern(self, image):
|
||||
"""
|
||||
Get the regex pattern to match the image quality metric.
|
||||
|
||||
Note, while this function is called PSNR for some images we may choose
|
||||
to match another metric (e.g. mPSNR for HDR images).
|
||||
|
||||
Args:
|
||||
image (TestImage): The test image we are compressing.
|
||||
|
||||
Returns:
|
||||
str: The string for a regex pattern.
|
||||
"""
|
||||
# pylint: disable=unused-argument,no-self-use,redundant-returns-doc
|
||||
assert False, "Missing subclass implementation"
|
||||
|
||||
def get_total_time_pattern(self):
|
||||
"""
|
||||
Get the regex pattern to match the total compression time.
|
||||
|
||||
Returns:
|
||||
str: The string for a regex pattern.
|
||||
"""
|
||||
# pylint: disable=unused-argument,no-self-use,redundant-returns-doc
|
||||
assert False, "Missing subclass implementation"
|
||||
|
||||
def get_coding_time_pattern(self):
|
||||
"""
|
||||
Get the regex pattern to match the coding compression time.
|
||||
|
||||
Returns:
|
||||
str: The string for a regex pattern.
|
||||
"""
|
||||
# pylint: disable=unused-argument,no-self-use,redundant-returns-doc
|
||||
assert False, "Missing subclass implementation"
|
||||
|
||||
def run_test(self, image, blockSize, preset, testRuns, keepOutput=True,
|
||||
threads=None):
|
||||
"""
|
||||
Run the test N times.
|
||||
|
||||
Args:
|
||||
image (TestImage): The test image to compress.
|
||||
blockSize (str): The block size to use.
|
||||
preset (str): The quality-performance preset to use.
|
||||
testRuns (int): The number of test runs.
|
||||
keepOutput (bool): Should the test preserve output images? This is
|
||||
only a hint and discarding output may be ignored if the encoder
|
||||
version used can't do it natively.
|
||||
threads (int or None): The thread count to use.
|
||||
|
||||
Returns:
|
||||
tuple(float, float, float, float): Returns the best results from
|
||||
the N test runs, as PSNR (dB), total time (seconds), coding time
|
||||
(seconds), and coding rate (M pixels/s).
|
||||
"""
|
||||
# pylint: disable=assignment-from-no-return
|
||||
command = self.build_cli(image, blockSize, preset, keepOutput, threads)
|
||||
|
||||
# Execute test runs
|
||||
bestPSNR = 0
|
||||
bestTTime = sys.float_info.max
|
||||
bestCTime = sys.float_info.max
|
||||
bestCRate = 0
|
||||
|
||||
for _ in range(0, testRuns):
|
||||
output = self.execute(command)
|
||||
result = self.parse_output(image, output)
|
||||
|
||||
# Keep the best results (highest PSNR, lowest times, highest rate)
|
||||
bestPSNR = max(bestPSNR, result[0])
|
||||
bestTTime = min(bestTTime, result[1])
|
||||
bestCTime = min(bestCTime, result[2])
|
||||
bestCRate = max(bestCRate, result[3])
|
||||
|
||||
return (bestPSNR, bestTTime, bestCTime, bestCRate)
|
||||
|
||||
|
||||
class Encoder2x(EncoderBase):
|
||||
"""
|
||||
This class wraps the latest `astcenc` 2.x series binaries from main branch.
|
||||
"""
|
||||
VERSION = "main"
|
||||
|
||||
SWITCHES = {
|
||||
"ldr": "-tl",
|
||||
"ldrs": "-ts",
|
||||
"hdr": "-th",
|
||||
"hdra": "-tH"
|
||||
}
|
||||
|
||||
OUTPUTS = {
|
||||
"ldr": ".png",
|
||||
"ldrs": ".png",
|
||||
"hdr": ".exr",
|
||||
"hdra": ".exr"
|
||||
}
|
||||
|
||||
def __init__(self, variant, binary=None):
|
||||
name = "astcenc-%s-%s" % (variant, self.VERSION)
|
||||
|
||||
if binary is None:
|
||||
if variant != "universal":
|
||||
binary = f"./bin/astcenc-{variant}"
|
||||
else:
|
||||
binary = "./bin/astcenc"
|
||||
|
||||
if os.name == 'nt':
|
||||
binary = f"{binary}.exe"
|
||||
|
||||
super().__init__(name, variant, binary)
|
||||
|
||||
def build_cli(self, image, blockSize="6x6", preset="-thorough",
|
||||
keepOutput=True, threads=None):
|
||||
opmode = self.SWITCHES[image.colorProfile]
|
||||
srcPath = image.filePath
|
||||
|
||||
if keepOutput:
|
||||
dstPath = image.outFilePath + self.OUTPUTS[image.colorProfile]
|
||||
dstDir = os.path.dirname(dstPath)
|
||||
dstFile = os.path.basename(dstPath)
|
||||
dstPath = os.path.join(dstDir, self.name, preset[1:], blockSize, dstFile)
|
||||
|
||||
dstDir = os.path.dirname(dstPath)
|
||||
os.makedirs(dstDir, exist_ok=True)
|
||||
elif sys.platform == "win32":
|
||||
dstPath = "nul"
|
||||
else:
|
||||
dstPath = "/dev/null"
|
||||
|
||||
command = [
|
||||
self.binary, opmode, srcPath, dstPath,
|
||||
blockSize, preset, "-silent"
|
||||
]
|
||||
|
||||
if image.colorFormat == "xy":
|
||||
command.append("-normal")
|
||||
|
||||
if image.isAlphaScaled:
|
||||
command.append("-a")
|
||||
command.append("1")
|
||||
|
||||
if threads is not None:
|
||||
command.append("-j")
|
||||
command.append("%u" % threads)
|
||||
|
||||
return command
|
||||
|
||||
def get_psnr_pattern(self, image):
|
||||
if image.colorProfile != "hdr":
|
||||
if image.colorFormat != "rgba":
|
||||
patternPSNR = r"\s*PSNR \(LDR-RGB\):\s*([0-9.]*) dB"
|
||||
else:
|
||||
patternPSNR = r"\s*PSNR \(LDR-RGBA\):\s*([0-9.]*) dB"
|
||||
else:
|
||||
patternPSNR = r"\s*mPSNR \(RGB\)(?: \[.*?\] )?:\s*([0-9.]*) dB.*"
|
||||
return patternPSNR
|
||||
|
||||
def get_total_time_pattern(self):
|
||||
return r"\s*Total time:\s*([0-9.]*) s"
|
||||
|
||||
def get_coding_time_pattern(self):
|
||||
return r"\s*Coding time:\s*([0-9.]*) s"
|
||||
|
||||
def get_coding_rate_pattern(self):
|
||||
return r"\s*Coding rate:\s*([0-9.]*) MT/s"
|
||||
|
||||
|
||||
class Encoder2xRel(Encoder2x):
|
||||
"""
|
||||
This class wraps a released 2.x series binary.
|
||||
"""
|
||||
def __init__(self, version, variant):
|
||||
|
||||
self.VERSION = version
|
||||
|
||||
if variant != "universal":
|
||||
binary = f"./Binaries/{version}/astcenc-{variant}"
|
||||
else:
|
||||
binary = f"./Binaries/{version}/astcenc"
|
||||
|
||||
if os.name == 'nt':
|
||||
binary = f"{binary}.exe"
|
||||
|
||||
super().__init__(variant, binary)
|
||||
|
||||
|
||||
class Encoder1_7(EncoderBase):
|
||||
"""
|
||||
This class wraps the 1.7 series binaries.
|
||||
"""
|
||||
VERSION = "1.7"
|
||||
|
||||
SWITCHES = {
|
||||
"ldr": "-tl",
|
||||
"ldrs": "-ts",
|
||||
"hdr": "-t"
|
||||
}
|
||||
|
||||
OUTPUTS = {
|
||||
"ldr": ".tga",
|
||||
"ldrs": ".tga",
|
||||
"hdr": ".htga"
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
name = "astcenc-%s" % self.VERSION
|
||||
if os.name == 'nt':
|
||||
binary = "./Binaries/1.7/astcenc.exe"
|
||||
else:
|
||||
binary = "./Binaries/1.7/astcenc"
|
||||
|
||||
super().__init__(name, None, binary)
|
||||
|
||||
def build_cli(self, image, blockSize="6x6", preset="-thorough",
|
||||
keepOutput=True, threads=None):
|
||||
|
||||
if preset == "-fastest":
|
||||
preset = "-fast"
|
||||
|
||||
opmode = self.SWITCHES[image.colorProfile]
|
||||
srcPath = image.filePath
|
||||
|
||||
dstPath = image.outFilePath + self.OUTPUTS[image.colorProfile]
|
||||
dstDir = os.path.dirname(dstPath)
|
||||
dstFile = os.path.basename(dstPath)
|
||||
dstPath = os.path.join(dstDir, self.name, preset[1:], blockSize, dstFile)
|
||||
|
||||
dstDir = os.path.dirname(dstPath)
|
||||
os.makedirs(dstDir, exist_ok=True)
|
||||
|
||||
command = [
|
||||
self.binary, opmode, srcPath, dstPath,
|
||||
blockSize, preset, "-silentmode", "-time", "-showpsnr"
|
||||
]
|
||||
|
||||
if image.colorFormat == "xy":
|
||||
command.append("-normal_psnr")
|
||||
|
||||
if image.colorProfile == "hdr":
|
||||
command.append("-hdr")
|
||||
|
||||
if image.isAlphaScaled:
|
||||
command.append("-alphablend")
|
||||
|
||||
if threads is not None:
|
||||
command.append("-j")
|
||||
command.append("%u" % threads)
|
||||
|
||||
return command
|
||||
|
||||
def get_psnr_pattern(self, image):
|
||||
if image.colorProfile != "hdr":
|
||||
if image.colorFormat != "rgba":
|
||||
patternPSNR = r"PSNR \(LDR-RGB\):\s*([0-9.]*) dB"
|
||||
else:
|
||||
patternPSNR = r"PSNR \(LDR-RGBA\):\s*([0-9.]*) dB"
|
||||
else:
|
||||
patternPSNR = r"mPSNR \(RGB\)(?: \[.*?\] )?:\s*([0-9.]*) dB.*"
|
||||
return patternPSNR
|
||||
|
||||
def get_total_time_pattern(self):
|
||||
# Pattern match on a new pattern for a 2.1 compatible variant
|
||||
# return r"Elapsed time:\s*([0-9.]*) seconds.*"
|
||||
return r"\s*Total time:\s*([0-9.]*) s"
|
||||
|
||||
def get_coding_time_pattern(self):
|
||||
# Pattern match on a new pattern for a 2.1 compatible variant
|
||||
# return r".* coding time: \s*([0-9.]*) seconds"
|
||||
return r"\s*Coding time:\s*([0-9.]*) s"
|
||||
|
||||
def get_coding_rate_pattern(self):
|
||||
# Pattern match on a new pattern for a 2.1 compatible variant
|
||||
return r"\s*Coding rate:\s*([0-9.]*) MT/s"
|
||||
+361
@@ -0,0 +1,361 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# -----------------------------------------------------------------------------
|
||||
# Copyright 2019-2022 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"]
|
||||
|
||||
|
||||
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.
|
||||
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", ".ktx")
|
||||
|
||||
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.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.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.
|
||||
"""
|
||||
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]
|
||||
|
||||
# Ensure convert factors in format origin if needed
|
||||
command += ["-auto-orient"]
|
||||
|
||||
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
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# -----------------------------------------------------------------------------
|
||||
# Copyright 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.
|
||||
# -----------------------------------------------------------------------------
|
||||
"""
|
||||
A collection of useful utility functions that are not module specific.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
|
||||
def path_splitall(path):
|
||||
"""
|
||||
Utility function to split a relative path into its component pieces.
|
||||
|
||||
Args:
|
||||
path(str): The relative path to split.
|
||||
|
||||
Returns:
|
||||
list(str): An array of path parts.
|
||||
"""
|
||||
# Sanity check we have a relative path on Windows
|
||||
assert ":" not in path
|
||||
|
||||
parts = []
|
||||
while path:
|
||||
head, tail = os.path.split(path)
|
||||
path = head
|
||||
parts.insert(0, tail)
|
||||
|
||||
return parts
|
||||
+354
@@ -0,0 +1,354 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# -----------------------------------------------------------------------------
|
||||
# Copyright 2020-2022 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.
|
||||
# -----------------------------------------------------------------------------
|
||||
"""
|
||||
A ResultSet stores a set of results about the performance of a TestSet. Each
|
||||
set keeps result Records for each image and block size tested, that store the
|
||||
PSNR and coding time.
|
||||
|
||||
ResultSets are often backed by a CSV file on disk, and a ResultSet can be
|
||||
compared against a set of reference results created by an earlier test run.
|
||||
"""
|
||||
|
||||
|
||||
import csv
|
||||
import enum
|
||||
import numpy as np
|
||||
import os
|
||||
|
||||
|
||||
@enum.unique
|
||||
class Result(enum.IntEnum):
|
||||
"""
|
||||
An enumeration of test result status values.
|
||||
|
||||
Attributes:
|
||||
NOTRUN: The test has not been run.
|
||||
PASS: The test passed.
|
||||
WARN: The test image quality was below the pass threshold but above
|
||||
the fail threshold.
|
||||
FAIL: The test image quality was below the fail threshold.
|
||||
"""
|
||||
NOTRUN = 0
|
||||
PASS = 1
|
||||
WARN = 2
|
||||
FAIL = 3
|
||||
|
||||
|
||||
class ResultSummary():
|
||||
"""
|
||||
An result summary data container, storing number of results of each type.
|
||||
|
||||
Attributes:
|
||||
notruns: The number of tests that did not run.
|
||||
passes: The number of tests that passed.
|
||||
warnings: The number of tests that produced a warning.
|
||||
fails: The number of tests that failed.
|
||||
tTimes: Total time speedup vs reference (<1 is slower, >1 is faster).
|
||||
cTimes: Coding time speedup vs reference (<1 is slower, >1 is faster).
|
||||
psnrs: Coding time quality vs reference (<0 is worse, >0 is better).
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Create a new result summary.
|
||||
"""
|
||||
# Pass fail metrics
|
||||
self.notruns = 0
|
||||
self.passes = 0
|
||||
self.warnings = 0
|
||||
self.fails = 0
|
||||
|
||||
# Relative results
|
||||
self.tTimesRel = []
|
||||
self.cTimesRel = []
|
||||
self.psnrRel = []
|
||||
|
||||
# Absolute results
|
||||
self.cTime = []
|
||||
self.psnr = []
|
||||
|
||||
def add_record(self, record):
|
||||
"""
|
||||
Add a record to this summary.
|
||||
|
||||
Args:
|
||||
record (Record): The Record to add.
|
||||
"""
|
||||
if record.status == Result.PASS:
|
||||
self.passes += 1
|
||||
elif record.status == Result.WARN:
|
||||
self.warnings += 1
|
||||
elif record.status == Result.FAIL:
|
||||
self.fails += 1
|
||||
else:
|
||||
self.notruns += 1
|
||||
|
||||
if record.tTimeRel is not None:
|
||||
self.tTimesRel.append(record.tTimeRel)
|
||||
self.cTimesRel.append(record.cTimeRel)
|
||||
self.psnrRel.append(record.psnrRel)
|
||||
|
||||
self.cTime.append(record.cTime)
|
||||
self.psnr.append(record.psnr)
|
||||
|
||||
def get_worst_result(self):
|
||||
"""
|
||||
Get the worst result in this set.
|
||||
|
||||
Returns:
|
||||
Result: The worst test result.
|
||||
"""
|
||||
if self.fails:
|
||||
return Result.FAIL
|
||||
|
||||
if self.warnings:
|
||||
return Result.WARN
|
||||
|
||||
if self.passes:
|
||||
return Result.PASS
|
||||
|
||||
return Result.NOTRUN
|
||||
|
||||
def __str__(self):
|
||||
# Overall pass/fail results
|
||||
overall = self.get_worst_result().name
|
||||
dat = (overall, self.passes, self.warnings, self.fails)
|
||||
result = ["\nSet Status: %s (Pass: %u | Warn: %u | Fail: %u)" % dat]
|
||||
|
||||
if (self.tTimesRel):
|
||||
# Performance summaries
|
||||
dat = (np.mean(self.tTimesRel), np.std(self.tTimesRel))
|
||||
result.append("\nTotal speed: Mean: %+0.3f x Std: %0.2f x" % dat)
|
||||
|
||||
dat = (np.mean(self.cTimesRel), np.std(self.cTimesRel))
|
||||
result.append("Coding speed: Mean: %+0.3f x Std: %0.2f x" % dat)
|
||||
|
||||
dat = (np.mean(self.psnrRel), np.std(self.psnrRel))
|
||||
result.append("Quality diff: Mean: %+0.3f dB Std: %0.2f dB" % dat)
|
||||
|
||||
dat = (np.mean(self.cTime), np.std(self.cTime))
|
||||
result.append("Coding time: Mean: %+0.3f s Std: %0.2f s" % dat)
|
||||
|
||||
dat = (np.mean(self.psnr), np.std(self.psnr))
|
||||
result.append("Quality: Mean: %+0.3f dB Std: %0.2f dB" % dat)
|
||||
|
||||
return "\n".join(result)
|
||||
|
||||
|
||||
class Record():
|
||||
"""
|
||||
A single result record, sotring results for a singel image and block size.
|
||||
|
||||
Attributes:
|
||||
blkSz: The block size.
|
||||
name: The test image name.
|
||||
psnr: The image quality (PSNR dB)
|
||||
tTime: The total compression time.
|
||||
cTime: The coding compression time.
|
||||
cRate: The coding compression rate.
|
||||
status: The test Result.
|
||||
"""
|
||||
|
||||
def __init__(self, blkSz, name, psnr, tTime, cTime, cRate):
|
||||
"""
|
||||
Create a result record, initially in the NOTRUN status.
|
||||
|
||||
Args:
|
||||
blkSz (str): The block size.
|
||||
name (str): The test image name.
|
||||
psnr (float): The image quality PSNR, in dB.
|
||||
tTime (float): The total compression time, in seconds.
|
||||
cTime (float): The coding compression time, in seconds.
|
||||
cRate (float): The coding compression rate, in MPix/s.
|
||||
tTimeRel (float): The relative total time speedup vs reference.
|
||||
cTimeRel (float): The relative coding time speedup vs reference.
|
||||
cRateRel (float): The relative rate speedup vs reference.
|
||||
psnrRel (float): The relative PSNR dB vs reference.
|
||||
"""
|
||||
self.blkSz = blkSz
|
||||
self.name = name
|
||||
self.psnr = psnr
|
||||
self.tTime = tTime
|
||||
self.cTime = cTime
|
||||
self.cRate = cRate
|
||||
self.status = Result.NOTRUN
|
||||
|
||||
self.tTimeRel = None
|
||||
self.cTimeRel = None
|
||||
self.cRateRel = None
|
||||
self.psnrRel = None
|
||||
|
||||
def set_status(self, result):
|
||||
"""
|
||||
Set the result status.
|
||||
|
||||
Args:
|
||||
result (Result): The test result.
|
||||
"""
|
||||
self.status = result
|
||||
|
||||
def __str__(self):
|
||||
return "'%s' / '%s'" % (self.blkSz, self.name)
|
||||
|
||||
|
||||
class ResultSet():
|
||||
"""
|
||||
A set of results for a TestSet, across one or more block sizes.
|
||||
|
||||
Attributes:
|
||||
testSet: The test set these results are linked to.
|
||||
records: The list of test results.
|
||||
"""
|
||||
|
||||
def __init__(self, testSet):
|
||||
"""
|
||||
Create a new empty ResultSet.
|
||||
|
||||
Args:
|
||||
testSet (TestSet): The test set these results are linked to.
|
||||
"""
|
||||
self.testSet = testSet
|
||||
self.records = []
|
||||
|
||||
def add_record(self, record):
|
||||
"""
|
||||
Add a new test record to this result set.
|
||||
|
||||
Args:
|
||||
record (Record): The test record to add.
|
||||
"""
|
||||
self.records.append(record)
|
||||
|
||||
def get_record(self, testSet, blkSz, name):
|
||||
"""
|
||||
Get a record matching the arguments.
|
||||
|
||||
Args:
|
||||
testSet (TestSet): The test set to get results from.
|
||||
blkSz (str): The block size.
|
||||
name (str): The test name.
|
||||
|
||||
Returns:
|
||||
Record: The test result, if present.
|
||||
|
||||
Raises:
|
||||
KeyError: No match could be found.
|
||||
"""
|
||||
if testSet != self.testSet:
|
||||
raise KeyError()
|
||||
|
||||
for record in self.records:
|
||||
if record.blkSz == blkSz and record.name == name:
|
||||
return record
|
||||
|
||||
raise KeyError()
|
||||
|
||||
def get_matching_record(self, other):
|
||||
"""
|
||||
Get a record matching the config of another record.
|
||||
|
||||
Args:
|
||||
other (Record): The pattern result record to match.
|
||||
|
||||
Returns:
|
||||
Record: The result, if present.
|
||||
|
||||
Raises:
|
||||
KeyError: No match could be found.
|
||||
"""
|
||||
for record in self.records:
|
||||
if record.blkSz == other.blkSz and record.name == other.name:
|
||||
return record
|
||||
|
||||
raise KeyError()
|
||||
|
||||
def get_results_summary(self):
|
||||
"""
|
||||
Get a results summary of all the records in this result set.
|
||||
|
||||
Returns:
|
||||
ResultSummary: The result summary.
|
||||
"""
|
||||
summary = ResultSummary()
|
||||
for record in self.records:
|
||||
summary.add_record(record)
|
||||
|
||||
return summary
|
||||
|
||||
def save_to_file(self, filePath):
|
||||
"""
|
||||
Save this result set to a CSV file.
|
||||
|
||||
Args:
|
||||
filePath (str): The output file path.
|
||||
"""
|
||||
dirName = os.path.dirname(filePath)
|
||||
if not os.path.exists(dirName):
|
||||
os.makedirs(dirName)
|
||||
|
||||
with open(filePath, "w", newline="") as csvfile:
|
||||
writer = csv.writer(csvfile)
|
||||
self._save_header(writer)
|
||||
for record in self.records:
|
||||
self._save_record(writer, record)
|
||||
|
||||
@staticmethod
|
||||
def _save_header(writer):
|
||||
"""
|
||||
Write the header to the CSV file.
|
||||
|
||||
Args:
|
||||
writer (csv.writer): The CSV writer.
|
||||
"""
|
||||
row = ["Image Set", "Block Size", "Name",
|
||||
"PSNR", "Total Time", "Coding Time", "Coding Rate"]
|
||||
writer.writerow(row)
|
||||
|
||||
def _save_record(self, writer, record):
|
||||
"""
|
||||
Write a record to the CSV file.
|
||||
|
||||
Args:
|
||||
writer (csv.writer): The CSV writer.
|
||||
record (Record): The record to write.
|
||||
"""
|
||||
row = [self.testSet,
|
||||
record.blkSz,
|
||||
record.name,
|
||||
"%0.4f" % record.psnr,
|
||||
"%0.4f" % record.tTime,
|
||||
"%0.4f" % record.cTime,
|
||||
"%0.4f" % record.cRate]
|
||||
writer.writerow(row)
|
||||
|
||||
def load_from_file(self, filePath):
|
||||
"""
|
||||
Load a reference result set from a CSV file on disk.
|
||||
|
||||
Args:
|
||||
filePath (str): The input file path.
|
||||
"""
|
||||
with open(filePath, "r") as csvfile:
|
||||
reader = csv.reader(csvfile)
|
||||
# Skip the header
|
||||
next(reader)
|
||||
for row in reader:
|
||||
assert row[0] == self.testSet
|
||||
record = Record(row[1], row[2],
|
||||
float(row[3]), float(row[4]),
|
||||
float(row[5]), float(row[6]))
|
||||
self.add_record(record)
|
||||
@@ -0,0 +1,96 @@
|
||||
# SPDX-License-Identifier: Apache-2.0
|
||||
# -----------------------------------------------------------------------------
|
||||
# Copyright 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.
|
||||
# -----------------------------------------------------------------------------
|
||||
"""
|
||||
An ASTC TestSet is comprised of a set of TestImages. Images are stored in a
|
||||
structured directory layout. This structure encodes important metadata about
|
||||
each image - such as color profile and data encoding - in the directory and
|
||||
file names used.
|
||||
|
||||
TestSets are built by using reflection on a root directory to automatically
|
||||
find all of the test images that comprise the set.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from testlib.image import TestImage
|
||||
|
||||
|
||||
class TSetException(Exception):
|
||||
"""
|
||||
Exception thrown for bad test set specification.
|
||||
"""
|
||||
|
||||
|
||||
class TestSet():
|
||||
"""
|
||||
Generate a list of images that are test candidates.
|
||||
|
||||
This reflection is built automatically based on a directory of images on
|
||||
disk, provided that the images follow a standard structure.
|
||||
|
||||
Attributes:
|
||||
name: The name of the test set.
|
||||
tests: The list of TestImages forming the set.
|
||||
"""
|
||||
|
||||
def __init__(self, name, rootDir, profiles, formats, imageFilter=None):
|
||||
"""
|
||||
Create a new TestSet through reflection.
|
||||
|
||||
Args:
|
||||
name (str): The name of the test set.
|
||||
rootDir (str): The root directory of the test set.
|
||||
profiles (list(str)): The ASTC profiles to allow.
|
||||
formats (list(str)): The image formats to allow.
|
||||
imageFilter (str): The name of the image to include (for bug repo).
|
||||
|
||||
Raises:
|
||||
TSetException: The specified TestSet could not be loaded.
|
||||
"""
|
||||
self.name = name
|
||||
|
||||
if not os.path.exists(rootDir) and not os.path.isdir(rootDir):
|
||||
raise TSetException("Bad test set root directory (%s)" % rootDir)
|
||||
|
||||
self.tests = []
|
||||
|
||||
for (dirPath, dirNames, fileNames) in os.walk(rootDir):
|
||||
for fileName in fileNames:
|
||||
# Only select image files
|
||||
fileExt = os.path.splitext(fileName)[1]
|
||||
if fileExt not in TestImage.TEST_EXTS:
|
||||
continue
|
||||
|
||||
# Create the TestImage for each file on disk
|
||||
filePath = os.path.join(dirPath, fileName)
|
||||
image = TestImage(filePath)
|
||||
|
||||
# Filter out the ones we don't want to allow
|
||||
if image.colorProfile not in profiles:
|
||||
continue
|
||||
|
||||
if image.colorFormat not in formats:
|
||||
continue
|
||||
|
||||
if imageFilter and image.testFile != imageFilter:
|
||||
continue
|
||||
|
||||
self.tests.append((filePath, image))
|
||||
|
||||
# Sort the TestImages so they are in a stable order
|
||||
self.tests.sort()
|
||||
self.tests = [x[1] for x in self.tests]
|
||||
Reference in New Issue
Block a user