• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# SPDX-License-Identifier: Apache-2.0
2# -----------------------------------------------------------------------------
3# Copyright 2019-2022 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"""
18These classes provide an abstraction around the astcenc command line tool,
19allowing the rest of the image test suite to ignore changes in the command line
20interface.
21"""
22
23import os
24import re
25import subprocess as sp
26import sys
27
28
29class EncoderBase():
30    """
31    This class is a Python wrapper for the `astcenc` binary, providing an
32    abstract means to set command line options and parse key results.
33
34    This is an abstract base class providing some generic helper functionality
35    used by concrete instantiations of subclasses.
36
37    Attributes:
38        binary: The encoder binary path.
39        variant: The encoder SIMD variant being tested.
40        name: The encoder name to use in reports.
41        VERSION: The encoder version or branch.
42        SWITCHES: Dict of switch replacements for different color formats.
43        OUTPUTS: Dict of output file extensions for different color formats.
44    """
45
46    VERSION = None
47    SWITCHES = None
48    OUTPUTS = None
49
50    def __init__(self, name, variant, binary):
51        """
52        Create a new encoder instance.
53
54        Args:
55            name (str): The name of the encoder.
56            variant (str): The SIMD variant of the encoder.
57            binary (str): The path to the binary on the file system.
58        """
59        self.name = name
60        self.variant = variant
61        self.binary = binary
62
63    def build_cli(self, image, blockSize="6x6", preset="-thorough",
64                  keepOutput=True, threads=None):
65        """
66        Build the command line needed for the given test.
67
68        Args:
69            image (TestImage): The test image to compress.
70            blockSize (str): The block size to use.
71            preset (str): The quality-performance preset to use.
72            keepOutput (bool): Should the test preserve output images? This is
73                only a hint and discarding output may be ignored if the encoder
74                version used can't do it natively.
75            threads (int or None): The thread count to use.
76
77        Returns:
78            list(str): A list of command line arguments.
79        """
80        # pylint: disable=unused-argument,no-self-use,redundant-returns-doc
81        assert False, "Missing subclass implementation"
82
83    def execute(self, command):
84        """
85        Run a subprocess with the specified command.
86
87        Args:
88            command (list(str)): The list of command line arguments.
89
90        Returns:
91            list(str): The output log (stdout) split into lines.
92        """
93        # pylint: disable=no-self-use
94        try:
95            result = sp.run(command, stdout=sp.PIPE, stderr=sp.PIPE,
96                            check=True, universal_newlines=True)
97        except (OSError, sp.CalledProcessError):
98            print("ERROR: Test run failed")
99            print("  + %s" % " ".join(command))
100            qcommand = ["\"%s\"" % x for x in command]
101            print("  + %s" % ", ".join(qcommand))
102            sys.exit(1)
103
104        return result.stdout.splitlines()
105
106    def parse_output(self, image, output):
107        """
108        Parse the log output for PSNR and performance metrics.
109
110        Args:
111            image (TestImage): The test image to compress.
112            output (list(str)): The output log from the compression process.
113
114        Returns:
115            tuple(float, float, float): PSNR in dB, TotalTime in seconds, and
116            CodingTime in seconds.
117        """
118        # Regex pattern for image quality
119        patternPSNR = re.compile(self.get_psnr_pattern(image))
120        patternTTime = re.compile(self.get_total_time_pattern())
121        patternCTime = re.compile(self.get_coding_time_pattern())
122        patternCRate = re.compile(self.get_coding_rate_pattern())
123
124        # Extract results from the log
125        runPSNR = None
126        runTTime = None
127        runCTime = None
128        runCRate = None
129
130        for line in output:
131            match = patternPSNR.match(line)
132            if match:
133                runPSNR = float(match.group(1))
134
135            match = patternTTime.match(line)
136            if match:
137                runTTime = float(match.group(1))
138
139            match = patternCTime.match(line)
140            if match:
141                runCTime = float(match.group(1))
142
143            match = patternCRate.match(line)
144            if match:
145                runCRate = float(match.group(1))
146
147        stdout = "\n".join(output)
148        assert runPSNR is not None, "No coding PSNR found %s" % stdout
149        assert runTTime is not None, "No total time found %s" % stdout
150        assert runCTime is not None, "No coding time found %s" % stdout
151        assert runCRate is not None, "No coding rate found %s" % stdout
152        return (runPSNR, runTTime, runCTime, runCRate)
153
154    def get_psnr_pattern(self, image):
155        """
156        Get the regex pattern to match the image quality metric.
157
158        Note, while this function is called PSNR for some images we may choose
159        to match another metric (e.g. mPSNR for HDR images).
160
161        Args:
162            image (TestImage): The test image we are compressing.
163
164        Returns:
165            str: The string for a regex pattern.
166        """
167        # pylint: disable=unused-argument,no-self-use,redundant-returns-doc
168        assert False, "Missing subclass implementation"
169
170    def get_total_time_pattern(self):
171        """
172        Get the regex pattern to match the total compression time.
173
174        Returns:
175            str: The string for a regex pattern.
176        """
177        # pylint: disable=unused-argument,no-self-use,redundant-returns-doc
178        assert False, "Missing subclass implementation"
179
180    def get_coding_time_pattern(self):
181        """
182        Get the regex pattern to match the coding compression time.
183
184        Returns:
185            str: The string for a regex pattern.
186        """
187        # pylint: disable=unused-argument,no-self-use,redundant-returns-doc
188        assert False, "Missing subclass implementation"
189
190    def run_test(self, image, blockSize, preset, testRuns, keepOutput=True,
191                 threads=None):
192        """
193        Run the test N times.
194
195        Args:
196            image (TestImage): The test image to compress.
197            blockSize (str): The block size to use.
198            preset (str): The quality-performance preset to use.
199            testRuns (int): The number of test runs.
200            keepOutput (bool): Should the test preserve output images? This is
201                only a hint and discarding output may be ignored if the encoder
202                version used can't do it natively.
203            threads (int or None): The thread count to use.
204
205        Returns:
206            tuple(float, float, float, float): Returns the best results from
207            the N test runs, as PSNR (dB), total time (seconds), coding time
208            (seconds), and coding rate (M pixels/s).
209        """
210        # pylint: disable=assignment-from-no-return
211        command = self.build_cli(image, blockSize, preset, keepOutput, threads)
212
213        # Execute test runs
214        bestPSNR = 0
215        bestTTime = sys.float_info.max
216        bestCTime = sys.float_info.max
217        bestCRate = 0
218
219        for _ in range(0, testRuns):
220            output = self.execute(command)
221            result = self.parse_output(image, output)
222
223            # Keep the best results (highest PSNR, lowest times, highest rate)
224            bestPSNR = max(bestPSNR, result[0])
225            bestTTime = min(bestTTime, result[1])
226            bestCTime = min(bestCTime, result[2])
227            bestCRate = max(bestCRate, result[3])
228
229        return (bestPSNR, bestTTime, bestCTime, bestCRate)
230
231
232class Encoder2x(EncoderBase):
233    """
234    This class wraps the latest `astcenc` 2.x series binaries from main branch.
235    branch.
236    """
237    VERSION = "main"
238
239    SWITCHES = {
240        "ldr": "-tl",
241        "ldrs": "-ts",
242        "hdr": "-th",
243        "hdra": "-tH"
244    }
245
246    OUTPUTS = {
247        "ldr": ".png",
248        "ldrs": ".png",
249        "hdr": ".exr",
250        "hdra": ".exr"
251    }
252
253    def __init__(self, variant, binary=None):
254        name = "astcenc-%s-%s" % (variant, self.VERSION)
255        if binary is None:
256            if os.name == 'nt':
257                binary = "./astcenc/astcenc-%s.exe" % variant
258            else:
259                binary = "./astcenc/astcenc-%s" % variant
260
261        super().__init__(name, variant, binary)
262
263    def build_cli(self, image, blockSize="6x6", preset="-thorough",
264                  keepOutput=True, threads=None):
265        opmode = self.SWITCHES[image.colorProfile]
266        srcPath = image.filePath
267
268        if keepOutput:
269            dstPath = image.outFilePath + self.OUTPUTS[image.colorProfile]
270            dstDir = os.path.dirname(dstPath)
271            dstFile = os.path.basename(dstPath)
272            dstPath = os.path.join(dstDir, self.name, preset[1:], blockSize, dstFile)
273
274            dstDir = os.path.dirname(dstPath)
275            os.makedirs(dstDir, exist_ok=True)
276        elif sys.platform == "win32":
277            dstPath = "nul"
278        else:
279            dstPath = "/dev/null"
280
281        command = [
282            self.binary, opmode, srcPath, dstPath,
283            blockSize, preset, "-silent"
284        ]
285
286        if image.colorFormat == "xy":
287            command.append("-normal")
288
289        if image.isMask:
290            command.append("-mask")
291
292        if image.isAlphaScaled:
293            command.append("-a")
294            command.append("1")
295
296        if threads is not None:
297            command.append("-j")
298            command.append("%u" % threads)
299
300        return command
301
302    def get_psnr_pattern(self, image):
303        if image.colorProfile != "hdr":
304            if image.colorFormat != "rgba":
305                patternPSNR = r"\s*PSNR \(LDR-RGB\):\s*([0-9.]*) dB"
306            else:
307                patternPSNR = r"\s*PSNR \(LDR-RGBA\):\s*([0-9.]*) dB"
308        else:
309            patternPSNR = r"\s*mPSNR \(RGB\)(?: \[.*?\] )?:\s*([0-9.]*) dB.*"
310        return patternPSNR
311
312    def get_total_time_pattern(self):
313        return r"\s*Total time:\s*([0-9.]*) s"
314
315    def get_coding_time_pattern(self):
316        return r"\s*Coding time:\s*([0-9.]*) s"
317
318    def get_coding_rate_pattern(self):
319        return r"\s*Coding rate:\s*([0-9.]*) MT/s"
320
321
322class Encoder2xRel(Encoder2x):
323    """
324    This class wraps a released 2.x series binary.
325    """
326    def __init__(self, version, variant):
327
328        self.VERSION = version
329
330        if os.name == 'nt':
331            binary = f"./Binaries/{version}/astcenc-{variant}.exe"
332        else:
333            binary = f"./Binaries/{version}/astcenc-{variant}"
334
335        super().__init__(variant, binary)
336
337
338class Encoder1_7(EncoderBase):
339    """
340    This class wraps the 1.7 series binaries.
341    """
342    VERSION = "1.7"
343
344    SWITCHES = {
345        "ldr": "-tl",
346        "ldrs": "-ts",
347        "hdr": "-t"
348    }
349
350    OUTPUTS = {
351        "ldr": ".tga",
352        "ldrs": ".tga",
353        "hdr": ".htga"
354    }
355
356    def __init__(self):
357        name = "astcenc-%s" % self.VERSION
358        if os.name == 'nt':
359            binary = "./Binaries/1.7/astcenc.exe"
360        else:
361            binary = "./Binaries/1.7/astcenc"
362
363        super().__init__(name, None, binary)
364
365    def build_cli(self, image, blockSize="6x6", preset="-thorough",
366                  keepOutput=True, threads=None):
367
368        if preset == "-fastest":
369            preset = "-fast"
370
371        opmode = self.SWITCHES[image.colorProfile]
372        srcPath = image.filePath
373
374        dstPath = image.outFilePath + self.OUTPUTS[image.colorProfile]
375        dstDir = os.path.dirname(dstPath)
376        dstFile = os.path.basename(dstPath)
377        dstPath = os.path.join(dstDir, self.name, preset[1:], blockSize, dstFile)
378
379        dstDir = os.path.dirname(dstPath)
380        os.makedirs(dstDir, exist_ok=True)
381
382        command = [
383            self.binary, opmode, srcPath, dstPath,
384            blockSize, preset, "-silentmode", "-time", "-showpsnr"
385        ]
386
387        if image.colorFormat == "xy":
388            command.append("-normal_psnr")
389
390        if image.colorProfile == "hdr":
391            command.append("-hdr")
392
393        if image.isMask:
394            command.append("-mask")
395
396        if image.isAlphaScaled:
397            command.append("-alphablend")
398
399        if threads is not None:
400            command.append("-j")
401            command.append("%u" % threads)
402
403        return command
404
405    def get_psnr_pattern(self, image):
406        if image.colorProfile != "hdr":
407            if image.colorFormat != "rgba":
408                patternPSNR = r"PSNR \(LDR-RGB\):\s*([0-9.]*) dB"
409            else:
410                patternPSNR = r"PSNR \(LDR-RGBA\):\s*([0-9.]*) dB"
411        else:
412            patternPSNR = r"mPSNR \(RGB\)(?: \[.*?\] )?:\s*([0-9.]*) dB.*"
413        return patternPSNR
414
415    def get_total_time_pattern(self):
416        # Pattern match on a new pattern for a 2.1 compatible variant
417        # return r"Elapsed time:\s*([0-9.]*) seconds.*"
418        return r"\s*Total time:\s*([0-9.]*) s"
419
420    def get_coding_time_pattern(self):
421        # Pattern match on a new pattern for a 2.1 compatible variant
422        # return r".* coding time: \s*([0-9.]*) seconds"
423        return r"\s*Coding time:\s*([0-9.]*) s"
424
425    def get_coding_rate_pattern(self):
426        # Pattern match on a new pattern for a 2.1 compatible variant
427        return r"\s*Coding rate:\s*([0-9.]*) MT/s"
428