Temporal JPEG-LS compression library for LWIR (Long-Wave Infrared) thermal imagery.
- 5× compression ratio with near-lossless quality
- Real-time performance (72-88 fps on ARM Cortex-A57)
- Temporal residual coding with closed-loop encoding
- GOP-based structure with keyframes and residual frames
- 12-bit range mapping for limited dynamic range sensors
- C++14 compatible for embedded systems
Tested on Flight 21052 dataset (4,701 frames @ 30fps):
| Metric | Value |
|---|---|
| Compression Ratio | 5.09× |
| Encoding Throughput | 72-88 fps (4 cores ARM) |
| Keyframe RMS Error | 0.4 DN (effectively lossless) |
| Residual RMS Error | 9 DN (below noise floor) |
# Clone with submodules (includes CharLS 3.0)
git clone --recursive https://github.com/ceresimaging/lwir-compress.git
# Or if already cloned, initialize submodules
git submodule update --init --recursive
# Build
./build.sh- CMake 3.14+
- C++14 compiler
- yaml-cpp
- libpng
- CharLS 3.0+: JPEG-LS encoder (included as git submodule)
- yaml-cpp: Configuration parsing
- libpng: Reference image I/O for testing
#include "encoder.hpp"
lwir::FrameEncoder encoder;
lwir::CompressedFrame compressed;
encoder.encode_frame(frame, is_keyframe,
keyframe_near, residual_near,
quant_params, compressed);./build/lwir_compress_tool \
--input frames/ \
--output compressed/ \
--config config.yamlThis library can be integrated as a git submodule:
git submodule add https://github.com/ceresimaging/lwir-compress tools/lwir_compressThen link against liblwir_compress.a in your build system.
The library exploits temporal redundancy in thermal video (typically 99%+ overlap between frames):
graph LR
A["Raw Frame 0<br/>(Keyframe)"] --> B["JPEG-LS<br/>Encode"]
B --> C["Compressed<br/>Keyframe<br/>750 KB"]
D["Raw Frame 1"] --> E["Compute<br/>Residual"]
F["Reconstructed<br/>Frame 0"] --> E
E --> G["Quantize<br/>(Dead Zone)"]
G --> H["JPEG-LS<br/>Encode"]
H --> I["Compressed<br/>Residual<br/>325 KB"]
I --> J["Decode +<br/>Reconstruct"]
J --> K["Reconstructed<br/>Frame 1"]
L["Raw Frame 2"] --> M["Compute<br/>Residual"]
K --> M
M --> N["Quantize"]
N --> O["JPEG-LS<br/>Encode"]
O --> P["Compressed<br/>Residual<br/>325 KB"]
style A fill:#1e90ff,stroke:#000,stroke-width:2px,color:#fff
style D fill:#1e90ff,stroke:#000,stroke-width:2px,color:#fff
style L fill:#1e90ff,stroke:#000,stroke-width:2px,color:#fff
style C fill:#ff6347,stroke:#000,stroke-width:2px,color:#fff
style I fill:#ff6347,stroke:#000,stroke-width:2px,color:#fff
style P fill:#ff6347,stroke:#000,stroke-width:2px,color:#fff
style B fill:#ffd700,stroke:#000,stroke-width:2px,color:#000
style E fill:#90ee90,stroke:#000,stroke-width:2px,color:#000
style G fill:#90ee90,stroke:#000,stroke-width:2px,color:#000
style H fill:#ffd700,stroke:#000,stroke-width:2px,color:#000
style M fill:#90ee90,stroke:#000,stroke-width:2px,color:#000
style N fill:#90ee90,stroke:#000,stroke-width:2px,color:#000
style O fill:#ffd700,stroke:#000,stroke-width:2px,color:#000
Frames are organized into Groups of Pictures (GOP), like a deck of cards:
┌──────────────────────────┐
│ Frame 0: KEYFRAME │ ← 750 KB (full frame)
│ 1024×768 @ 16-bit │
└──────────────────────────┘
┌──────────────────────────┐
│ Frame 1: RESIDUAL │ ← 325 KB (difference)
│ Mostly zeros │
└──────────────────────────┘
┌──────────────────────────┐
│ Frame 2: RESIDUAL │ ← 325 KB
│ Mostly zeros │
└──────────────────────────┘
┌──────────────────────────┐
│ Frame 3: RESIDUAL │ ← 325 KB
│ ... │
└──────────────────────────┘
⋮
┌──────────────────────────┐
│ Frame 59: RESIDUAL │ ← 325 KB
│ Last in GOP │
└──────────────────────────┘
┌──────────────────────────┐
│ Frame 60: KEYFRAME │ ← 750 KB (new GOP)
│ 1024×768 @ 16-bit │
└──────────────────────────┘
┌──────────────────────────┐
│ Frame 61: RESIDUAL │ ← 325 KB
│ ... │
└──────────────────────────┘
GOP = 60 frames = 2 seconds @ 30 Hz
Average: (750 + 59×325) / 60 = 376 KB per frame
Compression: 1.5 MB → 376 KB = 5× reduction
-
GOP Structure: Frames are organized into Groups of Pictures (GOP)
- First frame: Keyframe (full frame encoded with JPEG-LS)
- Subsequent frames: Residual frames (difference from reconstructed previous frame)
-
Closed-Loop Encoding:
- Encoder maintains a reconstructed frame buffer
- Each residual is computed against the reconstructed (decoded) previous frame
- Prevents error accumulation across the GOP
-
Quantization:
- Dead-zone quantizer: Values < threshold → 0
- Fractional-step quantizer: Remaining values quantized by step Q
- Reduces entropy while preserving important details
-
JPEG-LS Compression:
- Both keyframes and residuals compressed with CharLS 3.0
- Near-lossless mode: Small errors allowed for better compression
- Residuals compress extremely well (mostly zeros)
graph TD
A["Residual Value"] --> B{"|value| < t?<br/>(Dead Zone)"}
B -->|"Yes"| C["Quantized = 0<br/>(67% of pixels)"]
B -->|"No"| D["Quantized = round(value / q)<br/>(Fractional Step)"]
C --> E["JPEG-LS Encode"]
D --> E
E --> F["Compressed Residual<br/>325 KB"]
style A fill:#1e90ff,stroke:#000,stroke-width:2px,color:#fff
style B fill:#ffd700,stroke:#000,stroke-width:3px,color:#000
style C fill:#32cd32,stroke:#000,stroke-width:2px,color:#fff
style D fill:#ff8c00,stroke:#000,stroke-width:2px,color:#fff
style E fill:#ffd700,stroke:#000,stroke-width:2px,color:#000
style F fill:#ff6347,stroke:#000,stroke-width:2px,color:#fff
Example with t=2, q=2.0:
- Input residual:
[-5, -2, -1, 0, 1, 2, 3, 5, 10] - After dead zone:
[-5, 0, 0, 0, 0, 0, 3, 5, 10] - After quantization:
[-2, 0, 0, 0, 0, 0, 2, 2, 5] - Result: 67% zeros → excellent compression
graph TD
A["Thermal Video<br/>Properties"] --> B["High Temporal<br/>Correlation<br/>(99%+ overlap)"]
A --> C["Smooth Spatial<br/>Gradients<br/>(Low frequency)"]
A --> D["Low Noise Floor<br/>(~10 DN)"]
B --> E["Residuals Mostly<br/>Small Values"]
C --> E
E --> F["Dead Zone<br/>Quantization<br/>(|x| < 2 → 0)"]
D --> F
F --> G["67% Zeros in<br/>Residuals"]
G --> H["5× Compression<br/>with JPEG-LS<br/>(376 KB/frame)"]
style A fill:#1e90ff,stroke:#000,stroke-width:2px,color:#fff
style B fill:#87ceeb,stroke:#000,stroke-width:2px,color:#000
style C fill:#87ceeb,stroke:#000,stroke-width:2px,color:#000
style D fill:#87ceeb,stroke:#000,stroke-width:2px,color:#000
style E fill:#ffd700,stroke:#000,stroke-width:2px,color:#000
style F fill:#ff8c00,stroke:#000,stroke-width:2px,color:#fff
style G fill:#32cd32,stroke:#000,stroke-width:2px,color:#fff
style H fill:#228b22,stroke:#000,stroke-width:3px,color:#fff
- High temporal correlation: Aircraft motion is slow relative to frame rate
- Smooth spatial gradients: Thermal scenes have less high-frequency content
- Low noise floor: 16-bit sensors with ~10 DN noise → quantization is perceptually lossless
uint32_t gop_period = 60; // Keyframe every 60 frames (default)- Smaller GOP (30): More keyframes, less compression, faster seeking
- Larger GOP (120): Fewer keyframes, better compression, slower seeking
- Recommended: 60 frames @ 30 Hz = 2 seconds
uint32_t keyframe_near = 0; // 0 = lossless (default)
uint32_t residual_near = 10; // Allow ±10 DN error on residuals (default)near = 0: Lossless, slower encodingnear = 5-10: Near-lossless, 20-30% faster encoding- For thermal with ~10 DN noise floor,
near=10is perceptually identical
double quant_q = 2.0; // Quantization step (default: 2.0)
uint32_t dead_zone_t = 2; // Dead zone threshold (default: 2)Dead zone threshold (t):
- Residual values in
[-t, +t]→ quantized to 0 - Larger
t→ more zeros → better compression, slightly more error - Recommended: 2-5 DN for thermal data
Quantization step (q):
- Residual values outside dead zone → quantized by step
q quantized = round(residual / q)- Larger
q→ more compression, more error - Recommended: 1.0-3.0 for thermal
bool enable_12bit_mapping = true; // Enable for 12-bit sensors (default: true)Many thermal sensors use only 12 bits of the 16-bit range:
- Maps 12-bit range
[0, 4095]to full[0, 65535] - Improves compression by removing unused bits
- Disable if sensor uses full 16-bit range
lwir::CompressionConfig config = {
.gop_period = 60, // 2 seconds @ 30 Hz
.keyframe_near = 0, // Lossless keyframes
.residual_near = 10, // ±10 DN on residuals
.quant_q = 2.0, // 2 DN quantization step
.dead_zone_t = 2, // ±2 DN dead zone
.enable_12bit_mapping = true // 12-bit sensor
};
lwir::FrameEncoder encoder(config);See LICENSE file for details.
If you use this library in your research, please cite:
@misc{lwir-compress,
title={LWIR Compress: Temporal JPEG-LS Compression for Thermal Imagery},
author={Ceres Imaging},
year={2025},
url={https://github.com/ceresimaging/lwir-compress}
}