multimodalart HF Staff commited on
Commit
773bea0
·
verified ·
1 Parent(s): ad235ed

Fix pink halo: finish the matte (despeckle/choke/feather) before un-mixing color; expose matte shrink

Browse files
Files changed (2) hide show
  1. app.py +7 -1
  2. spritekit.py +68 -52
app.py CHANGED
@@ -74,6 +74,7 @@ def forge(
74
  softness,
75
  despill,
76
  despeckle,
 
77
  playback_fps,
78
  sprite_height,
79
  request: gr.Request,
@@ -148,6 +149,7 @@ def forge(
148
  softness=float(softness),
149
  despill=float(despill),
150
  despeckle=int(despeckle),
 
151
  )
152
  key_hex = "#%02X%02X%02X" % tuple((key_rgb * 255).astype(int))
153
 
@@ -278,6 +280,10 @@ with gr.Blocks(title="Sprite Forge") as demo:
278
  softness = gr.Slider(0.01, 0.25, value=0.09, step=0.01, label="Edge softness")
279
  despill = gr.Slider(0, 1, value=1.0, step=0.05, label="Despill")
280
  despeckle = gr.Slider(0, 4, value=2, step=1, label="Despeckle")
 
 
 
 
281
 
282
  demo_frame = gr.HTML(IDLE_PANEL, label="Playable")
283
  status = gr.Markdown()
@@ -296,7 +302,7 @@ with gr.Blocks(title="Sprite Forge") as demo:
296
  forge,
297
  [
298
  subject, uploaded, style, image_backend, canvas, selected_moves, duration, steps, seed,
299
- tolerance, softness, despill, despeckle, playback_fps, sprite_height,
300
  ],
301
  [demo_frame, character_preview, clip_gallery, sheet_image, sprite_previews, downloads, status],
302
  )
 
74
  softness,
75
  despill,
76
  despeckle,
77
+ shrink,
78
  playback_fps,
79
  sprite_height,
80
  request: gr.Request,
 
149
  softness=float(softness),
150
  despill=float(despill),
151
  despeckle=int(despeckle),
152
+ shrink=float(shrink),
153
  )
154
  key_hex = "#%02X%02X%02X" % tuple((key_rgb * 255).astype(int))
155
 
 
280
  softness = gr.Slider(0.01, 0.25, value=0.09, step=0.01, label="Edge softness")
281
  despill = gr.Slider(0, 1, value=1.0, step=0.05, label="Despill")
282
  despeckle = gr.Slider(0, 4, value=2, step=1, label="Despeckle")
283
+ shrink = gr.Slider(
284
+ 0, 0.5, value=0.2, step=0.05, label="Matte shrink",
285
+ info="Chokes the matte inward. The clip is 4:2:0, so chroma bleeds ~1px past the real edge.",
286
+ )
287
 
288
  demo_frame = gr.HTML(IDLE_PANEL, label="Playable")
289
  status = gr.Markdown()
 
302
  forge,
303
  [
304
  subject, uploaded, style, image_backend, canvas, selected_moves, duration, steps, seed,
305
+ tolerance, softness, despill, despeckle, shrink, playback_fps, sprite_height,
306
  ],
307
  [demo_frame, character_preview, clip_gallery, sheet_image, sprite_previews, downloads, status],
308
  )
spritekit.py CHANGED
@@ -87,70 +87,87 @@ def _smoothstep(edge0: float, edge1: float, x: np.ndarray) -> np.ndarray:
87
  return t * t * (3.0 - 2.0 * t)
88
 
89
 
90
- def key_out(
91
  frame: np.ndarray,
92
  key_rgb: np.ndarray,
93
  tolerance: float = 0.10,
94
  softness: float = 0.09,
95
- despill: float = 1.0,
96
  ) -> np.ndarray:
97
- """Chroma-key one RGB frame (float 0..1) against ``key_rgb``. Returns RGBA float 0..1.
98
 
99
  ``tolerance`` is the chroma radius that is fully transparent and ``softness`` the width of the ramp out to fully
100
  opaque, both as distances in the (Cb, Cr) plane whose useful range is about 0..0.7.
101
  """
102
  ycc = to_ycc(frame)
103
- key = to_ycc(key_rgb[None, None, :])[0, 0]
104
-
105
- key_chroma = key[1:]
106
- key_norm = float(np.linalg.norm(key_chroma))
107
- if key_norm < 1e-4:
108
  # A neutral backdrop has no chroma to key against; nothing here can separate it from a white glove.
109
- return np.concatenate([frame, np.ones((*frame.shape[:2], 1), np.float32)], axis=-1)
110
- key_dir = key_chroma / key_norm
111
-
112
  delta = ycc[..., 1:] - key_chroma
