Skip to content

Commit ffe3157

Browse files
committed
added preview to Image
1 parent 2522b10 commit ffe3157

7 files changed

Lines changed: 745 additions & 40 deletions

File tree

dithertools/generators.py

Lines changed: 97 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -227,17 +227,23 @@ def __init__(
227227
f"source must be Image, Video, or a file path,"
228228
f" got {type(source).__name__!r}"
229229
)
230+
self._crop_position: tuple[int, int] | None = None
231+
self._crop_size: tuple[int, int] | None = None
230232

231233
@property
232234
def width(self) -> int:
233-
"""Frame width in pixels."""
235+
"""Frame width in pixels (after any configured crop)."""
236+
if self._crop_size is not None:
237+
return self._crop_size[0]
234238
if self._source_type == "video":
235239
return self._video.width # type: ignore[union-attr]
236240
return self._buffer.shape[1] # type: ignore[index]
237241

238242
@property
239243
def height(self) -> int:
240-
"""Frame height in pixels."""
244+
"""Frame height in pixels (after any configured crop)."""
245+
if self._crop_size is not None:
246+
return self._crop_size[1]
241247
if self._source_type == "video":
242248
return self._video.height # type: ignore[union-attr]
243249
return self._buffer.shape[0] # type: ignore[index]
@@ -265,6 +271,47 @@ def reset(self) -> None:
265271
elif self._source_type == "video" and self._video is not None:
266272
iter(self._video)
267273

274+
def crop(
275+
self, position: tuple[int, int], size: tuple[int, int]
276+
) -> "FrameGenerator":
277+
"""Configure each yielded frame to be cropped to the region defined by
278+
*position* and *size*.
279+
280+
The user-supplied ``fn`` still receives the full-size frame buffer; the
281+
crop is applied to the output just before the frame is returned.
282+
Validation is performed against the source frame dimensions.
283+
284+
Args:
285+
position: ``(x, y)`` pixel coordinates of the crop region's top-left corner.
286+
size: ``(width, height)`` of the crop region in pixels.
287+
288+
Returns:
289+
``self``, to allow method chaining.
290+
291+
Raises:
292+
ValueError: If the crop region extends outside the source frame boundaries.
293+
"""
294+
x, y = position
295+
w, h = size
296+
src_w = (
297+
self._buffer.shape[1] # type: ignore[union-attr]
298+
if self._source_type == "image"
299+
else self._video.width # type: ignore[union-attr]
300+
)
301+
src_h = (
302+
self._buffer.shape[0] # type: ignore[union-attr]
303+
if self._source_type == "image"
304+
else self._video.height # type: ignore[union-attr]
305+
)
306+
if x < 0 or y < 0 or x + w > src_w or y + h > src_h:
307+
raise ValueError(
308+
f"crop region ({x}, {y}, {w}×{h}) out of bounds for "
309+
f"{src_w}×{src_h} frame"
310+
)
311+
self._crop_position = (x, y)
312+
self._crop_size = (w, h)
313+
return self
314+
268315
def __iter__(self) -> "FrameGenerator":
269316
return self
270317

@@ -273,15 +320,21 @@ def __next__(self) -> np.ndarray:
273320
if self._frame_idx > 0:
274321
self._fn(self._buffer, self._frame_idx)
275322
self._frame_idx += 1
276-
return self._buffer
277-
frame = next(self._video)
278-
if self._buffer is None:
279-
self._buffer = frame.copy()
323+
frame = self._buffer
280324
else:
281-
self._buffer[:] = frame
282-
self._fn(self._buffer, self._frame_idx)
283-
self._frame_idx += 1
284-
return self._buffer
325+
raw = next(self._video)
326+
if self._buffer is None:
327+
self._buffer = raw.copy()
328+
else:
329+
self._buffer[:] = raw
330+
self._fn(self._buffer, self._frame_idx)
331+
self._frame_idx += 1
332+
frame = self._buffer
333+
if self._crop_position is not None:
334+
cx, cy = self._crop_position
335+
cw, ch = self._crop_size # type: ignore[misc]
336+
return frame[cy : cy + ch, cx : cx + cw]
337+
return frame
285338

