This commit is contained in:
2026-06-14 19:09:18 +01:00
parent 14bd1a9271
commit 13fa90a0e9
3958 changed files with 999286 additions and 4 deletions
+427
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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)
+96
View File
@@ -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]