@@ -85,10 +85,73 @@ def _blend_color(
8585 return np .where (top > mask , top , bottom ).astype (np .uint8 )
8686
8787
88+ def _rgb_to_gray (arr : np .ndarray ) -> np .ndarray :
89+ if arr .ndim == 2 :
90+ return arr
91+ return (
92+ 0.299 * arr [:, :, 0 ].astype (np .float32 )
93+ + 0.587 * arr [:, :, 1 ].astype (np .float32 )
94+ + 0.114 * arr [:, :, 2 ].astype (np .float32 )
95+ ).astype (np .uint8 )
96+
97+
98+ def _build_mask_iter (
99+ source : object ,
100+ w : int ,
101+ h : int ,
102+ fc : int | None ,
103+ to_gray : bool ,
104+ ) -> Iterator [np .ndarray ]:
105+ """Build a per-frame mask iterator from an Image, Video, or FrameGenerator."""
106+ from dithertools .image import Image
107+ from dithertools .video import Video
108+
109+ if isinstance (source , Image ):
110+ m = source .resize (w , h ).data
111+ if to_gray :
112+ m = _rgb_to_gray (m )
113+ return itertools .repeat (m )
114+
115+ if isinstance (source , Video ):
116+ if fc is not None :
117+ source .resample (fc )
118+ source .resize (w , h )
119+
120+ def _video_mask () -> Iterator [np .ndarray ]:
121+ for frame in source :
122+ yield _rgb_to_gray (frame ) if to_gray else frame
123+
124+ return _video_mask ()
125+
126+ if isinstance (source , FrameGenerator ):
127+ if source ._source_type == "video" :
128+ if fc is not None :
129+ source ._video .resample (fc ) # type: ignore[union-attr]
130+ source ._video .resize (w , h ) # type: ignore[union-attr]
131+ iter (source ._video ) # type: ignore[union-attr]
132+ source ._frame_idx = 0
133+ source ._buffer = None
134+
135+ def _fg_video_mask () -> Iterator [np .ndarray ]:
136+ for frame in source :
137+ yield _rgb_to_gray (frame ) if to_gray else frame
138+
139+ return _fg_video_mask ()
140+
141+ def _fg_image_mask () -> Iterator [np .ndarray ]:
142+ while True :
143+ frame = _resize_frame (next (source ), w , h ) # type: ignore[arg-type]
144+ yield _rgb_to_gray (frame ) if to_gray else frame
145+
146+ return _fg_image_mask ()
147+
148+ raise TypeError (f"unsupported mask source type { type (source ).__name__ !r} " )
149+
150+
88151def composite (
89152 top : "Image | Video | FrameGenerator" ,
90153 bottom : "Image | Video | FrameGenerator" ,
91- mask : np .ndarray | None = None ,
154+ mask : " np.ndarray | Image | Video | FrameGenerator | MaskGenerator | None" = None ,
92155 tile_every_x : int | None = 8 ,
93156 tile_every_y : int | None = 8 ,
94157 repeat_every_x : int | None = 1 ,
@@ -132,18 +195,28 @@ def composite(
132195 :class:`~dithertools.image.Image`,
133196 :class:`~dithertools.video.Video`, or
134197 :class:`~dithertools.generators.FrameGenerator`.
135- mask: A ``(height, width)`` ``uint8`` array, or a
136- :class:`~dithertools.generators.MaskGenerator`, against which the
137- top pixel brightness is compared. Static arrays such as those
138- returned by :func:`~dithertools.matrix.random_matrix` are reused
139- for every frame. A :class:`~dithertools.generators.MaskGenerator`
140- yields a new mask per frame, enabling animated dithering patterns;
141- ``color=True`` generators are rejected. If ``None``, a fresh
142- random grayscale mask is generated at the bottom layer's dimensions.
143- If a static array does not match the bottom layer dimensions it is
144- resized with nearest-neighbour interpolation. A generator with no
145- ``initial`` is lazily initialized to a random mask at the bottom
146- layer's dimensions.
198+ mask: Grayscale mask source. Accepted types:
199+
200+ - ``None`` — a random grayscale mask is generated at the bottom
201+ layer's dimensions.
202+ - ``np.ndarray`` ``(H, W)`` ``uint8`` — static mask, reused every
203+ frame; resized with nearest-neighbour interpolation if dimensions
204+ differ.
205+ - :class:`~dithertools.image.Image` — converted to grayscale (RGB
206+ images use ``0.299R + 0.587G + 0.114B``), resized to bottom
207+ dimensions, and reused every frame.
208+ - :class:`~dithertools.video.Video` or video-backed
209+ :class:`~dithertools.generators.FrameGenerator` — frames are
210+ used one-per-output-frame; resampled to match the output frame
211+ count and resized to bottom dimensions. RGB frames are
212+ auto-converted to grayscale.
213+ - Image-backed :class:`~dithertools.generators.FrameGenerator` —
214+ infinite; ``fn`` is called each output frame; frames resized and
215+ converted to grayscale.
216+ - :class:`~dithertools.generators.MaskGenerator` — yields a new
217+ mask per frame; ``color=True`` generators are rejected. A
218+ generator with no ``initial`` is lazily initialized at the bottom
219+ layer's dimensions.
147220 tile_every_x: Tile width passed to :func:`~dithertools.matrix.random_matrix`
148221 when generating an auto mask (i.e. when ``mask`` is ``None``).
149222 The mask pattern repeats horizontally every this many columns.
@@ -199,7 +272,7 @@ def composite(
199272 repeat_every_x = repeat_every_x ,
200273 repeat_every_y = repeat_every_y ,
201274 )
202- mask_iter : Iterator [np .ndarray ] = itertools .repeat (_arr )
275+ mask_iter : Iterator [np .ndarray ] | None = itertools .repeat (_arr )
203276 elif isinstance (mask , MaskGenerator ):
204277 if mask .color :
205278 raise ValueError (
@@ -219,10 +292,25 @@ def composite(
219292 )
220293 )
221294 mask_iter = mask
222- else :
295+ elif isinstance ( mask , np . ndarray ) :
223296 if mask .shape [:2 ] != (h , w ):
224297 mask = np .array (_PILImage .fromarray (mask ).resize ((w , h ), _PILImage .NEAREST ))
225298 mask_iter = itertools .repeat (mask )
299+ elif isinstance (mask , Image ):
300+ _m = mask .resize (w , h ).data
301+ mask_iter = itertools .repeat (_rgb_to_gray (_m ))
302+ elif isinstance (mask , (Video , FrameGenerator )):
303+ mask_iter = None # resolved per-branch once frame count is known
304+ else :
305+ raise TypeError (
306+ f"mask must be a numpy array, Image, Video, FrameGenerator, "
307+ f"MaskGenerator, or None; got { type (mask ).__name__ !r} "
308+ )
309+
310+ def _resolve_mask (fc : int | None = None ) -> Iterator [np .ndarray ]:
311+ if mask_iter is not None :
312+ return mask_iter # type: ignore[return-value]
313+ return _build_mask_iter (mask , w , h , fc , to_gray = True )
226314
227315 def _is_fg_video (x : object ) -> bool :
228316 return isinstance (x , FrameGenerator ) and x ._source_type == "video"
@@ -233,13 +321,14 @@ def _is_fg_image(x: object) -> bool:
233321 # Image × Image → Image
234322 if isinstance (bottom , Image ) and isinstance (top , Image ):
235323 return Image .from_array (
236- _blend (top .resize (w , h ).data , bottom .data , next (mask_iter ))
324+ _blend (top .resize (w , h ).data , bottom .data , next (_resolve_mask () ))
237325 )
238326
239327 # --- Bottom is finite (Video or fg_video): bottom drives frame count ---
240328 if isinstance (bottom , Video ) or _is_fg_video (bottom ):
241329 fc = bottom .frame_count
242330 fps = bottom .fps
331+ mask_iter = _resolve_mask (fc )
243332
244333 if isinstance (top , Image ):
245334 top_frame = top .resize (w , h ).data
@@ -293,6 +382,7 @@ def _fg_video_over_video() -> Iterator[np.ndarray]:
293382 if isinstance (top , Video ):
294383 top .resize (w , h )
295384 total = top .frame_count
385+ mask_iter = _resolve_mask (total )
296386
297387 def _video_over_image () -> Iterator [np .ndarray ]:
298388 for top_frame , m in zip (top , mask_iter ):
@@ -307,6 +397,7 @@ def _video_over_image() -> Iterator[np.ndarray]:
307397 iter (top ._video ) # type: ignore[union-attr]
308398 top ._frame_idx = 0 # type: ignore[union-attr]
309399 total = top .frame_count
400+ mask_iter = _resolve_mask (total )
310401
311402 def _fg_video_over_image () -> Iterator [np .ndarray ]:
312403 for top_frame , m in zip (top , mask_iter ):
@@ -321,6 +412,7 @@ def _fg_video_over_image() -> Iterator[np.ndarray]:
321412 if isinstance (top , Video ):
322413 top .resize (w , h )
323414 total = top .frame_count
415+ mask_iter = _resolve_mask (total )
324416
325417 def _video_over_fg_image () -> Iterator [np .ndarray ]:
326418 for top_frame , m in zip (top , mask_iter ):
@@ -335,6 +427,7 @@ def _video_over_fg_image() -> Iterator[np.ndarray]:
335427 iter (top ._video ) # type: ignore[union-attr]
336428 top ._frame_idx = 0 # type: ignore[union-attr]
337429 total = top .frame_count
430+ mask_iter = _resolve_mask (total )
338431
339432 def _fg_video_over_fg_image () -> Iterator [np .ndarray ]:
340433 for top_frame , m in zip (top , mask_iter ):
@@ -353,7 +446,7 @@ def _fg_video_over_fg_image() -> Iterator[np.ndarray]:
353446def composite_color (
354447 top : "Image | Video | FrameGenerator" ,
355448 bottom : "Image | Video | FrameGenerator" ,
356- mask : np .ndarray | None = None ,
449+ mask : " np.ndarray | Image | Video | FrameGenerator | MaskGenerator | None" = None ,
357450 tile_every_x : int | None = 8 ,
358451 tile_every_y : int | None = 8 ,
359452 repeat_every_x : int | None = 1 ,
@@ -397,21 +490,26 @@ def composite_color(
397490 :class:`~dithertools.image.Image`,
398491 :class:`~dithertools.video.Video`, or
399492 :class:`~dithertools.generators.FrameGenerator`.
400- mask: A ``(height, width, 3)`` or ``(height, width)`` ``uint8`` array,
401- or a :class:`~dithertools.generators.MaskGenerator`, against which
402- the top channels are compared. A three-channel array or
403- ``color=True`` generator applies independent per-channel thresholds;
404- a grayscale array or ``color=False`` generator applies the same
405- threshold to all three channels. Static arrays such as those
406- returned by :func:`~dithertools.matrix.random_matrix_color` are
407- reused for every frame. A :class:`~dithertools.generators.MaskGenerator`
408- yields a new mask per frame. If ``None``, a fresh random color mask
409- is generated at the bottom layer's dimensions. If a static array
410- does not match the bottom layer dimensions it is resized with
411- nearest-neighbour interpolation. A generator with no ``initial``
412- is lazily initialized using
413- :func:`~dithertools.matrix.random_matrix_color` (for ``color=True``)
414- or :func:`~dithertools.matrix.random_matrix` (for ``color=False``).
493+ mask: Per-channel mask source. Accepted types:
494+
495+ - ``None`` — a random color mask is generated at the bottom layer's
496+ dimensions.
497+ - ``np.ndarray`` ``(H, W)`` or ``(H, W, 3)`` ``uint8`` — static
498+ mask, reused every frame; resized if dimensions differ. A
499+ non-3-channel 3-D array raises ``ValueError``.
500+ - :class:`~dithertools.image.Image` — used as-is (``(H, W)``
501+ grayscale or ``(H, W, 3)`` color); resized to bottom dimensions.
502+ A non-3-channel color image raises ``ValueError``.
503+ - :class:`~dithertools.video.Video` or video-backed
504+ :class:`~dithertools.generators.FrameGenerator` — frames are used
505+ one-per-output-frame; resampled to match output frame count and
506+ resized to bottom dimensions.
507+ - Image-backed :class:`~dithertools.generators.FrameGenerator` —
508+ infinite; ``fn`` is called each output frame; frames resized.
509+ - :class:`~dithertools.generators.MaskGenerator` — yields a new
510+ mask per frame; ``color=True`` applies per-channel thresholds,
511+ ``color=False`` broadcasts the same threshold to all channels.
512+ A generator with no ``initial`` is lazily initialized.
415513 tile_every_x: Tile width passed to
416514 :func:`~dithertools.matrix.random_matrix_color` when generating an
417515 auto mask (i.e. when ``mask`` is ``None``). The mask pattern repeats
@@ -468,7 +566,7 @@ def composite_color(
468566 repeat_every_x = repeat_every_x ,
469567 repeat_every_y = repeat_every_y ,
470568 )
471- mask_iter : Iterator [np .ndarray ] = itertools .repeat (_arr )
569+ mask_iter : Iterator [np .ndarray ] | None = itertools .repeat (_arr )
472570 elif isinstance (mask , MaskGenerator ):
473571 if not mask .initialized :
474572 if mask .color :
@@ -491,7 +589,7 @@ def composite_color(
491589 )
492590 mask ._initialize (_arr )
493591 mask_iter = mask
494- else :
592+ elif isinstance ( mask , np . ndarray ) :
495593 if mask .ndim == 3 and mask .shape [2 ] != 3 :
496594 raise ValueError (
497595 f"composite_color requires a (H, W) or (H, W, 3) mask, "
@@ -505,6 +603,31 @@ def composite_color(
505603 if mask .shape [:2 ] != (h , w ):
506604 mask = np .array (_PILImage .fromarray (mask ).resize ((w , h ), _PILImage .NEAREST ))
507605 mask_iter = itertools .repeat (mask )
606+ elif isinstance (mask , Image ):
607+ _m = mask .resize (w , h ).data
608+ if _m .ndim == 3 and _m .shape [2 ] != 3 :
609+ raise ValueError (
610+ f"composite_color requires a (H, W) or (H, W, 3) mask, "
611+ f"got Image with shape { _m .shape } "
612+ )
613+ if _m .ndim not in (2 , 3 ):
614+ raise ValueError (
615+ f"composite_color requires a (H, W) or (H, W, 3) mask, "
616+ f"got Image with shape { _m .shape } "
617+ )
618+ mask_iter = itertools .repeat (_m )
619+ elif isinstance (mask , (Video , FrameGenerator )):
620+ mask_iter = None # resolved per-branch once frame count is known
621+ else :
622+ raise TypeError (
623+ f"mask must be a numpy array, Image, Video, FrameGenerator, "
624+ f"MaskGenerator, or None; got { type (mask ).__name__ !r} "
625+ )
626+
627+ def _resolve_mask (fc : int | None = None ) -> Iterator [np .ndarray ]:
628+ if mask_iter is not None :
629+ return mask_iter # type: ignore[return-value]
630+ return _build_mask_iter (mask , w , h , fc , to_gray = False )
508631
509632 def _is_fg_video (x : object ) -> bool :
510633 return isinstance (x , FrameGenerator ) and x ._source_type == "video"
@@ -515,13 +638,14 @@ def _is_fg_image(x: object) -> bool:
515638 # Image × Image → Image
516639 if isinstance (bottom , Image ) and isinstance (top , Image ):
517640 return Image .from_array (
518- _blend_color (top .resize (w , h ).data , bottom .data , next (mask_iter ))
641+ _blend_color (top .resize (w , h ).data , bottom .data , next (_resolve_mask () ))
519642 )
520643
521644 # --- Bottom is finite (Video or fg_video): bottom drives frame count ---
522645 if isinstance (bottom , Video ) or _is_fg_video (bottom ):
523646 fc = bottom .frame_count
524647 fps = bottom .fps
648+ mask_iter = _resolve_mask (fc )
525649
526650 if isinstance (top , Image ):
527651 top_frame = top .resize (w , h ).data
@@ -575,6 +699,7 @@ def _fg_video_over_video() -> Iterator[np.ndarray]:
575699 if isinstance (top , Video ):
576700 top .resize (w , h )
577701 total = top .frame_count
702+ mask_iter = _resolve_mask (total )
578703
579704 def _video_over_image () -> Iterator [np .ndarray ]:
580705 for top_frame , m in zip (top , mask_iter ):
@@ -589,6 +714,7 @@ def _video_over_image() -> Iterator[np.ndarray]:
589714 iter (top ._video ) # type: ignore[union-attr]
590715 top ._frame_idx = 0 # type: ignore[union-attr]
591716 total = top .frame_count
717+ mask_iter = _resolve_mask (total )
592718
593719 def _fg_video_over_image () -> Iterator [np .ndarray ]:
594720 for top_frame , m in zip (top , mask_iter ):
@@ -603,6 +729,7 @@ def _fg_video_over_image() -> Iterator[np.ndarray]:
603729 if isinstance (top , Video ):
604730 top .resize (w , h )
605731 total = top .frame_count
732+ mask_iter = _resolve_mask (total )
606733
607734 def _video_over_fg_image () -> Iterator [np .ndarray ]:
608735 for top_frame , m in zip (top , mask_iter ):
@@ -617,6 +744,7 @@ def _video_over_fg_image() -> Iterator[np.ndarray]:
617744 iter (top ._video ) # type: ignore[union-attr]
618745 top ._frame_idx = 0 # type: ignore[union-attr]
619746 total = top .frame_count
747+ mask_iter = _resolve_mask (total )
620748
621749 def _fg_video_over_fg_image () -> Iterator [np .ndarray ]:
622750 for top_frame , m in zip (top , mask_iter ):
0 commit comments