code wiki / _hdl_build / nx_recolor.nx
nx_recolor.nx source
↩ module page · 275 lines · 13058 B
1// nx_recolor.nx -- DETERMINISTIC MASKED RECOLOUR: a real colour-edit channel, no GPU, no re-roll.
2//
3// WHY. The GPU img2img loop could not perform a colour edit at all -- denoise strength preserves or
4// destroys, it does not steer an attribute (MEASURED: the dress stayed yellow at every denoise, edit
5// compliance 2 permil). For the specific, common case "make the garment a different colour", a diffusion
6// re-roll is the wrong tool: it is slow, non-deterministic, and it moves identity/pose/lighting you did
7// NOT ask to touch. A masked hue rotation is the right tool -- it changes ONLY the hue of the pixels that
8// were the target colour, keeps their saturation and value, and leaves every other pixel byte-identical.
9// So it preserves identity/pose/lighting BY CONSTRUCTION and scores maximally on BOTH judges at once,
10// which the diffusion loop provably could not.
11//
12// This is the honest scope line: it recolours, it does not add structure (no sequins, no new folds).
13// For pure colour edits it is exact and free; for structural edits the inpaint/ControlNet channels remain.
14//
15// Integer HSV throughout (never-brick #26). Reuses the judge's hue/sat/decoder so one definition of
16// "this pixel is that colour" governs both the edit and the judgement of the edit.
17// license_tier: ORIGINAL
18import "nx_editjudge_lib.nx"
19import "nx_garment_lib.nx"
20import "nx_png_write.nx"
21
22func rw(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(1, s, n); return 0 }
23func rnum(v: i64) -> i64 {
24 let b: *u8 = sys_mmap(28)
25 var m: i64 = v
26 if m < 0 { sys_write(1, "-" as *u8, 1); m = 0 - m }
27 let t: *u8 = sys_mmap(28)
28 var k: i64 = 0
29 if m == 0 { t[0] = (48 as u8); k = 1 }
30 while m > 0 { t[k] = ((48 + (m % 10)) as u8); m = m / 10; k = k + 1 }
31 var i: i64 = 0
32 while i < k { b[i] = t[k - 1 - i]; i = i + 1 }
33 sys_write(1, b, k)
34 return 0
35}
36func r_streq(a: *u8, b: *u8) -> i64 {
37 var i: i64 = 0
38 while a[i] != (0 as u8) { if a[i] != b[i] { return 0 } i = i + 1 }
39 if b[i] != (0 as u8) { return 0 }
40 return 1
41}
42func r_atoi(s: *u8) -> i64 {
43 var v: i64 = 0
44 var i: i64 = 0
45 while s[i] != (0 as u8) { let c: i64 = (s[i] as i64) & 255; if c >= 48 { if c <= 57 { v = v*10 + (c-48) } } i = i + 1 }
46 return v
47}
48
49// Reconstruct RGB for a target hue while preserving this pixel's value (max) and chroma (delta = max-min).
50// Standard HSV sextant placement done in integers; writes r/g/b at out[o..o+2].
51func r_paint(out: *u8, o: i64, target_hue: i64, vmax: i64, delta: i64) -> i64 {
52 let mn: i64 = vmax - delta
53 var hue: i64 = target_hue
54 if hue < 0 { hue = 0 }
55 if hue >= 360 { hue = hue - 360 }
56 let region: i64 = hue / 60
57 let rem: i64 = hue - region * 60
58 let up: i64 = mn + (delta * rem) / 60
59 let down: i64 = mn + (delta * (60 - rem)) / 60
60 var r: i64 = 0
61 var g: i64 = 0
62 var b: i64 = 0
63 if region == 0 { r = vmax; g = up; b = mn }
64 else { if region == 1 { r = down; g = vmax; b = mn }
65 else { if region == 2 { r = mn; g = vmax; b = up }
66 else { if region == 3 { r = mn; g = down; b = vmax }
67 else { if region == 4 { r = up; g = mn; b = vmax }
68 else { r = vmax; g = mn; b = down } } } } }
69 out[o] = (r as u8)
70 out[o+1] = (g as u8)
71 out[o+2] = (b as u8)
72 return 0
73}
74
75func main(argc: i64, argv: *i64) -> i64 {
76 if argc < 6 {
77 rw("usage: nx_recolor recolor <in.png> <out.png> <from_hue> <to_hue> [x0 y0 x1 y1 (permil)]\n" as *u8)
78 return 2
79 }
80 let verb: *u8 = argv[1] as *u8
81 var use_segment: i64 = 0
82 var use_grow: i64 = 0
83 var use_skin: i64 = 0
84 var use_rotate: i64 = 0
85 if r_streq(verb, "segment" as *u8) == 1 { use_segment = 1 }
86 if r_streq(verb, "grow" as *u8) == 1 { use_grow = 1 }
87 if r_streq(verb, "skin" as *u8) == 1 { use_skin = 1 }
88 // ROTATE = the skin-aware mask, but each pixel's hue is SHIFTED BY A DELTA rather than SET to one
89 // value. For a solid garment the two are equivalent; for a PATTERNED or multi-tone one they are not:
90 // setting an absolute hue FLATTENS the pattern into a single colour, while rotating preserves every
91 // hue RELATIONSHIP inside the garment. A single hue anchor stops describing a patterned object at
92 // all, so the operation -- not just the mask -- has to change.
93 if r_streq(verb, "rotate" as *u8) == 1 { use_rotate = 1; use_skin = 1 }
94 // MULTI: the multi-modal mask (accepts every tone the garment actually contains) driving a hue
95 // ROTATION -- the pairing that finally handles a patterned garment. Rotation alone was inert
96 // because a single-hue mask never admitted a second tone for it to preserve.
97 var use_multi: i64 = 0
98 // ⚠use_skin MUST be set too: the multi branch lives inside the skin-aware block. Omitting it made
99 // `multi` fall silently through to the plain box path, so an entire measurement campaign graded the
100 // NAIVE path while attributing the result to multi-modal code that never executed.
101 if r_streq(verb, "multi" as *u8) == 1 { use_multi = 1; use_rotate = 1; use_skin = 1 }
102 if use_segment == 0 { if use_grow == 0 { if use_skin == 0 { if use_multi == 0 { if r_streq(verb, "recolor" as *u8) == 0 { rw("{\"error\":\"unknown verb (recolor|segment|grow|skin|rotate|multi)\"}\n" as *u8); return 2 } } } } }
103
104 // optional spatial box in permil of w/h; default full-frame. The box is what keeps a garment
105 // recolour off the model's face -- skin shares the warm hue, so a hue mask alone is insufficient.
106 var x0: i64 = 0
107 var y0: i64 = 0
108 var x1: i64 = 1000
109 var y1: i64 = 1000
110 if argc >= 10 { x0 = r_atoi(argv[6] as *u8); y0 = r_atoi(argv[7] as *u8); x1 = r_atoi(argv[8] as *u8); y1 = r_atoi(argv[9] as *u8) }
111
112 let w: *i64 = sys_mmap(8)
113 let h: *i64 = sys_mmap(8)
114 let rgb: *u8 = ej_load_rgb(argv[2] as *u8, w, h)
115 if rgb == (0 as *u8) { rw("{\"error\":\"cannot decode input PNG\"}\n" as *u8); return 3 }
116 let from: i64 = r_atoi(argv[4] as *u8)
117 let to: i64 = r_atoi(argv[5] as *u8)
118 let npix: i64 = w[0] * h[0]
119
120 var changed: i64 = 0
121 var ncomp: i64 = 0
122 var kept: i64 = 0
123
124 if use_skin == 1 {
125 // SKIN-AWARE: exclude skin via Kovac's YCbCr chroma classifier, then keep the largest connected
126 // region. Separates in a DIFFERENT colour space from the hue that could not separate them, so
127 // the saturation floor can stay LOW and the garment's shadowed folds survive.
128 let smask: *u8 = sys_mmap(npix)
129 let nc2: *i64 = sys_mmap(8)
130 let kp2: *i64 = sys_mmap(8)
131 var satmin2: i64 = EJ_SAT_MIN
132 if argc >= 7 { satmin2 = r_atoi(argv[6] as *u8) }
133 if use_multi == 1 {
134 let modes: *i64 = sys_mmap(64) as *i64
135 let nmo: *i64 = sys_mmap(8)
136 // argv[7] = dilation reach in pixels (0 = no dilation, i.e. modes from the core alone).
137 // Explicit and sweepable: the bounding-box version had an implicit, unbounded reach.
138 var reach: i64 = 8
139 if argc >= 8 { reach = r_atoi(argv[7] as *u8) }
140 gm_segment_dilate(rgb, w[0], h[0], from, satmin2, reach, smask, nmo, kp2, modes)
141 ncomp = nmo[0]
142 kept = kp2[0]
143 rw("{\"tones_found\":" as *u8); rnum(nmo[0]); rw(",\"hues\":[" as *u8)
144 var mi: i64 = 0
145 while mi < nmo[0] { if mi > 0 { rw("," as *u8) } rnum(modes[mi]); mi = mi + 1 }
146 rw("]}\n" as *u8)
147 } else {
148 gm_segment_noskin(rgb, w[0], h[0], from, satmin2, smask, nc2, kp2)
149 ncomp = nc2[0]
150 kept = kp2[0]
151 }
152 var si: i64 = 0
153 while si < npix {
154 if smask[si] == (1 as u8) {
155 let rr: i64 = rgb[si*3] as i64
156 let gg: i64 = rgb[si*3+1] as i64
157 let bb: i64 = rgb[si*3+2] as i64
158 let mx: i64 = ej_max3(rr, gg, bb)
159 let mn: i64 = ej_min3(rr, gg, bb)
160 var tgt: i64 = to
161 if use_rotate == 1 {
162 // shift THIS pixel's own hue by the requested delta -- pattern relationships survive
163 var d: i64 = to - from
164 tgt = ej_hue(rr, gg, bb) + d
165 while tgt < 0 { tgt = tgt + 360 }
166 while tgt >= 360 { tgt = tgt - 360 }
167 }
168 r_paint(rgb, si*3, tgt, mx, mx - mn)
169 changed = changed + 1
170 }
171 si = si + 1
172 }
173 }
174 if use_grow == 1 {
175 // LEARNED MODEL: a confident high-saturation core -> measure that core's OWN colour spread ->
176 // grow through adjacent pixels that fit it. Recovers shadowed folds a fixed floor rejects,
177 // while adjacency + the learned saturation distribution stop it at the skin boundary.
178 let gmask: *u8 = sys_mmap(npix)
179 let core: *i64 = sys_mmap(8)
180 let grown: *i64 = sys_mmap(8)
181 let model: *i64 = sys_mmap(48) as *i64
182 let total: i64 = gm_segment_grow(rgb, w[0], h[0], from, gmask, core, grown, model)
183 ncomp = core[0]
184 kept = total
185 var gi: i64 = 0
186 while gi < npix {
187 if gmask[gi] == (1 as u8) {
188 let rr: i64 = rgb[gi*3] as i64
189 let gg: i64 = rgb[gi*3+1] as i64
190 let bb: i64 = rgb[gi*3+2] as i64
191 let mx: i64 = ej_max3(rr, gg, bb)
192 let mn: i64 = ej_min3(rr, gg, bb)
193 r_paint(rgb, gi*3, to, mx, mx - mn)
194 changed = changed + 1
195 }
196 gi = gi + 1
197 }
198 rw("{\"learned_model\":{\"hue\":" as *u8); rnum(model[0])
199 rw(",\"sat\":" as *u8); rnum(model[1])
200 rw(",\"val\":" as *u8); rnum(model[2])
201 rw(",\"tol_hue\":" as *u8); rnum(model[3])
202 rw(",\"tol_sat\":" as *u8); rnum(model[4])
203 rw(",\"tol_val\":" as *u8); rnum(model[5])
204 rw("},\"core_px\":" as *u8); rnum(core[0])
205 rw(",\"grown_px\":" as *u8); rnum(grown[0])
206 rw("}\n" as *u8)
207 }
208 if use_segment == 1 {
209 // SPATIAL COHERENCE: keep only the largest connected colour region (the garment). The face
210 // falls away as its own component, and the boundary follows real fabric edges -- no rectangle,
211 // no seam, and no threshold tuned to this particular photo.
212 let mask: *u8 = sys_mmap(npix)
213 let nc: *i64 = sys_mmap(8)
214 let kp: *i64 = sys_mmap(8)
215 // segment verb reuses argv[6] as the saturation floor (default EJ_SAT_MIN)
216 var satmin: i64 = EJ_SAT_MIN
217 if argc >= 7 { satmin = r_atoi(argv[6] as *u8) }
218 gm_segment_sat(rgb, w[0], h[0], from, satmin, mask, nc, kp)
219 ncomp = nc[0]
220 kept = kp[0]
221 var i: i64 = 0
222 while i < npix {
223 if mask[i] == (1 as u8) {
224 let rr: i64 = rgb[i*3] as i64
225 let gg: i64 = rgb[i*3+1] as i64
226 let bb: i64 = rgb[i*3+2] as i64
227 let mx: i64 = ej_max3(rr, gg, bb)
228 let mn: i64 = ej_min3(rr, gg, bb)
229 r_paint(rgb, i*3, to, mx, mx - mn)
230 changed = changed + 1
231 }
232 i = i + 1
233 }
234 }
235 if use_grow == 0 { if use_segment == 0 { if use_skin == 0 {
236 var i: i64 = 0
237 while i < npix {
238 if ej_in_box(i, w[0], h[0], x0, y0, x1, y1) == 1 {
239 let rr: i64 = rgb[i*3] as i64
240 let gg: i64 = rgb[i*3+1] as i64
241 let bb: i64 = rgb[i*3+2] as i64
242 let sat: i64 = ej_sat(rr, gg, bb)
243 if sat >= EJ_SAT_MIN {
244 let hue: i64 = ej_hue(rr, gg, bb)
245 if ej_hue_dist(hue, from) <= EJ_HUE_TOL {
246 let mx: i64 = ej_max3(rr, gg, bb)
247 let mn: i64 = ej_min3(rr, gg, bb)
248 r_paint(rgb, i*3, to, mx, mx - mn)
249 changed = changed + 1
250 }
251 }
252 }
253 i = i + 1
254 }
255 } } }
256
257 if nx_png_write_rgb(argv[3] as *u8, rgb, w[0], h[0]) != 0 {
258 rw("{\"error\":\"cannot write output PNG\"}\n" as *u8)
259 return 4
260 }
261 rw("{\"action\":\"RECOLORED\",\"mode\":\"" as *u8)
262 if use_multi == 1 { rw("multi" as *u8) } else { if use_rotate == 1 { rw("rotate" as *u8) } else { if use_skin == 1 { rw("skin" as *u8) } else { if use_grow == 1 { rw("grow" as *u8) } else { if use_segment == 1 { rw("segment" as *u8) } else { rw("box" as *u8) } } } } }
263 rw("\",\"w\":" as *u8); rnum(w[0])
264 rw(",\"h\":" as *u8); rnum(h[0])
265 rw(",\"from_hue\":" as *u8); rnum(from)
266 rw(",\"to_hue\":" as *u8); rnum(to)
267 rw(",\"pixels_changed\":" as *u8); rnum(changed)
268 if use_segment == 1 {
269 rw(",\"components\":" as *u8); rnum(ncomp)
270 rw(",\"largest_component_px\":" as *u8); rnum(kept)
271 }
272 rw(",\"footprint_permil\":" as *u8); rnum((changed * 1000) / npix)
273 rw(",\"note\":\"only matched pixels rotated; value+saturation+every other pixel byte-preserved\"}\n" as *u8)
274 return 0
275}