nx_image_grade_v1.nx source
↩ module page · 488 lines · 17233 B
1// nx_image_grade_v1.nx -- skin-localized image grading composer.
2//
3// CAPABILITY_COMPLETENESS: PARTIAL
4// MISSING_CAPABILITIES:
5// - face landmark detection (eye / mouth / nose / wrist /
6// inside-elbow keypoints): queued; needed for the still-
7// PENDING per-landmark axes (vein-signal at wrist,
8// subsurface at fingertips, micro-expression at face, etc.)
9// - VLM grader (the second triangulation source)
10// - hand-specific segmentation: skin blob detection is generic;
11// hand vs face vs torso disambiguation queued
12//
13// COVERED (PROMOTED FROM v0):
14// - Skin-localized mean R/G/B via inline Kovac skin mask
15// - Lab + ITA + undertone computed on SKIN PIXELS ONLY
16// (no longer biased by clothing / background)
17// - Skin coverage fraction (skin_pixels / total_pixels Q10)
18// - Connected-component segmentation of skin region
19// - Largest skin blob bbox -> caller can invoke per-region
20// graders against it
21//
22// Composes:
23// - nx_png_decoder + nx_jpeg_decoder (auto-detect by magic)
24// - inline Kovac skin-pixel test (per Kovac 2003)
25// - nx_segmenter_classical (connected-component labeling)
26// - nx_lab_from_rgb (CIELab Q10)
27// - nx_skin_tone_ita + nx_undertone_classify
28// - nx_verdict_phrase
29//
30// genealogy_id: substrate_image_grade_v1_skin_localized_2026_05_16
31// lineage_id: nx_image_grade_v1
32
33// nx_safety_envelope:
34// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
35// sil_target: SIL1
36// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
37// verdict: NOT_YET_EVALUATED
38
39import "nx_syscalls.nx"
40import "nx_runtime.nx"
41import "nx_tier.nx"
42import "nx_png_decoder.nx"
43import "nx_jpeg_decoder.nx"
44import "nx_verdict_phrase.nx"
45import "nx_lab_from_rgb.nx"
46import "nx_skin_tone_ita.nx"
47import "nx_undertone_class.nx"
48import "nx_segmenter_classical.nx"
49const NX_MAGIC_1024: i64 = 1024
50
51// ===== sealed: format + error codes (same as v0) =================
52
53const NX_IGV1_FORMAT_UNKNOWN: nx_int = 0
54const NX_IGV1_FORMAT_PNG: nx_int = 1
55const NX_IGV1_FORMAT_JPEG: nx_int = 2
56
57const NX_IGV1_OK: nx_int = 0
58const NX_IGV1_ERR_TOO_SHORT: nx_int = 1
59const NX_IGV1_ERR_UNKNOWN_FORMAT: nx_int = 2
60const NX_IGV1_ERR_DECODER: nx_int = 3
61const NX_IGV1_ERR_NULL_PIXELS: nx_int = 4
62
63// ===== per-axis status (same as v0) ==============================
64
65const NX_IGV1_AXIS_OK: nx_int = 0
66const NX_IGV1_AXIS_PENDING_SEGMENTATION: nx_int = 1
67const NX_IGV1_AXIS_PENDING_LANDMARK: nx_int = 2
68const NX_IGV1_AXIS_PENDING_VLM: nx_int = 3
69const NX_IGV1_AXIS_NOT_APPLICABLE: nx_int = 4
70const NX_IGV1_AXIS_NO_SKIN_DETECTED: nx_int = 5
71
72// ===== per-axis verdict ==========================================
73
74struct NxIgv1AxisVerdict {
75 axis_id: nx_int,
76 status: nx_int,
77 verdict_value: nx_int,
78 score_q10: nx_int,
79 phrase_buf: *u8,
80 phrase_len: nx_int,
81}
82
83const NX_IGV1_AXIS_VERDICT_BYTES: nx_size = 48
84
85// ===== top-level report ==========================================
86
87struct NxIgv1Report {
88 source_format: nx_int,
89 width: nx_int,
90 height: nx_int,
91 error_code: nx_int,
92 n_axes: nx_int,
93 axes: *NxIgv1AxisVerdict,
94 // Whole-image stats
95 mean_r: nx_int,
96 mean_g: nx_int,
97 mean_b: nx_int,
98 // Skin-localized stats (the v1 promotion)
99 skin_pixel_count: nx_int,
100 skin_mean_r: nx_int,
101 skin_mean_g: nx_int,
102 skin_mean_b: nx_int,
103 skin_coverage_q10: nx_int,
104 // Largest skin blob
105 n_skin_blobs: nx_int,
106 largest_blob_x0: nx_int,
107 largest_blob_y0: nx_int,
108 largest_blob_x1: nx_int,
109 largest_blob_y1: nx_int,
110 largest_blob_pixels: nx_int,
111}
112
113const NX_IGV1_REPORT_BYTES: nx_size = 144
114
115// Axis IDs (same as v0).
116const NX_IGV1_AX_DIMENSIONS: nx_int = 0
117const NX_IGV1_AX_LUMINANCE: nx_int = 1
118const NX_IGV1_AX_SKIN_TONE_ITA: nx_int = 4
119const NX_IGV1_AX_GLCM_TEXTURE: nx_int = 5
120const NX_IGV1_AX_PORE_DETAIL: nx_int = 7
121const NX_IGV1_AX_UNDERTONE: nx_int = 12
122const NX_IGV1_AX_VEIN_SIGNAL: nx_int = 16
123const NX_IGV1_AX_SUBSURFACE_SIGNAL: nx_int = 17
124const NX_IGV1_AX_MICRO_EXPRESSION: nx_int = 22
125const NX_IGV1_AX_SHADOW_DIRECTION: nx_int = 23
126const NX_IGV1_AX_FINGER_COUNT: nx_int = 24
127const NX_IGV1_AX_SURFACE_PLANE: nx_int = 27
128const NX_IGV1_AX_SKIN_COVERAGE: nx_int = 100 // v1 addition
129
130const NX_IGV1_N_AXES: nx_int = 13
131
132// ===== format detect (same as v0) ================================
133
134func _igv1_detect_format(buf: *u8, n: nx_int) -> nx_int {
135 if n < 4 { return NX_IGV1_FORMAT_UNKNOWN }
136 if buf[0] == (137 as u8) {
137 if buf[1] == (80 as u8) {
138 if buf[2] == (78 as u8) {
139 if buf[3] == (71 as u8) {
140 return NX_IGV1_FORMAT_PNG
141 }
142 }
143 }
144 }
145 if buf[0] == (255 as u8) {
146 if buf[1] == (216 as u8) {
147 return NX_IGV1_FORMAT_JPEG
148 }
149 }
150 return NX_IGV1_FORMAT_UNKNOWN
151}
152
153// ===== Kovac skin-pixel test =====================================
154//
155// Per Kovac/Solina 2003 (Human Skin Colour Clustering for Face
156// Detection): RGB-space rules for typical-skin-tone pixels.
157//
158// R > 95 AND G > 40 AND B > 20
159// AND max(R, G, B) - min(R, G, B) > 15
160// AND |R - G| > 15
161// AND R > G AND R > B
162//
163// Substrate constraint: works on the bright-light rule set. A
164// secondary dim-light rule set is queued for v2.
165
166func _igv1_is_skin_pixel(r: nx_int, g: nx_int, b: nx_int) -> nx_int {
167 if r <= 95 { return 0 }
168 if g <= 40 { return 0 }
169 if b <= 20 { return 0 }
170 var max_v: nx_int = r
171 if g > max_v { max_v = g }
172 if b > max_v { max_v = b }
173 var min_v: nx_int = r
174 if g < min_v { min_v = g }
175 if b < min_v { min_v = b }
176 if (max_v - min_v) <= 15 { return 0 }
177 var rg_diff: nx_int = r - g
178 if rg_diff < 0 { rg_diff = 0 - rg_diff }
179 if rg_diff <= 15 { return 0 }
180 if r <= g { return 0 }
181 if r <= b { return 0 }
182 return 1
183}
184
185// ===== axis filler ===============================================
186
187func _igv1_set_axis(axes: *NxIgv1AxisVerdict, idx: nx_int,
188 axis_id: nx_int, status: nx_int,
189 verdict_value: nx_int, score_q10: nx_int) -> nx_int {
190 let slot: *NxIgv1AxisVerdict =
191 (axes as *u8 + (idx as nx_size) * NX_IGV1_AXIS_VERDICT_BYTES) as *NxIgv1AxisVerdict
192 slot.axis_id = axis_id
193 slot.status = status
194 slot.verdict_value = verdict_value
195 slot.score_q10 = score_q10
196 let phrase_buf: *u8 = (sys_mmap(128)) as *u8
197 if status == NX_IGV1_AXIS_OK {
198 let p: *u8 = nx_verdict_phrase_for(axis_id, verdict_value, NX_VP_MODE_TERSE)
199 var c: nx_int = 0
200 var safety: nx_int = 0
201 while safety < 127 {
202 let ch: nx_int = (p[c] as nx_int) & 255
203 if ch == 0 { break }
204 phrase_buf[c] = ch as u8
205 c = c + 1
206 safety = safety + 1
207 }
208 phrase_buf[c] = 0 as u8
209 slot.phrase_buf = phrase_buf
210 slot.phrase_len = c
211 } else {
212 var pmsg: *u8 = 0 as *u8
213 if status == NX_IGV1_AXIS_PENDING_SEGMENTATION {
214 pmsg = "pending segmenter" as *u8
215 }
216 if status == NX_IGV1_AXIS_PENDING_LANDMARK {
217 pmsg = "pending landmark detection" as *u8
218 }
219 if status == NX_IGV1_AXIS_PENDING_VLM {
220 pmsg = "pending vlm grader" as *u8
221 }
222 if status == NX_IGV1_AXIS_NOT_APPLICABLE {
223 pmsg = "not applicable" as *u8
224 }
225 if status == NX_IGV1_AXIS_NO_SKIN_DETECTED {
226 pmsg = "no skin pixels detected" as *u8
227 }
228 if pmsg == (0 as *u8) { pmsg = "unknown status" as *u8 }
229 var c2: nx_int = 0
230 var s2: nx_int = 0
231 while s2 < 127 {
232 let ch2: nx_int = (pmsg[c2] as nx_int) & 255
233 if ch2 == 0 { break }
234 phrase_buf[c2] = ch2 as u8
235 c2 = c2 + 1
236 s2 = s2 + 1
237 }
238 phrase_buf[c2] = 0 as u8
239 slot.phrase_buf = phrase_buf
240 slot.phrase_len = c2
241 }
242 return 0
243}
244
245// ===== top-level trigger =========================================
246
247func nx_image_grade_v1(input: *u8, input_size: nx_int) -> *NxIgv1Report {
248 let r_ptr: *u8 = sys_mmap(NX_IGV1_REPORT_BYTES)
249 let r: *NxIgv1Report = r_ptr as *NxIgv1Report
250 r.error_code = NX_IGV1_OK
251 r.source_format = NX_IGV1_FORMAT_UNKNOWN
252 r.skin_pixel_count = 0
253
254 if input_size < 8 {
255 r.error_code = NX_IGV1_ERR_TOO_SHORT
256 return r
257 }
258
259 let fmt: nx_int = _igv1_detect_format(input, input_size)
260 r.source_format = fmt
261 if fmt == NX_IGV1_FORMAT_UNKNOWN {
262 r.error_code = NX_IGV1_ERR_UNKNOWN_FORMAT
263 return r
264 }
265
266 let axes_bytes: nx_size = (NX_IGV1_N_AXES as nx_size) * NX_IGV1_AXIS_VERDICT_BYTES
267 let axes: *NxIgv1AxisVerdict = (sys_mmap(axes_bytes)) as *NxIgv1AxisVerdict
268 r.axes = axes
269 r.n_axes = NX_IGV1_N_AXES
270
271 var pixels: *u8 = 0 as *u8
272 var n_ch: nx_int = 0
273 var bpp: nx_int = 0
274 var w: nx_int = 0
275 var h: nx_int = 0
276
277 if fmt == NX_IGV1_FORMAT_PNG {
278 let png_r: *NxPngResult = nx_png_decode(input, input_size)
279 if png_r == (0 as *NxPngResult) {
280 r.error_code = NX_IGV1_ERR_DECODER
281 return r
282 }
283 if png_r.error_code != NX_PNG_OK {
284 r.error_code = NX_IGV1_ERR_DECODER
285 return r
286 }
287 pixels = png_r.pixels
288 n_ch = png_r.n_channels
289 bpp = png_r.bytes_per_pix
290 if png_r.header != (0 as *NxPngHeader) {
291 w = png_r.header.width
292 h = png_r.header.height
293 }
294 }
295 if fmt == NX_IGV1_FORMAT_JPEG {
296 let jpg_r: *NxJpgResult = nx_jpeg_decode(input, input_size)
297 if jpg_r == (0 as *NxJpgResult) {
298 r.error_code = NX_IGV1_ERR_DECODER
299 return r
300 }
301 if jpg_r.error_code != NX_JPG_OK {
302 r.error_code = NX_IGV1_ERR_DECODER
303 return r
304 }
305 pixels = jpg_r.rgb
306 n_ch = 3
307 bpp = 3
308 w = jpg_r.width
309 h = jpg_r.height
310 }
311 if pixels == (0 as *u8) {
312 r.error_code = NX_IGV1_ERR_NULL_PIXELS
313 return r
314 }
315 r.width = w
316 r.height = h
317
318 // Single-pass: whole-image RGB accumulation + skin-mask
319 // construction + skin-pixel RGB accumulation.
320 let n_pixels: nx_int = w * h
321 let mask_bytes: nx_size = (n_pixels as nx_size) * 8
322 let skin_mask: *nx_int = (sys_mmap(mask_bytes)) as *nx_int
323 var sum_r: nx_int = 0
324 var sum_g: nx_int = 0
325 var sum_b: nx_int = 0
326 var sum_sr: nx_int = 0
327 var sum_sg: nx_int = 0
328 var sum_sb: nx_int = 0
329 var n_skin: nx_int = 0
330 var y: nx_int = 0
331 while y < h {
332 var x: nx_int = 0
333 while x < w {
334 let pix_off: nx_int = (y * w + x) * bpp
335 var r_val: nx_int = (pixels[pix_off] as nx_int) & 255
336 var g_val: nx_int = r_val
337 var b_val: nx_int = r_val
338 if n_ch >= 3 {
339 g_val = (pixels[pix_off + 1] as nx_int) & 255
340 b_val = (pixels[pix_off + 2] as nx_int) & 255
341 }
342 sum_r = sum_r + r_val
343 sum_g = sum_g + g_val
344 sum_b = sum_b + b_val
345 let pix_idx: nx_int = y * w + x
346 let is_skin: nx_int = _igv1_is_skin_pixel(r_val, g_val, b_val)
347 skin_mask[pix_idx] = is_skin
348 if is_skin == 1 {
349 sum_sr = sum_sr + r_val
350 sum_sg = sum_sg + g_val
351 sum_sb = sum_sb + b_val
352 n_skin = n_skin + 1
353 }
354 x = x + 1
355 }
356 y = y + 1
357 }
358 if n_pixels <= 0 {
359 r.error_code = NX_IGV1_ERR_NULL_PIXELS
360 return r
361 }
362 r.mean_r = sum_r / n_pixels
363 r.mean_g = sum_g / n_pixels
364 r.mean_b = sum_b / n_pixels
365 r.skin_pixel_count = n_skin
366 r.skin_coverage_q10 = (n_skin * NX_MAGIC_1024) / n_pixels
367
368 // Run segmenter on the skin mask.
369 let seg_r: *NxSegResult = nx_segmenter_connected(skin_mask, w, h)
370 if seg_r != (0 as *NxSegResult) {
371 r.n_skin_blobs = seg_r.n_blobs
372 if seg_r.n_blobs >= 1 {
373 let largest: *NxSegBlob = nx_segmenter_largest_blob(seg_r)
374 if largest != (0 as *NxSegBlob) {
375 r.largest_blob_x0 = largest.x0
376 r.largest_blob_y0 = largest.y0
377 r.largest_blob_x1 = largest.x1
378 r.largest_blob_y1 = largest.y1
379 r.largest_blob_pixels = largest.pixel_count
380 }
381 }
382 }
383
384 // Skin-localized chroma classification.
385 let no_skin: nx_int = 0
386 if n_skin <= 0 {
387 // No skin detected. Mark chroma axes appropriately.
388 r.skin_mean_r = 0
389 r.skin_mean_g = 0
390 r.skin_mean_b = 0
391 _igv1_set_axis(axes, 0, NX_IGV1_AX_SKIN_TONE_ITA,
392 NX_IGV1_AXIS_NO_SKIN_DETECTED, 0, 0)
393 _igv1_set_axis(axes, 1, NX_IGV1_AX_UNDERTONE,
394 NX_IGV1_AXIS_NO_SKIN_DETECTED, 0, 0)
395 } else {
396 r.skin_mean_r = sum_sr / n_skin
397 r.skin_mean_g = sum_sg / n_skin
398 r.skin_mean_b = sum_sb / n_skin
399 // Lab on SKIN-ONLY mean (the v1 promotion).
400 let lab: *NxLabColor = nx_lab_from_rgb(r.skin_mean_r, r.skin_mean_g, r.skin_mean_b)
401 let ita_r: *NxSkinToneIta = nx_skin_tone_ita_compute(
402 lab.l_q10, lab.a_q10, lab.b_q10)
403 _igv1_set_axis(axes, 0, NX_IGV1_AX_SKIN_TONE_ITA,
404 NX_IGV1_AXIS_OK, ita_r.band, ita_r.ita_deg_q10)
405 let und_r: *NxUndertoneResult = nx_undertone_classify(
406 lab.a_q10, lab.b_q10)
407 _igv1_set_axis(axes, 1, NX_IGV1_AX_UNDERTONE,
408 NX_IGV1_AXIS_OK, und_r.verdict, und_r.conf_q10)
409 }
410
411 // Whole-image luma + skin coverage.
412 let mean_luma: nx_int = (306 * r.mean_r + 601 * r.mean_g + 117 * r.mean_b) / NX_MAGIC_1024
413 _igv1_set_axis(axes, 2, NX_IGV1_AX_LUMINANCE,
414 NX_IGV1_AXIS_OK, 0, mean_luma * 4)
415 _igv1_set_axis(axes, 3, NX_IGV1_AX_SKIN_COVERAGE,
416 NX_IGV1_AXIS_OK, 0, r.skin_coverage_q10)
417
418 // Dimensions.
419 _igv1_set_axis(axes, 4, NX_IGV1_AX_DIMENSIONS,
420 NX_IGV1_AXIS_OK, 0, w * h)
421
422 // Landmark-pending axes (still need face / hand keypoint detection).
423 _igv1_set_axis(axes, 5, NX_IGV1_AX_GLCM_TEXTURE,
424 NX_IGV1_AXIS_PENDING_LANDMARK, 0, 0)
425 _igv1_set_axis(axes, 6, NX_IGV1_AX_PORE_DETAIL,
426 NX_IGV1_AXIS_PENDING_LANDMARK, 0, 0)
427 _igv1_set_axis(axes, 7, NX_IGV1_AX_VEIN_SIGNAL,
428 NX_IGV1_AXIS_PENDING_LANDMARK, 0, 0)
429 _igv1_set_axis(axes, 8, NX_IGV1_AX_SUBSURFACE_SIGNAL,
430 NX_IGV1_AXIS_PENDING_LANDMARK, 0, 0)
431 _igv1_set_axis(axes, 9, NX_IGV1_AX_MICRO_EXPRESSION,
432 NX_IGV1_AXIS_PENDING_LANDMARK, 0, 0)
433 _igv1_set_axis(axes, 10, NX_IGV1_AX_SHADOW_DIRECTION,
434 NX_IGV1_AXIS_PENDING_LANDMARK, 0, 0)
435 _igv1_set_axis(axes, 11, NX_IGV1_AX_FINGER_COUNT,
436 NX_IGV1_AXIS_PENDING_LANDMARK, 0, 0)
437 _igv1_set_axis(axes, 12, NX_IGV1_AX_SURFACE_PLANE,
438 NX_IGV1_AXIS_PENDING_LANDMARK, 0, 0)
439 return r
440}
441
442// ===== self-test =================================================
443
444func main() -> nx_int {
445 // ---- Kovac skin pixel rule ----
446 // (200, 170, 150): R>95, G>40, B>20; max-min = 50 > 15; |R-G|=30 > 15; R>G, R>B.
447 if _igv1_is_skin_pixel(200, 170, 150) != 1 { return 1 }
448 // (50, 50, 50): R <= 95 -> not skin
449 if _igv1_is_skin_pixel(50, 50, 50) != 0 { return 2 }
450 // (100, 100, 100): R-G = 0, R not > G -> not skin
451 if _igv1_is_skin_pixel(100, 100, 100) != 0 { return 3 }
452 // (255, 0, 0) pure red: G <= 40 -> not skin
453 if _igv1_is_skin_pixel(255, 0, 0) != 0 { return 4 }
454 // (75, 60, 45) dark skin: R = 75 not > 95 -> NOT classified by Kovac
455 // (the rule is calibrated for lighter tones; dim-light variant queued).
456 if _igv1_is_skin_pixel(75, 60, 45) != 0 { return 5 }
457 // (180, 130, 100) tan skin: passes all rules
458 if _igv1_is_skin_pixel(180, 130, 100) != 1 { return 6 }
459
460 // ---- format detect (sanity) ----
461 let pm: *u8 = (sys_mmap(4)) as *u8
462 pm[0] = 137 as u8
463 pm[1] = 80 as u8
464 pm[2] = 78 as u8
465 pm[3] = 71 as u8
466 if _igv1_detect_format(pm, 4) != NX_IGV1_FORMAT_PNG { return 10 }
467
468 let jm: *u8 = (sys_mmap(4)) as *u8
469 jm[0] = 255 as u8
470 jm[1] = 216 as u8
471 if _igv1_detect_format(jm, 4) != NX_IGV1_FORMAT_JPEG { return 11 }
472
473 // ---- TOO_SHORT path ----
474 let r_short: *NxIgv1Report = nx_image_grade_v1(pm, 4)
475 if r_short.error_code != NX_IGV1_ERR_TOO_SHORT { return 20 }
476
477 // ---- UNKNOWN_FORMAT path ----
478 let bad: *u8 = (sys_mmap(8)) as *u8
479 var i: nx_int = 0
480 while i < 8 {
481 bad[i] = 0 as u8
482 i = i + 1
483 }
484 let r_unk: *NxIgv1Report = nx_image_grade_v1(bad, 8)
485 if r_unk.error_code != NX_IGV1_ERR_UNKNOWN_FORMAT { return 21 }
486
487 return 0
488}