From 8adbe4e8ee0458f40b15c657f8638de6ed1cb5fa Mon Sep 17 00:00:00 2001 From: Aldrich_CC <109075336+Chen17-sq@users.noreply.github.com> Date: Mon, 27 Apr 2026 19:59:11 +0800 Subject: [PATCH] fix: close PIL Image handles to prevent file-descriptor / memory leaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six call sites used `Image.open()` without a `with` block. The most impactful is `calculate_phash` in `opencontext/utils/image.py`, which runs on every captured screenshot via `ScreenshotProcessor._is_duplicate`, so each screenshot left an OS file handle (and the lazy-decoded image buffer) attached until the next garbage-collection pass. On a long-lived recording session this trends toward the file-descriptor ulimit and inflates resident memory. The four call sites in `document_converter.py` return the loaded image to their caller, so a plain `with` would close the data prematurely. The fix wraps the open call in `with Image.open(...) as fp:` and rebinds `img = fp.convert("RGB")` — `convert()` produces a new in-memory image (it returns `self.copy()` even when the source mode already matches), so the returned image is independent of the underlying file/BytesIO and the original lazy handle is closed on `with` exit. Behavior is preserved: every site previously ended with an RGB image, and that is still the case (the prior `if mode != "RGB": convert("RGB")` guard was a micro-optimization for the already-RGB case; we accept the extra copy in that branch in exchange for the resource-hygiene fix). The `.format` attribute on the returned image is now consistently `None`, matching the existing behavior on the non-RGB path; nothing downstream reads `.format` on these images. --- .../processor/document_converter.py | 20 ++++++++----------- opencontext/utils/image.py | 13 ++++-------- 2 files changed, 12 insertions(+), 21 deletions(-) diff --git a/opencontext/context_processing/processor/document_converter.py b/opencontext/context_processing/processor/document_converter.py index 22b3109d..f2f29b7f 100644 --- a/opencontext/context_processing/processor/document_converter.py +++ b/opencontext/context_processing/processor/document_converter.py @@ -93,9 +93,8 @@ def _load_image(self, image_path: str) -> List[Image.Image]: """Load single image""" logger.info(f"Loading image: {image_path}") try: - img = Image.open(image_path) - if img.mode != "RGB": - img = img.convert("RGB") + with Image.open(image_path) as fp: + img = fp.convert("RGB") return [img] except Exception as e: logger.exception(f"Error loading image: {e}") @@ -395,9 +394,8 @@ def _extract_paragraph_images(self, paragraph, doc) -> List[Image.Image]: # Convert image data to PIL.Image import io - img = Image.open(io.BytesIO(image_data)) - if img.mode != "RGB": - img = img.convert("RGB") + with Image.open(io.BytesIO(image_data)) as fp: + img = fp.convert("RGB") images.append(img) logger.debug(f"Extracted image from paragraph: {img.size}") @@ -569,9 +567,8 @@ def _extract_markdown_images(self, md_text: str, md_dir: Path) -> tuple: # Handle remote image by downloading it with urllib.request.urlopen(img_path_str, timeout=10) as response: image_data = response.read() - img = Image.open(io.BytesIO(image_data)) - if img.mode != "RGB": - img = img.convert("RGB") + with Image.open(io.BytesIO(image_data)) as fp: + img = fp.convert("RGB") images.append(img) logger.debug( f"Successfully downloaded remote image: {img_path_str[:70]}..." @@ -589,9 +586,8 @@ def _extract_markdown_images(self, md_text: str, md_dir: Path) -> tuple: logger.warning(f"Local image file not found: {img_path}") continue - img = Image.open(img_path) - if img.mode != "RGB": - img = img.convert("RGB") + with Image.open(img_path) as fp: + img = fp.convert("RGB") images.append(img) logger.debug(f"Loaded local image: {img_path}") diff --git a/opencontext/utils/image.py b/opencontext/utils/image.py index 9a2cb0d9..1b20520f 100644 --- a/opencontext/utils/image.py +++ b/opencontext/utils/image.py @@ -21,11 +21,8 @@ def calculate_bytes2phash(image_bytes: bytes) -> Optional[str]: try: import io - from PIL import Image - - image = Image.open(io.BytesIO(image_bytes)) - hash_result = str(imagehash.dhash(image, hash_size=8)) - return hash_result + with Image.open(io.BytesIO(image_bytes)) as image: + return str(imagehash.dhash(image, hash_size=8)) except Exception: return None @@ -35,10 +32,8 @@ def calculate_phash(path: str) -> Optional[str]: Calculate perceptual hash of image file (cached). """ try: - from PIL import Image - - image = Image.open(path) - return str(imagehash.dhash(image, hash_size=8)) + with Image.open(path) as image: + return str(imagehash.dhash(image, hash_size=8)) except Exception: return None