Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@
core/build/
core/images/
build/
build-sanitize/
images/
coverage.xml
8 changes: 5 additions & 3 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
52 changes: 42 additions & 10 deletions Makefile
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -44,39 +58,57 @@ 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)

# 4. Run Tests
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)
Expand Down
28 changes: 23 additions & 5 deletions core/inc/ImageCodec.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
#define IMAGE_PROCESSOR_H

#include "Image.h"
#include <vector>

/*
* ImageCodec class encapsulates the functionality for compressing and decompressing images using
Expand All @@ -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).
Expand All @@ -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;
Expand All @@ -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<double>& coeffs,
int& paddedW, int& paddedH, int& levels) const;
Image upsampleChannel(const Image& channel, int targetWidth, int targetHeight, ChromaSubsampling cs) const;

public:
Expand Down
47 changes: 47 additions & 0 deletions core/inc/entropy.h
Original file line number Diff line number Diff line change
@@ -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 <https://www.gnu.org/licenses/>.
*/
#ifndef ENTROPY_H
#define ENTROPY_H

#include <unordered_map>
#include <vector>

/*
* 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<std::unordered_map<long long, long long>> m_histograms;
std::vector<long long> m_counts;
};

#endif // ENTROPY_H
46 changes: 36 additions & 10 deletions core/inc/wavelet.h
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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.
*
Expand All @@ -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
Loading
Loading