1- """Layer compositing using dithered threshold masks.
1+ """Layer compositing using dithered masks compared against layer pixel values .
22
33The :func:`composite` function blends a top layer over a bottom layer pixel
4- by pixel: where the mask value exceeds ``threshold`` , the top layer shows
5- through; everywhere else the bottom layer is visible.
4+ by pixel: where the top pixel's brightness exceeds the mask value , the top
5+ layer shows through; everywhere else the bottom layer is visible.
66
77When both layers are ``Image`` instances, the result is a new ``Image``.
88When either layer is a ``Video``, all composited frames are collected eagerly
@@ -27,10 +27,19 @@ def _blend(
2727 top : np .ndarray ,
2828 bottom : np .ndarray ,
2929 mask : np .ndarray ,
30- threshold : int ,
3130) -> np .ndarray :
32- """Return a frame with top pixels where mask > threshold, else bottom pixels."""
33- cond = mask > threshold
31+ """Return a frame with top pixels where top brightness > mask, else bottom
32+ pixels.
33+ """
34+ if top .ndim == 3 :
35+ brightness = (
36+ 0.299 * top [:, :, 0 ].astype (np .float32 )
37+ + 0.587 * top [:, :, 1 ].astype (np .float32 )
38+ + 0.114 * top [:, :, 2 ].astype (np .float32 )
39+ )
40+ else :
41+ brightness = top .astype (np .float32 )
42+ cond = brightness > mask .astype (np .float32 )
3443 if top .ndim == 3 :
3544 cond = cond [:, :, np .newaxis ]
3645 return np .where (cond , top , bottom ).astype (np .uint8 )
@@ -40,50 +49,51 @@ def _blend_color(
4049 top : np .ndarray ,
4150 bottom : np .ndarray ,
4251 mask : np .ndarray ,
43- threshold : int ,
4452) -> np .ndarray :
45- """Return a frame with per-channel blending: each channel replaced where its mask > threshold."""
46- return np .where (mask > threshold , top , bottom ).astype (np .uint8 )
53+ """Return a frame with per-channel blending: each channel replaced where
54+ top channel > mask channel.
55+ """
56+ return np .where (top > mask , top , bottom ).astype (np .uint8 )
4757
4858
4959def composite (
5060 top : "Image | Video" ,
5161 bottom : "Image | Video" ,
5262 mask : np .ndarray | None = None ,
53- threshold : int = 127 ,
5463 tile_every_x : int | None = 8 ,
5564 tile_every_y : int | None = 8 ,
5665 repeat_every_x : int | None = 1 ,
5766 repeat_every_y : int | None = 1 ,
5867) -> "Image | Video" :
59- """Composite a top layer over a bottom layer using a dithered threshold mask.
68+ """Composite a top layer over a bottom layer using a dithered mask.
6069
61- For every pixel, the top layer is used where ``mask > threshold`` and the
62- bottom layer is used everywhere else.
70+ For every pixel, the top layer is used where the top pixel's perceived
71+ brightness (``0.299·R + 0.587·G + 0.114·B``) exceeds the corresponding
72+ mask value; the bottom layer is used everywhere else.
6373
6474 **Dimension matching** — the top layer is always resized to match the
6575 bottom layer's spatial dimensions. When both layers are ``Video``, the top
6676 is also temporally resampled to match the bottom's frame count.
6777
6878 **Return type** — mirrors the bottom layer type:
6979
70- - ``bottom`` is ``Image`` and ``top`` is ``Image`` → new :class:`~dithertools.image.Image`.
80+ - ``bottom`` is ``Image`` and ``top`` is ``Image`` →
81+ new :class:`~dithertools.image.Image`.
7182 - ``bottom`` is ``Video`` → new :class:`~dithertools.video.Video` (memory-backed).
72- - ``bottom`` is ``Image`` and ``top`` is ``Video`` → new :class:`~dithertools.video.Video`
83+ - ``bottom`` is ``Image`` and ``top`` is ``Video`` →
84+ new :class:`~dithertools.video.Video`
7385 (memory-backed); the bottom image is repeated for every frame of the top video.
7486
7587 Args:
76- top: The foreground layer. Shown where ``mask > threshold`` .
88+ top: The foreground layer. Shown where its brightness exceeds the mask .
7789 bottom: The background layer. Determines output dimensions and,
7890 when a ``Video``, the output frame count.
79- mask: A ``(height, width)`` ``uint8`` array of threshold values, such
80- as those returned by :func:`~dithertools.matrix.random_matrix`.
81- If ``None``, a fresh random mask is generated at the bottom
82- layer's dimensions. If the provided mask does not match the bottom
83- layer dimensions it is resized with nearest-neighbour interpolation.
84- threshold: Pixels in ``top`` replace pixels in ``bottom`` where the
85- corresponding mask value is strictly greater than this value.
86- Must be in ``[0, 255]``. Defaults to ``127``.
91+ mask: A ``(height, width)`` ``uint8`` array of values against which
92+ the top pixel brightness is compared, such as those returned by
93+ :func:`~dithertools.matrix.random_matrix`. If ``None``, a fresh
94+ random mask is generated at the bottom layer's dimensions. If the
95+ provided mask does not match the bottom layer dimensions it is
96+ resized with nearest-neighbour interpolation.
8797 tile_every_x: Tile width passed to :func:`~dithertools.matrix.random_matrix`
8898 when generating an auto mask (i.e. when ``mask`` is ``None``).
8999 The mask pattern repeats horizontally every this many columns.
@@ -144,15 +154,15 @@ def composite(
144154
145155 if isinstance (bottom , Image ) and isinstance (top , Image ):
146156 return Image .from_array (
147- _blend (top .resize (w , h ).data , bottom .data , mask , threshold )
157+ _blend (top .resize (w , h ).data , bottom .data , mask )
148158 )
149159
150160 if isinstance (bottom , Video ) and isinstance (top , Image ):
151161 top_frame = top .resize (w , h ).data
152162
153163 def _image_over_video () -> Iterator [np .ndarray ]:
154164 for bottom_frame in bottom :
155- yield _blend (top_frame , bottom_frame , mask , threshold )
165+ yield _blend (top_frame , bottom_frame , mask )
156166
157167 return Video .from_frames (_image_over_video (), fps = bottom .fps )
158168
@@ -161,7 +171,7 @@ def _image_over_video() -> Iterator[np.ndarray]:
161171
162172 def _video_over_video () -> Iterator [np .ndarray ]:
163173 for bottom_frame , top_frame in zip (bottom , top ):
164- yield _blend (top_frame , bottom_frame , mask , threshold )
174+ yield _blend (top_frame , bottom_frame , mask )
165175
166176 return Video .from_frames (_video_over_video (), fps = bottom .fps )
167177
@@ -171,20 +181,20 @@ def _video_over_video() -> Iterator[np.ndarray]:
171181
172182 def _video_over_image () -> Iterator [np .ndarray ]:
173183 for top_frame in top :
174- yield _blend (top_frame , bottom_frame , mask , threshold )
184+ yield _blend (top_frame , bottom_frame , mask )
175185
176186 return Video .from_frames (_video_over_image (), fps = top .fps )
177187
178188 raise TypeError (
179- f"Unsupported layer types: top={ type (top ).__name__ } , bottom={ type (bottom ).__name__ } "
189+ f"Unsupported layer types: top={ type (top ).__name__ } ,"
190+ f" bottom={ type (bottom ).__name__ } "
180191 )
181192
182193
183194def composite_color (
184195 top : "Image | Video" ,
185196 bottom : "Image | Video" ,
186197 mask : np .ndarray | None = None ,
187- threshold : int = 127 ,
188198 tile_every_x : int | None = 8 ,
189199 tile_every_y : int | None = 8 ,
190200 repeat_every_x : int | None = 1 ,
@@ -193,36 +203,35 @@ def composite_color(
193203 """Composite a top layer over a bottom layer with independent per-channel masking.
194204
195205 Identical to :func:`composite` except that the mask is a three-channel
196- ``(height, width, 3)`` array and the threshold comparison is applied
197- separately to each colour channel. This means the red, green, and blue
198- components of each pixel can be replaced independently , producing chromatic
199- dithering effects that are not possible with a single-channel mask.
206+ ``(height, width, 3)`` array and each channel of the top pixel is compared
207+ directly against the corresponding mask channel. A top channel value replaces
208+ the bottom wherever it strictly exceeds the mask channel value , producing
209+ chromatic dithering effects not possible with a single-channel mask.
200210
201211 **Dimension matching** — the top layer is always resized to match the
202212 bottom layer's spatial dimensions. When both layers are ``Video``, the top
203213 is also temporally resampled to match the bottom's frame count.
204214
205215 **Return type** — mirrors the bottom layer type:
206216
207- - ``bottom`` is ``Image`` and ``top`` is ``Image`` → new :class:`~dithertools.image.Image`.
217+ - ``bottom`` is ``Image`` and ``top`` is ``Image`` →
218+ new :class:`~dithertools.image.Image`.
208219 - ``bottom`` is ``Video`` → new :class:`~dithertools.video.Video` (memory-backed).
209- - ``bottom`` is ``Image`` and ``top`` is ``Video`` → new :class:`~dithertools.video.Video`
220+ - ``bottom`` is ``Image`` and ``top`` is ``Video`` →
221+ new :class:`~dithertools.video.Video`
210222 (memory-backed); the bottom image is repeated for every frame of the top video.
211223
212224 Args:
213- top: The foreground layer. Each channel is shown where the
214- corresponding mask channel exceeds ``threshold`` .
225+ top: The foreground layer. Each channel is shown where that channel's
226+ value exceeds the corresponding mask channel.
215227 bottom: The background layer. Determines output dimensions and,
216228 when a ``Video``, the output frame count.
217- mask: A ``(height, width, 3)`` ``uint8`` array of per-channel threshold
218- values , such as those returned by
219- :func:`~dithertools.matrix.random_matrix_color`. If ``None``, a
229+ mask: A ``(height, width, 3)`` ``uint8`` array of per-channel values
230+ against which the top channels are compared , such as those returned
231+ by :func:`~dithertools.matrix.random_matrix_color`. If ``None``, a
220232 fresh random colour mask is generated at the bottom layer's
221233 dimensions. If the provided mask does not match the bottom layer
222234 dimensions it is resized with nearest-neighbour interpolation.
223- threshold: A channel value in ``top`` replaces the corresponding
224- channel in ``bottom`` where its mask channel is strictly greater
225- than this value. Must be in ``[0, 255]``. Defaults to ``127``.
226235 tile_every_x: Tile width passed to
227236 :func:`~dithertools.matrix.random_matrix_color` when generating an
228237 auto mask (i.e. when ``mask`` is ``None``). The mask pattern repeats
@@ -260,7 +269,9 @@ def composite_color(
260269
261270 import numpy as np
262271 mask = np.random.randint(0, 256, (720, 1280, 3), dtype=np.uint8)
263- result = composite_color(top=Image("logo.png"), bottom=Video("clip.mp4"), mask=mask)
272+ result = composite_color(
273+ top=Image("logo.png"), bottom=Video("clip.mp4"), mask=mask
274+ )
264275 result.save("out.mp4")
265276 """
266277 from dithertools .image import Image
@@ -288,15 +299,15 @@ def composite_color(
288299
289300 if isinstance (bottom , Image ) and isinstance (top , Image ):
290301 return Image .from_array (
291- _blend_color (top .resize (w , h ).data , bottom .data , mask , threshold )
302+ _blend_color (top .resize (w , h ).data , bottom .data , mask )
292303 )
293304
294305 if isinstance (bottom , Video ) and isinstance (top , Image ):
295306 top_frame = top .resize (w , h ).data
296307
297308 def _image_over_video () -> Iterator [np .ndarray ]:
298309 for bottom_frame in bottom :
299- yield _blend_color (top_frame , bottom_frame , mask , threshold )
310+ yield _blend_color (top_frame , bottom_frame , mask )
300311
301312 return Video .from_frames (_image_over_video (), fps = bottom .fps )
302313
@@ -305,7 +316,7 @@ def _image_over_video() -> Iterator[np.ndarray]:
305316
306317 def _video_over_video () -> Iterator [np .ndarray ]:
307318 for bottom_frame , top_frame in zip (bottom , top ):
308- yield _blend_color (top_frame , bottom_frame , mask , threshold )
319+ yield _blend_color (top_frame , bottom_frame , mask )
309320
310321 return Video .from_frames (_video_over_video (), fps = bottom .fps )
311322
@@ -315,10 +326,11 @@ def _video_over_video() -> Iterator[np.ndarray]:
315326
316327 def _video_over_image () -> Iterator [np .ndarray ]:
317328 for top_frame in top :
318- yield _blend_color (top_frame , bottom_frame , mask , threshold )
329+ yield _blend_color (top_frame , bottom_frame , mask )
319330
320331 return Video .from_frames (_video_over_image (), fps = top .fps )
321332
322333 raise TypeError (
323- f"Unsupported layer types: top={ type (top ).__name__ } , bottom={ type (bottom ).__name__ } "
334+ f"Unsupported layer types: top={ type (top ).__name__ } ,"
335+ f" bottom={ type (bottom ).__name__ } "
324336 )
0 commit comments