code wiki / (root) / nx_skin_tone_ita.nx

nx_skin_tone_ita.nx source

↩ module page · 280 lines · 10000 B

1// nx_skin_tone_ita.nx -- Individual Typology Angle skin classifier. 2// 3// Tier 1 of nxc2/docs/IMAGE_VALIDATION_AMBIGUITY_LADDER.md. Maps 4// mean skin-pixel CIELab triple to a SCALAR (ITA in degrees Q10) 5// and a sealed 6-band classification approximating Fitzpatrick 6// types I-VI. 7// 8// Reference: del Bino & Bernerd 2013, "Variations in skin colour 9// and the biological consequences of ultraviolet radiation 10// exposure" (Brit J Dermatol). ITA definition: 11// 12// ITA = arctan( (L* - 50) / b* ) * (180/pi) degrees 13// 14// Bands per del Bino 2013 + Chardon 1991: 15// ITA > 55 -- Very Light (Fitzpatrick I) 16// 41 to 55 -- Light (Fitzpatrick II) 17// 28 to 41 -- Intermediate (Fitzpatrick III) 18// 10 to 28 -- Tan (Fitzpatrick IV) 19// -30 to 10 -- Brown (Fitzpatrick V) 20// < -30 -- Dark (Fitzpatrick VI) 21// 22// Inputs are pre-computed mean CIELab values in Q10 (multiply by 23// 1024). Substrate's discipline is to keep this primitive 24// SINGLE-RESPONSIBILITY: classify a given Lab triple. Caller 25// composes with nx_color_skin_mask + future nx_lab_from_rgb. 26// 27// genealogy_id: del_bino_2013_chardon_1991 28// lineage_id: substrate_skin_chroma_v1 29 30// nx_safety_envelope: 31// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 32// sil_target: SIL1 33// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 34// verdict: NOT_YET_EVALUATED 35 36import "nx_syscalls.nx" 37import "nx_runtime.nx" 38import "nx_tier.nx" 39const NX_MAGIC_46080: i64 = 46080 40const NX_MAGIC_27203: i64 = 27203 41const NX_MAGIC_14373: i64 = 14373 42const NX_MAGIC_7296: i64 = 7296 43const NX_MAGIC_3662: i64 = 3662 44const NX_MAGIC_1833: i64 = 1833 45const NX_MAGIC_1024: i64 = 1024 46 47const NX_ITA_Q10: nx_int = 1024 48const NX_ITA_DEG_PER_RAD_Q10: nx_int = 58695 // 57.2958 * 1024 49 50// Sealed-enum band IDs. 51const NX_ITA_BAND_VERY_LIGHT: nx_int = 0 52const NX_ITA_BAND_LIGHT: nx_int = 1 53const NX_ITA_BAND_INTERMEDIATE: nx_int = 2 54const NX_ITA_BAND_TAN: nx_int = 3 55const NX_ITA_BAND_BROWN: nx_int = 4 56const NX_ITA_BAND_DARK: nx_int = 5 57const NX_ITA_BAND_N: nx_int = 6 58 59// del Bino thresholds in Q10 degrees. 60const NX_ITA_THR_VERY_LIGHT_Q10: nx_int = 56320 // 55 * 1024 61const NX_ITA_THR_LIGHT_Q10: nx_int = 41984 // 41 * 1024 62const NX_ITA_THR_INTERMEDIATE_Q10: nx_int = 28672 // 28 * 1024 63const NX_ITA_THR_TAN_Q10: nx_int = 10240 // 10 * 1024 64const NX_ITA_THR_BROWN_Q10: nx_int = -30720 // -30 * 1024 65 66// ===== result struct =============================================== 67 68struct NxSkinToneIta { 69 l_q10: nx_int, 70 a_q10: nx_int, 71 b_q10: nx_int, 72 ita_deg_q10: nx_int, 73 band: nx_int, 74 fitzpatrick: nx_int, // I..VI = band + 1 75} 76 77const NX_ITA_RESULT_BYTES: nx_size = 48 78 79// ===== CORDIC atan2 (8 iterations, bounded loop per JPL Rule 2) === 80// 81// Vectoring-mode CORDIC: rotates input (x, y) toward x-axis, 82// accumulating the rotation angle in z. Result is atan2(y, x) in 83// Q10 degrees. Convergence: 8 iterations -> ~0.22 deg accuracy. 84 85const NX_ITA_CORDIC_ITERS: nx_int = 8 86 87// Precomputed atan(2^-i) in Q10 degrees. 88// i=0: 45.000 -> 46080 89// i=1: 26.565 -> 27203 90// i=2: 14.036 -> 14373 91// i=3: 7.125 -> 7296 92// i=4: 3.576 -> 3662 93// i=5: 1.790 -> 1833 94// i=6: 0.895 -> 917 95// i=7: 0.448 -> 458 96 97func _ita_cordic_angle_q10(i: nx_int) -> nx_int { 98 if i == 0 { return NX_MAGIC_46080 } 99 if i == 1 { return NX_MAGIC_27203 } 100 if i == 2 { return NX_MAGIC_14373 } 101 if i == 3 { return NX_MAGIC_7296 } 102 if i == 4 { return NX_MAGIC_3662 } 103 if i == 5 { return NX_MAGIC_1833 } 104 if i == 6 { return 917 } 105 if i == 7 { return 458 } 106 return 0 107} 108 109// atan2(y, x) in Q10 degrees. Handles all four quadrants. 110// 111// Pre-rotation: 112// if x < 0: 113// rotate (x, y) by +/- 90 deg into right half-plane 114// then run CORDIC vectoring. 115 116func _ita_atan2_q10(y_q10: nx_int, x_q10: nx_int) -> nx_int { 117 // Special cases. 118 if x_q10 == 0 { 119 if y_q10 > 0 { return 90 * NX_MAGIC_1024 } 120 if y_q10 < 0 { return 0 - (90 * NX_MAGIC_1024) } 121 return 0 122 } 123 if y_q10 == 0 { 124 if x_q10 > 0 { return 0 } 125 return 180 * NX_MAGIC_1024 126 } 127 128 var x: nx_int = x_q10 129 var y: nx_int = y_q10 130 var z: nx_int = 0 131 132 // Pre-rotation to bring x>=0. 133 if x < 0 { 134 if y >= 0 { 135 // Rotate by -90: (x, y) -> (y, -x); add 90 to angle. 136 let new_x: nx_int = y 137 let new_y: nx_int = 0 - x 138 x = new_x 139 y = new_y 140 z = 90 * NX_MAGIC_1024 141 } else { 142 // Rotate by +90: (x, y) -> (-y, x); subtract 90. 143 let new_x2: nx_int = 0 - y 144 let new_y2: nx_int = x 145 x = new_x2 146 y = new_y2 147 z = 0 - (90 * NX_MAGIC_1024) 148 } 149 } 150 151 // CORDIC vectoring iterations. 152 var i: nx_int = 0 153 while i < NX_ITA_CORDIC_ITERS { 154 let x_old: nx_int = x 155 let y_old: nx_int = y 156 let dx: nx_int = y_old >> i 157 let dy: nx_int = x_old >> i 158 let ang: nx_int = _ita_cordic_angle_q10(i) 159 if y_old > 0 { 160 x = x_old + dx 161 y = y_old - dy 162 z = z + ang 163 } else { 164 x = x_old - dx 165 y = y_old + dy 166 z = z - ang 167 } 168 i = i + 1 169 } 170 return z 171} 172 173// ===== band classification ========================================= 174 175func nx_ita_band_from_q10(ita_deg_q10: nx_int) -> nx_int { 176 if ita_deg_q10 >= NX_ITA_THR_VERY_LIGHT_Q10 { return NX_ITA_BAND_VERY_LIGHT } 177 if ita_deg_q10 >= NX_ITA_THR_LIGHT_Q10 { return NX_ITA_BAND_LIGHT } 178 if ita_deg_q10 >= NX_ITA_THR_INTERMEDIATE_Q10 { return NX_ITA_BAND_INTERMEDIATE } 179 if ita_deg_q10 >= NX_ITA_THR_TAN_Q10 { return NX_ITA_BAND_TAN } 180 if ita_deg_q10 >= NX_ITA_THR_BROWN_Q10 { return NX_ITA_BAND_BROWN } 181 return NX_ITA_BAND_DARK 182} 183 184// ===== compute ===================================================== 185 186func nx_skin_tone_ita_compute( 187 l_q10: nx_int, a_q10: nx_int, b_q10: nx_int) -> *NxSkinToneIta { 188 189 let r_ptr: *u8 = sys_mmap(NX_ITA_RESULT_BYTES) 190 let r: *NxSkinToneIta = r_ptr as *NxSkinToneIta 191 192 // ITA = atan2(L* - 50, b*). In Q10: y = l - 50*Q10, x = b. 193 let l_minus_50_q10: nx_int = l_q10 - (50 * NX_MAGIC_1024) 194 let ita_q10: nx_int = _ita_atan2_q10(l_minus_50_q10, b_q10) 195 let band: nx_int = nx_ita_band_from_q10(ita_q10) 196 197 r.l_q10 = l_q10 198 r.a_q10 = a_q10 199 r.b_q10 = b_q10 200 r.ita_deg_q10 = ita_q10 201 r.band = band 202 r.fitzpatrick = band + 1 203 return r 204} 205 206// ===== self-test ==================================================== 207 208func main() -> nx_int { 209 // ---- CORDIC atan2 sanity: known angle ---- 210 // 211 // atan2(1, 1) = 45 deg. In Q10: y_q10 = 1024, x_q10 = 1024. 212 // Allow +/- 1 deg tolerance from CORDIC truncation. 213 let a45_q10: nx_int = _ita_atan2_q10(NX_MAGIC_1024, NX_MAGIC_1024) 214 let target45: nx_int = 45 * NX_MAGIC_1024 215 var d45: nx_int = a45_q10 - target45 216 if d45 < 0 { d45 = 0 - d45 } 217 if d45 > NX_MAGIC_1024 { return 1 } 218 219 // atan2(0, 1) = 0. 220 let a0_q10: nx_int = _ita_atan2_q10(0, NX_MAGIC_1024) 221 if a0_q10 != 0 { return 2 } 222 223 // atan2(1, 0) = 90. 224 let a90_q10: nx_int = _ita_atan2_q10(NX_MAGIC_1024, 0) 225 if a90_q10 != (90 * NX_MAGIC_1024) { return 3 } 226 227 // atan2(-1, 1) = -45. Tolerance +/- 1 deg. 228 let an45: nx_int = _ita_atan2_q10(0 - NX_MAGIC_1024, NX_MAGIC_1024) 229 let target_n45: nx_int = 0 - (45 * NX_MAGIC_1024) 230 var dn45: nx_int = an45 - target_n45 231 if dn45 < 0 { dn45 = 0 - dn45 } 232 if dn45 > NX_MAGIC_1024 { return 4 } 233 234 // atan2(1, -1) = 135 deg. Pre-rotation + CORDIC. 235 let a135: nx_int = _ita_atan2_q10(NX_MAGIC_1024, 0 - NX_MAGIC_1024) 236 let target_135: nx_int = 135 * NX_MAGIC_1024 237 var d135: nx_int = a135 - target_135 238 if d135 < 0 { d135 = 0 - d135 } 239 if d135 > NX_MAGIC_1024 { return 5 } 240 241 // ---- ITA: very-light skin example ---- 242 // 243 // Typical Caucasian: L*=70, a*=10, b*=20. 244 // (L*-50)/b* = 20/20 = 1, atan(1) = 45. 245 // -- but ITA uses degrees of atan, so ITA = 45 deg. 246 // Band: 45 falls in LIGHT (41..55). 247 let r_light: *NxSkinToneIta = nx_skin_tone_ita_compute( 248 70 * NX_MAGIC_1024, 10 * NX_MAGIC_1024, 20 * NX_MAGIC_1024) 249 if r_light.band != NX_ITA_BAND_LIGHT { return 10 } 250 if r_light.fitzpatrick != 2 { return 11 } 251 252 // ---- ITA: very-light end ---- 253 // L*=85, b*=10 -> (85-50)/10 = 3.5, atan(3.5) ~= 74 deg -> VERY_LIGHT 254 let r_vl: *NxSkinToneIta = nx_skin_tone_ita_compute( 255 85 * NX_MAGIC_1024, 5 * NX_MAGIC_1024, 10 * NX_MAGIC_1024) 256 if r_vl.band != NX_ITA_BAND_VERY_LIGHT { return 12 } 257 258 // ---- ITA: dark skin ---- 259 // L*=30, b*=15 -> (30-50)/15 = -1.33, atan(-1.33) ~= -53 deg -> DARK 260 let r_dark: *NxSkinToneIta = nx_skin_tone_ita_compute( 261 30 * NX_MAGIC_1024, 18 * NX_MAGIC_1024, 15 * NX_MAGIC_1024) 262 if r_dark.band != NX_ITA_BAND_DARK { return 13 } 263 if r_dark.fitzpatrick != 6 { return 14 } 264 265 // ---- ITA: brown ---- 266 // L*=45, b*=20 -> (45-50)/20 = -0.25, atan(-0.25) ~= -14 deg -> BROWN 267 let r_brown: *NxSkinToneIta = nx_skin_tone_ita_compute( 268 45 * NX_MAGIC_1024, 15 * NX_MAGIC_1024, 20 * NX_MAGIC_1024) 269 if r_brown.band != NX_ITA_BAND_BROWN { return 15 } 270 271 // ---- band-from-ita boundary ---- 272 if nx_ita_band_from_q10(60 * NX_MAGIC_1024) != NX_ITA_BAND_VERY_LIGHT { return 20 } 273 if nx_ita_band_from_q10(50 * NX_MAGIC_1024) != NX_ITA_BAND_LIGHT { return 21 } 274 if nx_ita_band_from_q10(35 * NX_MAGIC_1024) != NX_ITA_BAND_INTERMEDIATE { return 22 } 275 if nx_ita_band_from_q10(20 * NX_MAGIC_1024) != NX_ITA_BAND_TAN { return 23 } 276 if nx_ita_band_from_q10(0) != NX_ITA_BAND_BROWN { return 24 } 277 if nx_ita_band_from_q10(0 - (40 * NX_MAGIC_1024)) != NX_ITA_BAND_DARK { return 25 } 278 279 return 0 280}