Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 8 additions & 8 deletions build.zig.zon
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
91 changes: 39 additions & 52 deletions src/main.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 .{
Expand All @@ -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);
Expand Down Expand Up @@ -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));
}
}

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand Down
66 changes: 66 additions & 0 deletions tests/cli/test_y4m.py
Original file line number Diff line number Diff line change
@@ -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()
Loading