Reduce image memory allocation with ring buffer, LUTs, and pool reuse

Replace per-frame image.copy() in ProcessedLiveStream with a 3-slot
ring buffer that reuses pre-allocated arrays. Use 256-byte LUTs for
brightness and gamma filters instead of full float32 conversion. Add
reusable float32 buffer to saturation filter. Use pool scratch buffer
for flip filter. Remove redundant .copy() calls from WGC capture engine.
Release intermediate filter outputs back to ImagePool.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-13 15:33:46 +03:00
parent 3100b0d979
commit 91e5384422
3 changed files with 66 additions and 25 deletions

View File

@@ -59,11 +59,12 @@ class WGCCaptureStream(CaptureStream):
height = frame.height height = frame.height
# WGC provides BGRA format, convert to RGB # WGC provides BGRA format, convert to RGB
# Fancy indexing creates a new contiguous array — no .copy() needed
frame_array = frame_buffer.reshape((height, width, 4)) frame_array = frame_buffer.reshape((height, width, 4))
frame_rgb = frame_array[:, :, [2, 1, 0]] frame_rgb = frame_array[:, :, [2, 1, 0]]
with self._frame_lock: with self._frame_lock:
self._latest_frame = frame_rgb.copy() self._latest_frame = frame_rgb
self._frame_event.set() self._frame_event.set()
except Exception as e: except Exception as e:
logger.error(f"Error processing WGC frame: {e}", exc_info=True) logger.error(f"Error processing WGC frame: {e}", exc_info=True)
@@ -141,7 +142,7 @@ class WGCCaptureStream(CaptureStream):
raise RuntimeError( raise RuntimeError(
f"No frame available yet for display {self.display_index}." f"No frame available yet for display {self.display_index}."
) )
frame = self._latest_frame.copy() frame = self._latest_frame
logger.debug( logger.debug(
f"WGC captured display {self.display_index}: " f"WGC captured display {self.display_index}: "

View File

@@ -16,6 +16,12 @@ class BrightnessFilter(PostprocessingFilter):
filter_id = "brightness" filter_id = "brightness"
filter_name = "Brightness" filter_name = "Brightness"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
value = self.options["value"]
lut = np.clip(np.arange(256, dtype=np.float32) * value, 0, 255)
self._lut = lut.astype(np.uint8)
@classmethod @classmethod
def get_options_schema(cls) -> List[FilterOptionDef]: def get_options_schema(cls) -> List[FilterOptionDef]:
return [ return [
@@ -31,14 +37,9 @@ class BrightnessFilter(PostprocessingFilter):
] ]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
value = self.options["value"] if self.options["value"] == 1.0:
if value == 1.0:
return None return None
# In-place float operation image[:] = self._lut[image]
arr = image.astype(np.float32)
arr *= value
np.clip(arr, 0, 255, out=arr)
np.copyto(image, arr.astype(np.uint8))
return None return None
@@ -49,6 +50,10 @@ class SaturationFilter(PostprocessingFilter):
filter_id = "saturation" filter_id = "saturation"
filter_name = "Saturation" filter_name = "Saturation"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
self._float_buf: Optional[np.ndarray] = None
@classmethod @classmethod
def get_options_schema(cls) -> List[FilterOptionDef]: def get_options_schema(cls) -> List[FilterOptionDef]:
return [ return [
@@ -67,11 +72,17 @@ class SaturationFilter(PostprocessingFilter):
value = self.options["value"] value = self.options["value"]
if value == 1.0: if value == 1.0:
return None return None
arr = image.astype(np.float32) / 255.0 h, w, c = image.shape
if self._float_buf is None or self._float_buf.shape != (h, w, c):
self._float_buf = np.empty((h, w, c), dtype=np.float32)
arr = self._float_buf
np.copyto(arr, image)
arr *= (1.0 / 255.0)
lum = np.dot(arr[..., :3], [0.299, 0.587, 0.114])[..., np.newaxis] lum = np.dot(arr[..., :3], [0.299, 0.587, 0.114])[..., np.newaxis]
arr[..., :3] = lum + (arr[..., :3] - lum) * value arr[..., :3] = lum + (arr[..., :3] - lum) * value
np.clip(arr * 255.0, 0, 255, out=arr) np.clip(arr, 0, 1.0, out=arr)
np.copyto(image, arr.astype(np.uint8)) arr *= 255.0
np.copyto(image, arr, casting='unsafe')
return None return None
@@ -82,6 +93,13 @@ class GammaFilter(PostprocessingFilter):
filter_id = "gamma" filter_id = "gamma"
filter_name = "Gamma" filter_name = "Gamma"
def __init__(self, options: Dict[str, Any]):
super().__init__(options)
value = self.options["value"]
lut = np.arange(256, dtype=np.float32) / 255.0
np.power(lut, 1.0 / value, out=lut)
self._lut = np.clip(lut * 255.0, 0, 255).astype(np.uint8)
@classmethod @classmethod
def get_options_schema(cls) -> List[FilterOptionDef]: def get_options_schema(cls) -> List[FilterOptionDef]:
return [ return [
@@ -97,13 +115,9 @@ class GammaFilter(PostprocessingFilter):
] ]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
value = self.options["value"] if self.options["value"] == 1.0:
if value == 1.0:
return None return None
arr = image.astype(np.float32) / 255.0 image[:] = self._lut[image]
np.power(arr, 1.0 / value, out=arr)
np.clip(arr * 255.0, 0, 255, out=arr)
np.copyto(image, arr.astype(np.uint8))
return None return None
@@ -315,8 +329,14 @@ class FlipFilter(PostprocessingFilter):
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
h = self.options.get("horizontal", False) h = self.options.get("horizontal", False)
v = self.options.get("vertical", False) v = self.options.get("vertical", False)
if h: if not h and not v:
image[:] = np.fliplr(image)
if v:
image[:] = np.flipud(image)
return None return None
height, width, c = image.shape
result = image_pool.acquire(height, width, c)
if h and v:
np.copyto(result, image[::-1, ::-1])
elif h:
np.copyto(result, image[:, ::-1])
else:
np.copyto(result, image[::-1])
return result

View File

@@ -203,6 +203,12 @@ class ProcessedLiveStream(LiveStream):
def _process_loop(self) -> None: def _process_loop(self) -> None:
"""Background thread: poll source, apply filters, cache result.""" """Background thread: poll source, apply filters, cache result."""
cached_source_frame: Optional[ScreenCapture] = None cached_source_frame: Optional[ScreenCapture] = None
# Ring buffer: 3 slots guarantees consumer finished with oldest buffer.
# At most 2 frames are in flight (one in _latest_frame, one being
# processed by a consumer), so the 3rd slot is always safe to reuse.
_ring: List[Optional[np.ndarray]] = [None, None, None]
_ring_idx = 0
while self._running: while self._running:
source_frame = self._source.get_latest_frame() source_frame = self._source.get_latest_frame()
if source_frame is None: if source_frame is None:
@@ -216,11 +222,25 @@ class ProcessedLiveStream(LiveStream):
cached_source_frame = source_frame cached_source_frame = source_frame
# Apply filters to a copy of the source image # Reuse ring buffer slot instead of allocating a new copy each frame
image = source_frame.image.copy() src = source_frame.image
h, w, c = src.shape
buf = _ring[_ring_idx]
if buf is None or buf.shape != (h, w, c):
buf = np.empty((h, w, c), dtype=np.uint8)
_ring[_ring_idx] = buf
_ring_idx = (_ring_idx + 1) % 3
np.copyto(buf, src)
image = buf
for f in self._filters: for f in self._filters:
result = f.process_image(image, self._image_pool) result = f.process_image(image, self._image_pool)
if result is not None: if result is not None:
# Release intermediate filter output back to pool
# (don't release the ring buffer itself)
if image is not buf:
self._image_pool.release(image)
image = result image = result
processed = ScreenCapture( processed = ScreenCapture(