@@ -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} "
0 commit comments