blob: 18e337296090567c5093a5cd94489aadac8b94ad [file] [edit]
# SPDX-License-Identifier: Apache-2.0
# -----------------------------------------------------------------------------
# Copyright 2020-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.
# -----------------------------------------------------------------------------
'''
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, storing 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.
'''
from __future__ import annotations
import csv
import enum
from pathlib import Path
from typing import Optional
import numpy
@enum.unique
class ResultStatus(enum.IntEnum):
'''
An enumeration of test result status.
Attributes:
NOT_RUN: The test has not been run.
PASS: The test passed.
WARN: The quality was below pass threshold but above fail threshold.
FAIL: The quality was below the fail threshold.
'''
NOT_RUN = 0
PASS = 1
WARN = 2
FAIL = 3
class Record:
'''
A single result record, holding results for a single image and block size.
Attributes:
block_size: The block size.
name: The image name.
psnr: The image quality (PSNR dB)
total_time: The total time.
coding_time: The coding time.
coding_rate: The coding rate.
status: The test result status.
psnr_rel: The relative image quality (diff PSNR dB, >= 0 good)
total_time_rel: The relative total time vs ref (scale, <1 fast).
coding_time_rel: The relative coding time vs ref (scale, <1 fast).
coding_rate_rel: The relative coding rate vs ref (scale, <1 fast).
'''
def __init__(self, block_size: str, name: str, psnr: float,
total_time: float, coding_time: float, coding_rate: float):
'''
Create a result record, initially in the NOT_RUN status.
Args:
block_size (str): The block size.
name (str): The test image name.
psnr (float): The image quality PSNR, in dB.
total_time (float): The total compression time, in seconds.
coding_time (float): The coding compression time, in seconds.
coding_rate (float): The coding compression rate, in MPix/s.
'''
self.block_size = block_size
self.name = name
self.psnr = psnr
self.total_time = total_time
self.coding_time = coding_time
self.coding_rate = coding_rate
self.status = ResultStatus.NOT_RUN
self.psnr_rel: Optional[float] = None
self.total_time_rel: Optional[float] = None
self.coding_time_rel: Optional[float] = None
self.coding_rate_rel: Optional[float] = None
def set_status(self, result: ResultStatus) -> None:
'''
Set the result status.
Args:
result: The test result status to set.
'''
self.status = result
def set_relative_to_reference(self, reference: Record) -> None:
'''
Set relative results compared to an existing reference score.
Args:
reference: The reference result to compare against.
'''
self.psnr_rel = self.psnr - reference.psnr
try:
self.total_time_rel = reference.total_time / self.total_time
except ZeroDivisionError:
self.total_time_rel = float('NaN')
try:
self.coding_time_rel = reference.coding_time / self.coding_time
except ZeroDivisionError:
self.coding_time_rel = float('NaN')
class ResultSet:
'''
A set of results for a TestSet, across one or more block sizes.
Attributes:
test_set_name: The name of the test set that generated these results.
records: The list of test result records.
'''
def __init__(self, test_set_name: str):
'''
Create a new empty ResultSet.
Args:
test_set_name: The test set these results were generated by.
'''
self.test_set_name = test_set_name
self.records: list[Record] = []
def add_record(self, record: Record) -> None:
'''
Add a new test result record to this result set.
Args:
record: The test record to add.
'''
self.records.append(record)
def get_matching_record(self, other: Record) -> Record:
'''
Get a record matching the config of another record.
Args:
other: The pattern record to match.
Return:
The result, if present.
Raise:
KeyError: No match could be found.
'''
for record in self.records:
if record.block_size == other.block_size and \
record.name == other.name:
return record
raise KeyError()
def get_results_summary(self) -> ResultSummary:
'''
Get a results summary of all the records in this result set.
Return:
ResultSummary: The result summary.
'''
summary = ResultSummary()
for record in self.records:
summary.add_record(record)
return summary
def save_to_file(self, file_path: Path):
'''
Save this result set to a CSV file.
Args:
file_path: The output file path.
'''
dir_path = file_path.parent
dir_path.mkdir(parents=True, exist_ok=True)
with open(file_path, 'w', encoding='utf=8', newline='') as handle:
writer = csv.writer(handle)
self._save_header(writer)
for record in self.records:
self._save_record(writer, record)
@staticmethod
def _save_header(writer) -> None:
'''
Write the header to a result CSV file.
Args:
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: Record) -> None:
'''
Write a record to the CSV file.
Args:
writer (csv.writer): The CSV writer.
record (Record): The record to write.
'''
row = [
self.test_set_name,
record.block_size,
record.name,
f'{record.psnr:0.4f}',
f'{record.total_time:0.4f}',
f'{record.coding_time:0.4f}',
f'{record.coding_rate:0.4f}'
]
writer.writerow(row)
def load_from_file(self, file_path: Path) -> None:
'''
Load a reference result set from a CSV file on disk.
Args:
file_path: The input file path.
'''
with open(file_path, 'r', encoding='utf-8') as handle:
reader = csv.reader(handle)
# Skip the header
next(reader)
# Read the data rows
for row in reader:
assert row[0] == self.test_set_name
record = Record(
row[1],
row[2],
float(row[3]),
float(row[4]),
float(row[5]),
float(row[6])
)
self.add_record(record)
class ResultSummary:
'''
A summary of a set of results.
Attributes:
not_runs: 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.
psnr: The image quality (PSNR dB)
total_time: The total time.
coding_time: The coding time.
status: The test result status.
psnr_rel: The relative image quality (diff PSNR dB, >= 0 good)
total_time_rel: The relative total time vs ref (scale, <1 fast).
coding_time_rel: The relative coding time vs ref (scale, <1 fast)..
'''
def __init__(self):
'''
Create a new result summary.
'''
# Pass fail metrics
self.not_runs = 0
self.passes = 0
self.warnings = 0
self.fails = 0
# Absolute results
self.psnr: list[float] = []
self.total_time: list[float] = []
self.coding_time: list[float] = []
# Relative results
self.psnr_rel: list[float] = []
self.total_time_rel: list[float] = []
self.coding_time_rel: list[float] = []
def add_record(self, record: Record) -> None:
'''
Add a record to this summary.
Args:
record (Record): The Record to add.
'''
# Record pass/fail status
if record.status == ResultStatus.PASS:
self.passes += 1
elif record.status == ResultStatus.WARN:
self.warnings += 1
elif record.status == ResultStatus.FAIL:
self.fails += 1
else:
self.not_runs += 1
# Store absolute results
self.total_time.append(record.total_time)
self.coding_time.append(record.coding_time)
self.psnr.append(record.psnr)
# Store relative results, if we have them
if record.total_time_rel is not None:
assert record.coding_time_rel is not None
assert record.psnr_rel is not None
self.total_time_rel.append(record.total_time_rel)
self.coding_time_rel.append(record.coding_time_rel)
self.psnr_rel.append(record.psnr_rel)
def get_worst_result(self) -> ResultStatus:
'''
Get the worst result in this set.
Return:
The worst test result.
'''
if self.fails:
return ResultStatus.FAIL
if self.warnings:
return ResultStatus.WARN
if self.passes:
return ResultStatus.PASS
return ResultStatus.NOT_RUN
def __str__(self) -> str:
lines = ['']
# Emit overall summary of the run
overall = self.get_worst_result().name
line = [
f'Set Status: {overall}',
f' (Pass: {self.passes}',
f' | Warn: {self.warnings}',
f' | Fail: {self.fails}',
]
lines.append(''.join(line))
# If no relative scores just return the summary
if not self.total_time_rel:
return '\n'.join(lines)
lines.append('')
def format_line(name, value, units) -> str:
mean_value = numpy.mean(value)
std_value = numpy.std(value)
line = [
f'{name:<15}',
f'Mean: {mean_value:>+7.3f} {units:<3}',
f' Std: {std_value:>+6.3f} {units:<3}'
]
return ''.join(line)
# Performance summaries
lines.append(format_line('Total speed:', self.total_time_rel, 'x'))
lines.append(format_line('Coding speed:', self.coding_time_rel, 'x'))
lines.append(format_line('Quality diff:', self.psnr_rel, 'dB'))
lines.append(format_line('Coding time:', self.coding_time, 's'))
# Filter out 999 dB images from the results ...
mod_psnr = [x for x in self.psnr if x < 999.0]
lines.append(format_line('Quality:', mod_psnr, 'dB'))
return '\n'.join(lines)