src/imgdiff.zig
Ref: Size: 4.9 KiB
//! 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
}