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
32 changes: 27 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,16 @@

By combining order-1 context-adaptive Huffman coding (N-Huffman) with SIMD-vectorized Median Edge Detection (MED) and length-limited (15-bit) canonical codes, PHF delivered **higher compression ratios than WebP Lossless** at **~300–400 MB/s** in the benchmark below.

PHF has two coding modes, and the encoder can pick between them:

| Mode | Best for | How |
| :--- | :--- | :--- |
| **MED** | photos, camera, video | R−G/G/B−G planes, MED prediction, N-Huffman per plane |
| **Palette** | graphics, screenshots, UI, few colours | each pixel coded whole as an index into the image's colours, with the same N-Huffman model (the monograph's "compression by pixel") |

On graphics the palette mode is about 70–75% smaller than MED and decodes 2–3× faster; on
photos MED wins. Files say which mode they use, so decoders need no option.

---

## Benchmark: High-Resolution Photographic Dataset (Unsplash)
Expand Down Expand Up @@ -132,8 +142,9 @@ Writes `apps/web/phf.js` and `apps/web/phf.wasm` (SIMD128 enabled). After rebuil
```bash
ctest --test-dir build -C Release --output-on-failure
```
Runs round-trips over many sizes and image types (RGB/RGBA, stride 3/4), header and error
checks, CRC detection, a corruption fuzz, file I/O, and decoding of `apps/web/sample.phf`.
Runs round-trips over many sizes and image types (RGB/RGBA, stride 3/4) in every mode and
effort, mode-selection checks, header and error checks, CRC detection, a corruption fuzz of
both modes, file I/O, and decoding of `apps/web/sample.phf`.
CI runs the suite on Linux (with OpenMP and with sanitizers), macOS and Windows.

```bash
Expand All @@ -151,8 +162,12 @@ PHF, WebP Lossless, PNG-6 and PNG-1, plus the machine and thread settings used.
Requires the shared library (see above; or point `PHF_LIB` at it) and `pip install opencv-python numpy`.

```bash
# Encode an image to PHF (8-bit RGB, RGBA or grayscale; grayscale is stored as RGB)
# Encode an image to PHF (8-bit RGB, RGBA or grayscale; grayscale is stored as RGB).
# The default mode is auto: it also tries the palette mode when the image has at most
# 4096 colours and keeps the smaller file.
python apps/cli/phf_tool.py encode photo.png photo.phf
python apps/cli/phf_tool.py encode logo.png logo.phf --mode=palette # or --mode=med
python apps/cli/phf_tool.py encode frame.png frame.phf --fast # one encode, no trials

