code wiki / (root) / nx_undertone_class.nx

nx_undertone_class.nx source

↩ module page · 274 lines · 9719 B

1// nx_undertone_class.nx -- Tier 4 skin-undertone classification. 2// 3// Tier 4 of nxc2/docs/IMAGE_VALIDATION_AMBIGUITY_LADDER.md. 4// Where ITA (nx_skin_tone_ita) classifies the LIGHT-TO-DARK axis 5// (luminance + b*), undertone classifies the CHROMA TYPE axis 6// (cool / warm / neutral) -- the dimension that makes the SAME ITA 7// band look "golden" vs "pink". 8// 9// Standard cosmetic-industry definition (Suzuki 1987, Fitzpatrick 10// 1988 supplement, Robinson-Bostom & DiGiovanna 2010): 11// 12// h_ab = atan2(b*, a*) -- CIELab hue angle in degrees 13// 14// For typical skin h_ab falls in [30, 80] degrees. 15// Within that range: 16// h_ab > 55 -> WARM (golden / yellow leaning) 17// 45..55 -> NEUTRAL (balanced) 18// h_ab < 45 -> COOL (pink / red leaning) 19// 20// Outside [20, 90]: classifier returns INVALID_RANGE -- the 21// pixel sample isn't typical-skin chroma; caller likely fed 22// a non-skin region. 23// 24// Confidence proxy in Q10: distance from nearest threshold. Larger 25// distance from the WARM/COOL boundary = higher confidence in the 26// classification. 27// 28// genealogy_id: suzuki_1987_robinson_2010 29// lineage_id: substrate_skin_chroma_v1 30 31// nx_safety_envelope: 32// intended_use: AUTO_APPLIED -- primitive-specific tuning queued 33// sil_target: SIL1 34// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail] 35// verdict: NOT_YET_EVALUATED 36 37import "nx_syscalls.nx" 38import "nx_runtime.nx" 39import "nx_tier.nx" 40const NX_MAGIC_46080: i64 = 46080 41const NX_MAGIC_27203: i64 = 27203 42const NX_MAGIC_14373: i64 = 14373 43const NX_MAGIC_7296: i64 = 7296 44const NX_MAGIC_3662: i64 = 3662 45const NX_MAGIC_1833: i64 = 1833 46const NX_MAGIC_1024: i64 = 1024 47 48const NX_UT_Q10: nx_int = 1024 49 50// Sealed-enum verdict IDs. 51const NX_UT_INVALID: nx_int = 0 52const NX_UT_COOL: nx_int = 1 53const NX_UT_NEUTRAL: nx_int = 2 54const NX_UT_WARM: nx_int = 3 55const NX_UT_N_CLASSES: nx_int = 4 56 57// Threshold values in Q10 degrees. 58const NX_UT_THR_WARM_Q10: nx_int = 56320 // 55 deg 59const NX_UT_THR_NEUTRAL_LO: nx_int = 46080 // 45 deg 60const NX_UT_THR_VALID_LO: nx_int = 20480 // 20 deg 61const NX_UT_THR_VALID_HI: nx_int = 92160 // 90 deg 62 63// Confidence scaling: max distance from boundary in degrees is 35 64// (e.g. WARM extreme h_ab=90 is 35 deg above WARM boundary 55). 65// So conf_q10 = min(distance_from_boundary / 35, 1) * Q10. 66const NX_UT_CONF_DEG_MAX: nx_int = 35 67 68// ===== result struct ============================================== 69 70struct NxUndertoneResult { 71 a_q10: nx_int, 72 b_q10: nx_int, 73 h_ab_deg_q10: nx_int, // CIELab hue angle in Q10 degrees 74 verdict: nx_int, 75 conf_q10: nx_int, 76 in_valid_band: nx_int, 77} 78 79const NX_UT_RESULT_BYTES: nx_size = 48 80 81// ===== CORDIC atan2 (copy of nx_skin_tone_ita helper) ============= 82// 83// Pure-function helper duplicated here intentionally rather than 84// imported across files: keeps this primitive self-contained for 85// the Tier 4 single-responsibility principle. Future consolidation 86// into nx_trig_atan2.nx is queued (would also serve nx_ita). 87 88const NX_UT_CORDIC_ITERS: nx_int = 8 89 90func _ut_cordic_angle_q10(i: nx_int) -> nx_int { 91 if i == 0 { return NX_MAGIC_46080 } 92 if i == 1 { return NX_MAGIC_27203 } 93 if i == 2 { return NX_MAGIC_14373 } 94 if i == 3 { return NX_MAGIC_7296 } 95 if i == 4 { return NX_MAGIC_3662 } 96 if i == 5 { return NX_MAGIC_1833 } 97 if i == 6 { return 917 } 98 if i == 7 { return 458 } 99 return 0 100} 101 102func _ut_atan2_q10(y_q10: nx_int, x_q10: nx_int) -> nx_int { 103 if x_q10 == 0 { 104 if y_q10 > 0 { return 90 * NX_MAGIC_1024 } 105 if y_q10 < 0 { return 0 - (90 * NX_MAGIC_1024) } 106 return 0 107 } 108 if y_q10 == 0 { 109 if x_q10 > 0 { return 0 } 110 return 180 * NX_MAGIC_1024 111 } 112 var x: nx_int = x_q10 113 var y: nx_int = y_q10 114 var z: nx_int = 0 115 if x < 0 { 116 if y >= 0 { 117 let nx_v: nx_int = y 118 let ny_v: nx_int = 0 - x 119 x = nx_v 120 y = ny_v 121 z = 90 * NX_MAGIC_1024 122 } else { 123 let nx2: nx_int = 0 - y 124 let ny2: nx_int = x 125 x = nx2 126 y = ny2 127 z = 0 - (90 * NX_MAGIC_1024) 128 } 129 } 130 var i: nx_int = 0 131 while i < NX_UT_CORDIC_ITERS { 132 let x_old: nx_int = x 133 let y_old: nx_int = y 134 let dx: nx_int = y_old >> i 135 let dy: nx_int = x_old >> i 136 let ang: nx_int = _ut_cordic_angle_q10(i) 137 if y_old > 0 { 138 x = x_old + dx 139 y = y_old - dy 140 z = z + ang 141 } else { 142 x = x_old - dx 143 y = y_old + dy 144 z = z - ang 145 } 146 i = i + 1 147 } 148 return z 149} 150 151// ===== classification ============================================== 152 153func _ut_distance_from_boundary_q10(h_q10: nx_int, verdict: nx_int) -> nx_int { 154 if verdict == NX_UT_WARM { 155 let d: nx_int = h_q10 - NX_UT_THR_WARM_Q10 156 if d < 0 { return 0 - d } 157 return d 158 } 159 if verdict == NX_UT_COOL { 160 let d: nx_int = NX_UT_THR_NEUTRAL_LO - h_q10 161 if d < 0 { return 0 - d } 162 return d 163 } 164 // NEUTRAL: distance to closer boundary. 165 let d_lo: nx_int = h_q10 - NX_UT_THR_NEUTRAL_LO 166 let d_hi: nx_int = NX_UT_THR_WARM_Q10 - h_q10 167 var d_min: nx_int = d_lo 168 if d_hi < d_min { d_min = d_hi } 169 if d_min < 0 { return 0 - d_min } 170 return d_min 171} 172 173func nx_undertone_classify(a_q10: nx_int, b_q10: nx_int) -> *NxUndertoneResult { 174 let r_ptr: *u8 = sys_mmap(NX_UT_RESULT_BYTES) 175 let r: *NxUndertoneResult = r_ptr as *NxUndertoneResult 176 177 // h_ab = atan2(b*, a*) 178 let h_q10: nx_int = _ut_atan2_q10(b_q10, a_q10) 179 180 r.a_q10 = a_q10 181 r.b_q10 = b_q10 182 r.h_ab_deg_q10 = h_q10 183 184 // Validity band: typical skin chroma h_ab in [20, 90] deg. 185 if h_q10 < NX_UT_THR_VALID_LO { 186 r.verdict = NX_UT_INVALID 187 r.conf_q10 = 0 188 r.in_valid_band = 0 189 return r 190 } 191 if h_q10 > NX_UT_THR_VALID_HI { 192 r.verdict = NX_UT_INVALID 193 r.conf_q10 = 0 194 r.in_valid_band = 0 195 return r 196 } 197 r.in_valid_band = 1 198 199 // Verdict by threshold. 200 var verdict: nx_int = NX_UT_NEUTRAL 201 if h_q10 > NX_UT_THR_WARM_Q10 { verdict = NX_UT_WARM } 202 if h_q10 < NX_UT_THR_NEUTRAL_LO { verdict = NX_UT_COOL } 203 r.verdict = verdict 204 205 // Confidence: scaled boundary distance. 206 let dist_q10: nx_int = _ut_distance_from_boundary_q10(h_q10, verdict) 207 let dist_deg_int: nx_int = dist_q10 / NX_UT_Q10 208 var conf_num: nx_int = dist_deg_int * NX_UT_Q10 209 let conf: nx_int = conf_num / NX_UT_CONF_DEG_MAX 210 if conf > NX_UT_Q10 { 211 r.conf_q10 = NX_UT_Q10 212 } else { 213 r.conf_q10 = conf 214 } 215 return r 216} 217 218// ===== self-test =================================================== 219 220func main() -> nx_int { 221 // ---- WARM: a*=15, b*=25 -> h_ab = atan2(25, 15) ~= 59 deg 222 // Above WARM threshold 55 -> WARM verdict. 223 let r_warm: *NxUndertoneResult = nx_undertone_classify(15 * NX_MAGIC_1024, 25 * NX_MAGIC_1024) 224 if r_warm.in_valid_band != 1 { return 1 } 225 if r_warm.verdict != NX_UT_WARM { return 2 } 226 // h_ab roughly 59 deg. 227 let h_warm_deg: nx_int = r_warm.h_ab_deg_q10 / NX_MAGIC_1024 228 if h_warm_deg < 56 { return 3 } 229 if h_warm_deg > 62 { return 4 } 230 231 // ---- COOL: a*=20, b*=15 -> h_ab = atan2(15, 20) ~= 37 deg 232 // Below NEUTRAL threshold 45 -> COOL verdict. 233 let r_cool: *NxUndertoneResult = nx_undertone_classify(20 * NX_MAGIC_1024, 15 * NX_MAGIC_1024) 234 if r_cool.verdict != NX_UT_COOL { return 10 } 235 let h_cool_deg: nx_int = r_cool.h_ab_deg_q10 / NX_MAGIC_1024 236 if h_cool_deg < 33 { return 11 } 237 if h_cool_deg > 41 { return 12 } 238 239 // ---- NEUTRAL: a*=15, b*=15 -> h_ab = 45 deg 240 // At the boundary; with CORDIC error +- 0.4 deg, should 241 // land in NEUTRAL (45..55). But h=45 is on the COOL side 242 // (h < 45 -> COOL). Move slightly: a*=14, b*=15 -> 243 // h_ab = atan2(15, 14) ~= 47 deg. 244 let r_neutral: *NxUndertoneResult = nx_undertone_classify(14 * NX_MAGIC_1024, 15 * NX_MAGIC_1024) 245 if r_neutral.verdict != NX_UT_NEUTRAL { return 20 } 246 247 // ---- INVALID: a*=5, b*=50 -> h_ab ~= 84 deg. In valid band still. 248 // Use a*=10, b*=500 to push above 90. In Q10: a=10240, b=512000. 249 // atan2(512000, 10240) ~= 88-89 deg, still in band. Need bigger ratio: 250 // a*=1, b*=100 -> h_ab ~= 89.4. Take b*=200, a*=1 -> h_ab ~= 89.7. 251 // Still within band. Try b*=1000, a*=0 -> h_ab=90 EXACT, equals upper. 252 // The validity test is h > 90; so 90 stays valid. To force INVALID: 253 // a* must be NEGATIVE so h_ab > 90 (cyan/green territory, never skin). 254 // 255 // a* = -10, b* = 20 -> h_ab = atan2(20, -10), 2nd quadrant, ~117 deg. 256 let r_invalid: *NxUndertoneResult = nx_undertone_classify( 257 0 - (10 * NX_MAGIC_1024), 20 * NX_MAGIC_1024) 258 if r_invalid.verdict != NX_UT_INVALID { return 30 } 259 if r_invalid.in_valid_band != 0 { return 31 } 260 261 // ---- LOW invalid: h_ab < 20 (orange territory) 262 // a*=50, b*=10 -> h_ab = atan2(10, 50) ~= 11 deg. 263 let r_low: *NxUndertoneResult = nx_undertone_classify(50 * NX_MAGIC_1024, 10 * NX_MAGIC_1024) 264 if r_low.verdict != NX_UT_INVALID { return 40 } 265 266 // ---- confidence: extreme WARM (h>>55) -> high conf. 267 // a*=10, b*=80 -> h ~= 83 deg, dist 28 from boundary 55. 268 let r_far_warm: *NxUndertoneResult = nx_undertone_classify( 269 10 * NX_MAGIC_1024, 80 * NX_MAGIC_1024) 270 if r_far_warm.verdict != NX_UT_WARM { return 50 } 271 if r_far_warm.conf_q10 <= r_warm.conf_q10 { return 51 } 272 273 return 0 274}