blob: f6db66d2314f24f2f53c2e7d4e6c9fb8f013729c [file] [edit]
# SPDX-License-Identifier: Apache-2.0
# -----------------------------------------------------------------------------
# Copyright 2019-2026 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.
Currently this module supports the latest 2.x branch onwards; the 1.x branch
is no longer supported.
TODO:
* Refactor more heavily now that we have dropped 1.x encoder support, given
that all the compressors are the same now ...
'''
import os
from pathlib import Path
import re
import subprocess as sp
import sys
from typing import Optional
from .testset import TestImage
RunResult = tuple[float, float, float, float]
class EncoderBase:
'''
A wrapper around the astcenc binary, abstracting the command line so that
the rest of the test suite does not need to worry about changes to the
command line interface over versions.
This is an abstract base class providing some generic helper functionality
used by concrete instantiations of subclasses.
Attributes:
name: The encoder name to use in reports.
variant: The encoder SIMD variant being tested.
binary_path: The encoder binary path.
Class attributes:
version: Encoder version or branch.
remap_color_switches: Dict of color format to profile switches.
remap_output_types: Dict of color format to output file extensions.
'''
# Subclasses override these if they need them
version = ''
remap_color_switches: dict[str, str] = {}
remap_output_types: dict[str, str] = {}
def __init__(self, name: str, variant: str, binary_path: Path):
'''
Create a new encoder instance.
Args:
name: The name of the encoder.
variant: The SIMD variant of the encoder.
binary: The path to the binary on the file system.
'''
self.name = name
self.variant = variant
self.binary_path = binary_path
def build_cli(self, image: TestImage, block_size: str = '6x6',
preset: str = '-thorough', keep_output: bool = True,
threads: Optional[int] = None) -> list[str]:
'''
Build the command line needed for the given test.
Args:
image: The test image to compress.
block_size: The block size to use.
preset: The quality preset to use.
keep_output: Should the test preserve output images? This is a hint
and may be ignored if astcenc version used can't do it.
threads: The thread count to use.
Return:
The command line arguments for this encoder version.
'''
# pylint: disable=unused-argument,redundant-returns-doc
assert False, 'Missing subclass implementation'
def execute(self, command: list[str]) -> list[str]:
'''
Run a subprocess with the specified command.
Args:
command: The command line arguments to use.
Return:
The output log (stdout) split into lines.
'''
try:
result = sp.run(command, stdout=sp.PIPE, stderr=sp.PIPE,
check=True, text=True)
except (OSError, sp.CalledProcessError):
print(f' + {" ".join(command)}')
assert False, 'ERROR: Test run failed'
return result.stdout.splitlines()
def parse_output(self, image: TestImage, output: list[str]) -> RunResult:
'''
Parse the log output for PSNR and performance metrics.
Args:
image: The test image that was compressed.
output: The astcenc compression output log.
Return:
Tuple containing PSNR in dB, total time in seconds, coding time
in seconds, and coding rate in MT/s.
'''
# Regex patterns. provided by this particular subclass
pattern_psnr = self.get_psnr_pattern(image)
pattern_total_time = self.get_total_time_pattern()
pattern_coding_time = self.get_coding_time_pattern()
pattern_coding_rate = self.get_coding_rate_pattern()
# Extract results from the log
psnr = None
total_time = None
coding_time = None
coding_rate = None
for line in output:
if match := pattern_psnr.match(line):
psnr = float(match.group(1))
continue
if match := pattern_total_time.match(line):
total_time = float(match.group(1))
continue
if match := pattern_coding_time.match(line):
coding_time = float(match.group(1))
continue
if match := pattern_coding_rate.match(line):
coding_rate = float(match.group(1))
continue
stdout = '\n'.join(output)
assert psnr is not None, f'Missing PSNR {stdout}'
assert total_time is not None, f'Missing total time {stdout}'
assert coding_time is not None, f'Missing coding time {stdout}'
assert coding_rate is not None, f'Missing coding rate {stdout}'
return (psnr, total_time, coding_time, coding_rate)
def get_psnr_pattern(self, image: TestImage) -> re.Pattern:
'''
Get the regex pattern to match the image quality metric.
Note that, although this function is called PSNR, for some images we
may choose to match another metric (e.g. mPSNR for HDR images).
Args:
image: The test image that was compressed.
Return:
The regex pattern.
'''
# pylint: disable=unused-argument,redundant-returns-doc
assert False, 'Missing subclass implementation'
return re.compile('^(?!x)x$')
def get_total_time_pattern(self) -> re.Pattern:
'''
Get the regex pattern to match the total compression time.
Return:
The regex pattern.
'''
# pylint: disable=unused-argument,redundant-returns-doc
assert False, 'Missing subclass implementation'
return re.compile('^(?!x)x$')
def get_coding_time_pattern(self) -> re.Pattern:
'''
Get the regex pattern to match the coding compression time.
Return:
The regex pattern.
'''
# pylint: disable=unused-argument,redundant-returns-doc
assert False, 'Missing subclass implementation'
return re.compile('^(?!x)x$')
def get_coding_rate_pattern(self) -> re.Pattern:
'''
Get the regex pattern to match the coding rate.
Return:
The regex pattern.
'''
# pylint: disable=unused-argument,redundant-returns-doc
assert False, 'Missing subclass implementation'
return re.compile('^(?!x)x$')
def run_test(self, image: TestImage, block_size: str, preset: str,
repeats: int, keep_output: bool = True,
threads: Optional[int] = None) -> RunResult:
'''
Run the test N times.
Args:
image: The test image to compress.
block_size: The block size to use.
preset: The quality-performance preset to use.
repeats: The number of test runs.
keep_output: Should the test preserve output images?
threads: The thread count to use.
Return:
Tuple containing PSNR in dB, total time in seconds, coding time
in seconds, and coding rate in MT/s.
'''
# pylint: disable=assignment-from-no-return
command = self.build_cli(image, block_size, preset, keep_output,
threads)
# Execute test runs keeping best results
best_psnr = 0.0
best_total_time = sys.float_info.max
best_coding_time = sys.float_info.max
best_coding_rate = 0.0
for _ in range(0, repeats):
output = self.execute(command)
result = self.parse_output(image, output)
# Keep the best results (highest PSNR, lowest times, highest rate)
best_psnr = max(best_psnr, result[0])
best_total_time = min(best_total_time, result[1])
best_coding_time = min(best_coding_time, result[2])
best_coding_rate = max(best_coding_rate, result[3])
return (best_psnr, best_total_time, best_coding_time, best_coding_rate)
class Encoder2x(EncoderBase):
'''
This class wraps the latest astcenc interface, supported from the 2.0
release or later, which broke argument compatibility with the earlier
1.x series.
'''
version = 'main'
remap_color_switches = {
'ldr': '-tl',
'ldrs': '-ts',
'hdr': '-th',
'hdra': '-tH'
}
remap_output_types = {
'ldr': '.png',
'ldrs': '.png',
'hdr': '.exr',
'hdra': '.exr'
}
def __init__(self, variant: str, binary_path: Optional[Path] = None):
name = f'astcenc-{variant}-{self.version}'
if binary_path is None:
binary = 'astcenc'
post = f'-{variant}' if variant != 'universal' else ''
ext = '.exe' if os.name == 'nt' else ''
binary_path = Path('./') / 'bin' / f'{binary}{post}{ext}'
super().__init__(name, variant, binary_path)
def build_cli(self, image, block_size='6x6', preset='-thorough',
keep_output=True, threads=None):
operation = self.remap_color_switches[image.color_profile]
src_path = image.file_path
if keep_output:
extension = self.remap_output_types[image.color_profile]
dst_file = f'{image.file_path_out.stem}{extension}'
dst_dir = image.file_path_out.parent
dst_dir = dst_dir / self.name / preset[1:] / block_size
dst_dir.mkdir(parents=True, exist_ok=True)
dst_path = str(dst_dir / dst_file)
else:
if sys.platform == 'win32':
dst_path = 'nul'
else:
dst_path = '/dev/null'
command = [
str(self.binary_path), operation, str(src_path), str(dst_path),
block_size, preset, '-silent'
]
if image.color_format == 'xy':
command.append('-normal')
if image.is_alpha_scaled:
command.append('-a')
command.append('1')
if threads is not None:
command.append('-j')
command.append(f'{threads}')
return command
def get_psnr_pattern(self, image: TestImage) -> re.Pattern:
# HDR profile
if image.color_profile == 'hdr':
pattern_psnr = r'\s*mPSNR \(RGB\)(?: \[.*?\] )?:\s*([0-9.]*) dB.*'
# LDR profile color formats
elif image.color_format == 'rgba':
pattern_psnr = r'\s*PSNR \(LDR-RGBA\):\s*([0-9.]*) dB'
else:
pattern_psnr = r'\s*PSNR \(LDR-RGB\):\s*([0-9.]*) dB'
return re.compile(pattern_psnr)
def get_total_time_pattern(self) -> re.Pattern:
return re.compile(r'\s*Total time:\s*([0-9.]*) s')
def get_coding_time_pattern(self) -> re.Pattern:
return re.compile(r'\s*Coding time:\s*([0-9.]*) s')
def get_coding_rate_pattern(self) -> re.Pattern:
return re.compile(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: str, variant: str):
self.version = version
binary = 'astcenc'
post = f'-{variant}' if variant != 'universal' else ''
ext = '.exe' if os.name == 'nt' else ''
binary_path = Path('./') / 'Binaries' / f'{binary}{post}{ext}'
super().__init__(variant, binary_path)