code wiki / (root) / nx_eye_detect.nx

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}