From 9276031ba5d39dc7bfba57f2956bef6bf73dd7c1 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Fri, 9 Oct 2026 22:22:39 +0530 Subject: [PATCH 1/4] feat(core): CDF 9/7 wavelet, entropy bit model, DCT edge fixes Wavelets - Add CDF 9/7 (JPEG 2000 lossy wavelet) via lifting with symmetric extension, alongside Haar. TransformType is now DCT / DWT_HAAR / DWT_97 (WASM transform_mode 0 / 1 / 2; existing callers keep Haar for 1). - Quant steps shrink by 0.75 per coarser level instead of halving, which over-spent bits on coarse bands of a near-orthonormal transform. 0.75 gave the best PSNR at equal bitrate on the bundled test images. - Chroma wavelet bands use a 1.5x coarser step, mirroring the JPEG chroma/luma table ratio the DCT path already uses. - subbandMap() renders the quantized pyramid (zeroed coefficients dim). Bit estimate - Both paths now estimate bits from quantization indices (previously dequantized values) using per-subband zeroth-order entropy (EntropyEstimator). The old flat 0.5 bit/zero model floored every curve near 0.75 bpp, and DWT alone paid a 400-bit header, so the DCT-vs-DWT comparison was skewed. DCT / pipeline correctness - Partial edge blocks are padded by edge replication and coded instead of copied through untouched (inflated PSNR, uncounted bits). - DCT output is clamped to [0, 255]; quality is clamped to [1, 100] (quality <= 0 divided by zero). - Chroma upsampling is bilinear instead of nearest-neighbour. WASM - Cache the reconstructed BGR image so view switches skip colour conversion; add rd_encode/get_rd_bit_estimate for cheap RD sweeps that leave the displayed result untouched; add the Subbands view (7). - Drop the duplicate chroma downsampler in favour of ImageCodec::downsampleChannel. - Makefile builds EXPORTED_FUNCTIONS from a single WEB_EXPORTS list. Co-Authored-By: Claude Opus 5.5 --- Makefile | 16 +- core/inc/ImageCodec.h | 28 ++- core/inc/entropy.h | 47 ++++ core/inc/wavelet.h | 46 +++- core/src/ImageCodec.cpp | 425 ++++++++++++++++++--------------- core/src/entropy.cpp | 45 ++++ core/src/wavelet.cpp | 165 +++++++++---- core/tests/test_imagecodec.cpp | 139 ++++++++++- core/tests/wavelet_test.cpp | 63 ++++- web/cpp/codec_web.cpp | 163 ++++++------- web/public/codec.js | 2 +- web/public/codec.wasm | Bin 66556 -> 81325 bytes 12 files changed, 784 insertions(+), 355 deletions(-) create mode 100644 core/inc/entropy.h create mode 100644 core/src/entropy.cpp diff --git a/Makefile b/Makefile index 3601ff9..dcabc67 100644 --- a/Makefile +++ b/Makefile @@ -1,9 +1,23 @@ # --- CONFIGURATION --- WEB_COMPILER = emcc +# C functions exported to JavaScript (keep in sync with web/src/types/wasm.d.ts) +WEB_EXPORTS = _init_session _process_image _rd_encode _get_rd_bit_estimate \ + _get_view_ptr _set_view_tint _set_artifact_gain \ + _get_psnr_y _get_psnr_cr _get_psnr_cb \ + _get_ssim_y _get_ssim_cr _get_ssim_cb \ + _get_last_bit_estimate _inspect_block_data _get_coeff_histogram \ + _init_me_session _run_motion_estimation _get_mv_count \ + _get_me_residual_ptr _get_search_steps _get_search_step_count \ + _malloc _free +comma := , +empty := +space := $(empty) $(empty) +WEB_EXPORTS_JSON = [$(subst $(space),$(comma),$(foreach f,$(WEB_EXPORTS),"$(f)"))] + WEB_FLAGS = -Icore/inc -O3 \ -s WASM=1 \ -s ALLOW_MEMORY_GROWTH=1 \ - -s EXPORTED_FUNCTIONS='["_init_session", "_process_image", "_get_view_ptr", "_set_view_tint", "_get_psnr_y", "_get_psnr_cr", "_get_psnr_cb", "_get_ssim_y", "_get_ssim_cr", "_get_ssim_cb", "_get_last_bit_estimate", "_inspect_block_data", "_get_coeff_histogram", "_init_me_session", "_run_motion_estimation", "_get_mv_count", "_get_me_residual_ptr", "_get_search_steps", "_get_search_step_count", "_malloc", "_free"]' \ + -s EXPORTED_FUNCTIONS='$(WEB_EXPORTS_JSON)' \ -s EXPORTED_RUNTIME_METHODS='["cwrap", "ccall", "HEAPU8"]' # Source: Core C++ + Web Glue C++ (in src folder) diff --git a/core/inc/ImageCodec.h b/core/inc/ImageCodec.h index b77c04f..0e93f87 100644 --- a/core/inc/ImageCodec.h +++ b/core/inc/ImageCodec.h @@ -19,6 +19,7 @@ #define IMAGE_PROCESSOR_H #include "Image.h" +#include /* * ImageCodec class encapsulates the functionality for compressing and decompressing images using @@ -34,14 +35,15 @@ class ImageCodec { }; enum class TransformType { - DCT, // Discrete Cosine Transform (JPEG-style) - DWT // Haar Discrete Wavelet Transform + DCT, // 8×8 block Discrete Cosine Transform (JPEG-style) + DWT_HAAR, // Full-image Haar Discrete Wavelet Transform + DWT_97 // Full-image CDF 9/7 Discrete Wavelet Transform (JPEG 2000) }; public: /* * Constructs an ImageCodec with the specified quality, quantization, and transform options. - * @param quality Quality factor for quantization (1-100). Higher means better quality. + * @param quality Quality factor for quantization, clamped to [1, 100]. Higher means better quality. * @param enableQuantization Whether to apply quantization to transform coefficients. * @param cs Chroma subsampling mode (default: 4:4:4). * @param transform Transform type to use (default: DCT). @@ -53,6 +55,21 @@ class ImageCodec { Image process(const Image& bgrImage); + /* + * Forward-transforms and quantizes a single channel with the configured + * wavelet and returns a displayable map of its subband pyramid (LL in the + * top-left, log-scaled detail magnitudes elsewhere; coefficients that + * quantize to zero are drawn dim), resampled to the channel's size. Returns an empty Image for TransformType::DCT. + */ + Image subbandMap(const Image& channel) const; + + bool isWavelet() const { return m_transformType != TransformType::DCT; } + + /* + * Downsamples a chroma channel by box-averaging (2×1 for 4:2:2, 2×2 for 4:2:0). + */ + static Image downsampleChannel(const Image& channel, ChromaSubsampling cs); + private: double m_quality; bool m_enableQuantization; @@ -68,8 +85,9 @@ class ImageCodec { void generateQuantizationTables(); Image processChannel(const Image& channel, const double quantTable[8][8]); - Image processChannelDWT(const Image& channel); - Image downsampleChannel(const Image& channel, ChromaSubsampling cs) const; + Image processChannelDWT(const Image& channel, bool isChroma); + void transformChannelDWT(const Image& channel, std::vector& coeffs, + int& paddedW, int& paddedH, int& levels) const; Image upsampleChannel(const Image& channel, int targetWidth, int targetHeight, ChromaSubsampling cs) const; public: diff --git a/core/inc/entropy.h b/core/inc/entropy.h new file mode 100644 index 0000000..b02791b --- /dev/null +++ b/core/inc/entropy.h @@ -0,0 +1,47 @@ +/* + * Codec Explorer: An interactive codec laboratory. + * Copyright (C) 2026 Abhinav Tanniru + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#ifndef ENTROPY_H +#define ENTROPY_H + +#include +#include + +/* + * Estimates the coded size of a set of quantization indices as their + * zeroth-order (Shannon) entropy, measured separately per subband. + * + * Grouping by subband — one band per DCT frequency position, or per wavelet + * subband — models what a context-adaptive entropy coder exploits: a band that + * quantizes to all zeros costs ~0 bits, while busy bands cost more. Using the + * same model for every transform keeps their bit estimates comparable. + */ +class EntropyEstimator { +public: + explicit EntropyEstimator(int numBands); + + void add(int band, double qIndex); + + // Sum over bands of count_b × H_b, in bits. + double totalBits() const; + +private: + std::vector> m_histograms; + std::vector m_counts; +}; + +#endif // ENTROPY_H diff --git a/core/inc/wavelet.h b/core/inc/wavelet.h index 5a55b2b..8e90ec0 100644 --- a/core/inc/wavelet.h +++ b/core/inc/wavelet.h @@ -18,6 +18,19 @@ #ifndef WAVELET_H #define WAVELET_H +/* + * Wavelet filter used by the full-image transform. + * Haar — 2-tap orthonormal filter. Cheap, but low-order: at low quality it + * leaves its own blocky, piecewise-constant artifacts. + * CDF97 — Cohen–Daubechies–Feauveau 9/7 biorthogonal filter (the JPEG 2000 + * lossy wavelet), computed via lifting with symmetric extension. + * Smooth basis functions give much more graceful degradation. + */ +enum class WaveletType { + Haar, + CDF97 +}; + /* * Forward 2D Haar Discrete Wavelet Transform on an 8x8 block. * Applies a 3-level separable orthonormal Haar decomposition (rows then columns). @@ -41,7 +54,7 @@ void idwt8x8(const double src[8][8], double dst[8][8]); int calcDwtLevels(int width, int height); /* - * Full-image forward 2D Haar DWT applied in-place on a flat row-major buffer. + * Full-image forward 2D DWT (Haar by default) applied in-place on a flat row-major buffer. * `data` has stride `width`. Applies `levels` levels of decomposition, each * time operating on the top-left LL subband from the previous level. * @@ -51,29 +64,42 @@ int calcDwtLevels(int width, int height); * rest of [0, W/2)×[0, H/2) — level-2 detail bands * rest of [0, W)×[0, H) — level-1 detail bands (finest) */ -void dwtImage(double* data, int width, int height, int levels); +void dwtImage(double* data, int width, int height, int levels, + WaveletType type = WaveletType::Haar); /* - * Full-image inverse 2D Haar DWT applied in-place. Exact inverse of dwtImage - * with the same `levels`. Reconstructs the original signal perfectly (before + * Full-image inverse 2D DWT applied in-place. Exact inverse of dwtImage + * with the same `levels` and `type`. Reconstructs the original signal perfectly (before * any quantization). */ -void idwtImage(double* data, int width, int height, int levels); +void idwtImage(double* data, int width, int height, int levels, + WaveletType type = WaveletType::Haar); /* * Returns the wavelet quantization step size for the coefficient at pixel * position (x, y) in a DWT coefficient image of size width×height with * `levels` decomposition levels. * - * `baseStep` is the step for the finest detail level; coarser levels use - * progressively smaller steps (halved per level), and the LL approximation - * band uses the smallest step of all. + * `baseStep` is the step for the finest detail level. Both supported + * wavelets are (near-)orthonormal, for which a uniform step is MSE-optimal, + * so coarser levels only get a mild perceptual reduction (see + * DWT_LEVEL_STEP_RATIO), and the LL approximation band uses the smallest + * step of all. */ double dwtQuantStep(int x, int y, int width, int height, int levels, double baseStep); /* - * Estimates the total bits needed for a DWT coefficient buffer. + * Returns the subband index of coefficient (x, y) in a `levels`-level DWT + * coefficient image: 0 is the LL approximation, and level `lev` detail bands + * are 1 + 3·lev + {0: HL, 1: LH, 2: HH}. There are 3·levels + 1 bands. + */ +int dwtSubbandIndex(int x, int y, int width, int height, int levels); + +/* + * Estimates the total bits needed to entropy-code a buffer of DWT + * quantization indices, as the per-subband zeroth-order entropy + * (see EntropyEstimator). */ -double dwtEstimateBits(const double* data, int width, int height); +double dwtEstimateBits(const double* data, int width, int height, int levels); #endif // WAVELET_H diff --git a/core/src/ImageCodec.cpp b/core/src/ImageCodec.cpp index b1c3839..f983542 100644 --- a/core/src/ImageCodec.cpp +++ b/core/src/ImageCodec.cpp @@ -20,6 +20,7 @@ #include "wavelet.h" #include "colorspace.h" #include "CodecAnalysis.h" +#include "entropy.h" #include #include @@ -47,9 +48,30 @@ const int BASE_CHROMA[8][8] = { {99, 99, 99, 99, 99, 99, 99, 99} }; +// Quantization step for the finest wavelet detail band at quality 50. Chosen so +// that at equal quality the wavelet paths land near DCT's PSNR. +static const double DWT_BASE_STEP = 32.0; + +// Chroma wavelet bands are quantized more coarsely than luma, mirroring the +// ratio between the JPEG chroma and luma tables used by the DCT path. +static const double DWT_CHROMA_STEP_SCALE = 1.5; + +/* +* Maps a quality factor in [1, 100] to the standard IJG quantization scale +* (1.0 at quality 50, smaller for higher quality). +*/ +static double qualityScale(double quality) { + const double scale = (quality < 50.0) ? 5000.0 / quality + : 200.0 - 2.0 * quality; + return scale / 100.0; +} + +static double clampPixel(double v) { + return std::max(0.0, std::min(255.0, v)); +} ImageCodec::ImageCodec(double quality, bool enableQuantization, ChromaSubsampling cs, TransformType transform) - : m_quality(quality), + : m_quality(std::max(1.0, std::min(100.0, quality))), m_enableQuantization(enableQuantization), m_chromaSubsampling(cs), m_transformType(transform) @@ -65,14 +87,7 @@ ImageCodec::ImageCodec(double quality, bool enableQuantization, ChromaSubsamplin */ void ImageCodec::generateQuantizationTables() { - double scale; - - if (m_quality < 50.0) - scale = 5000.0 / m_quality; - else - scale = 200.0 - 2.0 * m_quality; - - scale /= 100.0; + const double scale = qualityScale(m_quality); for (int i = 0; i < 8; ++i) { for (int j = 0; j < 8; ++j) { @@ -86,178 +101,235 @@ void ImageCodec::generateQuantizationTables() } } -/* - * Estimates bit count for a block of quantized DCT coefficients. - */ -static double estimateBlockBits(const double block[8][8]) { - double bits = 0; - for (int i = 0; i < 8; ++i) { - for (int j = 0; j < 8; ++j) { - double val = std::abs(block[i][j]); - if (val < 0.5) bits += 0.5; - else bits += std::log2(val) + 3.0; - } - } - return bits; -} - /* * Processes a single channel using the block-DCT pipeline (JPEG-style). * Each 8×8 block is forward-transformed, quantized with the supplied table, - * dequantized, and inverse-transformed independently. + * dequantized, and inverse-transformed independently. Partial blocks at the + * right/bottom edges are padded by edge replication (as JPEG encoders do), + * coded like any other block, and cropped on write-back. */ Image ImageCodec::processChannel(const Image& channel, const double quantTable[8][8]) { - Image reconstructed(channel.width(), - channel.height(), - 1); - - for (int y = 0; y < channel.height(); y += 8) { - for (int x = 0; x < channel.width(); x += 8) { - - int blockWidth = std::min(8, channel.width() - x); - int blockHeight = std::min(8, channel.height() - y); - if (blockWidth < 8 || blockHeight < 8) { - // Copy boundary blocks without processing. - const double* channelData = channel.data(); - double* reconData = reconstructed.data(); - const int channelWidth = channel.width(); - for (int i = 0; i < blockHeight; ++i) { - const double* src_row = &channelData[(y + i) * channelWidth + x]; - double* dst_row = &reconData[(y + i) * channelWidth + x]; - std::copy(src_row, src_row + blockWidth, dst_row); - } - continue; - } + const int width = channel.width(); + const int height = channel.height(); + Image reconstructed(width, height, 1); + + const double* channelData = channel.data(); + double* reconData = reconstructed.data(); + EntropyEstimator entropy(64); // one band per DCT frequency position + for (int y = 0; y < height; y += 8) { + for (int x = 0; x < width; x += 8) { double block[8][8]; double dctBlock[8][8]; double reconBlock[8][8]; - const double* channelData = channel.data(); - const int channelWidth = channel.width(); - for (int i = 0; i < 8; ++i) - for (int j = 0; j < 8; ++j) - block[i][j] = channelData[(y + i) * channelWidth + (x + j)] - 128.0; + for (int i = 0; i < 8; ++i) { + const int srcY = std::min(y + i, height - 1); + for (int j = 0; j < 8; ++j) { + const int srcX = std::min(x + j, width - 1); + block[i][j] = channelData[srcY * width + srcX] - 128.0; + } + } dct8x8(block, dctBlock); if (m_enableQuantization) { for (int i = 0; i < 8; ++i) for (int j = 0; j < 8; ++j) { - double coeff = dctBlock[i][j] / quantTable[i][j]; - dctBlock[i][j] = std::round(coeff) * quantTable[i][j]; + const double index = std::round(dctBlock[i][j] / quantTable[i][j]); + entropy.add(i * 8 + j, index); + dctBlock[i][j] = index * quantTable[i][j]; } - m_lastBitEstimate += estimateBlockBits(dctBlock); } idct8x8(dctBlock, reconBlock); - double* reconData = reconstructed.data(); - const int reconWidth = reconstructed.width(); - for (int i = 0; i < 8; ++i) - for (int j = 0; j < 8; ++j) - reconData[(y + i) * reconWidth + (x + j)] = - reconBlock[i][j] + 128.0; + const int blockHeight = std::min(8, height - y); + const int blockWidth = std::min(8, width - x); + for (int i = 0; i < blockHeight; ++i) + for (int j = 0; j < blockWidth; ++j) + reconData[(y + i) * width + (x + j)] = clampPixel(reconBlock[i][j] + 128.0); } } + if (m_enableQuantization) + m_lastBitEstimate += entropy.totalBits(); return reconstructed; } - /* - * Processes a single channel using the full-image Haar DWT pipeline. - * Unlike the block DCT, the wavelet transform operates on the entire channel - * at once, eliminating 8×8 blocking artifacts. Quantization steps are - * perceptually scaled per subband: coarser (low-frequency) subbands receive - * smaller steps (higher fidelity); finer (high-frequency) subbands receive - * larger steps (more compression). + * Pads a channel to a multiple of 2^levels by edge replication, applies the + * DC level shift, and runs the full-image forward DWT with the configured + * wavelet. `coeffs` receives the paddedW×paddedH coefficient buffer. */ -Image ImageCodec::processChannelDWT(const Image& channel) +void ImageCodec::transformChannelDWT(const Image& channel, std::vector& coeffs, + int& paddedW, int& paddedH, int& levels) const { const int W_orig = channel.width(); const int H_orig = channel.height(); - const int levels = calcDwtLevels(W_orig, H_orig); - - // Padding to ensure every level can be halved evenly without leaving "original" edges. - int stride = (1 << levels); - int W = (W_orig + stride - 1) & ~(stride - 1); - int H = (H_orig + stride - 1) & ~(stride - 1); - - // Quality → base quantization step for the finest detail subband. - double qualScale; - if (m_quality < 50.0) - qualScale = 5000.0 / m_quality; - else - qualScale = 200.0 - 2.0 * m_quality; - qualScale /= 100.0; - const double baseStep = 32.0 * qualScale; - - // Copy to a flat working buffer with DC level shift and edge mirroring. - std::vector buf(static_cast(W) * H, 0.0); + levels = calcDwtLevels(W_orig, H_orig); + + // Padding ensures every level can be halved evenly without leaving "original" edges. + const int stride = (1 << levels); + paddedW = (W_orig + stride - 1) & ~(stride - 1); + paddedH = (H_orig + stride - 1) & ~(stride - 1); + + coeffs.assign(static_cast(paddedW) * paddedH, 0.0); const double* src = channel.data(); - for (int y = 0; y < H; ++y) { - int srcY = std::min(y, H_orig - 1); - for (int x = 0; x < W; ++x) { - int srcX = std::min(x, W_orig - 1); - buf[(size_t)y * W + x] = src[(size_t)srcY * W_orig + srcX] - 128.0; + for (int y = 0; y < paddedH; ++y) { + const int srcY = std::min(y, H_orig - 1); + for (int x = 0; x < paddedW; ++x) { + const int srcX = std::min(x, W_orig - 1); + coeffs[(size_t)y * paddedW + x] = src[(size_t)srcY * W_orig + srcX] - 128.0; } } - // Full-image forward DWT. - dwtImage(buf.data(), W, H, levels); + const WaveletType wavelet = (m_transformType == TransformType::DWT_97) + ? WaveletType::CDF97 : WaveletType::Haar; + dwtImage(coeffs.data(), paddedW, paddedH, levels, wavelet); +} + +/* + * Processes a single channel using the full-image DWT pipeline. + * Unlike the block DCT, the wavelet transform operates on the entire channel + * at once, eliminating 8×8 blocking artifacts. Quantization steps are + * perceptually scaled per subband (see dwtQuantStep). + */ +Image ImageCodec::processChannelDWT(const Image& channel, bool isChroma) +{ + const int W_orig = channel.width(); + const int H_orig = channel.height(); + + std::vector buf; + int W = 0, H = 0, levels = 0; + transformChannelDWT(channel, buf, W, H, levels); - // Subband-adaptive quantization. + // Subband-adaptive quantization; bits are estimated on the integer indices. if (m_enableQuantization) { + EntropyEstimator entropy(3 * levels + 1); + const double baseStep = DWT_BASE_STEP * qualityScale(m_quality) + * (isChroma ? DWT_CHROMA_STEP_SCALE : 1.0); for (int y = 0; y < H; ++y) { for (int x = 0; x < W; ++x) { - double step = dwtQuantStep(x, y, W, H, levels, baseStep); + const double step = dwtQuantStep(x, y, W, H, levels, baseStep); double& c = buf[(size_t)y * W + x]; - c = std::round(c / step) * step; + const double index = std::round(c / step); + entropy.add(dwtSubbandIndex(x, y, W, H, levels), index); + c = index * step; } } + m_lastBitEstimate += entropy.totalBits(); } - // Accumulate bit estimate. - m_lastBitEstimate += dwtEstimateBits(buf.data(), W, H); - - // Full-image inverse DWT. - idwtImage(buf.data(), W, H, levels); + const WaveletType wavelet = (m_transformType == TransformType::DWT_97) + ? WaveletType::CDF97 : WaveletType::Haar; + idwtImage(buf.data(), W, H, levels, wavelet); // Write back with reverse level shift and pixel-value clamping, cropping back to original size. Image result(W_orig, H_orig, 1); double* dst = result.data(); - for (int y = 0; y < H_orig; ++y) { - for (int x = 0; x < W_orig; ++x) { - dst[(size_t)y * W_orig + x] = std::max(0.0, std::min(255.0, buf[(size_t)y * W + x] + 128.0)); + for (int y = 0; y < H_orig; ++y) + for (int x = 0; x < W_orig; ++x) + dst[(size_t)y * W_orig + x] = clampPixel(buf[(size_t)y * W + x] + 128.0); + + return result; +} + +Image ImageCodec::subbandMap(const Image& channel) const +{ + if (!isWavelet()) return Image(); + + const int W_orig = channel.width(); + const int H_orig = channel.height(); + + std::vector buf; + int W = 0, H = 0, levels = 0; + transformChannelDWT(channel, buf, W, H, levels); + + // Mark which coefficients survive quantization at the current quality. + std::vector survives(buf.size(), true); + if (m_enableQuantization) { + const double baseStep = DWT_BASE_STEP * qualityScale(m_quality); + for (int y = 0; y < H; ++y) + for (int x = 0; x < W; ++x) { + const double step = dwtQuantStep(x, y, W, H, levels, baseStep); + survives[(size_t)y * W + x] = std::round(buf[(size_t)y * W + x] / step) != 0.0; + } + } + + // LL is shown linearly (it is a thumbnail of the image); detail bands are + // log-scaled magnitudes so faint high-frequency energy stays visible. + const int llW = W >> levels; + const int llH = H >> levels; + double llMin = 1e300, llMax = -1e300, detailMax = 0.0; + for (int y = 0; y < H; ++y) + for (int x = 0; x < W; ++x) { + const double c = buf[(size_t)y * W + x]; + if (x < llW && y < llH) { + llMin = std::min(llMin, c); + llMax = std::max(llMax, c); + } else { + detailMax = std::max(detailMax, std::abs(c)); + } + } + const double llRange = std::max(1e-9, llMax - llMin); + const double logDetailMax = std::log1p(std::max(1e-9, detailMax)); + + // Coefficients quantized to zero are drawn dimly (so band structure stays + // readable); surviving ones are drawn brightly. + const double ZEROED_MAX = 70.0; + const double SURVIVOR_MIN = 110.0; + + // Resample the padded pyramid onto the channel's own grid. + Image map(W_orig, H_orig, 1); + for (int oy = 0; oy < H_orig; ++oy) { + const int y = static_cast(static_cast(oy) * H / H_orig); + for (int ox = 0; ox < W_orig; ++ox) { + const int x = static_cast(static_cast(ox) * W / W_orig); + const size_t i = (size_t)y * W + x; + const double c = buf[i]; + if (x < llW && y < llH) { + map.at(ox, oy, 0) = 255.0 * (c - llMin) / llRange; + } else { + const double v = std::log1p(std::abs(c)) / logDetailMax; + map.at(ox, oy, 0) = survives[i] ? SURVIVOR_MIN + (255.0 - SURVIVOR_MIN) * v + : ZEROED_MAX * v; + } } } - return result; + // Outline each level's quadrants. + for (int k = 1; k <= levels; ++k) { + const int lineX = static_cast(static_cast(W >> k) * W_orig / W); + const int lineY = static_cast(static_cast(H >> k) * H_orig / H); + const int extentX = static_cast(static_cast(W >> (k - 1)) * W_orig / W); + const int extentY = static_cast(static_cast(H >> (k - 1)) * H_orig / H); + for (int y = 0; y < std::min(extentY, H_orig); ++y) + if (lineX < W_orig) map.at(lineX, y, 0) = 255.0; + for (int x = 0; x < std::min(extentX, W_orig); ++x) + if (lineY < H_orig) map.at(x, lineY, 0) = 255.0; + } + + return map; } /* * Downsamples a single channel (Cr or Cb) based on the specified chroma subsampling mode. * Uses simple averaging for downsampling. */ -Image ImageCodec::downsampleChannel(const Image& channel, ChromaSubsampling cs) const { +Image ImageCodec::downsampleChannel(const Image& channel, ChromaSubsampling cs) { if (cs == ChromaSubsampling::CS_444) { return channel; // No subsampling } - int originalWidth = channel.width(); - int originalHeight = channel.height(); - int newWidth = originalWidth; - int newHeight = originalHeight; - - if (cs == ChromaSubsampling::CS_422 || cs == ChromaSubsampling::CS_420) { - newWidth = (originalWidth + 1) / 2; // Ceiling division - } - if (cs == ChromaSubsampling::CS_420) { - newHeight = (originalHeight + 1) / 2; // Ceiling division - } + const int originalWidth = channel.width(); + const int originalHeight = channel.height(); + const int scaleX = 2; // 4:2:2 and 4:2:0 both halve the width + const int scaleY = (cs == ChromaSubsampling::CS_420) ? 2 : 1; + const int newWidth = (originalWidth + scaleX - 1) / scaleX; + const int newHeight = (originalHeight + scaleY - 1) / scaleY; Image downsampled(newWidth, newHeight, 1); @@ -265,34 +337,25 @@ Image ImageCodec::downsampleChannel(const Image& channel, ChromaSubsampling cs) for (int x = 0; x < newWidth; ++x) { double sum = 0.0; int count = 0; + const int startX = x * scaleX; + const int startY = y * scaleY; - // Calculate the top-left corner of the 2x2 or 2x1 block in the original image - int startX = x * (cs == ChromaSubsampling::CS_444 ? 1 : 2); - int startY = y * (cs == ChromaSubsampling::CS_420 ? 2 : 1); - - // Calculate the bottom-right corner (exclusive) of the block - int endX = startX + (cs == ChromaSubsampling::CS_444 ? 1 : 2); - int endY = startY + (cs == ChromaSubsampling::CS_420 ? 2 : 1); - - for (int sy = startY; sy < endY && sy < originalHeight; ++sy) { - for (int sx = startX; sx < endX && sx < originalWidth; ++sx) { + for (int sy = startY; sy < startY + scaleY && sy < originalHeight; ++sy) { + for (int sx = startX; sx < startX + scaleX && sx < originalWidth; ++sx) { sum += channel.at(sx, sy, 0); count++; } } - if (count > 0) { - downsampled.at(x, y, 0) = sum / count; - } else { - // Fallback for edge cases where no pixels are covered (should ideally not happen with correct dimensions) - downsampled.at(x, y, 0) = channel.at(std::min(startX, originalWidth - 1), std::min(startY, originalHeight - 1), 0); - } + downsampled.at(x, y, 0) = sum / count; // count >= 1: start coords are always in range } } return downsampled; } /* -* Upsamples a single channel (Cr or Cb) back to target dimensions using nearest-neighbor interpolation. +* Upsamples a single channel (Cr or Cb) back to target dimensions using bilinear +* interpolation. Each subsampled value sits at the centre of the 2×1 / 2×2 box +* it was averaged from, so target pixel x maps to source coordinate x/2 - 0.25. */ Image ImageCodec::upsampleChannel(const Image& channel, int targetWidth, int targetHeight, ChromaSubsampling cs) const { if (cs == ChromaSubsampling::CS_444) { @@ -300,26 +363,31 @@ Image ImageCodec::upsampleChannel(const Image& channel, int targetWidth, int tar } Image upsampled(targetWidth, targetHeight, 1); - int currentWidth = channel.width(); - int currentHeight = channel.height(); + const int currentWidth = channel.width(); + const int currentHeight = channel.height(); + const bool halveY = (cs == ChromaSubsampling::CS_420); + + // Returns the two source taps and the weight of the second one. + auto taps = [](int t, bool halved, int srcSize, int& i0, int& i1, double& w) { + if (!halved) { i0 = i1 = std::min(t, srcSize - 1); w = 0.0; return; } + const double s = std::max(0.0, t / 2.0 - 0.25); + i0 = std::min(static_cast(s), srcSize - 1); + i1 = std::min(i0 + 1, srcSize - 1); + w = s - static_cast(s); + }; for (int y = 0; y < targetHeight; ++y) { + int y0, y1; + double wy; + taps(y, halveY, currentHeight, y0, y1, wy); for (int x = 0; x < targetWidth; ++x) { - int srcX = x; - int srcY = y; + int x0, x1; + double wx; + taps(x, true, currentWidth, x0, x1, wx); - if (cs == ChromaSubsampling::CS_422 || cs == ChromaSubsampling::CS_420) { - srcX /= 2; - } - if (cs == ChromaSubsampling::CS_420) { - srcY /= 2; - } - - // Clamp to valid source coordinates - srcX = std::min(srcX, currentWidth - 1); - srcY = std::min(srcY, currentHeight - 1); - - upsampled.at(x, y, 0) = channel.at(srcX, srcY, 0); + const double top = channel.at(x0, y0, 0) * (1.0 - wx) + channel.at(x1, y0, 0) * wx; + const double bottom = channel.at(x0, y1, 0) * (1.0 - wx) + channel.at(x1, y1, 0) * wx; + upsampled.at(x, y, 0) = top * (1.0 - wy) + bottom * wy; } } return upsampled; @@ -350,35 +418,24 @@ Image ImageCodec::process(const Image& bgrImage) cbData[i] = *ycrcbData++; } + auto codeChannel = [this](const Image& ch, bool isChroma) { + if (isWavelet()) return processChannelDWT(ch, isChroma); + return processChannel(ch, isChroma ? m_chromaQuantTable : m_lumaQuantTable); + }; + // Process Y channel (always at full resolution) - Image reconY = (m_transformType == TransformType::DWT) - ? processChannelDWT(Y_orig) - : processChannel(Y_orig, m_lumaQuantTable); + Image reconY = codeChannel(Y_orig, false); Image reconCr_final; Image reconCb_final; if (m_chromaSubsampling == ChromaSubsampling::CS_444) { - reconCr_final = (m_transformType == TransformType::DWT) - ? processChannelDWT(Cr_orig) - : processChannel(Cr_orig, m_chromaQuantTable); - reconCb_final = (m_transformType == TransformType::DWT) - ? processChannelDWT(Cb_orig) - : processChannel(Cb_orig, m_chromaQuantTable); + reconCr_final = codeChannel(Cr_orig, true); + reconCb_final = codeChannel(Cb_orig, true); } else { - // Downsample Cr and Cb - Image downsampledCr = downsampleChannel(Cr_orig, m_chromaSubsampling); - Image downsampledCb = downsampleChannel(Cb_orig, m_chromaSubsampling); - - // Process subsampled Cr and Cb - Image reconCr_sub = (m_transformType == TransformType::DWT) - ? processChannelDWT(downsampledCr) - : processChannel(downsampledCr, m_chromaQuantTable); - Image reconCb_sub = (m_transformType == TransformType::DWT) - ? processChannelDWT(downsampledCb) - : processChannel(downsampledCb, m_chromaQuantTable); - - // Upsample Cr and Cb back to original dimensions + // Downsample, code, and upsample Cr and Cb back to original dimensions + Image reconCr_sub = codeChannel(downsampleChannel(Cr_orig, m_chromaSubsampling), true); + Image reconCb_sub = codeChannel(downsampleChannel(Cb_orig, m_chromaSubsampling), true); reconCr_final = upsampleChannel(reconCr_sub, bgrImage.width(), bgrImage.height(), m_chromaSubsampling); reconCb_final = upsampleChannel(reconCb_sub, bgrImage.width(), bgrImage.height(), m_chromaSubsampling); } @@ -387,12 +444,12 @@ Image ImageCodec::process(const Image& bgrImage) Image merged(bgrImage.width(), bgrImage.height(), 3); - + double* mergedData = merged.data(); const double* reconYData = reconY.data(); const double* reconCrData = reconCr_final.data(); const double* reconCbData = reconCb_final.data(); - + for (size_t i = 0; i < numPixels; ++i) { *mergedData++ = reconYData[i]; *mergedData++ = reconCrData[i]; @@ -404,7 +461,7 @@ Image ImageCodec::process(const Image& bgrImage) ImageCodec::BlockDebugData ImageCodec::inspectBlock(const Image& channel, int blockX, int blockY, bool isChroma) { // Full-image DWT has no 8×8 block structure; return zeroed data. - if (m_transformType == TransformType::DWT) { + if (isWavelet()) { BlockDebugData empty = {}; return empty; } @@ -423,10 +480,10 @@ ImageCodec::BlockDebugData ImageCodec::inspectBlock(const Image& channel, int bl // Ensure we don't go out of bounds int startX = blockX * 8; int startY = blockY * 8; - + // Fill with zeros first - for(int i=0; i<8; ++i) - for(int j=0; j<8; ++j) + for(int i=0; i<8; ++i) + for(int j=0; j<8; ++j) data.original[i][j] = 0.0; const double* channelData = channel.data(); @@ -449,10 +506,7 @@ ImageCodec::BlockDebugData ImageCodec::inspectBlock(const Image& channel, int bl for(int j=0; j<8; ++j) blockCentered[i][j] = data.original[i][j] - 128.0; - if (m_transformType == TransformType::DWT) - dwt8x8(blockCentered, data.coefficients); - else - dct8x8(blockCentered, data.coefficients); + dct8x8(blockCentered, data.coefficients); // 4. Quantization if (m_enableQuantization) { @@ -480,14 +534,11 @@ ImageCodec::BlockDebugData ImageCodec::inspectBlock(const Image& channel, int bl } double reconBlock[8][8]; - if (m_transformType == TransformType::DWT) - idwt8x8(dequantized, reconBlock); - else - idct8x8(dequantized, reconBlock); + idct8x8(dequantized, reconBlock); for(int i=0; i<8; ++i) for(int j=0; j<8; ++j) data.reconstructed[i][j] = reconBlock[i][j] + 128.0; return data; -} \ No newline at end of file +} diff --git a/core/src/entropy.cpp b/core/src/entropy.cpp new file mode 100644 index 0000000..0df6f97 --- /dev/null +++ b/core/src/entropy.cpp @@ -0,0 +1,45 @@ +/* + * Codec Explorer: An interactive codec laboratory. + * Copyright (C) 2026 Abhinav Tanniru + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ +#include "entropy.h" +#include + +EntropyEstimator::EntropyEstimator(int numBands) + : m_histograms(numBands > 0 ? numBands : 1), + m_counts(numBands > 0 ? numBands : 1, 0) +{ +} + +void EntropyEstimator::add(int band, double qIndex) { + if (band < 0 || band >= static_cast(m_counts.size())) return; + ++m_histograms[band][std::llround(qIndex)]; + ++m_counts[band]; +} + +double EntropyEstimator::totalBits() const { + double bits = 0.0; + for (size_t b = 0; b < m_counts.size(); ++b) { + const double n = static_cast(m_counts[b]); + if (n == 0.0) continue; + // n·H = -Σ c·log2(c/n) + for (const auto& entry : m_histograms[b]) { + const double c = static_cast(entry.second); + bits -= c * std::log2(c / n); + } + } + return bits; +} diff --git a/core/src/wavelet.cpp b/core/src/wavelet.cpp index 89f199d..5243afa 100644 --- a/core/src/wavelet.cpp +++ b/core/src/wavelet.cpp @@ -16,6 +16,7 @@ * along with this program. If not, see . */ #include "wavelet.h" +#include "entropy.h" #include #include #include @@ -134,33 +135,6 @@ void idwt8x8(const double src[8][8], double dst[8][8]) { // Full-image DWT helpers & Statistics // ----------------------------------------------------------------------- -/* - * Estimates the number of bits required to encode the DWT coefficients - * in `data` after quantization. This is a simple entropy-based estimate: - * - Zero coefficients are very cheap (part of a run). - * - Non-zero coefficients are estimated based on their magnitude. - */ -double dwtEstimateBits(const double* data, int width, int height) { - double totalBits = 0; - size_t count = (size_t)width * height; - - // Very simple model: - // - Each zero-run is roughly 1-2 bits per zero. - // - Each non-zero coeff takes ~log2(abs(val)) + sign + overhead. - for (size_t i = 0; i < count; ++i) { - double val = std::abs(data[i]); - if (val < 0.5) { - totalBits += 0.5; // Roughly 0.5 bits per zero on average (JPEG-like) - } else { - // log2(val) bits + 1 sign bit + 2 bits overhead - totalBits += std::log2(val) + 3.0; - } - } - - // Add some constant header overhead (roughly 400 bits) - return totalBits + 400.0; -} - /* * Forward 1D orthonormal Haar on n elements (arbitrary even n), in-place. * Identical math to haar1d_fwd but uses a heap buffer for any length. @@ -188,6 +162,75 @@ static void haar1d_inv_n(double* data, int n) { for (int k = 0; k < n; ++k) data[k] = tmp[k]; } +// CDF 9/7 lifting coefficients (Daubechies & Sweldens factorisation). +static const double CDF97_ALPHA = -1.586134342059924; +static const double CDF97_BETA = -0.052980118572961; +static const double CDF97_GAMMA = 0.882911075530934; +static const double CDF97_DELTA = 0.443506852043971; +static const double CDF97_K = 1.230174104914001; +// Scale low/high bands so the filter has the same √2 DC gain as the +// orthonormal Haar — keeps quantization steps comparable across wavelets. +static const double CDF97_LOW_SCALE = SQRT2 / CDF97_K; +static const double CDF97_HIGH_SCALE = CDF97_K / SQRT2; + +// One lifting step over the odd (parity=1) or even (parity=0) samples: +// x[i] += c * (x[i-1] + x[i+1]), with whole-sample symmetric extension +// (x[-1] = x[1], x[n] = x[n-2]). Requires even n >= 2. +static void cdf97_lift(double* x, int n, int parity, double c) { + for (int i = parity; i < n; i += 2) { + double left = x[i > 0 ? i - 1 : 1]; + double right = x[i < n - 1 ? i + 1 : n - 2]; + x[i] += c * (left + right); + } +} + +/* + * Forward 1D CDF 9/7 on n elements (even n), in-place. + * Output layout matches haar1d_fwd_n: [low | high]. + */ +static void cdf97_fwd_n(double* data, int n) { + cdf97_lift(data, n, 1, CDF97_ALPHA); + cdf97_lift(data, n, 0, CDF97_BETA); + cdf97_lift(data, n, 1, CDF97_GAMMA); + cdf97_lift(data, n, 0, CDF97_DELTA); + + std::vector tmp(n); + const int half = n / 2; + for (int k = 0; k < half; ++k) { + tmp[k] = data[2*k] * CDF97_LOW_SCALE; + tmp[k + half] = data[2*k + 1] * CDF97_HIGH_SCALE; + } + std::copy(tmp.begin(), tmp.end(), data); +} + +/* + * Inverse 1D CDF 9/7 on n elements (even n), in-place. + */ +static void cdf97_inv_n(double* data, int n) { + std::vector tmp(n); + const int half = n / 2; + for (int k = 0; k < half; ++k) { + tmp[2*k] = data[k] / CDF97_LOW_SCALE; + tmp[2*k + 1] = data[k + half] / CDF97_HIGH_SCALE; + } + std::copy(tmp.begin(), tmp.end(), data); + + cdf97_lift(data, n, 0, -CDF97_DELTA); + cdf97_lift(data, n, 1, -CDF97_GAMMA); + cdf97_lift(data, n, 0, -CDF97_BETA); + cdf97_lift(data, n, 1, -CDF97_ALPHA); +} + +using Wavelet1D = void (*)(double*, int); + +static Wavelet1D forward1D(WaveletType type) { + return type == WaveletType::CDF97 ? cdf97_fwd_n : haar1d_fwd_n; +} + +static Wavelet1D inverse1D(WaveletType type) { + return type == WaveletType::CDF97 ? cdf97_inv_n : haar1d_inv_n; +} + int calcDwtLevels(int width, int height) { int levels = 0; int w = width, h = height; @@ -199,34 +242,35 @@ int calcDwtLevels(int width, int height) { return std::min(levels, 6); } -void dwtImage(double* data, int width, int height, int levels) { +void dwtImage(double* data, int width, int height, int levels, WaveletType type) { + const Wavelet1D fwd = forward1D(type); std::vector buf; int w = width, h = height; for (int lev = 0; lev < levels; ++lev) { if (w < 2 || h < 2) break; - // Round down to even so Haar pairs are well-defined at this level. + // Round down to even so filter pairs are well-defined at this level. // Any odd-boundary pixel in the last column/row is left untransformed. int wt = w & ~1; int ht = h & ~1; - // Forward Haar on rows: transform wt elements of each of the first ht rows. + // Forward 1D transform on rows: transform wt elements of each of the first ht rows. buf.resize(wt); for (int y = 0; y < ht; ++y) { std::copy(data + (size_t)y * width, data + (size_t)y * width + wt, buf.data()); - haar1d_fwd_n(buf.data(), wt); + fwd(buf.data(), wt); std::copy(buf.data(), buf.data() + wt, data + (size_t)y * width); } - // Forward Haar on columns: transform ht elements of each of the first wt columns. + // Forward 1D transform on columns: transform ht elements of each of the first wt columns. buf.resize(ht); for (int x = 0; x < wt; ++x) { for (int y = 0; y < ht; ++y) buf[y] = data[(size_t)y * width + x]; - haar1d_fwd_n(buf.data(), ht); + fwd(buf.data(), ht); for (int y = 0; y < ht; ++y) data[(size_t)y * width + x] = buf[y]; } @@ -235,8 +279,9 @@ void dwtImage(double* data, int width, int height, int levels) { } } -void idwtImage(double* data, int width, int height, int levels) { +void idwtImage(double* data, int width, int height, int levels, WaveletType type) { if (levels <= 0) return; + const Wavelet1D inv = inverse1D(type); // Replay the forward pass to record the exact (wt, ht) at each level. std::vector fwdW(levels), fwdH(levels); @@ -263,7 +308,7 @@ void idwtImage(double* data, int width, int height, int levels) { buf.resize(th); for (int x = 0; x < tw; ++x) { for (int y = 0; y < th; ++y) buf[y] = data[(size_t)y * width + x]; - haar1d_inv_n(buf.data(), th); + inv(buf.data(), th); for (int y = 0; y < th; ++y) data[(size_t)y * width + x] = buf[y]; } @@ -273,16 +318,22 @@ void idwtImage(double* data, int width, int height, int levels) { std::copy(data + (size_t)y * width, data + (size_t)y * width + tw, buf.data()); - haar1d_inv_n(buf.data(), tw); + inv(buf.data(), tw); std::copy(buf.data(), buf.data() + tw, data + (size_t)y * width); } } } -double dwtQuantStep(int x, int y, int width, int height, int levels, double baseStep) { - if (levels <= 0) return baseStep; - +// Per-level step multiplier for coarser subbands. The previous scheme halved +// the step every level, which on an (near-)orthonormal transform spends far +// more bits on coarse bands than they are worth (uniform steps are MSE-optimal). +// 0.75 gave the best PSNR-at-equal-bitrate on the bundled test images. +static const double DWT_LEVEL_STEP_RATIO = 0.75; + +int dwtSubbandIndex(int x, int y, int width, int height, int levels) { + if (levels <= 0) return 0; + // Safety cap to prevent buffer overflow and integer shift overflow. // 16 levels is enough for any image up to 65536x65536. if (levels > 16) levels = 16; @@ -303,18 +354,32 @@ double dwtQuantStep(int x, int y, int width, int height, int levels, double base // [0, fwdW[lev]/2) × [0, fwdH[lev]/2). Scan from coarsest to finest so the // smallest (innermost) enclosing block wins. for (int lev = levels - 1; lev >= 0; --lev) { - int tw = fwdW[lev], th = fwdH[lev]; - bool inBlock = (x < tw && y < th); - bool inLL = (x < tw / 2 && y < th / 2); - - if (inBlock && !inLL) { - // Detail band at forward level `lev`. - // lev=0 (finest details) → baseStep (most quantization) - // lev=levels-1 (coarsest details) → baseStep / 2^(levels-1) (least among details) - return std::max(1.0, baseStep / (double)(1 << lev)); + const int tw = fwdW[lev], th = fwdH[lev]; + const bool inBlock = (x < tw && y < th); + const bool right = (x >= tw / 2); + const bool bottom = (y >= th / 2); + if (inBlock && (right || bottom)) { + const int orientation = (right && bottom) ? 2 : (right ? 0 : 1); + return 1 + 3 * lev + orientation; } } + return 0; // LL approximation +} + +double dwtQuantStep(int x, int y, int width, int height, int levels, double baseStep) { + if (levels <= 0) return baseStep; + const int band = dwtSubbandIndex(x, y, width, height, levels); + // LL sits one level below the coarsest detail bands. + const int lev = (band == 0) ? std::min(levels, 16) : (band - 1) / 3; + // lev=0 (finest details) → baseStep (most quantization) + // coarser levels → baseStep · ratio^lev (mild perceptual weighting) + return std::max(1.0, baseStep * std::pow(DWT_LEVEL_STEP_RATIO, lev)); +} - // LL approximation: smallest step, preserving the global structure. - return std::max(1.0, baseStep / (double)(1 << levels)); +double dwtEstimateBits(const double* data, int width, int height, int levels) { + EntropyEstimator estimator(3 * std::max(0, levels) + 1); + for (int y = 0; y < height; ++y) + for (int x = 0; x < width; ++x) + estimator.add(dwtSubbandIndex(x, y, width, height, levels), data[(size_t)y * width + x]); + return estimator.totalBits(); } diff --git a/core/tests/test_imagecodec.cpp b/core/tests/test_imagecodec.cpp index cc098dd..f8cdb38 100644 --- a/core/tests/test_imagecodec.cpp +++ b/core/tests/test_imagecodec.cpp @@ -151,7 +151,7 @@ TEST(ImageCodecTest, ProcessSingleBlock) { } TEST(ImageCodecTest, ProcessNonMultipleOf8) { - // Dimensions not divisible by 8; partial boundary blocks are copied as-is + // Dimensions not divisible by 8; partial boundary blocks are padded and coded Image input = createTestImage(13, 11); ImageCodec codec(80.0); Image output = codec.process(input); @@ -285,3 +285,140 @@ TEST(ImageCodecTest, InspectBlockHigherQualityLowerQuantTable) { } EXPECT_GT(lowSum, highSum); } + +// Deterministic noisy image: a hard case for quantization at every position. +static Image createNoiseImage(int width, int height) { + Image img(width, height, 3); + unsigned state = 12345u; + double* data = img.data(); + for (size_t i = 0; i < img.size(); ++i) { + state = state * 1103515245u + 12345u; + data[i] = static_cast((state >> 16) % 256); + } + return img; +} + +static const ImageCodec::TransformType ALL_TRANSFORMS[] = { + ImageCodec::TransformType::DCT, + ImageCodec::TransformType::DWT_HAAR, + ImageCodec::TransformType::DWT_97, +}; + +TEST(ImageCodecTest, PartialEdgeBlocksAreCoded) { + // 12×12: the right/bottom 4-pixel strips are partial 8×8 blocks. They used + // to be copied through untouched; now heavy quantization must alter them. + Image input = createNoiseImage(12, 12); + ImageCodec codec(1.0); + Image output = codec.process(input); + + int changed = 0; + for (int y = 0; y < 12; ++y) + for (int x = 8; x < 12; ++x) + for (int c = 0; c < 3; ++c) + if (std::abs(output.at(x, y, c) - input.at(x, y, c)) > 1.0) ++changed; + EXPECT_GT(changed, 0); +} + +TEST(ImageCodecTest, OutputStaysInPixelRangeForAllTransforms) { + Image input = createNoiseImage(37, 29); + for (auto t : ALL_TRANSFORMS) { + ImageCodec codec(1.0, true, ImageCodec::ChromaSubsampling::CS_420, t); + Image output = codec.process(input); + for (size_t i = 0; i < output.size(); ++i) { + ASSERT_GE(output.data()[i], 0.0); + ASSERT_LE(output.data()[i], 255.0); + } + } +} + +TEST(ImageCodecTest, OutOfRangeQualityIsClamped) { + Image input = createTestImage(16, 16); + ImageCodec q1(1.0); + Image ref = q1.process(input); + + for (double q : {0.0, -5.0}) { + ImageCodec codec(q); + Image output = codec.process(input); + for (size_t i = 0; i < output.size(); ++i) { + ASSERT_TRUE(std::isfinite(output.data()[i])); + ASSERT_DOUBLE_EQ(output.data()[i], ref.data()[i]); + } + } +} + +TEST(ImageCodecTest, BitEstimateGrowsWithQuality) { + Image input = createNoiseImage(64, 64); + for (auto t : ALL_TRANSFORMS) { + double prev = -1.0; + for (double q : {5.0, 25.0, 50.0, 75.0, 95.0}) { + ImageCodec codec(q, true, ImageCodec::ChromaSubsampling::CS_444, t); + codec.process(input); + const double bits = codec.getLastBitEstimate(); + EXPECT_GT(bits, prev) << "transform " << static_cast(t) << " q" << q; + prev = bits; + } + } +} + +TEST(ImageCodecTest, UniformImageIsNearlyFree) { + // A flat image quantizes to a constant per band, which has zero entropy. + Image input(32, 32, 3); + for (size_t i = 0; i < input.size(); ++i) input.data()[i] = 100.0; + for (auto t : ALL_TRANSFORMS) { + ImageCodec codec(50.0, true, ImageCodec::ChromaSubsampling::CS_444, t); + codec.process(input); + EXPECT_NEAR(codec.getLastBitEstimate(), 0.0, 1e-6); + } +} + +TEST(ImageCodecTest, Cdf97OutperformsHaarOnSmoothContent) { + // Smooth gradients are where Haar's piecewise-constant basis struggles. + Image input(64, 64, 3); + for (int y = 0; y < 64; ++y) + for (int x = 0; x < 64; ++x) + for (int c = 0; c < 3; ++c) + input.at(x, y, c) = 128.0 + 100.0 * std::sin(x * 0.15 + c) * std::cos(y * 0.1); + + ImageCodec haar(20.0, true, ImageCodec::ChromaSubsampling::CS_444, ImageCodec::TransformType::DWT_HAAR); + ImageCodec cdf(20.0, true, ImageCodec::ChromaSubsampling::CS_444, ImageCodec::TransformType::DWT_97); + const double psnrHaar = CodecAnalysis::computeMetrics(input, haar.process(input)).psnrY; + const double psnrCdf = CodecAnalysis::computeMetrics(input, cdf.process(input)).psnrY; + EXPECT_GT(psnrCdf, psnrHaar); +} + +TEST(ImageCodecTest, SubbandMapOnlyForWavelets) { + Image channel(40, 24, 1); + for (int y = 0; y < 24; ++y) + for (int x = 0; x < 40; ++x) + channel.at(x, y, 0) = (x * 7 + y * 3) % 256; + + ImageCodec dct(50.0); + EXPECT_TRUE(dct.subbandMap(channel).empty()); + + ImageCodec cdf(50.0, true, ImageCodec::ChromaSubsampling::CS_444, ImageCodec::TransformType::DWT_97); + Image map = cdf.subbandMap(channel); + ASSERT_EQ(map.width(), 40); + ASSERT_EQ(map.height(), 24); + for (size_t i = 0; i < map.size(); ++i) { + ASSERT_GE(map.data()[i], 0.0); + ASSERT_LE(map.data()[i], 255.0); + } +} + +TEST(ImageCodecTest, ChromaUpsamplingIsSmooth) { + // A horizontal chroma ramp should survive 4:2:2 without stair-steps: + // bilinear upsampling keeps neighbouring output columns distinct. + Image input(32, 8, 3); + for (int y = 0; y < 8; ++y) + for (int x = 0; x < 32; ++x) { + input.at(x, y, 0) = 128.0; + input.at(x, y, 1) = 128.0; + input.at(x, y, 2) = 64.0 + 4.0 * x; // red ramp → Cr ramp + } + ImageCodec codec(100.0, false, ImageCodec::ChromaSubsampling::CS_422); + Image output = codec.process(input); + int flatPairs = 0; + for (int x = 2; x < 30; x += 2) + if (std::abs(output.at(x, 4, 2) - output.at(x + 1, 4, 2)) < 0.5) ++flatPairs; + EXPECT_EQ(flatPairs, 0); +} diff --git a/core/tests/wavelet_test.cpp b/core/tests/wavelet_test.cpp index a9924a4..af6d1ef 100644 --- a/core/tests/wavelet_test.cpp +++ b/core/tests/wavelet_test.cpp @@ -1,6 +1,7 @@ #include #include "wavelet.h" #include +#include // For a constant block of value A, the 3-level 2D Haar DWT concentrates all // energy into dst[0][0] = 8*A, with all other coefficients zero. @@ -93,16 +94,18 @@ TEST(WaveletTest, RoundTripMixed) { } } -// Bit estimation: verify it returns expected relative values +// Bit estimation: an all-zero buffer is free; varied indices cost bits. TEST(WaveletTest, BitEstimation) { std::vector data(64 * 64, 0.0); - double bitsZero = dwtEstimateBits(data.data(), 64, 64); - - // Fill with some large values - for (size_t i = 0; i < data.size(); ++i) data[i] = 100.0; - double bitsHigh = dwtEstimateBits(data.data(), 64, 64); - - EXPECT_GT(bitsHigh, bitsZero); + double bitsZero = dwtEstimateBits(data.data(), 64, 64, 3); + EXPECT_NEAR(bitsZero, 0.0, 1e-9); + + for (size_t i = 0; i < data.size(); ++i) data[i] = static_cast(i % 7) - 3.0; + double bitsBusy = dwtEstimateBits(data.data(), 64, 64, 3); + + EXPECT_GT(bitsBusy, bitsZero); + // Bounded by log2(7) bits per coefficient. + EXPECT_LE(bitsBusy, 64 * 64 * std::log2(7.0) + 1e-6); } // Level calculation: verify scaling logic @@ -141,3 +144,47 @@ TEST(WaveletTest, ImageRoundTrip) { EXPECT_NEAR(data[i], src[i], 1e-9); } } + +// CDF 9/7 perfect reconstruction for even, odd and non-power-of-2 sizes. +TEST(WaveletTest, Cdf97ImageRoundTrip) { + const int sizes[][2] = {{16, 16}, {12, 10}, {64, 48}, {7, 9}}; + for (const auto& sz : sizes) { + const int w = sz[0], h = sz[1]; + std::vector src(w * h); + for (int i = 0; i < w * h; ++i) src[i] = std::sin(i * 0.37) * 100.0 + (i % 13); + + std::vector data = src; + const int levels = calcDwtLevels(w, h); + dwtImage(data.data(), w, h, levels, WaveletType::CDF97); + idwtImage(data.data(), w, h, levels, WaveletType::CDF97); + + for (int i = 0; i < w * h; ++i) + ASSERT_NEAR(data[i], src[i], 1e-9) << w << "x" << h << " at " << i; + } +} + +// A constant signal lands entirely in LL with the same √2-per-dimension gain +// as Haar, so quantization steps mean the same thing for both wavelets. +TEST(WaveletTest, Cdf97ConstantHasHaarCompatibleGain) { + const int w = 32, h = 32, levels = 3; + const double val = 10.0; + std::vector data(w * h, val); + dwtImage(data.data(), w, h, levels, WaveletType::CDF97); + + const int llW = w >> levels, llH = h >> levels; + for (int y = 0; y < h; ++y) + for (int x = 0; x < w; ++x) { + const double expected = (x < llW && y < llH) ? val * (1 << levels) : 0.0; + ASSERT_NEAR(data[y * w + x], expected, 1e-6) << x << "," << y; + } +} + +TEST(WaveletTest, SubbandIndexLayout) { + // 8×8, 2 levels: level-0 bands are the outer ring, level-1 bands the 4×4. + EXPECT_EQ(dwtSubbandIndex(0, 0, 8, 8, 2), 0); // LL + EXPECT_EQ(dwtSubbandIndex(5, 1, 8, 8, 2), 1); // level 0 HL (right) + EXPECT_EQ(dwtSubbandIndex(1, 5, 8, 8, 2), 2); // level 0 LH (bottom) + EXPECT_EQ(dwtSubbandIndex(7, 7, 8, 8, 2), 3); // level 0 HH + EXPECT_EQ(dwtSubbandIndex(2, 0, 8, 8, 2), 4); // level 1 HL + EXPECT_EQ(dwtSubbandIndex(3, 3, 8, 8, 2), 6); // level 1 HH +} diff --git a/web/cpp/codec_web.cpp b/web/cpp/codec_web.cpp index 0dd2140..8673083 100644 --- a/web/cpp/codec_web.cpp +++ b/web/cpp/codec_web.cpp @@ -15,10 +15,14 @@ struct CodecSession { Image originalImage; Image originalYCrCb; + Image processedBgr; // cached so view switches don't re-run colour conversion Image processedYCrCb; Image inspectionChannel; - Image inspectionDS; double lastBitEstimate = 0.0; + double rdBitEstimate = 0.0; + int quality = 50; + int csMode = 444; + int transformMode = 0; CodecMetrics metrics; bool initialized = false; bool useTint = true; @@ -40,7 +44,8 @@ enum ViewMode { Cr = 3, Cb = 4, EdgeDistortion = 5, - BlockingMap = 6 + BlockingMap = 6, + Subbands = 7 }; static ImageCodec::ChromaSubsampling map_cs_mode(int mode) { @@ -53,7 +58,21 @@ static ImageCodec::ChromaSubsampling map_cs_mode(int mode) { } static ImageCodec::TransformType map_transform_mode(int mode) { - return (mode == 1) ? ImageCodec::TransformType::DWT : ImageCodec::TransformType::DCT; + switch (mode) { + case 1: return ImageCodec::TransformType::DWT_HAAR; + case 2: return ImageCodec::TransformType::DWT_97; + default: return ImageCodec::TransformType::DCT; + } +} + +// Copies one channel (0=Y, 1=Cr, 2=Cb) out of an interleaved YCrCb image. +static Image extractChannel(const Image& ycrcb, int offset) { + Image channel(ycrcb.width(), ycrcb.height(), 1); + const double* src = ycrcb.data(); + double* dst = channel.data(); + const size_t numPixels = static_cast(ycrcb.width()) * ycrcb.height(); + for (size_t i = 0; i < numPixels; ++i) dst[i] = src[i * 3 + offset]; + return channel; } extern "C" { @@ -83,10 +102,32 @@ void process_image(int quality, int cs_mode, int transform_mode) { auto cs = map_cs_mode(cs_mode); auto transform = map_transform_mode(transform_mode); ImageCodec codec(quality, true, cs, transform); - Image processedBgr = codec.process(g_session.originalImage); + g_session.processedBgr = codec.process(g_session.originalImage); g_session.lastBitEstimate = codec.getLastBitEstimate(); - g_session.metrics = CodecAnalysis::computeMetrics(g_session.originalImage, processedBgr); - g_session.processedYCrCb = bgrToYCrCb(processedBgr); + g_session.metrics = CodecAnalysis::computeMetrics(g_session.originalImage, g_session.processedBgr); + g_session.processedYCrCb = bgrToYCrCb(g_session.processedBgr); + g_session.quality = quality; + g_session.csMode = cs_mode; + g_session.transformMode = transform_mode; +} + +// Lightweight encode for rate–distortion sweeps: returns luma PSNR and records +// the bit estimate (read via get_rd_bit_estimate) without touching the +// session's processed image or computing SSIM. +EMSCRIPTEN_KEEPALIVE +double rd_encode(int quality, int cs_mode, int transform_mode) { + if (!g_session.initialized) return 0.0; + + ImageCodec codec(quality, true, map_cs_mode(cs_mode), map_transform_mode(transform_mode)); + Image processedBgr = codec.process(g_session.originalImage); + g_session.rdBitEstimate = codec.getLastBitEstimate(); + return CodecAnalysis::computePSNR(extractChannel(g_session.originalYCrCb, 0), + extractChannel(bgrToYCrCb(processedBgr), 0)); +} + +EMSCRIPTEN_KEEPALIVE +double get_rd_bit_estimate() { + return g_session.initialized ? g_session.rdBitEstimate : 0.0; } EMSCRIPTEN_KEEPALIVE @@ -105,32 +146,37 @@ uint8_t* get_view_ptr(int mode) { switch (static_cast(mode)) { case RGB: - viewImage = ycrcbToBgr(g_session.processedYCrCb); + viewImage = g_session.processedBgr; break; case Artifacts: viewImage = CodecAnalysis::computeArtifactMap( g_session.originalImage, - ycrcbToBgr(g_session.processedYCrCb), + g_session.processedBgr, g_artifact_gain ); break; case EdgeDistortion: - viewImage = CodecAnalysis::computeEdgeDistortionMap(g_session.originalImage, ycrcbToBgr(g_session.processedYCrCb)); + viewImage = CodecAnalysis::computeEdgeDistortionMap(g_session.originalImage, g_session.processedBgr); break; case BlockingMap: - viewImage = CodecAnalysis::computeBlockingMap(ycrcbToBgr(g_session.processedYCrCb)); + viewImage = CodecAnalysis::computeBlockingMap(g_session.processedBgr); + break; + case Subbands: { + // Wavelet pyramid of the original luma at the current settings. + // DCT has no subband pyramid, so fall back to the reconstructed image. + ImageCodec codec(g_session.quality, true, map_cs_mode(g_session.csMode), + map_transform_mode(g_session.transformMode)); + viewImage = codec.isWavelet() + ? codec.subbandMap(extractChannel(g_session.originalYCrCb, 0)) + : g_session.processedBgr; break; + } case Y: case Cr: case Cb: { - Image channel(width, height, 1); - const double* ycrcbData = g_session.processedYCrCb.data(); - double* channelData = channel.data(); - int offset = (mode == Y) ? 0 : (mode == Cr ? 1 : 2); - - for (size_t i = 0; i < numPixels; ++i) { - channelData[i] = ycrcbData[i * 3 + offset]; - } + const int offset = (mode == Y) ? 0 : (mode == Cr ? 1 : 2); + Image channel = extractChannel(g_session.processedYCrCb, offset); + const double* channelData = channel.data(); // Convert grayscale channel to 3-channel BGR for display Image bgrChannel(width, height, 3); @@ -287,81 +333,14 @@ double* get_coeff_histogram(int num_bins, double max_val) { return out; } -// Re-declaring to match the plan's arguments -// Helper to downsample a channel (simple averaging) -// Helper to downsample a channel (simple averaging) -void downsample_channel(const Image& src, Image& dst, ImageCodec::ChromaSubsampling cs) { - if (cs == ImageCodec::ChromaSubsampling::CS_444) { - // Just copy src to dst if 4:4:4 (shouldn't really happen with logic below, but safe fallback) - dst = src; - return; - } - - int w = src.width(); - int h = src.height(); - int scaleX = (cs == ImageCodec::ChromaSubsampling::CS_444) ? 1 : 2; - int scaleY = (cs == ImageCodec::ChromaSubsampling::CS_420) ? 2 : 1; - - int newW = (w + scaleX - 1) / scaleX; - int newH = (h + scaleY - 1) / scaleY; - - // Resize dst only if dimensions differ - if (dst.width() != newW || dst.height() != newH || dst.channels() != 1) { - dst = Image(newW, newH, 1); - } - - // We can't easily reuse the internal vector without a resize method in Image that preserves capacity, - // but assignment operator with new Image(w,h) will reallocate. - // However, since we defined 'dst' in g_session, we want to avoid reallocation if possible. - // The Image class in Image.h doesn't have a 'resize' method that keeps capacity. - // Let's assume for now assignment is better than creating a LOCAL Image that dies immediately. - // Ideally Image class should have a 'resize' or 'reshape'. - // Use the naive assignment for now, it is still better than stack trashing if the compiler optimizes. - // user: "Image class uses vector, so we could add a resize method?" - // For now, let's just stick to the plan. Even member assignment avoids stack variable destroy/create overhead. - - for (int y = 0; y < newH; ++y) { - for (int x = 0; x < newW; ++x) { - double sum = 0.0; - int count = 0; - int startX = x * scaleX; - int startY = y * scaleY; - - for (int dy = 0; dy < scaleY && (startY + dy) < h; ++dy) { - for (int dx = 0; dx < scaleX && (startX + dx) < w; ++dx) { - sum += src.at(startX + dx, startY + dy, 0); - count++; - } - } - dst.at(x, y, 0) = count > 0 ? sum / count : 0.0; - } - } -} - EMSCRIPTEN_KEEPALIVE double* inspect_block_data(int blockX, int blockY, int channelIndex, int quality, int cs_mode, int transform_mode) { if (!g_session.initialized) return nullptr; - // 1. Extract the specific channel from cached YCrCb - const Image& ycrcb = g_session.originalYCrCb; - - // Reuse session buffer - Image& channel = g_session.inspectionChannel; - if (channel.width() != ycrcb.width() || channel.height() != ycrcb.height() || channel.channels() != 1) { - channel = Image(ycrcb.width(), ycrcb.height(), 1); - } - - const double* src = ycrcb.data(); - double* dst = channel.data(); - int offset = (channelIndex == 0) ? 0 : (channelIndex == 1 ? 1 : 2); // Y=0, Cr=1, Cb=2 - - const size_t numPixels = static_cast(ycrcb.width()) * ycrcb.height(); - - // Optimization: Unroll or memcpy if possible? - // Strided copy, can't memcpy. But this simple loop is fast. - for(size_t i=0; i < numPixels; ++i) { - dst[i] = src[i*3 + offset]; - } + // 1. Extract the specific channel from cached YCrCb (Y=0, Cr=1, Cb=2) + const int offset = (channelIndex == 0) ? 0 : (channelIndex == 1 ? 1 : 2); + g_session.inspectionChannel = extractChannel(g_session.originalYCrCb, offset); + const Image& channel = g_session.inspectionChannel; bool isChroma = (channelIndex != 0); auto cs = map_cs_mode(cs_mode); @@ -372,10 +351,10 @@ double* inspect_block_data(int blockX, int blockY, int channelIndex, int quality int targetBx = blockX; int targetBy = blockY; + Image downsampled; if (isChroma && cs != ImageCodec::ChromaSubsampling::CS_444) { - // Reuse session buffer for downsampling - downsample_channel(channel, g_session.inspectionDS, cs); - blockSourcePtr = &g_session.inspectionDS; + downsampled = ImageCodec::downsampleChannel(channel, cs); + blockSourcePtr = &downsampled; // Map 8x8 block coords in original space to 8x8 block coords in downsampled space // 4:2:0 -> 2x2 original blocks = 1 chroma block diff --git a/web/public/codec.js b/web/public/codec.js index 3db253d..85a8c67 100644 --- a/web/public/codec.js +++ b/web/public/codec.js @@ -1 +1 @@ -var Module=typeof Module!="undefined"?Module:{};var ENVIRONMENT_IS_WEB=!!globalThis.window;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var ENVIRONMENT_IS_NODE=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var _scriptName=globalThis.document?.currentScript?.src;if(typeof __filename!="undefined"){_scriptName=__filename}else if(ENVIRONMENT_IS_WORKER){_scriptName=self.location.href}var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("node:fs");scriptDirectory=__dirname+"/";readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);if(typeof module!="undefined"){module["exports"]=Module}quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=async url=>{if(isFileURI(url)){return new Promise((resolve,reject)=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=()=>{if(xhr.status==200||xhr.status==0&&xhr.response){resolve(xhr.response);return}reject(xhr.status)};xhr.onerror=reject;xhr.send(null)})}var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var isFileURI=filename=>filename.startsWith("file://");var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;var HEAP64,HEAPU64;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b);HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports["e"]()}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);throw e}var wasmBinaryFile;function findWasmBinary(){return locateFile("codec.wasm")}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&!isFileURI(binaryFile)&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){var imports={a:wasmImports};return imports}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;assignWasmExports(wasmExports);updateMemoryViews();removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(inst,mod)=>{resolve(receiveInstance(inst,mod))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);var runDependencies=0;var dependenciesFulfilled=null;var removeRunDependency=id=>{runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}};var addRunDependency=id=>{runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)};var noExitRuntime=true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var exceptionLast=0;var uncaughtExceptionCount=0;var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast};var __abort_js=()=>abort("");var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=globalThis.TextDecoder&&new TextDecoder;var findStringEnd=(heapOrArray,idx,maxBytesToRead,ignoreNul)=>{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;var _init_session,_process_image,_get_view_ptr,_malloc,_set_view_tint,_set_artifact_gain,_get_psnr_y,_get_psnr_cr,_get_psnr_cb,_get_ssim_y,_get_ssim_cr,_get_ssim_cb,_get_last_bit_estimate,_get_coeff_histogram,_inspect_block_data,_init_me_session,_run_motion_estimation,_get_mv_count,_get_me_residual_ptr,_get_search_steps,_get_search_step_count,_free,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current,memory,__indirect_function_table,wasmMemory;function assignWasmExports(wasmExports){_init_session=Module["_init_session"]=wasmExports["f"];_process_image=Module["_process_image"]=wasmExports["g"];_get_view_ptr=Module["_get_view_ptr"]=wasmExports["h"];_malloc=Module["_malloc"]=wasmExports["i"];_set_view_tint=Module["_set_view_tint"]=wasmExports["j"];_set_artifact_gain=Module["_set_artifact_gain"]=wasmExports["k"];_get_psnr_y=Module["_get_psnr_y"]=wasmExports["l"];_get_psnr_cr=Module["_get_psnr_cr"]=wasmExports["m"];_get_psnr_cb=Module["_get_psnr_cb"]=wasmExports["n"];_get_ssim_y=Module["_get_ssim_y"]=wasmExports["o"];_get_ssim_cr=Module["_get_ssim_cr"]=wasmExports["p"];_get_ssim_cb=Module["_get_ssim_cb"]=wasmExports["q"];_get_last_bit_estimate=Module["_get_last_bit_estimate"]=wasmExports["r"];_get_coeff_histogram=Module["_get_coeff_histogram"]=wasmExports["s"];_inspect_block_data=Module["_inspect_block_data"]=wasmExports["t"];_init_me_session=Module["_init_me_session"]=wasmExports["u"];_run_motion_estimation=Module["_run_motion_estimation"]=wasmExports["v"];_get_mv_count=Module["_get_mv_count"]=wasmExports["w"];_get_me_residual_ptr=Module["_get_me_residual_ptr"]=wasmExports["x"];_get_search_steps=Module["_get_search_steps"]=wasmExports["y"];_get_search_step_count=Module["_get_search_step_count"]=wasmExports["z"];_free=Module["_free"]=wasmExports["A"];__emscripten_stack_restore=wasmExports["B"];__emscripten_stack_alloc=wasmExports["C"];_emscripten_stack_get_current=wasmExports["D"];memory=wasmMemory=wasmExports["d"];__indirect_function_table=wasmExports["__indirect_function_table"]}var wasmImports={a:___cxa_throw,b:__abort_js,c:_emscripten_resize_heap};function run(){if(runDependencies>0){dependenciesFulfilled=run;return}preRun();if(runDependencies>0){dependenciesFulfilled=run;return}function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}var wasmExports;createWasm();run(); +var Module=typeof Module!="undefined"?Module:{};var ENVIRONMENT_IS_WEB=!!globalThis.window;var ENVIRONMENT_IS_WORKER=!!globalThis.WorkerGlobalScope;var ENVIRONMENT_IS_NODE=globalThis.process?.versions?.node&&globalThis.process?.type!="renderer";var arguments_=[];var thisProgram="./this.program";var quit_=(status,toThrow)=>{throw toThrow};var _scriptName=globalThis.document?.currentScript?.src;if(typeof __filename!="undefined"){_scriptName=__filename}else if(ENVIRONMENT_IS_WORKER){_scriptName=self.location.href}var scriptDirectory="";function locateFile(path){if(Module["locateFile"]){return Module["locateFile"](path,scriptDirectory)}return scriptDirectory+path}var readAsync,readBinary;if(ENVIRONMENT_IS_NODE){var fs=require("node:fs");scriptDirectory=__dirname+"/";readBinary=filename=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename);return ret};readAsync=async(filename,binary=true)=>{filename=isFileURI(filename)?new URL(filename):filename;var ret=fs.readFileSync(filename,binary?undefined:"utf8");return ret};if(process.argv.length>1){thisProgram=process.argv[1].replace(/\\/g,"/")}arguments_=process.argv.slice(2);if(typeof module!="undefined"){module["exports"]=Module}quit_=(status,toThrow)=>{process.exitCode=status;throw toThrow}}else if(ENVIRONMENT_IS_WEB||ENVIRONMENT_IS_WORKER){try{scriptDirectory=new URL(".",_scriptName).href}catch{}{if(ENVIRONMENT_IS_WORKER){readBinary=url=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,false);xhr.responseType="arraybuffer";xhr.send(null);return new Uint8Array(xhr.response)}}readAsync=async url=>{if(isFileURI(url)){return new Promise((resolve,reject)=>{var xhr=new XMLHttpRequest;xhr.open("GET",url,true);xhr.responseType="arraybuffer";xhr.onload=()=>{if(xhr.status==200||xhr.status==0&&xhr.response){resolve(xhr.response);return}reject(xhr.status)};xhr.onerror=reject;xhr.send(null)})}var response=await fetch(url,{credentials:"same-origin"});if(response.ok){return response.arrayBuffer()}throw new Error(response.status+" : "+response.url)}}}else{}var out=console.log.bind(console);var err=console.error.bind(console);var wasmBinary;var ABORT=false;var isFileURI=filename=>filename.startsWith("file://");var HEAP8,HEAPU8,HEAP16,HEAPU16,HEAP32,HEAPU32,HEAPF32,HEAPF64;var HEAP64,HEAPU64;var runtimeInitialized=false;function updateMemoryViews(){var b=wasmMemory.buffer;HEAP8=new Int8Array(b);HEAP16=new Int16Array(b);Module["HEAPU8"]=HEAPU8=new Uint8Array(b);HEAPU16=new Uint16Array(b);HEAP32=new Int32Array(b);HEAPU32=new Uint32Array(b);HEAPF32=new Float32Array(b);HEAPF64=new Float64Array(b);HEAP64=new BigInt64Array(b);HEAPU64=new BigUint64Array(b)}function preRun(){if(Module["preRun"]){if(typeof Module["preRun"]=="function")Module["preRun"]=[Module["preRun"]];while(Module["preRun"].length){addOnPreRun(Module["preRun"].shift())}}callRuntimeCallbacks(onPreRuns)}function initRuntime(){runtimeInitialized=true;wasmExports["e"]()}function postRun(){if(Module["postRun"]){if(typeof Module["postRun"]=="function")Module["postRun"]=[Module["postRun"]];while(Module["postRun"].length){addOnPostRun(Module["postRun"].shift())}}callRuntimeCallbacks(onPostRuns)}function abort(what){Module["onAbort"]?.(what);what="Aborted("+what+")";err(what);ABORT=true;what+=". Build with -sASSERTIONS for more info.";var e=new WebAssembly.RuntimeError(what);throw e}var wasmBinaryFile;function findWasmBinary(){return locateFile("codec.wasm")}function getBinarySync(file){if(file==wasmBinaryFile&&wasmBinary){return new Uint8Array(wasmBinary)}if(readBinary){return readBinary(file)}throw"both async and sync fetching of the wasm failed"}async function getWasmBinary(binaryFile){if(!wasmBinary){try{var response=await readAsync(binaryFile);return new Uint8Array(response)}catch{}}return getBinarySync(binaryFile)}async function instantiateArrayBuffer(binaryFile,imports){try{var binary=await getWasmBinary(binaryFile);var instance=await WebAssembly.instantiate(binary,imports);return instance}catch(reason){err(`failed to asynchronously prepare wasm: ${reason}`);abort(reason)}}async function instantiateAsync(binary,binaryFile,imports){if(!binary&&!isFileURI(binaryFile)&&!ENVIRONMENT_IS_NODE){try{var response=fetch(binaryFile,{credentials:"same-origin"});var instantiationResult=await WebAssembly.instantiateStreaming(response,imports);return instantiationResult}catch(reason){err(`wasm streaming compile failed: ${reason}`);err("falling back to ArrayBuffer instantiation")}}return instantiateArrayBuffer(binaryFile,imports)}function getWasmImports(){var imports={a:wasmImports};return imports}async function createWasm(){function receiveInstance(instance,module){wasmExports=instance.exports;assignWasmExports(wasmExports);updateMemoryViews();removeRunDependency("wasm-instantiate");return wasmExports}addRunDependency("wasm-instantiate");function receiveInstantiationResult(result){return receiveInstance(result["instance"])}var info=getWasmImports();if(Module["instantiateWasm"]){return new Promise((resolve,reject)=>{Module["instantiateWasm"](info,(inst,mod)=>{resolve(receiveInstance(inst,mod))})})}wasmBinaryFile??=findWasmBinary();var result=await instantiateAsync(wasmBinary,wasmBinaryFile,info);var exports=receiveInstantiationResult(result);return exports}class ExitStatus{name="ExitStatus";constructor(status){this.message=`Program terminated with exit(${status})`;this.status=status}}var callRuntimeCallbacks=callbacks=>{while(callbacks.length>0){callbacks.shift()(Module)}};var onPostRuns=[];var addOnPostRun=cb=>onPostRuns.push(cb);var onPreRuns=[];var addOnPreRun=cb=>onPreRuns.push(cb);var runDependencies=0;var dependenciesFulfilled=null;var removeRunDependency=id=>{runDependencies--;Module["monitorRunDependencies"]?.(runDependencies);if(runDependencies==0){if(dependenciesFulfilled){var callback=dependenciesFulfilled;dependenciesFulfilled=null;callback()}}};var addRunDependency=id=>{runDependencies++;Module["monitorRunDependencies"]?.(runDependencies)};var noExitRuntime=true;var stackRestore=val=>__emscripten_stack_restore(val);var stackSave=()=>_emscripten_stack_get_current();class ExceptionInfo{constructor(excPtr){this.excPtr=excPtr;this.ptr=excPtr-24}set_type(type){HEAPU32[this.ptr+4>>2]=type}get_type(){return HEAPU32[this.ptr+4>>2]}set_destructor(destructor){HEAPU32[this.ptr+8>>2]=destructor}get_destructor(){return HEAPU32[this.ptr+8>>2]}set_caught(caught){caught=caught?1:0;HEAP8[this.ptr+12]=caught}get_caught(){return HEAP8[this.ptr+12]!=0}set_rethrown(rethrown){rethrown=rethrown?1:0;HEAP8[this.ptr+13]=rethrown}get_rethrown(){return HEAP8[this.ptr+13]!=0}init(type,destructor){this.set_adjusted_ptr(0);this.set_type(type);this.set_destructor(destructor)}set_adjusted_ptr(adjustedPtr){HEAPU32[this.ptr+16>>2]=adjustedPtr}get_adjusted_ptr(){return HEAPU32[this.ptr+16>>2]}}var exceptionLast=0;var uncaughtExceptionCount=0;var ___cxa_throw=(ptr,type,destructor)=>{var info=new ExceptionInfo(ptr);info.init(type,destructor);exceptionLast=ptr;uncaughtExceptionCount++;throw exceptionLast};var __abort_js=()=>abort("");var getHeapMax=()=>2147483648;var alignMemory=(size,alignment)=>Math.ceil(size/alignment)*alignment;var growMemory=size=>{var oldHeapSize=wasmMemory.buffer.byteLength;var pages=(size-oldHeapSize+65535)/65536|0;try{wasmMemory.grow(pages);updateMemoryViews();return 1}catch(e){}};var _emscripten_resize_heap=requestedSize=>{var oldSize=HEAPU8.length;requestedSize>>>=0;var maxHeapSize=getHeapMax();if(requestedSize>maxHeapSize){return false}for(var cutDown=1;cutDown<=4;cutDown*=2){var overGrownHeapSize=oldSize*(1+.2/cutDown);overGrownHeapSize=Math.min(overGrownHeapSize,requestedSize+100663296);var newSize=Math.min(maxHeapSize,alignMemory(Math.max(requestedSize,overGrownHeapSize),65536));var replacement=growMemory(newSize);if(replacement){return true}}return false};var getCFunc=ident=>{var func=Module["_"+ident];return func};var writeArrayToMemory=(array,buffer)=>{HEAP8.set(array,buffer)};var lengthBytesUTF8=str=>{var len=0;for(var i=0;i=55296&&c<=57343){len+=4;++i}else{len+=3}}return len};var stringToUTF8Array=(str,heap,outIdx,maxBytesToWrite)=>{if(!(maxBytesToWrite>0))return 0;var startIdx=outIdx;var endIdx=outIdx+maxBytesToWrite-1;for(var i=0;i=endIdx)break;heap[outIdx++]=u}else if(u<=2047){if(outIdx+1>=endIdx)break;heap[outIdx++]=192|u>>6;heap[outIdx++]=128|u&63}else if(u<=65535){if(outIdx+2>=endIdx)break;heap[outIdx++]=224|u>>12;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63}else{if(outIdx+3>=endIdx)break;heap[outIdx++]=240|u>>18;heap[outIdx++]=128|u>>12&63;heap[outIdx++]=128|u>>6&63;heap[outIdx++]=128|u&63;i++}}heap[outIdx]=0;return outIdx-startIdx};var stringToUTF8=(str,outPtr,maxBytesToWrite)=>stringToUTF8Array(str,HEAPU8,outPtr,maxBytesToWrite);var stackAlloc=sz=>__emscripten_stack_alloc(sz);var stringToUTF8OnStack=str=>{var size=lengthBytesUTF8(str)+1;var ret=stackAlloc(size);stringToUTF8(str,ret,size);return ret};var UTF8Decoder=globalThis.TextDecoder&&new TextDecoder;var findStringEnd=(heapOrArray,idx,maxBytesToRead,ignoreNul)=>{var maxIdx=idx+maxBytesToRead;if(ignoreNul)return maxIdx;while(heapOrArray[idx]&&!(idx>=maxIdx))++idx;return idx};var UTF8ArrayToString=(heapOrArray,idx=0,maxBytesToRead,ignoreNul)=>{var endPtr=findStringEnd(heapOrArray,idx,maxBytesToRead,ignoreNul);if(endPtr-idx>16&&heapOrArray.buffer&&UTF8Decoder){return UTF8Decoder.decode(heapOrArray.subarray(idx,endPtr))}var str="";while(idx>10,56320|ch&1023)}}return str};var UTF8ToString=(ptr,maxBytesToRead,ignoreNul)=>ptr?UTF8ArrayToString(HEAPU8,ptr,maxBytesToRead,ignoreNul):"";var ccall=(ident,returnType,argTypes,args,opts)=>{var toC={string:str=>{var ret=0;if(str!==null&&str!==undefined&&str!==0){ret=stringToUTF8OnStack(str)}return ret},array:arr=>{var ret=stackAlloc(arr.length);writeArrayToMemory(arr,ret);return ret}};function convertReturnValue(ret){if(returnType==="string"){return UTF8ToString(ret)}if(returnType==="boolean")return Boolean(ret);return ret}var func=getCFunc(ident);var cArgs=[];var stack=0;if(args){for(var i=0;i{var numericArgs=!argTypes||argTypes.every(type=>type==="number"||type==="boolean");var numericRet=returnType!=="string";if(numericRet&&numericArgs&&!opts){return getCFunc(ident)}return(...args)=>ccall(ident,returnType,argTypes,args,opts)};{if(Module["noExitRuntime"])noExitRuntime=Module["noExitRuntime"];if(Module["print"])out=Module["print"];if(Module["printErr"])err=Module["printErr"];if(Module["wasmBinary"])wasmBinary=Module["wasmBinary"];if(Module["arguments"])arguments_=Module["arguments"];if(Module["thisProgram"])thisProgram=Module["thisProgram"];if(Module["preInit"]){if(typeof Module["preInit"]=="function")Module["preInit"]=[Module["preInit"]];while(Module["preInit"].length>0){Module["preInit"].shift()()}}}Module["ccall"]=ccall;Module["cwrap"]=cwrap;var _init_session,_process_image,_rd_encode,_get_rd_bit_estimate,_get_view_ptr,_malloc,_set_view_tint,_set_artifact_gain,_get_psnr_y,_get_psnr_cr,_get_psnr_cb,_get_ssim_y,_get_ssim_cr,_get_ssim_cb,_get_last_bit_estimate,_get_coeff_histogram,_inspect_block_data,_init_me_session,_run_motion_estimation,_get_mv_count,_get_me_residual_ptr,_get_search_steps,_get_search_step_count,_free,__emscripten_stack_restore,__emscripten_stack_alloc,_emscripten_stack_get_current,memory,__indirect_function_table,wasmMemory;function assignWasmExports(wasmExports){_init_session=Module["_init_session"]=wasmExports["f"];_process_image=Module["_process_image"]=wasmExports["g"];_rd_encode=Module["_rd_encode"]=wasmExports["h"];_get_rd_bit_estimate=Module["_get_rd_bit_estimate"]=wasmExports["i"];_get_view_ptr=Module["_get_view_ptr"]=wasmExports["j"];_malloc=Module["_malloc"]=wasmExports["k"];_set_view_tint=Module["_set_view_tint"]=wasmExports["l"];_set_artifact_gain=Module["_set_artifact_gain"]=wasmExports["m"];_get_psnr_y=Module["_get_psnr_y"]=wasmExports["n"];_get_psnr_cr=Module["_get_psnr_cr"]=wasmExports["o"];_get_psnr_cb=Module["_get_psnr_cb"]=wasmExports["p"];_get_ssim_y=Module["_get_ssim_y"]=wasmExports["q"];_get_ssim_cr=Module["_get_ssim_cr"]=wasmExports["r"];_get_ssim_cb=Module["_get_ssim_cb"]=wasmExports["s"];_get_last_bit_estimate=Module["_get_last_bit_estimate"]=wasmExports["t"];_get_coeff_histogram=Module["_get_coeff_histogram"]=wasmExports["u"];_inspect_block_data=Module["_inspect_block_data"]=wasmExports["v"];_init_me_session=Module["_init_me_session"]=wasmExports["w"];_run_motion_estimation=Module["_run_motion_estimation"]=wasmExports["x"];_get_mv_count=Module["_get_mv_count"]=wasmExports["y"];_get_me_residual_ptr=Module["_get_me_residual_ptr"]=wasmExports["z"];_get_search_steps=Module["_get_search_steps"]=wasmExports["A"];_get_search_step_count=Module["_get_search_step_count"]=wasmExports["B"];_free=Module["_free"]=wasmExports["C"];__emscripten_stack_restore=wasmExports["D"];__emscripten_stack_alloc=wasmExports["E"];_emscripten_stack_get_current=wasmExports["F"];memory=wasmMemory=wasmExports["d"];__indirect_function_table=wasmExports["__indirect_function_table"]}var wasmImports={a:___cxa_throw,b:__abort_js,c:_emscripten_resize_heap};function run(){if(runDependencies>0){dependenciesFulfilled=run;return}preRun();if(runDependencies>0){dependenciesFulfilled=run;return}function doRun(){Module["calledRun"]=true;if(ABORT)return;initRuntime();Module["onRuntimeInitialized"]?.();postRun()}if(Module["setStatus"]){Module["setStatus"]("Running...");setTimeout(()=>{setTimeout(()=>Module["setStatus"](""),1);doRun()},1)}else{doRun()}}var wasmExports;createWasm();run(); diff --git a/web/public/codec.wasm b/web/public/codec.wasm index 7049a791923d2e064c3e17c6f16b4906653105ba..b4b5b01b99b198b3547268854cc9292d98ca1418 100755 GIT binary patch literal 81325 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

