1#![allow(clippy::multiple_crate_versions)] #[cfg(test)]
23use ::serde_json as _;
24
25use std::borrow::Cow;
26
27use ndarray::{Array, Array1, ArrayBase, ArrayViewMut, Axis, Data, DataMut, Dimension, IxDyn};
28use num_traits::Float;
29use numcodecs::{
30 AnyArray, AnyArrayDType, AnyArrayView, AnyArrayViewMut, AnyCowArray, Codec, StaticCodec,
31 StaticCodecConfig, StaticCodecVersion,
32};
33use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
34use serde::{Deserialize, Deserializer, Serialize, Serializer};
35use thiserror::Error;
36
37type EbccCodecVersion = StaticCodecVersion<0, 1, 0>;
38
39#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
49#[schemars(deny_unknown_fields)]
50pub struct EbccCodec {
51 #[serde(flatten)]
53 pub residual: EbccResidualType,
54 #[serde(default = "default_base_cr")]
56 pub base_cr: Positive<f32>,
57 #[serde(default, rename = "_version")]
59 pub version: EbccCodecVersion,
60}
61
62const fn default_base_cr() -> Positive<f32> {
63 Positive(100.0)
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, JsonSchema)]
68#[serde(tag = "residual")]
69#[serde(deny_unknown_fields)]
70pub enum EbccResidualType {
71 #[serde(rename = "jpeg2000-only")]
72 Jpeg2000Only,
74 #[serde(rename = "absolute")]
75 AbsoluteError {
77 error: Positive<f32>,
79 },
80 #[serde(rename = "relative")]
81 RelativeError {
83 error: Positive<f32>,
85 },
86}
87
88impl Codec for EbccCodec {
89 type Error = EbccCodecError;
90
91 fn encode(&self, data: AnyCowArray) -> Result<AnyArray, Self::Error> {
92 match data {
93 AnyCowArray::F32(data) => Ok(AnyArray::U8(
94 Array1::from(compress(data, self.residual, self.base_cr)?).into_dyn(),
95 )),
96 encoded => Err(EbccCodecError::UnsupportedDtype(encoded.dtype())),
97 }
98 }
99
100 fn decode(&self, encoded: AnyCowArray) -> Result<AnyArray, Self::Error> {
101 let AnyCowArray::U8(encoded) = encoded else {
102 return Err(EbccCodecError::EncodedDataNotBytes {
103 dtype: encoded.dtype(),
104 });
105 };
106
107 if !matches!(encoded.shape(), [_]) {
108 return Err(EbccCodecError::EncodedDataNotOneDimensional {
109 shape: encoded.shape().to_vec(),
110 });
111 }
112
113 decompress(&AnyCowArray::U8(encoded).as_bytes())
114 }
115
116 fn decode_into(
117 &self,
118 encoded: AnyArrayView,
119 decoded: AnyArrayViewMut,
120 ) -> Result<(), Self::Error> {
121 let AnyArrayView::U8(encoded) = encoded else {
122 return Err(EbccCodecError::EncodedDataNotBytes {
123 dtype: encoded.dtype(),
124 });
125 };
126
127 if !matches!(encoded.shape(), [_]) {
128 return Err(EbccCodecError::EncodedDataNotOneDimensional {
129 shape: encoded.shape().to_vec(),
130 });
131 }
132
133 match decoded {
134 AnyArrayViewMut::F32(decoded) => {
135 decompress_into(&AnyArrayView::U8(encoded).as_bytes(), decoded)
136 }
137 decoded => Err(EbccCodecError::UnsupportedDtype(decoded.dtype())),
138 }
139 }
140}
141
142impl StaticCodec for EbccCodec {
143 const CODEC_ID: &'static str = "ebcc.rs";
144
145 type Config<'de> = Self;
146
147 fn from_config(config: Self::Config<'_>) -> Self {
148 config
149 }
150
151 fn get_config(&self) -> StaticCodecConfig<'_, Self> {
152 StaticCodecConfig::from(self)
153 }
154}
155
156#[derive(Debug, thiserror::Error)]
158pub enum EbccCodecError {
159 #[error("Ebcc does not support the dtype {0}")]
161 UnsupportedDtype(AnyArrayDType),
162 #[error("Ebcc failed to encode the header")]
164 HeaderEncodeFailed {
165 source: EbccHeaderError,
167 },
168 #[error(
171 "Ebcc can only encode >2D data where the last two dimensions must be at least 32x32 but received an array of shape {shape:?}"
172 )]
173 InsufficientDimensions {
174 shape: Vec<usize>,
176 },
177 #[error("Ebcc failed to encode the data")]
179 EbccEncodeFailed {
180 source: EbccCodingError,
182 },
183 #[error("Ebcc failed to encode a 3D slice")]
185 SliceEncodeFailed {
186 source: EbccSliceError,
188 },
189 #[error(
192 "Ebcc can only decode one-dimensional byte arrays but received an array of dtype {dtype}"
193 )]
194 EncodedDataNotBytes {
195 dtype: AnyArrayDType,
197 },
198 #[error(
201 "Ebcc can only decode one-dimensional byte arrays but received a byte array of shape {shape:?}"
202 )]
203 EncodedDataNotOneDimensional {
204 shape: Vec<usize>,
206 },
207 #[error("Ebcc failed to decode the header")]
209 HeaderDecodeFailed {
210 source: EbccHeaderError,
212 },
213 #[error("Ebcc cannot decode an array of shape {decoded:?} into an array of shape {array:?}")]
215 DecodeIntoShapeMismatch {
216 decoded: Vec<usize>,
218 array: Vec<usize>,
220 },
221 #[error("Ebcc failed to decode a slice")]
223 SliceDecodeFailed {
224 source: EbccSliceError,
226 },
227 #[error("Ebcc failed to decode from an excessive number of slices")]
229 DecodeTooManySlices,
230 #[error("Ebcc failed to decode the data")]
232 EbccDecodeFailed {
233 source: EbccCodingError,
235 },
236}
237
238#[expect(clippy::derive_partial_eq_without_eq)] #[derive(Debug, Copy, Clone, PartialEq, PartialOrd, Hash)]
240pub struct Positive<T: Float>(T);
242
243impl<T: Float> PartialEq<T> for Positive<T> {
244 fn eq(&self, other: &T) -> bool {
245 self.0 == *other
246 }
247}
248
249impl Serialize for Positive<f32> {
250 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
251 serializer.serialize_f32(self.0)
252 }
253}
254
255impl<'de> Deserialize<'de> for Positive<f32> {
256 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
257 let x = f32::deserialize(deserializer)?;
258
259 if x > 0.0 {
260 Ok(Self(x))
261 } else {
262 Err(serde::de::Error::invalid_value(
263 serde::de::Unexpected::Float(f64::from(x)),
264 &"a positive value",
265 ))
266 }
267 }
268}
269
270impl JsonSchema for Positive<f32> {
271 fn schema_name() -> Cow<'static, str> {
272 Cow::Borrowed("PositiveF32")
273 }
274
275 fn schema_id() -> Cow<'static, str> {
276 Cow::Borrowed(concat!(module_path!(), "::", "Positive<f32>"))
277 }
278
279 fn json_schema(_gen: &mut SchemaGenerator) -> Schema {
280 json_schema!({
281 "type": "number",
282 "exclusiveMinimum": 0.0
283 })
284 }
285}
286
287#[derive(Debug, Error)]
288#[error(transparent)]
289pub struct EbccHeaderError(postcard::Error);
291
292#[derive(Debug, Error)]
293#[error(transparent)]
294pub struct EbccSliceError(postcard::Error);
296
297#[derive(Debug, Error)]
298#[error(transparent)]
299pub struct EbccCodingError(ebcc::EBCCError);
301
302#[allow(clippy::missing_panics_doc)]
314pub fn compress<S: Data<Elem = f32>, D: Dimension>(
315 data: ArrayBase<S, D>,
316 residual: EbccResidualType,
317 base_cr: Positive<f32>,
318) -> Result<Vec<u8>, EbccCodecError> {
319 let mut encoded = postcard::to_extend(
320 &CompressionHeader {
321 dtype: EbccDType::F32,
322 shape: Cow::Borrowed(data.shape()),
323 version: StaticCodecVersion,
324 },
325 Vec::new(),
326 )
327 .map_err(|err| EbccCodecError::HeaderEncodeFailed {
328 source: EbccHeaderError(err),
329 })?;
330
331 if data.is_empty() {
333 return Ok(encoded);
334 }
335
336 let mut chunk_size = Vec::from(data.shape());
337 let (width, height, depth) = match *chunk_size.as_mut_slice() {
338 [ref mut rest @ .., depth, height, width] => {
339 for r in rest {
340 *r = 1;
341 }
342 (width, height, depth)
343 }
344 [height, width] => (width, height, 1),
345 _ => {
346 return Err(EbccCodecError::InsufficientDimensions {
347 shape: Vec::from(data.shape()),
348 });
349 }
350 };
351
352 if (width < 32) || (height < 32) {
353 return Err(EbccCodecError::InsufficientDimensions {
354 shape: Vec::from(data.shape()),
355 });
356 }
357
358 for mut slice in data.into_dyn().exact_chunks(chunk_size.as_slice()) {
359 while slice.ndim() < 3 {
360 slice = slice.insert_axis(Axis(0));
361 }
362 #[expect(clippy::unwrap_used)]
363 let slice = slice.into_shape_with_order((depth, height, width)).unwrap();
366
367 let encoded_slice = ebcc::ebcc_encode(
368 slice,
369 &ebcc::EBCCConfig {
370 base_cr: base_cr.0,
371 residual_compression_type: match residual {
372 EbccResidualType::Jpeg2000Only => ebcc::EBCCResidualType::Jpeg2000Only,
373 EbccResidualType::AbsoluteError { error } => {
374 ebcc::EBCCResidualType::AbsoluteError(error.0)
375 }
376 EbccResidualType::RelativeError { error } => {
377 ebcc::EBCCResidualType::RelativeError(error.0)
378 }
379 },
380 },
381 )
382 .map_err(|err| EbccCodecError::EbccEncodeFailed {
383 source: EbccCodingError(err),
384 })?;
385
386 encoded = postcard::to_extend(encoded_slice.as_slice(), encoded).map_err(|err| {
387 EbccCodecError::SliceEncodeFailed {
388 source: EbccSliceError(err),
389 }
390 })?;
391 }
392
393 Ok(encoded)
394}
395
396pub fn decompress(encoded: &[u8]) -> Result<AnyArray, EbccCodecError> {
407 fn decompress_typed(
408 encoded: &[u8],
409 shape: &[usize],
410 ) -> Result<Array<f32, IxDyn>, EbccCodecError> {
411 let mut decoded = Array::<f32, _>::zeros(shape);
412 decompress_into_typed(encoded, decoded.view_mut())?;
413 Ok(decoded)
414 }
415
416 let (header, encoded) =
417 postcard::take_from_bytes::<CompressionHeader>(encoded).map_err(|err| {
418 EbccCodecError::HeaderDecodeFailed {
419 source: EbccHeaderError(err),
420 }
421 })?;
422
423 if header.shape.iter().copied().any(|s| s == 0) {
425 return match header.dtype {
426 EbccDType::F32 => Ok(AnyArray::F32(Array::zeros(&*header.shape))),
427 };
428 }
429
430 match header.dtype {
431 EbccDType::F32 => Ok(AnyArray::F32(decompress_typed(encoded, &header.shape)?)),
432 }
433}
434
435pub fn decompress_into<S: DataMut<Elem = f32>, D: Dimension>(
448 encoded: &[u8],
449 decoded: ArrayBase<S, D>,
450) -> Result<(), EbccCodecError> {
451 let (header, encoded) =
452 postcard::take_from_bytes::<CompressionHeader>(encoded).map_err(|err| {
453 EbccCodecError::HeaderDecodeFailed {
454 source: EbccHeaderError(err),
455 }
456 })?;
457
458 if decoded.shape() != &*header.shape {
459 return Err(EbccCodecError::DecodeIntoShapeMismatch {
460 decoded: header.shape.into_owned(),
461 array: Vec::from(decoded.shape()),
462 });
463 }
464
465 if header.shape.iter().copied().any(|s| s == 0) {
467 return match header.dtype {
468 EbccDType::F32 => Ok(()),
469 };
470 }
471
472 match header.dtype {
473 EbccDType::F32 => decompress_into_typed(encoded, decoded.into_dyn().view_mut()),
474 }
475}
476
477fn decompress_into_typed(
478 mut encoded: &[u8],
479 mut decoded: ArrayViewMut<f32, IxDyn>,
480) -> Result<(), EbccCodecError> {
481 let mut chunk_size = Vec::from(decoded.shape());
482 let (width, height, depth) = match *chunk_size.as_mut_slice() {
483 [ref mut rest @ .., depth, height, width] => {
484 for r in rest {
485 *r = 1;
486 }
487 (width, height, depth)
488 }
489 [height, width] => (width, height, 1),
490 [width] => (width, 1, 1),
491 [] => (1, 1, 1),
492 };
493
494 for mut slice in decoded.exact_chunks_mut(chunk_size.as_slice()) {
495 let (encoded_slice, rest) =
496 postcard::take_from_bytes::<Cow<[u8]>>(encoded).map_err(|err| {
497 EbccCodecError::SliceDecodeFailed {
498 source: EbccSliceError(err),
499 }
500 })?;
501 encoded = rest;
502
503 while slice.ndim() < 3 {
504 slice = slice.insert_axis(Axis(0));
505 }
506 #[expect(clippy::unwrap_used)]
507 let slice = slice.into_shape_with_order((depth, height, width)).unwrap();
510
511 ebcc::ebcc_decode_into(&encoded_slice, slice).map_err(|err| {
512 EbccCodecError::EbccDecodeFailed {
513 source: EbccCodingError(err),
514 }
515 })?;
516 }
517
518 if !encoded.is_empty() {
519 return Err(EbccCodecError::DecodeTooManySlices);
520 }
521
522 Ok(())
523}
524
525#[derive(Serialize, Deserialize)]
526struct CompressionHeader<'a> {
527 dtype: EbccDType,
528 #[serde(borrow)]
529 shape: Cow<'a, [usize]>,
530 version: EbccCodecVersion,
531}
532
533#[derive(Copy, Clone, Debug, Serialize, Deserialize)]
535enum EbccDType {
536 #[serde(rename = "f32", alias = "float32")]
537 F32,
538}
539
540#[cfg(test)]
541mod tests {
542 use super::*;
543
544 #[test]
545 fn test_unsupported_dtype() {
546 let codec = EbccCodec {
547 residual: EbccResidualType::Jpeg2000Only,
548 base_cr: Positive(10.0),
549 version: StaticCodecVersion,
550 };
551
552 let data = Array1::<i32>::zeros(100);
553 let result = codec.encode(AnyCowArray::I32(data.into_dyn().into()));
554
555 assert!(matches!(result, Err(EbccCodecError::UnsupportedDtype(_))));
556 }
557
558 #[test]
559 fn test_invalid_dimensions() {
560 let codec = EbccCodec {
561 residual: EbccResidualType::Jpeg2000Only,
562 base_cr: Positive(10.0),
563 version: StaticCodecVersion,
564 };
565
566 let data = Array::zeros(32);
568 let result = codec.encode(AnyCowArray::F32(data.into_dyn().into()));
569 assert!(
570 matches!(result, Err(EbccCodecError::InsufficientDimensions { shape }) if shape == [32])
571 );
572
573 let data = Array::zeros((16, 16));
575 let result = codec.encode(AnyCowArray::F32(data.into_dyn().into()));
576 assert!(
577 matches!(result, Err(EbccCodecError::InsufficientDimensions { shape }) if shape == [16, 16])
578 );
579
580 let data = Array::zeros((1, 32, 16));
582 let result = codec.encode(AnyCowArray::F32(data.into_dyn().into()));
583 assert!(
584 matches!(result, Err(EbccCodecError::InsufficientDimensions { shape }) if shape == [1, 32, 16])
585 );
586
587 let data = Array::zeros((1, 32, 32));
589 let result = codec.encode(AnyCowArray::F32(data.into_dyn().into()));
590 assert!(result.is_ok());
591
592 let data = Array::zeros((2, 2, 2, 32, 32));
594 let result = codec.encode(AnyCowArray::F32(data.into_dyn().into()));
595 assert!(result.is_ok());
596 }
597
598 #[test]
599 fn test_large_array() -> Result<(), EbccCodecError> {
600 let height = 721; let width = 1440;
603 let frames = 1;
604
605 #[expect(clippy::suboptimal_flops, clippy::cast_precision_loss)]
606 let data = Array::from_shape_fn((frames, height, width), |(_k, i, j)| {
607 let lat = -90.0 + (i as f32 / height as f32) * 180.0;
608 let lon = -180.0 + (j as f32 / width as f32) * 360.0;
609 #[allow(clippy::let_and_return)]
610 let temp = 273.15 + 30.0 * (1.0 - lat.abs() / 90.0) + 5.0 * (lon / 180.0).sin();
611 temp
612 });
613
614 let codec_error = 0.1;
615 let codec = EbccCodec {
616 residual: EbccResidualType::AbsoluteError {
617 error: Positive(codec_error),
618 },
619 base_cr: Positive(20.0),
620 version: StaticCodecVersion,
621 };
622
623 let encoded = codec.encode(AnyArray::F32(data.clone().into_dyn()).into_cow())?;
624 let decoded = codec.decode(encoded.cow())?;
625
626 let AnyArray::U8(encoded) = encoded else {
627 return Err(EbccCodecError::EncodedDataNotBytes {
628 dtype: encoded.dtype(),
629 });
630 };
631
632 let AnyArray::F32(decoded) = decoded else {
633 return Err(EbccCodecError::UnsupportedDtype(decoded.dtype()));
634 };
635
636 let original_size = data.len() * std::mem::size_of::<f32>();
638 #[allow(clippy::cast_precision_loss)]
639 let compression_ratio = original_size as f64 / encoded.len() as f64;
640
641 assert!(
642 compression_ratio > 5.0,
643 "Compression ratio {compression_ratio} should be at least 5:1",
644 );
645
646 let max_error = data
648 .iter()
649 .zip(decoded.iter())
650 .map(|(&orig, &decomp)| (orig - decomp).abs())
651 .fold(0.0f32, f32::max);
652
653 assert!(
654 max_error <= (codec_error + 1e-6),
655 "Max error {max_error} exceeds error bound {codec_error}",
656 );
657
658 Ok(())
659 }
660}