Repository navigation
Implement Cicp for ImageBuffer,DynamicImage and provide ColorTransform on moxcms - #2531
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). |
awxkee
left a comment
There was a problem hiding this comment.
Nice to see it here! I don’t think there are any misuses, but I’ve left some thoughts.
|
In light of the full transformation complexity, I propose we settle for this:
|
There was a problem hiding this comment.
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.
| /// 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.
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.
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 |
acf82c7 to
2ff0710
Compare
|
@fintelia Would you have time for re-review? The scratch buffer approach works somewhat but is unfortunately slightly more complicated than the sketch. If we would be operating on the most generic |
fintelia
left a comment
There was a problem hiding this comment.
The design looks good to me.
One thing I want to think about is how we could extend the code (in a subsequent PR) for automatically including cICP or ICC chunks when saving images. But since it is much better not to include color metadata than to include wrong metadata, we should be very confident in whatever we write. A few options:
- One strategy would be to distinguish between "unknown" color space and the various defined color space. We could then document that unknown color space behaves like sRGB for the methods that care about color space, but not write any color metadata when saving to a file.
- Another option is to never write colorspace metadata for sRGB. That would eliminate the concept of "unknown", but feels kind of messy.
- Something else?
| pub struct ConvertColorOptions { | ||
| /// A pre-calculated transform. This is only used when the actual colors of the input and | ||
| /// output image match the color spaces with which the was constructed. | ||
| pub transform: Option<CicpTransform>, |
There was a problem hiding this comment.
Would it make sense to take an Option<&mut CicpTransform> so the transform could be populated lazily and reused between calls?
There was a problem hiding this comment.
The transform already uses Arc for shared data under the hood, if we want to populate any of that lazily that is a OnceLock either way. So really it would not be necessary to take anything by mutable reference here; an owned value works pretty much precisely the same for our usage.
There was a problem hiding this comment.
Thinking about this more, perhaps an Option<Arc<CicpTransform>>? That would make it more clear from the type signature what was going on.
There was a problem hiding this comment.
That would be double indirection. The sharing happens internally, moxcms gives (more-or-less) a Box<dyn Fn(…)> and some of these transformers are used be multiple different sample types. Concretely, the f32 ones are used as fallbacks for all sample types. Hence some of them are already inherently shared and it is easiest to just share all of them, since nothing about the transformers is mutated in any case. If we switched to another implementation it would be very similar.
There was a problem hiding this comment.
I've marked the option struct !Send + !Sync by a PhantomData attribute to ensure that even local options can be added. Except if they have lifetimes, but if we don't tie that to the lifetime of self in all the calls its usefulness would always be limited. In any case with !Send + !Sync we could add an implementation-detail Rc<RefCell<_>> in the worst case. I think we'll be limited by available throughput for quite a while before we can actually explore the implications.
This should be as flexible as we can make it without impacting usability of values.
There was a problem hiding this comment.
I'd like to find a way to communicate that the object is a cheaply clonable handle to a cache of transform functions. Neither the doc comments nor the struct naming currently convey that point.
If you'd like, there is the option of merging this PR without any public fields in ConvertColorOptions, and then figuring out the API for transform caching separately. I think the rest of the PR is ready to merge.
There was a problem hiding this comment.
It's rather necessary for performance, so I'll currently hide the field as private which makes it useful in our tests. (The full coverage table will take far longer if we were to recompute the luts each time).
In contrast to the intra-color-space conversion this one only requires coefficients (in non-constant luminance) which we can do without problems for all DynamicImage buffers. This also does not require setting up any tables or tone matching functions. The only pain point is that we can not support the highly generic `DynamicImage::to` due to its output pixel generic being too unconstrained to pass it any information on the underlying color space. This will always be sRGB. Problematically this is not even `'static` so we can't due type-id hacks..
As has been implemented upstream, the matrix derivation is now stable even for the XYZ case. Deferring to it makes this cleaner and allows us to support that final missing case.
|
Rebased and updated the code to derive luminance coefficients to defer to the |
The best use of that flag is for guaranteed performance, with caching etc. deferred to another flag and feature, and to verify performance it makes more sense to enforce it strictly instead of spend a costly operation (synchronously, as a function call) before it can be detected.
This enables us to add, at a later point, options that own thread-local or shared mutable resources such as buffers, size-limited cache spaces, or executors. We can always remove that at a later point if no such option materializes (e.g. due to lack of implementation pressure).
See the discussion <#2531 (comment)>
|
I'm not sure I understand this PR, so I don't feel qualified to update the changelog in #2555, but I feel this should be included. Also note that this pulls in Many of these are platform-specific, so nobody gets all of this at once, but the battle with dependency sprawl is lost by a thousand cuts. I've opened awxkee/moxcms#97 to track this upstream. |
|
This PR also causes a nearly 3x slowdown in release clean build times when compiling with It touches a large number of places, but I'm really wondering whether this warrants having a "cms" feature to gives users the choice of whether to pull in all the color conversion code. |
|
#97 is already merged upstream 🎉 With regards to the feature, can you try again with |
|
I believe moxcms itself contributes a lot to the compilation time. A huge part of the codegen goes into LUT transforms, which are not used at all here. In fact the code for LUT transforms takes about 70%+ of the crate. If there are no plans to work with ICC directly you end up compiling 70% of code that won’t be used. Additionally, it has support for LUT + matrix shaper transforms in |
|
True, it seems to spend about twice as much time in codegen for @awxkee Here's a data point from |
The goal here is to bring CICP profiles into
ImageBuffer/DynamicImage. CICP is a perfect fit since, similar to PNG, we only have an rgb-ish pixel canvas in these types (and the sentiment is to have more complex representations, if at all, in separate types).The first consequence of performance is that we should not generalize over the subpixel type. Handling combinations with sample conversion is only possible when we have an enumeration of possibilities (as in
DynamicImage) and requires matches with each individual combination handled. Instead, we here convert throughf32when encountering mismatch to trade a little bit of performance with somewhat terse code.This uses the internal sealed trait to ensure we only need to handle the
PixelWithColorTypetypes in the public interface¹. The dispatch that makes this work is a little bit cursed (see comment incolor::private) but fully hidden away. Also the code is building all tables eagerly for 16 combinations × 3 color depths. This should not be a huge burden in practice. We can patch it later if users want to convert small images infrequently. The whole conversion apparatus allows caching of theCicpTransformer, which is alsoCloneand comprised of mainlyArc<dyn Fn()>for the individual paths.Closes: #786
Relates: #1554, #1030, #2493
¹For historical reasons,
Dynamicimage::to_luma_f32exists without havingColorType::Luma32F