-
Notifications
You must be signed in to change notification settings - Fork 658
Implement ColorTransform and Cicp on moxcms #2531
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Conversation
@awxkee I'd be glad to know if there's any gross misuse of your cms in the integration. Obviously, exercising the transformers on single pixels at a time isn't the final state here, it should take slices of pixels on both sides and forward their channels to the cms transformer in large chunks. (ImageBuffer can make use of the packed layout without any row strides to do everything at once). |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice to see it here! I don’t think there are any misuses, but I’ve left some thoughts.
src/color/cicp.rs
Outdated
|
||
let opt = moxcms::TransformOptions::default(); | ||
|
||
// TODO: really these should be lazy, eh? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Creating matrix shaper profiles until you're doing this at scale is cheap.
Typical 8-bit matrix shaper creation costs 65KB memory and ~4k power function execution what is cheap.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With all the different transform tables this is building, we get about 2 << 19
power function executions in total, which is no longer cheap. (16 tables for u8, u16, f32 respectively). The tests take a very noticeable time even with opt-level = 2
for moxcms
.
src/color/cicp.rs
Outdated
let from = from.to_moxcms_profile()?; | ||
let into = into.to_moxcms_profile()?; | ||
|
||
let opt = moxcms::TransformOptions::default(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Here are two caveats:
- Since it uses fixed point transforms as a default it immediately introduces 0.05% additional error to any transformation. Considering the scale of the image it might be better to use as a default more generalized approach. OTH Adobe CMM and Apple ColorSync also do transforms in fixed point as a default.
- It also uses an extended range for f32 by default, so it may unexpectedly return negative values or above 1.0. This also typical behavior of lcms2, but might be not really something what you'd expect.
src/color/cicp.rs
Outdated
/// However, there are some differences in the second digit of red's CIE 1931 and the precision | ||
/// is only 2 digits whereas CICP names three; so unsure if this is fully accurate as the | ||
/// actual source material. | ||
Industry22 = 22, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I suspect the roots of this comes from FFMPEG. I don't have any concrete information on this side, but almost any software I know identifies this as so.
This conversion is aware of the color spaces, and is allowed to error. In contrast to the trait based design of the basic sRGB `Convert` we opt for a simple method call. This simplifies the extensibility in the design quite a bit. If we wanted to incorporate more information then a second function can be added, and the options argument can be specific to image buffers. Such extension _will_ be copying data from an external source (`FlatView`) and subimages of image buffers. The method itself also takes both buffers by reference and will only copy pixel data when their dimensions match up. This allows it to happen in-place, which also makes the implementation independent of the exact buffer type whereas `Convert` can only work with `Vec` outputs as it must allocate that container and has no generic way of achieving it. Similarly, reallocation and resizing would make the method several times more complex. When demand comes, we can add a method to convert into a freshly allocated container which simply refers to this method (note: one optimization would be the added benefit of verifying the support for the transform before the container is re-/allocated).
This document the problems of trying to use grayTRC to support our meaning of Luma, which is more similar to a Y component of a YCbCr scheme than a pure monochrome profile. The reason here is that in ICC speak the latter is always in D50 while the former does define its own illuminant. We can not accurately convert rgb to gray since the chromatic adaptation does not take place in ICC and the transfer function itself would not allow accurate luminance for colors that are not linearly dependent of the pcs' whitepoint (D50).
This aligns it with slice::copy_from_slice.
Complements assignment from another color type with the equivalent for Vec::to_vec, which avoids allocation on failure and works with value semantics.
This is the one consistent with our conversion trait.
// coefficients which are for the D65 whitepoint in sRGB primaries. The other half is that | ||
// moxcms support for expressing an YCbCr space with transfer is not there yet. | ||
// | ||
// Implementation ideas: Maybe we'll get that by buffering into a full YCbCr image |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just to make sure nothing missed link to post here.
In light of the full transformation complexity, I propose we settle for this:
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I like the focus of doing color space conversions on ImageBuffer
/ DynamicImage
.
How would you feel about making the conversions happen in-place? I think it could significantly simplify the public API. It would remove the potential to convert RGB <-> RGBA or change the sample format at the same time as a color space conversion, but those could always be done in two steps.
The moxcms
crate doesn't directly support in-place conversions but we could implement it ourselves by iterating over the backing slice with chunks_mut
and copying each chunk into a stack (or heap) allocated buffer.
src/color/cicp.rs
Outdated
/// The transfer characteristics, expressing relation between encoded values and linear color | ||
/// values. | ||
/// | ||
/// Refer to Rec H.273 Table 3. | ||
#[repr(u8)] | ||
#[derive(Clone, Copy, Debug, Eq, PartialEq, Hash)] | ||
pub enum CicpTransferFunction { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: add non_exhaustive
in case more values are added to Rec H.273 (also applies to the color primaries and matrix coefficients)
Cargo.toml
Outdated
@@ -2,10 +2,10 @@ | |||
name = "image" | |||
version = "0.25.6" | |||
edition = "2021" | |||
resolver = "2" | |||
resolver = "3" |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The v3 resolver doesn't really work well for library crates and would cause both local and CI builds to run with outdated dependencies even when not passing -Z minimal-versions
What happens is that the MSRV of 1.85.0 gets treated as the maximum MSRV for any direct or transitive dependency, and Cargo pretends that any versions of dependencies that have a higher MSRV don't exist. It can be worked around with the right flags/env variables, but it is a hassle.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Good hint, I was under the impression it would choose based on the rust-version of the toolchain compiling the package but it instead uses the rust-version
field in the crate declaring the dependency. I'll undo this then but am continuing to be surprised. Well it makes sense considering .lock
file generation.
src/lib.rs
Outdated
pub use crate::color::cicp::{ | ||
Cicp, CicpColorPrimaries, CicpMatrixCoefficients, CicpTransferFunction, CicpTransform, | ||
CicpVideoFullRangeFlag, | ||
}; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a lot of types to add to the crate root. Could we add them to the metadata
module instead?
/// Reinterprets the existing red, blue, green channels as points in the new set of primary | ||
/// colors, potentially changing the apparent shade of pixels. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Probably worth saying something like "the pixel values themselves are not changed"
/// | ||
/// See [`ImageBuffer::copy_from_color`] if you intend to assign to an existing buffer, | ||
/// swapping the argument with `self`. | ||
pub fn to_color<IntoType>( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Perhaps apply_color_space
or apply_color_space_transform
? That would parallel the apply_orientation
method.
Allowing two different color types to take place in one operation is a pretty large feature. I'd even like to have different sample types for Input and Output but moxcms does not yet have that capability. The conversion is not accurate in all combinations (hence also the use Following that logic, of course |
I tried to implement in-place transforms in moxcms, but eventually I stopped, realized it's neither easy nor particularly useful. Unless you're in a sandboxed environment where allocations are restricted or too expensive, I see no compelling reason to maintain in-place transforms. Doing a lot of work just to avoid writing a single extra line of code doesn't seem like strong motivation to me. |
All the existing methods on If we're going to add the ability to switch the sample format while doing the transform, that to me is a strong reason to pick allocate+return. In particular, something like converting 8-bit sRGB to 16-bit Rec. 2020 makes a lot of sense to me. But I'm still not sure about being able to add/remove an alpha channel at the same time as a color space transform. Having it part of the API while banning RGB <-> Luma conversions adds an unfortunate gotcha. And I'd guess that it wouldn't take meaningfully longer to change color space and alpha channel in two steps versus one? @awxkee In place transforms wouldn't require any changes to let mut scratch = [P::Subpixel::DEFAULT_MIN_VALUE; 1024 * P::CHANNELS];
for chunk in (&*self.data).chunks_mut(scratch.len()) {
let scratch = &mut scratch[..chunk.len()];
scratch.copy_from_slice(chunk);
transform(scratch, chunk);
} |
But converting the bit-depth at the same time is problematic; and addressed by the very same API. 8-bit Luma to 16-bit or 32-float RGBA makes a lot of sense to avoid some of the banding; as does using another target depth when converting color spaces. That is routine for something like an editor that has an internal surface in lin-rgb RGBA but gets from the outside arbitrary other depths and colors. You can pretty much only do this wrong outside Regarding the claim of consistency, it is the analogue of copy_from but correctly modelled with efficiency in mind first, not doing opaque type dispatch that leads to us having to give the problematic advice of writing out the loop pixel-by-pixel. ( |
I was thinking that it would be possible to bound the method with For the method signatures, having /// Perform the transform in-place (using a scratch buffer)
pub fn apply_color_space_transform(
&mut self,
color_space: Cicp,
options: ConvertColorOptions) -> ImageResult<()>;
/// Check if the transform is supported, then allocate an output image and apply it.
pub fn convert_color_space(
&self,
color_space: Cicp,
color_type: ColorType,
options: ConvertColorOptions) -> ImageResult<DynamicImage>; To me, color space conversion feels similar to operations like I also think it is important to use a more expressive suffix than |
I think the best option would be to ensure that luma works too. This should be possible by interpreting Luma as if we had set the With that we can resolve the mismatch between the APIs and just provide both: a way to copy into and a way to apply, and change the color space. So all of it. That's probably the most consistent way to deal with it for now. I still wouldn't provide it for pixels because it is not cheap per-pixel transformation. So that interface should stew for a bit.. Then we can add |
This is a draft for reference purposes. The goal here is to sketch a way to bring CICP profiles into
ImageBuffer
/DynamicImage
through thePixel
trait.The first consequence of attempting to do this performantly is that we should not generalize over the subpixel type. Hence this uses the internal sealed trait to ensure we only need to handle the
PixelWithColorType
types, but the dispatch that makes this work is quite cursed. Also the code is still building all tables eagerly (so 48 tables which is slow) but there should possibly be a form of control over the point-in-time when the user wants this to happen.