This commit is contained in:
Mateusz Gruszczyński committed 2026-10-23 16:06:29 +02:00
1 parent 6a821c7512
commit 594d9e1df3
5 files changed
+173 -5

No files matched your search

+19
View File
@@ -87,6 +87,25 @@ python main.py \
Dostaniesz rzeczywisty przewidywany rozmiar po rekompresji (test wykonany na pliku tymczasowym). **Nie zmieni to oryginałów ani SQL.** `--top` dotyczy wyświetlania; `--optimize-top` ogranicza liczbę obrazów poddawanych testowi/zmniejszaniu.
### Duze skany: DecompressionBombWarning / DecompressionBombError
Pillow standardowo ostrzega powyzej ok. **89,5 mln pikseli (89,5 MP)**, a powyzej ok. **179 mln** przerywa otwieranie z bledem `DecompressionBombError`. To limit liczby pikseli, **nie rozmiaru pliku w MB**. Uczciwe skany dokumentow moga miec 100-200 MP.
W tej wersji prog jest konfigurowalny: domyslnie `--max-image-mpixels 220`, dzieki czemu skany o **199 756 800 pikselach** z podanego logu nie wywoluja juz ostrzezen ani automatycznego bledu Pillow. Narzedzie **nie wylacza globalnie ochrony**: otwiera naglowek, sprawdza liczbe pikseli **przed dekodowaniem**, a przekraczajace limit obrazy wypisuje jako `[SKIP-LIMIT]` z przyczyna. Pozostale rodzaje ostrzezen nie sa filtrowane. Zbyt duzy obraz nie zostaje zmieniony na dysku ani w bazie.
Uruchom ponownie swoj test:
```bash
python main.py \
--min-size-mb 5 --optimize --dry-run \
--max-width 4000 --max-height 6000 --max-file-mb 3 \
--max-image-mpixels 220 --report-csv plan.csv
```
Jesli serwer ma malo RAM, mozna obnizyc limit, np. do `--max-image-mpixels 120`: obrazy powyzej 120 MP zostana **pominiete**, a nie bezwarunkowo dekodowane. Skany JPEG wymagajace skalowania korzystaja z `draft()` do zmniejszenia pamieci przy dekodowaniu. Bardzo duze PNG moga nadal potrzebowac **ponad 1 GB RAM** na dekodowanie i skalowanie; domyslny limit MP nie jest gwarancja bezpiecznego zuzycia pamieci. Uruchamiaj najpierw `--dry-run`, a `--apply` testuj na pojedynczych plikach z backupem.
Nie ustawiaj `Image.MAX_IMAGE_PIXELS = None` ani nie ignoruj wszystkich ostrzezen Pillow: takie ustawienia usuwaja mechanizm ostrzegania przed niebezpiecznymi obrazami.
## 3. Zmniejsz np. pierwsze 10 obrazów i aktualizuj bazę
**Najpierw zrób pełen backup bazy danych i katalogu plików** oraz przetestuj na kopii. Następnie:
+14 -1
View File
@@ -10,6 +10,7 @@ import tempfile
from pathlib import Path
from PIL import Image, ImageOps, PngImagePlugin
from image_safety import DEFAULT_MAX_IMAGE_PIXELS, ImagePixelLimitExceeded, safe_image_open
class ImageOptimizationError(Exception):
@@ -43,6 +44,7 @@ def create_optimized_candidate(
min_jpeg_quality=85,
min_gain_percent=5.0,
temp_dir=None,
max_pixels=DEFAULT_MAX_IMAGE_PIXELS,
):
"""Zwraca {status,reason,before,after,width,height,quality,temp_path}.
@@ -57,7 +59,7 @@ def create_optimized_candidate(
}
temp_path = None
try:
with Image.open(path) as source:
with safe_image_open(path, max_pixels=max_pixels) as source:
fmt = source.format
ext = Path(path).suffix.lower()
if fmt not in ('JPEG', 'PNG'):
@@ -79,6 +81,13 @@ def create_optimized_candidate(
result['reason'] = 'obraz juz spelnia limity'
return result
# Duze JPEG-i dekodujemy w mozliwie mniejszej rozdzielczosci.
# JPEG draft korzysta z dekodera DCT i zapobiega alokacji
# kilkuset MB / kilku GB przy wejsciu rzedu 200 MP.
# Nie stosujemy draft do JPEG bez planowanego skalowania.
if fmt == 'JPEG' and (original_width > max_width or original_height > max_height):
source.draft(None, (max_width, max_height))
# EXIF Orientation ma znaczenie zwlaszcza dla pionowych skanow.
working = ImageOps.exif_transpose(source)
if (fmt == 'PNG' and working.mode == 'P' and
@@ -144,6 +153,10 @@ def create_optimized_candidate(
result['temp_path'] = temp_path
temp_path = None # przekazujemy plik wywolujacemu
return result
except ImagePixelLimitExceeded as exc:
result['status'] = 'skip'
result['reason'] = str(exc)
return result
except Exception as exc:
result['status'] = 'error'
result['reason'] = f'{type(exc).__name__}: {exc}'
+58
View File
@@ -0,0 +1,58 @@
"""Ograniczona obsluga duzych obrazow Pillow.
Pillow domyslnie ostrzega powyzej ~89 MP i zglasza blad powyzej ~179 MP.
Dla skanow dopuszczamy konfigurowalny limit, ale PRZED dekodowaniem
odrzucamy wszystko, co ten limit przekracza. Nie wylaczamy ochrony globalnie.
Narzędzie CLI wykonuje operacje obrazow sekwencyjnie; prog PIL jest globalny,
ale zmieniamy go tylko na czas kontrolowanego otwarcia i przywracamy po nim.
"""
from __future__ import annotations
import warnings
from contextlib import contextmanager
from PIL import Image
DEFAULT_MAX_IMAGE_PIXELS = 220_000_000 # 220 MP; obejmuje skany ~200 MP
class ImagePixelLimitExceeded(ValueError):
"""Obraz nie zostal zdekodowany, bo przekracza ustalony limit."""
@contextmanager
def safe_image_open(path, *, max_pixels=DEFAULT_MAX_IMAGE_PIXELS, check_pixels=True):
"""Otworz naglowek obrazu z limitem, zanim Pillow zdekoduje piksele.
Ostrzezenie Pillow dot. progu zostaje zastapione JAWNYM sprawdzeniem
wielkosci (i statusem SKIP w callerze). Pozostale ostrzezenia pozostaja
widoczne. Dla samego odczytu wymiarow check_pixels=False nie dekoduje.
"""
max_pixels = int(max_pixels)
if max_pixels <= 0:
raise ValueError('max_pixels musi byc wieksze od zera')
old_limit = Image.MAX_IMAGE_PIXELS
with warnings.catch_warnings():
warnings.filterwarnings('ignore', category=Image.DecompressionBombWarning)
Image.MAX_IMAGE_PIXELS = max_pixels
try:
try:
with Image.open(path) as image:
pixels = image.width * image.height
if check_pixels and pixels > max_pixels:
raise ImagePixelLimitExceeded(
f'{image.width}x{image.height} ({pixels / 1_000_000:.1f} MP) '
f'przekracza --max-image-mpixels={max_pixels / 1_000_000:g}; '
'pominieto dla ochrony pamieci RAM'
)
yield image
except Image.DecompressionBombError as exc:
# Bardzo duze obrazy Pillow moze odrzucic juz przy Image.open.
raise ImagePixelLimitExceeded(
f'Pillow odrzucil obraz przekraczajacy limit '
f'--max-image-mpixels={max_pixels / 1_000_000:g}: {exc}'
) from exc
finally:
Image.MAX_IMAGE_PIXELS = old_limit
+16 -4
View File
@@ -26,6 +26,7 @@ from PIL import Image
from drupal_lookup import DrupalReferenceLookup, node_url, search_url
from image_optimizer import create_optimized_candidate
from image_safety import DEFAULT_MAX_IMAGE_PIXELS, safe_image_open
def build_parser():
@@ -75,6 +76,8 @@ def build_parser():
change.add_argument('--jpeg-quality', type=int, default=92, help='Poczatkowa jakosc JPEG')
change.add_argument('--min-jpeg-quality', type=int, default=85, help='Minimalna dopuszczalna jakosc JPEG')
change.add_argument('--min-gain-percent', type=float, default=5.0, help='Minimalne zmniejszenie objetosci w procentach')
change.add_argument('--max-image-mpixels', type=float, default=DEFAULT_MAX_IMAGE_PIXELS / 1_000_000,
help='Limit megapikseli oryginalnego obrazu; powyzej tego progu plik bedzie pominiety (ochrona RAM)')
report = parser.add_argument_group('Raporty')
report.add_argument('--report-csv', help='CSV z plikami i linkami (UTF-8 BOM, otwiera sie w Excelu)')
@@ -93,6 +96,8 @@ def validate_args(args, parser):
parser.error('--min-gain-percent musi byc w przedziale 0..100')
if args.max_changes < 0 or args.optimize_top < 0:
parser.error('--max-changes i --optimize-top nie moga byc ujemne')
if not (0 < args.max_image_mpixels <= 500):
parser.error('--max-image-mpixels musi byc w zakresie (0, 500]')
if args.apply and not (args.optimize or args.update_db):
parser.error('--apply wymaga --optimize lub --update-db')
if args.apply and args.optimize and not args.backup_dir:
@@ -203,9 +208,12 @@ def sha256_file(path):
return digest.hexdigest()
def file_resolution(path):
def file_resolution(path, *, max_pixels=DEFAULT_MAX_IMAGE_PIXELS):
try:
with Image.open(path) as im:
# W metadanych mozemy odczytac wymiary nawet wtedy, gdy uzytkownik
# ustawi niski prog optymalizacji. Nie dekodujemy pikseli.
with safe_image_open(path, max_pixels=max(max_pixels, DEFAULT_MAX_IMAGE_PIXELS),
check_pixels=False) as im:
return im.size
except Exception:
return (0, 0)
@@ -257,7 +265,7 @@ def collect_images(connection, config, args):
if stat_result.st_size < args.min_size_mb * 1024 * 1024:
continue
if full_path not in groups:
width, height = file_resolution(full_path)
width, height = file_resolution(full_path, max_pixels=int(args.max_image_mpixels * 1_000_000))
groups[full_path] = {
'path': full_path,
'rel_path': rel_path,
@@ -286,6 +294,7 @@ def collect_images(connection, config, args):
def is_candidate(image, args):
return (
not image['width'] or not image['height'] or
image['width'] > args.max_width or
image['height'] > args.max_height or
image['before_size'] > args.max_file_mb * 1024 * 1024
@@ -465,6 +474,7 @@ def process_changes(connection, config, args, images, table_prefix):
min_jpeg_quality=args.min_jpeg_quality,
min_gain_percent=args.min_gain_percent,
temp_dir=os.path.dirname(image['path']) if args.apply else None,
max_pixels=int(args.max_image_mpixels * 1_000_000),
)
image['optimization_reason'] = result['reason']
image['optimize_status'] = result['status']
@@ -475,7 +485,9 @@ def process_changes(connection, config, args, images, table_prefix):
print(f'[ERROR] {image["rel_path"]}: {result["reason"]}')
continue
if result['status'] == 'skip':
if args.debug:
if 'przekracza --max-image-mpixels' in result['reason'] or 'Pillow odrzucil' in result['reason']:
print(f'[SKIP-LIMIT] {image["rel_path"]}: {result["reason"]}')
elif args.debug:
print(f'[SKIP] {image["rel_path"]}: {result["reason"]}')
# Opcjonalny sync DB dla plikow, ktorych optymalizacja sie nie udala.
if args.update_db and mismatched_rows:
+66
View File
@@ -1,10 +1,13 @@
import io
import os
import re
import struct
import sys
import tempfile
import types
import unittest
import warnings
import zlib
from contextlib import redirect_stdout
from pathlib import Path
from unittest.mock import patch
@@ -15,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import main
from drupal_lookup import DrupalReferenceLookup, ReferenceMatcher, node_url, search_url
from image_optimizer import create_optimized_candidate
from image_safety import safe_image_open
class FakeCursor:
@@ -346,6 +350,68 @@ class TestFiles(unittest.TestCase):
Image.new('RGB', (300, 500), 'red').save(gif)
self.assertEqual(create_optimized_candidate(str(gif), max_width=100)['status'], 'skip')
def test_image_limit_replaces_pillow_warning_without_disabling_safety(self):
# Symulujemy niski prog Pillow: nasz konfigurowalny limit nie
# powinien powodowac ostrzezen ani wychodzic poza scope.
with patch.object(Image, 'MAX_IMAGE_PIXELS', 100_000):
with warnings.catch_warnings(record=True) as emitted:
warnings.simplefilter('always', Image.DecompressionBombWarning)
self.assertEqual(main.file_resolution(str(self.image), max_pixels=2_000_000),
(950, 1250))
result = create_optimized_candidate(
str(self.image), max_width=550, max_height=700,
max_bytes=100_000, max_pixels=2_000_000,
)
self.assertEqual(result['status'], 'ready', result['reason'])
if result['temp_path']:
os.unlink(result['temp_path'])
self.assertEqual(Image.MAX_IMAGE_PIXELS, 100_000)
self.assertFalse(any(issubclass(item.category, Image.DecompressionBombWarning)
for item in emitted))
def test_giant_image_header_is_readable_but_skipped_above_limit(self):
# Header PNG deklaruje tyle pikseli co trzy problematyczne pliki
# z logu: 14400 * 13872 = 199 756 800. Nie generujemy tych pikseli.
fake = self.folder / 'scans' / '200mp.png'
with io.BytesIO() as buffer:
Image.new('RGB', (2, 2), 'red').save(buffer, format='PNG')
content = bytearray(buffer.getvalue())
content[16:24] = struct.pack('>II', 14400, 13872)
content[29:33] = struct.pack('>I', zlib.crc32(content[12:29]))
fake.write_bytes(content)
with patch.object(Image, 'MAX_IMAGE_PIXELS', 100_000):
with warnings.catch_warnings(record=True) as emitted:
warnings.simplefilter('always', Image.DecompressionBombWarning)
self.assertEqual(main.file_resolution(str(fake), max_pixels=220_000_000),
(14400, 13872))
with safe_image_open(str(fake), max_pixels=220_000_000) as source:
self.assertEqual(source.width * source.height, 199_756_800)
# Obrazu nie nalezy dekodowac bez limitu RAM.
rejected = create_optimized_candidate(str(fake), max_pixels=150_000_000)
self.assertEqual(rejected['status'], 'skip')
self.assertIn('--max-image-mpixels=150', rejected['reason'])
# Przypadek > 2 * limitu Pillow rowniez jest SKIP, nie ERROR.
rejected_strict = create_optimized_candidate(str(fake), max_pixels=60_000_000)
self.assertEqual(rejected_strict['status'], 'skip')
self.assertIn('--max-image-mpixels=60', rejected_strict['reason'])
self.assertEqual(Image.MAX_IMAGE_PIXELS, 100_000)
self.assertFalse(any(issubclass(item.category, Image.DecompressionBombWarning)
for item in emitted))
def test_oversize_images_are_reported_as_skipped_not_errors(self):
args = self.args('--optimize', '--dry-run', '--max-image-mpixels', '0.5',
'--max-width', '500')
original = self.image.read_bytes()
images, _, prefix = main.collect_images(self.conn, self.cfg, args)
with redirect_stdout(io.StringIO()) as output:
stats = main.process_changes(self.conn, self.cfg, args, images, prefix)
self.assertEqual(stats['errors'], 0)
self.assertEqual(stats['planned'], 0)
self.assertEqual(images[0]['optimize_status'], 'skip')
self.assertIn('[SKIP-LIMIT]', output.getvalue())
self.assertEqual(self.image.read_bytes(), original)
self.assertEqual(self.conn.files[0]['filesize'], 12345)
def test_cli_end_to_end_read_only_generates_reports(self):
csv_path = self.root / 'end.csv'
json_path = self.root / 'end.json'