# Decode a PHF image back to PNG
python apps/cli/phf_tool.py decode photo.phf photo.png
Expand All @@ -171,10 +186,17 @@ JPEG inputs work too: PHF stores the decoded JPEG pixels losslessly (it cannot u
```c
#include "phf.h"

// Encode RGB image to a .phf file (max 65535 px per side, 2^26 px total)
int err = phf_write_file("output.phf", rgb_buffer, width, height, 3);
// Encode RGB image to a .phf file (max 65535 px per side, 2^26 px total).
// NULL options = { PHF_MODE_AUTO, PHF_EFFORT_BEST }: best size for files.
int err = phf_write_file("output.phf", rgb_buffer, width, height, 3, NULL);
if (err != PHF_OK) { /* PHF_ERR_* code */ }

// Streaming: fix the mode up front and encode once per frame.
phf_encode_options_t live = { PHF_MODE_MED, PHF_EFFORT_FAST }; // PALETTE for screen content
size_t size;
uint8_t *frame_phf = phf_encode_memory(rgb_frame, width, height, 3, &live, &size);
phf_free(frame_phf);

// Read and decode a .phf file from disk (CRC-32 is verified when present)
phf_info_t info;
uint8_t *decoded_rgb = phf_read_file("output.phf", &info);
Expand Down
72 changes: 63 additions & 9 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,10 @@ All pixel arithmetic is modulo 256.
| 3 | `0x08` | `CRC32` | A 4-byte CRC-32 follows the payload (see 1.4). |
| 4–7 | | | Reserved, must be `0`. |

A v1 writer produces `0x0E` for RGB and `0x0F` for RGBA. Bits 0–2 are descriptive: the
decoder takes the actual coding modes from the NHF3 packet header (section 2), and they
must agree with it. Files written before bit 3 existed carry no CRC; files from early
A v1 writer produces `0x0E` (RGB) or `0x0F` (RGBA) for the MED mode and `0x08` (RGB) or
`0x09` (RGBA) for the palette mode (section 4). Bits 0–2 are descriptive: the decoder takes
the actual coding modes from the NHF3 packet header (section 2), and they must agree with
it. Files written before bit 3 existed carry no CRC; files from early
encoder builds also carry `0x04`/`0x05` (the MED bit was not set) and are still valid.

### 1.3 Size Limits
Expand Down Expand Up @@ -78,7 +79,7 @@ A decoder must check, in this order:
| :---: | :---: | :---: | :--- | :--- |
| 0 | 4 | ASCII | `magic` | `"NHF3"`. |
| 4 | 1 | u8 | `version` | `1`. |
| 5 | 1 | u8 | `mode` | bit 0: P-frame (must be 0 in PHF); bits 1–2: predictor (`0` none, `1` left, `2` MED, `3` palette); bit 3: color decorrelation. PHF v1 writers use `0x0C` (MED + decorrelation). |
| 5 | 1 | u8 | `mode` | bit 0: P-frame (must be 0 in PHF); bits 1–2: predictor (`0` none, `1` left, `2` MED, `3` palette); bit 3: color decorrelation. PHF v1 writers use `0x0C` (MED + decorrelation) or `0x06` (palette, section 4). |
| 6 | 2 | u16 BE | `width` | |
| 8 | 2 | u16 BE | `height` | |
| 10 | 1 | u8 | `ch` | Channel count (3 or 4 in PHF). |
Expand All @@ -88,9 +89,9 @@ The plane streams follow, concatenated in plane order. The sum of `plane_len` pl
header must equal `payload_size` exactly. Each plane can be decoded independently once its
bytes are available.

PHF v1 writers only produce predictor `2` with decorrelation. The reference decoder also
accepts predictors `0`/`1` from the NHF3 engine; the palette mode (`3`) is not part of this
specification, and independent decoders may reject it.
PHF v1 writers produce predictor `2` with decorrelation (sections 2.2–3) or predictor `3`
without it (section 4). The reference decoder also accepts predictors `0`/`1` from the NHF3
engine; independent decoders may reject those.

### 2.2 Planes and Color Transform

Expand Down Expand Up @@ -191,13 +192,66 @@ sequence, consecutive symbols always differ.

---

## 4. Streaming Notes
## 4. Palette Mode (predictor `3`)

Each pixel is coded whole, as an index into the image's own list of colours, with the same
order-1 N-Huffman model as section 3 but over indices instead of bytes. It suits graphics,
screenshots and UI; for photos the MED planes are smaller.

The packet header has `mode = 0x06` (predictor 3, no decorrelation). The first plane length
holds the whole palette stream and the other `ch − 1` lengths are `0`.

### 4.1 Layout

```
P : u24 BE number of colours, 1 <= P <= w·h
palette : P · ch bytes colour j is bytes [j·ch, (j+1)·ch), in R, G, B(, A) order
if P == 1 : nothing else follows (every pixel is colour 0)
otherwise : a bitstream (MSB-first, zero-padded to a byte) with b = bits(P − 1) = ⌊log2(P − 1)⌋ + 1:
first : b bits first index (< P)
rle : 1 bit as in 3.1, followed by the two run tables when set
fb_mode : 2 bits 0 = no fallback; 1 = fallback indices are raw b-bit values;
2 = fallback code table (3.2) over indices 0..P−1, only if P <= 256;
3 = invalid
if fb_mode == 2: code table (3.2)
contexts : P descriptors, for c = 0..P−1:
used : 1 bit
if used: kind : 2 bits
0 = SINGLE : idx : b bits (< P)
1 = OWN : index code table (4.2)
2 = FALLBACK : uses the fallback (requires fb_mode != 0)
3 = invalid
indices : as in 3.3 (rle = 0) or 3.4 (rle = 1), with indices for symbols and the
previous index as the context; run table t = (index != 0) ? 1 : 0
```

Pixel `i` is `palette[index_i]`. An RGB image decoded into a 4-byte stride gets an alpha
of 255, as in the MED mode.

### 4.2 Index Code Table

Like 3.2, widened to `b`-bit indices:

```
k − 1 : b bits k = number of coded indices, 2 <= k <= min(P, 16384)
k entries, in increasing index order:
gap : Elias-gamma index − previous index (previous starts at −1); m <= b
len : 4 bits 1..15
```

The lengths must form a complete prefix code; codes are canonical (3.2) over the listed
indices.

---

## 5. Streaming Notes

- **Header probe:** the first 20 bytes give width, height, channel count, the required
output size (`width · height · stride`) and the total file size, so a receiver can
allocate buffers before the rest arrives (`phf_probe_memory()`).
- **Independent planes:** after `20 + 11 + 4·ch` bytes the receiver knows where each plane
starts and ends, and each plane decodes on its own. The reference API
starts and ends, and each plane decodes on its own (MED mode; the palette mode is a
single stream). The reference API
(`phf_decode_memory()`) currently needs the complete file; incremental decoding is a
possible future API, not something v1 provides.
- **Inter-frame (P-frame) coding** exists in the underlying NHF3 engine (`nhf3_encode` with
Expand Down
55 changes: 44 additions & 11 deletions apps/cli/phf_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,11 @@
PHF_FLAG_DECORR = 0x04
PHF_FLAG_CRC32 = 0x08

# phf_mode_t / phf_effort_t
PHF_MODES = {"auto": 0, "med": 1, "palette": 2}
PHF_EFFORT_BEST = 0
PHF_EFFORT_FAST = 1

PHF_ERRORS = {
-1: "invalid argument",
-2: "not a PHF file (bad magic)",
Expand Down Expand Up @@ -79,7 +84,11 @@ class PHFInfo(ctypes.Structure):
libphf.phf_probe_memory.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.POINTER(PHFInfo)]
libphf.phf_probe_memory.restype = ctypes.c_int

libphf.phf_encode_memory.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint8, ctypes.POINTER(ctypes.c_size_t)]
class PHFEncodeOptions(ctypes.Structure):
_fields_ = [("mode", ctypes.c_int), ("effort", ctypes.c_int)]

libphf.phf_encode_memory.argtypes = [ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint8,
ctypes.POINTER(PHFEncodeOptions), ctypes.POINTER(ctypes.c_size_t)]
libphf.phf_encode_memory.restype = ctypes.c_void_p

libphf.phf_decode_memory.argtypes = [ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_uint32, ctypes.c_uint32, ctypes.c_uint8]
Expand Down Expand Up @@ -111,7 +120,7 @@ def load_rgb(input_path):
return None, f"unsupported channel count {img.shape[2]}"


def encode_image(input_path, output_path=None):
def encode_image(input_path, output_path=None, mode="auto", fast=False):
if not os.path.exists(input_path):
print(f"Error: File not found: {input_path}")
return False
Expand All @@ -130,9 +139,10 @@ def encode_image(input_path, output_path=None):
print(f"Encoding: {input_path}")
print(f" Dimensions: {w}x{h} ({channels} channels) | Raw: {raw_bytes / (1024*1024):.2f} MB")

opts = PHFEncodeOptions(PHF_MODES[mode], PHF_EFFORT_FAST if fast else PHF_EFFORT_BEST)
out_sz = ctypes.c_size_t(0)
t0 = time.perf_counter()
ptr = libphf.phf_encode_memory(rgb.ctypes.data, w, h, channels, ctypes.byref(out_sz))
ptr = libphf.phf_encode_memory(rgb.ctypes.data, w, h, channels, ctypes.byref(opts), ctypes.byref(out_sz))
enc_time = time.perf_counter() - t0

if not ptr:
Expand All @@ -150,12 +160,19 @@ def encode_image(input_path, output_path=None):
savings = (1.0 - comp_bytes / raw_bytes) * 100.0
throughput = (raw_bytes / (1024*1024)) / enc_time

print(f" -> Saved: {output_path}")
print(f" -> Saved: {output_path} ({coding_mode(phf_bytes)} mode)")
print(f" -> Size: {comp_bytes / (1024*1024):.2f} MB (Ratio: {ratio:.2f}x, Space Savings: {savings:.1f}%)")
print(f" -> Speed: {enc_time*1000:.1f} ms ({throughput:.1f} MB/s)")
return True


def coding_mode(phf_bytes):
"""Name of the NHF3 predictor stored in the payload header (byte 5 of the payload)."""
if len(phf_bytes) < 26:
return "unknown"
return {0: "none", 1: "left", 2: "MED", 3: "palette"}[(phf_bytes[25] >> 1) & 3]


def probe(phf_bytes):
info = PHFInfo()
rc = libphf.phf_probe_memory(phf_bytes, len(phf_bytes), ctypes.byref(info))
Expand Down Expand Up @@ -254,6 +271,7 @@ def info_image(input_path):
print(f"Resolution: {info.width} x {info.height} pixels")
print(f"Channels: {info.channels} ({'RGBA' if info.channels == 4 else 'RGB'})")
print(f"Flags: 0x{info.flags:02x} ({', '.join(flag_names) or 'none'})")
print(f"Coding mode: {coding_mode(phf_bytes)}")
print(f"Raw Size: {raw_sz / (1024*1024):.2f} MB ({raw_sz} bytes)")
print(f"PHF Size: {len(phf_bytes) / (1024*1024):.2f} MB ({len(phf_bytes)} bytes)")
print(f"Compression: {ratio:.2f}x ({savings:.1f}% space savings)")
Expand All @@ -263,22 +281,37 @@ def info_image(input_path):


def main():
if len(sys.argv) < 3:
args = [a for a in sys.argv[1:] if not a.startswith("--")]
flags = [a for a in sys.argv[1:] if a.startswith("--")]
mode, fast = "auto", False
for f in flags:
if f.startswith("--mode=") and f[7:] in PHF_MODES:
mode = f[7:]
elif f == "--fast":
fast = True
else:
print(f"Unknown option: {f}")
return 2

if len(args) < 2:
print("PHF Utility v1.0 — Pereira-Huffman Format")
print("Usage:")
print(" python phf_tool.py encode <input.jpg|png> [output.phf]")
print(" python phf_tool.py encode <input.jpg|png> [output.phf] [--mode=auto|med|palette] [--fast]")
print(" python phf_tool.py decode <input.phf> [output.png]")
print(" python phf_tool.py info <input.phf>")
print("Modes: med = photos, camera, video; palette = graphics, screenshots, few colours;")
print(" auto (default) tries both when the image has at most 4096 colours.")
print("--fast: encode once without trials (what streaming uses); default is best size.")
return 2

cmd = sys.argv[1].lower()
out = sys.argv[3] if len(sys.argv) >= 4 else None
cmd = args[0].lower()
out = args[2] if len(args) >= 3 else None
if cmd == "encode":
ok = encode_image(sys.argv[2], out)
ok = encode_image(args[1], out, mode, fast)
elif cmd == "decode":
ok = decode_image(sys.argv[2], out)
ok = decode_image(args[1], out)
elif cmd == "info":
ok = info_image(sys.argv[2])
ok = info_image(args[1])
else:
print(f"Unknown command: {cmd}")
return 2
Expand Down
2 changes: 1 addition & 1 deletion bench/bench.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
def phf_encode(rgb, ch):
h, w = rgb.shape[:2]
out_sz = ctypes.c_size_t(0)
ptr = lib.phf_encode_memory(rgb.ctypes.data, w, h, ch, ctypes.byref(out_sz))
ptr = lib.phf_encode_memory(rgb.ctypes.data, w, h, ch, None, ctypes.byref(out_sz))
if not ptr:
raise RuntimeError("phf_encode_memory failed")
data = ctypes.string_at(ptr, out_sz.value)
Expand Down
34 changes: 32 additions & 2 deletions libphf/include/phf.h
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,31 @@ extern "C" {
#define PHF_ERR_MEM -9 /* allocation failure */
#define PHF_ERR_IO -10 /* file open/write failure */

/* Coding mode. MED predicts each colour plane from its neighbours: best for photos,
* camera and video. PALETTE codes whole pixels as indices into the image's colours:
* best (and faster) for graphics, screenshots and UI with few colours. AUTO picks
* per image; see phf_effort_t. */
typedef enum {
PHF_MODE_AUTO = 0,
PHF_MODE_MED = 1,
PHF_MODE_PALETTE = 2
} phf_mode_t;

/* Encoder effort. BEST is for files: AUTO encodes both modes when the image has at
* most 4096 colours and keeps the smaller one, and each plane tries both of its
* variants. FAST is for streaming: one encode, no trials; AUTO uses PALETTE only
* for images with at most 256 colours. Decoding speed does not depend on it. */
typedef enum {
PHF_EFFORT_BEST = 0,
PHF_EFFORT_FAST = 1
} phf_effort_t;

/* A zero-initialised struct (or a NULL pointer) means { AUTO, BEST }. */
typedef struct {
phf_mode_t mode;
phf_effort_t effort;
} phf_encode_options_t;

typedef struct {
uint32_t width;
uint32_t height;
Expand All @@ -65,14 +90,17 @@ PHF_API int phf_probe_memory(const uint8_t *data, size_t size, phf_info_t *out_i
* Encodes an RGB or RGBA pixel buffer into the full PHF file container (.phf).
* The returned pointer includes the 20-byte header, the N-Huffman compressed
* payload and a 4-byte CRC-32 trailer. Must be freed with phf_free().
* options may be NULL for { PHF_MODE_AUTO, PHF_EFFORT_BEST }; streaming callers
* usually want a fixed mode with PHF_EFFORT_FAST.
* Width and height must each be <= PHF_MAX_DIM and width*height <= PHF_MAX_PIXELS;
* returns NULL otherwise or on allocation failure.
* returns NULL otherwise, on an invalid option, or on allocation failure.
*/
PHF_API uint8_t* phf_encode_memory(
const uint8_t *pixels,
uint32_t width,
uint32_t height,
uint8_t channels,
const phf_encode_options_t *options,
size_t *out_size
);

Expand All @@ -99,14 +127,16 @@ PHF_API int phf_decode_memory(
/**
* phf_write_file:
* Encodes and writes an RGB/RGBA image directly to a .phf file on disk.
* options as in phf_encode_memory (NULL = AUTO, BEST).
* Returns PHF_OK on success, or a negative PHF_ERR_* code.
*/
PHF_API int phf_write_file(
const char *filepath,
const uint8_t *pixels,
uint32_t width,
uint32_t height,
uint8_t channels
uint8_t channels,
const phf_encode_options_t *options
);

/**
Expand Down
Loading
Loading