From 38c7355a86ce669e9ea78240ba294eb85f0eff38 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Thu, 1 Oct 2026 23:30:58 -0400 Subject: [PATCH 1/5] perf: Bound extracted image buffer lifetimes --- CHANGELOG.md | 6 + .../benchmark_image_extraction_memory.py | 90 +++++++++++ .../pdf_image/test_extracted_image_memory.py | 148 ++++++++++++++++++ unstructured/__version__.py | 2 +- .../partition/pdf_image/pdf_image_utils.py | 68 ++++---- 5 files changed, 282 insertions(+), 32 deletions(-) create mode 100644 scripts/performance/benchmark_image_extraction_memory.py create mode 100644 test_unstructured/partition/pdf_image/test_extracted_image_memory.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 75720b6c14..2178ede060 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +## 0.27.12 + +### Fixes + +- **Release image extraction buffers after each crop.** Image and PDF figure extraction closes owned decoded pages, crops, and JPEG buffers promptly, and copies image uploads to its existing temporary file in bounded chunks. JPEG payloads, saved files, metadata, and caller-owned streams retain their existing behavior. + ## 0.27.11 ### Fixes diff --git a/scripts/performance/benchmark_image_extraction_memory.py b/scripts/performance/benchmark_image_extraction_memory.py new file mode 100644 index 0000000000..c8f60b82f7 --- /dev/null +++ b/scripts/performance/benchmark_image_extraction_memory.py @@ -0,0 +1,90 @@ +"""Measure actual PIL image extraction; output hashes compare main and candidate. + +Run with PYTHONPATH pointing at the desired library checkout. The deterministic +100-megapixel BMP is written a row at a time, so fixture generation is bounded. +""" + +import argparse +import hashlib +import json +import random +import resource +import struct +import sys +import tempfile +import time +from pathlib import Path + +from unstructured.documents.coordinates import PixelSpace +from unstructured.documents.elements import ElementMetadata, Image +from unstructured.partition.pdf_image.pdf_image_utils import save_elements + + +def main() -> None: + """Extract full-page figures from one high-resolution raster upload.""" + parser = argparse.ArgumentParser() + parser.add_argument("--side", type=int, default=10000) + parser.add_argument("--count", type=int, default=2) + args = parser.parse_args() + side = args.side + stride = (side * 3 + 3) // 4 * 4 + size = stride * side + with tempfile.TemporaryDirectory() as directory: + path = Path(directory) / "scan.bmp" + rng = random.Random(901) + with path.open("wb") as output: + output.write(struct.pack("<2sIHHI", b"BM", 54 + size, 0, 0, 54)) + output.write(struct.pack(" Date: Fri, 2 Oct 2026 00:15:26 -0400 Subject: [PATCH 2/5] fix: Validate image extraction benchmark dimensions --- .../performance/benchmark_image_extraction_memory.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/scripts/performance/benchmark_image_extraction_memory.py b/scripts/performance/benchmark_image_extraction_memory.py index c8f60b82f7..a4ae991cb7 100644 --- a/scripts/performance/benchmark_image_extraction_memory.py +++ b/scripts/performance/benchmark_image_extraction_memory.py @@ -20,11 +20,19 @@ from unstructured.partition.pdf_image.pdf_image_utils import save_elements +def _positive_int(value: str) -> int: + """Accept only positive dimensions and extraction counts.""" + result = int(value) + if result <= 0: + raise argparse.ArgumentTypeError("must be greater than zero") + return result + + def main() -> None: """Extract full-page figures from one high-resolution raster upload.""" parser = argparse.ArgumentParser() - parser.add_argument("--side", type=int, default=10000) - parser.add_argument("--count", type=int, default=2) + parser.add_argument("--side", type=_positive_int, default=10000) + parser.add_argument("--count", type=_positive_int, default=2) args = parser.parse_args() side = args.side stride = (side * 3 + 3) // 4 * 4 From a368a2aecc7521b3df278b96295a96a3b96d31e3 Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Fri, 2 Oct 2026 08:53:39 -0400 Subject: [PATCH 3/5] chore: Retarget proposed release to 0.27.16 --- CHANGELOG.md | 2 +- unstructured/__version__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b434bc9022..7f16196632 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -## 0.27.13 +## 0.27.16 ### Fixes diff --git a/unstructured/__version__.py b/unstructured/__version__.py index 5cdb02ca0c..1963d2278a 100644 --- a/unstructured/__version__.py +++ b/unstructured/__version__.py @@ -1 +1 @@ -__version__ = "0.27.13" # pragma: no cover +__version__ = "0.27.16" # pragma: no cover From 85ba150fc66d0509344221b426126039900b734d Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Fri, 2 Oct 2026 09:08:18 -0400 Subject: [PATCH 4/5] refactor: Release decoded page without redundant context manager --- .../performance/benchmark_image_extraction_memory.py | 2 +- unstructured/partition/pdf_image/pdf_image_utils.py | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/scripts/performance/benchmark_image_extraction_memory.py b/scripts/performance/benchmark_image_extraction_memory.py index a4ae991cb7..1e954a2c8f 100644 --- a/scripts/performance/benchmark_image_extraction_memory.py +++ b/scripts/performance/benchmark_image_extraction_memory.py @@ -1,4 +1,4 @@ -"""Measure actual PIL image extraction; output hashes compare main and candidate. +"""Measure peak memory of PIL image extraction and hash its output for cross-checkout comparison. Run with PYTHONPATH pointing at the desired library checkout. The deterministic 100-megapixel BMP is written a row at a time, so fixture generation is bounded. diff --git a/unstructured/partition/pdf_image/pdf_image_utils.py b/unstructured/partition/pdf_image/pdf_image_utils.py index 8f7c6e4f60..03fefc5c0f 100644 --- a/unstructured/partition/pdf_image/pdf_image_utils.py +++ b/unstructured/partition/pdf_image/pdf_image_utils.py @@ -199,11 +199,12 @@ def save_elements( try: image_path = image_paths[page_index] # The crop owns its pixels; release the decoded page before encoding it. - with Image.open(image_path) as image: - try: - cropped_image = image.crop(padded_bbox) - finally: - image.close() + # Image.close() frees the pixel data, which the context manager does not. + image = Image.open(image_path) + try: + cropped_image = image.crop(padded_bbox) + finally: + image.close() try: # PNG images with transparency need conversion before JPEG encoding. if cropped_image.mode == "RGBA": From 76dc987da8dd79d78e5e0639328abb7f3eba46cb Mon Sep 17 00:00:00 2001 From: Nick Franck <46548427+CyMule@users.noreply.github.com> Date: Mon, 5 Oct 2026 12:07:31 -0400 Subject: [PATCH 5/5] docs: Add Enhancements heading to changelog entry --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 990083135e..2f708f6f51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## 0.27.23 +### Enhancements + - **Release image extraction buffers after each crop.** Image and PDF figure extraction closes owned decoded pages, crops, and JPEG buffers promptly, and copies image uploads to its existing temporary file in bounded chunks. JPEG payloads, saved files, metadata, and caller-owned streams retain their existing behavior. ## 0.27.18