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}