a73x

4679378d

Add imgdiff: RMSE + per-pixel-max PNG comparison

a73x   2026-04-17 15:47

Commit message
Add imgdiff: RMSE + per-pixel-max PNG comparison

Standalone tool reused by test-render. Thresholds overridable via
WAYSTTY_TEST_RMSE_MAX and WAYSTTY_TEST_PIXEL_MAX.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

build.zig
Old New
@@ -331,4 +331,26 @@ pub fn build(b: *std.Build) void {
331 capture_mod.addImport("vulkan", vulkan_module); 331 capture_mod.addImport("vulkan", vulkan_module);
332 capture_mod.addImport("cell_instance", cell_instance_mod); 332 capture_mod.addImport("cell_instance", cell_instance_mod);
333 exe_mod.addImport("capture", capture_mod); 333 exe_mod.addImport("capture", capture_mod);
334
335 // imgdiff — standalone PNG comparison tool
336 const imgdiff_mod = b.createModule(.{
337 .root_source_file = b.path("src/tools/imgdiff.zig"),
338 .target = target,
339 .optimize = optimize,
340 });
341 imgdiff_mod.addImport("png", png_mod);
342 const imgdiff_exe = b.addExecutable(.{
343 .name = "imgdiff",
344 .root_module = imgdiff_mod,
345 });
346 b.installArtifact(imgdiff_exe);
347
348 const imgdiff_test_mod = b.createModule(.{
349 .root_source_file = b.path("src/tools/imgdiff.zig"),
350 .target = target,
351 .optimize = optimize,
352 });
353 imgdiff_test_mod.addImport("png", png_mod);
354 const imgdiff_tests = b.addTest(.{ .root_module = imgdiff_test_mod });
355 test_step.dependOn(&b.addRunArtifact(imgdiff_tests).step);
334 } 356 }
src/tools/imgdiff.zig
Old New
@@ -0,0 +1,151 @@
1 const std = @import("std");
2 const png = @import("png");
3
4 pub const DiffResult = struct {
5 rmse: f64, // [0, 1]
6 max_pixel: f64, // [0, 1]
7 pixel_count: usize,
8 };
9
10 pub fn compare(a: png.Image, b: png.Image) !DiffResult {
11 if (a.width != b.width or a.height != b.height) return error.DimensionsDiffer;
12 std.debug.assert(a.pixels.len == b.pixels.len);
13
14 const px_count = @as(usize, a.width) * a.height;
15 var sum_sq: f64 = 0;
16 var max_d: f64 = 0;
17
18 var i: usize = 0;
19 while (i < px_count) : (i += 1) {
20 const off = i * 4;
21 const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0;
22 const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0;
23 const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0;
24 const d_sq = (dr * dr + dg * dg + db * db) / 3.0;
25 sum_sq += d_sq;
26 const d = @sqrt(d_sq);
27 if (d > max_d) max_d = d;
28 }
29
30 return .{
31 .rmse = @sqrt(sum_sq / @as(f64, @floatFromInt(px_count))),
32 .max_pixel = max_d,
33 .pixel_count = px_count,
34 };
35 }
36
37 test "identical images produce zero RMSE" {
38 var pixels_a = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 };
39 var pixels_b = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 };
40 const a = png.Image{ .width = 2, .height = 1, .pixels = &pixels_a };
41 const b = png.Image{ .width = 2, .height = 1, .pixels = &pixels_b };
42 const r = try compare(a, b);
43 try std.testing.expectEqual(@as(f64, 0.0), r.rmse);
44 try std.testing.expectEqual(@as(f64, 0.0), r.max_pixel);
45 }
46
47 test "fully saturated difference produces rmse=1.0 and max=1.0" {
48 var pixels_a = [_]u8{ 0, 0, 0, 255 };
49 var pixels_b = [_]u8{ 255, 255, 255, 255 };
50 const a = png.Image{ .width = 1, .height = 1, .pixels = &pixels_a };
51 const b = png.Image{ .width = 1, .height = 1, .pixels = &pixels_b };
52 const r = try compare(a, b);
53 try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.rmse, 1e-9);
54 try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.max_pixel, 1e-9);
55 }
56
57 pub fn main() !void {
58 var gpa: std.heap.DebugAllocator(.{}) = .init;
59 defer _ = gpa.deinit();
60 const alloc = gpa.allocator();
61
62 const args = try std.process.argsAlloc(alloc);
63 defer std.process.argsFree(alloc, args);
64
65 if (args.len < 3) {
66 std.debug.print("usage: imgdiff <actual.png> <reference.png> [diff.png]\n", .{});
67 std.process.exit(2);
68 }
69 const actual_path = args[1];
70 const reference_path = args[2];
71 const diff_path: ?[]const u8 = if (args.len >= 4) args[3] else null;
72
73 const rmse_max = readFloatEnv("WAYSTTY_TEST_RMSE_MAX", 0.005);
74 const pixel_max = readFloatEnv("WAYSTTY_TEST_PIXEL_MAX", 0.125);
75
76 const actual_bytes = try std.fs.cwd().readFileAlloc(alloc, actual_path, 64 * 1024 * 1024);
77 defer alloc.free(actual_bytes);
78 const reference_bytes = try std.fs.cwd().readFileAlloc(alloc, reference_path, 64 * 1024 * 1024);
79 defer alloc.free(reference_bytes);
80
81 var actual = try png.decode(alloc, actual_bytes);
82 defer actual.deinit(alloc);
83 var reference = try png.decode(alloc, reference_bytes);
84 defer reference.deinit(alloc);
85
86 if (actual.width != reference.width or actual.height != reference.height) {
87 std.debug.print("FAIL: dimensions differ ({}x{} vs {}x{})\n", .{ actual.width, actual.height, reference.width, reference.height });
88 std.process.exit(3);
89 }
90
91 const r = try compare(actual, reference);
92 const pass = r.rmse <= rmse_max and r.max_pixel <= pixel_max;
93
94 if (pass) {
95 std.debug.print("OK: {s} RMSE={d:.4}% worst={d:.4}%\n", .{ reference_path, r.rmse * 100.0, r.max_pixel * 100.0 });
96 std.process.exit(0);
97 }
98
99 std.debug.print("FAIL: {s}\n RMSE: {d:.4}% (max {d:.4}%)\n worst pixel: {d:.4}% (max {d:.4}%)\n", .{ reference_path, r.rmse * 100.0, rmse_max * 100.0, r.max_pixel * 100.0, pixel_max * 100.0 });
100
101 if (diff_path) |p| {
102 const diff_img = try makeDiffImage(alloc, actual, reference);
103 defer alloc.free(diff_img.pixels);
104
105 var buf: std.ArrayList(u8) = .empty;
106 defer buf.deinit(alloc);
107 try png.encode(alloc, diff_img, buf.writer(alloc));
108
109 const out = try std.fs.cwd().createFile(p, .{ .truncate = true });
110 defer out.close();
111 try out.writeAll(buf.items);
112
113 std.debug.print(" diff: {s}\n", .{p});
114 }
115 std.debug.print(" actual: {s}\n", .{actual_path});
116 std.process.exit(1);
117 }
118
119 fn readFloatEnv(name: []const u8, default: f64) f64 {
120 const val = std.posix.getenv(name) orelse return default;
121 return std.fmt.parseFloat(f64, val) catch default;
122 }
123
124 fn makeDiffImage(alloc: std.mem.Allocator, a: png.Image, b: png.Image) !png.Image {
125 // Side-by-side: [actual | reference | delta-heatmap]
126 const w = a.width * 3;
127 const h = a.height;
128 const pixels = try alloc.alloc(u8, w * h * 4);
129 var y: u32 = 0;
130 while (y < h) : (y += 1) {
131 const row_off = @as(usize, y) * w * 4;
132 const a_off = @as(usize, y) * a.width * 4;
133 @memcpy(pixels[row_off .. row_off + a.width * 4], a.pixels[a_off .. a_off + a.width * 4]);
134 @memcpy(pixels[row_off + a.width * 4 .. row_off + 2 * a.width * 4], b.pixels[a_off .. a_off + a.width * 4]);
135 var x: u32 = 0;
136 while (x < a.width) : (x += 1) {
137 const off = a_off + x * 4;
138 const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0;
139 const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0;
140 const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0;
141 const d = @sqrt((dr * dr + dg * dg + db * db) / 3.0);
142 const brightness: u8 = @intFromFloat(@min(255.0, d * 255.0 * 2.0));
143 const dst = row_off + 2 * a.width * 4 + x * 4;
144 pixels[dst + 0] = brightness;
145 pixels[dst + 1] = brightness;
146 pixels[dst + 2] = brightness;
147 pixels[dst + 3] = 255;
148 }
149 }
150 return .{ .width = w, .height = h, .pixels = pixels };
151 }