···11+type rgba_image = {
22+ data : (int, Bigarray.int8_unsigned_elt, Bigarray.c_layout) Bigarray.Array1.t;
33+ width : int;
44+ height : int;
55+}
66+77+let percentile arr p =
88+ let sorted = Array.copy arr in
99+ Array.sort Float.compare sorted;
1010+ let n = Array.length sorted in
1111+ let idx = p /. 100.0 *. Float.of_int (n - 1) in
1212+ let lo = int_of_float (Float.floor idx) in
1313+ let hi = int_of_float (Float.ceil idx) in
1414+ let frac = idx -. Float.of_int lo in
1515+ if lo = hi then sorted.(lo)
1616+ else sorted.(lo) *. (1.0 -. frac) +. sorted.(hi) *. frac
1717+1818+type color = { r : int; g : int; b : int }
1919+2020+let pca_to_rgba ?(low_pct = 2.0) ?(high_pct = 98.0) ~width ~height mat =
2121+ let n_pixels = height * width in
2222+ let data = Bigarray.Array1.create Bigarray.int8_unsigned Bigarray.c_layout (n_pixels * 4) in
2323+ (* Extract each component column into a float array *)
2424+ let comp_values = Array.init 3 (fun c ->
2525+ Array.init n_pixels (fun i -> Linalg.mat_get mat i c)
2626+ ) in
2727+ (* Compute percentile bounds for each component *)
2828+ let bounds = Array.init 3 (fun c ->
2929+ let lo = percentile comp_values.(c) low_pct in
3030+ let hi = percentile comp_values.(c) high_pct in
3131+ (lo, hi)
3232+ ) in
3333+ (* Write pixels *)
3434+ for i = 0 to n_pixels - 1 do
3535+ let off = i * 4 in
3636+ for c = 0 to 2 do
3737+ let (lo, hi) = bounds.(c) in
3838+ let v = Linalg.mat_get mat i c in
3939+ let v = Float.max lo (Float.min hi v) in
4040+ let scaled =
4141+ if hi -. lo < 1e-12 then 0
4242+ else int_of_float ((v -. lo) /. (hi -. lo) *. 255.0)
4343+ in
4444+ Bigarray.Array1.set data (off + c) (min 255 (max 0 scaled))
4545+ done;
4646+ Bigarray.Array1.set data (off + 3) 255
4747+ done;
4848+ { data; width; height }
4949+5050+let color_of_hex s =
5151+ let s = if String.length s > 0 && s.[0] = '#' then String.sub s 1 (String.length s - 1) else s in
5252+ let r = int_of_string ("0x" ^ String.sub s 0 2) in
5353+ let g = int_of_string ("0x" ^ String.sub s 2 2) in
5454+ let b = int_of_string ("0x" ^ String.sub s 4 2) in
5555+ { r; g; b }
5656+5757+let classification_to_rgba ?(alpha = 200) ~predictions ~colors ~width ~height () =
5858+ let n_pixels = height * width in
5959+ let data = Bigarray.Array1.create Bigarray.int8_unsigned Bigarray.c_layout (n_pixels * 4) in
6060+ let color_tbl = Hashtbl.create (List.length colors) in
6161+ List.iter (fun (id, c) -> Hashtbl.replace color_tbl id c) colors;
6262+ let black = { r = 0; g = 0; b = 0 } in
6363+ for i = 0 to n_pixels - 1 do
6464+ let off = i * 4 in
6565+ let c = try Hashtbl.find color_tbl predictions.(i) with Not_found -> black in
6666+ Bigarray.Array1.set data off c.r;
6767+ Bigarray.Array1.set data (off + 1) c.g;
6868+ Bigarray.Array1.set data (off + 2) c.b;
6969+ Bigarray.Array1.set data (off + 3) alpha
7070+ done;
7171+ { data; width; height }
7272+7373+(* ---- Minimal PNG encoder ---- *)
7474+7575+let png_crc32_table =
7676+ Array.init 256 (fun n ->
7777+ let c = ref (Int32.of_int n) in
7878+ for _ = 0 to 7 do
7979+ if Int32.logand !c 1l <> 0l then
8080+ c := Int32.logxor (Int32.shift_right_logical !c 1) 0xEDB88320l
8181+ else
8282+ c := Int32.shift_right_logical !c 1
8383+ done;
8484+ !c)
8585+8686+let png_crc32 data ofs len =
8787+ let c = ref 0xFFFFFFFFl in
8888+ for i = ofs to ofs + len - 1 do
8989+ let byte = Char.code (Bytes.get data i) in
9090+ let idx = Int32.to_int (Int32.logand (Int32.logxor !c (Int32.of_int byte)) 0xFFl) in
9191+ c := Int32.logxor (Int32.shift_right_logical !c 8) png_crc32_table.(idx)
9292+ done;
9393+ Int32.logxor !c 0xFFFFFFFFl
9494+9595+let put_be32 buf pos v =
9696+ Bytes.set buf pos (Char.chr ((v lsr 24) land 0xFF));
9797+ Bytes.set buf (pos + 1) (Char.chr ((v lsr 16) land 0xFF));
9898+ Bytes.set buf (pos + 2) (Char.chr ((v lsr 8) land 0xFF));
9999+ Bytes.set buf (pos + 3) (Char.chr (v land 0xFF))
100100+101101+let put_be32_i32 buf pos v =
102102+ let v = Int32.to_int v in
103103+ put_be32 buf pos v
104104+105105+let png_chunk buf typ data =
106106+ let len = Bytes.length data in
107107+ let chunk = Bytes.create (len + 4) in
108108+ Bytes.blit_string typ 0 chunk 0 4;
109109+ Bytes.blit data 0 chunk 4 len;
110110+ let crc = png_crc32 chunk 0 (len + 4) in
111111+ Buffer.add_bytes buf (let b = Bytes.create 4 in put_be32 b 0 len; b);
112112+ Buffer.add_bytes buf chunk;
113113+ Buffer.add_bytes buf (let b = Bytes.create 4 in put_be32_i32 b 0 crc; b)
114114+115115+let png_of_rgba img =
116116+ let buf = Buffer.create (img.width * img.height * 4 + 1024) in
117117+ (* PNG signature *)
118118+ Buffer.add_string buf "\x89PNG\r\n\x1a\n";
119119+ (* IHDR *)
120120+ let ihdr = Bytes.create 13 in
121121+ put_be32 ihdr 0 img.width;
122122+ put_be32 ihdr 4 img.height;
123123+ Bytes.set ihdr 8 '\x08'; (* bit depth 8 *)
124124+ Bytes.set ihdr 9 '\x06'; (* color type 6 = RGBA *)
125125+ Bytes.set ihdr 10 '\x00'; (* compression *)
126126+ Bytes.set ihdr 11 '\x00'; (* filter *)
127127+ Bytes.set ihdr 12 '\x00'; (* interlace *)
128128+ png_chunk buf "IHDR" ihdr;
129129+ (* Build raw scanlines: filter_byte(0) + row RGBA data *)
130130+ let row_bytes = 1 + img.width * 4 in
131131+ let raw_len = row_bytes * img.height in
132132+ let raw = Bytes.create raw_len in
133133+ for y = 0 to img.height - 1 do
134134+ Bytes.set raw (y * row_bytes) '\x00'; (* filter: None *)
135135+ for x = 0 to img.width * 4 - 1 do
136136+ let v = Bigarray.Array1.get img.data (y * img.width * 4 + x) in
137137+ Bytes.set raw (y * row_bytes + 1 + x) (Char.chr v)
138138+ done
139139+ done;
140140+ (* Wrap in uncompressed deflate stored blocks *)
141141+ let max_block = 65535 in
142142+ let idat_buf = Buffer.create (raw_len + raw_len / max_block * 5 + 20) in
143143+ (* Zlib header: CM=8, CINFO=7, FCHECK to make it valid *)
144144+ Buffer.add_char idat_buf '\x78';
145145+ Buffer.add_char idat_buf '\x01';
146146+ let pos = ref 0 in
147147+ while !pos < raw_len do
148148+ let remaining = raw_len - !pos in
149149+ let block_len = min remaining max_block in
150150+ let is_final = !pos + block_len >= raw_len in
151151+ Buffer.add_char idat_buf (if is_final then '\x01' else '\x00');
152152+ Buffer.add_char idat_buf (Char.chr (block_len land 0xFF));
153153+ Buffer.add_char idat_buf (Char.chr ((block_len lsr 8) land 0xFF));
154154+ let nlen = block_len lxor 0xFFFF in
155155+ Buffer.add_char idat_buf (Char.chr (nlen land 0xFF));
156156+ Buffer.add_char idat_buf (Char.chr ((nlen lsr 8) land 0xFF));
157157+ Buffer.add_subbytes idat_buf raw !pos block_len;
158158+ pos := !pos + block_len
159159+ done;
160160+ (* Adler-32 checksum *)
161161+ let a = ref 1 and b = ref 0 in
162162+ for i = 0 to raw_len - 1 do
163163+ a := (!a + Char.code (Bytes.get raw i)) mod 65521;
164164+ b := (!b + !a) mod 65521
165165+ done;
166166+ let adler = (!b lsl 16) lor !a in
167167+ let adler_bytes = Bytes.create 4 in
168168+ put_be32 adler_bytes 0 adler;
169169+ Buffer.add_bytes idat_buf adler_bytes;
170170+ png_chunk buf "IDAT" (Buffer.to_bytes idat_buf);
171171+ (* IEND *)
172172+ png_chunk buf "IEND" Bytes.empty;
173173+ Buffer.contents buf
+59
lib/viz.mli
···11+(** Visualization utilities for GeoTessera embeddings.
22+33+ Converts PCA components and classification results to raw RGBA pixel
44+ arrays. Does no encoding — callers handle PNG/base64 conversion. *)
55+66+type rgba_image = {
77+ data : (int, Bigarray.int8_unsigned_elt, Bigarray.c_layout) Bigarray.Array1.t;
88+ width : int;
99+ height : int;
1010+}
1111+(** An RGBA image. [data] has length [width * height * 4], with pixels in
1212+ row-major order: R, G, B, A for each pixel. *)
1313+1414+(** {1 Percentile utilities} *)
1515+1616+val percentile : float array -> float -> float
1717+(** [percentile arr p] returns the [p]-th percentile (0-100) of [arr].
1818+ [arr] is not modified. *)
1919+2020+(** {1 PCA visualization} *)
2121+2222+val pca_to_rgba : ?low_pct:float -> ?high_pct:float -> width:int -> height:int -> Linalg.mat -> rgba_image
2323+(** Convert a 3-component PCA result to a false-color RGBA image.
2424+2525+ Input: mat with shape [(height*width, 3)] from [Linalg.pca_transform].
2626+ Each component is independently clipped to [\[low_pct, high_pct\]] percentiles
2727+ (default: 2.0, 98.0) and scaled to [0-255].
2828+ Alpha is always 255.
2929+3030+ Output: RGBA image of size [width * height]. *)
3131+3232+(** {1 Classification visualization} *)
3333+3434+type color = { r : int; g : int; b : int }
3535+(** An RGB color. *)
3636+3737+val color_of_hex : string -> color
3838+(** Parse a hex color string like ["#ff0000"] or ["ff0000"]. *)
3939+4040+val classification_to_rgba :
4141+ ?alpha:int ->
4242+ predictions:int array ->
4343+ colors:(int * color) list ->
4444+ width:int ->
4545+ height:int ->
4646+ unit ->
4747+ rgba_image
4848+(** Convert class predictions to a colored RGBA image.
4949+5050+ [predictions] has length [height * width].
5151+ [colors] maps class IDs to colors. Unknown classes are black.
5252+ [alpha] defaults to 200. *)
5353+5454+(** {1 PNG encoding} *)
5555+5656+val png_of_rgba : rgba_image -> string
5757+(** Encode an RGBA image as PNG bytes.
5858+ Uses uncompressed deflate (stored blocks) for portability —
5959+ no external compression library required. *)