From 3b36a1457a9c3b1cede838688d4220cc1164d2f6 Mon Sep 17 00:00:00 2001 From: Milos Marinkovic Date: Sun, 12 Apr 2026 22:23:41 +0200 Subject: [PATCH 1/3] Update openspecs related to image resize --- .../.openspec.yaml | 2 + .../design.md | 57 +++++++++++++++++ .../proposal.md | 28 +++++++++ .../specs/binary-search-resize/spec.md | 63 +++++++++++++++++++ .../tasks.md | 25 ++++++++ openspec/specs/binary-search-resize/spec.md | 63 +++++++++++++++++++ 6 files changed, 238 insertions(+) create mode 100644 openspec/changes/archive/2026-04-12-binary-search-image-resize/.openspec.yaml create mode 100644 openspec/changes/archive/2026-04-12-binary-search-image-resize/design.md create mode 100644 openspec/changes/archive/2026-04-12-binary-search-image-resize/proposal.md create mode 100644 openspec/changes/archive/2026-04-12-binary-search-image-resize/specs/binary-search-resize/spec.md create mode 100644 openspec/changes/archive/2026-04-12-binary-search-image-resize/tasks.md create mode 100644 openspec/specs/binary-search-resize/spec.md diff --git a/openspec/changes/archive/2026-04-12-binary-search-image-resize/.openspec.yaml b/openspec/changes/archive/2026-04-12-binary-search-image-resize/.openspec.yaml new file mode 100644 index 00000000..33d01f78 --- /dev/null +++ b/openspec/changes/archive/2026-04-12-binary-search-image-resize/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-04-12 diff --git a/openspec/changes/archive/2026-04-12-binary-search-image-resize/design.md b/openspec/changes/archive/2026-04-12-binary-search-image-resize/design.md new file mode 100644 index 00000000..05ba8291 --- /dev/null +++ b/openspec/changes/archive/2026-04-12-binary-search-image-resize/design.md @@ -0,0 +1,57 @@ +## Context + +`resize_file` in `src/features/images/image_size_utils.py` is called from `platform_bot_sdk.py` when a downloaded image exceeds the platform's size limit. The current implementation uses a linear walk-down: first reducing JPEG quality (95→85 in steps of 5), then reducing scale factor (0.9→0.1 in steps of 0.02). Each iteration performs a full PIL resize + encode. For a 20 MB image targeting 5 MB, this can take 15-20 iterations. + +The function's public contract is simple: `(input_path, max_size_bytes) → output_path`. No callers depend on internal behavior — only that the returned file is under the limit. + +## Goals / Non-Goals + +**Goals:** + +- Reduce iteration count from ~20 to ~7 for typical large-photo resizing +- Land output size in a predictable target band (90-100% of limit) +- Maintain the same public API (`resize_file(input_path, max_size_bytes) → str`) +- Add test coverage for `resize_file` and other untested functions in the module + +**Non-Goals:** + +- Changing the image format handling (PNG/JPEG/WEBP support stays the same) +- Optimizing PIL operations themselves (resize + encode are the fixed cost per iteration) +- Supporting additional output formats +- Changing the caller in `platform_bot_sdk.py` + +## Decisions + +### Binary search on scale factor with informed initial guess + +The core change. Instead of decrementing scale by a fixed step, binary search between `lo=0.1` and `hi=1.0`. Start with `guess = sqrt(max_size_bytes / original_size)` clamped to `[0.1, 1.0]`. + +**Why sqrt**: File size scales roughly with pixel area (width × height), and area scales with the square of the scale factor. So `sqrt(target_ratio)` gives a good first approximation. + +**Alternative considered**: Linear search with adaptive step size. Rejected because binary search is simpler, converges faster, and doesn't require tuning step parameters. + +### Fixed quality at 90 for lossy formats + +JPEG/WEBP quality fixed at 90 instead of iterating from 95→85. Quality 90 is visually near-lossless while providing significant compression. This eliminates the quality iteration phase entirely and keeps dimensions as large as possible. + +**Alternative considered**: Binary search on both quality and scale simultaneously. Rejected because it adds complexity with minimal benefit — quality 90 is the right tradeoff for chat-context images, and a single fixed value keeps the algorithm one-dimensional. + +### Target band: 90-100% of max_size_bytes + +Accept results where `max_size_bytes * 0.90 <= output_size <= max_size_bytes`. This gives a 10% wide band — wide enough for binary search to land in within ~7 iterations, tight enough to not waste space. + +**Previous behavior**: Accepted anything under limit, but preferred within 3%. The new band is wider on the low end (10% vs 3%) which allows faster convergence. + +### MAX_ITERATIONS = 10, MIN_DIMENSION = 64 + +Binary search on `[0.1, 1.0]` converges to 1% precision in `log2(0.9/0.01) ≈ 6.6` iterations. Cap at 10 as a safety net — should never trigger in practice. Minimum dimension raised from 32px to 64px since anything smaller is useless in a chat context. + +### Best-effort fallback strategy + +Track the best under-limit result seen during the search. If the loop exits without landing in the target band (due to iteration cap or minimum dimension), return the best under-limit result. If nothing was under the limit, return the closest result overall. Only raise `ValidationError` if no result was produced at all. + +## Risks / Trade-offs + +- **PNG compression unpredictability**: PNG file size depends heavily on image content (gradients compress well, noise doesn't). Binary search may take more iterations for PNG than JPEG. → Mitigation: the 10% wide target band and 10-iteration cap handle this; worst case returns best effort. +- **Quality 90 may over-compress for some use cases**: Fixed quality means no adaptation to image content. → Mitigation: 90 is the standard web-quality sweet spot; for chat-context images this is more than sufficient. +- **Informed guess assumes quadratic relationship**: The `sqrt` estimate is approximate — actual compression ratios vary by content. → Mitigation: it's just the starting point; binary search corrects from there regardless of guess accuracy. diff --git a/openspec/changes/archive/2026-04-12-binary-search-image-resize/proposal.md b/openspec/changes/archive/2026-04-12-binary-search-image-resize/proposal.md new file mode 100644 index 00000000..2c1fedf5 --- /dev/null +++ b/openspec/changes/archive/2026-04-12-binary-search-image-resize/proposal.md @@ -0,0 +1,28 @@ +## Why + +The current image resizing algorithm in `image_size_utils.py` uses a linear walk-down approach: it decreases JPEG quality in fixed steps (95 to 85), then decreases scale factor by 0.02 per iteration. For large photos needing significant size reduction, this burns 15-20+ iterations — each doing a full resize + encode cycle. A binary search approach converges logarithmically, reducing this to ~7 iterations. + +## What Changes + +- Replace the linear iterative resizing loop in `resize_file` with a binary search on scale factor +- Use an informed initial guess (`sqrt(target / original_size)`) to start near the right answer +- Fix compression quality at 90 for lossy formats (JPEG/WEBP) instead of iterating quality +- Target band of 90-100% of `max_size_bytes` (previously accepted anything under limit, preferring within 3%) +- Increase minimum dimension guard from 32px to 64px +- Reduce `MAX_ITERATIONS` from 30 to 10 (binary search converges in ~7) +- Add comprehensive test coverage for `resize_file` and other untested functions in the module + +## Capabilities + +### New Capabilities + +- `binary-search-resize`: Binary search image resizing algorithm with informed initial guess, fixed quality, and target band convergence + +### Modified Capabilities + +## Impact + +- `src/features/images/image_size_utils.py` — rewrite of `resize_file` function (public API unchanged) +- `test/features/images/test_image_size_utils.py` — new test file +- No API changes, no dependency changes, no breaking changes +- Callers (`platform_bot_sdk.py`) unaffected — same function signature, same return type diff --git a/openspec/changes/archive/2026-04-12-binary-search-image-resize/specs/binary-search-resize/spec.md b/openspec/changes/archive/2026-04-12-binary-search-image-resize/specs/binary-search-resize/spec.md new file mode 100644 index 00000000..eb02167f --- /dev/null +++ b/openspec/changes/archive/2026-04-12-binary-search-image-resize/specs/binary-search-resize/spec.md @@ -0,0 +1,63 @@ +## ADDED Requirements + +### Requirement: Binary search convergence on scale factor +The system SHALL use binary search on scale factor to find an output size within the target band. The initial guess SHALL be computed as `sqrt(max_size_bytes / original_file_size)`, clamped to `[0.1, 1.0]`. The search space SHALL be `lo=0.1`, `hi=1.0`. + +#### Scenario: Large JPEG resized into target band +- **WHEN** a 2000x2000 JPEG image exceeding the size limit is resized with a given max_size_bytes +- **THEN** the output file size SHALL be between 90% and 100% of max_size_bytes + +#### Scenario: Large PNG resized into target band +- **WHEN** a 2000x2000 PNG image exceeding the size limit is resized with a given max_size_bytes +- **THEN** the output file size SHALL be between 90% and 100% of max_size_bytes + +#### Scenario: Large WEBP resized into target band +- **WHEN** a 2000x2000 WEBP image exceeding the size limit is resized with a given max_size_bytes +- **THEN** the output file size SHALL be between 90% and 100% of max_size_bytes + +### Requirement: Fixed compression quality for lossy formats +The system SHALL use a fixed quality of 90 for JPEG and WEBP encoding. PNG encoding SHALL NOT use a quality parameter (lossless). + +#### Scenario: JPEG encoded at quality 90 +- **WHEN** a JPEG image is resized +- **THEN** the output SHALL be encoded with quality=90 and optimize=True + +#### Scenario: PNG encoded without quality parameter +- **WHEN** a PNG image is resized +- **THEN** the output SHALL be encoded with optimize=True and no quality parameter + +### Requirement: Early return for under-limit files +The system SHALL return the original file path unchanged when the original file size is already within the size limit. + +#### Scenario: Small file returns original path +- **WHEN** resize_file is called with an image whose file size is already under max_size_bytes +- **THEN** the original input_path SHALL be returned without re-encoding + +### Requirement: Minimum dimension guard +The system SHALL stop searching and return best effort when either dimension of the scaled image would fall below 64 pixels. + +#### Scenario: Image hits minimum dimension during search +- **WHEN** binary search reaches a scale factor where either width or height would be below 64px +- **THEN** the system SHALL stop the search and return the best under-limit result seen so far, or the closest result overall + +### Requirement: Iteration safety cap +The system SHALL stop after a maximum of 10 iterations and return the best result available. + +#### Scenario: Safety cap reached +- **WHEN** the binary search has not converged after 10 iterations +- **THEN** the system SHALL return the best under-limit result, or the closest result overall, or raise a ValidationError if no result was produced + +### Requirement: Best-effort fallback +The system SHALL track the best under-limit result and the best overall result during the search. When the search ends without landing in the target band, the system SHALL prefer the best under-limit result, then the best overall result, and only raise ValidationError as a last resort. + +#### Scenario: No result in target band but under-limit result exists +- **WHEN** the search ends and no result landed in the 90-100% band but a result under the limit was found +- **THEN** the system SHALL return the best under-limit result + +#### Scenario: No under-limit result exists +- **WHEN** the search ends and no result was under the limit +- **THEN** the system SHALL return the smallest result seen overall + +#### Scenario: No result produced at all +- **WHEN** the search ends with no results (e.g., image too small to encode) +- **THEN** the system SHALL raise a ValidationError with error code INVALID_IMAGE_SIZE diff --git a/openspec/changes/archive/2026-04-12-binary-search-image-resize/tasks.md b/openspec/changes/archive/2026-04-12-binary-search-image-resize/tasks.md new file mode 100644 index 00000000..0d0aab5b --- /dev/null +++ b/openspec/changes/archive/2026-04-12-binary-search-image-resize/tasks.md @@ -0,0 +1,25 @@ +## 1. Rewrite resize_file + +- [x] 1.1 Replace constants: `MAX_ITERATIONS=10`, `QUALITY=90`, `MIN_DIMENSION=64`, `TARGET_RATIO_LO=0.90` +- [x] 1.2 Implement informed initial guess: `scale = sqrt(max_size_bytes / original_size)` clamped to `[0.1, 1.0]` +- [x] 1.3 Implement binary search loop on scale factor with `lo=0.1`, `hi=1.0`, converging into the 90-100% target band +- [x] 1.4 Use fixed quality 90 for JPEG/WEBP, optimize-only for PNG (no quality iteration) +- [x] 1.5 Add minimum dimension guard at 64px (break search if either dimension would go below) +- [x] 1.6 Implement best-effort fallback: prefer best under-limit, then closest overall, then ValidationError + +## 2. Test suite for image_size_utils + +- [x] 2.1 Create `test/features/images/test_image_size_utils.py` with test class +- [x] 2.2 Test: under-limit file returns original path unchanged +- [x] 2.3 Test: large JPEG resized into 90-100% target band +- [x] 2.4 Test: large PNG resized into 90-100% target band +- [x] 2.5 Test: large WEBP resized into 90-100% target band +- [x] 2.6 Test: minimum dimension guard returns best effort without crashing +- [x] 2.7 Test: iteration safety cap returns best effort +- [x] 2.8 Test: `normalize_image_size_category` variants +- [x] 2.9 Test: `calculate_image_size_category` thresholds and error case + +## 3. Verify + +- [x] 3.1 Run existing tests to confirm no regressions +- [x] 3.2 Run pre-commit linting diff --git a/openspec/specs/binary-search-resize/spec.md b/openspec/specs/binary-search-resize/spec.md new file mode 100644 index 00000000..612facd8 --- /dev/null +++ b/openspec/specs/binary-search-resize/spec.md @@ -0,0 +1,63 @@ +## Requirements + +### Requirement: Binary search convergence on scale factor +The system SHALL use binary search on scale factor to find an output size within the target band. The initial guess SHALL be computed as `sqrt(max_size_bytes / original_file_size)`, clamped to `[0.1, 1.0]`. The search space SHALL be `lo=0.1`, `hi=1.0`. + +#### Scenario: Large JPEG resized into target band +- **WHEN** a 2000x2000 JPEG image exceeding the size limit is resized with a given max_size_bytes +- **THEN** the output file size SHALL be between 90% and 100% of max_size_bytes + +#### Scenario: Large PNG resized into target band +- **WHEN** a 2000x2000 PNG image exceeding the size limit is resized with a given max_size_bytes +- **THEN** the output file size SHALL be between 90% and 100% of max_size_bytes + +#### Scenario: Large WEBP resized into target band +- **WHEN** a 2000x2000 WEBP image exceeding the size limit is resized with a given max_size_bytes +- **THEN** the output file size SHALL be between 90% and 100% of max_size_bytes + +### Requirement: Fixed compression quality for lossy formats +The system SHALL use a fixed quality of 90 for JPEG and WEBP encoding. PNG encoding SHALL NOT use a quality parameter (lossless). + +#### Scenario: JPEG encoded at quality 90 +- **WHEN** a JPEG image is resized +- **THEN** the output SHALL be encoded with quality=90 and optimize=True + +#### Scenario: PNG encoded without quality parameter +- **WHEN** a PNG image is resized +- **THEN** the output SHALL be encoded with optimize=True and no quality parameter + +### Requirement: Early return for under-limit files +The system SHALL return the original file path unchanged when the original file size is already within the size limit. + +#### Scenario: Small file returns original path +- **WHEN** resize_file is called with an image whose file size is already under max_size_bytes +- **THEN** the original input_path SHALL be returned without re-encoding + +### Requirement: Minimum dimension guard +The system SHALL stop searching and return best effort when either dimension of the scaled image would fall below 64 pixels. + +#### Scenario: Image hits minimum dimension during search +- **WHEN** binary search reaches a scale factor where either width or height would be below 64px +- **THEN** the system SHALL stop the search and return the best under-limit result seen so far, or the closest result overall + +### Requirement: Iteration safety cap +The system SHALL stop after a maximum of 10 iterations and return the best result available. + +#### Scenario: Safety cap reached +- **WHEN** the binary search has not converged after 10 iterations +- **THEN** the system SHALL return the best under-limit result, or the closest result overall, or raise a ValidationError if no result was produced + +### Requirement: Best-effort fallback +The system SHALL track the best under-limit result and the best overall result during the search. When the search ends without landing in the target band, the system SHALL prefer the best under-limit result, then the best overall result, and only raise ValidationError as a last resort. + +#### Scenario: No result in target band but under-limit result exists +- **WHEN** the search ends and no result landed in the 90-100% band but a result under the limit was found +- **THEN** the system SHALL return the best under-limit result + +#### Scenario: No under-limit result exists +- **WHEN** the search ends and no result was under the limit +- **THEN** the system SHALL return the smallest result seen overall + +#### Scenario: No result produced at all +- **WHEN** the search ends with no results (e.g., image too small to encode) +- **THEN** the system SHALL raise a ValidationError with error code INVALID_IMAGE_SIZE From 813d355b6b8572111bcbfc21547c77740708a58b Mon Sep 17 00:00:00 2001 From: Milos Marinkovic Date: Sun, 12 Apr 2026 22:24:56 +0200 Subject: [PATCH 2/3] Update image resizing algorithm (binary search now) --- src/features/images/image_size_utils.py | 139 ++++++--------- test/features/images/test_image_size_utils.py | 158 ++++++++++++++++++ 2 files changed, 207 insertions(+), 90 deletions(-) create mode 100644 test/features/images/test_image_size_utils.py diff --git a/src/features/images/image_size_utils.py b/src/features/images/image_size_utils.py index 3f4f8177..b8d203a8 100644 --- a/src/features/images/image_size_utils.py +++ b/src/features/images/image_size_utils.py @@ -1,4 +1,5 @@ import io +import math import re import tempfile from pathlib import Path @@ -10,7 +11,10 @@ from util.error_codes import INVALID_IMAGE_SIZE from util.errors import ValidationError -MAX_ITERATIONS = 30 +MAX_ITERATIONS = 10 +QUALITY = 90 +MIN_DIMENSION = 64 +TARGET_RATIO_LO = 0.90 def __write_temp_file(content: bytes, suffix: str) -> str: @@ -31,76 +35,44 @@ def resize_file(input_path: str, max_size_bytes: int) -> str: original_format = image.format or "PNG" log.t(f"Original image: {image.size}, format: {original_format}") - # configure resizing parameters - quality = 95 - quality_min = 85 - quality_step = 5 - scale_factor = 0.9 - scale_step = 0.02 - scale_min = 0.1 + suffix = Path(input_path).suffix or ".png" + save_format = original_format if original_format in ["JPEG", "PNG", "WEBP"] else "PNG" + + save_kwargs: dict[str, Any] = {"format": save_format, "optimize": True} + if save_format != "PNG": + save_kwargs["quality"] = QUALITY + + original_width, original_height = image.size best_under: bytes | None = None best_under_diff: float = float("inf") best_any: bytes | None = None best_any_diff: float = float("inf") - suffix = Path(input_path).suffix or ".png" - prev_diff: float | None = None - - # resize the image until it fits the size limit - iteration = 0 - while True: - iteration += 1 - if iteration >= MAX_ITERATIONS: - log.w("Max iterations reached, returning best effort") - if best_under is not None: - return __write_temp_file(best_under, suffix) - if best_any is not None: - return __write_temp_file(best_any, suffix) - raise ValidationError(f"Could not resize image to acceptable size in {MAX_ITERATIONS} iterations", INVALID_IMAGE_SIZE) # noqa: E501 - log.d(f"Resizing in iteration {iteration}, scale_factor: {scale_factor}, quality: {quality}") + + scale = max(0.1, min(1.0, math.sqrt(max_size_bytes / original_size))) + lo = 0.1 + hi = 1.0 + + for iteration in range(MAX_ITERATIONS): + new_width = int(original_width * scale) + new_height = int(original_height * scale) + + if new_width < MIN_DIMENSION or new_height < MIN_DIMENSION: + log.w("Image hit minimum dimension during search, using best effort") + break + + log.d(f"Binary search iteration {iteration + 1}, scale: {scale:.4f}") output = io.BytesIO() - current_image = image.copy() - - # run the basic resizing operation - width, height = current_image.size - new_width = int(width * scale_factor) - new_height = int(height * scale_factor) - - if new_width < 32 or new_height < 32: - log.w("Image became too small during resizing, using best effort") - if best_under is not None: - return __write_temp_file(best_under, suffix) - if best_any is not None: - return __write_temp_file(best_any, suffix) - raise ValidationError("Could not resize image to acceptable size", INVALID_IMAGE_SIZE) - - current_image = current_image.resize((new_width, new_height), Image.Resampling.LANCZOS) - - # compute the format and quality metadata - save_format = original_format - if save_format not in ["JPEG", "PNG", "WEBP"]: - save_format = "PNG" - is_lossless = save_format == "PNG" - - save_kwargs: dict[str, Any] = {"format": save_format} - if save_format == "JPEG": - save_kwargs["quality"] = quality - save_kwargs["optimize"] = True - elif save_format == "PNG": - save_kwargs["optimize"] = True - elif save_format == "WEBP": - save_kwargs["quality"] = quality - - current_image.save(output, **save_kwargs) + resized = image.resize((new_width, new_height), Image.Resampling.LANCZOS) + resized.save(output, **save_kwargs) output_size = output.tell() output_mb = output_size / 1024 / 1024 - log.t(f"Resized to {current_image.size}, size: {output_mb:.2f} MB, quality: {quality}") + log.t(f"Resized to {resized.size}, size: {output_mb:.2f} MB") output.seek(0) output_bytes = output.read() - # check the result to see if it's the best so far diff = abs(output_size - max_size_bytes) if output_size <= max_size_bytes and diff < best_under_diff: best_under_diff = diff @@ -109,38 +81,26 @@ def resize_file(input_path: str, max_size_bytes: int) -> str: best_any_diff = diff best_any = output_bytes - # if the result is within the size limit, return it - if output_size <= max_size_bytes: - if output_size >= max_size_bytes * 0.97: - original_mb = original_size / 1024 / 1024 - log.i( - f"Successfully resized image from {original_mb:.2f} MB to " - f"{output_mb:.2f} MB (within 3% margin)", - ) - return __write_temp_file(output_bytes, suffix) - if prev_diff is not None and diff > prev_diff: - log.t("Diff started increasing below target; returning best under-limit") - if best_under is not None: - return __write_temp_file(best_under, suffix) - if best_any is not None: - return __write_temp_file(best_any, suffix) - return __write_temp_file(output_bytes, suffix) - - # if the result is not within the size limit, adjust the parameters and loop again - if (not is_lossless) and quality > quality_min + quality_step: - quality -= quality_step - else: - scale_factor -= scale_step - prev_diff = diff - - # check if the parameters are too small to continue - if scale_factor <= scale_min or quality <= quality_min: - log.w("Could not resize image to fit size limit closely, returning best effort") - if best_under is not None: - return __write_temp_file(best_under, suffix) - if best_any is not None: - return __write_temp_file(best_any, suffix) + if max_size_bytes * TARGET_RATIO_LO <= output_size <= max_size_bytes: + original_mb = original_size / 1024 / 1024 + log.i(f"Successfully resized image from {original_mb:.2f} MB to {output_mb:.2f} MB") return __write_temp_file(output_bytes, suffix) + + if output_size > max_size_bytes: + hi = scale + else: + lo = scale + scale = (lo + hi) / 2 + + log.w("Binary search ended without hitting target band, returning best effort") + if best_under is not None: + return __write_temp_file(best_under, suffix) + if best_any is not None: + return __write_temp_file(best_any, suffix) + raise ValidationError( + f"Could not resize image to acceptable size in {MAX_ITERATIONS} iterations", + INVALID_IMAGE_SIZE, + ) except Exception as e: log.e("Failed to resize image", e) raise @@ -177,7 +137,6 @@ def calculate_image_size_category(file_path: str) -> str: elif megapixels <= 8: return "8k" elif megapixels <= 14: - # we tolerate 14 megapixels for now if megapixels > 12: log.w(f"Large input image ({megapixels:.2f} MP) is tolerated, counting as 12k") return "12k" diff --git a/test/features/images/test_image_size_utils.py b/test/features/images/test_image_size_utils.py new file mode 100644 index 00000000..4955c023 --- /dev/null +++ b/test/features/images/test_image_size_utils.py @@ -0,0 +1,158 @@ +import random +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from PIL import Image + +from features.images.image_size_utils import ( + calculate_image_size_category, + normalize_image_size_category, + resize_file, +) +from util.error_codes import INVALID_IMAGE_SIZE +from util.errors import ValidationError + + +def _noisy_image(width: int, height: int) -> Image.Image: + random.seed(42) + data = [ + (random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) + for _ in range(width * height) + ] + img = Image.new("RGB", (width, height)) + img.putdata(data) + return img + + +def _blank_image(width: int, height: int) -> Image.Image: + return Image.new("RGB", (width, height), color = (100, 150, 200)) + + +class ImageSizeUtilsTest(unittest.TestCase): + + def setUp(self): + self._temp_files: list[str] = [] + + def tearDown(self): + for path in self._temp_files: + Path(path).unlink(missing_ok = True) + + def _save(self, img: Image.Image, suffix: str, **kwargs) -> str: + with tempfile.NamedTemporaryFile(suffix = suffix, delete = False) as f: + path = f.name + img.save(path, **kwargs) + self._temp_files.append(path) + return path + + # resize_file: early return + + def test_under_limit_returns_original_path(self): + path = self._save(_noisy_image(100, 100), ".jpg", format = "JPEG", quality = 90) + original_size = Path(path).stat().st_size + result = resize_file(path, original_size + 1000) + self.assertEqual(result, path) + + # resize_file: target band convergence + + def test_large_jpeg_resized_into_target_band(self): + path = self._save(_noisy_image(300, 300), ".jpg", format = "JPEG", quality = 90) + original_size = Path(path).stat().st_size + max_size = original_size // 3 + result = resize_file(path, max_size) + self._temp_files.append(result) + result_size = Path(result).stat().st_size + self.assertLessEqual(result_size, max_size) + self.assertGreaterEqual(result_size, int(max_size * 0.90)) + + def test_large_png_resized_into_target_band(self): + path = self._save(_noisy_image(300, 300), ".png", format = "PNG") + original_size = Path(path).stat().st_size + max_size = original_size // 3 + result = resize_file(path, max_size) + self._temp_files.append(result) + result_size = Path(result).stat().st_size + self.assertLessEqual(result_size, max_size) + self.assertGreaterEqual(result_size, int(max_size * 0.90)) + + def test_large_webp_resized_into_target_band(self): + path = self._save(_noisy_image(300, 300), ".webp", format = "WEBP", quality = 90) + original_size = Path(path).stat().st_size + max_size = original_size // 3 + result = resize_file(path, max_size) + self._temp_files.append(result) + result_size = Path(result).stat().st_size + self.assertLessEqual(result_size, max_size) + self.assertGreaterEqual(result_size, int(max_size * 0.90)) + + # resize_file: edge cases + + def test_min_dimension_guard_handles_gracefully(self): + path = self._save(_noisy_image(100, 100), ".jpg", format = "JPEG", quality = 90) + try: + result = resize_file(path, 10) + self._temp_files.append(result) + self.assertTrue(Path(result).exists()) + except ValidationError as e: + self.assertEqual(e.error_code, INVALID_IMAGE_SIZE) + + @patch("features.images.image_size_utils.MAX_ITERATIONS", 1) + def test_iteration_safety_cap_terminates_and_returns_best_effort(self): + path = self._save(_noisy_image(300, 300), ".jpg", format = "JPEG", quality = 90) + original_size = Path(path).stat().st_size + max_size = original_size // 3 + try: + result = resize_file(path, max_size) + self._temp_files.append(result) + self.assertTrue(Path(result).exists()) + except ValidationError as e: + self.assertEqual(e.error_code, INVALID_IMAGE_SIZE) + + # normalize_image_size_category + + def test_normalize_strips_spaces_and_lowercases(self): + self.assertEqual(normalize_image_size_category(" 2 K "), "2k") + + def test_normalize_mb_to_k(self): + self.assertEqual(normalize_image_size_category("4 MB"), "4k") + + def test_normalize_mp_to_k(self): + self.assertEqual(normalize_image_size_category("8 MP"), "8k") + + def test_normalize_m_to_k(self): + self.assertEqual(normalize_image_size_category("2 M"), "2k") + + def test_normalize_already_k_passthrough(self): + self.assertEqual(normalize_image_size_category("12k"), "12k") + + def test_normalize_mixed_case(self): + self.assertEqual(normalize_image_size_category("4Mp"), "4k") + + # calculate_image_size_category + + def test_calculate_1k_for_small_image(self): + path = self._save(_blank_image(500, 500), ".jpg", format = "JPEG") + self.assertEqual(calculate_image_size_category(path), "1k") + + def test_calculate_2k_for_1_to_2_mp(self): + path = self._save(_blank_image(1200, 1200), ".jpg", format = "JPEG") + self.assertEqual(calculate_image_size_category(path), "2k") + + def test_calculate_4k_for_2_to_4_mp(self): + path = self._save(_blank_image(1800, 1800), ".jpg", format = "JPEG") + self.assertEqual(calculate_image_size_category(path), "4k") + + def test_calculate_8k_for_4_to_8_mp(self): + path = self._save(_blank_image(2400, 2400), ".jpg", format = "JPEG") + self.assertEqual(calculate_image_size_category(path), "8k") + + def test_calculate_12k_for_8_to_14_mp(self): + path = self._save(_blank_image(3000, 3000), ".jpg", format = "JPEG") + self.assertEqual(calculate_image_size_category(path), "12k") + + def test_calculate_raises_for_over_14_mp(self): + path = self._save(_blank_image(3750, 3750), ".jpg", format = "JPEG") + with self.assertRaises(ValidationError) as ctx: + calculate_image_size_category(path) + self.assertEqual(ctx.exception.error_code, INVALID_IMAGE_SIZE) From dd29a33479baaf0380424aaa75867b16670aba70 Mon Sep 17 00:00:00 2001 From: Milos Marinkovic Date: Sun, 12 Apr 2026 22:25:12 +0200 Subject: [PATCH 3/3] Bump version --- docs/open-api-docs.yaml | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/open-api-docs.yaml b/docs/open-api-docs.yaml index 84e2b186..bbcd9378 100644 --- a/docs/open-api-docs.yaml +++ b/docs/open-api-docs.yaml @@ -2,7 +2,7 @@ openapi: 3.0.3 info: title: The Agent's user-facing API description: The user-facing parts of The Agent's API service (excluding system-level endpoints, chat completion, maintenance endpoints, etc.) - version: 5.8.0 + version: 5.8.1 license: name: MIT url: https://opensource.org/licenses/MIT diff --git a/pyproject.toml b/pyproject.toml index 8d1c513d..2d96df62 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "the-agent" -version = "5.8.0" +version = "5.8.1" [tool.setuptools] package-dir = {"" = "src"}