nx_eye_detect.nx source
↩ module page · 241 lines · 9649 B
1// nx_eye_detect.nx -- iris/eye-pair detection wrapping nx_geom_hough_circle_r.
2//
3// This is the KEYSTONE primitive. Once eye_width_px is measured, every
4// other anatomical axis is a Q10 ratio from it via nx_canon_proportions.
5//
6// Pipeline:
7// 1. Caller crops to face bbox (already from nx_region_segment).
8// 2. Caller computes sobel_x + sobel_y + edge mask.
9// 3. nx_eye_detect_iris_pair() sweeps candidate iris radii,
10// collects circles, pairs the best two within upper-half of face.
11// 4. Output: flat-array result (no struct pointer return -> avoids
12// nxc2 codegen bug).
13//
14// Result layout (i64):
15// result[0] = eye_count (0, 1, or 2)
16// result[1] = eye_width_px (palpebral fissure ≈ 5 * iris_radius)
17// result[2] = iris_radius_px (the raw Hough measurement)
18// result[3] = left_eye_cx (in face-bbox coords)
19// result[4] = left_eye_cy
20// result[5] = left_eye_iris_r
21// result[6] = right_eye_cx
22// result[7] = right_eye_cy
23// result[8] = right_eye_iris_r
24// result[9] = pair_quality_q10 (1024 = perfect horizontal alignment + matched radius)
25//
26// Constants:
27// - iris radius range tries 3..16 px (typical iris in face crop)
28// - palpebral fissure ≈ 5 × iris_radius_px (Loomis canon: eye width = unit)
29//
30// nx_safety_envelope:
31// intended_use: "Eye detection / iris localization -- input
32// primitive for the anatomy-as-proportions
33// substrate per cardinal feedback-eye-as-
34// keystone-anatomy-as-proportions"
35// sil_target: SIL1
36// asil_target: QM
37// dal_target: NONE
38// evidence: [Loomis_anatomy_canon_basis,
39// integer_pixel_arithmetic,
40// sealed_verdict_enum]
41// hazard_register: [bug-tape-false-positive-non-eye-region,
42// bug-tape-iris-occlusion-confidence-overstated]
43// residual_risk: "Substrate primitive for procgen/quality-
44// grader pipeline only; not for biometric
45// identification (that needs different
46// evidence/SIL profile)."
47// verdict: NOT_YET_EVALUATED
48
49import "nx_syscalls.nx"
50import "nx_image.nx"
51import "nx_geom.nx"
52const NX_MAGIC_1024: i64 = 1024
53
54const NX_EYE_RES_COUNT: i64 = 0
55const NX_EYE_RES_WIDTH_PX: i64 = 1
56const NX_EYE_RES_IRIS_R: i64 = 2
57const NX_EYE_RES_L_CX: i64 = 3
58const NX_EYE_RES_L_CY: i64 = 4
59const NX_EYE_RES_L_R: i64 = 5
60const NX_EYE_RES_R_CX: i64 = 6
61const NX_EYE_RES_R_CY: i64 = 7
62const NX_EYE_RES_R_R: i64 = 8
63const NX_EYE_RES_PAIR_QUALITY: i64 = 9
64const NX_EYE_RES_FIELDS: i64 = 10
65
66// Palpebral fissure (visible eye opening) relative to iris radius.
67const NX_EYE_PALPEBRAL_MULT: i64 = 5
68
69// Eye region (upper half of face), pixel tolerance for pairing.
70const NX_EYE_Y_VERT_TOL_PX: i64 = 6 // two eyes within 6 vertical px
71const NX_EYE_R_DELTA_TOL: i64 = 3 // iris radii must match within 3 px
72const NX_EYE_MIN_X_SEP: i64 = 8 // eyes at least 8 px apart horizontally
73
74// Score the quality of a candidate pair (Q10).
75func nx_eye_pair_quality_q10(l_cx: i64, l_cy: i64, l_r: i64,
76 r_cx: i64, r_cy: i64, r_r: i64) -> i64 {
77 var y_dist: i64 = l_cy - r_cy
78 if y_dist < 0 { y_dist = -y_dist }
79 var r_dist: i64 = l_r - r_r
80 if r_dist < 0 { r_dist = -r_dist }
81 var x_sep: i64 = r_cx - l_cx
82 if x_sep < 0 { x_sep = -x_sep }
83
84 var q: i64 = NX_MAGIC_1024
85 // Vertical alignment penalty: 256/Q10 per pixel of y-misalignment.
86 q = q - (y_dist * 256) / 4
87 // Radius mismatch penalty: 384/Q10 per pixel of radius delta.
88 q = q - (r_dist * 384) / 4
89 if x_sep < NX_EYE_MIN_X_SEP { q = q - 256 }
90 if q < 0 { q = 0 }
91 if q > NX_MAGIC_1024 { q = NX_MAGIC_1024 }
92 return q
93}
94
95// Try a single candidate radius, collect best circles, return the count
96// written to candidates buffer. candidates layout: 4 i64 per circle
97// (cx, cy, r, votes). Caller pre-allocs candidates buffer.
98func nx_eye_collect_radius(edge: *Image, gx: *ImageS64, gy: *ImageS64,
99 r: i64, min_votes: i64,
100 candidates: *i64, cand_count: i64,
101 cand_max: i64) -> i64 {
102 let max_per_r: i64 = 4
103 let circles: *HoughCircles = nx_geom_hough_circle_r(edge, gx, gy, r, min_votes, max_per_r)
104 // Bind every struct field to a local IMMEDIATELY (workaround for
105 // nxc2 codegen bug with struct-pointer-returns clobbered across uses).
106 let n_local: i64 = circles.n_circles
107 let cxs_local: *i64 = circles.cxs
108 let cys_local: *i64 = circles.cys
109 let votes_local: *i64 = circles.votes
110 var written: i64 = cand_count
111 var i: i64 = 0
112 while i < n_local {
113 if written < cand_max {
114 let cx: i64 = cxs_local[i]
115 let cy: i64 = cys_local[i]
116 let v: i64 = votes_local[i]
117 candidates[written * 4 + 0] = cx
118 candidates[written * 4 + 1] = cy
119 candidates[written * 4 + 2] = r
120 candidates[written * 4 + 3] = v
121 written = written + 1
122 }
123 i = i + 1
124 }
125 return written
126}
127
128// Filter candidates to those whose center lies in the upper-half of the
129// face bbox AND within the bbox horizontally. Reads/writes in place.
130func nx_eye_filter_upper_half(candidates: *i64, count: i64,
131 face_min_x: i64, face_min_y: i64,
132 face_max_x: i64, face_max_y: i64) -> i64 {
133 let mid_y: i64 = (face_min_y + face_max_y) / 2
134 var read: i64 = 0
135 var write: i64 = 0
136 while read < count {
137 let cx: i64 = candidates[read * 4 + 0]
138 let cy: i64 = candidates[read * 4 + 1]
139 var keep: i64 = 0
140 if cx >= face_min_x { if cx <= face_max_x {
141 if cy >= face_min_y { if cy <= mid_y { keep = 1 } }
142 } }
143 if keep == 1 {
144 candidates[write * 4 + 0] = candidates[read * 4 + 0]
145 candidates[write * 4 + 1] = candidates[read * 4 + 1]
146 candidates[write * 4 + 2] = candidates[read * 4 + 2]
147 candidates[write * 4 + 3] = candidates[read * 4 + 3]
148 write = write + 1
149 }
150 read = read + 1
151 }
152 return write
153}
154
155// Detect best eye pair from a face-cropped grayscale image.
156// Caller responsibilities:
157// - gray: face-cropped grayscale image
158// - edge: pre-computed edge mask (sobel magnitude > threshold)
159// - gx, gy: pre-computed sobel gradients
160// - face_min_x..face_max_y: bbox coords matching gray's coord space
161// - r_min, r_max: candidate iris radius range (try every other px)
162// - result: pre-allocated NX_EYE_RES_FIELDS-sized i64 array
163//
164// Returns 0 on success (eye_count written to result), nonzero on input error.
165func nx_eye_detect(edge: *Image, gx: *ImageS64, gy: *ImageS64,
166 face_min_x: i64, face_min_y: i64,
167 face_max_x: i64, face_max_y: i64,
168 r_min: i64, r_max: i64,
169 result: *i64) -> i64 {
170 // Zero result.
171 var z: i64 = 0
172 while z < NX_EYE_RES_FIELDS { result[z] = 0; z = z + 1 }
173 if r_max <= r_min { return 1 }
174 if face_max_x <= face_min_x { return 2 }
175 if face_max_y <= face_min_y { return 3 }
176
177 // Collect candidates across radius sweep.
178 let cand_max: i64 = 32
179 let cand: *i64 = (sys_mmap(cand_max * 4 * 8 + 16)) as *i64
180 var count: i64 = 0
181 var r: i64 = r_min
182 let min_votes: i64 = 4
183 while r <= r_max {
184 count = nx_eye_collect_radius(edge, gx, gy, r, min_votes, cand, count, cand_max)
185 r = r + 2
186 }
187 count = nx_eye_filter_upper_half(cand, count, face_min_x, face_min_y, face_max_x, face_max_y)
188 if count == 0 { return 0 }
189
190 if count == 1 {
191 result[NX_EYE_RES_COUNT] = 1
192 result[NX_EYE_RES_IRIS_R] = cand[2]
193 result[NX_EYE_RES_WIDTH_PX] = cand[2] * NX_EYE_PALPEBRAL_MULT
194 result[NX_EYE_RES_L_CX] = cand[0]
195 result[NX_EYE_RES_L_CY] = cand[1]
196 result[NX_EYE_RES_L_R] = cand[2]
197 return 0
198 }
199
200 // Score every (left, right) pair, keep best.
201 var best_q: i64 = -1
202 var best_l: i64 = -1
203 var best_r: i64 = -1
204 var i: i64 = 0
205 while i < count {
206 var j: i64 = 0
207 while j < count {
208 if j != i {
209 let l_cx: i64 = cand[i * 4 + 0]
210 let l_cy: i64 = cand[i * 4 + 1]
211 let l_r: i64 = cand[i * 4 + 2]
212 let r_cx: i64 = cand[j * 4 + 0]
213 let r_cy: i64 = cand[j * 4 + 1]
214 let r_r: i64 = cand[j * 4 + 2]
215 if l_cx < r_cx {
216 let q: i64 = nx_eye_pair_quality_q10(l_cx, l_cy, l_r, r_cx, r_cy, r_r)
217 if q > best_q { best_q = q; best_l = i; best_r = j }
218 }
219 }
220 j = j + 1
221 }
222 i = i + 1
223 }
224
225 if best_l < 0 { return 0 }
226 let l_r_final: i64 = cand[best_l * 4 + 2]
227 let r_r_final: i64 = cand[best_r * 4 + 2]
228 var iris_r_avg: i64 = (l_r_final + r_r_final) / 2
229
230 result[NX_EYE_RES_COUNT] = 2
231 result[NX_EYE_RES_IRIS_R] = iris_r_avg
232 result[NX_EYE_RES_WIDTH_PX] = iris_r_avg * NX_EYE_PALPEBRAL_MULT
233 result[NX_EYE_RES_L_CX] = cand[best_l * 4 + 0]
234 result[NX_EYE_RES_L_CY] = cand[best_l * 4 + 1]
235 result[NX_EYE_RES_L_R] = l_r_final
236 result[NX_EYE_RES_R_CX] = cand[best_r * 4 + 0]
237 result[NX_EYE_RES_R_CY] = cand[best_r * 4 + 1]
238 result[NX_EYE_RES_R_R] = r_r_final
239 result[NX_EYE_RES_PAIR_QUALITY] = best_q
240 return 0
241}