nx_anatomy_measure.nx source
↩ module page · 340 lines · 14987 B
1// nx_anatomy_measure.nx -- measure named anatomical axes from binary mask.
2//
3// Composes existing primitives (no duplication):
4// - nx_color_skin_mask (already in nx_color_v2)
5// - nx_region_segment (already in nx_region)
6// - nx_eye_detect (Layer 7, KEYSTONE)
7//
8// Cross-section measurement is the central technique here:
9// - bust = widest mask-width across upper torso Y-band
10// - waist = narrowest mask-width across mid torso Y-band
11// - hip = widest mask-width across lower torso Y-band
12// - navel = darkest pixel along torso vertical midline
13//
14// Output: flat-array (axis_id, value_px) bundle ready for
15// nx_beauty_score(). All primitives are flat-array-API to avoid the
16// nxc2 codegen bug with struct-pointer returns.
17
18// nx_safety_envelope:
19// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
20// sil_target: SIL1
21// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
22// verdict: NOT_YET_EVALUATED
23
24import "nx_syscalls.nx"
25import "nx_image.nx"
26import "nx_canon_proportions.nx"
27import "nx_beauty_score.nx"
28const K_MAGIC_9999: i64 = 9999
29
30// Count consecutive skin pixels horizontally at row y within [x0, x1).
31// Returns the LONGEST run found.
32func nx_mask_longest_run_at_y(mask: *Image, y: i64, x0: i64, x1: i64) -> i64 {
33 if y < 0 { return 0 }
34 if y >= mask.height { return 0 }
35 var best: i64 = 0
36 var cur: i64 = 0
37 var x: i64 = x0
38 while x < x1 {
39 let v: i64 = nx_image_get(mask, x, y, 0)
40 if v > 0 {
41 cur = cur + 1
42 if cur > best { best = cur }
43 } else {
44 cur = 0
45 }
46 x = x + 1
47 }
48 return best
49}
50
51// Find Y in [y0, y1) where mask is WIDEST. Returns (y, width) packed.
52// Out: out_y receives the Y, returns the width.
53func nx_mask_widest_y(mask: *Image, y0: i64, y1: i64,
54 x0: i64, x1: i64, out_y: *i64) -> i64 {
55 var best_w: i64 = 0
56 var best_y: i64 = y0
57 var y: i64 = y0
58 while y < y1 {
59 let w: i64 = nx_mask_longest_run_at_y(mask, y, x0, x1)
60 if w > best_w { best_w = w; best_y = y }
61 y = y + 1
62 }
63 out_y[0] = best_y
64 return best_w
65}
66
67// Find Y in [y0, y1) where mask is NARROWEST (but nonzero). Returns
68// (out_y, width).
69func nx_mask_narrowest_y(mask: *Image, y0: i64, y1: i64,
70 x0: i64, x1: i64, out_y: *i64) -> i64 {
71 var best_w: i64 = K_MAGIC_9999
72 var best_y: i64 = y0
73 var found: i64 = 0
74 var y: i64 = y0
75 while y < y1 {
76 let w: i64 = nx_mask_longest_run_at_y(mask, y, x0, x1)
77 if w > 0 {
78 if w < best_w { best_w = w; best_y = y; found = 1 }
79 }
80 y = y + 1
81 }
82 out_y[0] = best_y
83 if found == 0 { return 0 }
84 return best_w
85}
86
87// Measure standard body axes from a skin mask + torso bbox + eye width.
88// Caller pre-allocates measurement bundle (NX_MEAS_FIELDS * count i64).
89// Returns number of (axis, value) pairs written.
90//
91// Inputs:
92// mask: full-frame skin mask
93// eye_width_px: from nx_eye_detect
94// face_min_x..face_max_y: face bbox from nx_region
95// torso_min_x..torso_max_y: torso bbox from nx_region
96// meas: output flat-array, layout per nx_beauty_score's NX_MEAS_FIELDS
97func nx_anatomy_measure_body(mask: *Image, eye_width_px: i64,
98 face_min_x: i64, face_min_y: i64,
99 face_max_x: i64, face_max_y: i64,
100 torso_min_x: i64, torso_min_y: i64,
101 torso_max_x: i64, torso_max_y: i64,
102 meas: *i64) -> i64 {
103 var n: i64 = 0
104 let face_w: i64 = face_max_x - face_min_x + 1
105 let face_h: i64 = face_max_y - face_min_y + 1
106 let torso_h: i64 = torso_max_y - torso_min_y + 1
107
108 // Keystone.
109 meas[n * NX_MEAS_FIELDS] = NX_AXIS_EYE_WIDTH
110 meas[n * NX_MEAS_FIELDS + 1] = eye_width_px
111 n = n + 1
112
113 // Face dimensions (bbox-derived).
114 meas[n * NX_MEAS_FIELDS] = NX_AXIS_FACE_WIDTH
115 meas[n * NX_MEAS_FIELDS + 1] = face_w
116 n = n + 1
117 meas[n * NX_MEAS_FIELDS] = NX_AXIS_FACE_HEIGHT
118 meas[n * NX_MEAS_FIELDS + 1] = face_h
119 n = n + 1
120
121 // Bust = widest mask row in UPPER third of torso.
122 let bust_y0: i64 = torso_min_y
123 let bust_y1: i64 = torso_min_y + torso_h / 3
124 let bust_y_out: *i64 = (sys_mmap(8 + 8)) as *i64
125 let bust_w: i64 = nx_mask_widest_y(mask, bust_y0, bust_y1,
126 torso_min_x, torso_max_x + 1, bust_y_out)
127 if bust_w > 0 {
128 meas[n * NX_MEAS_FIELDS] = NX_AXIS_BUST_WIDTH
129 meas[n * NX_MEAS_FIELDS + 1] = bust_w
130 n = n + 1
131 }
132
133 // Waist = narrowest mask row in MIDDLE third of torso.
134 let waist_y0: i64 = torso_min_y + torso_h / 3
135 let waist_y1: i64 = torso_min_y + (torso_h * 2) / 3
136 let waist_y_out: *i64 = (sys_mmap(8 + 8)) as *i64
137 let waist_w: i64 = nx_mask_narrowest_y(mask, waist_y0, waist_y1,
138 torso_min_x, torso_max_x + 1, waist_y_out)
139 if waist_w > 0 {
140 meas[n * NX_MEAS_FIELDS] = NX_AXIS_WAIST_WIDTH
141 meas[n * NX_MEAS_FIELDS + 1] = waist_w
142 n = n + 1
143 }
144
145 // Hip = widest mask row in LOWER third of torso.
146 let hip_y0: i64 = torso_min_y + (torso_h * 2) / 3
147 let hip_y1: i64 = torso_max_y + 1
148 let hip_y_out: *i64 = (sys_mmap(8 + 8)) as *i64
149 let hip_w: i64 = nx_mask_widest_y(mask, hip_y0, hip_y1,
150 torso_min_x, torso_max_x + 1, hip_y_out)
151 if hip_w > 0 {
152 meas[n * NX_MEAS_FIELDS] = NX_AXIS_HIP_WIDTH
153 meas[n * NX_MEAS_FIELDS + 1] = hip_w
154 n = n + 1
155 }
156
157 // Shoulder width: widest row in TOP 15% of torso, where pectoral fold
158 // is largest. For a face-on subject this approximates shoulder span.
159 let shoulder_y0: i64 = torso_min_y
160 let shoulder_y1: i64 = torso_min_y + torso_h / 6
161 let shoulder_y_out: *i64 = (sys_mmap(8 + 8)) as *i64
162 let shoulder_w: i64 = nx_mask_widest_y(mask, shoulder_y0, shoulder_y1,
163 torso_min_x, torso_max_x + 1, shoulder_y_out)
164 if shoulder_w > 0 {
165 meas[n * NX_MEAS_FIELDS] = NX_AXIS_SHOULDER_WIDTH
166 meas[n * NX_MEAS_FIELDS + 1] = shoulder_w
167 n = n + 1
168 }
169
170 return n
171}
172
173// ============================================================================
174// AT2 (aesthetictwin rung): OUTLINE CURVINESS -- the measure the published
175// evidence prefers to the waist-to-hip ratio.
176//
177// WHY THIS IS NOT A SECOND WHR, AND THE REASON IS MECHANICAL. On line drawings
178// that varied curviness and width independently, curviness predicted the rated
179// attractiveness of a woman's body BETTER than the waist-to-hip ratio, and the
180// authors state that the two are not uniquely related (Hubner and Ufken 2024,
181// pinned as aesthetictwin.refs key hubner2024, whose grounds field already
182// names this function as the contract). The reason is the whole point here:
183// A RATIO IS SCALE-INVARIANT AND A CURVATURE IS NOT. Two silhouettes can share
184// a waist-to-hip ratio EXACTLY while one turns twice as sharply, because
185// curvature carries a 1/length -- double every dimension and the curvature
186// halves. So this separates bodies the WHR reports as identical, which is the
187// discrimination the raters were making and the one this board could not
188// previously express at all.
189//
190// METHOD. Over the row band take the mask run width w(y) -- the SAME primitive
191// nx_mask_widest_y and nx_mask_narrowest_y already use, so this organ keeps one
192// width measure and does not grow a second ruler beside it. Smooth with a
193// 3-row box, because a mask edge jitters by a pixel and an unsmoothed second
194// difference would measure that jitter rather than the body. Then integrate the
195// absolute discrete second difference of the smoothed profile.
196//
197// WHY IT IS NOT A SECOND WHR, THE OTHER HALF: A RATIO IS BLIND TO WHERE THE
198// TAPER HAPPENS. Two bodies can carry the SAME waist and the SAME hip -- so the
199// same ratio to the permil -- while one reaches its waist through a short sharp
200// pinch and the other through a long gentle sweep. Those are different amounts
201// of curviness and the ratio cannot see the difference at all. That is the
202// discrimination this function exists to supply.
203//
204// REPORTED THREE WAYS, BECAUSE AN AGGREGATE WITHOUT ITS DENOMINATOR IS NOT
205// PUBLISHABLE AND BECAUSE THE FIGURES ANSWER DIFFERENT QUESTIONS:
206// O_TOTAL -- TOTAL TURNING of the outline, in milli-slope units. This is the
207// curviness and it is what the function RETURNS. It is the total
208// change in the outline's SLOPE, and a slope is dimensionless, so
209// it is INVARIANT UNDER UNIFORM SCALE: photograph the same body
210// twice as large and this number does not move. That invariance is
211// a REQUIREMENT, not a side effect -- curviness is a property of
212// SHAPE, and a measure that rose merely because a body was smaller
213// would be reporting size wearing the name of shape.
214// O_ROWS -- how many BODY ROWS fed the measurement. Never read a total
215// without its denominator.
216// O_SEGS -- how many interior segments contributed a second difference.
217// Fixed by construction, and reported precisely so a reader can
218// SEE that the term count does not vary with the body's size.
219//
220// HOW THE INVARIANCE IS OBTAINED, BECAUSE IT DOES NOT COME FOR FREE. The width
221// profile is RESAMPLED onto a FIXED number of segments spanning the body, and
222// each segment's mean width is then divided by the body's OWN height. Both
223// steps are load-bearing: the fixed segment count makes the NUMBER OF TERMS
224// independent of how many pixels tall the body happens to be, and dividing
225// width by height makes each term DIMENSIONLESS. Together they mean the same
226// shape photographed at any size yields the same number.
227//
228// WARNING -- TWO EARLIER DRAFTS GOT THIS WRONG AND THE GATE CAUGHT BOTH, WHICH
229// IS THE ONLY REASON THIS COMMENT IS TRUSTWORTHY. The first returned a per-row
230// density and argued that scale-SENSITIVITY was the point; the rung's done-rule,
231// written into aesthetictwin.plan before any code, refuted it. The second
232// integrated the second difference ROW BY ROW -- which is invariant in exact
233// arithmetic and is NOT invariant in integers, because every rounded row adds a
234// little quantisation noise and a body twice as tall accumulates twice as much
235// of it. MEASURED: the same shape at twice the scale read 13333 against 18666,
236// a 40 percent error no fixture could have removed, because the defect was in
237// the measure. Resampling to a fixed segment count removes it at the root: each
238// segment averages many rows so the rounding averages out, and the count of
239// terms stops depending on size.
240//
241// DECLARED IMPRECISION, so the next reader does not trust this as exact: this is
242// a discrete second difference over a COARSE resampling -- the paper's own
243// relatively simple curvature-based measure -- and NOT the exact
244// second-derivative form normalised by the slope. Its absolute value therefore
245// depends on the chosen segment count, so this number is comparable ACROSS
246// BODIES and NOT against any externally published curvature figure. Ordering and
247// invariance, the two properties the done-rule asks for, are unaffected.
248//
249// UNMEASURED IS A REAL ANSWER AND IT IS NOT ZERO. A body too short to give two
250// rows per segment cannot be resampled at all and returns AM_CURV_UNMEASURED,
251// because no-evidence and measured-zero are different claims and publishing the
252// first as the second is a lie the caller cannot see.
253// ============================================================================
254const AM_CURV_UNMEASURED: i64 = 0 - 1
255// The profile is resampled onto this many equal shares of the body. 16 is
256// chosen so the shortest body this organ will accept still gives 2 rows per
257// segment, while a full-height torso gives dozens -- enough averaging to bury
258// pixel rounding without smoothing away a waist. MINROWS follows FROM it.
259const AM_CURV_SEGS: i64 = 16
260const AM_CURV_MINROWS: i64 = 32
261const AM_CURV_UNIT: i64 = 1000000
262const AM_CURV_PERMIL: i64 = 1000
263const AM_CURV_O_TOTAL: i64 = 0
264const AM_CURV_O_ROWS: i64 = 1
265const AM_CURV_O_SEGS: i64 = 2
266const AM_CURV_O_FIELDS: i64 = 3
267const AM_CURV_SLACK: i64 = 16
268
269func am_outline_curvature(mask: *Image, y0: i64, y1: i64,
270 x0: i64, x1: i64, out: *i64) -> i64 {
271 out[AM_CURV_O_TOTAL] = 0
272 out[AM_CURV_O_ROWS] = 0
273 out[AM_CURV_O_SEGS] = AM_CURV_UNMEASURED
274 let band: i64 = y1 - y0
275 if band < AM_CURV_MINROWS { return AM_CURV_UNMEASURED }
276 let w: *i64 = (sys_mmap(band * 8 + AM_CURV_SLACK)) as *i64
277 var i: i64 = 0
278 while i < band {
279 w[i] = nx_mask_longest_run_at_y(mask, y0 + i, x0, x1)
280 i = i + 1
281 }
282 // The body is the FIRST CONTIGUOUS RUN of non-empty rows. A zero row inside
283 // the band is background, and a second difference taken across that edge
284 // would measure where the band was placed rather than how the body turns.
285 var f: i64 = 0 - 1
286 i = 0
287 while i < band {
288 if w[i] > 0 { if f < 0 { f = i } }
289 i = i + 1
290 }
291 if f < 0 { return AM_CURV_UNMEASURED }
292 var l: i64 = f
293 var scanning: i64 = 1
294 while scanning == 1 {
295 scanning = 0
296 if l < band {
297 if w[l] > 0 { l = l + 1; scanning = 1 }
298 }
299 }
300 let n: i64 = l - f
301 if n < AM_CURV_MINROWS { return AM_CURV_UNMEASURED }
302 // RESAMPLE onto a FIXED number of segments spanning the body, and carry each
303 // segment as its MEAN WIDTH DIVIDED BY THE BODY'S OWN HEIGHT in AM_CURV_UNIT
304 // fixed point. The division by n is what makes each term dimensionless, and
305 // the fixed segment count is what makes the number of terms independent of
306 // the body's pixel size. Neither is optional: drop either one and the same
307 // shape at a different scale reads a different number.
308 let seg: *i64 = (sys_mmap(AM_CURV_SEGS * 8 + AM_CURV_SLACK)) as *i64
309 var j: i64 = 0
310 while j < AM_CURV_SEGS {
311 let a: i64 = f + (n * j) / AM_CURV_SEGS
312 var b: i64 = f + (n * (j + 1)) / AM_CURV_SEGS
313 if b <= a { b = a + 1 }
314 var s: i64 = 0
315 var c: i64 = 0
316 var y: i64 = a
317 while y < b {
318 s = s + w[y]
319 c = c + 1
320 y = y + 1
321 }
322 seg[j] = (s * AM_CURV_UNIT) / (c * n)
323 j = j + 1
324 }
325 var total: i64 = 0
326 var used: i64 = 0
327 j = 1
328 while j < AM_CURV_SEGS - 1 {
329 var d: i64 = seg[j - 1] + seg[j + 1] - seg[j] - seg[j]
330 if d < 0 { d = 0 - d }
331 total = total + d
332 used = used + 1
333 j = j + 1
334 }
335 if used < 1 { return AM_CURV_UNMEASURED }
336 out[AM_CURV_O_TOTAL] = total
337 out[AM_CURV_O_ROWS] = n
338 out[AM_CURV_O_SEGS] = used
339 return total
340}