ضغط 1000 صورة دفعة واحدة باستخدام Python Pillow: دليل كامل

لماذا نضغط الصور دفعة واحدة

لديك 1000 صورة عالية الدقة مُصدَّرة من كاميرا، كل واحدة بحوالي 8 ميغابايت، والدليل بأكمله يستهلك قرابة 8 جيجابايت. إذا كان الهدف هو رفعها إلى موقع ويب، أو تضمينها في عرض تقديمي، أو إرسالها عبر البريد الإلكتروني، فإن هذا الحجم غير مقبول إطلاقًا. ضغطها واحدة تلو الأخرى في محرر رسومي غير فعال وعرضة للأخطاء — وهنا بالضبط يتألق المعالجة الدفعية بالبرمجة النصية.

مكتبة Pillow في Python هي المعيار الواقعي لمعالجة الصور. فهي توفر دعم القراءة والكتابة للصيغ السائدة بما في ذلك JPEG وPNG وWebP وBMP وTIFF، بالإضافة إلى تحكم دقيق في عوامل الجودة ومعدلات الاعتماد وأنماط الألوان. بعشرات الأسطر من Python فقط، يمكنك اجتياز شجرة دلائل كاملة، وتطبيق استراتيجية ضغط موحدة، والحفاظ على بنية المجلدات الأصلية في المخرجات.

تبدأ هذه المقالة من الاستخدام الأساسي لـ Pillow وتبني تدريجيًا نص برمجي كامل وقابل للتنفيذ ومتعدد المسارات للضغط الدفعي. كما يغطي تفاصيل هندسية عملية مثل الحفاظ على بيانات EXIF وتحويل الصيغ والاختيار الذكي للمعاملات.


تثبيت Pillow والاستخدام الأساسي

التثبيت

Pillow هو التفرع الذي يتم صيانته بنشاط من PIL (مكتبة Python للتصوير)، لكن اسم الاستيراد يبقى PIL. ثبّته باستخدام pip:


pip install Pillow

إذا كنت بحاجة إلى معالجة حجم كبير من الصور أو تريد فك ترميز أسرع، فثبّت النسخة الكاملة مع دعم الإضافات:


pip install "Pillow[all]"

بعد التثبيت، تحقق من الإصدار:


from PIL import Image, __version__
print(__version__)  # مثال: 10.4.0

العمليات الأساسية

نقطة الدخول الرئيسية لـ Pillow هي الفئة Image. العمليات الثلاث الأكثر شيوعًا هي الفتح والتعديل والحفظ:


from PIL import Image

# فتح صورة (تحميل كسول؛ تُقرأ بيانات البكسل عند أول وصول)
img = Image.open("photo.jpg")
print(img.format, img.size, img.mode)  # JPEG (4000, 3000) RGB

# الحصول على قيمة بكسل
pixel = img.getpixel((100, 100))

# تغيير الحجم
resized = img.resize((1920, 1080))

# الحفظ
resized.save("photo_small.jpg", quality=85)

هناك تفاصيل جديرة بالملاحظة: الكائن الذي يعيده Image.open() يحتفظ بمرجع ملف لا يُحرَّر حتى يُغلَق صراحةً أو يخرج من كتلة with. في المعالجة الدفعية، استخدم دائمًا مدير سياق أو استدعِ close() صراحةً، وإلا ستتسرب مقابض الملفات.


أمثلة على ضغط صورة واحدة

أساس الضغط الدفعي هو معالجة الصورة الواحدة. ننفذ أولاً دالة تدعم ضبط جودة JPEG وتقليل الدقة وتحويل الصيغة.

ضبط جودة JPEG

معامل quality في JPEG يتراوح من 1 إلى 100 ويتحكم مباشرة في تحجيم جدول التكميم. إنه المقبض الرئيسي لموازنة حجم الملف وجودة الصورة:


from PIL import Image

