zignal icon indicating copy to clipboard operation
zignal copied to clipboard

Allow custom backing types for colorspaces transforms

Open arrufat opened this issue 1 month ago • 13 comments

Currently, Zignal uses f64 in all colorspace conversions.

While this is great for precision, it prevents Zignal from being used in SPIR-V shaders, as spotted by @Traxar.

Convert all color types from:

pub const Hsv = struct { ... };

to

pub fn Hsv(comptime T: type) type {
    comptime assert(@typeInfo(T) == .float);
    return struct { ... };
}

While at it, also do:

pub fn Rgb(comptime T: type) type {
    // check T is float or u8
    return switch assert(@typeInfo(T) {
        .int => RgbU8, // current Rgb
        .float => RgbFloat, // partially implemented, not exposed
    };
}

The the user could request Rgb(u8) for standard [0-255] ranges, or Rgb(f32) for [0-1] ranges.

The Python bindings should then use Hsv(f64) since Python uses f64 for its float type.

arrufat avatar Nov 15 '25 14:11 arrufat

Already got an idea on how to do the conversions? :)

I think rbg.to(Hsv(f32)) would be nice but this will require quite a bit of comptime magic ^^'

Traxar avatar Nov 15 '25 14:11 Traxar

I think it can be done like:

color.to(f32, Hsv)

The second parameter is:

function: fn(comptime T: type, a: T, b: T, c: T) function(T))

Or anytype if I can't make that work. Let me think about it.

Edit: I think you're right, it's going to be way more complicated than that.

arrufat avatar Nov 15 '25 21:11 arrufat

I got it working while waiting for my flight.

Here's a self-contained example that can convert between Rgb and Hsv using your proposed syntax, except that the floating point precision is only set once.

I just need to extend that switch now. I will play around with this to see how I feel about it, especially about non-float colorspaces (Rgb(u8) and Rgba(u8)).

Thank you for the suggestion, one way or another I will make sure we can choose the underlying floating point for colors, so Zignal can run in SPIR-V.

const std = @import("std");

pub fn main() !void {
    const rgb: Rgb(f32) = .{ .r = 0.97, .g = 0.64, .b = 0.11 };
    std.debug.print("{any}\n", .{rgb});
    const hsv: Hsv(f32) = rgb.to(Hsv);
    std.debug.print("{any}\n", .{hsv});
    // back to Rgb
    std.debug.print("{any}\n", .{hsv.to(Rgb)});
}

pub fn Rgb(comptime T: type) type {
    return struct {
        r: T,
        g: T,
        b: T,

        const Self = @This();

        pub fn to(self: Self, comptime Color: fn (comptime U: type) type) Color(T) {
            return switch (Color(T)) {
                Rgb(T) => self,
                Hsv(T) => rgbToHsv(T, self),
                else => @compileError("conversion from " ++ @typeName(Self) ++ " to " ++ @typeName(Color(T)) ++ " not yet implemented"),
            };
        }
    };
}

pub fn Hsv(comptime T: type) type {
    return struct {
        h: T,
        s: T,
        v: T,

        const Self = @This();

        pub fn to(self: Self, comptime Color: fn (comptime U: type) type) Color(T) {
            return switch (Color(T)) {
                Rgb(T) => hsvToRgb(T, self),
                Hsv(T) => self,
                else => @compileError("conversion from " ++ @typeName(Self) ++ " to " ++ @typeName(Color(T)) ++ " not yet implemented"),
            };
        }
    };
}

pub fn rgbToHsv(comptime T: type, rgb: Rgb(T)) Hsv(T) {
    const min = @min(rgb.r, @min(rgb.g, rgb.b));
    const max = @max(rgb.r, @max(rgb.g, rgb.b));
    const delta = max - min;

    return .{
        .h = if (delta == 0) 0 else blk: {
            if (max == rgb.r) {
                break :blk @mod((rgb.g - rgb.b) / delta * 60, 360);
            } else if (max == rgb.g) {
                break :blk @mod(120 + (rgb.b - rgb.r) / delta * 60, 360);
            } else {
                break :blk @mod(240 + (rgb.r - rgb.g) / delta * 60, 360);
            }
        },
        .s = if (max == 0) 0 else (delta / max) * 100,
        .v = max * 100,
    };
}

