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 3601ff9..978e993 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) @@ -44,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) @@ -56,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) 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/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 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 7049a79..b4b5b01 100755 Binary files a/web/public/codec.wasm and b/web/public/codec.wasm differ 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]);