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:
@@ -59,11 +59,12 @@ class WGCCaptureStream(CaptureStream):
|
||||
height = frame.height
|
||||
|
||||
# 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_rgb = frame_array[:, :, [2, 1, 0]]
|
||||
|
||||
with self._frame_lock:
|
||||
self._latest_frame = frame_rgb.copy()
|
||||
self._latest_frame = frame_rgb
|
||||
self._frame_event.set()
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing WGC frame: {e}", exc_info=True)
|
||||
@@ -141,7 +142,7 @@ class WGCCaptureStream(CaptureStream):
|
||||
raise RuntimeError(
|
||||
f"No frame available yet for display {self.display_index}."
|
||||
)
|
||||
frame = self._latest_frame.copy()
|
||||
frame = self._latest_frame
|
||||
|
||||
logger.debug(
|
||||
f"WGC captured display {self.display_index}: "
|
||||
|
||||
@@ -16,6 +16,12 @@ class BrightnessFilter(PostprocessingFilter):
|
||||
filter_id = "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
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
@@ -31,14 +37,9 @@ class BrightnessFilter(PostprocessingFilter):
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
value = self.options["value"]
|
||||
if value == 1.0:
|
||||
if self.options["value"] == 1.0:
|
||||
return None
|
||||
# In-place float operation
|
||||
arr = image.astype(np.float32)
|
||||
arr *= value
|
||||
np.clip(arr, 0, 255, out=arr)
|
||||
np.copyto(image, arr.astype(np.uint8))
|
||||
image[:] = self._lut[image]
|
||||
return None
|
||||
|
||||
|
||||
@@ -49,6 +50,10 @@ class SaturationFilter(PostprocessingFilter):
|
||||
filter_id = "saturation"
|
||||
filter_name = "Saturation"
|
||||
|
||||
def __init__(self, options: Dict[str, Any]):
|
||||
super().__init__(options)
|
||||
self._float_buf: Optional[np.ndarray] = None
|
||||
|
||||
@classmethod
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
@@ -67,11 +72,17 @@ class SaturationFilter(PostprocessingFilter):
|
||||
value = self.options["value"]
|
||||
if value == 1.0:
|
||||
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]
|
||||
arr[..., :3] = lum + (arr[..., :3] - lum) * value
|
||||
np.clip(arr * 255.0, 0, 255, out=arr)
|
||||
np.copyto(image, arr.astype(np.uint8))
|
||||
np.clip(arr, 0, 1.0, out=arr)
|
||||
arr *= 255.0
|
||||
np.copyto(image, arr, casting='unsafe')
|
||||
return None
|
||||
|
||||
|
||||
@@ -82,6 +93,13 @@ class GammaFilter(PostprocessingFilter):
|
||||
filter_id = "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
|
||||
def get_options_schema(cls) -> List[FilterOptionDef]:
|
||||
return [
|
||||
@@ -97,13 +115,9 @@ class GammaFilter(PostprocessingFilter):
|
||||
]
|
||||
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
value = self.options["value"]
|
||||
if value == 1.0:
|
||||
if self.options["value"] == 1.0:
|
||||
return None
|
||||
arr = image.astype(np.float32) / 255.0
|
||||
np.power(arr, 1.0 / value, out=arr)
|
||||
np.clip(arr * 255.0, 0, 255, out=arr)
|
||||
np.copyto(image, arr.astype(np.uint8))
|
||||
image[:] = self._lut[image]
|
||||
return None
|
||||
|
||||
|
||||
@@ -315,8 +329,14 @@ class FlipFilter(PostprocessingFilter):
|
||||
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
|
||||
h = self.options.get("horizontal", False)
|
||||
v = self.options.get("vertical", False)
|
||||
if h:
|
||||
image[:] = np.fliplr(image)
|
||||
if v:
|
||||
image[:] = np.flipud(image)
|
||||
return None
|
||||
if not h and not v:
|
||||
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
|
||||
|
||||
@@ -203,6 +203,12 @@ class ProcessedLiveStream(LiveStream):
|
||||
def _process_loop(self) -> None:
|
||||
"""Background thread: poll source, apply filters, cache result."""
|
||||
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:
|
||||
source_frame = self._source.get_latest_frame()
|
||||
if source_frame is None:
|
||||
@@ -216,11 +222,25 @@ class ProcessedLiveStream(LiveStream):
|
||||
|
||||
cached_source_frame = source_frame
|
||||
|
||||
# Apply filters to a copy of the source image
|
||||
image = source_frame.image.copy()
|
||||
# Reuse ring buffer slot instead of allocating a new copy each frame
|
||||
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:
|
||||
result = f.process_image(image, self._image_pool)
|
||||
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
|
||||
|
||||
processed = ScreenCapture(
|
||||
|
||||
Reference in New Issue
Block a user