#![forbid(unsafe_code)]
#![no_std]
extern crate alloc;
use alloc::vec;
macro_rules! dev_modules {
($($mod:ident),* $(,)?) => {
$(
#[cfg(feature = "_dev")]
pub mod $mod;
#[cfg(not(feature = "_dev"))]
pub(crate) mod $mod;
)*
};
}
pub(crate) mod blue_noise;
dev_modules!(
dither, histogram, masking, median_cut, metric, oklab, palette, remap
);
pub mod error;
#[cfg(feature = "joint")]
pub(crate) mod joint;
#[cfg(feature = "joint")]
mod joint_predict;
pub(crate) mod simd;
pub use error::QuantizeError;
pub use imgref::{Img, ImgRef, ImgVec};
pub use rgb::{RGB, RGBA};
#[doc(hidden)]
pub mod _internals {
pub use crate::dither::DitherMode;
pub use crate::masking::compute_masking_weights;
pub use crate::metric::{MpeResult, compute_mpe, compute_mpe_rgba};
pub use crate::oklab::srgb_to_oklab;
pub use crate::palette::index_delta_score;
pub use crate::remap::{RunPriority, average_run_length};
}
#[cfg(feature = "_dev")]
#[doc(hidden)]
pub mod _dev {
pub use crate::{dither, histogram, masking, median_cut, metric, oklab, palette, remap};
}
use alloc::vec::Vec;
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum Quality {
Fast,
Balanced,
#[default]
Best,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
#[non_exhaustive]
pub enum OutputFormat {
Gif,
Png,
PngJoint,
PngMinSize,
WebpLossless,
}
#[derive(Debug, Clone)]
pub(crate) struct QuantizeTuning {
pub(crate) dither_strength: f32,
pub(crate) sort_strategy: palette::PaletteSortStrategy,
pub(crate) gif_frequency_reorder: bool,
pub(crate) alpha_mode: AlphaMode,
pub(crate) viterbi_lambda_scale: f32,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum AlphaMode {
Binary,
Full,
}
impl QuantizeTuning {
pub(crate) fn from_config(config: &QuantizeConfig) -> Self {
let (default_dither, sort, gif_reorder, alpha, lambda_scale) = match config.output_format {
OutputFormat::Gif => (
0.35,
palette::PaletteSortStrategy::DeltaMinimize,
true,
AlphaMode::Binary,
3.0, ),
OutputFormat::Png => (
0.7,
palette::PaletteSortStrategy::Luminance,
false,
AlphaMode::Full,
1.0,
),
OutputFormat::PngJoint => (
0.3, palette::PaletteSortStrategy::Luminance,
false,
AlphaMode::Full,
1.0,
),
OutputFormat::PngMinSize => (
0.1, palette::PaletteSortStrategy::Luminance,
false,
AlphaMode::Full,
2.5, ),
OutputFormat::WebpLossless => (
0.4,
palette::PaletteSortStrategy::DeltaMinimize,
false,
AlphaMode::Full,
2.0, ),
};
Self {
dither_strength: config.dither_strength.unwrap_or(default_dither),
sort_strategy: sort,
gif_frequency_reorder: gif_reorder,
alpha_mode: alpha,
viterbi_lambda_scale: lambda_scale,
}
}
}
#[derive(Debug, Clone)]
pub struct QuantizeConfig {
max_colors: u32,
quality: Quality,
output_format: OutputFormat,
run_priority: remap::RunPriority,
dither_mode: dither::DitherMode,
dither_strength: Option<f32>,
viterbi_lambda: Option<f32>,
compute_metric: bool,
target_ssim2: Option<f32>,
min_ssim2: Option<f32>,
joint_deflate_effort: u32,
joint_tolerance: f32,
kmeans_sample_cap: usize,
}
impl QuantizeConfig {
#[must_use]
pub fn new(format: OutputFormat) -> Self {
Self {
max_colors: 256,
quality: Quality::Best,
output_format: format,
run_priority: match format {
OutputFormat::PngMinSize => remap::RunPriority::Compression,
_ => remap::RunPriority::Quality,
},
dither_mode: match format {
OutputFormat::PngMinSize => dither::DitherMode::BlueNoise,
_ => dither::DitherMode::Adaptive,
},
dither_strength: None,
viterbi_lambda: None,
compute_metric: false,
target_ssim2: None,
min_ssim2: None,
joint_deflate_effort: 10,
joint_tolerance: 0.01,
kmeans_sample_cap: 131_072,
}
}
#[must_use]
pub fn with_max_colors(mut self, n: u32) -> Self {
self.max_colors = n;
self
}
#[must_use]
pub fn with_quality(mut self, q: Quality) -> Self {
self.quality = q;
self
}
#[must_use]
pub fn with_compute_quality_metric(mut self, enable: bool) -> Self {
self.compute_metric = enable;
self
}
#[must_use]
pub fn with_target_ssim2(mut self, score: f32) -> Self {
self.target_ssim2 = Some(score);
self
}
#[must_use]
pub fn with_min_ssim2(mut self, score: f32) -> Self {
self.min_ssim2 = Some(score);
self
}
#[must_use]
pub fn with_kmeans_sample_cap(mut self, cap: usize) -> Self {
self.kmeans_sample_cap = cap;
self
}
#[doc(hidden)]
#[must_use]
pub fn _with_no_dither(mut self) -> Self {
self.dither_mode = dither::DitherMode::None;
self
}
#[doc(hidden)]
#[must_use]
pub fn _with_adaptive_dither(mut self) -> Self {
self.dither_mode = dither::DitherMode::Adaptive;
self
}
#[doc(hidden)]
#[must_use]
pub fn _with_run_priority_quality(mut self) -> Self {
self.run_priority = remap::RunPriority::Quality;
self
}
#[doc(hidden)]
#[must_use]
pub fn _with_run_priority_compression(mut self) -> Self {
self.run_priority = remap::RunPriority::Compression;
self
}
#[doc(hidden)]
#[must_use]
pub fn _with_dither_strength(mut self, strength: f32) -> Self {
self.dither_strength = Some(strength);
self
}
#[doc(hidden)]
#[must_use]
pub fn _with_viterbi_lambda(mut self, lambda: f32) -> Self {
self.viterbi_lambda = Some(lambda);
self
}
#[doc(hidden)]
#[must_use]
pub fn _with_blue_noise_dither(mut self) -> Self {
self.dither_mode = dither::DitherMode::BlueNoise;
self
}
#[doc(hidden)]
#[must_use]
pub fn _with_sierra_lite_dither(mut self) -> Self {
self.dither_mode = dither::DitherMode::SierraLite;
self
}
#[doc(hidden)]
#[must_use]
pub fn _with_linear_dither(mut self) -> Self {
self.dither_mode = dither::DitherMode::Linear;
self
}
#[doc(hidden)]
#[must_use]
pub fn _with_ordered_dither(mut self) -> Self {
self.dither_mode = dither::DitherMode::Ordered;
self
}
#[doc(hidden)]
#[must_use]
pub fn _with_joint_deflate_effort(mut self, effort: u32) -> Self {
self.joint_deflate_effort = effort.clamp(1, 22);
self
}
#[doc(hidden)]
#[must_use]
pub fn _with_joint_tolerance(mut self, tolerance: f32) -> Self {
self.joint_tolerance = tolerance;
self
}
}
#[derive(Debug)]
pub struct QuantizeResult {
palette: palette::Palette,
indices: Vec<u8>,
mpe_result: Option<metric::MpeResult>,
}
impl QuantizeResult {
#[must_use]
pub fn palette(&self) -> &[[u8; 3]] {
self.palette.entries()
}
#[must_use]
pub fn indices(&self) -> &[u8] {
&self.indices
}
#[must_use]
pub fn transparent_index(&self) -> Option<u8> {
self.palette.transparent_index()
}
#[must_use]
pub fn palette_len(&self) -> usize {
self.palette.len()
}
#[must_use]
pub fn palette_rgba(&self) -> &[[u8; 4]] {
self.palette.entries_rgba()
}
#[must_use]
pub fn alpha_table(&self) -> Option<Vec<u8>> {
let rgba = self.palette.entries_rgba();
let alphas: Vec<u8> = rgba.iter().map(|e| e[3]).collect();
let last_non_opaque = alphas.iter().rposition(|&a| a != 255);
last_non_opaque.map(|pos| alphas[..=pos].to_vec())
}
#[must_use]
pub fn mpe_result(&self) -> Option<&metric::MpeResult> {
self.mpe_result.as_ref()
}
#[must_use]
pub fn mpe_score(&self) -> Option<f32> {
self.mpe_result.as_ref().map(|r| r.score)
}
#[must_use]
pub fn ssimulacra2_estimate(&self) -> Option<f32> {
self.mpe_result.as_ref().map(|r| r.ssimulacra2_estimate)
}
#[must_use]
pub fn butteraugli_estimate(&self) -> Option<f32> {
self.mpe_result.as_ref().map(|r| r.butteraugli_estimate)
}
pub fn remap(
&self,
pixels: &[rgb::RGB<u8>],
width: usize,
height: usize,
config: &QuantizeConfig,
) -> Result<QuantizeResult, QuantizeError> {
remap_rgb_impl(&self.palette, pixels, width, height, config, None)
}
pub fn remap_rgba(
&self,
pixels: &[rgb::RGBA<u8>],
width: usize,
height: usize,
config: &QuantizeConfig,
) -> Result<QuantizeResult, QuantizeError> {
remap_rgba_impl(&self.palette, pixels, width, height, config, None)
}
#[doc(hidden)]
pub fn remap_with_prev(
&self,
pixels: &[rgb::RGB<u8>],
width: usize,
height: usize,
config: &QuantizeConfig,
prev_indices: &[u8],
) -> Result<QuantizeResult, QuantizeError> {
remap_rgb_impl(
&self.palette,
pixels,
width,
height,
config,
Some(prev_indices),
)
}
#[doc(hidden)]
pub fn remap_rgba_with_prev(
&self,
pixels: &[rgb::RGBA<u8>],
width: usize,
height: usize,
config: &QuantizeConfig,
prev_indices: &[u8],
) -> Result<QuantizeResult, QuantizeError> {
remap_rgba_impl(
&self.palette,
pixels,
width,
height,
config,
Some(prev_indices),
)
}
}
#[derive(Debug, Clone, Copy)]
struct CompressionTier {
quality: Quality,
run_priority: remap::RunPriority,
dither_strength_mult: f32,
}
const COMPRESSION_TIERS: [CompressionTier; 5] = [
CompressionTier {
quality: Quality::Best,
run_priority: remap::RunPriority::Quality,
dither_strength_mult: 1.0,
},
CompressionTier {
quality: Quality::Best,
run_priority: remap::RunPriority::Balanced,
dither_strength_mult: 1.0,
},
CompressionTier {
quality: Quality::Best,
run_priority: remap::RunPriority::Compression,
dither_strength_mult: 1.0,
},
CompressionTier {
quality: Quality::Balanced,
run_priority: remap::RunPriority::Compression,
dither_strength_mult: 0.8,
},
CompressionTier {
quality: Quality::Fast,
run_priority: remap::RunPriority::Compression,
dither_strength_mult: 0.6,
},
];
fn select_compression_tier(target_ssim2: f32) -> &'static CompressionTier {
if target_ssim2 > 90.0 {
&COMPRESSION_TIERS[0] } else if target_ssim2 > 82.0 {
&COMPRESSION_TIERS[1] } else if target_ssim2 > 74.0 {
&COMPRESSION_TIERS[2] } else if target_ssim2 > 60.0 {
&COMPRESSION_TIERS[3] } else {
&COMPRESSION_TIERS[4] }
}
pub fn quantize(
pixels: &[rgb::RGB<u8>],
width: usize,
height: usize,
config: &QuantizeConfig,
) -> Result<QuantizeResult, QuantizeError> {
validate_inputs(pixels.len(), width, height, config)?;
let needs_metric =
config.compute_metric || config.target_ssim2.is_some() || config.min_ssim2.is_some();
let (effective_quality, effective_run_priority, effective_dither_strength) =
if let Some(target) = config.target_ssim2 {
let tier = select_compression_tier(target);
(
tier.quality,
tier.run_priority,
config
.dither_strength
.map(|s| s * tier.dither_strength_mult),
)
} else {
(config.quality, config.run_priority, config.dither_strength)
};
let effective_dither_mode = if effective_quality == Quality::Fast
&& config.dither_mode == dither::DitherMode::Adaptive
{
dither::DitherMode::Ordered
} else {
config.dither_mode
};
let effective_config = QuantizeConfig {
quality: effective_quality,
run_priority: effective_run_priority,
dither_strength: effective_dither_strength,
dither_mode: effective_dither_mode,
compute_metric: needs_metric,
..config.clone()
};
let tuning = QuantizeTuning::from_config(&effective_config);
let max_colors = config.max_colors as usize;
if let Some(exact_colors) = histogram::detect_exact_palette(pixels, max_colors) {
let centroids = simd::batch_srgb_to_oklab_vec(&exact_colors);
let pal = palette::Palette::from_centroids_sorted(centroids, false, tuning.sort_strategy);
let mut indices = dither::simple_remap(pixels, &pal);
let pal = if tuning.gif_frequency_reorder {
palette::reorder_by_frequency(&pal, &mut indices)
} else {
pal
};
let mpe_result = if needs_metric {
Some(metric::MpeResult::zero(width, height))
} else {
None
};
return Ok(QuantizeResult {
palette: pal,
indices,
mpe_result,
});
}
let use_masking = matches!(effective_quality, Quality::Balanced | Quality::Best);
let kmeans_iters: usize = match effective_quality {
Quality::Best => 8,
Quality::Balanced => 2,
Quality::Fast => 0,
};
let labs = simd::batch_srgb_to_oklab_vec(pixels);
let weights = if use_masking {
masking::compute_masking_weights_from_labs(&labs, width, height)
} else {
vec![1.0f32; pixels.len()]
};
let (hist, _hist_bumped) = histogram::build_histogram_from_labs(&labs, &weights, max_colors);
let mut centroids = median_cut::farthest_point_quantize(hist, max_colors);
if kmeans_iters > 0 {
centroids = median_cut::refine_against_pixels_from_labs(
centroids,
pixels,
&labs,
&weights,
kmeans_iters,
config.kmeans_sample_cap,
);
}
let mut pal = palette::Palette::from_centroids_sorted(centroids, false, tuning.sort_strategy);
pal.build_nn_cache();
let mut mpe_acc = if needs_metric {
Some(metric::MpeAccumulator::new(width, height))
} else {
None
};
let dither_params = dither::DitherParams {
width,
height,
weights: &weights,
palette: &pal,
mode: effective_config.dither_mode,
run_priority: effective_run_priority,
dither_strength: tuning.dither_strength,
prev_indices: None,
precomputed_labs: Some(&labs),
};
let mut indices = dither::dither_image(pixels, &dither_params, mpe_acc.as_mut());
let use_viterbi = matches!(effective_quality, Quality::Best);
let run_lambda = if use_masking {
config
.viterbi_lambda
.unwrap_or(match effective_run_priority {
remap::RunPriority::Quality => 0.0,
remap::RunPriority::Balanced => 0.01,
remap::RunPriority::Compression => 0.02,
})
* tuning.viterbi_lambda_scale
} else {
config.viterbi_lambda.unwrap_or(0.0)
};
if run_lambda > 0.0 {
if use_viterbi {
remap::viterbi_refine_with_labs(
pixels,
&labs,
width,
height,
&weights,
&pal,
&mut indices,
run_lambda,
);
} else {
remap::run_extend_refine_with_labs(
&labs,
width,
height,
&weights,
&pal,
&mut indices,
run_lambda,
);
}
}
let pal = if tuning.gif_frequency_reorder {
palette::reorder_by_frequency(&pal, &mut indices)
} else {
pal
};
let mpe_result = mpe_acc.map(|acc| acc.finalize());
if let Some(min) = config.min_ssim2 {
let achieved = mpe_result
.as_ref()
.map(|r| r.ssimulacra2_estimate)
.unwrap_or(100.0);
if achieved < min {
return Err(QuantizeError::QualityNotMet {
min_ssim2: min,
achieved_ssim2: achieved,
});
}
}
#[cfg(feature = "joint")]
let indices = if matches!(
config.output_format,
OutputFormat::PngJoint | OutputFormat::PngMinSize
) {
joint::optimize_rgb_with_labs(
&labs,
width,
height,
&weights,
&pal,
&indices,
config.joint_deflate_effort,
config.joint_tolerance,
)
} else {
indices
};
Ok(QuantizeResult {
palette: pal,
indices,
mpe_result,
})
}
pub fn quantize_rgba(
pixels: &[rgb::RGBA<u8>],
width: usize,
height: usize,
config: &QuantizeConfig,
) -> Result<QuantizeResult, QuantizeError> {
validate_inputs(pixels.len(), width, height, config)?;
let needs_metric =
config.compute_metric || config.target_ssim2.is_some() || config.min_ssim2.is_some();
let (effective_quality, effective_run_priority, effective_dither_strength) =
if let Some(target) = config.target_ssim2 {
let tier = select_compression_tier(target);
(
tier.quality,
tier.run_priority,
config
.dither_strength
.map(|s| s * tier.dither_strength_mult),
)
} else {
(config.quality, config.run_priority, config.dither_strength)
};
let effective_dither_mode = if effective_quality == Quality::Fast
&& config.dither_mode == dither::DitherMode::Adaptive
{
dither::DitherMode::Ordered
} else {
config.dither_mode
};
let effective_config = QuantizeConfig {
quality: effective_quality,
run_priority: effective_run_priority,
dither_strength: effective_dither_strength,
dither_mode: effective_dither_mode,
compute_metric: needs_metric,
..config.clone()
};
let tuning = QuantizeTuning::from_config(&effective_config);
let max_colors = config.max_colors as usize;
if let Some((exact_colors, has_transparent)) =
histogram::detect_exact_palette_rgba(pixels, max_colors)
{
let rgb_colors: Vec<rgb::RGB<u8>> = exact_colors
.iter()
.map(|c| rgb::RGB::new(c.r, c.g, c.b))
.collect();
let centroids = simd::batch_srgb_to_oklab_vec(&rgb_colors);
let pal = palette::Palette::from_centroids_sorted(
centroids,
has_transparent,
tuning.sort_strategy,
);
let transparent_idx = pal.transparent_index().unwrap_or(0);
let mut indices = dither::simple_remap_rgba(pixels, &pal, transparent_idx);
let pal = if tuning.gif_frequency_reorder {
palette::reorder_by_frequency(&pal, &mut indices)
} else {
pal
};
let mpe_result = if needs_metric {
Some(metric::MpeResult::zero(width, height))
} else {
None
};
return Ok(QuantizeResult {
palette: pal,
indices,
mpe_result,
});
}
let use_masking = matches!(effective_quality, Quality::Balanced | Quality::Best);
let use_viterbi = matches!(effective_quality, Quality::Best);
let kmeans_iters: usize = match effective_quality {
Quality::Best => 8,
Quality::Balanced => 2,
Quality::Fast => 0,
};
let rgb_pixels: Vec<rgb::RGB<u8>> = pixels
.iter()
.map(|p| rgb::RGB::new(p.r, p.g, p.b))
.collect();
let labs = simd::batch_srgb_to_oklab_vec(&rgb_pixels);
let weights = if use_masking {
masking::compute_masking_weights_rgba(pixels, width, height)
} else {
vec![1.0f32; pixels.len()]
};
let (pal, mut indices) = if tuning.alpha_mode == AlphaMode::Full {
let (hist, has_transparent) = histogram::build_histogram_alpha(pixels, &weights);
let opaque_colors = if has_transparent {
max_colors.saturating_sub(1)
} else {
max_colors
};
let mut centroids = median_cut::wu_quantize_alpha(hist, opaque_colors, true);
if kmeans_iters > 0 {
centroids = median_cut::refine_against_pixels_alpha(
centroids,
pixels,
&weights,
kmeans_iters,
config.kmeans_sample_cap,
);
}
let pal = palette::Palette::from_centroids_alpha(
centroids,
has_transparent,
tuning.sort_strategy,
);
let viterbi_lambda = if use_masking {
config
.viterbi_lambda
.unwrap_or(match effective_run_priority {
remap::RunPriority::Quality => 0.0,
remap::RunPriority::Balanced => 0.01,
remap::RunPriority::Compression => 0.02,
})
* tuning.viterbi_lambda_scale
} else {
config.viterbi_lambda.unwrap_or(0.0)
};
let dither_params = dither::DitherParams {
width,
height,
weights: &weights,
palette: &pal,
mode: effective_config.dither_mode,
run_priority: effective_run_priority,
dither_strength: tuning.dither_strength,
prev_indices: None,
precomputed_labs: Some(&labs),
};
let mut indices = dither::dither_image_rgba_alpha(pixels, &dither_params, None);
if viterbi_lambda > 0.0 {
if use_viterbi {
remap::viterbi_refine_rgba_with_labs(
pixels,
&labs,
width,
height,
&weights,
&pal,
&mut indices,
viterbi_lambda,
);
} else {
remap::run_extend_refine_rgba_with_labs(
pixels,
&labs,
width,
height,
&weights,
&pal,
&mut indices,
viterbi_lambda,
);
}
}
(pal, indices)
} else {
let (hist, has_transparent) = histogram::build_histogram_rgba(pixels, &weights);
let opaque_colors = if has_transparent {
max_colors.saturating_sub(1)
} else {
max_colors
};
let mut centroids = median_cut::farthest_point_quantize(hist, opaque_colors);
if kmeans_iters > 0 {
centroids = median_cut::refine_against_pixels_rgba_from_labs(
centroids,
pixels,
&labs,
&weights,
kmeans_iters,
config.kmeans_sample_cap,
);
}
let mut pal = palette::Palette::from_centroids_sorted(
centroids,
has_transparent,
tuning.sort_strategy,
);
pal.build_nn_cache();
let viterbi_lambda = if use_masking {
config
.viterbi_lambda
.unwrap_or(match effective_run_priority {
remap::RunPriority::Quality => 0.0,
remap::RunPriority::Balanced => 0.01,
remap::RunPriority::Compression => 0.02,
})
* tuning.viterbi_lambda_scale
} else {
config.viterbi_lambda.unwrap_or(0.0)
};
let dither_params = dither::DitherParams {
width,
height,
weights: &weights,
palette: &pal,
mode: effective_config.dither_mode,
run_priority: effective_run_priority,
dither_strength: tuning.dither_strength,
prev_indices: None,
precomputed_labs: Some(&labs),
};
let mut indices = dither::dither_image_rgba(pixels, &dither_params, None);
if viterbi_lambda > 0.0 {
if use_viterbi {
remap::viterbi_refine_rgba_with_labs(
pixels,
&labs,
width,
height,
&weights,
&pal,
&mut indices,
viterbi_lambda,
);
} else {
remap::run_extend_refine_rgba_with_labs(
pixels,
&labs,
width,
height,
&weights,
&pal,
&mut indices,
viterbi_lambda,
);
}
}
(pal, indices)
};
let pal = if tuning.gif_frequency_reorder {
palette::reorder_by_frequency(&pal, &mut indices)
} else {
pal
};
let mpe_result = if needs_metric {
Some(metric::compute_mpe_rgba(
pixels,
pal.entries_rgba(),
&indices,
width,
height,
None,
))
} else {
None
};
if let Some(min) = config.min_ssim2 {
let achieved = mpe_result
.as_ref()
.map(|r| r.ssimulacra2_estimate)
.unwrap_or(100.0);
if achieved < min {
return Err(QuantizeError::QualityNotMet {
min_ssim2: min,
achieved_ssim2: achieved,
});
}
}
#[cfg(feature = "joint")]
let indices = if matches!(
config.output_format,
OutputFormat::PngJoint | OutputFormat::PngMinSize
) {
joint::optimize_rgba_with_labs(
&labs,
width,
height,
&weights,
&pal,
&indices,
config.joint_deflate_effort,
config.joint_tolerance,
)
} else {
indices
};
Ok(QuantizeResult {
palette: pal,
indices,
mpe_result,
})
}
pub fn build_palette(
frames: &[ImgRef<'_, rgb::RGB<u8>>],
config: &QuantizeConfig,
) -> Result<QuantizeResult, QuantizeError> {
if frames.is_empty() {
return Err(QuantizeError::ZeroDimension);
}
for frame in frames {
if frame.width() == 0 || frame.height() == 0 {
return Err(QuantizeError::ZeroDimension);
}
}
if config.max_colors < 2 || config.max_colors > 256 {
return Err(QuantizeError::InvalidMaxColors(config.max_colors));
}
let tuning = QuantizeTuning::from_config(config);
let max_colors = config.max_colors as usize;
let all_exact = detect_exact_palette_multi_rgb(frames, max_colors);
if let Some(exact_colors) = all_exact {
let centroids = simd::batch_srgb_to_oklab_vec(&exact_colors);
let mut pal =
palette::Palette::from_centroids_sorted(centroids, false, tuning.sort_strategy);
pal.build_nn_cache();
return Ok(QuantizeResult {
palette: pal,
indices: Vec::new(),
mpe_result: None,
});
}
let use_masking = matches!(config.quality, Quality::Balanced | Quality::Best);
let kmeans_iters: usize = match config.quality {
Quality::Best => 8,
Quality::Balanced => 2,
Quality::Fast => 0,
};
let mut merged_hist: Vec<(oklab::OKLab, f32)> = Vec::new();
let mut all_pixels: Vec<rgb::RGB<u8>> = Vec::new();
let mut all_weights: Vec<f32> = Vec::new();
for frame in frames {
let pixels: Vec<rgb::RGB<u8>> = frame.pixels().collect();
let w = frame.width();
let h = frame.height();
let weights = if use_masking {
masking::compute_masking_weights(&pixels, w, h)
} else {
vec![1.0f32; pixels.len()]
};
let hist = histogram::build_histogram(&pixels, &weights);
merged_hist.extend_from_slice(&hist);
if kmeans_iters > 0 {
all_pixels.extend_from_slice(&pixels);
all_weights.extend_from_slice(&weights);
}
}
let mut centroids = median_cut::farthest_point_quantize(merged_hist, max_colors);
if kmeans_iters > 0 {
centroids = median_cut::refine_against_pixels(
centroids,
&all_pixels,
&all_weights,
kmeans_iters,
config.kmeans_sample_cap,
);
}
let mut pal = palette::Palette::from_centroids_sorted(centroids, false, tuning.sort_strategy);
pal.build_nn_cache();
Ok(QuantizeResult {
palette: pal,
indices: Vec::new(),
mpe_result: None,
})
}
pub fn build_palette_rgba(
frames: &[ImgRef<'_, rgb::RGBA<u8>>],
config: &QuantizeConfig,
) -> Result<QuantizeResult, QuantizeError> {
if frames.is_empty() {
return Err(QuantizeError::ZeroDimension);
}
for frame in frames {
if frame.width() == 0 || frame.height() == 0 {
return Err(QuantizeError::ZeroDimension);
}
}
if config.max_colors < 2 || config.max_colors > 256 {
return Err(QuantizeError::InvalidMaxColors(config.max_colors));
}
let tuning = QuantizeTuning::from_config(config);
let max_colors = config.max_colors as usize;
let all_exact = detect_exact_palette_multi_rgba(frames, max_colors);
if let Some((exact_colors, has_transparent)) = all_exact {
if tuning.alpha_mode == AlphaMode::Full {
let rgb_colors: Vec<rgb::RGB<u8>> = exact_colors
.iter()
.map(|c| rgb::RGB::new(c.r, c.g, c.b))
.collect();
let labs = simd::batch_srgb_to_oklab_vec(&rgb_colors);
let centroids: Vec<oklab::OKLabA> = labs
.into_iter()
.zip(exact_colors.iter())
.map(|(lab, c)| lab.with_alpha(c.a as f32 / 255.0))
.collect();
let mut pal =
palette::Palette::from_centroids_alpha(centroids, false, tuning.sort_strategy);
pal.build_nn_cache();
return Ok(QuantizeResult {
palette: pal,
indices: Vec::new(),
mpe_result: None,
});
} else {
let rgb_colors: Vec<rgb::RGB<u8>> = exact_colors
.iter()
.map(|c| rgb::RGB::new(c.r, c.g, c.b))
.collect();
let centroids = simd::batch_srgb_to_oklab_vec(&rgb_colors);
let mut pal = palette::Palette::from_centroids_sorted(
centroids,
has_transparent,
tuning.sort_strategy,
);
pal.build_nn_cache();
return Ok(QuantizeResult {
palette: pal,
indices: Vec::new(),
mpe_result: None,
});
}
}
let use_masking = matches!(config.quality, Quality::Balanced | Quality::Best);
let kmeans_iters: usize = match config.quality {
Quality::Best => 8,
Quality::Balanced => 2,
Quality::Fast => 0,
};
let mut all_pixels: Vec<rgb::RGBA<u8>> = Vec::new();
let mut all_weights: Vec<f32> = Vec::new();
for frame in frames {
let pixels: Vec<rgb::RGBA<u8>> = frame.pixels().collect();
let w = frame.width();
let h = frame.height();
let weights = if use_masking {
masking::compute_masking_weights_rgba(&pixels, w, h)
} else {
vec![1.0f32; pixels.len()]
};
all_pixels.extend_from_slice(&pixels);
all_weights.extend_from_slice(&weights);
}
let pal = if tuning.alpha_mode == AlphaMode::Full {
let (merged_hist, has_transparent) =
histogram::build_histogram_alpha(&all_pixels, &all_weights);
let _ = has_transparent;
let mut centroids = median_cut::wu_quantize_alpha(merged_hist, max_colors, true);
if kmeans_iters > 0 {
centroids = median_cut::refine_against_pixels_alpha(
centroids,
&all_pixels,
&all_weights,
kmeans_iters,
config.kmeans_sample_cap,
);
}
let mut p = palette::Palette::from_centroids_alpha(centroids, false, tuning.sort_strategy);
p.build_nn_cache();
p
} else {
let (merged_hist, has_transparent) =
histogram::build_histogram_rgba(&all_pixels, &all_weights);
let opaque_colors = if has_transparent {
max_colors.saturating_sub(1)
} else {
max_colors
};
let mut centroids = median_cut::farthest_point_quantize(merged_hist, opaque_colors);
if kmeans_iters > 0 {
centroids = median_cut::refine_against_pixels_rgba(
centroids,
&all_pixels,
&all_weights,
kmeans_iters,
config.kmeans_sample_cap,
);
}
let mut p = palette::Palette::from_centroids_sorted(
centroids,
has_transparent,
tuning.sort_strategy,
);
p.build_nn_cache();
p
};
Ok(QuantizeResult {
palette: pal,
indices: Vec::new(),
mpe_result: None,
})
}
fn remap_rgb_impl(
source_palette: &palette::Palette,
pixels: &[rgb::RGB<u8>],
width: usize,
height: usize,
config: &QuantizeConfig,
prev_indices: Option<&[u8]>,
) -> Result<QuantizeResult, QuantizeError> {
if width == 0 || height == 0 {
return Err(QuantizeError::ZeroDimension);
}
if pixels.len() != width * height {
return Err(QuantizeError::DimensionMismatch {
len: pixels.len(),
width,
height,
});
}
let needs_metric =
config.compute_metric || config.target_ssim2.is_some() || config.min_ssim2.is_some();
let (effective_quality, effective_run_priority, effective_dither_strength) =
if let Some(target) = config.target_ssim2 {
let tier = select_compression_tier(target);
(
tier.quality,
tier.run_priority,
config
.dither_strength
.map(|s| s * tier.dither_strength_mult),
)
} else {
(config.quality, config.run_priority, config.dither_strength)
};
let effective_dither_mode = if effective_quality == Quality::Fast
&& config.dither_mode == dither::DitherMode::Adaptive
{
dither::DitherMode::Ordered
} else {
config.dither_mode
};
let effective_config = QuantizeConfig {
quality: effective_quality,
run_priority: effective_run_priority,
dither_strength: effective_dither_strength,
dither_mode: effective_dither_mode,
compute_metric: needs_metric,
..config.clone()
};
let tuning = QuantizeTuning::from_config(&effective_config);
let mut pal = source_palette.clone();
if !pal.has_nn_cache() {
pal.build_nn_cache();
}
let use_masking = matches!(effective_quality, Quality::Balanced | Quality::Best);
let use_viterbi = matches!(effective_quality, Quality::Best);
let labs = simd::batch_srgb_to_oklab_vec(pixels);
let weights = if use_masking {
masking::compute_masking_weights_from_labs(&labs, width, height)
} else {
vec![1.0f32; pixels.len()]
};
let dither_params = dither::DitherParams {
width,
height,
weights: &weights,
palette: &pal,
mode: effective_config.dither_mode,
run_priority: effective_run_priority,
dither_strength: tuning.dither_strength,
prev_indices,
precomputed_labs: Some(&labs),
};
let mut indices = dither::dither_image(pixels, &dither_params, None);
let run_lambda = if use_masking {
config
.viterbi_lambda
.unwrap_or(match effective_run_priority {
remap::RunPriority::Quality => 0.0,
remap::RunPriority::Balanced => 0.01,
remap::RunPriority::Compression => 0.02,
})
* tuning.viterbi_lambda_scale
} else {
config.viterbi_lambda.unwrap_or(0.0)
};
if run_lambda > 0.0 {
if use_viterbi {
remap::viterbi_refine_with_labs(
pixels,
&labs,
width,
height,
&weights,
&pal,
&mut indices,
run_lambda,
);
} else {
remap::run_extend_refine_with_labs(
&labs,
width,
height,
&weights,
&pal,
&mut indices,
run_lambda,
);
}
}
let mpe_result = if needs_metric {
let w = if use_masking {
Some(&weights[..])
} else {
None
};
Some(metric::compute_mpe(
pixels,
pal.entries(),
&indices,
width,
height,
w,
))
} else {
None
};
if let Some(min) = config.min_ssim2 {
let achieved = mpe_result
.as_ref()
.map(|r| r.ssimulacra2_estimate)
.unwrap_or(100.0);
if achieved < min {
return Err(QuantizeError::QualityNotMet {
min_ssim2: min,
achieved_ssim2: achieved,
});
}
}
Ok(QuantizeResult {
palette: pal,
indices,
mpe_result,
})
}
fn remap_rgba_impl(
source_palette: &palette::Palette,
pixels: &[rgb::RGBA<u8>],
width: usize,
height: usize,
config: &QuantizeConfig,
prev_indices: Option<&[u8]>,
) -> Result<QuantizeResult, QuantizeError> {
if width == 0 || height == 0 {
return Err(QuantizeError::ZeroDimension);
}
if pixels.len() != width * height {
return Err(QuantizeError::DimensionMismatch {
len: pixels.len(),
width,
height,
});
}
let needs_metric =
config.compute_metric || config.target_ssim2.is_some() || config.min_ssim2.is_some();
let (effective_quality, effective_run_priority, effective_dither_strength) =
if let Some(target) = config.target_ssim2 {
let tier = select_compression_tier(target);
(
tier.quality,
tier.run_priority,
config
.dither_strength
.map(|s| s * tier.dither_strength_mult),
)
} else {
(config.quality, config.run_priority, config.dither_strength)
};
let effective_dither_mode = if effective_quality == Quality::Fast
&& config.dither_mode == dither::DitherMode::Adaptive
{
dither::DitherMode::Ordered
} else {
config.dither_mode
};
let effective_config = QuantizeConfig {
quality: effective_quality,
run_priority: effective_run_priority,
dither_strength: effective_dither_strength,
dither_mode: effective_dither_mode,
compute_metric: needs_metric,
..config.clone()
};
let tuning = QuantizeTuning::from_config(&effective_config);
let mut pal = source_palette.clone();
if !pal.has_nn_cache() {
pal.build_nn_cache();
}
let use_masking = matches!(effective_quality, Quality::Balanced | Quality::Best);
let use_viterbi = matches!(effective_quality, Quality::Best);
let rgb_pixels: Vec<rgb::RGB<u8>> = pixels
.iter()
.map(|p| rgb::RGB::new(p.r, p.g, p.b))
.collect();
let labs = simd::batch_srgb_to_oklab_vec(&rgb_pixels);
let weights = if use_masking {
masking::compute_masking_weights_rgba(pixels, width, height)
} else {
vec![1.0f32; pixels.len()]
};
let has_full_alpha = pal.entries_rgba().iter().any(|e| e[3] > 0 && e[3] < 255);
let dither_params = dither::DitherParams {
width,
height,
weights: &weights,
palette: &pal,
mode: effective_config.dither_mode,
run_priority: effective_run_priority,
dither_strength: tuning.dither_strength,
prev_indices,
precomputed_labs: Some(&labs),
};
let mut indices = if has_full_alpha {
dither::dither_image_rgba_alpha(pixels, &dither_params, None)
} else {
dither::dither_image_rgba(pixels, &dither_params, None)
};
let run_lambda = if use_masking {
config
.viterbi_lambda
.unwrap_or(match effective_run_priority {
remap::RunPriority::Quality => 0.0,
remap::RunPriority::Balanced => 0.01,
remap::RunPriority::Compression => 0.02,
})
* tuning.viterbi_lambda_scale
} else {
config.viterbi_lambda.unwrap_or(0.0)
};
if run_lambda > 0.0 {
if use_viterbi {
remap::viterbi_refine_rgba_with_labs(
pixels,
&labs,
width,
height,
&weights,
&pal,
&mut indices,
run_lambda,
);
} else {
remap::run_extend_refine_rgba_with_labs(
pixels,
&labs,
width,
height,
&weights,
&pal,
&mut indices,
run_lambda,
);
}
}
let mpe_result = if needs_metric {
Some(metric::compute_mpe_rgba(
pixels,
pal.entries_rgba(),
&indices,
width,
height,
None,
))
} else {
None
};
if let Some(min) = config.min_ssim2 {
let achieved = mpe_result
.as_ref()
.map(|r| r.ssimulacra2_estimate)
.unwrap_or(100.0);
if achieved < min {
return Err(QuantizeError::QualityNotMet {
min_ssim2: min,
achieved_ssim2: achieved,
});
}
}
Ok(QuantizeResult {
palette: pal,
indices,
mpe_result,
})
}
fn detect_exact_palette_multi_rgb(
frames: &[ImgRef<'_, rgb::RGB<u8>>],
max_colors: usize,
) -> Option<Vec<rgb::RGB<u8>>> {
let mut seen = alloc::collections::BTreeSet::new();
for frame in frames {
for p in frame.pixels() {
let key = (p.r as u32) << 16 | (p.g as u32) << 8 | p.b as u32;
seen.insert(key);
if seen.len() > max_colors {
return None;
}
}
}
Some(
seen.into_iter()
.map(|k| rgb::RGB {
r: (k >> 16) as u8,
g: (k >> 8) as u8,
b: k as u8,
})
.collect(),
)
}
fn detect_exact_palette_multi_rgba(
frames: &[ImgRef<'_, rgb::RGBA<u8>>],
max_colors: usize,
) -> Option<(Vec<rgb::RGBA<u8>>, bool)> {
let mut seen = alloc::collections::BTreeSet::new();
let mut has_transparent = false;
for frame in frames {
for p in frame.pixels() {
if p.a == 0 {
has_transparent = true;
continue;
}
let key = (p.r as u32) << 24 | (p.g as u32) << 16 | (p.b as u32) << 8 | p.a as u32;
seen.insert(key);
if seen.len() > max_colors {
return None;
}
}
}
let colors = seen
.into_iter()
.map(|k| rgb::RGBA {
r: (k >> 24) as u8,
g: (k >> 16) as u8,
b: (k >> 8) as u8,
a: k as u8,
})
.collect();
Some((colors, has_transparent))
}
fn validate_inputs(
pixel_count: usize,
width: usize,
height: usize,
config: &QuantizeConfig,
) -> Result<(), QuantizeError> {
if width == 0 || height == 0 {
return Err(QuantizeError::ZeroDimension);
}
if pixel_count != width * height {
return Err(QuantizeError::DimensionMismatch {
len: pixel_count,
width,
height,
});
}
if config.max_colors < 2 || config.max_colors > 256 {
return Err(QuantizeError::InvalidMaxColors(config.max_colors));
}
Ok(())
}