nx_silhouette_integrity.nx source
↩ module page · 342 lines · 12680 B
1// nx_silhouette_integrity.nx -- Tier 6 melty-edge detector.
2//
3// CAPABILITY_COMPLETENESS: FULL
4//
5// User feedback 2026-05-16 (image #5): "its a weird melty body
6// and environment". AI image-gen fails this commonly: the body
7// silhouette wavers / fuses into the background, limb-to-torso
8// joints have no clear contour transition, fabric-to-skin
9// boundaries blur instead of meeting at a sharp edge.
10//
11// Substrate's detector:
12// - Given a segmented blob (from nx_segmenter_classical) and
13// the source RGB image
14// - For each pixel on the blob's PERIMETER (in-blob pixel with
15// at least one out-of-blob neighbor):
16// compute local Sobel gradient magnitude on luminance
17// - Strong gradient = sharp edge; weak = melty
18// - Score = fraction of perimeter pixels above NX_SILH_STRONG_Q10
19// - Sealed verdict SHARP / SOFT / MELTED / NO_PERIMETER
20//
21// Composes:
22// nx_segmenter_classical (provides label_map + blob bbox)
23// inline BT.601 luminance + Sobel 3x3 gradient
24//
25// genealogy_id: substrate_silhouette_integrity_2026_05_16
26// lineage_id: tier_6_melty_detector_v1
27
28// nx_safety_envelope:
29// intended_use: AUTO_APPLIED -- primitive-specific tuning queued
30// sil_target: SIL1
31// evidence: [bulk_applied_2026-05-16, see-file-comment-for-detail]
32// verdict: NOT_YET_EVALUATED
33
34import "nx_syscalls.nx"
35import "nx_runtime.nx"
36import "nx_tier.nx"
37import "nx_segmenter_classical.nx"
38const NX_MAGIC_1024: i64 = 1024
39
40const NX_SILH_Q10: nx_int = 1024
41
42// Sealed verdicts.
43const NX_SILH_VERDICT_SHARP: nx_int = 0 // most perimeter pixels strong
44const NX_SILH_VERDICT_SOFT: nx_int = 1 // moderate
45const NX_SILH_VERDICT_MELTED: nx_int = 2 // most weak; AI tell
46const NX_SILH_VERDICT_NO_PERIMETER: nx_int = 3 // blob too small / no border
47
48// Gradient thresholds (Q10 of normalised |Sobel|).
49// strong: > NX_SILH_STRONG_Q10 (sharp edge)
50// weak: < NX_SILH_WEAK_Q10 (no edge)
51// Normalisation: max Sobel L1 magnitude on 8-bit luma is 4*255 = 1020
52// from Gx and another 1020 from Gy; we divide by 8 to land
53// in [0, 255] then scale to Q10.
54const NX_SILH_STRONG_RAW: nx_int = 60 // strong edge raw threshold
55const NX_SILH_WEAK_RAW: nx_int = 20
56
57// Verdict thresholds on fraction-strong (Q10).
58const NX_SILH_THR_SHARP_Q10: nx_int = 700 // >= 0.68 strong
59const NX_SILH_THR_SOFT_Q10: nx_int = 400 // >= 0.39 strong
60
61// JPL bounds.
62const NX_SILH_MAX_DIM: nx_int = 8192
63
64// ===== result struct =============================================
65
66struct NxSilhouetteResult {
67 blob_label: nx_int,
68 n_perimeter: nx_int,
69 n_strong: nx_int,
70 n_weak: nx_int,
71 fraction_strong: nx_int, // Q10
72 fraction_weak: nx_int, // Q10
73 mean_gradient: nx_int, // 0..255 raw
74 verdict: nx_int,
75}
76
77const NX_SILH_RESULT_BYTES: nx_size = 64
78
79// ===== BT.601 luma from RGB bytes ================================
80
81func _silh_luma_u8(pixels: *u8, off: nx_int, bpp: nx_int, n_ch: nx_int) -> nx_int {
82 let r_val: nx_int = (pixels[off] as nx_int) & 255
83 if n_ch < 3 { return r_val }
84 let g_val: nx_int = (pixels[off + 1] as nx_int) & 255
85 let b_val: nx_int = (pixels[off + 2] as nx_int) & 255
86 return (306 * r_val + 601 * g_val + 117 * b_val) / NX_MAGIC_1024
87}
88
89// ===== Sobel 3x3 gradient magnitude ==============================
90//
91// Returns |Gx| + |Gy| (L1 approximation; cheap). Out-of-bounds
92// neighbours treated as zero (caller should ensure pixel is at
93// least 1 in from the image edge for full kernel).
94
95func _silh_sobel_at(pixels: *u8, x: nx_int, y: nx_int,
96 w: nx_int, h: nx_int, bpp: nx_int, n_ch: nx_int) -> nx_int {
97 // 8 neighbours required. Skip edges of image.
98 if x < 1 { return 0 }
99 if y < 1 { return 0 }
100 if (x + 1) >= w { return 0 }
101 if (y + 1) >= h { return 0 }
102 // Gather luma values in 3x3 window.
103 let off00: nx_int = ((y - 1) * w + (x - 1)) * bpp
104 let off01: nx_int = ((y - 1) * w + x) * bpp
105 let off02: nx_int = ((y - 1) * w + (x + 1)) * bpp
106 let off10: nx_int = (y * w + (x - 1)) * bpp
107 let off12: nx_int = (y * w + (x + 1)) * bpp
108 let off20: nx_int = ((y + 1) * w + (x - 1)) * bpp
109 let off21: nx_int = ((y + 1) * w + x) * bpp
110 let off22: nx_int = ((y + 1) * w + (x + 1)) * bpp
111 let l00: nx_int = _silh_luma_u8(pixels, off00, bpp, n_ch)
112 let l01: nx_int = _silh_luma_u8(pixels, off01, bpp, n_ch)
113 let l02: nx_int = _silh_luma_u8(pixels, off02, bpp, n_ch)
114 let l10: nx_int = _silh_luma_u8(pixels, off10, bpp, n_ch)
115 let l12: nx_int = _silh_luma_u8(pixels, off12, bpp, n_ch)
116 let l20: nx_int = _silh_luma_u8(pixels, off20, bpp, n_ch)
117 let l21: nx_int = _silh_luma_u8(pixels, off21, bpp, n_ch)
118 let l22: nx_int = _silh_luma_u8(pixels, off22, bpp, n_ch)
119 // Gx = [-1 0 1; -2 0 2; -1 0 1]
120 let gx: nx_int = (l02 - l00) + 2 * (l12 - l10) + (l22 - l20)
121 // Gy = [-1 -2 -1; 0 0 0; 1 2 1]
122 let gy: nx_int = (l20 - l00) + 2 * (l21 - l01) + (l22 - l02)
123 var abs_gx: nx_int = gx
124 if abs_gx < 0 { abs_gx = 0 - abs_gx }
125 var abs_gy: nx_int = gy
126 if abs_gy < 0 { abs_gy = 0 - abs_gy }
127 // Normalise: max possible is ~4*255 = 1020 per axis; total max ~2040.
128 // Divide by 8 to land roughly in [0, 255].
129 let mag: nx_int = (abs_gx + abs_gy) / 8
130 if mag > 255 { return 255 }
131 return mag
132}
133
134// ===== perimeter scan + classification ===========================
135
136func nx_silhouette_integrity(
137 pixels: *u8, w: nx_int, h: nx_int, bpp: nx_int, n_ch: nx_int,
138 label_map: *nx_int, blob: *NxSegBlob) -> *NxSilhouetteResult {
139
140 let r_ptr: *u8 = sys_mmap(NX_SILH_RESULT_BYTES)
141 let r: *NxSilhouetteResult = r_ptr as *NxSilhouetteResult
142 r.blob_label = blob.label
143 r.n_perimeter = 0
144 r.n_strong = 0
145 r.n_weak = 0
146 r.fraction_strong = 0
147 r.fraction_weak = 0
148 r.mean_gradient = 0
149 r.verdict = NX_SILH_VERDICT_NO_PERIMETER
150
151 if w <= 0 { return r }
152 if h <= 0 { return r }
153 if w > NX_SILH_MAX_DIM { return r }
154 if h > NX_SILH_MAX_DIM { return r }
155
156 let bid: nx_int = blob.label
157 var n_perim: nx_int = 0
158 var n_strong: nx_int = 0
159 var n_weak: nx_int = 0
160 var sum_grad: nx_int = 0
161
162 var y: nx_int = blob.y0
163 while y < blob.y1 {
164 var x: nx_int = blob.x0
165 while x < blob.x1 {
166 let idx: nx_int = y * w + x
167 if label_map[idx] == bid {
168 // Check 4-neighbours for label difference -> perimeter.
169 var is_perim: nx_int = 0
170 if x == 0 { is_perim = 1 }
171 else {
172 if label_map[idx - 1] != bid { is_perim = 1 }
173 }
174 if is_perim == 0 {
175 if (x + 1) >= w { is_perim = 1 }
176 else {
177 if label_map[idx + 1] != bid { is_perim = 1 }
178 }
179 }
180 if is_perim == 0 {
181 if y == 0 { is_perim = 1 }
182 else {
183 if label_map[idx - w] != bid { is_perim = 1 }
184 }
185 }
186 if is_perim == 0 {
187 if (y + 1) >= h { is_perim = 1 }
188 else {
189 if label_map[idx + w] != bid { is_perim = 1 }
190 }
191 }
192 if is_perim == 1 {
193 let mag: nx_int = _silh_sobel_at(pixels, x, y, w, h, bpp, n_ch)
194 sum_grad = sum_grad + mag
195 n_perim = n_perim + 1
196 if mag >= NX_SILH_STRONG_RAW {
197 n_strong = n_strong + 1
198 }
199 if mag <= NX_SILH_WEAK_RAW {
200 n_weak = n_weak + 1
201 }
202 }
203 }
204 x = x + 1
205 }
206 y = y + 1
207 }
208
209 r.n_perimeter = n_perim
210 r.n_strong = n_strong
211 r.n_weak = n_weak
212 if n_perim <= 0 {
213 r.verdict = NX_SILH_VERDICT_NO_PERIMETER
214 return r
215 }
216 r.mean_gradient = sum_grad / n_perim
217 r.fraction_strong = (n_strong * NX_SILH_Q10) / n_perim
218 r.fraction_weak = (n_weak * NX_SILH_Q10) / n_perim
219
220 if r.fraction_strong >= NX_SILH_THR_SHARP_Q10 {
221 r.verdict = NX_SILH_VERDICT_SHARP
222 } else {
223 if r.fraction_strong >= NX_SILH_THR_SOFT_Q10 {
224 r.verdict = NX_SILH_VERDICT_SOFT
225 } else {
226 r.verdict = NX_SILH_VERDICT_MELTED
227 }
228 }
229 return r
230}
231
232// ===== self-test ==================================================
233//
234// Synthetic test: build a small 10x10 RGB image where pixels in a
235// rectangle are bright (200 luma) and outside are dark (50 luma).
236// The rectangle has a SHARP edge by construction. Run segmenter
237// on a matching binary mask, pick the blob, run silhouette
238// integrity -> expect SHARP verdict.
239//
240// Second test: gradient interior (luma fades from rect center to
241// edge) -> SOFT or MELTED verdict.
242
243func _silh_fill_rgb_rect(pixels: *u8, w: nx_int, bpp: nx_int,
244 x0: nx_int, y0: nx_int, x1: nx_int, y1: nx_int,
245 r: nx_int, g: nx_int, b: nx_int) -> nx_int {
246 var y: nx_int = y0
247 while y < y1 {
248 var x: nx_int = x0
249 while x < x1 {
250 let off: nx_int = (y * w + x) * bpp
251 pixels[off] = r as u8
252 pixels[off + 1] = g as u8
253 pixels[off + 2] = b as u8
254 x = x + 1
255 }
256 y = y + 1
257 }
258 return 0
259}
260
261func main() -> nx_int {
262 // 10x10 RGB image, all dark by default.
263 let pixels: *u8 = (sys_mmap(300)) as *u8
264 _silh_fill_rgb_rect(pixels, 10, 3, 0, 0, 10, 10, 50, 50, 50)
265
266 // Bright 4x4 rectangle in the middle (perfect sharp edges).
267 _silh_fill_rgb_rect(pixels, 10, 3, 3, 3, 7, 7, 220, 220, 220)
268
269 // Build matching binary mask + run segmenter.
270 let mask: *nx_int = (sys_mmap(800)) as *nx_int
271 var z: nx_int = 0
272 while z < 100 {
273 mask[z] = 0
274 z = z + 1
275 }
276 var yy: nx_int = 3
277 while yy < 7 {
278 var xx: nx_int = 3
279 while xx < 7 {
280 mask[yy * 10 + xx] = 1
281 xx = xx + 1
282 }
283 yy = yy + 1
284 }
285 let seg_r: *NxSegResult = nx_segmenter_connected(mask, 10, 10)
286 if seg_r == (0 as *NxSegResult) { return 1 }
287 if seg_r.n_blobs != 1 { return 2 }
288 let blob: *NxSegBlob = nx_segmenter_largest_blob(seg_r)
289
290 let silh_r: *NxSilhouetteResult = nx_silhouette_integrity(
291 pixels, 10, 10, 3, 3, seg_r.label_map, blob)
292 if silh_r == (0 as *NxSilhouetteResult) { return 10 }
293 if silh_r.n_perimeter == 0 { return 11 }
294 // Sharp-edged rectangle -> SHARP verdict expected.
295 if silh_r.verdict != NX_SILH_VERDICT_SHARP { return 12 }
296 if silh_r.fraction_strong < NX_SILH_THR_SHARP_Q10 { return 13 }
297
298 // ---- gradient (melty) case: replace pixels with gradient ----
299 //
300 // Inside rectangle: luma drops gradually from center to edge.
301 // center (5,5): luma 220
302 // edges: luma 70 (just slightly above background 50)
303 // This produces weak Sobel gradient at perimeter.
304 let pixels2: *u8 = (sys_mmap(300)) as *u8
305 _silh_fill_rgb_rect(pixels2, 10, 3, 0, 0, 10, 10, 50, 50, 50)
306 var py: nx_int = 3
307 while py < 7 {
308 var px: nx_int = 3
309 while px < 7 {
310 // Gradient: luma = 70 at edge of rect, higher toward center.
311 // Just within 1 of the boundary pixel value to make
312 // gradient weak.
313 let off: nx_int = (py * 10 + px) * 3
314 pixels2[off] = 70 as u8
315 pixels2[off + 1] = 70 as u8
316 pixels2[off + 2] = 70 as u8
317 px = px + 1
318 }
319 py = py + 1
320 }
321 // Now luma jump at perimeter is 70 - 50 = 20; Sobel maxes around
322 // 20*4/8 = 10 per axis, total ~20 -> below NX_SILH_STRONG_RAW = 60
323 // and at the NX_SILH_WEAK_RAW = 20 boundary. Should classify
324 // as MELTED.
325 let silh_melted: *NxSilhouetteResult = nx_silhouette_integrity(
326 pixels2, 10, 10, 3, 3, seg_r.label_map, blob)
327 if silh_melted.verdict != NX_SILH_VERDICT_MELTED { return 20 }
328
329 // ---- NO_PERIMETER case (synthetic empty bbox) ----
330 let empty_blob_ptr: *u8 = sys_mmap(NX_SEG_BLOB_BYTES)
331 let empty_blob: *NxSegBlob = empty_blob_ptr as *NxSegBlob
332 empty_blob.label = 999 // not in label_map
333 empty_blob.x0 = 0
334 empty_blob.y0 = 0
335 empty_blob.x1 = 10
336 empty_blob.y1 = 10
337 let silh_empty: *NxSilhouetteResult = nx_silhouette_integrity(
338 pixels, 10, 10, 3, 3, seg_r.label_map, empty_blob)
339 if silh_empty.verdict != NX_SILH_VERDICT_NO_PERIMETER { return 30 }
340
341 return 0
342}