a73x

9fae3529

imgdiff: shrink CLI to thin wrapper over src/imgdiff.zig

a73x   2026-04-19 08:47

Removes duplicated pure-math (compare, makeDiffImage, DiffResult,
inline tests) from the CLI. CLI now imports the library module
for both the comparison and the default threshold constants.

No behavior change. test-render still passes against existing
golden PNGs.

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

diff --git a/src/tools/imgdiff.zig b/src/tools/imgdiff.zig
index bf33b00..aa3701d 100644
--- a/src/tools/imgdiff.zig
+++ b/src/tools/imgdiff.zig
@@ -1,58 +1,6 @@
const std = @import("std");
const png = @import("png");

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,
    };
}

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);
}
const imgdiff = @import("imgdiff");

pub fn main() !void {
    var gpa: std.heap.DebugAllocator(.{}) = .init;
@@ -70,8 +18,8 @@ pub fn main() !void {
    const reference_path = args[2];
    const diff_path: ?[]const u8 = if (args.len >= 4) args[3] else null;

    const rmse_max = readFloatEnv("WAYSTTY_TEST_RMSE_MAX", 0.005);
    const pixel_max = readFloatEnv("WAYSTTY_TEST_PIXEL_MAX", 0.125);
    const rmse_max = readFloatEnv("WAYSTTY_TEST_RMSE_MAX", imgdiff.RMSE_DEFAULT);
    const pixel_max = readFloatEnv("WAYSTTY_TEST_PIXEL_MAX", imgdiff.PIXEL_MAX_DEFAULT);

    const actual_bytes = try std.fs.cwd().readFileAlloc(alloc, actual_path, 64 * 1024 * 1024);
    defer alloc.free(actual_bytes);
@@ -88,7 +36,7 @@ pub fn main() !void {
        std.process.exit(3);
    }

    const r = try compare(actual, reference);
    const r = try imgdiff.compare(actual, reference);
    const pass = r.rmse <= rmse_max and r.max_pixel <= pixel_max;

    if (pass) {
@@ -99,7 +47,7 @@ pub fn main() !void {
    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 });

    if (diff_path) |p| {
        const diff_img = try makeDiffImage(alloc, actual, reference);
        const diff_img = try imgdiff.makeDiffImage(alloc, actual, reference);
        defer alloc.free(diff_img.pixels);

        var buf: std.ArrayList(u8) = .empty;
@@ -120,32 +68,3 @@ fn readFloatEnv(name: []const u8, default: f64) f64 {
    const val = std.posix.getenv(name) orelse return default;
    return std.fmt.parseFloat(f64, val) catch default;
}

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 };
}