5b5gi753{a}}(fXbWGQV-K-J+7j5M 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 D$gO(s delta 27445 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 Date: Fri, 9 Oct 2026 22:22:39 +0530 Subject: [PATCH 2/4] feat(web): three transforms, subbands view, shareable URLs, PNG export - Transform selector offers DCT / Haar / CDF 9/7 everywhere (transforms.ts is the single registry: labels, URL slugs, chart colours). - RD chart plots one curve per transform via rd_encode, so sampling no longer re-encodes and re-renders the displayed image afterwards. - Subbands view for wavelets, shown full-frame rather than behind the comparison split. The DCT-only block inspector is swapped for a "Wavelet Subbands" tool in wavelet mode instead of showing zeros; the inspector's broken DWT toggle is replaced with an explanation. - Settings sync to the URL hash (#q=30&t=cdf97&cs=420&v=subbands&img=3) and restore on load, including the bundled test image. Loading an image keeps the current quality instead of resetting to 50. - Download PNG of the processed view; [ / ] change quality by 5, T cycles the transform. - Tests: url-state, transforms, rdEncode unit tests; real-WASM browser test of all three transforms, rd_encode isolation and Subbands output. Co-Authored-By: Claude Opus 5.5 --- web/src/App.svelte | 4 + web/src/ArtifactInspectorMode.svelte | 22 ++- web/src/InspectorMode.svelte | 16 +- web/src/ViewerMode.svelte | 173 ++++++++++++------ .../components/viewer/CodecControls.svelte | 41 +++-- web/src/lib/components/viewer/RdChart.svelte | 133 +++++++------- .../lib/components/viewer/ToolsPanel.svelte | 40 +++- web/src/lib/state.svelte.ts | 12 +- web/src/lib/transforms.ts | 37 ++++ web/src/lib/url-state.ts | 82 +++++++++ web/src/lib/wasm-bridge.ts | 8 + web/src/styles/components.css | 23 +++ web/src/styles/variables.css | 8 + web/src/styles/viewer.css | 20 -- web/src/types/wasm.d.ts | 2 + web/tests/browser/codec.browser.test.js | 108 +++++++++++ web/tests/setup/unit.setup.js | 2 + web/tests/unit/state.test.js | 8 +- web/tests/unit/transforms.test.js | 31 ++++ web/tests/unit/url-state.test.js | 76 ++++++++ web/tests/unit/wasm-bridge.test.js | 17 ++ web/tests/unit/wasm-exports.test.js | 9 +- 22 files changed, 682 insertions(+), 190 deletions(-) create mode 100644 web/src/lib/transforms.ts create mode 100644 web/src/lib/url-state.ts create mode 100644 web/tests/browser/codec.browser.test.js create mode 100644 web/tests/unit/transforms.test.js create mode 100644 web/tests/unit/url-state.test.js diff --git a/web/src/App.svelte b/web/src/App.svelte index 8de2efe..84ca3a5 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -4,6 +4,10 @@ import ViewerMode from './ViewerMode.svelte'; import InspectorMode from './InspectorMode.svelte'; import ArtifactInspectorMode from './ArtifactInspectorMode.svelte'; + import { restoreFromUrl } from './lib/url-state.js'; + + // Restore codec settings from a shared link before any mode mounts. + restoreFromUrl(); // WASM init setupWasm(() => { diff --git a/web/src/ArtifactInspectorMode.svelte b/web/src/ArtifactInspectorMode.svelte index 3816d5f..fec9b3b 100644 --- a/web/src/ArtifactInspectorMode.svelte +++ b/web/src/ArtifactInspectorMode.svelte @@ -2,6 +2,7 @@ import { onMount, onDestroy } from 'svelte'; import { fade } from 'svelte/transition'; import { appState, ViewMode } from './lib/state.svelte.js'; + import { TRANSFORMS, isWavelet } from './lib/transforms.js'; import { processImage, getViewPtr, getStats, free, setArtifactGain } from './lib/wasm-bridge.js'; import ImageViewer from './lib/components/ImageViewer.svelte'; @@ -46,6 +47,12 @@ { q: 90, label: 'High', desc: 'Print / Archive' }, ]; + // Wavelets have no 8×8 grid, so the blocking map should stay dark — say so. + const artifactModes = $derived(ARTIFACT_MODES.map(m => + m.val === ViewMode.BlockingMap && isWavelet(appState.transformType) + ? { ...m, desc: 'Detects discontinuities at 8×8 block edges. Wavelets transform the whole image at once, so this map should stay nearly dark — compare it with DCT.' } + : m)); + const currentMode = $derived( ARTIFACT_MODES.find(m => m.val === appState.currentViewMode) ?? ARTIFACT_MODES[0] ); @@ -319,7 +326,7 @@ Artifact Analyzer

- {#each ARTIFACT_MODES as mode} + {#each artifactModes as mode}
diff --git a/web/src/ViewerMode.svelte b/web/src/ViewerMode.svelte index 3d86773..3c959f4 100644 --- a/web/src/ViewerMode.svelte +++ b/web/src/ViewerMode.svelte @@ -1,7 +1,9 @@ @@ -579,7 +644,7 @@ isInspectMode={appState.isInspectMode} bind:highlightBlock={appState.highlightBlock} showInspectorBanner={true} - showQuickstart={true} + showQuickstart={!isWavelet(appState.transformType)} zoomHint="Click image to zoom in  ·  drag to use comparison slider  ·  Esc to exit" onViewerMouseDown={onViewerMouseDown} onViewerClick={onViewerClick} @@ -589,7 +654,7 @@ - void rebuildRdCurve()} /> @@ -600,7 +665,7 @@
Basic
-
@@ -608,7 +673,7 @@ - + diff --git a/web/src/lib/components/viewer/CodecControls.svelte b/web/src/lib/components/viewer/CodecControls.svelte index 7781e29..8b2c72d 100644 --- a/web/src/lib/components/viewer/CodecControls.svelte +++ b/web/src/lib/components/viewer/CodecControls.svelte @@ -1,5 +1,18 @@
@@ -13,6 +17,22 @@
Tools
+ {#if wavelet} + + + {:else} + {/if} + + {#if wavelet} +

The block inspector is specific to the 8×8 DCT. Wavelets have no blocks — explore their subband pyramid instead.

+ {:else}

Dive deep into compression artifacts, error heatmaps, and edge distortion analysis.

+ {/if} +

[ ] quality ±5 · T cycle transform

diff --git a/web/src/lib/state.svelte.ts b/web/src/lib/state.svelte.ts index 8f5c683..6dc91e1 100644 --- a/web/src/lib/state.svelte.ts +++ b/web/src/lib/state.svelte.ts @@ -1,3 +1,5 @@ +import { Transform, type TransformValue } from './transforms.js'; + export const ViewMode = { RGB: 0, Artifacts: 1, @@ -5,7 +7,8 @@ export const ViewMode = { Cr: 3, Cb: 4, EdgeDistortion: 5, - BlockingMap: 6 + BlockingMap: 6, + Subbands: 7 } as const; export type ViewModeValue = typeof ViewMode[keyof typeof ViewMode]; @@ -45,8 +48,11 @@ class AppState { // Shared quality (synced between viewer and inspector sliders) quality = $state(50); - // Transform type: 0 = DCT, 1 = DWT (Haar wavelet) - transformType = $state(0); + // Transform type: see Transform in transforms.ts (DCT, Haar, CDF 9/7) + transformType = $state(Transform.DCT); + + // Index of the bundled test image currently loaded (null for uploads) + activeTestImage = $state(null); // Render stats psnr = $state({ y: 0, cr: 0, cb: 0 }); diff --git a/web/src/lib/transforms.ts b/web/src/lib/transforms.ts new file mode 100644 index 0000000..8b437c3 --- /dev/null +++ b/web/src/lib/transforms.ts @@ -0,0 +1,37 @@ +// Transform types understood by the WASM codec (`transform_mode` argument). +export const Transform = { + DCT: 0, + Haar: 1, + CDF97: 2 +} as const; + +export type TransformValue = typeof Transform[keyof typeof Transform]; + +export interface TransformInfo { + value: TransformValue; + label: string; + /** Stable short name used in URLs and download filenames. */ + slug: string; + fullName: string; + /** CSS color for this transform's chart series. */ + color: string; +} + +export const TRANSFORMS: readonly TransformInfo[] = [ + { value: Transform.DCT, label: 'DCT', slug: 'dct', fullName: 'Discrete Cosine Transform', color: 'var(--series-dct)' }, + { value: Transform.Haar, label: 'Haar', slug: 'haar', fullName: 'Haar Discrete Wavelet Transform', color: 'var(--series-haar)' }, + { value: Transform.CDF97, label: 'CDF 9/7', slug: 'cdf97', fullName: 'CDF 9/7 Discrete Wavelet Transform', color: 'var(--series-cdf97)' } +]; + +export function transformInfo(value: number): TransformInfo { + return TRANSFORMS.find(t => t.value === value) ?? TRANSFORMS[0]; +} + +export function isWavelet(value: number): boolean { + return value !== Transform.DCT; +} + +export function nextTransform(value: number): TransformValue { + const i = TRANSFORMS.findIndex(t => t.value === value); + return TRANSFORMS[(i + 1) % TRANSFORMS.length].value; +} diff --git a/web/src/lib/url-state.ts b/web/src/lib/url-state.ts new file mode 100644 index 0000000..68969f3 --- /dev/null +++ b/web/src/lib/url-state.ts @@ -0,0 +1,82 @@ +import { appState, ViewMode, type ViewModeValue } from './state.svelte.js'; +import { TRANSFORMS, type TransformValue } from './transforms.js'; + +// Codec settings that round-trip through the URL hash, e.g. +// #q=30&t=cdf97&cs=420&v=subbands&img=3 +// so a configuration can be bookmarked or shared. +export interface UrlState { + quality?: number; + transformType?: TransformValue; + csMode?: number; + viewMode?: ViewModeValue; + /** Index of a bundled test image (user-uploaded images can't be shared). */ + testImage?: number; +} + +const VIEW_SLUGS: Record = { + rgb: ViewMode.RGB, + error: ViewMode.Artifacts, + y: ViewMode.Y, + cr: ViewMode.Cr, + cb: ViewMode.Cb, + subbands: ViewMode.Subbands +}; + +const CS_MODES = [444, 422, 420]; + +function parseIntInRange(raw: string | null, min: number, max: number): number | undefined { + if (raw === null || !/^\d+$/.test(raw)) return undefined; + const n = Number(raw); + return n >= min && n <= max ? n : undefined; +} + +export function parseUrlState(hash: string): UrlState { + const params = new URLSearchParams(hash.replace(/^#/, '')); + const state: UrlState = {}; + + const q = parseIntInRange(params.get('q'), 1, 100); + if (q !== undefined) state.quality = q; + + const t = TRANSFORMS.find(info => info.slug === params.get('t')); + if (t) state.transformType = t.value; + + const cs = parseIntInRange(params.get('cs'), 0, 999); + if (cs !== undefined && CS_MODES.includes(cs)) state.csMode = cs; + + const v = params.get('v'); + if (v !== null && v in VIEW_SLUGS) state.viewMode = VIEW_SLUGS[v]; + + const img = parseIntInRange(params.get('img'), 0, 999); + if (img !== undefined) state.testImage = img; + + return state; +} + +export function serializeUrlState(state: UrlState): string { + const params = new URLSearchParams(); + if (state.quality !== undefined) params.set('q', String(state.quality)); + const t = TRANSFORMS.find(info => info.value === state.transformType); + if (t) params.set('t', t.slug); + if (state.csMode !== undefined) params.set('cs', String(state.csMode)); + const v = Object.keys(VIEW_SLUGS).find(slug => VIEW_SLUGS[slug] === state.viewMode); + if (v) params.set('v', v); + if (state.testImage !== undefined) params.set('img', String(state.testImage)); + return params.toString(); +} + +/** Applies codec settings from the hash to appState; returns the full parsed state. */ +export function restoreFromUrl(hash: string = location.hash): UrlState { + const state = parseUrlState(hash); + if (state.quality !== undefined) appState.quality = state.quality; + if (state.transformType !== undefined) appState.transformType = state.transformType; + if (state.csMode !== undefined) appState.currentCsMode = state.csMode; + if (state.viewMode !== undefined) appState.currentViewMode = state.viewMode; + return state; +} + +/** Replaces the hash in place (no history entry per slider tweak). */ +export function writeUrlState(state: UrlState): void { + const next = '#' + serializeUrlState(state); + if (location.hash === next) return; + history.replaceState(history.state, '', next); +} diff --git a/web/src/lib/wasm-bridge.ts b/web/src/lib/wasm-bridge.ts index 0202b26..848cd4d 100644 --- a/web/src/lib/wasm-bridge.ts +++ b/web/src/lib/wasm-bridge.ts @@ -34,6 +34,14 @@ export function processImage(quality: number, csMode: number, transformType?: nu Module._process_image(quality, csMode, t); } +// Encodes at the given settings for a rate–distortion sample. Unlike +// processImage this leaves the session's processed image untouched and skips +// SSIM, so it is cheap enough to call many times in a row. +export function rdEncode(quality: number, csMode: number, transformType: number): { psnrY: number; bits: number } { + const psnrY = Module._rd_encode(quality, csMode, transformType); + return { psnrY, bits: Module._get_rd_bit_estimate() }; +} + export function getViewPtr(viewMode: number): number { return Module._get_view_ptr(viewMode); } diff --git a/web/src/styles/components.css b/web/src/styles/components.css index 9323496..0c88551 100644 --- a/web/src/styles/components.css +++ b/web/src/styles/components.css @@ -423,6 +423,29 @@ input[type="checkbox"] { color: var(--text-muted); } +.control-hint { + margin: 6px 0 0; + font-size: var(--text-base-sm); + line-height: 1.45; + color: var(--text-muted); +} + +.kbd-hint { + margin: 8px 0 0; + font-size: var(--text-base-sm); + color: var(--text-muted); +} + +.kbd-hint kbd { + font-family: var(--font-mono); + font-size: 0.9em; + padding: 1px 5px; + border: 1px solid var(--border); + border-bottom-width: 2px; + border-radius: 4px; + background: var(--bg-secondary); +} + /* Quality Presets */ .preset-group { display: flex; diff --git a/web/src/styles/variables.css b/web/src/styles/variables.css index 9609fd6..c64e6c6 100644 --- a/web/src/styles/variables.css +++ b/web/src/styles/variables.css @@ -18,6 +18,11 @@ --toggle-bg: #e2e8f0; --toggle-active-bg: #ffffff; + + /* Chart series, one per transform */ + --series-dct: var(--primary); + --series-haar: #d97706; + --series-cdf97: #059669; --toggle-label: #64748b; --tooltip-bg: #1e293b; @@ -74,6 +79,9 @@ } [data-theme="dark"] { + --series-haar: #fbbf24; + --series-cdf97: #34d399; + --primary: #3b82f6; --primary-hover: #60a5fa; --primary-subtle: rgba(59, 130, 246, 0.1); diff --git a/web/src/styles/viewer.css b/web/src/styles/viewer.css index 46856d6..b679e4f 100644 --- a/web/src/styles/viewer.css +++ b/web/src/styles/viewer.css @@ -462,10 +462,6 @@ canvas { opacity: 0.75; } -.rd-fill { - fill: url(#rdFillGrad); -} - .rd-curve-path { fill: none; stroke: var(--primary); @@ -474,14 +470,6 @@ canvas { stroke-linejoin: round; } -.rd-curve-path-dwt { - stroke: #f59e0b; -} - -.rd-fill-dwt { - fill: url(#rdFillGradDwt); -} - .rd-crosshair { stroke: var(--primary); stroke-width: 1; @@ -513,14 +501,6 @@ canvas { transform: scale(1.2); } -.rd-point-dwt { - stroke: #f59e0b; -} - -.rd-point-current-dwt { - fill: #f59e0b; -} - .rd-point-label { fill: var(--primary); font-size: 10px; diff --git a/web/src/types/wasm.d.ts b/web/src/types/wasm.d.ts index 7afc3ba..db32183 100644 --- a/web/src/types/wasm.d.ts +++ b/web/src/types/wasm.d.ts @@ -12,6 +12,8 @@ export interface WasmModule { _free(ptr: number): void; _init_session(ptr: number, width: number, height: number): void; _process_image(quality: number, csMode: number, transformMode: number): void; + _rd_encode(quality: number, csMode: number, transformMode: number): number; + _get_rd_bit_estimate(): number; _get_view_ptr(viewMode: number): number; _set_view_tint(enabled: number): void; _set_artifact_gain(gain: number): void; diff --git a/web/tests/browser/codec.browser.test.js b/web/tests/browser/codec.browser.test.js new file mode 100644 index 0000000..b2c583e --- /dev/null +++ b/web/tests/browser/codec.browser.test.js @@ -0,0 +1,108 @@ +// Codec pipeline end to end against the real WASM build (web/public/codec.js), +// not the Module mock used by the other browser tests. +import { describe, it, expect, beforeAll } from 'vitest'; +import { appState, ViewMode } from '../../src/lib/state.svelte.js'; +import { initSession, processImage, rdEncode, getStats, getLastBitEstimate } from '../../src/lib/wasm-bridge.js'; +import { Transform, TRANSFORMS } from '../../src/lib/transforms.js'; + +const WIDTH = 384; +const HEIGHT = 256; +const CS_420 = 420; + +function loadRealWasm() { + return new Promise((resolve, reject) => { + globalThis.Module = { + locateFile: (file) => `/public/${file}`, + onRuntimeInitialized: () => resolve(), + onAbort: (reason) => reject(new Error(`WASM aborted: ${reason}`)), + }; + const script = document.createElement('script'); + script.src = '/public/codec.js'; + script.onerror = () => reject(new Error('Failed to load /public/codec.js')); + document.head.appendChild(script); + }); +} + +async function loadTestImage(index) { + const blob = await (await fetch(`/public/test-images/${index}.png`)).blob(); + const bitmap = await createImageBitmap(blob); + const canvas = document.createElement('canvas'); + canvas.width = WIDTH; + canvas.height = HEIGHT; + const ctx = canvas.getContext('2d'); + ctx.drawImage(bitmap, 0, 0, WIDTH, HEIGHT); + return ctx.getImageData(0, 0, WIDTH, HEIGHT); +} + +// Copies the RGBA buffer for a view mode out of the WASM heap. +function readView(mode) { + const ptr = Module._get_view_ptr(mode); + expect(ptr).not.toBe(0); + const pixels = Module.HEAPU8.slice(ptr, ptr + WIDTH * HEIGHT * 4); + Module._free(ptr); + return pixels; +} + +function distinctValues(rgba) { + const seen = new Set(); + for (let i = 0; i < rgba.length; i += 4) seen.add(rgba[i]); + return seen.size; +} + +beforeAll(async () => { + await loadRealWasm(); + appState.originalImageData = await loadTestImage(3); + appState.imgWidth = WIDTH; + appState.imgHeight = HEIGHT; + initSession(); +}, 30000); + +describe('codec with the real WASM module', () => { + it.each(TRANSFORMS)('$label encodes with sane PSNR and bit estimate', (t) => { + processImage(50, CS_420, t.value); + const { psnr } = getStats(); + expect(psnr.y).toBeGreaterThan(28); + expect(psnr.y).toBeLessThan(60); + + const bpp = getLastBitEstimate() / (WIDTH * HEIGHT); + expect(bpp).toBeGreaterThan(0.05); + expect(bpp).toBeLessThan(4); + }); + + it('CDF 9/7 beats Haar at the same quality', () => { + const haar = rdEncode(20, CS_420, Transform.Haar); + const cdf = rdEncode(20, CS_420, Transform.CDF97); + expect(cdf.psnrY).toBeGreaterThan(haar.psnrY); + expect(cdf.bits).toBeLessThan(haar.bits); + }); + + it('rdEncode leaves the displayed result untouched', () => { + processImage(80, CS_420, Transform.DCT); + const before = { psnr: getStats().psnr.y, bits: getLastBitEstimate(), rgb: readView(ViewMode.RGB) }; + + rdEncode(5, CS_420, Transform.CDF97); + + expect(getStats().psnr.y).toBe(before.psnr); + expect(getLastBitEstimate()).toBe(before.bits); + expect(readView(ViewMode.RGB)).toEqual(before.rgb); + }); + + it('Subbands view draws the wavelet pyramid', () => { + processImage(50, CS_420, Transform.CDF97); + const subbands = readView(ViewMode.Subbands); + const rgb = readView(ViewMode.RGB); + + expect(distinctValues(subbands)).toBeGreaterThan(16); + expect(subbands).not.toEqual(rgb); + // Grayscale map: R == G == B everywhere. + for (let i = 0; i < subbands.length; i += 4 * 97) { + expect(subbands[i + 1]).toBe(subbands[i]); + expect(subbands[i + 2]).toBe(subbands[i]); + } + }); + + it('Subbands view falls back to the reconstruction for DCT', () => { + processImage(50, CS_420, Transform.DCT); + expect(readView(ViewMode.Subbands)).toEqual(readView(ViewMode.RGB)); + }); +}); diff --git a/web/tests/setup/unit.setup.js b/web/tests/setup/unit.setup.js index 99aaba6..2de6e03 100644 --- a/web/tests/setup/unit.setup.js +++ b/web/tests/setup/unit.setup.js @@ -54,6 +54,8 @@ globalThis.Module = { _free: vi.fn(), _init_session: vi.fn(), _process_image: vi.fn(), + _rd_encode: vi.fn(() => 33.25), + _get_rd_bit_estimate: vi.fn(() => 12345), _get_view_ptr: vi.fn(() => 256), _set_view_tint: vi.fn(), _get_psnr_y: vi.fn(() => 35.5), diff --git a/web/tests/unit/state.test.js b/web/tests/unit/state.test.js index 9a374cb..f6fa80a 100644 --- a/web/tests/unit/state.test.js +++ b/web/tests/unit/state.test.js @@ -44,8 +44,12 @@ describe('ViewMode', () => { expect(ViewMode.Cb).toBe(4); }); - it('has exactly 7 entries', () => { - expect(Object.keys(ViewMode)).toHaveLength(7); + it('has exactly 8 entries', () => { + expect(Object.keys(ViewMode)).toHaveLength(8); + }); + + it('exposes the wavelet Subbands view', () => { + expect(ViewMode.Subbands).toBe(7); }); it('all values are unique integers', () => { diff --git a/web/tests/unit/transforms.test.js b/web/tests/unit/transforms.test.js new file mode 100644 index 0000000..335f7f0 --- /dev/null +++ b/web/tests/unit/transforms.test.js @@ -0,0 +1,31 @@ +import { describe, it, expect } from 'vitest'; +import { Transform, TRANSFORMS, transformInfo, isWavelet, nextTransform } from '../../src/lib/transforms.js'; + +describe('transforms', () => { + it('matches the WASM transform_mode values', () => { + expect(Transform).toEqual({ DCT: 0, Haar: 1, CDF97: 2 }); + expect(TRANSFORMS.map(t => t.value)).toEqual([0, 1, 2]); + }); + + it('has unique slugs', () => { + const slugs = TRANSFORMS.map(t => t.slug); + expect(new Set(slugs).size).toBe(slugs.length); + }); + + it('looks up info by value, falling back to DCT', () => { + expect(transformInfo(2).label).toBe('CDF 9/7'); + expect(transformInfo(99).value).toBe(Transform.DCT); + }); + + it('treats only DCT as non-wavelet', () => { + expect(isWavelet(Transform.DCT)).toBe(false); + expect(isWavelet(Transform.Haar)).toBe(true); + expect(isWavelet(Transform.CDF97)).toBe(true); + }); + + it('cycles through every transform and wraps around', () => { + expect(nextTransform(Transform.DCT)).toBe(Transform.Haar); + expect(nextTransform(Transform.Haar)).toBe(Transform.CDF97); + expect(nextTransform(Transform.CDF97)).toBe(Transform.DCT); + }); +}); diff --git a/web/tests/unit/url-state.test.js b/web/tests/unit/url-state.test.js new file mode 100644 index 0000000..a22ba26 --- /dev/null +++ b/web/tests/unit/url-state.test.js @@ -0,0 +1,76 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { appState, ViewMode } from '../../src/lib/state.svelte.js'; +import { Transform } from '../../src/lib/transforms.js'; +import { parseUrlState, serializeUrlState, restoreFromUrl, writeUrlState } from '../../src/lib/url-state.js'; + +describe('parseUrlState', () => { + it('parses every supported field', () => { + expect(parseUrlState('#q=30&t=cdf97&cs=420&v=subbands&img=3')).toEqual({ + quality: 30, + transformType: Transform.CDF97, + csMode: 420, + viewMode: ViewMode.Subbands, + testImage: 3 + }); + }); + + it('returns an empty state for an empty hash', () => { + expect(parseUrlState('')).toEqual({}); + expect(parseUrlState('#')).toEqual({}); + }); + + it('ignores invalid or out-of-range values', () => { + expect(parseUrlState('#q=0&t=jpeg&cs=411&v=nope&img=-1')).toEqual({}); + expect(parseUrlState('#q=101')).toEqual({}); + expect(parseUrlState('#q=5.5')).toEqual({}); + }); +}); + +describe('serializeUrlState', () => { + it('round-trips through parseUrlState', () => { + const state = { + quality: 75, + transformType: Transform.Haar, + csMode: 422, + viewMode: ViewMode.Artifacts, + testImage: 12 + }; + expect(parseUrlState('#' + serializeUrlState(state))).toEqual(state); + }); + + it('omits fields that are unset or have no URL form', () => { + expect(serializeUrlState({ quality: 50, viewMode: ViewMode.BlockingMap })).toBe('q=50'); + }); +}); + +describe('restoreFromUrl', () => { + beforeEach(() => { + appState.quality = 50; + appState.transformType = Transform.DCT; + appState.currentCsMode = 444; + appState.currentViewMode = ViewMode.RGB; + }); + + it('applies settings to appState', () => { + restoreFromUrl('#q=20&t=haar&cs=420&v=y'); + expect(appState.quality).toBe(20); + expect(appState.transformType).toBe(Transform.Haar); + expect(appState.currentCsMode).toBe(420); + expect(appState.currentViewMode).toBe(ViewMode.Y); + }); + + it('leaves appState alone for missing fields', () => { + restoreFromUrl('#t=cdf97'); + expect(appState.quality).toBe(50); + expect(appState.transformType).toBe(Transform.CDF97); + }); +}); + +describe('writeUrlState', () => { + it('replaces the hash without adding history entries', () => { + const before = history.length; + writeUrlState({ quality: 42, transformType: Transform.CDF97 }); + expect(location.hash).toBe('#q=42&t=cdf97'); + expect(history.length).toBe(before); + }); +}); diff --git a/web/tests/unit/wasm-bridge.test.js b/web/tests/unit/wasm-bridge.test.js index 8e97087..1b85307 100644 --- a/web/tests/unit/wasm-bridge.test.js +++ b/web/tests/unit/wasm-bridge.test.js @@ -4,6 +4,7 @@ import { setupWasm, initSession, processImage, + rdEncode, getViewPtr, getStats, setViewTint, @@ -223,3 +224,19 @@ describe('free', () => { expect(globalThis.Module._free).toHaveBeenCalledWith(1024); }); }); + +// --------------------------------------------------------------------------- +// rdEncode +// --------------------------------------------------------------------------- +describe('rdEncode', () => { + it('encodes with the given settings and returns PSNR and bits', () => { + const result = rdEncode(40, 420, 2); + expect(globalThis.Module._rd_encode).toHaveBeenCalledWith(40, 420, 2); + expect(result).toEqual({ psnrY: 33.25, bits: 12345 }); + }); + + it('does not touch the displayed image', () => { + rdEncode(40, 420, 1); + expect(globalThis.Module._process_image).not.toHaveBeenCalled(); + }); +}); diff --git a/web/tests/unit/wasm-exports.test.js b/web/tests/unit/wasm-exports.test.js index 9031f05..890824c 100644 --- a/web/tests/unit/wasm-exports.test.js +++ b/web/tests/unit/wasm-exports.test.js @@ -4,7 +4,7 @@ import { resolve } from 'node:path'; // Guards against declaring a WASM function in TypeScript that the Emscripten // build never exports (which only fails at runtime with "is not a function"). -// A function is exported if it is listed in the Makefile's EXPORTED_FUNCTIONS +// A function is exported if it is listed in the Makefile's WEB_EXPORTS // or marked EMSCRIPTEN_KEEPALIVE in codec_web.cpp. // Vitest runs from web/, so the repo root is one level up. const root = resolve(process.cwd(), '..') + '/'; @@ -12,9 +12,10 @@ const makefile = readFileSync(root + 'Makefile', 'utf8'); const dts = readFileSync(root + 'web/src/types/wasm.d.ts', 'utf8'); const cpp = readFileSync(root + 'web/cpp/codec_web.cpp', 'utf8'); -const exported = new Set( - [...makefile.match(/EXPORTED_FUNCTIONS='\[([^\]]*)\]'/)[1].matchAll(/"(_\w+)"/g)].map(m => m[1]) -); +// WEB_EXPORTS = _a _b \ +// _c ... +const webExports = makefile.match(/^WEB_EXPORTS\s*=((?:.*\\\n)*.*)$/m)[1]; +const exported = new Set(webExports.match(/_\w+/g)); const declared = [...dts.matchAll(/^\s+(_\w+)\(/gm)].map(m => m[1]); const keepalive = [...cpp.matchAll(/EMSCRIPTEN_KEEPALIVE\s+[\w\s*]+?\b(\w+)\s*\(/g)].map(m => '_' + m[1]); From bef97110b5aec5fd9783845b58dc1cebc2f2f4b7 Mon Sep 17 00:00:00 2001 From: Abhinav Date: Fri, 9 Oct 2026 22:22:39 +0530 Subject: [PATCH 3/4] docs: document wavelet choices, subbands view and sharing Co-Authored-By: Claude Opus 5.5 --- readme.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 49970a0..2d8d215 100644 --- a/readme.md +++ b/readme.md @@ -8,11 +8,14 @@ An interactive codec laboratory to visualize how transform-based image compressi - Real-time transform-based compression in the browser - C++ codec core compiled to WebAssembly -- DCT and Wavelet transform comparison +- DCT vs. wavelet (Haar and CDF 9/7, the JPEG 2000 wavelet) comparison +- Wavelet subband pyramid view +- Rate–distortion curves using a per-subband entropy estimate of bits - Interactive block inspector - Zig-zag and zero-run visualization - Artifact analysis maps - PSNR-based quality metrics +- Shareable settings via the URL (`#q=30&t=cdf97&cs=420&img=3`), PNG export, and keyboard shortcuts (`[` / `]` quality, `T` transform) ## 🛠 Technologies @@ -133,6 +136,8 @@ Run `./build/codec_app --help` to see available options. - [x] Multi-level decomposition (subband scaling) - [x] DWT-specific artifact analysis in Inspector - [x] Multi-transform bitrate estimation & comparison +- [x] CDF 9/7 wavelet (lifting, symmetric extension) +- [x] Wavelet subband pyramid visualization ### Phase 4: Future Work From 93c9c68ff2b06adc4f1e4a0a8d1d1e2edcb6c63e Mon Sep 17 00:00:00 2001 From: Abhinav Date: Fri, 9 Oct 2026 22:38:02 +0530 Subject: [PATCH 4/4] build: make sanitize usable on macOS and stop it polluting build/ - sanitize builds in build-sanitize/ instead of build/. The old recipe meant to drop the CMake cache but tested `-d` on a file, so ENABLE_SANITIZERS stayed cached and the next `make test` hung. - dev/test/coverage pass ENABLE_COVERAGE/ENABLE_SANITIZERS explicitly for the same reason. - Apple clang 17 on macOS 26 ships an ASan runtime that deadlocks before main (re-entrant malloc in InitializeShadowMemory). sanitize now runs a probe under a 10 s watchdog and falls back to UBSan only, with a warning. UBSan findings now fail the run. - New CI job runs the C++ suite under ASan + UBSan on Ubuntu and gates the web build. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yml | 17 ++++++++++++++++- .gitignore | 1 + CMakeLists.txt | 8 +++++--- Makefile | 36 +++++++++++++++++++++++++++--------- 4 files changed, 49 insertions(+), 13 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 76158d5..9048f31 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -48,6 +48,21 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} + sanitize-native: + # ASan + UBSan over the C++ test suite. Runs on Linux, where the ASan + # runtime works (it deadlocks at startup with Apple clang 17 on macOS 26). + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install Dependencies + run: | + sudo apt-get update + sudo apt-get install -y cmake g++ libopencv-dev + + - name: Run tests under ASan + UBSan + run: make sanitize + test-js: runs-on: ubuntu-latest defaults: @@ -77,7 +92,7 @@ jobs: build-web: runs-on: ubuntu-latest - needs: [build-native, test-js] # Ensure core logic and JS tests pass first + needs: [build-native, sanitize-native, test-js] # Ensure core logic and JS tests pass first steps: - uses: actions/checkout@v4 diff --git a/.gitignore b/.gitignore index c3a9f47..36d9849 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,6 @@ core/build/ core/images/ build/ +build-sanitize/ images/ coverage.xml \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index 8b93c18..27d4628 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -25,11 +25,13 @@ endif() # --- Sanitizers --- option(ENABLE_SANITIZERS "Enable ASan and UBSan" OFF) +set(SANITIZERS "address,undefined" CACHE STRING "Comma-separated -fsanitize= list used when ENABLE_SANITIZERS is ON") if(ENABLE_SANITIZERS) if(CMAKE_CXX_COMPILER_ID STREQUAL "GNU" OR CMAKE_CXX_COMPILER_ID STREQUAL "Clang" OR CMAKE_CXX_COMPILER_ID STREQUAL "AppleClang") - message(STATUS "Enabling AddressSanitizer and UndefinedBehaviorSanitizer") - add_compile_options(-fsanitize=address,undefined -fno-omit-frame-pointer) - add_link_options(-fsanitize=address,undefined) + message(STATUS "Enabling sanitizers: ${SANITIZERS}") + # Make UBSan findings fail the test run instead of only printing. + add_compile_options(-fsanitize=${SANITIZERS} -fno-sanitize-recover=undefined -fno-omit-frame-pointer) + add_link_options(-fsanitize=${SANITIZERS}) endif() endif() diff --git a/Makefile b/Makefile index dcabc67..978e993 100644 --- a/Makefile +++ b/Makefile @@ -58,11 +58,16 @@ web-dev: web # 🖥️ NATIVE WORKFLOW # ------------------------------------ +# CMake options are cached in build/, so every target states them explicitly; +# otherwise `make coverage` would leave coverage instrumentation on for the +# next `make test`. +PLAIN_FLAGS = -DENABLE_COVERAGE=OFF -DENABLE_SANITIZERS=OFF + # 3. Native Dev Loop dev: @echo "🔥 Building Native App..." @mkdir -p build - @cd build && cmake .. && make -j$(NPROCS) + @cd build && cmake .. $(PLAIN_FLAGS) && make -j$(NPROCS) @echo "🚀 Running Native App..." @cd build && ./$(NATIVE_APP_NAME) @@ -70,27 +75,40 @@ dev: test: @echo "🧪 Running Tests..." @mkdir -p build - @cd build && cmake .. && make -j$(NPROCS) + @cd build && cmake .. $(PLAIN_FLAGS) && make -j$(NPROCS) @cd build && ctest --output-on-failure # 5. Coverage coverage: @echo "📊 Generating Coverage..." @mkdir -p build - @find build -name "*.gcda" -delete - @cd build && cmake .. -DENABLE_COVERAGE=ON && make -j$(NPROCS) + @find build -name "*.gcda" -delete + @cd build && cmake .. -DENABLE_COVERAGE=ON -DENABLE_SANITIZERS=OFF && make -j$(NPROCS) @cd build && ctest --output-on-failure @echo "📈 Generating Sonar-compatible XML report..." # In gcovr 8.6, we use the search path at the end instead of --build-root gcovr --sonarqube -o coverage.xml -r . --filter core/src/ -e build/ --gcov-ignore-parse-errors=all build -# 6. Sanitize (ASan + UBSan) +# 6. Sanitize (ASan + UBSan) in its own build directory +# +# Some toolchains ship a broken ASan runtime (Apple clang 17 on macOS 26 +# deadlocks inside __asan::InitializeShadowMemory before main). A tiny probe +# binary is run under a 10 s watchdog first; if ASan can't start, fall back to +# UBSan only rather than hanging. CI runs the full ASan + UBSan set on Linux. +SANITIZE_DIR = build-sanitize + sanitize: @echo "🛡️ Running with Sanitizers..." - @mkdir -p build - @if [ -d build/CMakeCache.txt ]; then rm build/CMakeCache.txt; fi - @cd build && cmake .. -DENABLE_SANITIZERS=ON && make -j$(NPROCS) - @cd build && ctest --output-on-failure + @mkdir -p $(SANITIZE_DIR) + @printf 'int main(){return 0;}\n' > $(SANITIZE_DIR)/asan_probe.cpp + @SANITIZERS=address,undefined; \ + if ! $(CXX) -fsanitize=address $(SANITIZE_DIR)/asan_probe.cpp -o $(SANITIZE_DIR)/asan_probe 2>/dev/null || \ + ! perl -e 'alarm 10; exec @ARGV' $(SANITIZE_DIR)/asan_probe; then \ + echo "⚠️ AddressSanitizer is unavailable with this toolchain; running UndefinedBehaviorSanitizer only."; \ + SANITIZERS=undefined; \ + fi; \ + cd $(SANITIZE_DIR) && cmake .. -DENABLE_SANITIZERS=ON -DSANITIZERS=$$SANITIZERS -DENABLE_COVERAGE=OFF && make -j$(NPROCS) && \ + UBSAN_OPTIONS=halt_on_error=1:print_stacktrace=1 ctest --output-on-failure # ------------------------------------ # 🌐 JS / WEB TESTS (Vitest)