add jpeg codec - #66
Conversation
|
I think there are more parameters that |
Different JPEG implementations may or may not allow you that level of control (I know a bunch don't). |
|
Very excited to see this; crazy timing because I'm playing with jpeg encoded chunks in zarrs. For example, it's been useful to have the ability to pass a header in the config -- and colorspace data, i forgot about that one! |
|
|
||
| ## Supported chunk shapes | ||
|
|
||
| JPEG can only store images with **1 component** (grayscale) or **3 components** (RGB), so the channel count is derived from the chunk shape: |
There was a problem hiding this comment.
Should you precise that RGB images and microscopy images with 3 channels are different things? Otherwise this distinction may not be clear for everyone that RGB images have colored pixel (they are measured together) while 3 channels fluorescence microscopy images are another dimension of the array (~independant from each other).
There was a problem hiding this comment.
I don't think there needs to be a strong link between how a channels are acquired and how they are represented when stored in a Zarr array. it's perfectly valid to store multichannel microscopy images with the different bands packed in a struct data type (although it would not work with the proposed jpeg codec without a data type conversion), and not all RGB images have the color channels acquired simultaneously. The choice of stored representation is just a choice here.
There was a problem hiding this comment.
I think the point is that common JPEG settings are optimised for human eyes looking at natural scenes rather than for equal fidelity across each of 3 interchangeable channels. So we should provide guidance on when it is appropriate to use "natural" JPEG compression (e.g. taking a photo down a bright field microscope) vs when it may be more representative to compress each channel separately as greyscale (e.g. some arbitrary number of different fluorescent labels, even if that arbitrary number is 3).
There was a problem hiding this comment.
Note that while YCbCr 420 is the default representation for libjpeg, the JPEG format (and libjpeg) do allow many other possibilities, including storing RGB directly as RGB with no color space conversion or chroma subsampling. It isn't clear whether that would be particularly useful compared to just using sharding and encoding each channel as a separate jpeg, but it is a possibility.
|
A quick rust implementation for zarrs, using the encoder crate's defaults to fill in all the other settings: https://github.com/clbarnes/zarrs_jpeg Deviations from the spec as written are in the README. The chunks it creates are valid JFIFs (which is a must-have feature IMO). Because it accepts |
|
Thanks everyone, this was super helpful. I am summarizing my specifications of the codec below: Supported shapesThe codec will now accept only these shapes (inspired by @clbarnes' implementation https://github.com/clbarnes/zarrs_jpeg):
Color conversion & subsampling — no default for 3-channel@jbms mentioned YCbCr + 4:2:0 is designed for natural photos and silently destroys fidelity for scientific data whose channels aren't real colors. So for 3-component data there will be no default, you have to choose explicitly:
Usecases for parameters:
Also max dimensions: JPEG stores width/height as 16-bit, so max 65,535 in any dimension (@clbarnes). |
|
Just from digging through rust implementations, it seems like it's pretty common not to support encoding without color transformation. Even the libjpeg-turbo bindings, some expansive vibecoded codec ecosystems, and foundational image crate don't offer it at all. Maybe that's just a rust problem. As that config is hopefully only used on write, it may not be a showstopper (although I don't have a lot of faith that the decoders will support it if the encoders don't); it's just something I'm a bit worried about. It can be worked around by sharding or transposing and reshaping to split the channels out into greyscales; we could propose that instead of allowing the |
|
Just want to check that I understand the algorithm correctly wrt when to ignore and default config values: def jpeg_encode(rgb_bytes: bytes, quality: int, internal_color_space: str, subsampling: str=None):
...
def encode(config, bytes, shape):
assert len(shape) in (2, 3)
if len(shape) == 2 or (len(shape) == 3 and shape[2] == 1):
# ignore color_transform and subsampling
return jpeg_encode(bytes, config.quality, "grayscale")
else:
assert shape[2] == 3
assert config.color_transform is not None, "color_transform must be set"
if config.color_transform == "ycbcr":
# default subsampling if not defined
subsampling = config.subsampling or "4:2:0"
return jpeg_encode(bytes, config.quality, "ycbcr", subsampling)
elif config.color_transform == "none":
# ignore subsampling, maybe raise a warning
return jpeg_encode(bytes, config.quality, "rgb", "4:4:4") |
|
I looked a bit more at the color space issue. The plain JFIF format does not indicate color space, and instead 1 channel is assumed to be stored as grayscale while 3 channels are assumed to be stored as YCbCr. There is, however, an APP14 Adobe marker that is supported by many implementations, including libjpeg, that can indicate the stored color space as one of: unknown, YCbCr, or YCCK (not widely supported 4-channel color space). Therefore for the color space transform, arguably it should specify both the input and output color spaces, rather than a more ambiguous transform. For example, there could be separate
This representation allows the YCbCr -> YCbCr noop color space conversion while still allowing the JPEG to correctly indicate that it stores YCbCr data. We could indicate that not all implementations support combinations other than "grayscale -> grayscale" and "RGB -> YCbCr", and therefore it is not recommended to use the other options. Subsampling is entirely independent of the color space conversion, at least as far as the file format is concerned. The jpeg format itself allows separate vertical and horizontal subsampling factors to be specified for each component, as a number of 1, 2, 3, or 4, which correspond to subsampling factors of 1/4, 2/4, 3/4, 4/4. Therefore I think it would make sense to specify the horizontal and vertical subsampling factors for each encoded component separately rather than the "4:2:0" syntax which is more limited and also rather confusing. |
|
@clbarnes Regardless of the specific representation of the color space parameters, I'd suggest that invalid color space parameters for the number of channels be treated as an error rather than ignored. |
|
@jbms regarding color space, I'd keep a single color_transform: grayscale, ycbcr (RGB→YCbCr), and none (RGB→RGB, marked "unknown") cover every case that occurs in practice. On subsampling, I'd also keep the named presets (4:4:4/4:2:2/4:2:0). The per-component [[2,2],[1,1],[1,1]] notation is more faithful to the format, but this codec targets scientists, not JPEG experts 4:2:0 is the industry-standard notation everyone recognizes, while [[1,1],[1,1],[1,1]] requires knowing JPEG's internals to read. The three presets cover every meaningful case, I feel like more would be over engineering. |
|
There are other color models, like CMYK (potential for 4-channel storage) and XYB (better visual fidelity for natural RGB data), but neither are in the original spec and don't have the same breadth of support. The benefit of being more explicit is that if the codec was ever extended to include them in future, it would be a backwards-compatible relaxation rather than needing new fields. Allowing an explicit serialisation but then strict guidance on which ones are allowed (or should be used for maximum compatibility) may be the middle ground - APIs can be defined around those usage patterns. The same sort of goes for subsampling, but the subsampling regimes are more limited and there isn't really a use case for the full flexibility. It looks like turboJPEG just uses an enum covering 4:4:4, 4:2:2, 4:2:0, greyscale, 4:4:0, 4:1:1, 4:4:1, and unknown.
FWIW, I raised issues and adding color transforms (or lack thereof) to the rust libjpeg-turbo bindings seems tractable, and the aforementioned vibecoded ecosystem has now vibecoded them in, for better or for worse. |
|
adjusted specs according to discussion |
I think the main audience for the JSON form of the codec will be software engineers with some degree of Zarr knowledge. For that audience, this codec will be one of many codecs. IMO for this audience the per-dimension arrays of subsampling factors will be easier to explain than a string like |
|
I have been staring at 4:2:2-like values for some hours now and still have no idea what they're supposed to mean. A serialisation referring to the size of the luminance blocks represented by a single chrominance pixel makes more sense. Is the explicit inclusion of the chrominance pixel block sizes in case of future colour spaces where there might be some whackier ratio? Also, I've updated the rust impl for the current spec, using my fork of the libjpeg-turbo bindings which supports color transforms (PR up for review). |
Yes, the 420 syntax is commonly used for specifying chroma subsampling for image and video formats but is not flexible enough to represent what the JPEG format actually supports, and is frankly quite confusing compared to explicit values for vertical and horizontal subsampling: Even referring to the wikipedia page it is easy to pick the wrong one by accident . |
|
Colorspaces with different numbers of channels should use subsampling arrays of different lengths, right? In my code I've modeled the colorspace/ subsampling config as a tagged union rather than make the user remember which combinations are and aren't valid. It serialises to two separate fields. use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
#[serde(tag = "encoded_color_space", rename_all = "lowercase")]
enum ColorConfig {
Rgb,
Grayscale,
YCbCr { subsampling: [[u8; 2]; 3] },
// maybe in future
// CMYK { subsampling: [[u8; 2]; 4] }
}
#[derive(Serialize, Deserialize)]
struct JpegCodecConfiguration {
quality: u8,
#[serde(flatten)]
color_config: ColorConfig,
}I think the clearest explanation might be
Unrelated: I think the spec should include an instruction that decoders should basically ignore the encoding arguments and decode whatever they can, then just check that the shape and dtype matches expectation. This gives us some leeway for converting legacy JPEG-compressed formats which might have more baroque settings and still being able to read them. |
Logically, the steps to encode a JPEG are:
The subsampling applies to the encoded color space and therefore must match the number of channels in the encoded color space.
There is also 4-channel YCCK, which is like YCbCr but for CMYK.
Subsampling is independent of color space --- it occurs after color space conversion and even be used with grayscale. In practice, however, it is unlikely to be useful except for YCbCr and YCCK representations.
I would say that the decoders should indeed ignore However, I have suggested that there is a For example, you might already have a color image in YCbCr format. Then you can specify
|
Co-authored-by: Norman Rzepka <code@normanrz.com>
6c05f9c to
7e6865d
Compare
|
@normanrz @clbarnes I completely agree with your view on the parameters: ▎ encoded_color_space MUST be given as "ycbcr", "rgb", or "grayscale". I went looking at how this is handled in practice, and I think imagecodecs is the most relevant reference here. https://github.com/cgohlke/imagecodecs/blob/master/imagecodecs/_jpeg8.pyx On decoded_color_space @jbms The parameter pair already exists in libjpeg, and imagecodecs exposes it directly: as functions parameters (step: in, out) we would have: On encode that's precisely (decoded, encoded); on decode the same two reappear as "what the file is" and "what I want handed back". I'd want decoded_color_space to be optional, defaulting to rgb for 3-component and grayscale for 1-component data, therefore defaulting to what the spec currently hardcodes. This isn't inconsistent with encoded_color_space having no default: defaulting that to ycbcr would silently destroy non-colour channels, whereas rgb here is just the single value the spec allows today, so the default can't surprise anyone. imagecodecs already does exactly this — when colorspace (the decoded one) isn't given, it infers JCS_GRAYSCALE for 1 sample and JCS_RGB for 3 line 187-197 same link from above. |
add jpeg codec specification for pr that I did
'
zarr-developers/zarr-java#81