def compress_jpeg(input_path, output_path, quality=75):
    """ضغط JPEG بعامل الجودة المحدد"""
    with Image.open(input_path) as img:
        # JPEG لا يدعم الشفافية؛ يجب تحويل أنماط RGBA/P أولاً
        if img.mode in ("RGBA", "P", "LA"):
            img = img.convert("RGB")
        elif img.mode != "RGB":
            img = img.convert("RGB")
        img.save(
            output_path,
            format="JPEG",
            quality=quality,
            optimize=True,       # تفعيل تحسين ترميز Huffman
            progressive=True,    # إنشاء JPEG تقدمي؛ أكبر قليلاً لكن تجربة تحميل أفضل
        )

# مقارنة عوامل جودة مختلفة
compress_jpeg("photo.jpg", "q95.jpg", quality=95)
compress_jpeg("photo.jpg", "q75.jpg", quality=75)
compress_jpeg("photo.jpg", "q50.jpg", quality=50)

optimize=True يجعل Pillow يمسح البيانات مرة إضافية أثناء الحفظ لإعادة بناء جدول ترميز Huffman أمثل. يقلل هذا عادةً حجم الملف بنسبة 2-5% إضافية على حساب حفظ أبطأ قليلاً.

ضبط الدقة

للعرض على الويب، فإن أصل بدقة 4000x3000 أكبر بكثير من اللازم. استخدام thumbnail() للتقليل النسبي هو النهج الأكثر فعالية — خفض الدقة إلى النصف يقلل حجم الملف بنحو 75%:


from PIL import Image

def resize_image(input_path, output_path, max_size=1920, quality=80):
    """تحجيم أطول حافة إلى max_size مع الحفاظ على نسبة الأبعاد"""
    with Image.open(input_path) as img:
        # thumbnail يعدل في المكان ولا يتجاوز الأبعاد المحددة أبدًا
        # Image.Resampling.LANCZOS هو أفضل خوارزمية أخذ عينات تنازلي من حيث الجودة
        img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
        if img.mode != "RGB":
            img = img.convert("RGB")
        img.save(output_path, format="JPEG", quality=quality, optimize=True)

resize_image("photo.jpg", "photo_1920.jpg", max_size=1920, quality=80)

الفرق بين thumbnail() وresize() هو أن thumbnail() يحافظ على نسبة الأبعاد ولا يكبّر الصورة أبدًا — الأبعاد النهائية دائمًا أقل من أو تساوي القيم المحددة. resize() يجبر الصورة على الأبعاد المستهدفة بالضبط. في المعالجة الدفعية، تريد دائمًا تقريبًا thumbnail().

تحويل الصيغة

تحويل PNG إلى WebP هو تحسين شائع — WebP أصغر بنسبة 26% من PNG وأصغر بنسبة 25-35% من JPEG عند جودة مكافئة، مع دعم الشفافية أيضًا:


from PIL import Image

def convert_to_webp(input_path, output_path, quality=80, lossless=False):
    """تحويل أي صيغة إلى WebP"""
    with Image.open(input_path) as img:
        img.save(
            output_path,
            format="WebP",
            quality=quality,
            lossless=lossless,   # عند True، يستخدم وضع بدون فقدان
            method=6,            # جهد الضغط 0-6؛ 6 هو الأبطأ لكنه الأصغر
        )

convert_to_webp("icon.png", "icon.webp", quality=85)
convert_to_webp("logo.png", "logo_lossless.webp", lossless=True)

معامل method يتحكم في جهد الضغط من 0 إلى 6. القيم الأعلى تعطي نسب ضغط أفضل لكنها تستغرق وقتًا أطول. للمعالجة الدفعية حيث يكون الحجم النهائي للملف مهمًا، اضبطه على 6؛ للسرعة، 4 هو توازن جيد.


نص برمجي كامل للضغط الدفعي

الآن نجمع منطق الصورة الواحدة في نص برمجي دفعي كامل. يجتاز هذا النص جميع الصور في دليل الإدخال، ويحافظ على بنية المجلدات الأصلية في المخرجات، ويعرض تقدم المعالجة مع نتائج الضغط الإجمالية.

يوضح المخطط الانسيابي أدناه سير المعالجة الدفعية الإجمالي:

Python batch image compression processing flowchart: directory traversal, PIL/Pillow processing, quality adjustment, output

النص البرمجي الكامل:


"""
batch_compress.py - نص برمجي للضغط الدفعي للصور
الاستخدام: python batch_compress.py <input_dir> <output_dir> [--quality 75] [--max-size 1920]
"""
import os
import sys
import argparse
from pathlib import Path
from PIL import Image

SUPPORTED_FORMATS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif"}


def compress_single(input_path, output_path, quality=75, max_size=1920,
                    target_format=None, keep_exif=True):
    """ضغط صورة واحدة"""
    with Image.open(input_path) as img:
        # 1. استخراج بيانات EXIF (وقت الالتقاط، طراز الكاميرا، GPS، إلخ)
        exif_data = img.info.get("exif", b"") if keep_exif else b""

        # 2. تحويل نمط اللون: JPEG لا يدعم الشفافية
        out_format = (target_format or img.format or "JPEG").upper()
        if out_format in ("JPEG", "JPG") and img.mode in ("RGBA", "P", "LA"):
            # التركيب على خلفية بيضاء لتجنب المناطق السوداء بعد التحويل
            background = Image.new("RGB", img.size, (255, 255, 255))
            if img.mode == "P":
                img = img.convert("RGBA")
            background.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None)
            img = background
        elif img.mode not in ("RGB", "RGBA", "L"):
            img = img.convert("RGB")

        # 3. ضبط الدقة: تصغير فقط، لا تكبير أبدًا
        if max_size and max(img.size) > max_size:
            img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)

        # 4. الحفظ
        save_kwargs = {"optimize": True}
        if out_format in ("JPEG", "JPG"):
            save_kwargs["quality"] = quality
            save_kwargs["progressive"] = True
            if exif_data:
                save_kwargs["exif"] = exif_data
        elif out_format == "WEBP":
            save_kwargs["quality"] = quality
            save_kwargs["method"] = 6
        elif out_format == "PNG":
            save_kwargs["optimize"] = True

        img.save(output_path, format=out_format, **save_kwargs)


