167 lines
7.3 KiB
Python
167 lines
7.3 KiB
Python
"""Zachowawcza, bezstratna dla PNG / wysokiej jakosci dla JPEG, optymalizacja.
|
|
|
|
Ten modul NIGDY nie nadpisuje pliku zrodlowego: tworzy jedynie propozycje
|
|
w pliku tymczasowym. main.py odpowiada za backup, podmiane i transakcje SQL.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
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):
|
|
pass
|
|
|
|
|
|
def _image_save_kwargs(source, image_format):
|
|
kwargs = {}
|
|
if source.info.get('icc_profile'):
|
|
kwargs['icc_profile'] = source.info['icc_profile']
|
|
if source.info.get('dpi'):
|
|
kwargs['dpi'] = source.info['dpi']
|
|
if image_format == 'PNG':
|
|
pnginfo = PngImagePlugin.PngInfo()
|
|
text_chunks = getattr(source, 'text', {}) or {}
|
|
for key, value in text_chunks.items():
|
|
if isinstance(key, str) and isinstance(value, str):
|
|
pnginfo.add_text(key, value)
|
|
if text_chunks:
|
|
kwargs['pnginfo'] = pnginfo
|
|
return kwargs
|
|
|
|
|
|
def create_optimized_candidate(
|
|
path,
|
|
*,
|
|
max_width=2400,
|
|
max_height=3200,
|
|
max_bytes=2 * 1024 * 1024,
|
|
jpeg_quality=92,
|
|
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}.
|
|
|
|
Status: ready, skip, error. Wlasnosc pliku temp_path przekazywana wywolujacemu:
|
|
wywolujacy MUSI usunac plik, takze przy bledzie.
|
|
"""
|
|
orig_size = os.path.getsize(path)
|
|
result = {
|
|
'status': 'skip', 'reason': '', 'before': orig_size,
|
|
'after': orig_size, 'width': 0, 'height': 0, 'quality': None,
|
|
'temp_path': None,
|
|
}
|
|
temp_path = None
|
|
try:
|
|
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'):
|
|
result['reason'] = f'format {fmt or "nieznany"} nie jest bezpiecznie obslugiwany'
|
|
return result
|
|
if fmt == 'JPEG' and ext not in ('.jpg', '.jpeg'):
|
|
result['reason'] = 'format i rozszerzenie pliku nie sa zgodne'
|
|
return result
|
|
if fmt == 'PNG' and ext != '.png':
|
|
result['reason'] = 'format i rozszerzenie pliku nie sa zgodne'
|
|
return result
|
|
if getattr(source, 'is_animated', False) and getattr(source, 'n_frames', 1) > 1:
|
|
result['reason'] = 'animacja (nie modyfikujemy)'
|
|
return result
|
|
|
|
original_width, original_height = source.size
|
|
result['width'], result['height'] = original_width, original_height
|
|
if original_width <= max_width and original_height <= max_height and orig_size <= max_bytes:
|
|
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
|
|
(working.width > max_width or working.height > max_height)):
|
|
# Skalowanie obrazu paletowego przed konwersja daje nearest-neighbor.
|
|
# Konwertuj najpierw, aby LANCZOS pracowal na pelnych kolorach.
|
|
working = working.convert('RGBA' if 'transparency' in working.info else 'RGB')
|
|
working.thumbnail((max_width, max_height), Image.Resampling.LANCZOS)
|
|
result['width'], result['height'] = working.size
|
|
kwargs = _image_save_kwargs(source, fmt)
|
|
|
|
fd, temp_path = tempfile.mkstemp(suffix=ext, prefix='.d6-image-', dir=temp_dir)
|
|
os.close(fd)
|
|
|
|
if fmt == 'JPEG':
|
|
if working.mode not in ('RGB', 'L', 'CMYK'):
|
|
if working.mode == 'RGBA' or 'transparency' in working.info:
|
|
result['reason'] = 'JPEG z kanalem alfa (nie modyfikujemy)'
|
|
return result
|
|
working = working.convert('RGB')
|
|
# Pobieraj EXIF juz po prawidlowej korekcji orientacji.
|
|
try:
|
|
exif = working.getexif()
|
|
if exif:
|
|
kwargs['exif'] = exif.tobytes()
|
|
except (ValueError, TypeError, OSError):
|
|
# Uszkodzone EXIF nie powinno blokowac zapisu obrazu.
|
|
pass
|
|
qualities = list(range(jpeg_quality, min_jpeg_quality - 1, -3))
|
|
if min_jpeg_quality not in qualities:
|
|
qualities.append(min_jpeg_quality)
|
|
best_quality = None
|
|
best_size = orig_size
|
|
for quality in qualities:
|
|
working.save(
|
|
temp_path, format='JPEG', optimize=True,
|
|
progressive=True, subsampling=0, quality=quality, **kwargs,
|
|
)
|
|
size = os.path.getsize(temp_path)
|
|
if size < best_size:
|
|
best_quality, best_size = quality, size
|
|
if size <= max_bytes and 100.0 * (orig_size - size) / max(1, orig_size) >= min_gain_percent:
|
|
break
|
|
# Jezeli nie udalo sie zejsc pod limit, zastosuj najmniejsza
|
|
# akceptowalna jakosc. Plik wynikowy bedzie ostatnim kandydatem.
|
|
if best_quality != quality:
|
|
working.save(
|
|
temp_path, format='JPEG', optimize=True,
|
|
progressive=True, subsampling=0, quality=best_quality or min_jpeg_quality,
|
|
**kwargs,
|
|
)
|
|
result['quality'] = best_quality
|
|
else:
|
|
working.save(temp_path, format='PNG', optimize=True, compress_level=9, **kwargs)
|
|
|
|
result['after'] = os.path.getsize(temp_path)
|
|
gain_percent = 100.0 * (orig_size - result['after']) / max(1, orig_size)
|
|
if result['after'] >= orig_size or gain_percent < min_gain_percent:
|
|
result['reason'] = f'za mala oszczednosc ({gain_percent:.1f}% < {min_gain_percent:.1f}%)'
|
|
return result
|
|
result['status'] = 'ready'
|
|
result['reason'] = 'osiagnieto limit' if result['after'] <= max_bytes else 'zmniejszono; nadal powyzej limitu'
|
|
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}'
|
|
return result
|
|
finally:
|
|
if temp_path and os.path.exists(temp_path):
|
|
os.unlink(temp_path)
|