pub fn hsvToRgb(comptime T: type, hsv: Hsv(T)) Rgb(T) {
    const hue = @max(0, @min(1, hsv.h / 360));
    const sat = @max(0, @min(1, hsv.s / 100));
    const val = @max(0, @min(1, hsv.v / 100));

    if (sat == 0.0) {
        return .{ .r = val, .g = val, .b = val };
    }

    const sector = hue * 6;
    const index: i32 = @intFromFloat(sector);
    const fractional = sector - @as(T, @floatFromInt(index));
    const p = val * (1 - sat);
    const q = val * (1 - (sat * fractional));
    const t = val * (1 - sat * (1 - fractional));
    const colors = [_][3]T{
        .{ val, t, p },
        .{ q, val, p },
        .{ p, val, t },
        .{ p, q, val },
        .{ t, p, val },
        .{ val, p, q },
    };
    const idx: usize = @intCast(@mod(index, 6));

    return .{
        .r = colors[idx][0],
        .g = colors[idx][1],
        .b = colors[idx][2],
    };
}

output

.{ .r = 0.97, .g = 0.64, .b = 0.11 }
.{ .h = 36.97674, .s = 88.65979, .v = 97 }
.{ .r = 0.97, .g = 0.64000005, .b = 0.11000007 }

arrufat avatar Nov 16 '25 00:11 arrufat

Very cool :D Zignal will definetly be my goto to do any sorts of color shenanigans (and soon even on the gpu ;))

Traxar avatar Nov 16 '25 01:11 Traxar

What do you think about this API, @Traxar?

  • .to for converting between color spaces
  • .as for converting the underlying numeric type (u8, f32, f64)

After this change, the user could choose:

  • Rgb(u8) with each channel between 0-255.
  • Rgb(f32) or Rgb(f64) with each channel between 0 and 1

Below there's is an example for Rgb, and the implementation to make this happen (it might be simplifiable...)

Which outputs:

.{ .r = 247, .g = 164, .b = 29 }
.{ .r = 0.96862745, .g = 0.6431373, .b = 0.11372549 }
.{ .r = 0.9686274509803922, .g = 0.6431372549019608, .b = 0.11372549019607843 }
.{ .r = 247, .g = 164, .b = 29 }

pub fn main() !void {
    const rgb: Rgb(u8) = .{ .r = 247, .g = 164, .b = 29 };
    std.debug.print("{any}\n", .{rgb});
    std.debug.print("{any}\n", .{rgb.as(f32)});
    std.debug.print("{any}\n", .{rgb.as(f64)});
    std.debug.print("{any}\n", .{rgb.as(f32).as(u8)});
}

pub fn Rgb(comptime T: type) type {
    return struct {
        r: T,
        g: T,
        b: T,

        const Self = @This();

        pub fn to(self: Self, comptime Color: fn (comptime U: type) type) Color(T) {
            return switch (Color(T)) {
                Rgb(T) => self,
                Hsv(T) => rgbToHsv(T, self),
                else => @compileError("conversion from " ++ @typeName(Self) ++ " to " ++ @typeName(Color(T)) ++ " not yet implemented"),
            };
        }

        pub fn as(self: Self, comptime U: type) Rgb(U) {
            return switch (T) {
                f32, f64 => switch (U) {
                    f32, f64 => .{
                        .r = @floatCast(self.r),
                        .g = @floatCast(self.g),
                        .b = @floatCast(self.b),
                    },
                    u8 => .{
                        .r = @intFromFloat(255 * self.r),
                        .g = @intFromFloat(255 * self.g),
                        .b = @intFromFloat(255 * self.g),
                    },
                    else => @compileError("conversion from " ++ @typeName(Self) ++ "(" ++ @typeName(T) ++ ")" ++ " to " ++ @typeName(U) ++ " not implemented"),
                },
                u8 => switch (U) {
                    u8 => self,
                    f32, f64 => .{
                        .r = @as(U, @floatFromInt(self.r)) / 255,
                        .g = @as(U, @floatFromInt(self.g)) / 255,
                        .b = @as(U, @floatFromInt(self.b)) / 255,
                    },
                    else => @compileError("conversion from " ++ @typeName(Self) ++ "(" ++ @typeName(T) ++ ")" ++ " to " ++ @typeName(U) ++ " not implemented"),
                },
                else => @compileError("conversion from " ++ @typeName(Self) ++ "(" ++ @typeName(T) ++ ")" ++ " to " ++ @typeName(U) ++ " not implemented"),
            };
        }
    };
}

