5de16e01
imgdiff: add src/imgdiff.zig pure-math module
a73x 2026-04-19 08:44
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>
diff --git a/build.zig b/build.zig index a6ed382..46cbc6a 100644 --- a/build.zig +++ b/build.zig @@ -365,13 +365,31 @@ pub fn build(b: *std.Build) void { capture_mod.addImport("vk_sync", vk_sync_mod); exe_mod.addImport("capture", capture_mod); // imgdiff — standalone PNG comparison tool // imgdiff — pure library used by the CLI + the scenario runner const imgdiff_lib_mod = b.createModule(.{ .root_source_file = b.path("src/imgdiff.zig"), .target = target, .optimize = optimize, }); imgdiff_lib_mod.addImport("png", png_mod); const imgdiff_lib_test_mod = b.createModule(.{ .root_source_file = b.path("src/imgdiff.zig"), .target = target, .optimize = optimize, }); imgdiff_lib_test_mod.addImport("png", png_mod); const imgdiff_lib_tests = b.addTest(.{ .root_module = imgdiff_lib_test_mod }); test_step.dependOn(&b.addRunArtifact(imgdiff_lib_tests).step); // imgdiff — standalone PNG comparison CLI const imgdiff_mod = b.createModule(.{ .root_source_file = b.path("src/tools/imgdiff.zig"), .target = target, .optimize = optimize, }); imgdiff_mod.addImport("png", png_mod); imgdiff_mod.addImport("imgdiff", imgdiff_lib_mod); const imgdiff_exe = b.addExecutable(.{ .name = "imgdiff", .root_module = imgdiff_mod, @@ -384,6 +402,7 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); imgdiff_test_mod.addImport("png", png_mod); imgdiff_test_mod.addImport("imgdiff", imgdiff_lib_mod); const imgdiff_tests = b.addTest(.{ .root_module = imgdiff_test_mod }); test_step.dependOn(&b.addRunArtifact(imgdiff_tests).step); diff --git a/src/imgdiff.zig b/src/imgdiff.zig new file mode 100644 index 0000000..7fceed9 --- /dev/null +++ b/src/imgdiff.zig @@ -0,0 +1,109 @@ //! Pure-math PNG image diff used by both the `imgdiff` CLI //! (src/tools/imgdiff.zig) and the scenario runner (to be added later). //! Threshold constants live here so both callers agree. const std = @import("std"); const png = @import("png"); pub const RMSE_DEFAULT: f64 = 0.005; pub const PIXEL_MAX_DEFAULT: f64 = 0.125; pub const DiffResult = struct { rmse: f64, // [0, 1] max_pixel: f64, // [0, 1] pixel_count: usize, }; pub fn compare(a: png.Image, b: png.Image) !DiffResult { if (a.width != b.width or a.height != b.height) return error.DimensionsDiffer; std.debug.assert(a.pixels.len == b.pixels.len); const px_count = @as(usize, a.width) * a.height; var sum_sq: f64 = 0; var max_d: f64 = 0; var i: usize = 0; while (i < px_count) : (i += 1) { const off = i * 4; const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0; const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0; const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0; const d_sq = (dr * dr + dg * dg + db * db) / 3.0; sum_sq += d_sq; const d = @sqrt(d_sq); if (d > max_d) max_d = d; } return .{ .rmse = @sqrt(sum_sq / @as(f64, @floatFromInt(px_count))), .max_pixel = max_d, .pixel_count = px_count, }; } pub fn makeDiffImage(alloc: std.mem.Allocator, a: png.Image, b: png.Image) !png.Image { // Side-by-side: [actual | reference | delta-heatmap] const w = a.width * 3; const h = a.height; const pixels = try alloc.alloc(u8, w * h * 4); var y: u32 = 0; while (y < h) : (y += 1) { const row_off = @as(usize, y) * w * 4; const a_off = @as(usize, y) * a.width * 4; @memcpy(pixels[row_off .. row_off + a.width * 4], a.pixels[a_off .. a_off + a.width * 4]); @memcpy(pixels[row_off + a.width * 4 .. row_off + 2 * a.width * 4], b.pixels[a_off .. a_off + a.width * 4]); var x: u32 = 0; while (x < a.width) : (x += 1) { const off = a_off + x * 4; const dr = (@as(f64, @floatFromInt(a.pixels[off + 0])) - @as(f64, @floatFromInt(b.pixels[off + 0]))) / 255.0; const dg = (@as(f64, @floatFromInt(a.pixels[off + 1])) - @as(f64, @floatFromInt(b.pixels[off + 1]))) / 255.0; const db = (@as(f64, @floatFromInt(a.pixels[off + 2])) - @as(f64, @floatFromInt(b.pixels[off + 2]))) / 255.0; const d = @sqrt((dr * dr + dg * dg + db * db) / 3.0); const brightness: u8 = @intFromFloat(@min(255.0, d * 255.0 * 2.0)); const dst = row_off + 2 * a.width * 4 + x * 4; pixels[dst + 0] = brightness; pixels[dst + 1] = brightness; pixels[dst + 2] = brightness; pixels[dst + 3] = 255; } } return .{ .width = w, .height = h, .pixels = pixels }; } test "identical images produce zero RMSE" { var pixels_a = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 }; var pixels_b = [_]u8{ 10, 20, 30, 255, 40, 50, 60, 255 }; const a = png.Image{ .width = 2, .height = 1, .pixels = &pixels_a }; const b = png.Image{ .width = 2, .height = 1, .pixels = &pixels_b }; const r = try compare(a, b); try std.testing.expectEqual(@as(f64, 0.0), r.rmse); try std.testing.expectEqual(@as(f64, 0.0), r.max_pixel); } test "fully saturated difference produces rmse=1.0 and max=1.0" { var pixels_a = [_]u8{ 0, 0, 0, 255 }; var pixels_b = [_]u8{ 255, 255, 255, 255 }; const a = png.Image{ .width = 1, .height = 1, .pixels = &pixels_a }; const b = png.Image{ .width = 1, .height = 1, .pixels = &pixels_b }; const r = try compare(a, b); try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.rmse, 1e-9); try std.testing.expectApproxEqAbs(@as(f64, 1.0), r.max_pixel, 1e-9); } test "makeDiffImage: width triples, heatmap brighter where delta is larger" { const alloc = std.testing.allocator; // 2x1 image pair where one column differs more than the other. var pixels_a = [_]u8{ 0, 0, 0, 255, 100, 100, 100, 255 }; var pixels_b = [_]u8{ 0, 0, 0, 255, 200, 200, 200, 255 }; const a = png.Image{ .width = 2, .height = 1, .pixels = &pixels_a }; const b = png.Image{ .width = 2, .height = 1, .pixels = &pixels_b }; const out = try makeDiffImage(alloc, a, b); defer alloc.free(out.pixels); try std.testing.expectEqual(@as(u32, 6), out.width); try std.testing.expectEqual(@as(u32, 1), out.height); // Heatmap is the last third. Column 0 (no delta) should be black; // column 1 (delta) should be non-zero. const heatmap_offset = 4 * 4; // bytes before heatmap (2 images × 2 pixels × 4 bytes = 16) try std.testing.expectEqual(@as(u8, 0), out.pixels[heatmap_offset + 0]); // R at heatmap col 0 try std.testing.expect(out.pixels[heatmap_offset + 4] > 0); // R at heatmap col 1 }