5de16e01
imgdiff: add src/imgdiff.zig pure-math module
a73x 2026-04-19 08:44
Commit message
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 | } | ||