4679378d
Add imgdiff: RMSE + per-pixel-max PNG comparison
a73x 2026-04-17 15:47
Commit message
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 | } | ||