diff --git a/README.md b/README.md index 7746c5c..3e19b5e 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,25 @@ options: sRGB PNG, PNM/PAM, QOI, or Y4M input expected ``` +### Y4M color conversion + +Y4M metrics preserve range and chroma-location tags and reconstruct chroma with +cubic interpolation. CVVDP receives float linear RGB directly, retaining source +bit depth. The SDR defaults follow FFVship: limited range when unspecified, +BT.709 above 650 lines, and BT.470 BG otherwise. Other image formats retain their +existing color handling. + +Run the generated SD/HD Y4M regression fixtures with: + +```sh +python3 tests/cli/test_y4m.py +``` + +The goldens come from FFVship at Vship commit +`7dca6ea307fb84506980ac61e99030c1773aea5f`, using the local Metal backend. +They cover centered and unspecified chroma, limited and full range, and 8/10-bit +samples. No video downloads are needed. + ## Credits fmetrics is under the [Apache 2.0 License](LICENSE). fmetrics is developed by diff --git a/build.zig.zon b/build.zig.zon index 628d260..1315d5f 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -6,18 +6,18 @@ .url = "git+https://github.com/allyourcodebase/libspng.git#8858232bde47083739c3e25f676093423610ec5e", .hash = "spng-0.0.1-T166cP8UAABcBGTOPKx7kUUyHEcu0eDvN8LnSIj3S_cW", }, - .simpleimgio = .{ - .url = "git+https://github.com/gianni-rosato/simpleimgio.git#988a6b730eb4ad99ee4205ef40c035ded6a2099d", - .hash = "simpleimgio-0.0.0-oHTuOTfLAACpIG8VkUMQJOTXytlnfi2AVf83GwFXOtBP", - }, - .fcvvdp = .{ - .url = "git+https://github.com/halidecx/fcvvdp.git#5b02be6320271c24e67d9b850aaa4747dbbb1b0c", - .hash = "fcvvdp-0.3.3-DualWMfLBAAzo5jowlUhlCW-2FsPKLGpzu_f2nvc0ZlJ", - }, .lcms2 = .{ .url = "git+https://github.com/allyourcodebase/lcms2.git#66c7c0ef0a9bd29a5f78aeff797bf541d0b91624", .hash = "lcms2-2.18.0-CNsMs50PAAAbhEQvq2uI5_O2vqgvYXvcDKwFw8QbzKSk", }, + .simpleimgio = .{ + .url = "git+https://github.com/gianni-rosato/simpleimgio.git#6d9b55ead31a181f82595ffaed05986476510126", + .hash = "simpleimgio-0.0.0-oHTuOdP9AADo0x_m-pBpu0Fi6GnqgVvbN_l2Ul4HbViE", + }, + .fcvvdp = .{ + .url = "git+https://github.com/halidecx/fcvvdp.git#012f2243cf68ce5456705860a711db202fa5c63f", + .hash = "fcvvdp-0.3.3-DualWPL1BABTZLRqGdraqZ9abuGhSKWOIDJRfYs2TTXO", + }, }, .fingerprint = 0xcc4003cccc882e16, .minimum_zig_version = "0.16.0", diff --git a/src/main.zig b/src/main.zig index 315bc15..5db9c8d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -346,6 +346,8 @@ const VideoWorker = struct { dis_rgb: []u8, butteraugli_options: fmetrics.ButteraugliOptions, workspace: fmetrics.Workspace, + converter: imgio.YuvRgbConverter, + linear: []f32, err: ?anyerror = null, fn init( @@ -362,6 +364,10 @@ const VideoWorker = struct { errdefer allocator.free(ref_rgb); const dis_rgb = try allocator.alloc(u8, pixels * 3); errdefer allocator.free(dis_rgb); + var converter = try imgio.YuvRgbConverter.init(allocator, width, height); + errdefer converter.deinit(); + const linear = try allocator.alloc(f32, pixels * 3); + errdefer allocator.free(linear); var workspace = try fmetrics.Workspace.init(); errdefer workspace.deinit(); return .{ @@ -375,18 +381,22 @@ const VideoWorker = struct { .dis_rgb = dis_rgb, .butteraugli_options = butteraugli_options, .workspace = workspace, + .converter = converter, + .linear = linear, }; } fn deinit(self: *VideoWorker) void { self.workspace.deinit(); + self.converter.deinit(); + self.allocator.free(self.linear); self.allocator.free(self.ref_rgb); self.allocator.free(self.dis_rgb); } fn processFrames(self: *VideoWorker, ref_frame: imgio.YuvFrame, dis_frame: imgio.YuvFrame) !void { - try yuv420ToRgb8Into(self.allocator, self.ref_rgb, ref_frame); - try yuv420ToRgb8Into(self.allocator, self.dis_rgb, dis_frame); + try yuv420ToRgb8Into(&self.converter, self.linear, self.ref_rgb, ref_frame); + try yuv420ToRgb8Into(&self.converter, self.linear, self.dis_rgb, dis_frame); const ref = try fmetricsImage(self.ref_rgb, self.width, self.height); const dis = try fmetricsImage(self.dis_rgb, self.width, self.height); @@ -511,50 +521,13 @@ fn hasExtension(path: []const u8, ext: []const u8) bool { return std.ascii.eqlIgnoreCase(tail, ext); } -fn yuv420ToRgb8Into(allocator: std.mem.Allocator, rgb: []u8, frame: imgio.YuvFrame) !void { - if (frame.chroma != .yuv420) return error.UnsupportedY4MChroma; - - var converted: ?imgio.YuvFrame = null; - defer if (converted) |*eight| eight.deinit(allocator); - const eight = if (frame.bit_depth == .b8) frame else blk: { - converted = try frame.to8Bit(allocator); - break :blk converted.?; - }; - - const width = eight.width; - const height = eight.height; - if (rgb.len < width * height * 3) return error.BadImageData; - - // Convert to RGB8 using a simple full-range BT.601-like YUV->RGB. - // This is intended for metric input, not broadcast-accurate color management. - const cw = (width + 1) / 2; - - const clampU8 = struct { - fn f(x: i32) u8 { - if (x < 0) return 0; - if (x > 255) return 255; - return @intCast(x); - } - }.f; - - for (0..height) |yy| { - for (0..width) |xx| { - const yv: i32 = eight.y[yy * width + xx]; - const uv: i32 = eight.u[(yy / 2) * cw + (xx / 2)]; - const vv: i32 = eight.v[(yy / 2) * cw + (xx / 2)]; - - const u_off = uv - 128; - const v_off = vv - 128; - - const r = yv + ((359 * v_off) >> 8); - const g = yv - ((88 * u_off + 183 * v_off) >> 8); - const b = yv + ((454 * u_off) >> 8); - - const i = (yy * width + xx) * 3; - rgb[i + 0] = clampU8(r); - rgb[i + 1] = clampU8(g); - rgb[i + 2] = clampU8(b); - } +fn yuv420ToRgb8Into(converter: *imgio.YuvRgbConverter, linear: []f32, rgb: []u8, frame: imgio.YuvFrame) !void { + // Convert to sRGB8 after range expansion and chroma reconstruction. + // Linear RGB keeps the source bit depth until the final quantization. + try converter.convert(frame, linear); + for (linear, rgb) |v, *dst| { + const encoded = if (v <= 0.0031308) v * 12.92 else 1.055 * std.math.pow(f32, v, 1.0 / 2.4) - 0.055; + dst.* = @intFromFloat(@round(std.math.clamp(encoded, 0, 1) * 255)); } } @@ -610,6 +583,17 @@ fn cvvdpLinearImage(img: fmetrics.Image) c.FmetricsImg { }; } +fn cvvdpImageFromLinearRgb(rgb: []const f32, width: usize, height: usize) c.FmetricsImg { + return .{ + .data = rgb.ptr, + .width = @intCast(width), + .height = @intCast(height), + .stride = @intCast(width * 3 * @sizeOf(f32)), + .format = c.FMETRICS_PIX_FMT_RGB_FLOAT, + .colorspace = c.FMETRICS_COLORSPACE_LINEAR_SRGB, + }; +} + fn cvvdpImageFromRgb( rgb: []const u8, width: usize, @@ -1113,11 +1097,14 @@ pub fn main(init: std.process.Init) !void { defer c.fmetrics_cvvdp_destroy(ctx_ptr.?); const pixels = try std.math.mul(usize, ref_dec.header.width, ref_dec.header.height); - const ref_rgb = try allocator.alloc(u8, pixels * 3); + const ref_rgb = try allocator.alloc(f32, pixels * 3); defer allocator.free(ref_rgb); - const dis_rgb = try allocator.alloc(u8, pixels * 3); + const dis_rgb = try allocator.alloc(f32, pixels * 3); defer allocator.free(dis_rgb); + var converter = try imgio.YuvRgbConverter.init(allocator, ref_dec.header.width, ref_dec.header.height); + defer converter.deinit(); + var cvvdp_result: c.FmetricsCvvdpResult = undefined; while (true) { @@ -1144,11 +1131,11 @@ pub fn main(init: std.process.Init) !void { var dis_frame = dis_frame_opt.?; defer dis_frame.deinit(allocator); - try yuv420ToRgb8Into(allocator, ref_rgb, ref_frame); - try yuv420ToRgb8Into(allocator, dis_rgb, dis_frame); + try converter.convert(ref_frame, ref_rgb); + try converter.convert(dis_frame, dis_rgb); - var ref = cvvdpImageFromRgb(ref_rgb, ref_frame.width, ref_frame.height); - var dis = cvvdpImageFromRgb(dis_rgb, dis_frame.width, dis_frame.height); + var ref = cvvdpImageFromLinearRgb(ref_rgb, ref_frame.width, ref_frame.height); + var dis = cvvdpImageFromLinearRgb(dis_rgb, dis_frame.width, dis_frame.height); const proc_err = c.fmetrics_cvvdp_process_frame( ctx_ptr.?, &ref, diff --git a/tests/cli/test_y4m.py b/tests/cli/test_y4m.py new file mode 100644 index 0000000..7f54e54 --- /dev/null +++ b/tests/cli/test_y4m.py @@ -0,0 +1,66 @@ +import array +import json +import os +import pathlib +import subprocess +import sys +import tempfile +import unittest + + +BINARY = pathlib.Path(os.environ.get("FMETRICS_BINARY", "zig-out/bin/fmetrics")).resolve() + + +def write_video(path, distorted=False, bits=8, full=False, height=720): + width = 128 + chroma = " C420jpeg" if bits == 8 else f" C420p{bits}" + range_tag = " XCOLORRANGE=FULL" if full else " XCOLORRANGE=LIMITED" + with path.open("wb") as stream: + stream.write(f"YUV4MPEG2 W{width} H{height} F50:1 Ip{chroma}{range_tag}\n".encode()) + for frame in range(24): + values = [] + for plane in range(3): + w, h = (width, height) if plane == 0 else (width // 2, height // 2) + for y in range(h): + for x in range(w): + value = (x + y + frame * 3) % 180 + 32 if plane == 0 else 112 + (x + y + frame + plane * 3) % 32 + value = value * (1 << (bits - 8)) + if distorted: + value += (3 if x % 7 == 0 else -1) * (1 << (bits - 8)) + if bits > 8: + value += x % 3 + values.append(value) + payload = array.array("B" if bits == 8 else "H", values) + if bits > 8 and sys.byteorder != "little": + payload.byteswap() + stream.write(b"FRAME\n") + stream.write(payload.tobytes()) + + +class Y4mTests(unittest.TestCase): + def compare(self, reference, distorted): + result = subprocess.run([str(BINARY), "cvvdp", str(reference), str(distorted), "--json"], capture_output=True, text=True, check=True) + return json.loads(result.stdout or result.stderr[result.stderr.index("{"):]) + + def test_vship_y4m_golden(self): + with tempfile.TemporaryDirectory() as directory: + root = pathlib.Path(directory) + for height, bits, full, expected in [ + (720, 8, False, 8.85979), + (720, 10, False, 8.86006), + (720, 8, True, 9.04493), + (72, 8, False, 9.32735), + (72, 10, False, 9.38274), + (72, 8, True, 9.46047), + ]: + with self.subTest(height=height, bits=bits, full=full): + ref, dis = root / "ref.y4m", root / "dis.y4m" + write_video(ref, bits=bits, full=full, height=height) + write_video(dis, distorted=True, bits=bits, full=full, height=height) + result = self.compare(ref, dis) + self.assertEqual(result["frames"], 24) + self.assertAlmostEqual(result["jod"], expected, delta=0.0001) + + +if __name__ == "__main__": + unittest.main()