Skip to content

Commit acee87a

Browse files
committed
feat: Add a pixel comparison method for codecs that don’t generate identical outputs
-Old codecs like MPEG-2 Video do not use a standarized IDCT (MPEG-C Part1) causing mismatches in the generated output. -This new method allows creating tests suites that will compare the output pixel by pixel with a tolerance range
1 parent 2f2bb22 commit acee87a

File tree

12 files changed

+347
-69
lines changed

12 files changed

+347
-69
lines changed

check/dummy.json

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,6 @@
1111
"output_format": "yuv420p",
1212
"result": "19b8d716307ae8b28c81b21f14f870dc"
1313
}
14-
]
15-
}
14+
],
15+
"test_method": "md5"
16+
}

fluster/decoder.py

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from abc import ABC, abstractmethod
1919
from functools import lru_cache
2020
from shutil import which
21-
from typing import List, Type
21+
from typing import List, Optional, Type
2222

2323
from fluster.codec import Codec, OutputFormat
2424
from fluster.utils import normalize_binary_cmd
@@ -32,6 +32,7 @@ class Decoder(ABC):
3232
hw_acceleration = False
3333
description = ""
3434
binary = ""
35+
is_reference = False
3536

3637
def __init__(self) -> None:
3738
if self.binary:
@@ -76,3 +77,16 @@ def register_decoder(cls: Type[Decoder]) -> Type[Decoder]:
7677
DECODERS.append(cls())
7778
DECODERS.sort(key=lambda dec: dec.name)
7879
return cls
80+
81+
82+
def get_reference_decoder_for_codec(codec: Codec) -> Optional["Decoder"]:
83+
"""Find the reference decoder for a specific codec"""
84+
85+
reference_decoders = [d for d in DECODERS if d.codec == codec and d.is_reference]
86+
87+
if not reference_decoders:
88+
return None
89+
if len(reference_decoders) > 1:
90+
print(f"Multiple reference decoders found for codec {codec.name}")
91+
92+
return reference_decoders[0]

fluster/decoders/iso_mpeg2_aac.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class ISOAACDecoder(Decoder):
3030
description = "ISO MPEG2 AAC reference decoder"
3131
codec = Codec.AAC
3232
binary = "aacdec_mc"
33+
is_reference = True
3334

