Allow custom backing types for colorspaces transforms
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.
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 ^^'
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.
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 }
Very cool :D Zignal will definetly be my goto to do any sorts of color shenanigans (and soon even on the gpu ;))
What do you think about this API, @Traxar?
.tofor converting between color spaces.asfor 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)orRgb(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"),
};
}
};
}
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.
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?
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
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 🤔
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 :)
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
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) { ... }
}
}
Thats very clean ^-^ i like it too :)