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. 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ę ## 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: **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 pathlib import Path
from PIL import Image, ImageOps, PngImagePlugin from PIL import Image, ImageOps, PngImagePlugin
from image_safety import DEFAULT_MAX_IMAGE_PIXELS, ImagePixelLimitExceeded, safe_image_open
class ImageOptimizationError(Exception): class ImageOptimizationError(Exception):
@@ -43,6 +44,7 @@ def create_optimized_candidate(
min_jpeg_quality=85, min_jpeg_quality=85,
min_gain_percent=5.0, min_gain_percent=5.0,
temp_dir=None, temp_dir=None,
max_pixels=DEFAULT_MAX_IMAGE_PIXELS,
): ):
"""Zwraca {status,reason,before,after,width,height,quality,temp_path}. """Zwraca {status,reason,before,after,width,height,quality,temp_path}.
@@ -57,7 +59,7 @@ def create_optimized_candidate(
} }
temp_path = None temp_path = None
try: try:
with Image.open(path) as source: with safe_image_open(path, max_pixels=max_pixels) as source:
fmt = source.format fmt = source.format
ext = Path(path).suffix.lower() ext = Path(path).suffix.lower()
if fmt not in ('JPEG', 'PNG'): if fmt not in ('JPEG', 'PNG'):
@@ -79,6 +81,13 @@ def create_optimized_candidate(
result['reason'] = 'obraz juz spelnia limity' result['reason'] = 'obraz juz spelnia limity'
return result 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. # EXIF Orientation ma znaczenie zwlaszcza dla pionowych skanow.
working = ImageOps.exif_transpose(source) working = ImageOps.exif_transpose(source)
if (fmt == 'PNG' and working.mode == 'P' and if (fmt == 'PNG' and working.mode == 'P' and
@@ -144,6 +153,10 @@ def create_optimized_candidate(
result['temp_path'] = temp_path result['temp_path'] = temp_path
temp_path = None # przekazujemy plik wywolujacemu temp_path = None # przekazujemy plik wywolujacemu
return result return result
except ImagePixelLimitExceeded as exc:
result['status'] = 'skip'
result['reason'] = str(exc)
return result
except Exception as exc: except Exception as exc:
result['status'] = 'error' result['status'] = 'error'
result['reason'] = f'{type(exc).__name__}: {exc}' 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 drupal_lookup import DrupalReferenceLookup, node_url, search_url
from image_optimizer import create_optimized_candidate from image_optimizer import create_optimized_candidate
from image_safety import DEFAULT_MAX_IMAGE_PIXELS, safe_image_open
def build_parser(): 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('--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-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('--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 = parser.add_argument_group('Raporty')
report.add_argument('--report-csv', help='CSV z plikami i linkami (UTF-8 BOM, otwiera sie w Excelu)') 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') parser.error('--min-gain-percent musi byc w przedziale 0..100')
if args.max_changes < 0 or args.optimize_top < 0: if args.max_changes < 0 or args.optimize_top < 0:
parser.error('--max-changes i --optimize-top nie moga byc ujemne') 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): if args.apply and not (args.optimize or args.update_db):
parser.error('--apply wymaga --optimize lub --update-db') parser.error('--apply wymaga --optimize lub --update-db')
if args.apply and args.optimize and not args.backup_dir: if args.apply and args.optimize and not args.backup_dir:
@@ -203,9 +208,12 @@ def sha256_file(path):
return digest.hexdigest() return digest.hexdigest()
def file_resolution(path): def file_resolution(path, *, max_pixels=DEFAULT_MAX_IMAGE_PIXELS):
try: 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 return im.size
except Exception: except Exception:
return (0, 0) return (0, 0)
@@ -257,7 +265,7 @@ def collect_images(connection, config, args):
if stat_result.st_size < args.min_size_mb * 1024 * 1024: if stat_result.st_size < args.min_size_mb * 1024 * 1024:
continue continue
if full_path not in groups: 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] = { groups[full_path] = {
'path': full_path, 'path': full_path,
'rel_path': rel_path, 'rel_path': rel_path,
@@ -286,6 +294,7 @@ def collect_images(connection, config, args):
def is_candidate(image, args): def is_candidate(image, args):
return ( return (
not image['width'] or not image['height'] or
image['width'] > args.max_width or image['width'] > args.max_width or
image['height'] > args.max_height or image['height'] > args.max_height or
image['before_size'] > args.max_file_mb * 1024 * 1024 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_jpeg_quality=args.min_jpeg_quality,
min_gain_percent=args.min_gain_percent, min_gain_percent=args.min_gain_percent,
temp_dir=os.path.dirname(image['path']) if args.apply else None, 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['optimization_reason'] = result['reason']
image['optimize_status'] = result['status'] 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"]}') print(f'[ERROR] {image["rel_path"]}: {result["reason"]}')
continue continue
if result['status'] == 'skip': 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"]}') print(f'[SKIP] {image["rel_path"]}: {result["reason"]}')
# Opcjonalny sync DB dla plikow, ktorych optymalizacja sie nie udala. # Opcjonalny sync DB dla plikow, ktorych optymalizacja sie nie udala.
if args.update_db and mismatched_rows: if args.update_db and mismatched_rows:
+66
View File
@@ -1,10 +1,13 @@
import io import io
import os import os
import re import re
import struct
import sys import sys
import tempfile import tempfile
import types import types
import unittest import unittest
import warnings
import zlib
from contextlib import redirect_stdout from contextlib import redirect_stdout
from pathlib import Path from pathlib import Path
from unittest.mock import patch from unittest.mock import patch
@@ -15,6 +18,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import main import main
from drupal_lookup import DrupalReferenceLookup, ReferenceMatcher, node_url, search_url from drupal_lookup import DrupalReferenceLookup, ReferenceMatcher, node_url, search_url
from image_optimizer import create_optimized_candidate from image_optimizer import create_optimized_candidate
from image_safety import safe_image_open
class FakeCursor: class FakeCursor:
@@ -346,6 +350,68 @@ class TestFiles(unittest.TestCase):
Image.new('RGB', (300, 500), 'red').save(gif) Image.new('RGB', (300, 500), 'red').save(gif)
self.assertEqual(create_optimized_candidate(str(gif), max_width=100)['status'], 'skip') 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): def test_cli_end_to_end_read_only_generates_reports(self):
csv_path = self.root / 'end.csv' csv_path = self.root / 'end.csv'
json_path = self.root / 'end.json' json_path = self.root / 'end.json'