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}