113
- alpha = _smoothstep(tolerance, tolerance + max(softness, 1e-4), np.linalg.norm(delta, axis=-1))
114
-
115
- out = frame
116
- if despill > 0:
117
- # Un-mix the backdrop out of the edge instead of merely desaturating it.
118
- #
119
- # An edge pixel is a real alpha composite of the character over the backdrop,
120
- # ``observed = a*F + (1-a)*key``, so the character's own color is recoverable exactly:
121
- # ``F = (observed - (1-a)*key) / a``. Solving it removes the fringe rather than trading it for a grey rim,
122
- # and it is an identity at ``a == 1``, so opaque interior pixels are provably untouched.
123
- #
124
- # That last property is why this replaced a chroma-projection despill. Projection subtracts the backdrop's
125
- # chroma direction from *every* pixel, and a magenta key points almost exactly along +Cr, which every warm
126
- # color shares — the first version of this turned an orange cat green. Scaling it by `1 - alpha` fixed the
127
- # hue but then only removed a fraction of the spill on the edge, leaving the 1px magenta halo this does not.
128
- safe = np.maximum(alpha, 1e-3)[..., None]
129
- unmixed = np.clip((frame - (1.0 - alpha)[..., None] * key_rgb) / safe, 0.0, 1.0)
130
- # Below a few percent coverage the division is mostly amplifying noise, and those pixels are invisible
131
- # anyway — fade the correction out rather than letting it produce confetti.
132
- trust = np.clip(alpha / 0.15, 0.0, 1.0)[..., None] * despill
133
- out = frame * (1.0 - trust) + unmixed * trust
134
-
135
- return np.concatenate([out, alpha[..., None]], axis=-1).astype(np.float32)
136
-
137
-
138
- def clean_matte(rgba: np.ndarray, despeckle: int = 2, feather: float = 0.6) -> np.ndarray:
139
- """Drop isolated specks out of the matte and soften the remaining edge.
140
-
141
- A median filter is what removes single-pixel noise without eating a whisker; an erode/dilate pair of the same
142
- radius would take the whisker with it.
143
  """
144
- if despeckle <= 0 and feather <= 0:
145
- return rgba
146
- alpha = Image.fromarray((np.clip(rgba[..., 3], 0, 1) * 255).astype(np.uint8), "L")
147
  if despeckle > 0:
148
- alpha = alpha.filter(ImageFilter.MedianFilter(size=despeckle * 2 + 1))
 
 
 
149
  if feather > 0:
150
- alpha = alpha.filter(ImageFilter.GaussianBlur(radius=feather))
151
- out = rgba.copy()
152
- out[..., 3] = np.asarray(alpha, np.float32) / 255.0
153
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
 