3435
def decode(
3536
self,
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
# Fluster - testing framework for decoders conformance
2+
# Copyright (C) 2024, Fluendo, S.A.
3+
# Author: Rubén Sánchez <[email protected]>, Fluendo, S.A.
4+
#
5+
# This library is free software; you can redistribute it and/or
6+
# modify it under the terms of the GNU Lesser General Public License
7+
# as published by the Free Software Foundation, either version 3
8+
# of the License, or (at your option) any later version.
9+
#
10+
# This library is distributed in the hope that it will be useful,
11+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13+
# Lesser General Public License for more details.
14+
#
15+
# You should have received a copy of the GNU Lesser General Public
16+
# License along with this library. If not, see <https://www.gnu.org/licenses/>.
17+
import glob
18+
import os
19+
import tempfile
20+
21+
from fluster.codec import Codec, OutputFormat
22+
from fluster.decoder import Decoder, register_decoder
23+
from fluster.utils import file_checksum, run_command
24+
25+
26+
@register_decoder
27+
class ISOMPEG2VDecoder(Decoder):
28+
"""ISO MPEG2 Video reference decoder implementation"""
29+
30+
name = "ISO-MPEG2-VIDEO"
31+
description = "ISO MPEG2 Video reference decoder"
32+
codec = Codec.MPEG2_VIDEO
33+
binary = "mpeg2decode"
34+
is_reference = True
35+
36+
def decode(
37+
self,
38+
input_filepath: str,
39+
output_filepath: str,
40+
output_format: OutputFormat,
41+
timeout: int,
42+
verbose: bool,
43+
keep_files: bool,
44+
) -> str:
45+
"""Decodes input_filepath in output_filepath"""
46+
with tempfile.TemporaryDirectory() as temp_dir:
47+
run_command(
48+
[self.binary, "-b", input_filepath, "-f", "-r", "-o0", os.path.join(temp_dir, "rec%d")],
49+
timeout=timeout,
50+
verbose=verbose,
51+
)
52+
self._merge_yuv_files(temp_dir, output_filepath)
53+
checksum = file_checksum(output_filepath)
54+
55+
if not keep_files:
56+
os.remove(output_filepath)
57+
58+
return checksum
59+
60+
@staticmethod
61+
def _merge_yuv_files(input_dir: str, output_filepath: str) -> None:
62+
"""Merge YUV frames into an only raw .yuv file for mpeg2 video test suite"""
63+
num_frames = len(glob.glob(os.path.join(input_dir, "rec*.Y")))
64+
65+
if num_frames == 0:
66+
raise ValueError("No frames were decoded")
67+
68+
with open(output_filepath, "wb") as output_file:
69+
for frame_num in range(num_frames):
70+
frame_name = f"rec{frame_num}"
71+
y_file = os.path.join(input_dir, f"{frame_name}.Y")
72+
u_file = os.path.join(input_dir, f"{frame_name}.U")
73+
v_file = os.path.join(input_dir, f"{frame_name}.V")
74+
75+
if not (os.path.exists(y_file) and os.path.exists(u_file) and os.path.exists(v_file)):
76+
print(f"Warning: Files for frame {frame_name} not found in {input_dir}")
77+
continue
78+
79+
chunk_size = 1024
80+
for plane_file in [y_file, u_file, v_file]:
81+
with open(plane_file, "rb") as file:
82+
chunk = file.read(chunk_size)
83+
while chunk:
84+
output_file.write(chunk)
85+
chunk = file.read(chunk_size)

fluster/decoders/iso_mpeg4_aac.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class ISOAACDecoder(Decoder):
3030
description = "ISO MPEG4 AAC reference decoder"
3131
codec = Codec.AAC
3232
binary = "mp4audec_mc"
33+
is_reference = True
3334

3435
def decode(
3536
self,

fluster/decoders/iso_mpeg4_aac_er.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ class ISOAACDecoder(Decoder):
3030
description = "ISO MPEG4 AAC ER reference decoder"
3131
codec = Codec.AAC
3232
binary = "mp4audec"
33+
is_reference = True
3334

3435
def decode(
3536
self,

fluster/fluster.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
from fluster.decoders import * # noqa: F403
3232
from fluster.decoders.av1_aom import AV1AOMDecoder
3333
from fluster.test_suite import Context as TestSuiteContext
34-
from fluster.test_suite import TestSuite
34+
from fluster.test_suite import TestMethod, TestSuite
3535
from fluster.test_vector import TestVector, TestVectorResult
3636

3737

@@ -278,6 +278,8 @@ def run_test_suites(self, ctx: Context) -> None:
278278
decoder.multiple_layers = True
279279
if decoder.codec != test_suite.codec:
280280
continue
281+
if test_suite.test_method == TestMethod.PIXEL and decoder.is_reference:
282+
continue
281283
test_suite_res = test_suite.run(
282284
ctx.to_test_suite_context(
283285
decoder,

fluster/test.py

Lines changed: 145 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,17 @@
66
# modify it under the terms of the GNU Lesser General Public License
77
# as published by the Free Software Foundation, either version 3
88
# of the License, or (at your option) any later version.
9-
#
10-
# This library is distributed in the hope that it will be useful,
11-
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12-
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
13-
# Lesser General Public License for more details.
14-
#
15-
# You should have received a copy of the GNU Lesser General Public
16-
# License along with this library. If not, see <https://www.gnu.org/licenses/>.
179

1810
import os
1911
import unittest
12+
from abc import abstractmethod
2013
from subprocess import TimeoutExpired
2114
from time import perf_counter
2215
from typing import Any
2316

2417
from fluster.decoder import Decoder
2518
from fluster.test_vector import TestVector, TestVectorResult
26-
from fluster.utils import normalize_path
19+
from fluster.utils import compare_byte_wise_files, normalize_path
2720

2821

2922
class Test(unittest.TestCase):
@@ -51,57 +44,160 @@ def __init__(
5144
self.timeout = timeout
5245
self.keep_files = keep_files
5346
self.verbose = verbose
54-
setattr(self, test_vector.name, self._test)
47+
self._keep_files_during_test = False
48+
self.test_vector_result = self.test_suite.test_vectors[self.test_vector.name]
49+
50+
# Set up the test method
51+
setattr(self, test_vector.name, self._test_wrapper)
5552
super().__init__(test_vector.name)
5653

54+
# Initialize file paths
55+
self._initialize_file_paths()
56+
57+
def _initialize_file_paths(self) -> None:
58+
"""Initialize input and output file paths."""
59+
self.output_filepath = normalize_path(os.path.join(self.output_dir, self.test_vector.name + ".out"))
60+
61+
input_dir = os.path.join(self.resources_dir, self.test_suite.name)
62+
63+
if not self.test_suite.is_single_archive:
64+
input_dir = os.path.join(input_dir, self.test_vector.name)
65+
66+
self.input_filepath = normalize_path(os.path.join(input_dir, self.test_vector.input_file))
67+
68+
def _execute_decode(self) -> str:
69+
"""Execute the decoder and return the result."""
70+
keep_files_for_decode = self._keep_files_during_test or self.keep_files
71+
72+
return self.decoder.decode(
73+
self.input_filepath,
74+
self.output_filepath,
75+
self.test_vector.output_format,
76+
self.timeout,
77+
self.verbose,
78+
keep_files_for_decode,
79+
)
80+
81+
def _cleanup_if_needed(self) -> None:
82+
"""Clean up output files if keep_files is False."""
83+
if not self.keep_files and os.path.exists(self.output_filepath):
84+
os.remove(self.output_filepath)
85+
86+
def _test_wrapper(self) -> None:
87+
try:
88+
self._test()
89+
finally:
90+
self._cleanup_if_needed()
91+
5792
def _test(self) -> None:
93+
"""Execute the test and process results."""
5894
if self.skip:
59-
self.test_suite.test_vectors[self.test_vector.name].test_result = TestVectorResult.NOT_RUN
95+
self.test_vector_result.test_result = TestVectorResult.NOT_RUN
6096
return
6197

62-
output_filepath = os.path.join(self.output_dir, self.test_vector.name + ".out")
63-
64-
input_filepath = os.path.join(
65-
self.resources_dir,
66-
self.test_suite.name,
67-
(self.test_vector.name if not self.test_suite.is_single_archive else ""),
68-
self.test_vector.input_file,
69-
)
70-
output_filepath = normalize_path(output_filepath)
71-
input_filepath = normalize_path(input_filepath)
98+
start = perf_counter()
7299

73100
try:
74-
start = perf_counter()
75-
result = self.decoder.decode(
76-
input_filepath,
77-
output_filepath,
78-
self.test_vector.output_format,
79-
self.timeout,
80-
self.verbose,
81-
self.keep_files,
82-
)
83-
self.test_suite.test_vectors[self.test_vector.name].test_time = perf_counter() - start
101+
result = self._execute_decode()
102+
self.test_vector_result.test_time = perf_counter() - start
84103
except TimeoutExpired:
85-
self.test_suite.test_vectors[self.test_vector.name].test_result = TestVectorResult.TIMEOUT
86-
self.test_suite.test_vectors[self.test_vector.name].test_time = perf_counter() - start
104+
self.test_vector_result.test_result = TestVectorResult.TIMEOUT
105+
self.test_vector_result.test_time = perf_counter() - start
87106
raise
88107
except Exception:
89-
self.test_suite.test_vectors[self.test_vector.name].test_result = TestVectorResult.ERROR
90-
self.test_suite.test_vectors[self.test_vector.name].test_time = perf_counter() - start
108+
self.test_vector_result.test_result = TestVectorResult.ERROR
109+
self.test_vector_result.test_time = perf_counter() - start
91110
raise
92111

93-
if not self.keep_files and os.path.exists(output_filepath) and os.path.isfile(output_filepath):
94-
os.remove(output_filepath)
95-
96-
if not self.reference:
97-
self.test_suite.test_vectors[self.test_vector.name].test_result = TestVectorResult.FAIL
98-
if self.test_vector.result.lower() == result.lower():
99-
self.test_suite.test_vectors[self.test_vector.name].test_result = TestVectorResult.SUCCESS
100-
self.assertEqual(
101-
self.test_vector.result.lower(),
102-
result.lower(),
103-
self.test_vector.name,
104-
)
112+
if self.reference:
113+
self.test_vector_result.test_result = TestVectorResult.REFERENCE
114+
self.test_vector_result.result = result
105115
else:
106-
self.test_suite.test_vectors[self.test_vector.name].test_result = TestVectorResult.REFERENCE
107-
self.test_suite.test_vectors[self.test_vector.name].result = result
116+
try:
117+
self.compare_result(result)
118+
self.test_vector_result.test_result = TestVectorResult.SUCCESS
119+
except Exception:
120+
self.test_vector_result.test_result = TestVectorResult.FAIL
121+
raise
122+
123+
@abstractmethod
124+
def compare_result(self, result: str) -> None:
125+
"""Compare the test result with the expected value.
126+
127+
Args:
128+
result: The result string from the decoder
129+
"""
130+
131+
132+
class MD5ComparisonTest(Test):
133+
"""Test class for MD5 comparison"""
134+
135+
def compare_result(self, result: str) -> None:
136+
"""Compare MD5 hash results."""
137+
expected = self.test_vector.result.lower()
138+
actual = result.lower()
139+
140+
self.assertEqual(expected, actual, self.test_vector.name)
141+
142+
143+
class PixelComparisonTest(Test):
144+
"""Test class for pixel comparison"""
145+
146+
def __init__(
147+
self,
148+
decoder: Decoder,
149+
test_suite: Any, # can't use TestSuite type because of circular dependency
150+
test_vector: TestVector,
151+
skip: bool,
152+
output_dir: str,
153+
reference: bool,
154+
timeout: int,
155+
keep_files: bool,
156+
verbose: bool,
157+
reference_decoder: Decoder,
158+
):
159+
super().__init__(
160+
decoder,
161+
test_suite,
162+
test_vector,
163+
skip,
164+
output_dir,
165+
reference,
166+
timeout,
167+
keep_files,
168+
verbose,
169+
)
170+
self._keep_files_during_test = True
171+
self.reference_decoder = reference_decoder
172+
self.reference_filepath = normalize_path(os.path.join(self.output_dir, self.test_vector.name + "_ref.yuv"))
173+
174+
def _decode_reference(self) -> str:
175+
"""Decode the reference file."""
176+
keep_files_for_decode = self._keep_files_during_test or self.keep_files
177+
178+
return self.reference_decoder.decode(
179+
self.input_filepath,
180+
self.reference_filepath,
181+
self.test_vector.output_format,
182+
self.timeout,
183+
self.verbose,
184+
keep_files_for_decode,
185+
)
186+
187+
def _cleanup_if_needed(self) -> None:
188+
super()._cleanup_if_needed()
189+
if not self.keep_files and os.path.exists(self.reference_filepath):
190+
os.remove(self.reference_filepath)
191+
192+
def compare_result(self, result: str) -> None:
193+
"""Compare decoded output with reference decoder output pixel-wise."""
194+
reference_result = self._decode_reference()
195+
196+
if not os.path.exists(self.reference_filepath) and os.path.exists(reference_result):
197+
self.reference_filepath = reference_result
198+
199+
comparison_result = compare_byte_wise_files(
200+
self.reference_filepath, self.output_filepath, keep_files=self.keep_files
201+
)
202+
203+
self.assertEqual(0, comparison_result, self.test_vector.name)

0 commit comments

Comments
 (0)