def batch_compress(input_dir, output_dir, quality=75, max_size=1920,
                   target_format=None, keep_exif=True):
    """ضغط دليل كامل دفعة واحدة"""
    input_dir = Path(input_dir)
    output_dir = Path(output_dir)

    # جمع جميع الملفات للمعالجة
    tasks = []
    for root, _, files in os.walk(input_dir):
        for filename in files:
            if Path(filename).suffix.lower() in SUPPORTED_FORMATS:
                src = Path(root) / filename
                rel = src.relative_to(input_dir)
                dst = output_dir / rel
                # تعديل الامتداد بناءً على الصيغة المستهدفة
                if target_format:
                    dst = dst.with_suffix(f".{target_format.lower()}")
                tasks.append((src, dst))

    total = len(tasks)
    if total == 0:
        print("لم يتم العثور على ملفات صور مدعومة")
        return

    print(f"تم العثور على {total} صورة. بدء الضغط...\n")

    processed = 0
    total_original = 0
    total_compressed = 0
    errors = []

    for src, dst in tasks:
        # التأكد من وجود دليل الإخراج
        dst.parent.mkdir(parents=True, exist_ok=True)

        original_size = src.stat().st_size
        try:
            compress_single(src, dst, quality, max_size, target_format, keep_exif)
            new_size = dst.stat().st_size
            total_original += original_size
            total_compressed += new_size
        except Exception as e:
            errors.append((src, str(e)))
            new_size = original_size  # عدّ كحجم أصلي عند الفشل

        processed += 1
        pct = processed / total * 100
        saved = (1 - new_size / original_size) * 100 if original_size else 0
        print(f"[{pct:5.1f}%] ({processed}/{total}) {src.relative_to(input_dir)}  "
              f"{original_size // 1024}KB -> {new_size // 1024}KB  (-{saved:.1f}%)")

    # تقرير ملخص
    print(f"\n{'=' * 50}")
    print(f"تم: {processed} ناجحة, {len(errors)} فاشلة")
    if total_original > 0:
        ratio = total_original / total_compressed if total_compressed else 0
        saved_mb = (total_original - total_compressed) / 1024 / 1024
        print(f"الحجم الأصلي: {total_original / 1024 / 1024:.2f} MB")
        print(f"الحجم المضغوط: {total_compressed / 1024 / 1024:.2f} MB")
        print(f"المساحة الموفرة: {saved_mb:.2f} MB (نسبة {ratio:.2f}x)")

    for src, err in errors:
        print(f"  فشل: {src} - {err}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="أداة الضغط الدفعي للصور")
    parser.add_argument("input_dir", help="دليل صور الإدخال")
    parser.add_argument("output_dir", help="دليل الإخراج")
    parser.add_argument("--quality", type=int, default=75, help="عامل جودة JPEG/WebP (1-100)")
    parser.add_argument("--max-size", type=int, default=1920, help="أطول حافة قصوى بالبكسل")
    parser.add_argument("--format", default=None, help="الصيغة المستهدفة (JPEG/WebP/PNG)")
    parser.add_argument("--no-exif", action="store_true", help="تجاهل بيانات EXIF")
    args = parser.parse_args()

    batch_compress(
        args.input_dir,
        args.output_dir,
        quality=args.quality,
        max_size=args.max_size,
        target_format=args.format,
        keep_exif=not args.no_exif,
    )

أمثلة على الاستخدام:


# الاستخدام الأساسي
python batch_compress.py ./photos ./output

# التحويل إلى WebP، الجودة 80، أطول حافة قصوى 2560
python batch_compress.py ./photos ./output --quality 80 --max-size 2560 --format webp

يعتمد هذا النص البرمجي على ثلاثة مبادئ تصميم أساسية: يستخدم os.walk() للاجتياز التكراري والحفاظ على بنية الدلائل؛ ويستخدم relative_to() لحساب المسارات النسبية لضمان مطابقة تخطيط الإخراج للإدخال؛ ويستخدم Path.stat() للحصول على أحجام الملفات لحساب تأثير الضغط الفعلي.


تقنيات متقدمة

الحفاظ على معلومات EXIF

تحتوي بيانات EXIF على وقت الالتقاط وإحداثيات GPS ومعاملات الكاميرا والفتحة وسرعة الغالق وغيرها من البيانات الوصفية. افتراضيًا، يتجاهل img.save() هذه المعلومات. للحفاظ عليها، يجب استخراجها يدويًا وتمريرها عند الحفظ:


from PIL import Image

def compress_with_exif(input_path, output_path, quality=75):
    with Image.open(input_path) as img:
        exif = img.info.get("exif", b"")  # استخراج بايتات EXIF الخام
        if img.mode != "RGB":
            img = img.convert("RGB")
        img.save(
            output_path,
            format="JPEG",
            quality=quality,
            exif=exif if exif else None,  # مرر None عند عدم وجود EXIF
            optimize=True,
        )

إذا كنت تحتاج فقط إلى الاحتفاظ بحقول EXIF معينة (مثلاً، وسم الاتجاه فقط)، استخدم الطريقة getexif() لاختيار العناصر بشكل فردي:


from PIL import ExifTags

def keep_orientation_only(input_path, output_path, quality=75):
    with Image.open(input_path) as img:
        exif = img.getexif()
        orientation = exif.get(0x0112)  # وسم الاتجاه
        # تدوير الصورة بناءً على وسم الاتجاه
        if orientation:
            from PIL import ImageOps
            img = ImageOps.exif_transpose(img)
        img.save(output_path, format="JPEG", quality=quality, optimize=True)

ImageOps.exif_transpose() يدور الصورة تلقائيًا وفقًا لوسم اتجاه EXIF. هذا مفيد بشكل خاص عند معالجة الصور الملتقطة بالهواتف الذكية — حيث يتم تركيب العديد من مستشعرات الهواتف في اتجاه أفقي وتعتمد على وسم اتجاه EXIF للإشارة إلى اتجاه العرض الصحيح.

PNG إلى WebP

عند تحويل PNG إلى WebP، تحتاج إلى الاختيار بين الوضعين الفقدان وبدون فقدان بناءً على خصائص الصورة. للصور التي تحتوي على نص وخطوط حادة (أيقونات، عناصر واجهة)، يحافظ WebP بدون فقدان على حواف حادة. للمحتوى الفوتوغرافي، يقدم WebP الفقداني ميزة حجم كبيرة:


from PIL import Image

def png_to_webp(input_path, output_path, lossless_threshold=0.3):
    """اختيار ذكي بين WebP الفقداني أو بدون فقدان بناءً على خصائص الصورة"""
    with Image.open(input_path) as img:
        # عد الألوان لتحديد ما إذا كانت رسومات بسيطة
        colors = img.getcolors(maxcolors=65536)
        is_simple = colors is not None and len(colors) < 256

        if is_simple:
            # ألوان قليلة، حواف حادة: وضع بدون فقدان
            img.save(output_path, format="WebP", lossless=True, method=6)
            print(f"WebP بدون فقدان (عدد الألوان: {len(colors)})")
        else:
            # ألوان غنية، فوتوغرافي: وضع فقداني
            if img.mode != "RGB":
                img = img.convert("RGB")
            img.save(output_path, format="WebP", quality=82, method=6)
            print("WebP فقداني (فوتوغرافي)")

getcolors() يعيد قائمة بالألوان أو None (عندما يتجاوز عدد الألوان maxcolors). العدد الصغير من الألوان يشير عادةً إلى رسومات بسيطة مثل أيقونة أو لقطة شاشة، وهي أكثر ملاءمة للضغط بدون فقدان.

الاختيار الذكي للمعاملات

الصور المختلفة تناسب استراتيجيات ضغط مختلفة. الدالة التالية تختار الصيغة والمعاملات تلقائيًا بناءً على الدقة والشفافية وعدد الألوان:


from PIL import Image

def smart_compress(input_path, output_path):
    """اختيار استراتيجية الضغط المثلى تلقائيًا بناءً على خصائص الصورة"""
    with Image.open(input_path) as img:
        width, height = img.size
        pixel_count = width * height
        mode = img.mode
        has_transparency = mode in ("RGBA", "LA") or (
            mode == "P" and "transparency" in img.info
        )

        # الاستراتيجية 1: لديه شفافية -> WebP (يوازن الشفافية والحجم)
        if has_transparency:
            if pixel_count > 2_000_000:
                q = 80
            else:
                q = 88
            img.save(output_path, format="WebP", quality=q, method=6)
            return "WebP فقداني (شفاف)"

        # الاستراتيجية 2: صورة كبيرة جدًا -> تصغير + JPEG جودة متوسطة
        if pixel_count > 8_000_000 or width > 4000:
            img = img.convert("RGB")
            img.thumbnail((2560, 2560), Image.Resampling.LANCZOS)
            img.save(output_path, format="JPEG", quality=75, optimize=True)
            return "JPEG (صورة كبيرة تم تصغيرها)"

        # الاستراتيجية 3: صورة عادية -> JPEG جودة أعلى
        if pixel_count > 500_000:
            img = img.convert("RGB")
            img.save(output_path, format="JPEG", quality=82, optimize=True)
            return "JPEG (جودة عالية)"

        # الاستراتيجية 4: صورة صغيرة -> البقاء كـ PNG
        img.save(output_path, format="PNG", optimize=True)
        return "PNG (صورة صغيرة محفوظة)"

المنطق الأساسي لهذه الاستراتيجية هو: الصور ذات الشفافية تذهب إلى WebP، والصور الكبيرة يتم تصغيرها قبل الضغط، والصور الفوتوغرافية العادية تستخدم JPEG، والصور الصغيرة تستخدم PNG من أجل الدقة. يمكنك تعديل العتبات بناءً على متطلباتك الفعلية.


تحسين الأداء

المعالجة متعددة المسارات

ضغط الصور هو مهمة تخلط بين العمل المقيد بالإدخال/الإخراج والعمل المقيد بالمعالج. القراءة/الكتابة على القرص والترميز يمثلان كل منهما جزءًا من الوقت. يحد GIL في Python من التوازي النقي للمعالج، لكن Pillow يحرر GIL عند استدعاء مكتبات C الأساسية لترميز JPEG/WebP، لذا يمكن لتعدد المسارات أن يجلب تسارعًا كبيرًا:


import os
from pathlib import Path
from PIL import Image
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading

SUPPORTED_FORMATS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff"}

_print_lock = threading.Lock()


def compress_single(input_path, output_path, quality=75, max_size=1920):
    with Image.open(input_path) as img:
        exif = img.info.get("exif", b"")
        if img.mode != "RGB":
            img = img.convert("RGB")
        if max_size and max(img.size) > max_size:
            img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
        kwargs = {"quality": quality, "optimize": True, "progressive": True}
        if exif:
            kwargs["exif"] = exif
        img.save(output_path, format="JPEG", **kwargs)


def batch_compress_threaded(input_dir, output_dir, quality=75, max_size=1920,
                            workers=4):
    input_dir = Path(input_dir)
    output_dir = Path(output_dir)

    # جمع المهام
    tasks = []
    for root, _, files in os.walk(input_dir):
        for f in files:
            if Path(f).suffix.lower() in SUPPORTED_FORMATS:
                src = Path(root) / f
                rel = src.relative_to(input_dir)
                dst = output_dir / rel
                dst.parent.mkdir(parents=True, exist_ok=True)
                tasks.append((src, dst))

    total = len(tasks)
    print(f"إجمالي {total} صورة، المعالجة بـ {workers} مسار\n")

    completed = 0
    total_saved = 0

    with ThreadPoolExecutor(max_workers=workers) as executor:
        futures = {
            executor.submit(compress_single, src, dst, quality, max_size): (src, dst)
            for src, dst in tasks
        }
        for future in as_completed(futures):
            src, dst = futures[future]
            try:
                future.result()
                saved = src.stat().st_size - dst.stat().st_size
                total_saved += saved
            except Exception as e:
                with _print_lock:
                    print(f"فشل: {src.name} - {e}")
            completed += 1
            with _print_lock:
                print(f"[{completed / total * 100:5.1f}%] ({completed}/{total}) {src.name}")

    print(f"\nتم. المساحة الموفرة: {total_saved / 1024 / 1024:.2f} MB")


if __name__ == "__main__":
    batch_compress_threaded("./photos", "./output", quality=75, max_size=1920, workers=4)

القاعدة التجريبية لعدد المسارات هي عدد الأنوية المنطقية للمعالج. على جهاز بـ 8 أنوية، يحقق 4-6 مسارات عادةً تسارعًا شبه أمثل. المسارات الكثيرة جدًا قد تقلل الأداء فعلًا بسبب تنافس إدخال/إخراج القرص وضغط الذاكرة.

إذا كنت بحاجة إلى الذهاب أبعد في التوازي، يمكنك استخدام ProcessPoolExecutor لتجاوز GIL. المقايضة هي أن العمليات لا يمكنها مشاركة الذاكرة، ويجب على كل عملية تحميل مكتبة Pillow بشكل مستقل، وأن نفقات البدء أعلى.

إدارة الذاكرة

يصبح استهلاك الذاكرة مصدر قلق عند معالجة صور عالية الدقة. صورة RGB واحدة بدقة 6000x4000 تشغل حوالي 72 ميغابايت من الذاكرة بعد فك الترميز (6000x4000x3 بايت). إذا تم فتح 10 في وقت واحد، فهذا 720 ميغابايت.

عدة مبادئ رئيسية لإدارة الذاكرة:


from PIL import Image
import gc

# 1. استخدم دائمًا عبارة with لضمان تحرير مقابض الملفات وذاكرات البكسل المؤقتة بسرعة
def safe_compress(input_path, output_path, quality=75):
    with Image.open(input_path) as img:
        # 2. انسخ الصورة إذا كنت بحاجة إلى تعديلها، تجنبًا للعمليات على الأصل
        work = img.copy()
    # تم تحرير img؛ work لا يزال في النطاق
    if work.mode != "RGB":
        work = work.convert("RGB")
    work.save(output_path, format="JPEG", quality=quality, optimize=True)
    work.close()  # 3. الإغلاق صراحةً

# 4. تشغيل جمع المهملات دوريًا أثناء المعالجة الدفعية
def batch_with_gc(tasks, interval=100):
    for i, task in enumerate(tasks):
        process(task)
        if i % interval == 0:
            gc.collect()

بالإضافة إلى ذلك، Image.MAX_IMAGE_PIXELS له حد افتراضي يبلغ حوالي 178 مليون بكسل (حوالي 8900x8900). تجاوزه يرفع DecompressionBombError. إذا كنت تحتاج فعلًا إلى معالجة صور كبيرة جدًا، يمكنك رفع هذه العتبة، لكن انتبه لاستهلاك الذاكرة:


Image.MAX_IMAGE_PIXELS = None  # تعطيل الحد (استخدم بحذر)

مقارنة نتائج الضغط

يوضح الجدول أدناه بيانات مقاسة من مجموعة من 50 صورة هاتف ذكي (المتوسط الأصلي 6.5 ميغابايت، 4000x3000 بكسل). جميع الاختبارات أُجريت على نفس الجهاز، مع تصغير الدقة بشكل موحد إلى أطول حافة 1920:

عامل الجودةالصيغةمتوسط الحجمالنسبةالجودة الذاتيةحالة الاستخدام
الأصليJPEG6.5 MB1.0xالمرجع الأساسي
95JPEG1.8 MB3.6xمتطابقة تقريبًاأرشفة عالية الجودة
85JPEG0.92 MB7.1xفرق غير محسوسصور رئيسية للموقع
75JPEG0.58 MB11.2xتشوهات طفيفة عند الفحص الدقيقمصغرات، صفحات القوائم
65JPEG0.42 MB15.5xتشوهات كتلية مرئيةغير موصى به
50JPEG0.28 MB23.2xتشوه واضحمعاينات صغيرة فقط
80WebP0.51 MB12.7xمماثلة لـ JPEG 85المواقع الحديثة
بدون فقدانWebP4.2 MB1.5xبدون فقدان تمامًاعند الحاجة للشفافية

تظهر عدة أنماط من البيانات:

  • الجودة 85 هي نقطة انعطاف الفعالية من حيث التكلفة: توفر ما يقرب من نصف الحجم مقارنة بـ 95، مع فروقات جودة غير محسوسة عمليًا للعين.
  • أقل من الجودة 70، تنخفض العوائد بسرعة: يستمر حجم الملف بالانخفاض، لكن تدهور الجودة يتسارع بشكل ملحوظ.
  • عوامل جودة WebP ليست مكافئة مباشرة لـ JPEG: WebP 80 ينتج جودة مماثلة لـ JPEG 85 لكن بحجم أصغر.
  • WebP بدون فقدان منطقي فقط عندما تكون الشفافية مطلوبة؛ للصور الفوتوغرافية، ميزة الحجم ضئيلة.

الأسئلة الشائعة

س1: لماذا تظهر الصور مقلوبة أو جانبية بعد الضغط؟

يحدث هذا لأن وسم اتجاه EXIF من الهاتف الذكي يتم تجاهله أثناء الضغط، بينما تم تخزين بيانات البكسل الفعلية في اتجاه أفقي. الحل هو استدعاء ImageOps.exif_transpose(img) قبل الحفظ. يقوم بتدوير بيانات البكسل وفقًا لوسم اتجاه EXIF بحيث يتم توجيه الصورة بشكل صحيح قبل حفظها. بهذه الطريقة، حتى لو لم يقرأ البرنامج اللاحق معلومات اتجاه EXIF، ستظهر الصورة بشكل صحيح.

س2: لماذا يستمر استخدام الذاكرة بالنمو عند معالجة 1000 صورة؟

السبب الأكثر شيوعًا هو عدم إغلاق كائنات الصور في وقت مناسب. استخدم دائمًا مدير السياق with Image.open(...) as img: لضمان تحرير كل صورة فورًا بعد المعالجة. ثانيًا، في النسخة متعددة المسارات، إذا تم إرسال جميع المهام دفعة واحدة، فإن كائنات future تحتفظ بمراجع للنتائج حتى يتم استهلاكها، مما يسبب تراكم الذاكرة. الحل هو إرسال المهام على دفعات باستخدام chunksize، أو التبديل إلى نمط طابور منتج-مستهلك للتحكم في التزامن. أخيرًا، استدعاء gc.collect() دوريًا يمكن أن يستعيد الذاكرة المحتجزة بواسطة مراجع دائرية.

س3: لماذا تتحول المناطق الشفافة إلى اللون الأسود بعد تحويل PNG إلى JPEG؟

صيغة JPEG لا تدعم الشفافية. عندما يحول Pillow RGBA إلى RGB افتراضيًا، فإنه يملأ الخلفية الشفافة باللون الأسود. الحل هو إنشاء صورة RGB بخلفية بيضاء أولًا، ثم استخدام paste() لتركيب الصورة الأصلية مع قناة ألفا كقناع:


background = Image.new("RGB", img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3])  # القناة الرابعة هي ألفا

