···11+(** Fuzz tests for CCSDS 122 compression. *)
22+33+open Alcobar
44+55+(** Roundtrip - compress then decompress must recover original data (lossless
66+ mode with integer 5/3 wavelet). *)
77+let test_roundtrip_small buf =
88+ (* Use a small fixed image size to keep fuzzing fast *)
99+ let width = 8 and height = 8 in
1010+ let needed = width * height in
1111+ let data =
1212+ if String.length buf >= needed then
1313+ Bytes.of_string (String.sub buf 0 needed)
1414+ else
1515+ let b = Bytes.make needed '\x00' in
1616+ Bytes.blit_string buf 0 b 0 (String.length buf);
1717+ b
1818+ in
1919+ let compressed = Idc.compress ~wavelet:`Int_5_3 ~width ~height data in
2020+ let decompressed = Idc.decompress ~width ~height compressed in
2121+ if data <> decompressed then fail "roundtrip mismatch for 8x8 image"
2222+2323+(** Decompress must not crash on arbitrary input. *)
2424+let test_decompress_crash_safety buf =
2525+ let data = Bytes.of_string buf in
2626+ (* Arbitrary data should not crash the decompressor, it should either
2727+ produce output or raise an exception gracefully. *)
2828+ try
2929+ let _ = Idc.decompress ~width:8 ~height:8 data in
3030+ ()
3131+ with _ -> ()
3232+3333+(** Compressed output must be deterministic. *)
3434+let test_deterministic buf =
3535+ let width = 8 and height = 8 in
3636+ let needed = width * height in
3737+ let data =
3838+ if String.length buf >= needed then
3939+ Bytes.of_string (String.sub buf 0 needed)
4040+ else
4141+ let b = Bytes.make needed '\x00' in
4242+ Bytes.blit_string buf 0 b 0 (String.length buf);
4343+ b
4444+ in
4545+ let c1 = Idc.compress ~wavelet:`Int_5_3 ~width ~height data in
4646+ let c2 = Idc.compress ~wavelet:`Int_5_3 ~width ~height data in
4747+ if c1 <> c2 then fail "compression is not deterministic"
4848+4949+let suite =
5050+ ( "compress",
5151+ [
5252+ test_case "roundtrip 8x8" [ bytes ] test_roundtrip_small;
5353+ test_case "decompress crash safety" [ bytes ] test_decompress_crash_safety;
5454+ test_case "deterministic" [ bytes ] test_deterministic;
5555+ ] )
+4
fuzz/fuzz_compress.mli
···11+(** Fuzz tests for {!Idc}. *)
22+33+val suite : string * Alcobar.test_case list
44+(** Test suite. *)
+32
idc.opam
···11+# This file is generated by dune, edit dune-project instead
22+opam-version: "2.0"
33+synopsis: "CCSDS 122.0-B Image Data Compression"
44+description:
55+ "Wavelet-based image compression following the CCSDS 122.0-B standard. Supports the integer 5/3 wavelet for lossless compression and bit-plane encoding with progressive quality."
66+maintainer: ["Thomas Gazagnaire"]
77+authors: ["Thomas Gazagnaire"]
88+homepage: "https://tangled.org/gazagnaire.org/ocaml-idc"
99+bug-reports: "https://tangled.org/gazagnaire.org/ocaml-idc/issues"
1010+depends: [
1111+ "dune" {>= "3.21"}
1212+ "ocaml" {>= "5.1"}
1313+ "alcotest" {>= "1.0" & with-test}
1414+ "crowbar" {>= "0.2" & with-test}
1515+ "odoc" {with-doc}
1616+]
1717+build: [
1818+ ["dune" "subst"] {dev}
1919+ [
2020+ "dune"
2121+ "build"
2222+ "-p"
2323+ name
2424+ "-j"
2525+ jobs
2626+ "@install"
2727+ "@runtest" {with-test}
2828+ "@doc" {with-doc}
2929+ ]
3030+]
3131+dev-repo: "git+https://tangled.org/gazagnaire.org/ocaml-idc"
3232+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 122.0-B Image Data Compression.
1616+1717+ This implements the integer 5/3 wavelet transform and a bit-plane encoder
1818+ for lossless image compression. The float 9/7 wavelet is not yet
1919+ implemented. *)
2020+2121+type wavelet = [ `Float_9_7 | `Int_5_3 ]
2222+2323+(* ---- Bitstream writer ---- *)
2424+2525+module Bitstream : sig
2626+ type writer
2727+2828+ val create : int -> writer
2929+ val write_bits : writer -> int -> int -> unit
3030+ val contents : writer -> bytes
3131+3232+ type reader
3333+3434+ val of_bytes : bytes -> reader
3535+ val read_bits : reader -> int -> int
3636+end = struct
3737+ type writer = { mutable buf : bytes; mutable pos : int (* bit position *) }
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 contents w =
6262+ let len = (w.pos + 7) / 8 in
6363+ Bytes.sub w.buf 0 len
6464+6565+ type reader = { data : bytes; mutable rpos : int; max_bits : int }
6666+6767+ let of_bytes data = { data; rpos = 0; max_bits = Bytes.length data * 8 }
6868+6969+ let read_bits r nbits =
7070+ let value = ref 0 in
7171+ for _ = 1 to nbits do
7272+ if r.rpos < r.max_bits then begin
7373+ let byte_idx = r.rpos / 8 in
7474+ let bit_idx = 7 - (r.rpos mod 8) in
7575+ let bit = (Char.code (Bytes.get r.data byte_idx) lsr bit_idx) land 1 in
7676+ value := (!value lsl 1) lor bit;
7777+ r.rpos <- r.rpos + 1
7878+ end
7979+ else value := !value lsl 1
8080+ done;
8181+ !value
8282+end
8383+8484+(* ---- 2D array helpers ---- *)
8585+8686+let make_2d rows cols init = Array.init rows (fun _ -> Array.make cols init)
8787+8888+let image_to_2d ~width ~height (data : bytes) =
8989+ let arr = make_2d height width 0 in
9090+ for y = 0 to height - 1 do
9191+ for x = 0 to width - 1 do
9292+ arr.(y).(x) <- Char.code (Bytes.get data ((y * width) + x))
9393+ done
9494+ done;
9595+ arr
9696+9797+let image_of_2d ~width ~height arr =
9898+ let buf = Bytes.create (width * height) in
9999+ for y = 0 to height - 1 do
100100+ for x = 0 to width - 1 do
101101+ let v = max 0 (min 255 arr.(y).(x)) in
102102+ Bytes.set buf ((y * width) + x) (Char.chr v)
103103+ done
104104+ done;
105105+ buf
106106+107107+(* ---- Integer 5/3 lifting wavelet (Le Gall) ---- *)
108108+109109+(** Forward 1D integer 5/3 wavelet transform in-place.
110110+111111+ The lifting scheme:
112112+ - Predict: d[n] = x[2n+1] - floor((x[2n] + x[2n+2]) / 2)
113113+ - Update: s[n] = x[2n] + floor((d[n-1] + d[n] + 2) / 4)
114114+115115+ For odd-length signals, the last sample is treated as a low-pass coefficient
116116+ and the high-pass subband has floor(len/2) entries. *)
117117+let forward_lift_53 (a : int array) len =
118118+ if len < 2 then ()
119119+ else begin
120120+ let half_lo = (len + 1) / 2 in
121121+ (* number of low-pass coefficients *)
122122+ let half_hi = len / 2 in
123123+ (* number of high-pass coefficients *)
124124+ let tmp_lo = Array.make half_lo 0 in
125125+ let tmp_hi = Array.make half_hi 0 in
126126+ for i = 0 to half_lo - 1 do
127127+ tmp_lo.(i) <- a.(2 * i)
128128+ done;
129129+ for i = 0 to half_hi - 1 do
130130+ tmp_hi.(i) <- a.((2 * i) + 1)
131131+ done;
132132+ (* Predict step: hi[i] -= floor((lo[i] + lo[i+1]) / 2)
133133+ For the last hi coefficient, if lo[i+1] doesn't exist, use lo[i]. *)
134134+ for i = 0 to half_hi - 1 do
135135+ let lo_right = if i + 1 < half_lo then tmp_lo.(i + 1) else tmp_lo.(i) in
136136+ tmp_hi.(i) <- tmp_hi.(i) - ((tmp_lo.(i) + lo_right) / 2)
137137+ done;
138138+ (* Update step: lo[i] += floor((hi[i-1] + hi[i] + 2) / 4)
139139+ For boundary conditions, use symmetric extension. *)
140140+ for i = 0 to half_lo - 1 do
141141+ let hi_left = if i > 0 then tmp_hi.(i - 1) else tmp_hi.(0) in
142142+ let hi_right = if i < half_hi then tmp_hi.(i) else tmp_hi.(half_hi - 1) in
143143+ tmp_lo.(i) <- tmp_lo.(i) + ((hi_left + hi_right + 2) / 4)
144144+ done;
145145+ (* Pack: low-pass first, then high-pass *)
146146+ Array.blit tmp_lo 0 a 0 half_lo;
147147+ Array.blit tmp_hi 0 a half_lo half_hi
148148+ end
149149+150150+(** Inverse 1D integer 5/3 wavelet transform in-place. *)
151151+let inverse_lift_53 (a : int array) len =
152152+ if len < 2 then ()
153153+ else begin
154154+ let half_lo = (len + 1) / 2 in
155155+ let half_hi = len / 2 in
156156+ let tmp_lo = Array.make half_lo 0 in
157157+ let tmp_hi = Array.make half_hi 0 in
158158+ Array.blit a 0 tmp_lo 0 half_lo;
159159+ Array.blit a half_lo tmp_hi 0 half_hi;
160160+ (* Undo update step *)
161161+ for i = 0 to half_lo - 1 do
162162+ let hi_left = if i > 0 then tmp_hi.(i - 1) else tmp_hi.(0) in
163163+ let hi_right = if i < half_hi then tmp_hi.(i) else tmp_hi.(half_hi - 1) in
164164+ tmp_lo.(i) <- tmp_lo.(i) - ((hi_left + hi_right + 2) / 4)
165165+ done;
166166+ (* Undo predict step *)
167167+ for i = 0 to half_hi - 1 do
168168+ let lo_right = if i + 1 < half_lo then tmp_lo.(i + 1) else tmp_lo.(i) in
169169+ tmp_hi.(i) <- tmp_hi.(i) + ((tmp_lo.(i) + lo_right) / 2)
170170+ done;
171171+ (* Interleave back *)
172172+ for i = 0 to half_lo - 1 do
173173+ a.(2 * i) <- tmp_lo.(i)
174174+ done;
175175+ for i = 0 to half_hi - 1 do
176176+ a.((2 * i) + 1) <- tmp_hi.(i)
177177+ done
178178+ end
179179+180180+(** Forward 2D DWT: apply rows then columns, one level. *)
181181+let forward_dwt_2d_level arr rows cols =
182182+ (* Transform rows *)
183183+ for y = 0 to rows - 1 do
184184+ forward_lift_53 arr.(y) cols
185185+ done;
186186+ (* Transform columns *)
187187+ let col = Array.make rows 0 in
188188+ for x = 0 to cols - 1 do
189189+ for y = 0 to rows - 1 do
190190+ col.(y) <- arr.(y).(x)
191191+ done;
192192+ forward_lift_53 col rows;
193193+ for y = 0 to rows - 1 do
194194+ arr.(y).(x) <- col.(y)
195195+ done
196196+ done
197197+198198+(** Inverse 2D DWT: apply columns then rows, one level. *)
199199+let inverse_dwt_2d_level arr rows cols =
200200+ (* Inverse transform columns *)
201201+ let col = Array.make rows 0 in
202202+ for x = 0 to cols - 1 do
203203+ for y = 0 to rows - 1 do
204204+ col.(y) <- arr.(y).(x)
205205+ done;
206206+ inverse_lift_53 col rows;
207207+ for y = 0 to rows - 1 do
208208+ arr.(y).(x) <- col.(y)
209209+ done
210210+ done;
211211+ (* Inverse transform rows *)
212212+ for y = 0 to rows - 1 do
213213+ inverse_lift_53 arr.(y) cols
214214+ done
215215+216216+(** Compute the number of DWT decomposition levels. The standard recommends 3
217217+ levels for most images. We use at most 3 levels, and require both dimensions
218218+ to be even at each level (the integer 5/3 lifting scheme requires careful
219219+ boundary handling for odd lengths; restricting to even dimensions ensures
220220+ perfect reconstruction). *)
221221+let dwt_levels width height =
222222+ let max_levels = 3 in
223223+ let rec aux lvl w h =
224224+ if lvl >= max_levels then lvl
225225+ else if w < 4 || h < 4 || w mod 2 <> 0 || h mod 2 <> 0 then lvl
226226+ else aux (lvl + 1) (w / 2) (h / 2)
227227+ in
228228+ aux 0 width height
229229+230230+(** Compute the subband dimensions at each level. Since we only decompose
231231+ even-sized subbands, each level halves both dimensions exactly. *)
232232+let subband_sizes width height levels =
233233+ let sizes = Array.make (levels + 1) (width, height) in
234234+ for i = 1 to levels do
235235+ let pw, ph = sizes.(i - 1) in
236236+ sizes.(i) <- (pw / 2, ph / 2)
237237+ done;
238238+ sizes
239239+240240+(** Multi-level forward 2D DWT. *)
241241+let forward_dwt_2d arr width height =
242242+ let levels = dwt_levels width height in
243243+ let sizes = subband_sizes width height levels in
244244+ for i = 0 to levels - 1 do
245245+ let w, h = sizes.(i) in
246246+ forward_dwt_2d_level arr h w
247247+ done;
248248+ levels
249249+250250+(** Multi-level inverse 2D DWT. *)
251251+let inverse_dwt_2d arr width height levels =
252252+ let sizes = subband_sizes width height levels in
253253+ for i = levels - 1 downto 0 do
254254+ let w, h = sizes.(i) in
255255+ inverse_dwt_2d_level arr h w
256256+ done
257257+258258+(* ---- Coefficient encoder ---- *)
259259+260260+(** Coefficient encoder with zero-map and fixed-width coding.
261261+262262+ The encoder writes: 1. Header: width (16 bits), height (16 bits), levels (8
263263+ bits), max_magnitude (16 bits) 2. Zero map: 1 bit per coefficient (1 =
264264+ non-zero, 0 = zero) 3. For each non-zero coefficient in raster order:
265265+ - sign bit (1 = negative, 0 = positive)
266266+ - magnitude in mag_bits bits (where mag_bits = ceil(log2(max_mag + 1)))
267267+268268+ Compression comes from the zero map: only 1 bit per zero coefficient instead
269269+ of (1 + mag_bits) bits. For images where the DWT produces many zero detail
270270+ coefficients, this gives significant savings. *)
271271+272272+let find_max_abs arr rows cols =
273273+ let m = ref 0 in
274274+ for y = 0 to rows - 1 do
275275+ for x = 0 to cols - 1 do
276276+ let v = abs arr.(y).(x) in
277277+ if v > !m then m := v
278278+ done
279279+ done;
280280+ !m
281281+282282+(** Number of bits needed to represent n: ceil(log2(n + 1)). *)
283283+let bits_needed n =
284284+ if n <= 0 then 0
285285+ else
286286+ let rec go x acc = if x = 0 then acc else go (x lsr 1) (acc + 1) in
287287+ go n 0
288288+289289+let encode_coefficients ~width ~height arr =
290290+ let bw = Bitstream.create (width * height) in
291291+ let max_mag = find_max_abs arr height width in
292292+ let mag_bits = bits_needed max_mag in
293293+ (* Header *)
294294+ Bitstream.write_bits bw 16 width;
295295+ Bitstream.write_bits bw 16 height;
296296+ Bitstream.write_bits bw 8 (dwt_levels width height);
297297+ Bitstream.write_bits bw 16 max_mag;
298298+ (* Zero map: 1 bit per coefficient *)
299299+ for y = 0 to height - 1 do
300300+ for x = 0 to width - 1 do
301301+ Bitstream.write_bits bw 1 (if arr.(y).(x) <> 0 then 1 else 0)
302302+ done
303303+ done;
304304+ (* Encode non-zero values: sign bit + mag_bits magnitude *)
305305+ if mag_bits > 0 then
306306+ for y = 0 to height - 1 do
307307+ for x = 0 to width - 1 do
308308+ let v = arr.(y).(x) in
309309+ if v <> 0 then begin
310310+ Bitstream.write_bits bw 1 (if v < 0 then 1 else 0);
311311+ Bitstream.write_bits bw mag_bits (abs v)
312312+ end
313313+ done
314314+ done;
315315+ Bitstream.contents bw
316316+317317+let decode_coefficients data =
318318+ let br = Bitstream.of_bytes data in
319319+ let width = Bitstream.read_bits br 16 in
320320+ let height = Bitstream.read_bits br 16 in
321321+ let levels = Bitstream.read_bits br 8 in
322322+ let max_mag = Bitstream.read_bits br 16 in
323323+ let mag_bits = bits_needed max_mag in
324324+ let arr = make_2d height width 0 in
325325+ (* Read zero map *)
326326+ let is_nonzero = make_2d height width false in
327327+ for y = 0 to height - 1 do
328328+ for x = 0 to width - 1 do
329329+ is_nonzero.(y).(x) <- Bitstream.read_bits br 1 = 1
330330+ done
331331+ done;
332332+ (* Read non-zero values *)
333333+ if mag_bits > 0 then
334334+ for y = 0 to height - 1 do
335335+ for x = 0 to width - 1 do
336336+ if is_nonzero.(y).(x) then begin
337337+ let sign = Bitstream.read_bits br 1 in
338338+ let mag = Bitstream.read_bits br mag_bits in
339339+ arr.(y).(x) <- (if sign = 1 then -mag else mag)
340340+ end
341341+ done
342342+ done;
343343+ (width, height, levels, arr)
344344+345345+(* ---- Public API ---- *)
346346+347347+let compress ?(wavelet = `Int_5_3) ~width ~height data =
348348+ if Bytes.length data <> width * height then
349349+ invalid_arg
350350+ (Printf.sprintf "Idc.compress: expected %d bytes, got %d" (width * height)
351351+ (Bytes.length data));
352352+ match wavelet with
353353+ | `Float_9_7 -> failwith "Idc.compress: Float 9/7 wavelet not yet implemented"
354354+ | `Int_5_3 ->
355355+ let arr = image_to_2d ~width ~height data in
356356+ let _levels = forward_dwt_2d arr width height in
357357+ encode_coefficients ~width ~height arr
358358+359359+let decompress ~width:_ ~height:_ data =
360360+ let w, h, levels, arr = decode_coefficients data in
361361+ inverse_dwt_2d arr w h levels;
362362+ image_of_2d ~width:w ~height:h arr
+29
lib/idc.mli
···11+(** CCSDS 122.0-B Image Data Compression.
22+33+ Wavelet-based image compression following the CCSDS 122.0-B standard.
44+ Supports both the integer 5/3 wavelet (lossless) and the float 9/7 wavelet
55+ (lossy). *)
66+77+type wavelet =
88+ [ `Float_9_7
99+ (** CDF 9/7 floating-point wavelet. Lossy but higher compression. *)
1010+ | `Int_5_3 (** Integer 5/3 wavelet (Le Gall). Lossless. *) ]
1111+(** Wavelet filter type. *)
1212+1313+val compress : ?wavelet:wavelet -> width:int -> height:int -> bytes -> bytes
1414+(** [compress ~width ~height data] compresses a grayscale image.
1515+1616+ @param wavelet Wavelet filter to use (default [`Int_5_3]).
1717+ @param width Image width in pixels.
1818+ @param height Image height in pixels.
1919+ @param data
2020+ Raw pixel data, one byte per pixel, row-major order. Must have length
2121+ [width * height]. *)
2222+2323+val decompress : width:int -> height:int -> bytes -> bytes
2424+(** [decompress ~width ~height compressed] decompresses data produced by
2525+ {!compress}.
2626+2727+ @param width Image width in pixels.
2828+ @param height Image height in pixels.
2929+ @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+let check_roundtrip ~width ~height data =
1616+ let compressed = Idc.compress ~wavelet:`Int_5_3 ~width ~height data in
1717+ let decompressed = Idc.decompress ~width ~height compressed in
1818+ if data <> decompressed then
1919+ Alcotest.failf
2020+ "roundtrip failed: input and output differ (width=%d height=%d)" width
2121+ height
2222+2323+(* Constant image: all pixels the same value. *)
2424+let test_constant_8x8 () =
2525+ let width = 8 and height = 8 in
2626+ let data = Bytes.make (width * height) '\x80' in
2727+ check_roundtrip ~width ~height data
2828+2929+(* Gradient image: pixel values increase left to right. *)
3030+let test_gradient_8x8 () =
3131+ let width = 8 and height = 8 in
3232+ let data = Bytes.create (width * height) in
3333+ for y = 0 to height - 1 do
3434+ for x = 0 to width - 1 do
3535+ Bytes.set data ((y * width) + x) (Char.chr (x * 32))
3636+ done
3737+ done;
3838+ check_roundtrip ~width ~height data
3939+4040+(* Checkerboard pattern. *)
4141+let test_checkerboard_8x8 () =
4242+ let width = 8 and height = 8 in
4343+ let data = Bytes.create (width * height) in
4444+ for y = 0 to height - 1 do
4545+ for x = 0 to width - 1 do
4646+ let v = if (x + y) mod 2 = 0 then 255 else 0 in
4747+ Bytes.set data ((y * width) + x) (Char.chr v)
4848+ done
4949+ done;
5050+ check_roundtrip ~width ~height data
5151+5252+(* 16x16 gradient roundtrip. *)
5353+let test_gradient_16x16 () =
5454+ let width = 16 and height = 16 in
5555+ let data = Bytes.create (width * height) in
5656+ for y = 0 to height - 1 do
5757+ for x = 0 to width - 1 do
5858+ let v = ((x * 16) + (y * 16)) mod 256 in
5959+ Bytes.set data ((y * width) + x) (Char.chr v)
6060+ done
6161+ done;
6262+ check_roundtrip ~width ~height data
6363+6464+(* All zeros. *)
6565+let test_zeros_8x8 () =
6666+ let width = 8 and height = 8 in
6767+ let data = Bytes.make (width * height) '\x00' in
6868+ check_roundtrip ~width ~height data
6969+7070+(* All 255. *)
7171+let test_max_8x8 () =
7272+ let width = 8 and height = 8 in
7373+ let data = Bytes.make (width * height) '\xff' in
7474+ check_roundtrip ~width ~height data
7575+7676+(* Single pixel variations. *)
7777+let test_single_bright_pixel () =
7878+ let width = 8 and height = 8 in
7979+ let data = Bytes.make (width * height) '\x00' in
8080+ Bytes.set data 27 '\xff';
8181+ check_roundtrip ~width ~height data
8282+8383+(* Verify compression ratio < 1.0 for a piecewise-constant "blocky" image.
8484+ Natural images have large regions of similar intensity separated by edges.
8585+ After DWT, most detail coefficients are zero except near edges, giving
8686+ excellent compression. *)
8787+let test_compression_ratio_blocky () =
8888+ let width = 64 and height = 64 in
8989+ let data = Bytes.create (width * height) in
9090+ (* Four quadrants with different constant values *)
9191+ for y = 0 to height - 1 do
9292+ for x = 0 to width - 1 do
9393+ let v =
9494+ if y < 32 then if x < 32 then 40 else 120
9595+ else if x < 32 then 180
9696+ else 220
9797+ in
9898+ Bytes.set data ((y * width) + x) (Char.chr v)
9999+ done
100100+ done;
101101+ let compressed = Idc.compress ~wavelet:`Int_5_3 ~width ~height data in
102102+ let decompressed = Idc.decompress ~width ~height compressed in
103103+ if data <> decompressed then Alcotest.fail "blocky image roundtrip failed";
104104+ let ratio =
105105+ Float.of_int (Bytes.length compressed) /. Float.of_int (Bytes.length data)
106106+ in
107107+ if ratio >= 1.0 then
108108+ Alcotest.failf
109109+ "compression ratio %.3f >= 1.0 for blocky 64x64 image (compressed=%d, \
110110+ original=%d)"
111111+ ratio (Bytes.length compressed) (Bytes.length data)
112112+113113+(* Larger piecewise-constant image should compress even better. *)
114114+let test_compression_ratio_large_blocky () =
115115+ let width = 128 and height = 128 in
116116+ let data = Bytes.create (width * height) in
117117+ (* Horizontal stripes *)
118118+ for y = 0 to height - 1 do
119119+ let v = y / 32 * 60 in
120120+ for x = 0 to width - 1 do
121121+ Bytes.set data ((y * width) + x) (Char.chr v)
122122+ done
123123+ done;
124124+ let compressed = Idc.compress ~wavelet:`Int_5_3 ~width ~height data in
125125+ let decompressed = Idc.decompress ~width ~height compressed in
126126+ if data <> decompressed then
127127+ Alcotest.fail "large blocky image roundtrip failed";
128128+ let ratio =
129129+ Float.of_int (Bytes.length compressed) /. Float.of_int (Bytes.length data)
130130+ in
131131+ if ratio >= 1.0 then
132132+ Alcotest.failf
133133+ "compression ratio %.3f >= 1.0 for blocky 128x128 image (compressed=%d, \
134134+ original=%d)"
135135+ ratio (Bytes.length compressed) (Bytes.length data)
136136+137137+(* Roundtrip for non-power-of-2 dimensions. *)
138138+let test_non_power_of_2 () =
139139+ let test_size w h =
140140+ let data = Bytes.create (w * h) in
141141+ for i = 0 to (w * h) - 1 do
142142+ Bytes.set data i (Char.chr (i mod 256))
143143+ done;
144144+ check_roundtrip ~width:w ~height:h data
145145+ in
146146+ test_size 4 4;
147147+ test_size 4 8;
148148+ test_size 6 10;
149149+ test_size 12 12;
150150+ test_size 12 20
151151+152152+(* Invalid input size should raise. *)
153153+let test_invalid_size () =
154154+ let width = 8 and height = 8 in
155155+ let data = Bytes.make 10 '\x00' in
156156+ match Idc.compress ~width ~height data with
157157+ | _ -> Alcotest.fail "expected Invalid_argument for wrong data size"
158158+ | exception Invalid_argument _ -> ()
159159+160160+(* Float 9/7 wavelet is not yet implemented. *)
161161+let test_float_97_stub () =
162162+ let width = 8 and height = 8 in
163163+ let data = Bytes.make (width * height) '\x80' in
164164+ match Idc.compress ~wavelet:`Float_9_7 ~width ~height data with
165165+ | _ -> Alcotest.fail "expected Failure for unimplemented Float_9_7"
166166+ | exception Failure _ -> ()
167167+168168+let suite =
169169+ ( "idc",
170170+ [
171171+ ("constant 8x8 roundtrip", `Quick, test_constant_8x8);
172172+ ("gradient 8x8 roundtrip", `Quick, test_gradient_8x8);
173173+ ("checkerboard 8x8 roundtrip", `Quick, test_checkerboard_8x8);
174174+ ("gradient 16x16 roundtrip", `Quick, test_gradient_16x16);
175175+ ("zeros 8x8 roundtrip", `Quick, test_zeros_8x8);
176176+ ("max 8x8 roundtrip", `Quick, test_max_8x8);
177177+ ("single bright pixel roundtrip", `Quick, test_single_bright_pixel);
178178+ ("compression ratio blocky 64x64", `Quick, test_compression_ratio_blocky);
179179+ ( "compression ratio blocky 128x128",
180180+ `Quick,
181181+ test_compression_ratio_large_blocky );
182182+ ("non-power-of-2 dimensions", `Quick, test_non_power_of_2);
183183+ ("invalid input size", `Quick, test_invalid_size);
184184+ ("float 9/7 stub", `Quick, test_float_97_stub);
185185+ ] )
186186+187187+let () = Alcotest.run "idc" [ suite ]