arrufat avatar Nov 18 '25 12:11 arrufat

Be sure to add some type checks here:

pub fn Rgb(comptime T: type) type {
    switch (@typeInfo(T)) {
        .float => {},
        .int => { // only for rgb
            if (T != u8) @compileError("...");
        },
        else => @compileError("..."),
    }
    return struct {
        //...
    };
}

to disallow conversions like:

const rgb:  Rgb(u8) = // ...
_ = rgb.to(Hsv);

Having to and as seperatly, forces the user of the lib to think about the order in which to use them. Which is more inline with general zig philosophy than my proposed one function to do all conversions.

Traxar avatar Nov 19 '25 00:11 Traxar

Sure, I will use @typeInfo in the final version with many comptime checks.

The new workflow should be:

const rgb: Rgb(u8) = .{ .r = 247, .g = 164, .b = 29 };
const hsv: rgb.as(f32).to(Hsv);

It's a bit more verbose than the current API, but way more flexible.

Does that seem reasonable?

arrufat avatar Nov 19 '25 01:11 arrufat

It's a big refactor, it will take some time, though... I will call this colorgate as it seems to be the standard way of naming coding scandals in Zig:

  • https://github.com/ziglang/zig/pull/10055
  • https://github.com/ziglang/zig/pull/24329

arrufat avatar Nov 19 '25 01:11 arrufat

Maybe instead of passing the type generating function use an enum as parameter for to() this will work nicely with LSPs and look better when using the library from another project 🤔

Traxar avatar Nov 19 '25 01:11 Traxar

Right, that would simplify functions such as isColor, since they would be trivial (or even made obsolete). But the enum should have what fields? A field for each colorspace times the backing scalar? .rgb8, rgb32, .rgb64, etc... That doesn't feel right.

I still need to flush this out, since it affects many parts of the library (color types are pretty fundamental in an image processing library, unsurprisingly).

It was the first feature I ever implemented in this library. So everything is built around it. But worry not, I'll come up with a nice API :)

arrufat avatar Nov 19 '25 01:11 arrufat

the enum should have one value per colorspace so to looks like:

pub fn to(self: Self, color_space: ColorSpace)  Color(color_space, T) {
    // ...
}

or somthing along those lines

Traxar avatar Nov 19 '25 02:11 Traxar

Yeah, I think I like this API:

const std = @import("std");

pub fn main() !void {
    const rgb: Rgb(u8) = .{ .r = 247, .g = 164, .b = 29 };
    const hsv = rgb.as(f32).to(.hsv);
    std.debug.print("{any}\n", .{rgb});
    std.debug.print("{any}\n", .{hsv});
    std.debug.print("{any}\n", .{hsv.to(.rgb)});
    std.debug.print("{any}\n", .{hsv.to(.rgb).as(u8)});
}

pub const ColorSpace = enum {
    rgb,
    hsv,

    pub fn Color(self: ColorSpace, comptime T: type) type {
        return switch (self) {
            .rgb => Rgb(T),
            .hsv => Hsv(T),
        };
    }
};

pub fn Rgb(comptime T: type) type {
    switch (@typeInfo(T)) {
        .float => {},
        .int => |info| if (info.bits != 8 or info.signedness != .unsigned) @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space"),
        else => @compileError("Unsupported backing type " ++ @typeName(T) ++ " for color space"),
    }
    return struct {
        r: T,
        g: T,
        b: T,

        const Self = @This();

        pub fn to(self: Self, comptime color_space: ColorSpace) color_space.Color(T) {
            return switch (color_space) {
                .rgb => self,
                .hsv => rgbToHsv(T, self),
            };
        }

        pub fn as(self: Self, comptime U: type) Rgb(U) { ... }

    }
}

arrufat avatar Nov 19 '25 12:11 arrufat

Thats very clean ^-^ i like it too :)

Traxar avatar Nov 20 '25 05:11 Traxar