mirror of
https://github.com/MobileGL-Dev/MobileGL
synced 2026-11-07 01:53:24 +09:00
559 lines
25 KiB
Python
Executable File
559 lines
25 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
# MobileGL - tools/trace_replay/compare_actuals.py
|
|
# Copyright (c) 2025-2026 MobileGL-Dev
|
|
# Licensed under the GNU Lesser General Public License v3.0:
|
|
# https://www.gnu.org/licenses/gpl-3.0.txt
|
|
# https://www.gnu.org/licenses/lgpl-3.0.txt
|
|
# SPDX-License-Identifier: LGPL-3.0-only
|
|
# End of Source File Header
|
|
|
|
"""Actual-vs-actual SSIM for archived retrace repeats, on the retrace gate's own SSIM.
|
|
|
|
WHY THIS EXISTS. `result.json` carries ONE number per run: this attempt's actual against its
|
|
golden. P7-7 E0 asks a different question - "are three repeats of the same case on the same
|
|
device the same picture?" - and no artifact in the tree answers it, because every repeat
|
|
overwrites the previous one's `.trace-work/android-retrace-result/<case>-<backend>/`. With the
|
|
repeats archived (`run_android_retrace_local.py --archive-dir`), this script computes the
|
|
missing pair: SSIM(repeat N, repeat 1) beside the recorded SSIM(repeat N, golden).
|
|
|
|
THE SSIM IS NOT A NEW ONE. It is a transcription of the retrace gate's own comparator,
|
|
`android-plugin/app/src/trace/cpp/trace_replay_core.cpp:630` (`ComputeChannelSsim`) and `:671`
|
|
(`ComputeRgbSsim`) - a single global window per channel, C1 = (0.01*255)^2, C2 = (0.03*255)^2,
|
|
the three color channels averaged and alpha ignored - and the crop comes from the repeat's own
|
|
`result.json`, so the window is the one the gate scored. A different SSIM (a gaussian-windowed
|
|
one, say) would produce numbers that could not be read beside the recorded ones at all, which is
|
|
the whole reason this file does not import a library implementation.
|
|
|
|
Two modes:
|
|
|
|
compare_actuals.py compare A.png B.png [--crop-x N --crop-y N --crop-width N --crop-height N]
|
|
compare_actuals.py summary <archive-dir> [--json PATH]
|
|
compare_actuals.py --self-test
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
from operator import mul
|
|
from pathlib import Path
|
|
import re
|
|
import sys
|
|
import zlib
|
|
|
|
# (0.01 * 255)^2 and (0.03 * 255)^2, verbatim from trace_replay_core.cpp:662-663.
|
|
C1 = 6.5025
|
|
C2 = 58.5225
|
|
|
|
_SQUARES = [value * value for value in range(256)]
|
|
|
|
|
|
class ImageError(ValueError):
|
|
"""A PNG this reader will not decode, or a pair it will not compare."""
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# PNG decode. Deliberately stdlib-only (zlib): this script has to run in the device window on
|
|
# whatever interpreter is at hand, and an unavailable Pillow would turn "the repeats diverged"
|
|
# into "the tool did not run", which are not the same finding. The accepted subset is what
|
|
# libpng gives trace_replay_core after its own normalization (ReadPngRgba, :278): 8- and 16-bit
|
|
# grayscale/RGB/grayscale+alpha/RGBA and 8-bit palette, non-interlaced. Anything else is
|
|
# REFUSED BY NAME rather than guessed at.
|
|
# ---------------------------------------------------------------------------------------------
|
|
_PNG_MAGIC = b"\x89PNG\r\n\x1a\n"
|
|
_CHANNELS = {0: 1, 2: 3, 3: 1, 4: 2, 6: 4}
|
|
|
|
|
|
def _chunks(data):
|
|
if not data.startswith(_PNG_MAGIC):
|
|
raise ImageError("not a PNG file")
|
|
offset = len(_PNG_MAGIC)
|
|
while offset + 8 <= len(data):
|
|
length = int.from_bytes(data[offset:offset + 4], "big")
|
|
kind = data[offset + 4:offset + 8]
|
|
body = data[offset + 8:offset + 8 + length]
|
|
if len(body) != length:
|
|
raise ImageError("truncated PNG chunk: " + kind.decode("ascii", "replace"))
|
|
yield kind, body
|
|
offset += 12 + length
|
|
|
|
|
|
def _unfilter(raw, width, height, bytes_per_pixel, row_bytes):
|
|
out = bytearray(height * row_bytes)
|
|
previous = bytearray(row_bytes)
|
|
position = 0
|
|
for y in range(height):
|
|
filter_type = raw[position]
|
|
position += 1
|
|
line = bytearray(raw[position:position + row_bytes])
|
|
position += row_bytes
|
|
if len(line) != row_bytes:
|
|
raise ImageError("truncated PNG scanline")
|
|
if filter_type == 1:
|
|
for index in range(bytes_per_pixel, row_bytes):
|
|
line[index] = (line[index] + line[index - bytes_per_pixel]) & 0xFF
|
|
elif filter_type == 2:
|
|
for index in range(row_bytes):
|
|
line[index] = (line[index] + previous[index]) & 0xFF
|
|
elif filter_type == 3:
|
|
for index in range(row_bytes):
|
|
left = line[index - bytes_per_pixel] if index >= bytes_per_pixel else 0
|
|
line[index] = (line[index] + ((left + previous[index]) >> 1)) & 0xFF
|
|
elif filter_type == 4:
|
|
for index in range(row_bytes):
|
|
left = line[index - bytes_per_pixel] if index >= bytes_per_pixel else 0
|
|
up = previous[index]
|
|
upper_left = previous[index - bytes_per_pixel] if index >= bytes_per_pixel else 0
|
|
estimate = left + up - upper_left
|
|
distance_left = abs(estimate - left)
|
|
distance_up = abs(estimate - up)
|
|
distance_upper_left = abs(estimate - upper_left)
|
|
if distance_left <= distance_up and distance_left <= distance_upper_left:
|
|
predictor = left
|
|
elif distance_up <= distance_upper_left:
|
|
predictor = up
|
|
else:
|
|
predictor = upper_left
|
|
line[index] = (line[index] + predictor) & 0xFF
|
|
elif filter_type != 0:
|
|
raise ImageError("unknown PNG filter type %d" % filter_type)
|
|
out[y * row_bytes:(y + 1) * row_bytes] = line
|
|
previous = line
|
|
return out
|
|
|
|
|
|
class Image:
|
|
"""An 8-bit RGBA raster, the shape trace_replay_core compares in."""
|
|
|
|
__slots__ = ("width", "height", "pixels")
|
|
|
|
def __init__(self, width, height, pixels):
|
|
self.width = width
|
|
self.height = height
|
|
self.pixels = pixels
|
|
|
|
def row(self, y):
|
|
stride = self.width * 4
|
|
return self.pixels[y * stride:(y + 1) * stride]
|
|
|
|
|
|
def read_png_rgba(path):
|
|
data = Path(path).read_bytes()
|
|
header = None
|
|
palette = b""
|
|
transparency = b""
|
|
compressed = bytearray()
|
|
for kind, body in _chunks(data):
|
|
if kind == b"IHDR":
|
|
header = body
|
|
elif kind == b"PLTE":
|
|
palette = body
|
|
elif kind == b"tRNS":
|
|
transparency = body
|
|
elif kind == b"IDAT":
|
|
compressed += body
|
|
elif kind == b"IEND":
|
|
break
|
|
if header is None or len(header) < 13:
|
|
raise ImageError("PNG has no IHDR: " + str(path))
|
|
width = int.from_bytes(header[0:4], "big")
|
|
height = int.from_bytes(header[4:8], "big")
|
|
depth = header[8]
|
|
color_type = header[9]
|
|
interlace = header[12]
|
|
if width <= 0 or height <= 0:
|
|
raise ImageError("PNG has a zero dimension: " + str(path))
|
|
if interlace != 0:
|
|
raise ImageError("interlaced PNG is not supported: " + str(path))
|
|
if color_type not in _CHANNELS:
|
|
raise ImageError("unsupported PNG color type %d: %s" % (color_type, path))
|
|
if depth not in (8, 16) or (color_type == 3 and depth != 8):
|
|
raise ImageError("unsupported PNG bit depth %d (color type %d): %s"
|
|
% (depth, color_type, path))
|
|
channels = _CHANNELS[color_type]
|
|
sample_bytes = depth // 8
|
|
bytes_per_pixel = channels * sample_bytes
|
|
row_bytes = width * bytes_per_pixel
|
|
raw = _unfilter(zlib.decompress(bytes(compressed)), width, height, bytes_per_pixel, row_bytes)
|
|
|
|
pixels = bytearray(width * height * 4)
|
|
for y in range(height):
|
|
source = raw[y * row_bytes:(y + 1) * row_bytes]
|
|
# png_set_strip_16: keep the high byte, exactly as the gate's reader does.
|
|
if sample_bytes == 2:
|
|
source = source[0::2]
|
|
base = y * width * 4
|
|
if color_type == 6:
|
|
pixels[base:base + width * 4] = source
|
|
continue
|
|
for x in range(width):
|
|
out = base + x * 4
|
|
if color_type == 0:
|
|
value = source[x]
|
|
pixels[out] = pixels[out + 1] = pixels[out + 2] = value
|
|
pixels[out + 3] = 255
|
|
elif color_type == 2:
|
|
pixels[out:out + 3] = source[x * 3:x * 3 + 3]
|
|
pixels[out + 3] = 255
|
|
elif color_type == 4:
|
|
value = source[x * 2]
|
|
pixels[out] = pixels[out + 1] = pixels[out + 2] = value
|
|
pixels[out + 3] = source[x * 2 + 1]
|
|
else: # palette
|
|
index = source[x]
|
|
if (index + 1) * 3 > len(palette):
|
|
raise ImageError("PNG palette index out of range: " + str(path))
|
|
pixels[out:out + 3] = palette[index * 3:index * 3 + 3]
|
|
pixels[out + 3] = transparency[index] if index < len(transparency) else 255
|
|
return Image(width, height, bytes(pixels))
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# The gate's SSIM, transcribed.
|
|
# ---------------------------------------------------------------------------------------------
|
|
def channel_ssim(actual, golden, x0, y0, compare_width, compare_height, channel):
|
|
"""trace_replay_core.cpp:630 ComputeChannelSsim, one global window over the crop."""
|
|
count = float(compare_width) * float(compare_height)
|
|
sum_a = sum_g = sum_aa = sum_gg = sum_ag = 0
|
|
stride = actual.width * 4
|
|
golden_stride = golden.width * 4
|
|
start_a = x0 * 4 + channel
|
|
stop_a = (x0 + compare_width) * 4 + channel
|
|
for row in range(compare_height):
|
|
base_a = (y0 + row) * stride
|
|
base_g = (y0 + row) * golden_stride
|
|
a = actual.pixels[base_a + start_a:base_a + stop_a:4]
|
|
g = golden.pixels[base_g + start_a:base_g + stop_a:4]
|
|
sum_a += sum(a)
|
|
sum_g += sum(g)
|
|
sum_aa += sum(map(_SQUARES.__getitem__, a))
|
|
sum_gg += sum(map(_SQUARES.__getitem__, g))
|
|
sum_ag += sum(map(mul, a, g))
|
|
|
|
mean_a = sum_a / count
|
|
mean_g = sum_g / count
|
|
variance_a = max(0.0, sum_aa / count - mean_a * mean_a)
|
|
variance_g = max(0.0, sum_gg / count - mean_g * mean_g)
|
|
covariance = sum_ag / count - mean_a * mean_g
|
|
luminance = (2.0 * mean_a * mean_g + C1) / (mean_a * mean_a + mean_g * mean_g + C1)
|
|
contrast_structure = (2.0 * covariance + C2) / (variance_a + variance_g + C2)
|
|
return luminance * contrast_structure
|
|
|
|
|
|
def rgb_ssim(actual, golden, x0, y0, compare_width, compare_height):
|
|
"""trace_replay_core.cpp:671 ComputeRgbSsim - the three color channels, alpha ignored."""
|
|
total = 0.0
|
|
for channel in range(3):
|
|
total += channel_ssim(actual, golden, x0, y0, compare_width, compare_height, channel)
|
|
return total / 3.0
|
|
|
|
|
|
def resolve_crop(actual, golden, crop_x=0, crop_y=0, crop_width=0, crop_height=0):
|
|
"""CompareAgainstOneGolden's bounds rules (trace_replay_core.cpp:683-717), verbatim.
|
|
|
|
In particular: an uncropped pair of DIFFERENT sizes is a refusal, not a silent intersection.
|
|
Two repeats that rendered at different sizes are a finding, and averaging over whichever
|
|
rectangle happened to be common to both would bury it.
|
|
"""
|
|
if crop_width <= 0 and crop_height <= 0 and (
|
|
actual.width != golden.width or actual.height != golden.height):
|
|
raise ImageError("image size %dx%d does not match %dx%d"
|
|
% (actual.width, actual.height, golden.width, golden.height))
|
|
compare_width = crop_width if crop_width > 0 else actual.width
|
|
compare_height = crop_height if crop_height > 0 else actual.height
|
|
if (compare_width <= 0 or compare_height <= 0 or crop_x < 0 or crop_y < 0
|
|
or crop_x + compare_width > actual.width or crop_y + compare_height > actual.height
|
|
or crop_x + compare_width > golden.width or crop_y + compare_height > golden.height):
|
|
raise ImageError("compare crop is outside image bounds")
|
|
return crop_x, crop_y, compare_width, compare_height
|
|
|
|
|
|
def mismatch_pixels(actual, golden, x0, y0, compare_width, compare_height):
|
|
"""Exact RGB inequality count, the gate's `mismatchPixels` (trace_replay_core.cpp:719-736)."""
|
|
total = 0
|
|
stride = actual.width * 4
|
|
golden_stride = golden.width * 4
|
|
for row in range(compare_height):
|
|
base_a = (y0 + row) * stride + x0 * 4
|
|
base_g = (y0 + row) * golden_stride + x0 * 4
|
|
a = actual.pixels[base_a:base_a + compare_width * 4]
|
|
g = golden.pixels[base_g:base_g + compare_width * 4]
|
|
if a == g:
|
|
continue
|
|
for index in range(compare_width):
|
|
if a[index * 4:index * 4 + 3] != g[index * 4:index * 4 + 3]:
|
|
total += 1
|
|
return total
|
|
|
|
|
|
def compare_files(left, right, crop_x=0, crop_y=0, crop_width=0, crop_height=0):
|
|
a = read_png_rgba(left)
|
|
b = read_png_rgba(right)
|
|
x0, y0, width, height = resolve_crop(a, b, crop_x, crop_y, crop_width, crop_height)
|
|
return {"left": str(left), "right": str(right), "width": a.width, "height": a.height,
|
|
"crop": {"x": x0, "y": y0, "width": width, "height": height},
|
|
"ssim": rgb_ssim(a, b, x0, y0, width, height),
|
|
"mismatch_pixels": mismatch_pixels(a, b, x0, y0, width, height)}
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# The archive summary.
|
|
# ---------------------------------------------------------------------------------------------
|
|
REPEAT_DIR = re.compile(r"^repeat-(\d+)$")
|
|
|
|
|
|
def digest(path):
|
|
h = hashlib.sha256()
|
|
with Path(path).open("rb") as stream:
|
|
for block in iter(lambda: stream.read(1024 * 1024), b""):
|
|
h.update(block)
|
|
return h.hexdigest()
|
|
|
|
|
|
def _actual_png(directory):
|
|
candidates = sorted(directory.glob("*-actual.png"))
|
|
return candidates[0] if candidates else None
|
|
|
|
|
|
def summarize(archive_dir):
|
|
"""case x repeat x SSIM-vs-golden x SSIM-vs-first-repeat over an --archive-dir tree.
|
|
|
|
SSIM-VS-GOLDEN IS READ, NOT RECOMPUTED. It is the number the device wrote into result.json,
|
|
against the golden the device matched; recomputing it here would silently answer a different
|
|
question whenever a run matched `alternate_golden`. SSIM-vs-first-repeat is the one this
|
|
script owns, and it uses the SAME crop result.json recorded, so the two columns describe the
|
|
same rectangle.
|
|
"""
|
|
archive = Path(archive_dir)
|
|
if not archive.is_dir():
|
|
raise ImageError("not a directory: " + str(archive))
|
|
rows = []
|
|
for arm in sorted(p for p in archive.iterdir() if p.is_dir()):
|
|
repeats = []
|
|
for child in arm.iterdir():
|
|
match = REPEAT_DIR.match(child.name) if child.is_dir() else None
|
|
if match:
|
|
repeats.append((int(match.group(1)), child))
|
|
repeats.sort(key=lambda pair: pair[0])
|
|
first_actual = None
|
|
first_sha = None
|
|
for index, directory in repeats:
|
|
row = {"arm": arm.name, "repeat": index, "dir": str(directory)}
|
|
result_path = directory / "result.json"
|
|
result = None
|
|
if result_path.is_file():
|
|
try:
|
|
result = json.loads(result_path.read_text(encoding="utf-8"))
|
|
except ValueError as error:
|
|
row["error"] = "unreadable result.json: " + str(error)
|
|
else:
|
|
row["error"] = "no result.json"
|
|
if result is not None:
|
|
row.update(backend=result.get("backend"),
|
|
passed=result.get("passed"), status_code=result.get("statusCode"),
|
|
ssim_vs_golden=result.get("ssim"),
|
|
ssim_threshold=result.get("ssimThreshold"),
|
|
matched_golden=Path(result.get("matchedGoldenPath") or "").name,
|
|
mismatch_pixels_vs_golden=result.get("mismatchPixels"))
|
|
actual = _actual_png(directory)
|
|
if actual is None:
|
|
row.setdefault("error", "no *-actual.png")
|
|
rows.append(row)
|
|
continue
|
|
row["actual"] = actual.name
|
|
row["actual_sha256"] = digest(actual)
|
|
if first_actual is None:
|
|
first_actual, first_sha = actual, row["actual_sha256"]
|
|
row["identical_to_first"] = row["actual_sha256"] == first_sha
|
|
crop = (int((result or {}).get("cropX", 0) or 0), int((result or {}).get("cropY", 0) or 0),
|
|
int((result or {}).get("cropWidth", 0) or 0), int((result or {}).get("cropHeight", 0) or 0))
|
|
try:
|
|
comparison = compare_files(actual, first_actual, *crop)
|
|
row["ssim_vs_first"] = comparison["ssim"]
|
|
row["mismatch_pixels_vs_first"] = comparison["mismatch_pixels"]
|
|
row["crop"] = comparison["crop"]
|
|
except (ImageError, OSError) as error:
|
|
row["error"] = "actual-vs-first comparison failed: " + str(error)
|
|
rows.append(row)
|
|
|
|
arms = {}
|
|
for row in rows:
|
|
arm = arms.setdefault(row["arm"], {"arm": row["arm"], "repeats": 0, "errors": 0,
|
|
"all_bitwise_identical": True, "passed_all": True,
|
|
"min_ssim_vs_golden": None, "min_ssim_vs_first": None})
|
|
arm["repeats"] += 1
|
|
if row.get("error"):
|
|
arm["errors"] += 1
|
|
if not row.get("identical_to_first", False):
|
|
arm["all_bitwise_identical"] = False
|
|
if row.get("passed") is not True:
|
|
arm["passed_all"] = False
|
|
for key, source in (("min_ssim_vs_golden", "ssim_vs_golden"), ("min_ssim_vs_first", "ssim_vs_first")):
|
|
value = row.get(source)
|
|
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
|
arm[key] = value if arm[key] is None else min(arm[key], value)
|
|
return {"archive": str(archive), "rows": rows, "arms": [arms[name] for name in sorted(arms)]}
|
|
|
|
|
|
def _cell(value, width, decimals=None):
|
|
if value is None:
|
|
text = "-"
|
|
elif isinstance(value, bool):
|
|
text = "yes" if value else "NO"
|
|
elif decimals is not None and isinstance(value, (int, float)):
|
|
text = ("%." + str(decimals) + "f") % value
|
|
else:
|
|
text = str(value)
|
|
return text.ljust(width)
|
|
|
|
|
|
def render_summary(summary, stream=sys.stdout):
|
|
# The arm column is sized from the data. A truncated case name in a divergence table is a
|
|
# name the reader has to guess at, and several of these differ only in their last word.
|
|
arm_width = max([len("arm")] + [len(row["arm"]) for row in summary["rows"]])
|
|
columns = [("arm", arm_width), ("rep", 3), ("pass", 5), ("ssim_vs_golden", 16),
|
|
("ssim_vs_first", 15), ("bit-identical", 13), ("px!=first", 9)]
|
|
stream.write(" ".join(name.ljust(width) for name, width in columns) + "\n")
|
|
stream.write(" ".join("-" * width for _, width in columns) + "\n")
|
|
for row in summary["rows"]:
|
|
stream.write(" ".join([
|
|
_cell(row["arm"], arm_width), _cell(row["repeat"], 3), _cell(row.get("passed"), 5),
|
|
_cell(row.get("ssim_vs_golden"), 16, 9), _cell(row.get("ssim_vs_first"), 15, 9),
|
|
_cell(row.get("identical_to_first"), 13),
|
|
_cell(row.get("mismatch_pixels_vs_first"), 9)]).rstrip() + "\n")
|
|
if row.get("error"):
|
|
stream.write(" ! " + row["error"] + "\n")
|
|
stream.write("\n")
|
|
for arm in summary["arms"]:
|
|
stream.write("%s: %d repeat(s), %d error(s), all-bitwise-identical=%s, passed-all=%s, "
|
|
"min ssim vs golden=%s, min ssim vs first=%s\n"
|
|
% (arm["arm"], arm["repeats"], arm["errors"],
|
|
"yes" if arm["all_bitwise_identical"] else "NO",
|
|
"yes" if arm["passed_all"] else "NO",
|
|
"-" if arm["min_ssim_vs_golden"] is None else "%.9f" % arm["min_ssim_vs_golden"],
|
|
"-" if arm["min_ssim_vs_first"] is None else "%.9f" % arm["min_ssim_vs_first"]))
|
|
|
|
|
|
# ---------------------------------------------------------------------------------------------
|
|
# Self-test: synthetic pairs whose SSIM is a closed form, so the transcription above cannot
|
|
# drift without something going red here.
|
|
# ---------------------------------------------------------------------------------------------
|
|
def _png_bytes(width, height, rgba):
|
|
def chunk(kind, body):
|
|
return (len(body).to_bytes(4, "big") + kind + body
|
|
+ zlib.crc32(kind + body).to_bytes(4, "big"))
|
|
raw = bytearray()
|
|
for y in range(height):
|
|
raw.append(0)
|
|
raw += rgba[y * width * 4:(y + 1) * width * 4]
|
|
return (_PNG_MAGIC
|
|
+ chunk(b"IHDR", width.to_bytes(4, "big") + height.to_bytes(4, "big")
|
|
+ bytes([8, 6, 0, 0, 0]))
|
|
+ chunk(b"IDAT", zlib.compress(bytes(raw)))
|
|
+ chunk(b"IEND", b""))
|
|
|
|
|
|
def _solid(width, height, value):
|
|
return bytes([value, value, value, 255] * (width * height))
|
|
|
|
|
|
def self_test():
|
|
import tempfile
|
|
|
|
failures = []
|
|
|
|
def expect(name, actual, expected, tolerance=1e-12):
|
|
if abs(actual - expected) > tolerance:
|
|
failures.append("%s: got %.17g, expected %.17g" % (name, actual, expected))
|
|
|
|
with tempfile.TemporaryDirectory(prefix="compare-actuals-selftest-") as directory:
|
|
root = Path(directory)
|
|
width, height = 8, 4
|
|
black = root / "black.png"
|
|
white = root / "white.png"
|
|
black.write_bytes(_png_bytes(width, height, _solid(width, height, 0)))
|
|
white.write_bytes(_png_bytes(width, height, _solid(width, height, 255)))
|
|
# Identical constant images: luminance and contrast are both exactly 1.
|
|
expect("identical", compare_files(black, black)["ssim"], 1.0, 0.0)
|
|
# Constant 0 vs constant 255: contrast is 1 (no variance anywhere), luminance is
|
|
# C1 / (255^2 + C1).
|
|
expect("black vs white", compare_files(black, white)["ssim"], 9.999000099990002e-05)
|
|
halves = bytearray()
|
|
inverse = bytearray()
|
|
for y in range(height):
|
|
for x in range(width):
|
|
left = 0 if x < width // 2 else 255
|
|
halves += bytes([left, left, left, 255])
|
|
right = 255 - left
|
|
inverse += bytes([right, right, right, 255])
|
|
a = root / "halves.png"
|
|
b = root / "inverse.png"
|
|
a.write_bytes(_png_bytes(width, height, bytes(halves)))
|
|
b.write_bytes(_png_bytes(width, height, bytes(inverse)))
|
|
# Equal means and variances, covariance = -variance: luminance 1, contrast negative.
|
|
expect("inverted halves", compare_files(a, b)["ssim"], -0.9964064683569576)
|
|
if compare_files(a, b)["mismatch_pixels"] != width * height:
|
|
failures.append("inverted halves: every pixel should differ")
|
|
# The crop is honored: the left half of `halves` is black and the left half of
|
|
# `inverse` is white, so cropping to it reproduces the black/white number.
|
|
cropped = compare_files(a, b, 0, 0, width // 2, height)["ssim"]
|
|
expect("cropped half", cropped, 9.999000099990002e-05)
|
|
# A size mismatch with no crop is a refusal, not an intersection.
|
|
small = root / "small.png"
|
|
small.write_bytes(_png_bytes(4, 4, _solid(4, 4, 0)))
|
|
try:
|
|
compare_files(black, small)
|
|
except ImageError:
|
|
pass
|
|
else:
|
|
failures.append("a size mismatch with no crop was silently compared")
|
|
|
|
if failures:
|
|
for line in failures:
|
|
print("compare_actuals self-test: " + line, file=sys.stderr)
|
|
return 1
|
|
print("compare_actuals self-test: all checks passed")
|
|
return 0
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--self-test", action="store_true")
|
|
sub = parser.add_subparsers(dest="mode")
|
|
compare = sub.add_parser("compare", help="SSIM between two actual PNGs")
|
|
compare.add_argument("left", type=Path)
|
|
compare.add_argument("right", type=Path)
|
|
compare.add_argument("--crop-x", type=int, default=0)
|
|
compare.add_argument("--crop-y", type=int, default=0)
|
|
compare.add_argument("--crop-width", type=int, default=0)
|
|
compare.add_argument("--crop-height", type=int, default=0)
|
|
summary = sub.add_parser("summary", help="case x repeat table over an --archive-dir tree")
|
|
summary.add_argument("archive", type=Path)
|
|
summary.add_argument("--json", type=Path, help="also write the table as JSON")
|
|
args = parser.parse_args(argv)
|
|
if args.self_test:
|
|
return self_test()
|
|
try:
|
|
if args.mode == "compare":
|
|
print(json.dumps(compare_files(args.left, args.right, args.crop_x, args.crop_y,
|
|
args.crop_width, args.crop_height),
|
|
indent=2, allow_nan=False))
|
|
return 0
|
|
if args.mode == "summary":
|
|
report = summarize(args.archive)
|
|
render_summary(report)
|
|
if args.json:
|
|
args.json.write_text(json.dumps(report, indent=2, allow_nan=False) + "\n",
|
|
encoding="utf-8")
|
|
return 0 if all(not row.get("error") for row in report["rows"]) else 1
|
|
except (ImageError, OSError) as error:
|
|
print("compare_actuals: " + str(error), file=sys.stderr)
|
|
return 2
|
|
parser.print_help()
|
|
return 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|