code wiki / (root) / nx_color_v2.nx

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}