Skip to content

Commit 2c051a7

Browse files
committed
changed composite functions to allow video and image mask values
1 parent 3b6ba63 commit 2c051a7

1 file changed

Lines changed: 163 additions & 35 deletions

File tree

dithertools/dither.py

Lines changed: 163 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
88151
def 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]:
353446
def 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

Comments
 (0)