···11+(** Fuzz tests for CCSDS 123 compression. *)
22+33+open Alcobar
44+55+(** Roundtrip - compress then decompress must recover original data. *)
66+let test_roundtrip buf =
77+ (* Fixed small image: 2 bands, 4x4 *)
88+ let bands = 2 and width = 4 and height = 4 and bits_per_sample = 8 in
99+ let needed = bands * width * height * 2 in
1010+ let data =
1111+ if String.length buf >= needed then
1212+ Bytes.of_string (String.sub buf 0 needed)
1313+ else
1414+ let b = Bytes.make needed '\x00' in
1515+ Bytes.blit_string buf 0 b 0 (String.length buf);
1616+ b
1717+ in
1818+ (* Clamp values to valid range *)
1919+ let max_val = (1 lsl bits_per_sample) - 1 in
2020+ for i = 0 to (needed / 2) - 1 do
2121+ let hi = Char.code (Bytes.get data (i * 2)) in
2222+ let lo = Char.code (Bytes.get data ((i * 2) + 1)) in
2323+ let v = min max_val ((hi lsl 8) lor lo) in
2424+ Bytes.set data (i * 2) (Char.chr ((v lsr 8) land 0xFF));
2525+ Bytes.set data ((i * 2) + 1) (Char.chr (v land 0xFF))
2626+ done;
2727+ let compressed = Hcomp.compress ~bands ~width ~height ~bits_per_sample data in
2828+ let decompressed =
2929+ Hcomp.decompress ~bands ~width ~height ~bits_per_sample compressed
3030+ in
3131+ if data <> decompressed then fail "roundtrip mismatch"
3232+3333+(** Decompress must not crash on arbitrary input. *)
3434+let test_decompress_crash_safety buf =
3535+ let data = Bytes.of_string buf in
3636+ try
3737+ let _ =
3838+ Hcomp.decompress ~bands:1 ~width:4 ~height:4 ~bits_per_sample:8 data
3939+ in
4040+ ()
4141+ with _ -> ()
4242+4343+(** Compressed output must be deterministic. *)
4444+let test_deterministic buf =
4545+ let bands = 2 and width = 4 and height = 4 and bits_per_sample = 8 in
4646+ let needed = bands * width * height * 2 in
4747+ let data =
4848+ if String.length buf >= needed then
4949+ Bytes.of_string (String.sub buf 0 needed)
5050+ else
5151+ let b = Bytes.make needed '\x00' in
5252+ Bytes.blit_string buf 0 b 0 (String.length buf);
5353+ b
5454+ in
5555+ let max_val = (1 lsl bits_per_sample) - 1 in
5656+ for i = 0 to (needed / 2) - 1 do
5757+ let hi = Char.code (Bytes.get data (i * 2)) in
5858+ let lo = Char.code (Bytes.get data ((i * 2) + 1)) in
5959+ let v = min max_val ((hi lsl 8) lor lo) in
6060+ Bytes.set data (i * 2) (Char.chr ((v lsr 8) land 0xFF));
6161+ Bytes.set data ((i * 2) + 1) (Char.chr (v land 0xFF))
6262+ done;
6363+ let c1 = Hcomp.compress ~bands ~width ~height ~bits_per_sample data in
6464+ let c2 = Hcomp.compress ~bands ~width ~height ~bits_per_sample data in
6565+ if c1 <> c2 then fail "compression is not deterministic"
6666+6767+let suite =
6868+ ( "compress",
6969+ [
7070+ test_case "roundtrip" [ bytes ] test_roundtrip;
7171+ test_case "decompress crash safety" [ bytes ] test_decompress_crash_safety;
7272+ test_case "deterministic" [ bytes ] test_deterministic;
7373+ ] )
+4
fuzz/fuzz_compress.mli
···11+(** Fuzz tests for {!Hcomp}. *)
22+33+val suite : string * Alcobar.test_case list
44+(** Test suite. *)
+33
hcomp.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis:
44+ "CCSDS 123.0-B Lossless Multispectral and Hyperspectral Image Compression"
55+description:
66+ "Predictor-based lossless compression for multi-band (hyperspectral) images following the CCSDS 123.0-B standard. Uses a local difference predictor with spatial and spectral neighbors and an adaptive entropy coder."
77+maintainer: ["Thomas Gazagnaire"]
88+authors: ["Thomas Gazagnaire"]
99+homepage: "https://tangled.org/gazagnaire.org/ocaml-hcomp"
1010+bug-reports: "https://tangled.org/gazagnaire.org/ocaml-hcomp/issues"
1111+depends: [
1212+ "dune" {>= "3.21"}
1313+ "ocaml" {>= "5.1"}
1414+ "alcotest" {>= "1.0" & with-test}
1515+ "crowbar" {>= "0.2" & with-test}
1616+ "odoc" {with-doc}
1717+]
1818+build: [
1919+ ["dune" "subst"] {dev}
2020+ [
2121+ "dune"
2222+ "build"
2323+ "-p"
2424+ name
2525+ "-j"
2626+ jobs
2727+ "@install"
2828+ "@runtest" {with-test}
2929+ "@doc" {with-doc}
3030+ ]
3131+]
3232+dev-repo: "git+https://tangled.org/gazagnaire.org/ocaml-hcomp"
3333+x-maintenance-intent: ["(latest)"]
···11+(* Copyright (c) 2026 Thomas Gazagnaire <thomas@gazagnaire.org>
22+33+ Permission to use, copy, modify, and distribute this software for any
44+ purpose with or without fee is hereby granted, provided that the above
55+ copyright notice and this permission notice appear in all copies.
66+77+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
88+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
99+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1010+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1111+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1212+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1313+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *)
1414+1515+(** CCSDS 123.0-B Lossless Multispectral and Hyperspectral Image Compression.
1616+1717+ Implements: 1. Local difference predictor using spatial and spectral
1818+ neighbors 2. Sample-adaptive entropy coder (Rice-like variable-length
1919+ coding) *)
2020+2121+(* ---- Bitstream I/O ---- *)
2222+2323+module Bitstream : sig
2424+ type writer
2525+2626+ val create : int -> writer
2727+ val write_bits : writer -> int -> int -> unit
2828+ val write_unary : writer -> int -> unit
2929+ val contents : writer -> bytes
3030+3131+ type reader
3232+3333+ val of_bytes : bytes -> reader
3434+ val read_bits : reader -> int -> int
3535+ val read_unary : reader -> int
3636+end = struct
3737+ type writer = { mutable buf : bytes; mutable pos : int }
3838+3939+ let create cap = { buf = Bytes.make (max 16 cap) '\x00'; pos = 0 }
4040+4141+ let ensure_capacity w nbits =
4242+ let needed = (w.pos + nbits + 7) / 8 in
4343+ if needed > Bytes.length w.buf then begin
4444+ let new_cap = max needed (Bytes.length w.buf * 2) in
4545+ let new_buf = Bytes.make new_cap '\x00' in
4646+ Bytes.blit w.buf 0 new_buf 0 (Bytes.length w.buf);
4747+ w.buf <- new_buf
4848+ end
4949+5050+ let write_bits w nbits value =
5151+ ensure_capacity w nbits;
5252+ for i = nbits - 1 downto 0 do
5353+ let bit = (value lsr i) land 1 in
5454+ let byte_idx = w.pos / 8 in
5555+ let bit_idx = 7 - (w.pos mod 8) in
5656+ let cur = Char.code (Bytes.get w.buf byte_idx) in
5757+ Bytes.set w.buf byte_idx (Char.chr (cur lor (bit lsl bit_idx)));
5858+ w.pos <- w.pos + 1
5959+ done
6060+6161+ let write_unary w n =
6262+ ensure_capacity w (n + 1);
6363+ for _ = 1 to n do
6464+ (* Write n zeros *)
6565+ w.pos <- w.pos + 1
6666+ done;
6767+ (* Write terminating 1 *)
6868+ let byte_idx = w.pos / 8 in
6969+ let bit_idx = 7 - (w.pos mod 8) in
7070+ let cur = Char.code (Bytes.get w.buf byte_idx) in
7171+ Bytes.set w.buf byte_idx (Char.chr (cur lor (1 lsl bit_idx)));
7272+ w.pos <- w.pos + 1
7373+7474+ let contents w =
7575+ let len = (w.pos + 7) / 8 in
7676+ Bytes.sub w.buf 0 len
7777+7878+ type reader = { data : bytes; mutable rpos : int; max_bits : int }
7979+8080+ let of_bytes data = { data; rpos = 0; max_bits = Bytes.length data * 8 }
8181+8282+ let read_bits r nbits =
8383+ let value = ref 0 in
8484+ for _ = 1 to nbits do
8585+ if r.rpos < r.max_bits then begin
8686+ let byte_idx = r.rpos / 8 in
8787+ let bit_idx = 7 - (r.rpos mod 8) in
8888+ let bit = (Char.code (Bytes.get r.data byte_idx) lsr bit_idx) land 1 in
8989+ value := (!value lsl 1) lor bit;
9090+ r.rpos <- r.rpos + 1
9191+ end
9292+ else value := !value lsl 1
9393+ done;
9494+ !value
9595+9696+ let read_unary r =
9797+ let n = ref 0 in
9898+ while
9999+ r.rpos < r.max_bits
100100+ && begin
101101+ let byte_idx = r.rpos / 8 in
102102+ let bit_idx = 7 - (r.rpos mod 8) in
103103+ let bit = (Char.code (Bytes.get r.data byte_idx) lsr bit_idx) land 1 in
104104+ r.rpos <- r.rpos + 1;
105105+ bit = 0
106106+ end
107107+ do
108108+ incr n
109109+ done;
110110+ !n
111111+end
112112+113113+(* ---- Sample access helpers ---- *)
114114+115115+(** Image data is stored in band-interleaved-by-pixel (BIP) order with 16-bit
116116+ big-endian samples. *)
117117+118118+let get_sample (data : bytes) ~bands ~width ~band ~x ~y =
119119+ let idx = ((((y * width) + x) * bands) + band) * 2 in
120120+ let hi = Char.code (Bytes.get data idx) in
121121+ let lo = Char.code (Bytes.get data (idx + 1)) in
122122+ (hi lsl 8) lor lo
123123+124124+let set_sample (data : bytes) ~bands ~width ~band ~x ~y value =
125125+ let idx = ((((y * width) + x) * bands) + band) * 2 in
126126+ Bytes.set data idx (Char.chr ((value lsr 8) land 0xFF));
127127+ Bytes.set data (idx + 1) (Char.chr (value land 0xFF))
128128+129129+(* ---- Local difference predictor ---- *)
130130+131131+(** Predict a sample using spatial and spectral neighbors.
132132+133133+ The predictor uses:
134134+ - North (x, y-1): spatial neighbor above
135135+ - West (x-1, y): spatial neighbor to the left
136136+ - Northwest (x-1, y-1): spatial diagonal neighbor
137137+ - Previous band (band-1) at same position: spectral neighbor
138138+139139+ Prediction formula (simplified from CCSDS 123.0-B):
140140+ - If first band, first pixel: mid-range value
141141+ - If first band: spatial prediction (median of N, W, N+W-NW)
142142+ - Otherwise: weighted combination of spatial prediction and spectral
143143+ neighbor *)
144144+145145+let predict ~(data : bytes) ~bands ~width ~height:(_height : int) ~band ~x ~y
146146+ ~bits_per_sample =
147147+ ignore _height;
148148+ let mid = 1 lsl (bits_per_sample - 1) in
149149+ if band = 0 then begin
150150+ (* First band: spatial-only prediction *)
151151+ if x = 0 && y = 0 then mid
152152+ else if y = 0 then get_sample data ~bands ~width ~band ~x:(x - 1) ~y
153153+ else if x = 0 then get_sample data ~bands ~width ~band ~x ~y:(y - 1)
154154+ else begin
155155+ let n = get_sample data ~bands ~width ~band ~x ~y:(y - 1) in
156156+ let w = get_sample data ~bands ~width ~band ~x:(x - 1) ~y in
157157+ let nw = get_sample data ~bands ~width ~band ~x:(x - 1) ~y:(y - 1) in
158158+ (* Median predictor (same as PNG "Paeth" without abs distance) *)
159159+ let p = n + w - nw in
160160+ let max_val = (1 lsl bits_per_sample) - 1 in
161161+ max 0 (min max_val p)
162162+ end
163163+ end
164164+ else begin
165165+ (* Subsequent bands: blend spatial prediction with spectral neighbor *)
166166+ let spectral = get_sample data ~bands ~width ~band:(band - 1) ~x ~y in
167167+ if x = 0 && y = 0 then spectral
168168+ else begin
169169+ let spatial =
170170+ if y = 0 then get_sample data ~bands ~width ~band ~x:(x - 1) ~y
171171+ else if x = 0 then get_sample data ~bands ~width ~band ~x ~y:(y - 1)
172172+ else begin
173173+ let n = get_sample data ~bands ~width ~band ~x ~y:(y - 1) in
174174+ let w = get_sample data ~bands ~width ~band ~x:(x - 1) ~y in
175175+ let nw = get_sample data ~bands ~width ~band ~x:(x - 1) ~y:(y - 1) in
176176+ let p = n + w - nw in
177177+ let max_val = (1 lsl bits_per_sample) - 1 in
178178+ max 0 (min max_val p)
179179+ end
180180+ in
181181+ (* Weight: 3/4 spectral + 1/4 spatial *)
182182+ ((spectral * 3) + spatial) / 4
183183+ end
184184+ end
185185+186186+(* ---- Mapped residual (for entropy coding) ---- *)
187187+188188+(** Map a signed prediction residual to an unsigned value. Uses the standard
189189+ interleaving: 0, -1, 1, -2, 2, ... -> 0, 1, 2, 3, 4, ... This is sometimes
190190+ called "zigzag" encoding. *)
191191+let map_residual r = if r >= 0 then 2 * r else (-2 * r) - 1
192192+193193+let unmap_residual m = if m mod 2 = 0 then m / 2 else -(m / 2) - 1
194194+195195+(* ---- Adaptive entropy coder (Rice-like) ---- *)
196196+197197+(** The entropy coder maintains a running estimate of the optimal Rice parameter
198198+ k per band, adapting it based on recent residual magnitudes.
199199+200200+ Encoding of mapped residual m with parameter k:
201201+ - quotient q = m >> k (written in unary)
202202+ - remainder r = m & ((1 << k) - 1) (written in k bits)
203203+204204+ The parameter k is adapted: accumulate |residuals| and count samples, then k
205205+ = floor(log2(accumulator / count)). *)
206206+207207+type entropy_state = { mutable accum : int; mutable count : int }
208208+209209+let create_entropy_state bits_per_sample =
210210+ (* Initialize accumulator to bias toward middle k values *)
211211+ let initial_k = max 1 (bits_per_sample / 2) in
212212+ { accum = 1 lsl initial_k; count = 1 }
213213+214214+let compute_k st =
215215+ if st.count = 0 then 0
216216+ else begin
217217+ let ratio = st.accum / st.count in
218218+ if ratio <= 0 then 0
219219+ else
220220+ let rec log2 n acc = if n <= 1 then acc else log2 (n lsr 1) (acc + 1) in
221221+ log2 ratio 0
222222+ end
223223+224224+let update_entropy_state st mapped_val =
225225+ st.accum <- st.accum + mapped_val;
226226+ st.count <- st.count + 1;
227227+ (* Halve periodically to keep adaptation responsive *)
228228+ if st.count >= 64 then begin
229229+ st.accum <- st.accum / 2;
230230+ st.count <- st.count / 2
231231+ end
232232+233233+let encode_sample bw st mapped_val =
234234+ let k = compute_k st in
235235+ let q = mapped_val lsr k in
236236+ let r = mapped_val land ((1 lsl k) - 1) in
237237+ (* Cap unary length to avoid pathological cases *)
238238+ let max_unary = 32 in
239239+ if q < max_unary then begin
240240+ Bitstream.write_unary bw q;
241241+ if k > 0 then Bitstream.write_bits bw k r
242242+ end
243243+ else begin
244244+ (* Escape: write max_unary zeros + 1, then full value *)
245245+ Bitstream.write_unary bw max_unary;
246246+ Bitstream.write_bits bw 16 mapped_val
247247+ end;
248248+ update_entropy_state st mapped_val
249249+250250+let decode_sample br st =
251251+ let k = compute_k st in
252252+ let max_unary = 32 in
253253+ let q = Bitstream.read_unary br in
254254+ let mapped_val =
255255+ if q < max_unary then begin
256256+ let r = if k > 0 then Bitstream.read_bits br k else 0 in
257257+ (q lsl k) lor r
258258+ end
259259+ else Bitstream.read_bits br 16
260260+ in
261261+ update_entropy_state st mapped_val;
262262+ mapped_val
263263+264264+(* ---- Public API ---- *)
265265+266266+let compress ~bands ~width ~height ~bits_per_sample data =
267267+ let expected_len = bands * width * height * 2 in
268268+ if Bytes.length data <> expected_len then
269269+ invalid_arg
270270+ (Printf.sprintf "Hcomp.compress: expected %d bytes, got %d" expected_len
271271+ (Bytes.length data));
272272+ if bits_per_sample < 1 || bits_per_sample > 16 then
273273+ invalid_arg
274274+ (Printf.sprintf "Hcomp.compress: bits_per_sample must be 1..16, got %d"
275275+ bits_per_sample);
276276+ let bw = Bitstream.create (expected_len / 2) in
277277+ (* Header *)
278278+ Bitstream.write_bits bw 16 bands;
279279+ Bitstream.write_bits bw 16 width;
280280+ Bitstream.write_bits bw 16 height;
281281+ Bitstream.write_bits bw 8 bits_per_sample;
282282+ (* One entropy state per band for spectral adaptation *)
283283+ let states =
284284+ Array.init bands (fun _ -> create_entropy_state bits_per_sample)
285285+ in
286286+ (* Encode in BIP order: for each pixel, for each band *)
287287+ for y = 0 to height - 1 do
288288+ for x = 0 to width - 1 do
289289+ for band = 0 to bands - 1 do
290290+ let sample = get_sample data ~bands ~width ~band ~x ~y in
291291+ let predicted =
292292+ predict ~data ~bands ~width ~height ~band ~x ~y ~bits_per_sample
293293+ in
294294+ let residual = sample - predicted in
295295+ let mapped = map_residual residual in
296296+ encode_sample bw states.(band) mapped
297297+ done
298298+ done
299299+ done;
300300+ Bitstream.contents bw
301301+302302+let decompress ~bands ~width ~height ~bits_per_sample data =
303303+ let br = Bitstream.of_bytes data in
304304+ (* Read and verify header *)
305305+ let h_bands = Bitstream.read_bits br 16 in
306306+ let h_width = Bitstream.read_bits br 16 in
307307+ let h_height = Bitstream.read_bits br 16 in
308308+ let h_bps = Bitstream.read_bits br 8 in
309309+ if
310310+ h_bands <> bands || h_width <> width || h_height <> height
311311+ || h_bps <> bits_per_sample
312312+ then invalid_arg "Hcomp.decompress: header mismatch";
313313+ let output = Bytes.make (bands * width * height * 2) '\x00' in
314314+ let states =
315315+ Array.init bands (fun _ -> create_entropy_state bits_per_sample)
316316+ in
317317+ let max_val = (1 lsl bits_per_sample) - 1 in
318318+ for y = 0 to height - 1 do
319319+ for x = 0 to width - 1 do
320320+ for band = 0 to bands - 1 do
321321+ let mapped = decode_sample br states.(band) in
322322+ let residual = unmap_residual mapped in
323323+ let predicted =
324324+ predict ~data:output ~bands ~width ~height ~band ~x ~y
325325+ ~bits_per_sample
326326+ in
327327+ let sample = max 0 (min max_val (predicted + residual)) in
328328+ set_sample output ~bands ~width ~band ~x ~y sample
329329+ done
330330+ done
331331+ done;
332332+ output
+33
lib/hcomp.mli
···11+(** CCSDS 123.0-B Lossless and Near-Lossless Multispectral and Hyperspectral
22+ Image Compression.
33+44+ Predictor-based compression for multi-band (hyperspectral) images. The
55+ algorithm uses a local difference predictor with spatial and spectral
66+ neighbors followed by an adaptive entropy coder (sample-adaptive, similar to
77+ Rice coding). *)
88+99+val compress :
1010+ bands:int -> width:int -> height:int -> bits_per_sample:int -> bytes -> bytes
1111+(** [compress ~bands ~width ~height ~bits_per_sample data] compresses multi-band
1212+ image data.
1313+1414+ @param bands Number of spectral bands.
1515+ @param width Image width in pixels (samples per line).
1616+ @param height Image height in pixels (number of lines).
1717+ @param bits_per_sample Bits per sample (1--16).
1818+ @param data
1919+ Raw image data in band-interleaved-by-pixel (BIP) order: for each pixel
2020+ (y, x), all [bands] samples are stored consecutively as 16-bit big-endian
2121+ unsigned integers. Total length must be [bands * width * height * 2]
2222+ bytes. *)
2323+2424+val decompress :
2525+ bands:int -> width:int -> height:int -> bits_per_sample:int -> bytes -> bytes
2626+(** [decompress ~bands ~width ~height ~bits_per_sample compressed] decompresses
2727+ data produced by {!compress}.
2828+2929+ @param bands Number of spectral bands.
3030+ @param width Image width in pixels.
3131+ @param height Image height in pixels.
3232+ @param bits_per_sample Bits per sample (1--16).
3333+ @param compressed Compressed bitstream. *)
···11+(* Copyright (c) 2026 Thomas Gazagnaire <thomas@gazagnaire.org>
22+33+ Permission to use, copy, modify, and distribute this software for any
44+ purpose with or without fee is hereby granted, provided that the above
55+ copyright notice and this permission notice appear in all copies.
66+77+ THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
88+ WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
99+ MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1010+ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1111+ WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1212+ ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1313+ OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. *)
1414+1515+(** Helper: create multi-band image data in BIP order with 16-bit big-endian
1616+ samples. *)
1717+let make_image ~bands ~width ~height f =
1818+ let data = Bytes.make (bands * width * height * 2) '\x00' in
1919+ for y = 0 to height - 1 do
2020+ for x = 0 to width - 1 do
2121+ for band = 0 to bands - 1 do
2222+ let v = f ~band ~x ~y in
2323+ let idx = ((((y * width) + x) * bands) + band) * 2 in
2424+ Bytes.set data idx (Char.chr ((v lsr 8) land 0xFF));
2525+ Bytes.set data (idx + 1) (Char.chr (v land 0xFF))
2626+ done
2727+ done
2828+ done;
2929+ data
3030+3131+let check_roundtrip ~bands ~width ~height ~bits_per_sample data =
3232+ let compressed = Hcomp.compress ~bands ~width ~height ~bits_per_sample data in
3333+ let decompressed =
3434+ Hcomp.decompress ~bands ~width ~height ~bits_per_sample compressed
3535+ in
3636+ if data <> decompressed then
3737+ Alcotest.failf
3838+ "roundtrip failed: input and output differ (bands=%d width=%d height=%d \
3939+ bps=%d)"
4040+ bands width height bits_per_sample
4141+4242+(* Single band, constant image. *)
4343+let test_single_band_constant () =
4444+ let bands = 1 and width = 8 and height = 8 and bits_per_sample = 8 in
4545+ let data = make_image ~bands ~width ~height (fun ~band:_ ~x:_ ~y:_ -> 128) in
4646+ check_roundtrip ~bands ~width ~height ~bits_per_sample data
4747+4848+(* Single band, gradient. *)
4949+let test_single_band_gradient () =
5050+ let bands = 1 and width = 8 and height = 8 and bits_per_sample = 8 in
5151+ let data = make_image ~bands ~width ~height (fun ~band:_ ~x ~y:_ -> x * 32) in
5252+ check_roundtrip ~bands ~width ~height ~bits_per_sample data
5353+5454+(* Multi-band constant. *)
5555+let test_multi_band_constant () =
5656+ let bands = 4 and width = 8 and height = 8 and bits_per_sample = 8 in
5757+ let data =
5858+ make_image ~bands ~width ~height (fun ~band ~x:_ ~y:_ -> 50 * (band + 1))
5959+ in
6060+ check_roundtrip ~bands ~width ~height ~bits_per_sample data
6161+6262+(* Multi-band with spatial gradient. *)
6363+let test_multi_band_gradient () =
6464+ let bands = 3 and width = 16 and height = 16 and bits_per_sample = 8 in
6565+ let data =
6666+ make_image ~bands ~width ~height (fun ~band ~x ~y ->
6767+ (((x + y) * 8) + (band * 20)) mod 256)
6868+ in
6969+ check_roundtrip ~bands ~width ~height ~bits_per_sample data
7070+7171+(* 16-bit samples. *)
7272+let test_16bit_samples () =
7373+ let bands = 2 and width = 8 and height = 8 and bits_per_sample = 16 in
7474+ let data =
7575+ make_image ~bands ~width ~height (fun ~band ~x ~y ->
7676+ ((x * 1000) + (y * 500) + (band * 10000)) mod 65536)
7777+ in
7878+ check_roundtrip ~bands ~width ~height ~bits_per_sample data
7979+8080+(* All zeros. *)
8181+let test_all_zeros () =
8282+ let bands = 3 and width = 4 and height = 4 and bits_per_sample = 8 in
8383+ let data = make_image ~bands ~width ~height (fun ~band:_ ~x:_ ~y:_ -> 0) in
8484+ check_roundtrip ~bands ~width ~height ~bits_per_sample data
8585+8686+(* Single pixel image. *)
8787+let test_single_pixel () =
8888+ let bands = 5 and width = 1 and height = 1 and bits_per_sample = 12 in
8989+ let data =
9090+ make_image ~bands ~width ~height (fun ~band ~x:_ ~y:_ -> band * 100)
9191+ in
9292+ check_roundtrip ~bands ~width ~height ~bits_per_sample data
9393+9494+(* Spectral correlation: bands are similar with small offsets. *)
9595+let test_spectral_correlation () =
9696+ let bands = 8 and width = 8 and height = 8 and bits_per_sample = 10 in
9797+ let data =
9898+ make_image ~bands ~width ~height (fun ~band ~x ~y ->
9999+ let base = ((x * 30) + (y * 30)) mod 1024 in
100100+ (base + (band * 5)) mod 1024)
101101+ in
102102+ check_roundtrip ~bands ~width ~height ~bits_per_sample data
103103+104104+(* Checkerboard pattern per band. *)
105105+let test_checkerboard_multi_band () =
106106+ let bands = 2 and width = 8 and height = 8 and bits_per_sample = 8 in
107107+ let data =
108108+ make_image ~bands ~width ~height (fun ~band ~x ~y ->
109109+ if (x + y + band) mod 2 = 0 then 200 else 50)
110110+ in
111111+ check_roundtrip ~bands ~width ~height ~bits_per_sample data
112112+113113+(* Verify compression ratio for correlated multi-band data. *)
114114+let test_compression_ratio () =
115115+ let bands = 4 and width = 16 and height = 16 and bits_per_sample = 8 in
116116+ let data =
117117+ make_image ~bands ~width ~height (fun ~band ~x ~y ->
118118+ let base = (x + y) * 255 / 30 in
119119+ (base + (band * 2)) mod 256)
120120+ in
121121+ let compressed = Hcomp.compress ~bands ~width ~height ~bits_per_sample data in
122122+ let ratio =
123123+ Float.of_int (Bytes.length compressed) /. Float.of_int (Bytes.length data)
124124+ in
125125+ if ratio >= 1.0 then
126126+ Alcotest.failf
127127+ "compression ratio %.3f >= 1.0 for correlated multi-band data \
128128+ (compressed=%d, original=%d)"
129129+ ratio (Bytes.length compressed) (Bytes.length data)
130130+131131+(* Invalid input size should raise. *)
132132+let test_invalid_size () =
133133+ let bands = 2 and width = 4 and height = 4 and bits_per_sample = 8 in
134134+ let data = Bytes.make 10 '\x00' in
135135+ match Hcomp.compress ~bands ~width ~height ~bits_per_sample data with
136136+ | _ -> Alcotest.fail "expected Invalid_argument for wrong data size"
137137+ | exception Invalid_argument _ -> ()
138138+139139+(* Invalid bits_per_sample should raise. *)
140140+let test_invalid_bps () =
141141+ let bands = 1 and width = 2 and height = 2 in
142142+ let data = Bytes.make (1 * 2 * 2 * 2) '\x00' in
143143+ (match Hcomp.compress ~bands ~width ~height ~bits_per_sample:0 data with
144144+ | _ -> Alcotest.fail "expected Invalid_argument for bps=0"
145145+ | exception Invalid_argument _ -> ());
146146+ match Hcomp.compress ~bands ~width ~height ~bits_per_sample:17 data with
147147+ | _ -> Alcotest.fail "expected Invalid_argument for bps=17"
148148+ | exception Invalid_argument _ -> ()
149149+150150+let suite =
151151+ ( "hcomp",
152152+ [
153153+ ("single band constant roundtrip", `Quick, test_single_band_constant);
154154+ ("single band gradient roundtrip", `Quick, test_single_band_gradient);
155155+ ("multi-band constant roundtrip", `Quick, test_multi_band_constant);
156156+ ("multi-band gradient roundtrip", `Quick, test_multi_band_gradient);
157157+ ("16-bit samples roundtrip", `Quick, test_16bit_samples);
158158+ ("all zeros roundtrip", `Quick, test_all_zeros);
159159+ ("single pixel roundtrip", `Quick, test_single_pixel);
160160+ ("spectral correlation roundtrip", `Quick, test_spectral_correlation);
161161+ ("checkerboard multi-band roundtrip", `Quick, test_checkerboard_multi_band);
162162+ ("compression ratio", `Quick, test_compression_ratio);
163163+ ("invalid input size", `Quick, test_invalid_size);
164164+ ("invalid bits_per_sample", `Quick, test_invalid_bps);
165165+ ] )
166166+167167+let () = Alcotest.run "hcomp" [ suite ]