fixes
This commit is contained in:
1 parent
6a821c7512
commit
594d9e1df3
5 files changed
+173
-5
No files matched your search
@@ -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
@@ -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}'
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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'
|
||||
|
||||
Reference in new issue
Block a user