a73x

5de16e01

imgdiff: add src/imgdiff.zig pure-math module

a73x   2026-04-19 08:44

Commit message
imgdiff: add src/imgdiff.zig pure-math module

Copies compare + DiffResult + makeDiffImage into a standalone
module so it can be reused by the upcoming scenario runner.
CLI at src/tools/imgdiff.zig is untouched this commit; dedup
happens in the next commit.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

build.zig
Old New
@@ -365,13 +365,31 @@ pub fn build(b: *std.Build) void {
365 capture_mod.addImport("vk_sync", vk_sync_mod); 365 capture_mod.addImport("vk_sync", vk_sync_mod);
366 exe_mod.addImport("capture", capture_mod); 366 exe_mod.addImport("capture", capture_mod);
367 367
368 // imgdiff — standalone PNG comparison tool 368 // imgdiff — pure library used by the CLI + the scenario runner
369 const imgdiff_lib_mod = b.createModule(.{
370 .root_source_file = b.path("src/imgdiff.zig"),
371 .target = target,
372 .optimize = optimize,
373 });
374 imgdiff_lib_mod.addImport("png", png_mod);
375
376 const imgdiff_lib_test_mod = b.createModule(.{
377 .root_source_file = b.path("src/imgdiff.zig"),
378 .target = target,
379 .optimize = optimize,
380 });
381 imgdiff_lib_test_mod.addImport("png", png_mod);
382 const imgdiff_lib_tests = b.addTest(.{ .root_module = imgdiff_lib_test_mod });
383 test_step.dependOn(&b.addRunArtifact(imgdiff_lib_tests).step);
384
385 // imgdiff — standalone PNG comparison CLI
369 const imgdiff_mod = b.createModule(.{ 386 const imgdiff_mod = b.createModule(.{
370 .root_source_file = b.path("src/tools/imgdiff.zig"), 387 .root_source_file = b.path("src/tools/imgdiff.zig"),
371 .target = target, 388 .target = target,
372 .optimize = optimize, 389 .optimize = optimize,
373 }); 390 });
374 imgdiff_mod.addImport("png", png_mod); 391 imgdiff_mod.addImport("png", png_mod);
392 imgdiff_mod.addImport("imgdiff", imgdiff_lib_mod);
375 const imgdiff_exe = b.addExecutable(.{ 393 const imgdiff_exe = b.addExecutable(.{
376 .name = "imgdiff", 394 .name = "imgdiff",
377 .root_module = imgdiff_mod, 395 .root_module = imgdiff_mod,
@@ -384,6 +402,7 @@ pub fn build(b: *std.Build) void {
384 .optimize = optimize, 402 .optimize = optimize,
385 }); 403 });
386 imgdiff_test_mod.addImport("png", png_mod); 404 imgdiff_test_mod.addImport("png", png_mod);
405 imgdiff_test_mod.addImport("imgdiff", imgdiff_lib_mod);
387 const imgdiff_tests = b.addTest(.{ .root_module = imgdiff_test_mod }); 406 const imgdiff_tests = b.addTest(.{ .root_module = imgdiff_test_mod });
388 test_step.dependOn(&b.addRunArtifact(imgdiff_tests).step); 407 test_step.dependOn(&b.addRunArtifact(imgdiff_tests).step);
389 408
src/imgdiff.zig
Old New
@@ -0,0 +1,109 @@
1 //! Pure-math PNG image diff used by both the `imgdiff` CLI
2 //! (src/tools/imgdiff.zig) and the scenario runner (to be added later).
3 //! Threshold constants live here so both callers agree.
4
5 const std = @import("std");
6 const png = @import("png");
7
8 pub const RMSE_DEFAULT: f64 = 0.005;
9 pub const PIXEL_MAX_DEFAULT: f64 = 0.125;
10
11 pub const DiffResult = struct {
12 rmse: f64, // [0, 1]
13 max_pixel: f64, // [0, 1]
14 pixel_count: usize,
15 };
16
17 pub fn compare(a: png.Image, b: png.Image) !DiffResult {
18 if (a.width != b.width or a.height != b.height) return error.DimensionsDiffer;
19 std.debug.assert(a.pixels.len == b.pixels.len);
20
21 const px_count = @as(usize, a.width) * a.height;
22 var sum_sq: f64 = 0;
23 var max_d: f64 = 0;
24
25 var i: usize = 0;
26 while (i < px_count) : (i += 1) {
27 const off = i * 4;
28 const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0;
29 const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0;
30 const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0;
31 const d_sq = (dr * dr + dg * dg + db * db) / 3.0;
32 sum_sq += d_sq;
33 const d = @sqrt(d_sq);
34 if (d > max_d) max_d = d;
35 }
36
37 return .{
38 .rmse = @sqrt(sum_sq / @as(f64, @floatFromInt(px_count))),
39 .max_pixel = max_d,
40 .pixel_count = px_count,
41 };
42 }
43
44 pub fn makeDiffImage(alloc: std.mem.Allocator, a: png.Image, b: png.Image) !png.Image {
45 // Side-by-side: [actual | reference | delta-heatmap]
46 const w = a.width * 3;
47 const h = a.height;
48 const pixels = try alloc.alloc(u8, w * h * 4);
49 var y: u32 = 0;
50 while (y < h) : (y += 1) {
51 const row_off = @as(usize, y) * w * 4;
52 const a_off = @as(usize, y) * a.width * 4;
53 @memcpy(pixels[row_off .. row_off + a.width * 4], a.pixels[a_off .. a_off + a.width * 4]);
54 @memcpy(pixels[row_off + a.width * 4 .. row_off + 2 * a.width * 4], b.pixels[a_off .. a_off + a.width * 4]);
55 var x: u32 = 0;
56 while (x < a.width) : (x += 1) {
57 const off = a_off + x * 4;
58 const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0;
59 const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0;
60 const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0;
61 const d = @sqrt((dr * dr + dg * dg + db * db) / 3.0);
62 const brightness: u8 = @intFromFloat(@min(255.0, d * 255.0 * 2.0));
63 const dst = row_off + 2 * a.width * 4 + x * 4;
64 pixels[dst + 0] = brightness;
65 pixels[dst + 1] = brightness;
66 pixels[dst + 2] = brightness;
67 pixels[dst + 3] = 255;
68 }
69 }
70 return .{ .width = w, .height = h, .pixels = pixels };
71 }
72
73 test "identical images produce zero RMSE" {
74 var pixels_a = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 };
75 var pixels_b = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 };
76 const a = png.Image{ .width = 2, .height = 1, .pixels = &pixels_a };
77 const b = png.Image{ .width = 2, .height = 1, .pixels = &pixels_b };
78 const r = try compare(a, b);
79 try std.testing.expectEqual(@as(f64, 0.0), r.rmse);
80 try std.testing.expectEqual(@as(f64, 0.0), r.max_pixel);
81 }
82
83 test "fully saturated difference produces rmse=1.0 and max=1.0" {
84 var pixels_a = [_]u8{ 0, 0, 0, 255 };
85 var pixels_b = [_]u8{ 255, 255, 255, 255 };
86 const a = png.Image{ .width = 1, .height = 1, .pixels = &pixels_a };
87 const b = png.Image{ .width = 1, .height = 1, .pixels = &pixels_b };
88 const r = try compare(a, b);
89 try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.rmse, 1e-9);
90 try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.max_pixel, 1e-9);
91 }
92
93 test "makeDiffImage: width triples, heatmap brighter where delta is larger" {
94 const alloc = std.testing.allocator;
95 // 2x1 image pair where one column differs more than the other.
96 var pixels_a = [_]u8{ 0, 0, 0, 255, 100, 100, 100, 255 };
97 var pixels_b = [_]u8{ 0, 0, 0, 255, 200, 200, 200, 255 };
98 const a = png.Image{ .width = 2, .height = 1, .pixels = &pixels_a };
99 const b = png.Image{ .width = 2, .height = 1, .pixels = &pixels_b };
100 const out = try makeDiffImage(alloc, a, b);
101 defer alloc.free(out.pixels);
102 try std.testing.expectEqual(@as(u32, 6), out.width);
103 try std.testing.expectEqual(@as(u32, 1), out.height);
104 // Heatmap is the last third. Column 0 (no delta) should be black;
105 // column 1 (delta) should be non-zero.
106 const heatmap_offset = 4 * 4; // bytes before heatmap (2 images × 2 pixels × 4 bytes = 16)
107 try std.testing.expectEqual(@as(u8, 0), out.pixels[heatmap_offset + 0]); // R at heatmap col 0
108 try std.testing.expect(out.pixels[heatmap_offset + 4] > 0); // R at heatmap col 1
109 }