286339
def to_video(
287340
self,
@@ -467,25 +520,30 @@ def __getitem__(self, key: "int | slice") -> "np.ndarray | FrameGenerator":
467520
buf = self._original.copy() # type: ignore[union-attr]
468521
for idx in range(1, key + 1):
469522
self._fn(buf, idx)
470-
return buf.copy()
471-
472-
# video-backed: use a temporary sub-video so self._video is untouched
473-
total = self._video.frame_count # type: ignore[union-attr]
474-
if key < 0:
475-
key += total
476-
if not 0 <= key < total:
477-
raise IndexError(
478-
f"frame index {key} out of range for {total}-frame generator"
479-
)
480-
sub = self._video[0 : key + 1] # type: ignore[union-attr]
481-
buf = None
482-
for idx, raw in enumerate(sub):
483-
if buf is None:
484-
buf = raw.copy()
485-
else:
486-
buf[:] = raw
487-
self._fn(buf, idx)
488-
return buf.copy() # type: ignore[union-attr]
523+
frame = buf
524+
else:
525+
# video-backed: use a temporary sub-video so self._video is untouched
526+
total = self._video.frame_count # type: ignore[union-attr]
527+
if key < 0:
528+
key += total
529+
if not 0 <= key < total:
530+
raise IndexError(
531+
f"frame index {key} out of range for {total}-frame generator"
532+
)
533+
sub = self._video[0 : key + 1] # type: ignore[union-attr]
534+
buf = None
535+
for idx, raw in enumerate(sub):
536+
if buf is None:
537+
buf = raw.copy()
538+
else:
539+
buf[:] = raw
540+
self._fn(buf, idx)
541+
frame = buf # type: ignore[assignment]
542+
if self._crop_position is not None:
543+
cx, cy = self._crop_position
544+
cw, ch = self._crop_size # type: ignore[misc]
545+
return frame[cy : cy + ch, cx : cx + cw].copy() # type: ignore[index]
546+
return frame.copy() # type: ignore[union-attr]
489547

490548
if isinstance(key, slice):
491549
if self._source_type == "image":
@@ -499,13 +557,16 @@ def __getitem__(self, key: "int | slice") -> "np.ndarray | FrameGenerator":
499557
raise IndexError("slice selects no frames")
500558
fps = _DEFAULT_FPS
501559
src_frames = [self._original.copy() for _ in indices] # type: ignore[union-attr]
502-
return FrameGenerator(Video.from_frames(src_frames, fps), self._fn)
503-
504-
# video-backed: slice the raw source and let fn run fresh
505-
raw_sub = self._video[key] # type: ignore[union-attr]
506-
if not isinstance(raw_sub, Video):
507-
raise IndexError("slice selects no frames")
508-
return FrameGenerator(raw_sub, self._fn)
560+
result = FrameGenerator(Video.from_frames(src_frames, fps), self._fn)
561+
else:
562+
# video-backed: slice the raw source and let fn run fresh
563+
raw_sub = self._video[key] # type: ignore[union-attr]
564+
if not isinstance(raw_sub, Video):
565+
raise IndexError("slice selects no frames")
566+
result = FrameGenerator(raw_sub, self._fn)
567+
result._crop_position = self._crop_position
568+
result._crop_size = self._crop_size
569+
return result
509570

510571
raise TypeError(
511572
f"indices must be integers or slices, not {type(key).__name__!r}"

dithertools/image.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,21 @@ def from_array(cls, data: np.ndarray) -> "Image":
7777
img.width = img.data.shape[1]
7878
return img
7979

80+
@classmethod
81+
def solid(cls, width: int, height: int, color: tuple[int, int, int]) -> "Image":
82+
"""Create an ``Image`` filled entirely with a single color.
83+
84+
Args:
85+
width: Image width in pixels.
86+
height: Image height in pixels.
87+
color: Fill color as an ``(R, G, B)`` tuple (0–255 per channel).
88+
89+
Returns:
90+
A new ``Image`` with every pixel set to *color*.
91+
"""
92+
data = np.full((height, width, 3), color, dtype=np.uint8)
93+
return cls.from_array(data)
94+
8095
def resize(self, width: int, height: int) -> "Image":
8196
"""Return a new ``Image`` resized to ``(width, height)``
8297
using Lanczos resampling.
@@ -115,6 +130,105 @@ def resize_nearest(self, width: int, height: int) -> "Image":
115130
)
116131
return Image.from_array(resized)
117132

133+
def crop(self, position: tuple[int, int], size: tuple[int, int]) -> "Image":
134+
"""Return a new ``Image`` cropped to the region defined by *position* and
135+
*size*.
136+
137+
The original instance is not modified.
138+
139+
Args:
140+
position: ``(x, y)`` pixel coordinates of the crop region's top-left corner.
141+
size: ``(width, height)`` of the crop region in pixels.
142+
143+
Returns:
144+
A new ``Image`` containing only the cropped pixel data.
145+
146+
Raises:
147+
ValueError: If the crop region extends outside the image boundaries.
148+
"""
149+
x, y = position
150+
w, h = size
151+
if x < 0 or y < 0 or x + w > self.width or y + h > self.height:
152+
raise ValueError(
153+
f"crop region ({x}, {y}, {w}×{h}) out of bounds for "
154+
f"{self.width}×{self.height} image"
155+
)
156+
return Image.from_array(self.data[y : y + h, x : x + w])
157+
158+
def overlay(
159+
self,
160+
other: "Image",
161+
pos: tuple[int, int],
162+
size: tuple[int, int],
163+
blend: str = "replace",
164+
) -> "Image":
165+
"""Return a new ``Image`` with *other* composited at *pos*.
166+
167+
*other* is resized to *size* (Lanczos) before placement. Portions
168+
that fall outside the base image's bounds are clipped silently.
169+
170+
Args:
171+
other: The image to overlay.
172+
pos: ``(x, y)`` pixel coordinates of the overlay's top-left corner.
173+
size: ``(width, height)`` — *other* is resized to these dimensions
174+
before placement.
175+
blend: Compositing mode. ``"replace"`` (default) writes overlay
176+
pixels directly over the base. Other modes: ``"multiply"``,
177+
``"screen"``, ``"add"``, ``"difference"``.
178+
179+
Returns:
180+
A new ``Image`` with the overlay applied. The original is not
181+
modified.
182+
183+
Raises:
184+
ValueError: If *blend* is not a recognised mode.
185+
"""
186+
_MODES = {"replace", "multiply", "screen", "add", "difference"}
187+
if blend not in _MODES:
188+
raise ValueError(f"blend must be one of {sorted(_MODES)!r}, got {blend!r}")
189+
190+
x, y = pos
191+
ow, oh = size
192+
193+
ov_data = other.resize(ow, oh).data
194+
195+
# Normalise overlay channels to match base
196+
base_mode = _PILImage.fromarray(self.data).mode
197+
ov_pil = _PILImage.fromarray(ov_data)
198+
if ov_pil.mode != base_mode:
199+
ov_data = np.array(ov_pil.convert(base_mode))
200+
201+
dst_x0 = max(x, 0)
202+
dst_y0 = max(y, 0)
203+
dst_x1 = min(x + ow, self.width)
204+
dst_y1 = min(y + oh, self.height)
205+
206+
out = self.data.copy()
207+
if dst_x0 >= dst_x1 or dst_y0 >= dst_y1:
208+
return Image.from_array(out)
209+
210+
src_x0 = dst_x0 - x
211+
src_y0 = dst_y0 - y
212+
src_x1 = src_x0 + (dst_x1 - dst_x0)
213+
src_y1 = src_y0 + (dst_y1 - dst_y0)
214+
215+
base_reg = out[dst_y0:dst_y1, dst_x0:dst_x1].astype(np.float32)
216+
ov_reg = ov_data[src_y0:src_y1, src_x0:src_x1].astype(np.float32)
217+
218+
if blend == "replace":
219+
blended = ov_reg
220+
elif blend == "multiply":
221+
blended = base_reg * ov_reg / 255.0
222+
elif blend == "screen":
223+
blended = 255.0 - (255.0 - base_reg) * (255.0 - ov_reg) / 255.0
224+
elif blend == "add":
225+
blended = base_reg + ov_reg
226+
else: # difference
227+
blended = np.abs(base_reg - ov_reg)
228+
229+
out[dst_y0:dst_y1, dst_x0:dst_x1] = np.clip(blended, 0, 255).astype(np.uint8)
230+
return Image.from_array(out)
231+
118232
def save(self, filepath: str | Path) -> None:
119233
"""Save pixel data to disk.
120234
@@ -140,6 +254,52 @@ def to_palette(self, colors: int = 8) -> list[tuple[int, int, int]]:
140254
raw = quantized.getpalette()
141255
return [tuple(raw[i * 3 : (i + 1) * 3]) for i in range(colors)]
142256

257+
def preview(self) -> None:
258+
"""Open the image in an external viewer for preview.
259+
260+
``ffplay`` (part of the FFmpeg suite) is tried first because it
261+
blocks until the window is closed. If ``ffplay`` is not found on
262+
``PATH``, the image is opened with the system default viewer and a
263+
:class:`UserWarning` is issued, as that opener returns immediately
264+
rather than waiting for the window to close.
265+
"""
266+
import atexit
267+
import os
268+
import platform
269+
import subprocess
270+
import tempfile
271+
import warnings
272+
273+
fd, name = tempfile.mkstemp(suffix=".png")
274+
os.close(fd)
275+
tmp = Path(name)
276+
atexit.register(lambda: tmp.unlink(missing_ok=True))
277+
278+
self.save(tmp)
279+
280+
try:
281+
subprocess.run(
282+
["ffplay", str(tmp)],
283+
stdout=subprocess.DEVNULL,
284+
stderr=subprocess.DEVNULL,
285+
)
286+
return
287+
except FileNotFoundError:
288+
pass
289+
290+
warnings.warn(
291+
"ffplay not found; opening with system default viewer "
292+
"(the call will return before the window is closed)",
293+
stacklevel=2,
294+
)
295+
system = platform.system()
296+
if system == "Darwin":
297+
subprocess.run(["open", "-W", str(tmp)])
298+
elif system == "Windows":
299+
subprocess.run(["cmd", "/c", "start", "/wait", "", str(tmp)])
300+
else:
301+
subprocess.run(["xdg-open", str(tmp)])
302+
143303
def __mul__(self, n: int | float) -> "object":
144304
"""Return a Video consisting of *n* identical copies of this image.
145305

0 commit comments

Comments
 (0)