Implement optional JPEG2000 codec adapter (#40)
Some checks failed
JPEG 2000 feature / linux (push) Has been cancelled
JPEG 2000 feature / macos (push) Has been cancelled
JPEG 2000 feature / windows (push) Has been cancelled

This commit is contained in:
2026-08-09 03:42:18 +00:00
parent 5d1573fc82
commit 16501f2331
24 changed files with 2776 additions and 53 deletions

View File

@@ -7,8 +7,13 @@ license.workspace = true
repository.workspace = true
description = "Texture codec abstractions for the MetaCrate LibreMetaverse rewrite"
[features]
default = []
jpeg2000 = ["dep:libremetaverse-openjpeg"]
[dependencies]
libremetaverse-types = { path = "../libremetaverse-types" }
libremetaverse-openjpeg = { path = "../libremetaverse-openjpeg", optional = true }
[lints]
workspace = true

View File

@@ -0,0 +1,298 @@
//! Project-owned boundaries for JPEG 2000 codec types exposed by the C# API.
use crate::{DEFAULT_MAX_PIXELS, Error, ManagedImage};
use std::any::Any;
use std::fmt::Debug;
use std::marker::PhantomData;
/// A type-erased decoded image returned by an image creator.
pub trait IImage: Any + Debug + Send + Sync {
/// Returns the concrete image wrapper for checked downcasting.
fn as_any(&self) -> &dyn Any;
/// Returns the already-created managed image when this is a compatible
/// decode target.
fn as_managed_image(&self) -> Option<&ManagedImage> {
None
}
}
/// Marker corresponding to `CoreJ2K`'s image-creator boundary.
pub trait IImageCreator: Send + Sync {}
/// Typed marker corresponding to `CoreJ2K`'s generic image creator.
#[derive(Clone, Copy, Debug, Default)]
pub struct ImageCreator<T>(pub PhantomData<T>);
/// One decoded JPEG 2000 component at its original integer precision.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InterleavedComponent {
precision: u8,
signed: bool,
alpha: bool,
samples: Vec<i32>,
}
impl InterleavedComponent {
/// Creates a checked component without reducing its sample precision.
///
/// # Errors
///
/// Returns [`Error::Argument`] for precision outside 1 through 31 bits or
/// for samples outside the declared signed or unsigned range.
pub fn new(precision: u8, signed: bool, alpha: bool, samples: Vec<i32>) -> Result<Self, Error> {
if !(1..=31).contains(&precision) {
return Err(Error::Argument);
}
let (minimum, maximum) = component_range(precision, signed);
if samples
.iter()
.any(|sample| i64::from(*sample) < minimum || i64::from(*sample) > maximum)
{
return Err(Error::Argument);
}
Ok(Self {
precision,
signed,
alpha,
samples,
})
}
/// Declared component precision in bits.
#[must_use]
pub const fn precision(&self) -> u8 {
self.precision
}
/// Whether component samples use signed representation.
#[must_use]
pub const fn is_signed(&self) -> bool {
self.signed
}
/// Whether the container marks this component as alpha.
#[must_use]
pub const fn is_alpha(&self) -> bool {
self.alpha
}
/// Original integer samples, in top-left row-major order.
#[must_use]
pub fn samples(&self) -> &[i32] {
&self.samples
}
}
/// A bounded decoded image retaining component precision and order.
///
/// The historical external type calls this representation interleaved, while
/// its public conversion API is component-oriented. Keeping the components in
/// separate owned planes avoids a second full-image allocation and retains the
/// original JPEG 2000 integer samples until byte conversion is requested.
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InterleavedImage {
width: i32,
height: i32,
components: Vec<InterleavedComponent>,
}
impl InterleavedImage {
/// Creates a checked decoded component image.
///
/// # Errors
///
/// Returns [`Error::Argument`] for invalid/over-limit dimensions, zero or
/// more than five components, or a plane whose size differs from
/// `width * height`.
pub fn new(
width: i32,
height: i32,
components: Vec<InterleavedComponent>,
) -> Result<Self, Error> {
let pixels = checked_pixels(width, height)?;
if components.is_empty()
|| components.len() > 5
|| components
.iter()
.any(|component| component.samples.len() != pixels)
{
return Err(Error::Argument);
}
pixels
.checked_mul(components.len())
.filter(|samples| *samples <= DEFAULT_MAX_PIXELS * 5)
.ok_or(Error::Argument)?;
Ok(Self {
width,
height,
components,
})
}
/// Decoded width after any discard-level reduction.
#[must_use]
pub const fn width(&self) -> i32 {
self.width
}
/// Decoded height after any discard-level reduction.
#[must_use]
pub const fn height(&self) -> i32 {
self.height
}
/// Number of decoded components in codestream order.
#[must_use]
pub fn number_of_components(&self) -> usize {
self.components.len()
}
/// Component metadata and original samples.
#[must_use]
pub fn components(&self) -> &[InterleavedComponent] {
&self.components
}
/// Scales one component into an 8-bit plane using CoreJ2K-compatible full
/// range conversion.
///
/// # Errors
///
/// Returns [`Error::IndexOutOfRange`] for an invalid component index and
/// [`Error::Argument`] when `destination` is not exactly one image plane.
pub fn to_component_bytes(
&self,
component_index: usize,
destination: &mut [u8],
) -> Result<(), Error> {
let component = self
.components
.get(component_index)
.ok_or(Error::IndexOutOfRange)?;
if destination.len() != component.samples.len() {
return Err(Error::Argument);
}
for (destination, sample) in destination.iter_mut().zip(&component.samples) {
*destination = scale_sample(*sample, component.precision, component.signed);
}
Ok(())
}
}
/// Encode-side sample source corresponding to `CoreJ2K`'s block image source.
pub trait BlkImgDataSrc: Debug + Send + Sync {
/// Image width.
fn width(&self) -> i32;
/// Image height.
fn height(&self) -> i32;
/// Number of presented components.
fn number_of_components(&self) -> usize;
/// Nominal range bits for one component.
///
/// # Errors
///
/// Returns [`Error::IndexOutOfRange`] for an invalid component index.
fn nominal_range_bits(&self, component_index: usize) -> Result<u8, Error>;
/// Fixed-point fractional bits for one component.
///
/// # Errors
///
/// Returns [`Error::IndexOutOfRange`] for an invalid component index.
fn fixed_point(&self, component_index: usize) -> Result<u8, Error>;
/// Whether one presented component is signed at the source.
///
/// # Errors
///
/// Returns [`Error::IndexOutOfRange`] for an invalid component index.
fn is_original_signed(&self, component_index: usize) -> Result<bool, Error>;
/// Returns a checked top-left row-major rectangle with the source DC offset
/// applied, exactly as `CoreJ2K` expects.
///
/// # Errors
///
/// Returns a typed index or argument error for an invalid component or
/// rectangle.
fn component_block(
&self,
component_index: usize,
x: i32,
y: i32,
width: i32,
height: i32,
) -> Result<Vec<i32>, Error>;
}
fn checked_pixels(width: i32, height: i32) -> Result<usize, Error> {
let width = usize::try_from(width).map_err(|_| Error::Argument)?;
let height = usize::try_from(height).map_err(|_| Error::Argument)?;
if width == 0 || height == 0 {
return Err(Error::Argument);
}
width
.checked_mul(height)
.filter(|pixels| *pixels <= DEFAULT_MAX_PIXELS)
.ok_or(Error::Argument)
}
fn component_range(precision: u8, signed: bool) -> (i64, i64) {
if signed {
let magnitude = 1_i64 << (precision - 1);
(-magnitude, magnitude - 1)
} else {
(0, (1_i64 << precision) - 1)
}
}
fn scale_sample(sample: i32, precision: u8, signed: bool) -> u8 {
if signed {
let old_max = 1_i64 << (precision - 1);
let scaled = (i64::from(sample) * 128) / old_max + 128;
u8::try_from(scaled.clamp(0, 255)).unwrap_or_default()
} else {
let old_max = (1_u64 << precision) - 1;
let scaled = (u64::try_from(sample).unwrap_or_default() * 255) / old_max;
u8::try_from(scaled).unwrap_or(u8::MAX)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn component_conversion_preserves_precision_and_signed_range() {
let unsigned = InterleavedComponent::new(16, false, false, vec![0, 32_768, 65_535])
.expect("16-bit component");
let signed = InterleavedComponent::new(8, true, false, vec![-128, 0, 127])
.expect("signed component");
let unsigned = InterleavedImage::new(3, 1, vec![unsigned]).expect("unsigned image");
let signed = InterleavedImage::new(3, 1, vec![signed]).expect("signed image");
let mut bytes = [0; 3];
unsigned
.to_component_bytes(0, &mut bytes)
.expect("unsigned bytes");
assert_eq!(bytes, [0, 127, 255]);
signed
.to_component_bytes(0, &mut bytes)
.expect("signed bytes");
assert_eq!(bytes, [0, 128, 255]);
}
#[test]
fn interleaved_image_rejects_bad_layouts_and_ranges() {
assert_eq!(
InterleavedComponent::new(0, false, false, vec![]),
Err(Error::Argument)
);
assert_eq!(
InterleavedComponent::new(8, false, false, vec![256]),
Err(Error::Argument)
);
let component = InterleavedComponent::new(8, false, false, vec![0]).unwrap();
assert_eq!(
InterleavedImage::new(2, 1, vec![component]),
Err(Error::Argument)
);
}
}

View File

@@ -0,0 +1,707 @@
//! Optional JPEG 2000 codec backed by a bounded system `OpenJPEG` adapter.
use crate::codec::{InterleavedComponent, InterleavedImage};
use crate::{
DEFAULT_MAX_ENCODED_BYTES, DEFAULT_MAX_PIXELS, Error, ITextureCodec, ManagedImage,
ManagedImageImageChannels,
};
use libremetaverse_openjpeg as openjpeg;
use libremetaverse_types::compat::ReadWrite;
use std::io::Read;
/// JPEG 2000 container selection.
#[derive(Clone, Copy, Debug, Default, Eq, Hash, PartialEq)]
pub enum J2kFormat {
/// Raw JPEG 2000 codestream used by Second Life texture assets.
#[default]
Codestream,
/// JP2 file-format container.
Jp2,
}
/// JPEG 2000 wavelet and rate-control mode.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub enum J2kCompression {
/// Reversible 5/3 wavelet with exact sample reconstruction.
#[default]
Lossless,
/// Irreversible 9/7 wavelet targeting the given compression ratio.
Lossy {
/// Uncompressed bytes divided by target codestream bytes. Must be at
/// least 1.0 and finite.
compression_ratio: f32,
},
}
/// Bounded decode configuration.
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub struct J2kDecodeOptions {
discard_levels: u32,
quality_layers: u32,
strict: bool,
max_encoded_bytes: usize,
max_pixels: usize,
}
impl Default for J2kDecodeOptions {
fn default() -> Self {
Self {
discard_levels: 0,
quality_layers: 0,
strict: true,
max_encoded_bytes: DEFAULT_MAX_ENCODED_BYTES,
max_pixels: DEFAULT_MAX_PIXELS,
}
}
}
impl J2kDecodeOptions {
/// Sets the number of highest-resolution levels to discard.
#[must_use]
pub const fn with_discard_levels(mut self, discard_levels: u32) -> Self {
self.discard_levels = discard_levels;
self
}
/// Limits decoding to the first `quality_layers` progressive layers. Zero
/// decodes every available layer.
#[must_use]
pub const fn with_quality_layers(mut self, quality_layers: u32) -> Self {
self.quality_layers = quality_layers;
self
}
/// Selects whether truncated codestreams are rejected.
#[must_use]
pub const fn with_strict_mode(mut self, strict: bool) -> Self {
self.strict = strict;
self
}
/// Replaces the encoded-byte and decoded-pixel limits.
///
/// Zero limits are invalid and cause decode to return [`Error::Argument`].
#[must_use]
pub const fn with_limits(mut self, max_encoded_bytes: usize, max_pixels: usize) -> Self {
self.max_encoded_bytes = max_encoded_bytes;
self.max_pixels = max_pixels;
self
}
/// Configured discard level.
#[must_use]
pub const fn discard_levels(self) -> u32 {
self.discard_levels
}
/// Configured quality-layer limit.
#[must_use]
pub const fn quality_layers(self) -> u32 {
self.quality_layers
}
}
/// Encode configuration.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct J2kEncodeOptions {
format: J2kFormat,
compression: J2kCompression,
max_encoded_bytes: usize,
}
impl Default for J2kEncodeOptions {
fn default() -> Self {
Self {
format: J2kFormat::Codestream,
compression: J2kCompression::Lossless,
max_encoded_bytes: DEFAULT_MAX_ENCODED_BYTES,
}
}
}
impl J2kEncodeOptions {
/// Selects raw codestream or JP2 output.
#[must_use]
pub const fn with_format(mut self, format: J2kFormat) -> Self {
self.format = format;
self
}
/// Selects reversible lossless or irreversible lossy compression.
#[must_use]
pub const fn with_compression(mut self, compression: J2kCompression) -> Self {
self.compression = compression;
self
}
/// Caps the produced codestream size.
#[must_use]
pub const fn with_max_encoded_bytes(mut self, max_encoded_bytes: usize) -> Self {
self.max_encoded_bytes = max_encoded_bytes;
self
}
}
/// Cross-platform JPEG 2000 adapter.
///
/// The optional adapter links to BSD-2-Clause `OpenJPEG` 2.5.4 or newer. Linux
/// and macOS builds discover it with `pkg-config`; Windows MSVC builds use
/// vcpkg. Checked-in minimal bindings and all unsafe FFI are isolated in the private
/// adapter crate and no native codec type crosses this boundary.
#[derive(Clone, Copy, Debug, Default)]
pub struct J2kCodec {
decode_options: J2kDecodeOptions,
}
impl J2kCodec {
/// Creates a codec with explicit bounded decode options.
#[must_use]
pub const fn new(decode_options: J2kDecodeOptions) -> Self {
Self { decode_options }
}
/// Decodes a raw J2K codestream or JP2 container while retaining component
/// precision, signedness, alpha metadata, and order.
///
/// The encoded-byte limit is checked before buffering to a codec stream.
/// `OpenJPEG` header parsing does not allocate sample planes; dimensions and
/// component counts are validated before `decode` may allocate them.
///
/// # Errors
///
/// Returns a typed argument or parse failure for invalid limits, oversized
/// input/dimensions, unsupported component layouts, or malformed data.
pub fn decode_interleaved(
encoded: &[u8],
options: J2kDecodeOptions,
) -> Result<InterleavedImage, Error> {
if options.max_encoded_bytes == 0
|| options.max_pixels == 0
|| encoded.is_empty()
|| encoded.len() > options.max_encoded_bytes
{
return Err(Error::Argument);
}
let format = detect_format(encoded)?;
let decoded = openjpeg::decode(
encoded,
backend_format(format),
openjpeg::DecodeOptions {
discard_levels: options.discard_levels,
quality_layers: options.quality_layers,
strict: options.strict,
max_pixels: options.max_pixels.min(DEFAULT_MAX_PIXELS),
},
)
.map_err(map_decode_error)?;
backend_image_to_interleaved(decoded, options.max_pixels)
}
/// Decodes into the C#-compatible planar byte representation.
///
/// # Errors
///
/// Returns the failures documented by [`Self::decode_interleaved`] or a
/// typed error for a component conversion/allocation failure.
pub fn decode_bytes(encoded: &[u8], options: J2kDecodeOptions) -> Result<ManagedImage, Error> {
interleaved_to_managed(&Self::decode_interleaved(encoded, options)?)
}
/// Encodes the four-component compatibility view used by `CoreJ2K`.
///
/// Color images preserve RGB and optional alpha. Alpha-only images repeat
/// alpha into RGB and encode an opaque alpha plane. Images without alpha
/// receive opaque alpha. Bump is not a JPEG 2000 output component, matching
/// the reference adapter.
///
/// # Errors
///
/// Returns a typed validation/operation failure for invalid image layouts,
/// lossy settings, allocation/codec errors, or oversized output.
pub fn encode(image: &ManagedImage, options: J2kEncodeOptions) -> Result<Vec<u8>, Error> {
image.validate()?;
if options.max_encoded_bytes == 0 {
return Err(Error::Argument);
}
if let J2kCompression::Lossy { compression_ratio } = options.compression {
if !compression_ratio.is_finite() || compression_ratio < 1.0 {
return Err(Error::Argument);
}
}
encode_with_openjpeg(image, options)
}
}
impl ITextureCodec for J2kCodec {
fn decode(&self, mut stream: Box<dyn ReadWrite + Send>) -> Result<ManagedImage, Error> {
if self.decode_options.max_encoded_bytes == 0 {
return Err(Error::Argument);
}
let limit = self
.decode_options
.max_encoded_bytes
.checked_add(1)
.ok_or(Error::Argument)?;
let mut encoded = Vec::new();
Read::by_ref(&mut stream)
.take(u64::try_from(limit).map_err(|_| Error::Argument)?)
.read_to_end(&mut encoded)
.map_err(|_| parse("JPEG 2000 input stream"))?;
if encoded.len() > self.decode_options.max_encoded_bytes {
return Err(Error::Argument);
}
Self::decode_bytes(&encoded, self.decode_options)
}
}
fn backend_image_to_interleaved(
image: openjpeg::Image,
max_pixels: usize,
) -> Result<InterleavedImage, Error> {
let (width, height) = checked_dimensions(image.width, image.height, max_pixels)?;
let mut decoded = Vec::new();
decoded
.try_reserve_exact(image.components.len())
.map_err(|_| Error::InvalidOperation)?;
for component in image.components {
if component.width != image.width || component.height != image.height {
return Err(parse("subsampled JPEG 2000 components"));
}
decoded.push(InterleavedComponent::new(
component.precision,
component.signed,
component.alpha,
component.samples,
)?);
}
InterleavedImage::new(width, height, decoded)
}
fn interleaved_to_managed(image: &InterleavedImage) -> Result<ManagedImage, Error> {
let channels = channels_for_components(image.number_of_components())?;
let pixels = usize::try_from(image.width())
.ok()
.and_then(|width| {
usize::try_from(image.height())
.ok()
.and_then(|height| width.checked_mul(height))
})
.ok_or(Error::Argument)?;
let mut bytes = Vec::new();
let length = pixels
.checked_mul(image.number_of_components())
.ok_or(Error::Argument)?;
bytes
.try_reserve_exact(length)
.map_err(|_| Error::InvalidOperation)?;
bytes.resize(length, 0);
let mut plane = vec![0; pixels];
for component in 0..image.number_of_components() {
image.to_component_bytes(component, &mut plane)?;
for (pixel, sample) in plane.iter().enumerate() {
bytes[pixel * image.number_of_components() + component] = *sample;
}
}
managed_from_reference_interleaved(image.width(), image.height(), channels, &bytes)
}
fn encode_with_openjpeg(image: &ManagedImage, options: J2kEncodeOptions) -> Result<Vec<u8>, Error> {
let width = u32::try_from(image.width).map_err(|_| Error::Argument)?;
let height = u32::try_from(image.height).map_err(|_| Error::Argument)?;
let planes = reference_encode_planes(image)?;
let components = [
openjpeg::ComponentRef {
precision: 8,
signed: false,
alpha: false,
samples: &planes[0],
},
openjpeg::ComponentRef {
precision: 8,
signed: false,
alpha: false,
samples: &planes[1],
},
openjpeg::ComponentRef {
precision: 8,
signed: false,
alpha: false,
samples: &planes[2],
},
openjpeg::ComponentRef {
precision: 8,
signed: false,
alpha: true,
samples: &planes[3],
},
];
let compression = match options.compression {
J2kCompression::Lossless => openjpeg::Compression::Lossless,
J2kCompression::Lossy { compression_ratio } => {
openjpeg::Compression::Lossy { compression_ratio }
}
};
openjpeg::encode(
width,
height,
&components,
backend_format(options.format),
compression,
options.max_encoded_bytes,
)
.map_err(map_encode_error)
}
fn reference_encode_planes(image: &ManagedImage) -> Result<[Vec<i32>; 4], Error> {
let pixels = usize::try_from(image.width)
.ok()
.and_then(|width| {
usize::try_from(image.height)
.ok()
.and_then(|height| width.checked_mul(height))
})
.ok_or(Error::Argument)?;
let mut planes = [Vec::new(), Vec::new(), Vec::new(), Vec::new()];
for plane in &mut planes {
plane
.try_reserve_exact(pixels)
.map_err(|_| Error::InvalidOperation)?;
plane.resize(pixels, 0);
}
let has_color = image.channels.contains(ManagedImageImageChannels::COLOR);
let has_alpha = image.channels.contains(ManagedImageImageChannels::ALPHA);
if has_alpha && !has_color {
for (pixel, sample) in image.alpha.iter().copied().enumerate() {
let sample = i32::from(sample);
planes[0][pixel] = sample;
planes[1][pixel] = sample;
planes[2][pixel] = sample;
planes[3][pixel] = 255;
}
return Ok(planes);
}
if !has_color {
return Err(Error::InvalidOperation);
}
for (pixel, red) in image.red.iter().copied().enumerate() {
planes[0][pixel] = i32::from(red);
planes[1][pixel] = i32::from(image.green[pixel]);
planes[2][pixel] = i32::from(image.blue[pixel]);
planes[3][pixel] = if has_alpha {
i32::from(image.alpha[pixel])
} else {
255
};
}
Ok(planes)
}
fn managed_from_reference_interleaved(
width: i32,
height: i32,
channels: ManagedImageImageChannels,
bytes: &[u8],
) -> Result<ManagedImage, Error> {
let components = component_count(channels);
let pixels = usize::try_from(width)
.ok()
.and_then(|width| {
usize::try_from(height)
.ok()
.and_then(|height| width.checked_mul(height))
})
.ok_or(Error::Argument)?;
if bytes.len() != pixels.checked_mul(components).ok_or(Error::Argument)? {
return Err(Error::Argument);
}
let mut image = ManagedImage::new(width, height, channels)?;
for pixel in 0..pixels {
let source = pixel * components;
match components {
1 => image.red[pixel] = bytes[source],
2 => {
image.red[pixel] = bytes[source];
image.alpha[pixel] = bytes[source + 1];
}
3 => {
image.red[pixel] = bytes[source];
image.green[pixel] = bytes[source + 1];
image.blue[pixel] = bytes[source + 2];
}
4 => {
image.red[pixel] = bytes[source];
image.green[pixel] = bytes[source + 1];
image.blue[pixel] = bytes[source + 2];
image.alpha[pixel] = bytes[source + 3];
}
5 => {
image.red[pixel] = bytes[source];
image.green[pixel] = bytes[source + 1];
image.blue[pixel] = bytes[source + 2];
image.bump[pixel] = bytes[source + 3];
image.alpha[pixel] = bytes[source + 4];
}
_ => return Err(Error::Argument),
}
}
Ok(image)
}
fn channels_for_components(components: usize) -> Result<ManagedImageImageChannels, Error> {
Ok(match components {
1 => ManagedImageImageChannels::GRAY,
2 => ManagedImageImageChannels::GRAY | ManagedImageImageChannels::ALPHA,
3 => ManagedImageImageChannels::COLOR,
4 => ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA,
5 => {
ManagedImageImageChannels::COLOR
| ManagedImageImageChannels::BUMP
| ManagedImageImageChannels::ALPHA
}
_ => return Err(Error::Argument),
})
}
fn component_count(channels: ManagedImageImageChannels) -> usize {
if channels.contains(ManagedImageImageChannels::BUMP) {
5
} else if channels.contains(ManagedImageImageChannels::COLOR) {
3 + usize::from(channels.contains(ManagedImageImageChannels::ALPHA))
} else if channels.contains(ManagedImageImageChannels::GRAY) {
1 + usize::from(channels.contains(ManagedImageImageChannels::ALPHA))
} else {
usize::from(channels.contains(ManagedImageImageChannels::ALPHA))
}
}
fn detect_format(encoded: &[u8]) -> Result<J2kFormat, Error> {
const JP2_MAGIC: &[u8] = &[
0x00, 0x00, 0x00, 0x0c, b'j', b'P', b' ', b' ', 0x0d, 0x0a, 0x87, 0x0a,
];
const J2K_MAGIC: &[u8] = &[0xff, 0x4f, 0xff, 0x51];
if encoded.starts_with(JP2_MAGIC) {
Ok(J2kFormat::Jp2)
} else if encoded.starts_with(J2K_MAGIC) {
Ok(J2kFormat::Codestream)
} else {
Err(parse("JPEG 2000 magic"))
}
}
const fn backend_format(format: J2kFormat) -> openjpeg::Format {
match format {
J2kFormat::Codestream => openjpeg::Format::J2k,
J2kFormat::Jp2 => openjpeg::Format::Jp2,
}
}
const fn map_decode_error(error: openjpeg::Error) -> Error {
match error {
openjpeg::Error::LimitExceeded => Error::Argument,
openjpeg::Error::Allocation => Error::InvalidOperation,
openjpeg::Error::InvalidInput | openjpeg::Error::Codec => parse("JPEG 2000 codestream"),
}
}
const fn map_encode_error(error: openjpeg::Error) -> Error {
match error {
openjpeg::Error::InvalidInput | openjpeg::Error::LimitExceeded => Error::Argument,
openjpeg::Error::Allocation | openjpeg::Error::Codec => Error::InvalidOperation,
}
}
fn checked_dimensions(width: u32, height: u32, max_pixels: usize) -> Result<(i32, i32), Error> {
let width_usize = usize::try_from(width).map_err(|_| Error::Argument)?;
let height_usize = usize::try_from(height).map_err(|_| Error::Argument)?;
let pixels = width_usize
.checked_mul(height_usize)
.ok_or(Error::Argument)?;
if width == 0 || height == 0 || pixels > max_pixels || pixels > DEFAULT_MAX_PIXELS {
return Err(Error::Argument);
}
Ok((
i32::try_from(width).map_err(|_| Error::Argument)?,
i32::try_from(height).map_err(|_| Error::Argument)?,
))
}
const fn parse(context: &'static str) -> Error {
Error::Parse {
position: 0,
context,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Cursor;
fn rgba(width: i32, height: i32) -> ManagedImage {
let mut image = ManagedImage::new(
width,
height,
ManagedImageImageChannels::COLOR | ManagedImageImageChannels::ALPHA,
)
.unwrap();
for pixel in 0..image.red.len() {
image.red[pixel] = u8::try_from((pixel * 17) & 255).unwrap();
image.green[pixel] = u8::try_from((pixel * 29 + 3) & 255).unwrap();
image.blue[pixel] = u8::try_from((pixel * 43 + 7) & 255).unwrap();
image.alpha[pixel] = u8::try_from((pixel * 11 + 101) & 255).unwrap();
}
image
}
#[test]
fn lossless_codestream_and_jp2_round_trip_all_channels() {
let source = rgba(17, 9);
for format in [J2kFormat::Codestream, J2kFormat::Jp2] {
let encoded =
J2kCodec::encode(&source, J2kEncodeOptions::default().with_format(format))
.expect("encode");
let decoded =
J2kCodec::decode_bytes(&encoded, J2kDecodeOptions::default()).expect("decode");
assert_eq!(decoded, source);
}
}
#[test]
fn alpha_only_substitution_matches_managed_image_creator() {
let mut source = ManagedImage::new(2, 1, ManagedImageImageChannels::ALPHA).unwrap();
source.alpha.copy_from_slice(&[17, 231]);
let encoded = J2kCodec::encode(&source, J2kEncodeOptions::default()).unwrap();
let decoded = J2kCodec::decode_bytes(&encoded, J2kDecodeOptions::default()).unwrap();
assert_eq!(decoded.red, [17, 231]);
assert_eq!(decoded.green, [17, 231]);
assert_eq!(decoded.blue, [17, 231]);
assert_eq!(decoded.alpha, [255, 255]);
}
#[test]
fn discard_levels_reduce_dimensions_and_stream_boundary_is_bounded() {
let source = rgba(64, 32);
let encoded = J2kCodec::encode(&source, J2kEncodeOptions::default()).unwrap();
let reduced =
J2kCodec::decode_bytes(&encoded, J2kDecodeOptions::default().with_discard_levels(1))
.unwrap();
assert_eq!((reduced.width, reduced.height), (32, 16));
let codec = J2kCodec::new(J2kDecodeOptions::default().with_limits(8, 64));
assert_eq!(
codec.decode(Box::new(Cursor::new(encoded))),
Err(Error::Argument)
);
let encoded = J2kCodec::encode(&source, J2kEncodeOptions::default()).unwrap();
assert_eq!(
J2kCodec::decode_bytes(
&encoded,
J2kDecodeOptions::default().with_limits(encoded.len(), 128),
),
Err(Error::Argument)
);
}
#[test]
fn invalid_data_and_lossy_settings_fail_without_panicking() {
assert!(matches!(
J2kCodec::decode_bytes(b"not jpeg2000", J2kDecodeOptions::default()),
Err(Error::Parse { .. })
));
let source = rgba(2, 2);
assert_eq!(
J2kCodec::encode(
&source,
J2kEncodeOptions::default().with_compression(J2kCompression::Lossy {
compression_ratio: f32::NAN,
}),
),
Err(Error::Argument)
);
assert!(matches!(
J2kCodec::decode_bytes(
b"\0\0\0\x0cjP \r\n\x87\n\0\0\0",
J2kDecodeOptions::default(),
),
Err(Error::Parse { .. })
));
assert_eq!(
J2kCodec::encode(
&source,
J2kEncodeOptions::default().with_max_encoded_bytes(32),
),
Err(Error::Argument)
);
}
#[test]
fn lossy_mode_preserves_layout_with_bounded_sample_error() {
let source = rgba(64, 64);
let encoded = J2kCodec::encode(
&source,
J2kEncodeOptions::default()
.with_format(J2kFormat::Jp2)
.with_compression(J2kCompression::Lossy {
compression_ratio: 8.0,
}),
)
.expect("lossy encode");
let decoded =
J2kCodec::decode_bytes(&encoded, J2kDecodeOptions::default().with_quality_layers(1))
.expect("lossy decode");
assert_eq!(
(decoded.width, decoded.height, decoded.channels),
(source.width, source.height, source.channels)
);
let total_error: u64 = source
.red
.iter()
.chain(&source.green)
.chain(&source.blue)
.chain(&source.alpha)
.zip(
decoded
.red
.iter()
.chain(&decoded.green)
.chain(&decoded.blue)
.chain(&decoded.alpha),
)
.map(|(expected, actual)| u64::from(expected.abs_diff(*actual)))
.sum();
let samples = u64::try_from(source.red.len() * 4).unwrap();
assert!(
total_error > 0,
"lossy mode unexpectedly reconstructed exactly"
);
assert!(total_error / samples < 32, "mean sample error is too high");
}
#[test]
fn openjpeg_2_5_4_golden_retains_sixteen_bit_samples() {
// Generated by OpenJPEG 2.5.4 `opj_compress` from the deterministic
// 64x64 unsigned 16-bit gradient asserted below.
const GOLDEN_HEX: &str = "ff4fff5100290000000000400000004000000000000000000000004000000040000000000000000000010f0101ff52000c00000001000504040001ff5c00134080888890888890888890888890888890ff640025000143726561746564206279204f70656e4a5045472076657273696f6e20322e352e34ff90000a00000000011c0001ff93dff890500c58e2753ee5f7dd26b3c7fe1811000fa8120007ce080d020629b05f7dbf0c199c5f0c44c3ff0306c001f3858000f90180221a085d6dcb5f4686076b4a7f00d09a4b7f01627fc1ff4005e000f9c440007c80c036a199ae63a3628986621949a89ff8bdd889db3a01813f439e44760735fc3f3e871dc0ff0278001f20f80007c2385fa7c788a00db0790644473e98d48fd92b5faff3c71e10932fde0e8f81be8b5bafccacfdd7cf9f9eab0ab177092bc3e079cc1037c0679d9aecfa3be703c07f2670001f09700007438b6201aba8dfc3791d615480235541001a680dce59014e5d9a88d45e9373f9519239613060c182ef7f7816874a3e947ceeaf7fe19cdb5b3899ff0636ff7fe18c4fffd9";
let encoded = GOLDEN_HEX
.as_bytes()
.chunks_exact(2)
.map(|pair| {
let pair = std::str::from_utf8(pair).unwrap();
u8::from_str_radix(pair, 16).unwrap()
})
.collect::<Vec<_>>();
let decoded = J2kCodec::decode_interleaved(&encoded, J2kDecodeOptions::default())
.expect("decode OpenJPEG golden");
assert_eq!((decoded.width(), decoded.height()), (64, 64));
assert_eq!(decoded.number_of_components(), 1);
let component = &decoded.components()[0];
assert_eq!((component.precision(), component.is_signed()), (16, false));
let expected = (0..64)
.flat_map(|y| (0..64).map(move |x| (x * 65_535 / 64) ^ ((y * 3) & 0xffff)))
.collect::<Vec<_>>();
assert_eq!(component.samples(), expected);
}
}

View File

@@ -2,17 +2,16 @@
extern crate self as libremetaverse_imaging;
pub mod codec {
pub trait IImage {}
pub trait IImageCreator {}
pub struct ImageCreator<T>(pub std::marker::PhantomData<T>);
pub struct InterleavedImage;
pub trait BlkImgDataSrc {}
}
pub mod codec;
#[cfg(feature = "jpeg2000")]
mod jpeg2000;
mod generated;
mod managed_image;
pub use generated::*;
#[cfg(feature = "jpeg2000")]
pub use jpeg2000::{J2kCodec, J2kCompression, J2kDecodeOptions, J2kEncodeOptions, J2kFormat};
pub use libremetaverse_types::Error;
pub use managed_image::{DEFAULT_MAX_ENCODED_BYTES, DEFAULT_MAX_PIXELS};