Colour is arithmetic: match each frame onto the anchor instead of asking for it
The character-as-image fix landed and did NOT close the gap, which is the answer I promised to report rather than paper over. The brute's serve sheet came back with his build, his garments and his DARK TONES PIXEL-IDENTICAL to the anchor — (56,56,32) both — and his big pale shirt two shades brighter, (200,200,184) against (176,176,152). The model was looking straight at the character. It held identity perfectly and re-lit the largest flat area anyway, because every call re-renders and re-rendering re-lights. No prompt fixes that. Six gates spent a day trying to make a generator do arithmetic. So the arithmetic happens here now. Pair each of a frame's dominant colours with its nearest anchor colour, fit one gain+offset per channel across those pairs, apply it to every pixel. Pairs further apart than NEW_COLOUR are dropped from the FIT — colours the anchor genuinely has not got, i.e. blood on a fresh wound — but they still RECEIVE the transform, so a wound stays the right red relative to the body it is on. The gain is clamped so one odd pair cannot posterise a frame. PROVEN ON THE REJECTED FRAME BEFORE SPENDING A SINGLE CALL, which is the discipline I skipped all day: the frame that measured 20.0 away passes after correction, its shirt pulled from (200,200,184) to (176,176,160). And the order is now CORRECT, THEN CHECK — the palette gate is a safety net for what arithmetic could not fix, not a tripwire for what it can. The model this leaves: the generator, given references -> identity, pose, composition the pipeline, deterministically -> exact colour the gates -> layout accidents only Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
525f017636
commit
a58e8b18aa
11 files changed
+81
-9
No files matched your search
+39
-1
@@ -277,6 +277,41 @@ def palette_off(outs, anchor, want):
|
||||
f'{d:.1f} away (want <= {want}). Something the size of a garment or the skin changed.')
|
||||
return None
|
||||
|
||||
# ---- COLOUR IS ARITHMETIC, NOT LUCK. Every call re-renders, and re-rendering re-lights: the brute's
|
||||
# serve sheet came back with his build, his garments and his dark tones PIXEL-IDENTICAL to the anchor
|
||||
# and his big pale shirt two shades brighter. That is not a specification failure — the model was
|
||||
# looking straight at the character — and no prompt fixes it, which is why six gates spent a day
|
||||
# trying to make a generator do arithmetic.
|
||||
#
|
||||
# So do the arithmetic here. Pair each of the frame's dominant colours with its nearest anchor
|
||||
# colour, fit one gain+offset per channel across those pairs, and apply it to every pixel. Pairs
|
||||
# further apart than NEW_COLOUR are dropped from the FIT — those are colours the anchor genuinely
|
||||
# does not have (blood on a fresh wound) — but they still receive the transform, so a wound stays
|
||||
# the right red relative to the body it is on.
|
||||
NEW_COLOUR = 60
|
||||
def match_to_anchor(src, anchor_name):
|
||||
ap = os.path.join(HERE, anchor_name + '.png')
|
||||
if not os.path.exists(ap): return
|
||||
ua, _ = dom_colours(ap)
|
||||
uo, wo = dom_colours(src)
|
||||
if not len(ua) or not len(uo): return
|
||||
d = np.sqrt(((uo[:, None, :] - ua[None, :, :]) ** 2).sum(2))
|
||||
near = d.argmin(1)
|
||||
keep = d.min(1) < NEW_COLOUR
|
||||
if keep.sum() < 2: return # nothing recognisable to fit against
|
||||
src_c, dst_c, w = uo[keep].astype(float), ua[near[keep]].astype(float), wo[keep]
|
||||
a = np.array(Image.open(src).convert('RGBA')).astype(float)
|
||||
m = a[..., 3] > 0
|
||||
for ch in range(3):
|
||||
x, y = src_c[:, ch], dst_c[:, ch]
|
||||
# weighted least squares y = gain*x + offset, with the gain kept sane so one odd pair
|
||||
# cannot posterise the frame
|
||||
W = w.sum(); mx, my = (w * x).sum() / W, (w * y).sum() / W
|
||||
var = (w * (x - mx) ** 2).sum()
|
||||
gain = float(np.clip(((w * (x - mx) * (y - my)).sum() / var) if var > 1e-6 else 1, 0.7, 1.4))
|
||||
a[m, ch] = np.clip(gain * (a[m, ch] - mx) + my, 0, 255)
|
||||
Image.fromarray(a.astype('uint8'), 'RGBA').save(src)
|
||||
|
||||
def not_magenta(sheet):
|
||||
"""The generator sometimes ignores the magenta and paints its own background. That was written
|
||||
off as harmless — the key uses the corner colour — right up until it returned a GREEN sheet and
|
||||
@@ -374,7 +409,10 @@ def run(name, spec, dry=False):
|
||||
bar = {'finer': 1.25, 'same': 0.85, 'loose': 0.7, 'any': 0.0}[spec.get('check', 'finer')]
|
||||
if sum(after) / len(after) < sum(before) / len(before) * bar:
|
||||
print(f'[{name}] REJECTED: not finer than what it replaces {before} -> {after}'); return False
|
||||
# the palette gate supersedes the feature-picked kit gate wherever a batch declares an anchor
|
||||
# CORRECT, THEN CHECK. The frames are pulled onto the anchor's colours first, so the gate below is
|
||||
# a safety net for what arithmetic could not fix rather than a tripwire for what it can.
|
||||
if spec.get('palette_anchor'):
|
||||
for o in outs: match_to_anchor(o, spec['palette_anchor'])
|
||||
if spec.get('palette_anchor'):
|
||||
bad = palette_off(outs, spec['palette_anchor'], spec.get('palette_max', 10))
|
||||
if bad: print(f'[{name}] REJECTED: {bad}'); return False
|
||||
|
||||
Reference in new issue
Block a user