fix: thumbnail-first + megapixel guard to prevent image OOM in PDF/DOCX
Previous draft()+convert()+thumbnail() ordering still caused OOM for
non-JPEG formats (PNG/HEIC) because draft() is a no-op for those formats,
and convert('RGB') forces a full pixel decode regardless.
Replace with thumbnail()-first ordering:
- thumbnail() internally calls draft() for JPEG before decoding
- thumbnail() performs in-place resize without allocating a second
full-resolution buffer
- convert('RGB') is then called on the already-small image (safe for
any format)
Add _COMPRESS_IMAGE_MAX_MEGAPIXELS guard (20 MP): read image dimensions
from headers only (no pixel decode) and return None for images that
exceed the limit. This prevents OOM even for pathologically large files
where draft() provides no benefit (e.g. PNG, TIFF, HEIC).
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -7,7 +7,7 @@ Nakon što je gitea webhook i testirana skripta za automatsko povlačenje promje
|
||||
```bash
|
||||
cd /opt/erp/app
|
||||
git fetch origin
|
||||
git reset --hard origin/main
|
||||
git reset --hard origin/main ili git pull --ff-only origin main
|
||||
|
||||
# rebuild backend + worker + beat + frontend
|
||||
docker compose -f docker-compose.yml -f docker-compose.prod.yml build backend worker beat frontend
|
||||
|
||||
@@ -464,24 +464,29 @@ def _resolve_service_report_tasks(work_order, task_id):
|
||||
return [selected_task], selected_task
|
||||
|
||||
|
||||
_COMPRESS_IMAGE_MAX_MEGAPIXELS = 20 # preskači slike iznad 20 MP kako bi se izbjegao OOM
|
||||
|
||||
|
||||
def _compress_image_for_pdf(image_field, max_width=1280, quality=75):
|
||||
"""
|
||||
Otvori image_field (Django FileField), kompresiraj na max_width JPEG u memoriji,
|
||||
vrati ImageReader spreman za reportlab. Vraća None ako slika nije dostupna.
|
||||
|
||||
Koristi Image.draft() + thumbnail() kako bi se izbjeglo učitavanje pune rezolucije
|
||||
velikih fotografija u RAM (sprječava OOM kill Gunicorn workera).
|
||||
Redoslijed thumbnail() → convert() je bitan: thumbnail() interno poziva draft()
|
||||
za JPEG (smanjuje rezoluciju JPEG dekodera na serveru) te radi in-place resize
|
||||
bez alokacije full-resolution kopije. convert() se poziva tek na već maloj slici.
|
||||
Pixel-count guard preskači slike koje bi i uz draft() prekoračile RAM.
|
||||
"""
|
||||
try:
|
||||
image_field.open('rb')
|
||||
with Image.open(image_field) as src:
|
||||
# draft() smanjuje dekodiranu rezoluciju za JPEG na serveru (hint, ne garantija)
|
||||
src.draft('RGB', (max_width, max_width * 4))
|
||||
img = src.convert('RGB')
|
||||
# thumbnail() radi in-place i ne alocira novu sliku pune veličine
|
||||
with Image.open(image_field) as img:
|
||||
w, h = img.size # čita samo header, bez dekodiranja piksela
|
||||
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
|
||||
return None
|
||||
img.thumbnail((max_width, max_width * 4), Image.LANCZOS)
|
||||
rgb = img.convert('RGB')
|
||||
buf = BytesIO()
|
||||
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
||||
rgb.save(buf, format='JPEG', quality=quality, optimize=True)
|
||||
buf.seek(0)
|
||||
return ImageReader(buf)
|
||||
except Exception:
|
||||
@@ -497,17 +502,21 @@ def _compress_image_for_docx(image_field, max_width=1600, quality=80):
|
||||
"""
|
||||
Pripremi sliku za python-docx kao JPEG stream razumne veličine.
|
||||
|
||||
Koristi Image.draft() + thumbnail() kako bi se izbjeglo učitavanje pune rezolucije
|
||||
velikih fotografija u RAM (sprječava OOM kill Gunicorn workera).
|
||||
Redoslijed thumbnail() → convert() je bitan: thumbnail() interno poziva draft()
|
||||
za JPEG (smanjuje rezoluciju JPEG dekodera na serveru) te radi in-place resize
|
||||
bez alokacije full-resolution kopije. convert() se poziva tek na već maloj slici.
|
||||
Pixel-count guard preskači slike koje bi i uz draft() prekoračile RAM.
|
||||
"""
|
||||
try:
|
||||
image_field.open('rb')
|
||||
with Image.open(image_field) as src:
|
||||
src.draft('RGB', (max_width, max_width * 4))
|
||||
img = src.convert('RGB')
|
||||
with Image.open(image_field) as img:
|
||||
w, h = img.size # čita samo header, bez dekodiranja piksela
|
||||
if w * h > _COMPRESS_IMAGE_MAX_MEGAPIXELS * 1_000_000:
|
||||
return None
|
||||
img.thumbnail((max_width, max_width * 4), Image.LANCZOS)
|
||||
rgb = img.convert('RGB')
|
||||
buf = BytesIO()
|
||||
img.save(buf, format='JPEG', quality=quality, optimize=True)
|
||||
rgb.save(buf, format='JPEG', quality=quality, optimize=True)
|
||||
buf.seek(0)
|
||||
return buf
|
||||
except Exception:
|
||||
|
||||
Reference in New Issue
Block a user