156
  def key_clip(
@@ -160,6 +177,7 @@ def key_clip(
160
  softness: float = 0.09,
161
  despill: float = 1.0,
162
  despeckle: int = 2,
 
163
  feather: float = 0.6,
164
  ) -> tuple[list[np.ndarray], np.ndarray]:
165
  """Key every frame of a clip against **one** color measured on the first frame.
@@ -169,9 +187,7 @@ def key_clip(
169
  """
170
  if key_rgb is None:
171
  key_rgb = detect_key_color(frames[0])
172
- keyed = [
173
- clean_matte(key_out(f, key_rgb, tolerance, softness, despill), despeckle, feather) for f in frames
174
- ]
175
  return keyed, key_rgb
176
 
177
 
 
87
  return t * t * (3.0 - 2.0 * t)
88
 
89
 
90
+ def matte(
91
  frame: np.ndarray,
92
  key_rgb: np.ndarray,
93
  tolerance: float = 0.10,
94
  softness: float = 0.09,
 
95
  ) -> np.ndarray:
96
+ """The alpha channel alone, from the chroma distance to ``key_rgb``.
97
 
98
  ``tolerance`` is the chroma radius that is fully transparent and ``softness`` the width of the ramp out to fully
99
  opaque, both as distances in the (Cb, Cr) plane whose useful range is about 0..0.7.
100
  """
101
  ycc = to_ycc(frame)
102
+ key_chroma = to_ycc(key_rgb[None, None, :])[0, 0][1:]
103
+ if float(np.linalg.norm(key_chroma)) < 1e-4:
 
 
 
104
  # A neutral backdrop has no chroma to key against; nothing here can separate it from a white glove.
105
+ return np.ones(frame.shape[:2], np.float32)
 
 
106
  delta = ycc[..., 1:] - key_chroma
107
+ return _smoothstep(tolerance, tolerance + max(softness, 1e-4), np.linalg.norm(delta, axis=-1))
108
+
109
+
110
+ def refine_matte(alpha: np.ndarray, despeckle: int = 2, shrink: float = 0.2, feather: float = 0.6) -> np.ndarray:
111
+ """Despeckle, choke and soften the matte — all of it before any color is recovered.
112
+
113
+ A median filter removes single-pixel noise without eating a whisker, which an erode/dilate pair of the same
114
+ radius would not.
115
+
116
+ ``shrink`` is the compositor's choke, and it is what actually kills the halo. The alpha ramp is derived from
117
+ chroma, and the clip arrives 4:2:0 subsampled, so chroma bleeds about a pixel past the real edge and a rim of
118
+ genuinely-backdrop pixels comes out faintly opaque. Rescaling the ramp so everything under ``shrink`` goes to
119
+ zero drops that rim.
120
+
121
+ Ordering matters more than any of the individual filters here: feathering *expands* alpha outward, and if it runs
122
+ after the color step it hands partial opacity to pixels that are still pure backdrop — a pink rim, reintroduced
123
+ by the very filter meant to tidy the edge. The matte is finished here and `unmix` is told about it.
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  """
 
 
 
125
  if despeckle > 0:
126
+ image = Image.fromarray((np.clip(alpha, 0, 1) * 255).astype(np.uint8), "L")
127
+ alpha = np.asarray(image.filter(ImageFilter.MedianFilter(size=despeckle * 2 + 1)), np.float32) / 255.0
128
+ if shrink > 0:
129
+ alpha = np.clip((alpha - shrink) / max(1.0 - shrink, 1e-3), 0.0, 1.0)
130
  if feather > 0:
131
+ image = Image.fromarray((np.clip(alpha, 0, 1) * 255).astype(np.uint8), "L")
132
+ alpha = np.asarray(image.filter(ImageFilter.GaussianBlur(radius=feather)), np.float32) / 255.0
133
+ return alpha.astype(np.float32)
134
+
135
+
136
+ def unmix(frame: np.ndarray, alpha: np.ndarray, key_rgb: np.ndarray, strength: float = 1.0) -> np.ndarray:
137
+ """Recover the character's own color from an edge pixel, given the finished matte.
138
+
139
+ An edge pixel is a real alpha composite of the character over the backdrop, ``observed = a*F + (1-a)*key``, so
140
+ the character's color comes back out algebraically: ``F = (observed - (1-a)*key) / a``. That removes the fringe
141
+ rather than trading it for a grey rim, and it is an identity at ``a == 1``, so opaque interior pixels are
142
+ provably untouched.
143
+
144
+ That last property is why this replaced a chroma-projection despill. Projection subtracts the backdrop's chroma
145
+ direction from *every* pixel, and a magenta key points almost exactly along +Cr — an axis every warm color
146
+ shares — so an early version of this turned an orange cat green.
147
+ """
148
+ if strength <= 0:
149
+ return frame
150
+ safe = np.maximum(alpha, 1e-3)[..., None]
151
+ unmixed = np.clip((frame - (1.0 - alpha)[..., None] * key_rgb) / safe, 0.0, 1.0)
152
+ # Below a few percent coverage the division mostly amplifies noise, and those pixels are invisible anyway —
153
+ # fade the correction out rather than letting it produce confetti.
154
+ trust = np.clip(alpha / 0.15, 0.0, 1.0)[..., None] * strength
155
+ return (frame * (1.0 - trust) + unmixed * trust).astype(np.float32)
156
+
157
+
158
+ def key_frame(
159
+ frame: np.ndarray,
160
+ key_rgb: np.ndarray,
161
+ tolerance: float = 0.10,
162
+ softness: float = 0.09,
163
+ despill: float = 1.0,
164
+ despeckle: int = 2,
165
+ shrink: float = 0.2,
166
+ feather: float = 0.6,
167
+ ) -> np.ndarray:
168
+ """matte -> refine -> unmix, in that order. Returns RGBA float 0..1."""
169
+ alpha = refine_matte(matte(frame, key_rgb, tolerance, softness), despeckle, shrink, feather)
170
+ return np.concatenate([unmix(frame, alpha, key_rgb, despill), alpha[..., None]], axis=-1).astype(np.float32)
171
 
172
 
173
  def key_clip(
 
177
  softness: float = 0.09,
178
  despill: float = 1.0,
179
  despeckle: int = 2,
180
+ shrink: float = 0.2,
181
  feather: float = 0.6,
182
  ) -> tuple[list[np.ndarray], np.ndarray]:
183
  """Key every frame of a clip against **one** color measured on the first frame.
 
187
  """
188
  if key_rgb is None:
189
  key_rgb = detect_key_color(frames[0])
190
+ keyed = [key_frame(f, key_rgb, tolerance, softness, despill, despeckle, shrink, feather) for f in frames]
 
 
191
  return keyed, key_rgb
192
 
193