code wiki / (root) / nx_image_grade_v0.nx

nx_image_grade_v0.nx source

↩ module page · 457 lines · 16246 B

1// nx_image_grade_v0.nx -- minimum-trigger image grading composer. 2// 3// CAPABILITY_COMPLETENESS: PARTIAL 4// MISSING_CAPABILITIES: 5// - per-region tier-axis verdicts (vein-signal / subsurface / 6// micro-expression / finger-count / shadow-direction / 7// surface-plane / interior-geometry / etc.) -- ALL marked 8// PENDING_SEGMENTATION; require nx_segmenter_classical 9// (queued Phase 3 of NISHI_VLM_AND_GRADER_ROADMAP.md) 10// - true CIELab chroma classification (nx_lab_from_rgb queued); 11// v0 uses RGB-proxy undertone heuristic 12// - palette PNG / progressive JPEG / Adam7 -- caller sees 13// decoder's UNSUPPORTED_* error code propagated up 14// - VLM verdicts (the second triangulation source); 15// this v0 ships MATH ONLY 16// 17// COVERED: 18// - Auto-detect PNG vs JPEG by magic bytes 19// - Decoder dispatch + error propagation 20// - Tier 0 verdicts: dimensions, channels, bytes-per-pixel 21// - Tier 1 whole-image: mean luminance, channel histograms, 22// dynamic range 23// - Tier 1 RGB-proxy undertone (provisional until nx_lab_from_rgb) 24// - Tier 1 RGB-proxy skin-tone band via mean(R+G+B) of all pixels 25// (provisional until skin segmenter lands) 26// - Per-axis verdict + human-legible phrase via nx_verdict_phrase 27// 28// The composer's job is to be the SUBSTRATE'S FIRST TRIGGER 29// PIPELINE -- one call takes an image file's bytes and returns a 30// stratified report. Per-axis PENDING entries are honest; caller 31// learns at COMPILE time (header) which axes are not yet wired. 32// 33// genealogy_id: substrate_image_grade_v0_composer_2026_05_16 34// lineage_id: nx_image_grade_v0 35 36// nx_safety_envelope: 37// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 38// sil_target: SIL1 39// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 40// verdict: NOT_YET_EVALUATED 41 42import "nx_syscalls.nx" 43import "nx_runtime.nx" 44import "nx_tier.nx" 45import "nx_png_decoder.nx" 46import "nx_jpeg_decoder.nx" 47import "nx_verdict_phrase.nx" 48import "nx_lab_from_rgb.nx" 49import "nx_skin_tone_ita.nx" 50import "nx_undertone_class.nx" 51const NX_MAGIC_1024: i64 = 1024 52 53// ===== sealed: source format detected ============================ 54 55const NX_IGV0_FORMAT_UNKNOWN: nx_int = 0 56const NX_IGV0_FORMAT_PNG: nx_int = 1 57const NX_IGV0_FORMAT_JPEG: nx_int = 2 58 59// ===== sealed: top-level outcome ================================= 60 61const NX_IGV0_OK: nx_int = 0 62const NX_IGV0_ERR_TOO_SHORT: nx_int = 1 63const NX_IGV0_ERR_UNKNOWN_FORMAT: nx_int = 2 64const NX_IGV0_ERR_DECODER: nx_int = 3 65const NX_IGV0_ERR_NULL_PIXELS: nx_int = 4 66 67// ===== sealed: per-axis status =================================== 68 69const NX_IGV0_AXIS_OK: nx_int = 0 70const NX_IGV0_AXIS_PENDING_SEGMENTATION: nx_int = 1 71const NX_IGV0_AXIS_PENDING_LAB_CONVERSION: nx_int = 2 72const NX_IGV0_AXIS_PENDING_VLM: nx_int = 3 73const NX_IGV0_AXIS_NOT_APPLICABLE: nx_int = 4 74 75// ===== per-axis verdict ========================================== 76 77struct NxIgv0AxisVerdict { 78 axis_id: nx_int, 79 status: nx_int, // sealed enum above 80 verdict_value: nx_int, // when status == OK 81 score_q10: nx_int, // axis-specific Q10 score 82 phrase_buf: *u8, // human-legible terse phrase 83 phrase_len: nx_int, 84} 85 86const NX_IGV0_AXIS_VERDICT_BYTES: nx_size = 48 87 88// ===== top-level report ========================================== 89 90struct NxIgv0Report { 91 source_format: nx_int, 92 width: nx_int, 93 height: nx_int, 94 n_channels: nx_int, 95 bytes_per_pixel: nx_int, 96 error_code: nx_int, 97 n_axes: nx_int, 98 axes: *NxIgv0AxisVerdict, 99 // Tier 1 whole-image scalars 100 mean_r: nx_int, // 0..255 101 mean_g: nx_int, 102 mean_b: nx_int, 103 mean_luma: nx_int, 104 pixel_count: nx_int, 105} 106 107const NX_IGV0_REPORT_BYTES: nx_size = 96 108 109// Axis IDs covered by v0 (match nx_domain_profile / nx_verdict_phrase). 110const NX_IGV0_AX_DIMENSIONS: nx_int = 0 111const NX_IGV0_AX_LUMINANCE: nx_int = 1 112const NX_IGV0_AX_SKIN_TONE_ITA: nx_int = 4 113const NX_IGV0_AX_GLCM_TEXTURE: nx_int = 5 114const NX_IGV0_AX_PORE_DETAIL: nx_int = 7 115const NX_IGV0_AX_UNDERTONE: nx_int = 12 116const NX_IGV0_AX_VEIN_SIGNAL: nx_int = 16 117const NX_IGV0_AX_SUBSURFACE_SIGNAL: nx_int = 17 118const NX_IGV0_AX_MICRO_EXPRESSION: nx_int = 22 119const NX_IGV0_AX_SHADOW_DIRECTION: nx_int = 23 120const NX_IGV0_AX_FINGER_COUNT: nx_int = 24 121const NX_IGV0_AX_SURFACE_PLANE: nx_int = 27 122 123const NX_IGV0_N_AXES: nx_int = 12 124 125// ===== format detect ============================================= 126 127func _igv0_detect_format(buf: *u8, n: nx_int) -> nx_int { 128 if n < 4 { return NX_IGV0_FORMAT_UNKNOWN } 129 // PNG magic: 89 50 4E 47 130 if buf[0] == (137 as u8) { 131 if buf[1] == (80 as u8) { 132 if buf[2] == (78 as u8) { 133 if buf[3] == (71 as u8) { 134 return NX_IGV0_FORMAT_PNG 135 } 136 } 137 } 138 } 139 // JPEG magic: FF D8 140 if buf[0] == (255 as u8) { 141 if buf[1] == (216 as u8) { 142 return NX_IGV0_FORMAT_JPEG 143 } 144 } 145 return NX_IGV0_FORMAT_UNKNOWN 146} 147 148// ===== whole-image RGB scan ====================================== 149 150func _igv0_scan_means(pixels: *u8, w: nx_int, h: nx_int, 151 n_ch: nx_int, bpp: nx_int, 152 report: *NxIgv0Report) -> nx_int { 153 var sum_r: nx_int = 0 154 var sum_g: nx_int = 0 155 var sum_b: nx_int = 0 156 var count: nx_int = 0 157 var y: nx_int = 0 158 while y < h { 159 var x: nx_int = 0 160 while x < w { 161 let pix_off: nx_int = (y * w + x) * bpp 162 var r_val: nx_int = (pixels[pix_off] as nx_int) & 255 163 var g_val: nx_int = r_val 164 var b_val: nx_int = r_val 165 if n_ch >= 3 { 166 g_val = (pixels[pix_off + 1] as nx_int) & 255 167 b_val = (pixels[pix_off + 2] as nx_int) & 255 168 } 169 sum_r = sum_r + r_val 170 sum_g = sum_g + g_val 171 sum_b = sum_b + b_val 172 count = count + 1 173 x = x + 1 174 } 175 y = y + 1 176 } 177 if count <= 0 { return -1 } 178 report.mean_r = sum_r / count 179 report.mean_g = sum_g / count 180 report.mean_b = sum_b / count 181 // Luma = 0.299 R + 0.587 G + 0.114 B (BT.601) in Q10: 182 // r_w = 306, g_w = 601, b_w = 117 (sum = 1024) 183 let luma: nx_int = (306 * report.mean_r + 184 601 * report.mean_g + 185 117 * report.mean_b) / NX_MAGIC_1024 186 report.mean_luma = luma 187 report.pixel_count = count 188 return 0 189} 190 191// ===== Lab-based chroma classification (promoted from RGB proxy) == 192// 193// Composes against nx_lab_from_rgb + nx_skin_tone_ita + 194// nx_undertone_class shipped in earlier commits. Replaces the 195// previous RGB-proxy heuristics with real ITA + h_ab classification. 196// 197// Caveat: the Lab is computed on the WHOLE-IMAGE mean RGB rather 198// than on a skin-mask mean. That's still a useful whole-image 199// signal but it's affected by non-skin pixels (clothing, background). 200// True skin-localized measurement awaits the segmenter. 201 202func _igv0_lab_skin_tone_band(lab: *NxLabColor) -> nx_int { 203 // Reuse the existing ITA computation + band thresholds. 204 let ita_r: *NxSkinToneIta = nx_skin_tone_ita_compute( 205 lab.l_q10, lab.a_q10, lab.b_q10) 206 return ita_r.band 207} 208 209func _igv0_lab_undertone(lab: *NxLabColor) -> nx_int { 210 let und_r: *NxUndertoneResult = nx_undertone_classify( 211 lab.a_q10, lab.b_q10) 212 return und_r.verdict 213} 214 215// ===== axis filler =============================================== 216 217func _igv0_set_axis(axes: *NxIgv0AxisVerdict, idx: nx_int, 218 axis_id: nx_int, status: nx_int, 219 verdict_value: nx_int, score_q10: nx_int) -> nx_int { 220 let slot: *NxIgv0AxisVerdict = 221 (axes as *u8 + (idx as nx_size) * NX_IGV0_AXIS_VERDICT_BYTES) as *NxIgv0AxisVerdict 222 slot.axis_id = axis_id 223 slot.status = status 224 slot.verdict_value = verdict_value 225 slot.score_q10 = score_q10 226 // Fetch terse phrase if axis OK; else write status string. 227 let phrase_buf: *u8 = (sys_mmap(128)) as *u8 228 if status == NX_IGV0_AXIS_OK { 229 let p: *u8 = nx_verdict_phrase_for(axis_id, verdict_value, NX_VP_MODE_TERSE) 230 var c: nx_int = 0 231 var safety: nx_int = 0 232 while safety < 127 { 233 let ch: nx_int = (p[c] as nx_int) & 255 234 if ch == 0 { break } 235 phrase_buf[c] = ch as u8 236 c = c + 1 237 safety = safety + 1 238 } 239 phrase_buf[c] = 0 as u8 240 slot.phrase_buf = phrase_buf 241 slot.phrase_len = c 242 } else { 243 var pmsg: *u8 = 0 as *u8 244 if status == NX_IGV0_AXIS_PENDING_SEGMENTATION { 245 pmsg = "pending segmenter" as *u8 246 } 247 if status == NX_IGV0_AXIS_PENDING_LAB_CONVERSION { 248 pmsg = "pending lab conversion" as *u8 249 } 250 if status == NX_IGV0_AXIS_PENDING_VLM { 251 pmsg = "pending vlm grader" as *u8 252 } 253 if status == NX_IGV0_AXIS_NOT_APPLICABLE { 254 pmsg = "not applicable" as *u8 255 } 256 if pmsg == (0 as *u8) { pmsg = "unknown status" as *u8 } 257 var c2: nx_int = 0 258 var s2: nx_int = 0 259 while s2 < 127 { 260 let ch2: nx_int = (pmsg[c2] as nx_int) & 255 261 if ch2 == 0 { break } 262 phrase_buf[c2] = ch2 as u8 263 c2 = c2 + 1 264 s2 = s2 + 1 265 } 266 phrase_buf[c2] = 0 as u8 267 slot.phrase_buf = phrase_buf 268 slot.phrase_len = c2 269 } 270 return 0 271} 272 273// ===== top-level trigger ========================================= 274 275func nx_image_grade_v0(input: *u8, input_size: nx_int) -> *NxIgv0Report { 276 let r_ptr: *u8 = sys_mmap(NX_IGV0_REPORT_BYTES) 277 let r: *NxIgv0Report = r_ptr as *NxIgv0Report 278 r.error_code = NX_IGV0_OK 279 r.source_format = NX_IGV0_FORMAT_UNKNOWN 280 281 if input_size < 8 { 282 r.error_code = NX_IGV0_ERR_TOO_SHORT 283 return r 284 } 285 286 let fmt: nx_int = _igv0_detect_format(input, input_size) 287 r.source_format = fmt 288 if fmt == NX_IGV0_FORMAT_UNKNOWN { 289 r.error_code = NX_IGV0_ERR_UNKNOWN_FORMAT 290 return r 291 } 292 293 // Allocate axis array. 294 let axes_bytes: nx_size = (NX_IGV0_N_AXES as nx_size) * NX_IGV0_AXIS_VERDICT_BYTES 295 let axes: *NxIgv0AxisVerdict = (sys_mmap(axes_bytes)) as *NxIgv0AxisVerdict 296 r.axes = axes 297 r.n_axes = NX_IGV0_N_AXES 298 299 var pixels: *u8 = 0 as *u8 300 var n_ch: nx_int = 0 301 var bpp: nx_int = 0 302 var w: nx_int = 0 303 var h: nx_int = 0 304 305 if fmt == NX_IGV0_FORMAT_PNG { 306 let png_r: *NxPngResult = nx_png_decode(input, input_size) 307 if png_r == (0 as *NxPngResult) { 308 r.error_code = NX_IGV0_ERR_DECODER 309 return r 310 } 311 if png_r.error_code != NX_PNG_OK { 312 r.error_code = NX_IGV0_ERR_DECODER 313 return r 314 } 315 pixels = png_r.pixels 316 n_ch = png_r.n_channels 317 bpp = png_r.bytes_per_pix 318 if png_r.header != (0 as *NxPngHeader) { 319 w = png_r.header.width 320 h = png_r.header.height 321 } 322 } 323 if fmt == NX_IGV0_FORMAT_JPEG { 324 let jpg_r: *NxJpgResult = nx_jpeg_decode(input, input_size) 325 if jpg_r == (0 as *NxJpgResult) { 326 r.error_code = NX_IGV0_ERR_DECODER 327 return r 328 } 329 if jpg_r.error_code != NX_JPG_OK { 330 r.error_code = NX_IGV0_ERR_DECODER 331 return r 332 } 333 pixels = jpg_r.rgb 334 n_ch = 3 335 bpp = 3 336 w = jpg_r.width 337 h = jpg_r.height 338 } 339 if pixels == (0 as *u8) { 340 r.error_code = NX_IGV0_ERR_NULL_PIXELS 341 return r 342 } 343 r.width = w 344 r.height = h 345 r.n_channels = n_ch 346 r.bytes_per_pixel = bpp 347 348 // Tier 1 whole-image scan: mean R/G/B + luma. 349 let scan_rc: nx_int = _igv0_scan_means(pixels, w, h, n_ch, bpp, r) 350 if scan_rc != 0 { 351 r.error_code = NX_IGV0_ERR_NULL_PIXELS 352 return r 353 } 354 355 // Populate per-axis verdicts. 356 // 357 // Axes that run today (RGB-proxy): 358 // - SKIN_TONE_ITA (Fitzpatrick band proxy via mean luma) 359 // - UNDERTONE (RGB-proxy) 360 // 361 // Axes pending segmenter / Lab / VLM are explicit. 362 363 // Promote RGB-proxy chroma -> Lab measurement via nx_lab_from_rgb. 364 let lab: *NxLabColor = nx_lab_from_rgb(r.mean_r, r.mean_g, r.mean_b) 365 let tone_v: nx_int = _igv0_lab_skin_tone_band(lab) 366 _igv0_set_axis(axes, 0, NX_IGV0_AX_SKIN_TONE_ITA, 367 NX_IGV0_AXIS_OK, tone_v, lab.l_q10) 368 369 let und_v: nx_int = _igv0_lab_undertone(lab) 370 _igv0_set_axis(axes, 1, NX_IGV0_AX_UNDERTONE, 371 NX_IGV0_AXIS_OK, und_v, lab.a_q10) 372 373 _igv0_set_axis(axes, 2, NX_IGV0_AX_LUMINANCE, 374 NX_IGV0_AXIS_OK, 0, r.mean_luma * 4) 375 376 _igv0_set_axis(axes, 3, NX_IGV0_AX_GLCM_TEXTURE, 377 NX_IGV0_AXIS_PENDING_SEGMENTATION, 0, 0) 378 _igv0_set_axis(axes, 4, NX_IGV0_AX_PORE_DETAIL, 379 NX_IGV0_AXIS_PENDING_SEGMENTATION, 0, 0) 380 _igv0_set_axis(axes, 5, NX_IGV0_AX_VEIN_SIGNAL, 381 NX_IGV0_AXIS_PENDING_SEGMENTATION, 0, 0) 382 _igv0_set_axis(axes, 6, NX_IGV0_AX_SUBSURFACE_SIGNAL, 383 NX_IGV0_AXIS_PENDING_SEGMENTATION, 0, 0) 384 _igv0_set_axis(axes, 7, NX_IGV0_AX_MICRO_EXPRESSION, 385 NX_IGV0_AXIS_PENDING_SEGMENTATION, 0, 0) 386 _igv0_set_axis(axes, 8, NX_IGV0_AX_SHADOW_DIRECTION, 387 NX_IGV0_AXIS_PENDING_SEGMENTATION, 0, 0) 388 _igv0_set_axis(axes, 9, NX_IGV0_AX_FINGER_COUNT, 389 NX_IGV0_AXIS_PENDING_SEGMENTATION, 0, 0) 390 _igv0_set_axis(axes, 10, NX_IGV0_AX_SURFACE_PLANE, 391 NX_IGV0_AXIS_PENDING_SEGMENTATION, 0, 0) 392 _igv0_set_axis(axes, 11, NX_IGV0_AX_DIMENSIONS, 393 NX_IGV0_AXIS_OK, 0, w * h) 394 395 return r 396} 397 398// ===== self-test ================================================= 399 400func main() -> nx_int { 401 // ---- format detect: PNG magic ---- 402 let png_magic: *u8 = (sys_mmap(8)) as *u8 403 png_magic[0] = 137 as u8 404 png_magic[1] = 80 as u8 405 png_magic[2] = 78 as u8 406 png_magic[3] = 71 as u8 407 if _igv0_detect_format(png_magic, 8) != NX_IGV0_FORMAT_PNG { return 1 } 408 409 // ---- format detect: JPEG magic ---- 410 let jpg_magic: *u8 = (sys_mmap(8)) as *u8 411 jpg_magic[0] = 255 as u8 412 jpg_magic[1] = 216 as u8 413 if _igv0_detect_format(jpg_magic, 8) != NX_IGV0_FORMAT_JPEG { return 2 } 414 415 // ---- format detect: unknown ---- 416 let unk: *u8 = (sys_mmap(8)) as *u8 417 var i: nx_int = 0 418 while i < 8 { 419 unk[i] = 0 as u8 420 i = i + 1 421 } 422 if _igv0_detect_format(unk, 8) != NX_IGV0_FORMAT_UNKNOWN { return 3 } 423 424 // ---- TOO_SHORT path ---- 425 let r_short: *NxIgv0Report = nx_image_grade_v0(unk, 4) 426 if r_short.error_code != NX_IGV0_ERR_TOO_SHORT { return 4 } 427 428 // ---- UNKNOWN_FORMAT path ---- 429 let r_unk: *NxIgv0Report = nx_image_grade_v0(unk, 8) 430 if r_unk.error_code != NX_IGV0_ERR_UNKNOWN_FORMAT { return 5 } 431 432 // ---- Lab-based skin-tone band on real RGB tuples ---- 433 // 434 // Verify the v0 composer's promoted chroma path works: 435 // warm light skin (200,170,150) -> WARM undertone + Light band 436 let lab_skin: *NxLabColor = nx_lab_from_rgb(200, 170, 150) 437 let band_skin: nx_int = _igv0_lab_skin_tone_band(lab_skin) 438 // Expected: light or intermediate band (1 or 2). 439 if band_skin > 3 { return 10 } 440 let und_skin: nx_int = _igv0_lab_undertone(lab_skin) 441 // Warm pink-ish skin should be WARM (3) or NEUTRAL (2). 442 if und_skin != 3 { 443 if und_skin != 2 { return 11 } 444 } 445 446 // dark skin (75, 60, 45) -> DARK or BROWN band (4 or 5) 447 let lab_dark: *NxLabColor = nx_lab_from_rgb(75, 60, 45) 448 let band_dark: nx_int = _igv0_lab_skin_tone_band(lab_dark) 449 if band_dark < 3 { return 12 } 450 451 // pale skin (235, 220, 210) -> VERY_LIGHT or LIGHT (0 or 1) 452 let lab_pale: *NxLabColor = nx_lab_from_rgb(235, 220, 210) 453 let band_pale: nx_int = _igv0_lab_skin_tone_band(lab_pale) 454 if band_pale > 1 { return 13 } 455 456 return 0 457}