بدلًا من ذلك، تحويل مباشرة إلى WebP، الذي يدعم الشفافية أصليًا دون أي معالجة إضافية.

س4: لماذا لا يقدم تعدد المسارات تسارعًا خطيًا؟

ضغط الصور يتضمن كلًا من إدخال/إخراج القرص وترميز المعالج. بمجرد أن يتجاوز عدد المسارات نقطة معينة، يصبح عرض نطاق القرص هو عنق الزجاجة — لا يمكن للمعالج أن يذهب أسرع مما يمكن قراءة البيانات وكتابتها. بالإضافة إلى ذلك، بيانات البكسل المفكوكة للصور عالية الدقة كبيرة، ومعالجة عدة صور كبيرة في وقت واحد في مسارات مختلفة تؤدي إلى تنافس عرض نطاق الذاكرة وانخفاض معدلات إصابة ذاكرة التخزين المؤقت. في الممارسة، 4-8 مسارات تقدم عادةً تسارعًا بمقدار 2-4 أضعاف، مع عوائد متناقصة بعد ذلك. للأقراص الصلبة الميكانيكية، يوصى بـ 4 مسارات؛ لأقراص NVMe SSD، يمكن زيادة العدد قليلًا.


الخلاصة

يمكن تفكيك الضغط الدفعي للصور باستخدام Python Pillow إلى ثلاث طبقات:

  • الطبقة الأساسية: أتقن الطرق الثلاث Image.open() وthumbnail() وsave()، مجتمعةً مع معاملي quality وoptimize، وستتمكن من ضغط صورة واحدة. هذا هو اللبنة الأساسية لكل المعالجة الدفعية.
  • طبقة الهندسة: استخدم os.walk() لاجتياز الدلائل، وPath.relative_to() للحفاظ على البنية، وconcurrent.futures للمعالجة المتوازية لتوسيع منطق الصورة الواحدة إلى آلاف الصور. تركز هذه الطبقة على الصحة والمتانة والإنتاجية.
  • طبقة التحسين: اختر الصيغ والمعاملات بذكاء بناءً على خصائص الصورة — الصور الشفافة تذهب إلى WebP، والصور الكبيرة يتم تصغيرها أولًا، والصور الصغيرة تبقى PNG. تفاصيل مثل الحفاظ على EXIF وتصحيح الاتجاه وإدارة الذاكرة تحدد ما إذا كان النص البرمجي يمكن أن يعمل بشكل موثوق في بيئة إنتاج.

هناك قاعدة تجريبية لاختيار عامل الجودة: 85 هو عتبة الجودة الفقدانية بصريًا، و75 هو نقطة التوازن بين الحجم والجودة، وأقل من 65 غير موصى به لأغراض العرض. WebP مدعوم الآن بالكامل عبر المتصفحات الحديثة ويجب أن يكون الصيغة المفضلة للمشاريع الجديدة.

قيمة هذا النص البرمجي لا تكمن في استبدال الأدوات الاحترافية بل في قابليته للتخصيص. يمكنك تعديل كل مرحلة وفقًا لاحتياجاتك الفعلية — تكوين معاملات مختلفة لدلائل محددة، أو الرفع التلقائي إلى CDN بعد الضغط، أو دمجه في خط أنابيب CI/CD. فهم المبادئ وراء كل سطر كود يمنحك الأساس للتعامل مع أي سيناريو معالجة صور.

مقالات ذات صلة:

هل تحتاج لضغط الملفات؟ جرّب SmartSlim

على أساس محرك ضغط Rust خاص، يدعم 10 فئات رئيسية و40+ صيغة بما في ذلك PDF/الصور/الفيديو/Office/OFD. معالجة محلية — البيانات لا تغادر نطاقك.