Skip to content

Commit 8e49026

Browse files
committed
reconfigured sphinx to use markdown, removed threshold from composite and composite_color
1 parent 31446c7 commit 8e49026

11 files changed

Lines changed: 133 additions & 122 deletions

File tree

.github/workflows/docs.yml

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ on:
66

77
permissions:
88
contents: read
9-
pages: write
10-
id-token: write
119

1210
jobs:
1311
build:
@@ -18,17 +16,4 @@ jobs:
1816
with:
1917
python-version: "3.12"
2018
- run: pip install -e ".[docs]"
21-
- run: sphinx-build -b html docs/ public/
22-
- uses: actions/upload-pages-artifact@v3
23-
with:
24-
path: public/
25-
26-
deploy:
27-
needs: build
28-
runs-on: ubuntu-latest
29-
environment:
30-
name: github-pages
31-
url: ${{ steps.deployment.outputs.page_url }}
32-
steps:
33-
- uses: actions/deploy-pages@v4
34-
id: deployment
19+
- run: sphinx-build -b markdown docs/ public/

dithertools/dither.py

Lines changed: 62 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
"""Layer compositing using dithered threshold masks.
1+
"""Layer compositing using dithered masks compared against layer pixel values.
22
33
The :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
77
When both layers are ``Image`` instances, the result is a new ``Image``.
88
When 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

4959
def 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

183194
def 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
)

dithertools/image.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,8 @@ def from_array(cls, data: np.ndarray) -> "Image":
7878
return img
7979

8080
def resize(self, width: int, height: int) -> "Image":
81-
"""Return a new ``Image`` resized to ``(width, height)`` using Lanczos resampling.
81+
"""Return a new ``Image`` resized to ``(width, height)``
82+
using Lanczos resampling.
8283
8384
The original instance is not modified.
8485
@@ -95,7 +96,8 @@ def resize(self, width: int, height: int) -> "Image":
9596
return Image.from_array(resized)
9697

9798
def resize_nearest(self, width: int, height: int) -> "Image":
98-
"""Return a new ``Image`` resized to ``(width, height)`` using nearest-neighbour resampling.
99+
"""Return a new ``Image`` resized to ``(width, height)``
100+
using nearest-neighbour resampling.
99101
100102
Nearest-neighbour preserves hard pixel edges, making it suitable for
101103
pixel art or any case where interpolation between values is undesirable.
@@ -125,4 +127,7 @@ def save(self, filepath: str | Path) -> None:
125127
_PILImage.fromarray(self.data).save(Path(filepath))
126128

127129
def __repr__(self) -> str:
128-
return f"Image(path={self.filepath.name!r}, width={self.width}, height={self.height})"
130+
return (
131+
f"Image(path={self.filepath.name!r},"
132+
f" width={self.width}, height={self.height})"
133+
)

dithertools/io.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88

99

1010
def load(filepath: str | Path) -> Image | Video:
11-
"""Load a file as an :class:`~dithertools.image.Image` or :class:`~dithertools.video.Video`.
11+
"""Load a file as an :class:`~dithertools.image.Image`
12+
or :class:`~dithertools.video.Video`.
1213
1314
The type is determined by the MIME type inferred from the file extension.
1415
Any path whose MIME type starts with ``video/`` is opened as a ``Video``;

dithertools/matrix.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
"""Functions for generating 2D NumPy arrays used as dithering masks throughout the library.
1+
"""Functions for generating 2D NumPy arrays used as dithering masks
2+
throughout the library.
23
34
All matrices follow the NumPy image convention: shape is (height, width) for grayscale
45
and (height, width, 3) for color.

docs/conf.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55

66
extensions = [
77
"autoapi.extension",
8+
"sphinx-markdown-builder",
89
]
910

1011
autoapi_dirs = ["../dithertools"]

main.py

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55

66
def _default_output(layers) -> str:
7-
return "output.mp4" if any(isinstance(l, Video) for l in layers) else "output.png"
7+
return "output.mp4" if any(isinstance(v, Video) for v in layers) else "output.png"
88

99

1010
@click.command()
@@ -16,11 +16,8 @@ def _default_output(layers) -> str:
1616
help="Output file path (default: output.png or output.mp4).",
1717
)
1818
@click.option("-c", "--color", is_flag=True, help="Use per-channel color compositing.")
19-
@click.option(
20-
"--threshold", default=127, show_default=True, help="Composite threshold (0–255)."
21-
)
2219
def main(
23-
files: tuple[str, ...], output: str | None, color: bool, threshold: int
20+
files: tuple[str, ...], output: str | None, color: bool
2421
) -> None:
2522
"""Composite a list of image/video files dithered on top of each other.
2623
@@ -42,7 +39,7 @@ def main(
4239

4340
result = layers[0]
4441
for layer in layers[1:]:
45-
result = fn(top=layer, bottom=result, threshold=threshold) # type: ignore[arg-type]
42+
result = fn(top=layer, bottom=result) # type: ignore[arg-type]
4643

4744
result.save(output)
4845
click.echo(f"Saved to {output}")

pyproject.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,11 @@ dependencies = [
2121
dev = [
2222
"pytest>=8.0",
2323
"ruff>=0.4",
24-
"black>=24.0",
2524
]
2625
docs = [
2726
"sphinx>=7.0",
2827
"sphinx-autoapi>=3.0",
29-
"sphinx-rtd-theme>=2.0",
28+
"sphinx-markdown-builder==0.6.10",
3029
]
3130

3231
[project.scripts]

0 commit comments

Comments
 (0)