nx_color_v2.nx source
↩ module page · 376 lines · 13638 B
1// nx_color_v2.nx -- full color-space transforms (V1 of vision rollout).
2//
3// Per VISION_FROM_BITS_UP doc, V1 (color) closes ~50% of the gap to
4// classical scene understanding. Ships:
5//
6// - RGB -> HSV (Hue 0..360 degrees, Sat/Val 0..255)
7// - RGB -> YCbCr (Rec.601 broadcast standard)
8// - K-means palette extraction (3D RGB clustering)
9// - Skin-mask via Kovac 2003 fixed-rule YCbCr classifier
10// - Dominant-channel detection
11//
12// All i64. Q-format scaling for fractional intermediates. No f64.
13//
14// genealogy_id: smith_1978_hsv + itu_r_bt601_ycbcr + kovac_2003_skin
15// + lloyd_1957_kmeans
16// lineage_id: color_space_transform + fixed_point_arithmetic
17// axioms: NX_AX_ALG_DISTRIBUTIVITY (linear transforms compose)
18
19// nx_safety_envelope:
20// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
21// sil_target: SIL1
22// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
23// verdict: NOT_YET_EVALUATED
24
25import "syscalls.nx"
26import "nx_axioms.nx"
27import "nx_i128.nx"
28import "nx_image.nx"
29
30// ===== RGB to HSV ======================================================
31//
32// Given r, g, b in 0..255:
33// max = max(r, g, b)
34// min = min(r, g, b)
35// V = max
36// S = (max - min) * 255 / max (0 if max == 0)
37// H = depends on which is max:
38// if max == r: H = 60 * (g - b) / (max - min) + offset
39// if max == g: H = 60 * (b - r) / (max - min) + 120
40// if max == b: H = 60 * (r - g) / (max - min) + 240
41// normalized to [0, 360).
42//
43// We return H in degrees (0..359), S in 0..255, V in 0..255.
44
45struct HSVColor {
46 h: i64, // hue 0..359
47 s: i64, // saturation 0..255
48 v: i64, // value 0..255
49}
50
51func nx_color_max3(a: i64, b: i64, c: i64) -> i64 {
52 var m: i64 = a
53 if b > m { m = b }
54 if c > m { m = c }
55 return m
56}
57
58func nx_color_min3(a: i64, b: i64, c: i64) -> i64 {
59 var m: i64 = a
60 if b < m { m = b }
61 if c < m { m = c }
62 return m
63}
64
65func nx_color_rgb_to_hsv(r: i64, g: i64, b: i64, out: *HSVColor) -> i64 {
66 let max_v: i64 = nx_color_max3(r, g, b)
67 let min_v: i64 = nx_color_min3(r, g, b)
68 let delta: i64 = max_v - min_v
69 out.v = max_v
70 if max_v == 0 {
71 out.s = 0
72 out.h = 0
73 return 0
74 }
75 out.s = (delta * 255) / max_v
76 if delta == 0 {
77 out.h = 0
78 return 0
79 }
80 var h: i64 = 0
81 if max_v == r {
82 h = 60 * (g - b) / delta
83 }
84 if max_v == g {
85 if max_v != r {
86 h = 60 * (b - r) / delta + 120
87 }
88 }
89 if max_v == b {
90 if max_v != r {
91 if max_v != g {
92 h = 60 * (r - g) / delta + 240
93 }
94 }
95 }
96 while h < 0 { h = h + 360 }
97 while h >= 360 { h = h - 360 }
98 out.h = h
99 return 0
100}
101
102// ===== RGB to YCbCr (Rec.601) ==========================================
103//
104// Y = (66*R + 129*G + 25*B + 128) >> 8 + 16
105// Cb = (-38*R - 74*G + 112*B + 128) >> 8 + 128
106// Cr = (112*R - 94*G - 18*B + 128) >> 8 + 128
107//
108// Output: Y, Cb, Cr in 0..255 each.
109
110struct YCbCrColor {
111 y: i64,
112 cb: i64,
113 cr: i64,
114}
115
116func nx_color_rgb_to_ycbcr(r: i64, g: i64, b: i64, out: *YCbCrColor) -> i64 {
117 let y_raw: i64 = ((66 * r + 129 * g + 25 * b + 128) >> 8) + 16
118 let cb_raw: i64 = ((-38 * r - 74 * g + 112 * b + 128) >> 8) + 128
119 let cr_raw: i64 = ((112 * r - 94 * g - 18 * b + 128) >> 8) + 128
120 var y: i64 = y_raw
121 if y < 0 { y = 0 }
122 if y > 255 { y = 255 }
123 var cb: i64 = cb_raw
124 if cb < 0 { cb = 0 }
125 if cb > 255 { cb = 255 }
126 var cr: i64 = cr_raw
127 if cr < 0 { cr = 0 }
128 if cr > 255 { cr = 255 }
129 out.y = y
130 out.cb = cb
131 out.cr = cr
132 return 0
133}
134
135// ===== Kovac 2003 skin-mask classifier =================================
136//
137// Kovac et al. 2003 "Human skin colour clustering for face detection"
138// proposed two fixed-rule classifiers. We use their YCbCr rule:
139//
140// skin pixel iff:
141// Y > 80
142// AND 85 < Cb < 135
143// AND 135 < Cr < 180
144//
145// Pure rule, no training, no learning. Detects skin candidate
146// regions; user then applies morphological cleanup + region analysis.
147//
148// Substrate guarantees: only objective image-property classification.
149// Does not identify individuals; does not rank persons; does not
150// store any reference faces.
151
152// ONE COPY OF THE PUBLISHED BOUNDS. Three predicates read the constants below, so no caller can
153// drift from another, and a caller that already holds Y/Cb/Cr pays no second conversion and no
154// per-pixel allocation (the *_ycc forms are pure -- safe in a hot loop, unlike the *_pixel form).
155const NX_SKIN_Y_MIN: i64 = 80
156const NX_SKIN_CB_LO: i64 = 85
157const NX_SKIN_CB_HI: i64 = 135
158const NX_SKIN_CR_LO: i64 = 135
159const NX_SKIN_CR_HI: i64 = 180
160
161// The CHROMA HALF of Kovac's rule: the same published Cb/Cr window, with NO luminance term.
162//
163// WHY THIS EXISTS, MEASURED (2026-08-25, /compare/graphics GR1): the luminance gate below is the
164// term a colour grade moves most. The panel measured cinematically graded reference stills at 0-6
165// permil skin under the full rule while ungraded captures of the same subject class read 130-624.
166// A referee whose ruler moves with the grade cannot compare a graded reference against an ungraded
167// render at all. Dropping Y makes the predicate invariant to exposure, lift and gain by
168// construction -- it consults only chroma, which those operations do not move.
169// HONEST BOUND: chroma-only is invariant to LUMINANCE, not to HUE. A grade that rotates hue (a
170// teal-orange look) still moves Cb/Cr, so this is a strictly weaker sensitivity, never immunity.
171func nx_color_skin_chroma_ycc(cb: i64, cr: i64) -> i64 {
172 if cb <= NX_SKIN_CB_LO { return 0 }
173 if cb >= NX_SKIN_CB_HI { return 0 }
174 if cr <= NX_SKIN_CR_LO { return 0 }
175 if cr >= NX_SKIN_CR_HI { return 0 }
176 return 1
177}
178
179// Kovac's FULL rule = the chroma window AND the daylight luminance gate. This is the incumbent
180// predicate, unchanged in behaviour; it is expressed here so the window has exactly one copy.
181func nx_color_skin_kovac_ycc(y: i64, cb: i64, cr: i64) -> i64 {
182 if y <= NX_SKIN_Y_MIN { return 0 }
183 return nx_color_skin_chroma_ycc(cb, cr)
184}
185
186// Chai & Ngan 1999, "Face segmentation using skin-colour map in videophone applications": the
187// SKIN-COLOUR MAP, a chroma-only locus stated by its authors WITHOUT any luminance term, on the
188// finding that the skin cluster in the Cb-Cr plane is largely independent of luminance. Bounds are
189// INCLUSIVE as published, which is why they are spelled with < / > rather than the <= / >= above.
190// A second published rule is carried on purpose: it lets the panel CHOOSE its skin ruler on
191// measured separability instead of on the author's taste.
192const NX_SKIN_LOCUS_CB_LO: i64 = 77
193const NX_SKIN_LOCUS_CB_HI: i64 = 127
194const NX_SKIN_LOCUS_CR_LO: i64 = 133
195const NX_SKIN_LOCUS_CR_HI: i64 = 173
196
197func nx_color_skin_locus_ycc(cb: i64, cr: i64) -> i64 {
198 if cb < NX_SKIN_LOCUS_CB_LO { return 0 }
199 if cb > NX_SKIN_LOCUS_CB_HI { return 0 }
200 if cr < NX_SKIN_LOCUS_CR_LO { return 0 }
201 if cr > NX_SKIN_LOCUS_CR_HI { return 0 }
202 return 1
203}
204
205func nx_color_kovac_skin_pixel(r: i64, g: i64, b: i64) -> i64 {
206 let ycc: *YCbCrColor = (sys_mmap(24)) as *YCbCrColor
207 nx_color_rgb_to_ycbcr(r, g, b, ycc)
208 return nx_color_skin_kovac_ycc(ycc.y, ycc.cb, ycc.cr)
209}
210
211// ALL THREE PREDICATES FROM ONE CONVERSION, ON A CALLER-OWNED SCRATCH.
212// Returns a flag sum: 1 = Kovac (daylight), 2 = chroma-only, 4 = Chai-Ngan locus.
213//
214// The scratch struct is passed IN rather than allocated here because the caller is a per-pixel loop:
215// nx_color_kovac_skin_pixel above maps 24 bytes on EVERY call, which at a 400x240 fitted frame is
216// 96,000 mappings per image. That form is kept unchanged for its existing callers; anything scanning
217// an image should call this one, hoist the scratch above its loop, and allocate nothing per pixel.
218// One conversion serves all three rules, so the three can never disagree about the pixel they read.
219func nx_color_skin_flags_at(r: i64, g: i64, b: i64, ycc: *YCbCrColor) -> i64 {
220 nx_color_rgb_to_ycbcr(r, g, b, ycc)
221 var f: i64 = 0
222 if nx_color_skin_kovac_ycc(ycc.y, ycc.cb, ycc.cr) == 1 { f = f + 1 }
223 if nx_color_skin_chroma_ycc(ycc.cb, ycc.cr) == 1 { f = f + 2 }
224 if nx_color_skin_locus_ycc(ycc.cb, ycc.cr) == 1 { f = f + 4 }
225 return f
226}
227
228// Apply Kovac classifier to a whole RGB image, returning a binary mask.
229func nx_color_skin_mask(rgb: *Image) -> *Image {
230 let mask: *Image = nx_image_alloc(rgb.width, rgb.height, 1)
231 var y: i64 = 0
232 while y < rgb.height {
233 var x: i64 = 0
234 while x < rgb.width {
235 let r: i64 = nx_image_get(rgb, x, y, 0)
236 let g: i64 = nx_image_get(rgb, x, y, 1)
237 let b: i64 = nx_image_get(rgb, x, y, 2)
238 var m: i64 = 0
239 if nx_color_kovac_skin_pixel(r, g, b) == 1 { m = 255 }
240 nx_image_set(mask, x, y, 0, m)
241 x = x + 1
242 }
243 y = y + 1
244 }
245 return mask
246}
247
248// ===== K-means palette extraction (3D RGB) ==============================
249//
250// Given an image and k=number of clusters, return k RGB centroids
251// that approximate the image's color palette.
252//
253// Implementation: Lloyd's algorithm (1957). Random init using image-
254// pixel sampling at fixed strides (deterministic since the sampling
255// stride is fixed). Iterate cluster assignments + centroid update
256// for up to max_iter rounds or until stable.
257
258const NX_COLOR_KMEANS_MAX_K: i64 = 16
259
260struct Palette {
261 k: i64,
262 centroids_r: *i64, // length k
263 centroids_g: *i64,
264 centroids_b: *i64,
265 counts: *i64, // pixels per cluster
266}
267
268func nx_palette_alloc(k: i64) -> *Palette {
269 let raw: *u8 = sys_mmap(40)
270 let p: *Palette = raw as *Palette
271 p.k = k
272 p.centroids_r = (sys_mmap(k * 8)) as *i64
273 p.centroids_g = (sys_mmap(k * 8)) as *i64
274 p.centroids_b = (sys_mmap(k * 8)) as *i64
275 p.counts = (sys_mmap(k * 8)) as *i64
276 return p
277}
278
279// Distance-squared between two RGB colors (i64 safe; max = 3 * 255^2).
280func nx_color_dist_sq(r1: i64, g1: i64, b1: i64,
281 r2: i64, g2: i64, b2: i64) -> i64 {
282 let dr: i64 = r1 - r2
283 let dg: i64 = g1 - g2
284 let db: i64 = b1 - b2
285 return dr * dr + dg * dg + db * db
286}
287
288func nx_color_kmeans_palette(rgb: *Image, k: i64, max_iter: i64) -> *Palette {
289 if k <= 0 { return 0 as *Palette }
290 if k > NX_COLOR_KMEANS_MAX_K { return 0 as *Palette }
291 let p: *Palette = nx_palette_alloc(k)
292 let n_pixels: i64 = rgb.width * rgb.height
293
294 // Initialize centroids by sampling pixels at evenly-spaced positions.
295 var i: i64 = 0
296 while i < k {
297 let idx: i64 = (n_pixels * i) / k
298 let py: i64 = idx / rgb.width
299 let px: i64 = idx - py * rgb.width
300 p.centroids_r[i] = nx_image_get(rgb, px, py, 0)
301 p.centroids_g[i] = nx_image_get(rgb, px, py, 1)
302 p.centroids_b[i] = nx_image_get(rgb, px, py, 2)
303 i = i + 1
304 }
305
306 // Iterate.
307 var iter: i64 = 0
308 while iter < max_iter {
309 // Reset accumulators.
310 let sum_r: *i64 = (sys_mmap(k * 8)) as *i64
311 let sum_g: *i64 = (sys_mmap(k * 8)) as *i64
312 let sum_b: *i64 = (sys_mmap(k * 8)) as *i64
313 var j: i64 = 0
314 while j < k {
315 sum_r[j] = 0
316 sum_g[j] = 0
317 sum_b[j] = 0
318 p.counts[j] = 0
319 j = j + 1
320 }
321 // Assign each pixel to closest centroid.
322 var y: i64 = 0
323 while y < rgb.height {
324 var x: i64 = 0
325 while x < rgb.width {
326 let pr: i64 = nx_image_get(rgb, x, y, 0)
327 let pg: i64 = nx_image_get(rgb, x, y, 1)
328 let pb: i64 = nx_image_get(rgb, x, y, 2)
329 var best_i: i64 = 0
330 var best_d: i64 = nx_color_dist_sq(pr, pg, pb,
331 p.centroids_r[0], p.centroids_g[0], p.centroids_b[0])
332 var ci: i64 = 1
333 while ci < k {
334 let d: i64 = nx_color_dist_sq(pr, pg, pb,
335 p.centroids_r[ci], p.centroids_g[ci], p.centroids_b[ci])
336 if d < best_d { best_d = d; best_i = ci }
337 ci = ci + 1
338 }
339 sum_r[best_i] = sum_r[best_i] + pr
340 sum_g[best_i] = sum_g[best_i] + pg
341 sum_b[best_i] = sum_b[best_i] + pb
342 p.counts[best_i] = p.counts[best_i] + 1
343 x = x + 1
344 }
345 y = y + 1
346 }
347 // Update centroids.
348 j = 0
349 while j < k {
350 if p.counts[j] > 0 {
351 p.centroids_r[j] = sum_r[j] / p.counts[j]
352 p.centroids_g[j] = sum_g[j] / p.counts[j]
353 p.centroids_b[j] = sum_b[j] / p.counts[j]
354 }
355 j = j + 1
356 }
357 iter = iter + 1
358 }
359 return p
360}
361
362// ===== dominant channel detection ======================================
363//
364// Returns 0 if R is dominant, 1 if G, 2 if B, 3 if approximately
365// gray (max - min < threshold).
366
367const NX_COLOR_GRAY_THRESHOLD: i64 = 20
368
369func nx_color_dominant_channel(r: i64, g: i64, b: i64) -> i64 {
370 let max_v: i64 = nx_color_max3(r, g, b)
371 let min_v: i64 = nx_color_min3(r, g, b)
372 if max_v - min_v < NX_COLOR_GRAY_THRESHOLD { return 3 }
373 if max_v == r { return 0 }
374 if max_v == g { return 1 }
375 return 2
376}