محرر الصور بالذكاء الاصطناعي
تعديل الصور يدويًا في أداة رسم أمر جيد لصورة واحدة؛ ينهار حين تحتاج نفس الإصلاح ، تفتيح، قصّ، إطار ، لمئة صورة تصل على جدول زمني. يبني هذا المشروع الشيء الذي لا يستطيع إنسان فعله: محرر صور Python يقرأ تعليمة إنجليزية بسيطة مثل crop to 400x300 and brighten 25%، ويطبّقها على أي صورة، ويستطيع التراجع عن عمله الخاص. تقوم Pillow بجراحة البكسلات؛ ويقوم خط الأنابيب بالتمييز ورصة التراجع والمعالجة الدفعية.
هذا يفترض Python 101 والراحة في استخدام مكتبات الطرف الثالث ، لا شيء من تحليل البيانات مطلوب. هذا اختياري وغير مُقيَّم؛ راجع مشاريع من العالم الحقيقي للاطلاع على القائمة الكاملة والنامية.
🎯 ما ستفعله
- تحميل صورة حقيقية بـ Pillow وقراءة صيغتها وحجمها ونمطها وأقصى وأدنى قيم البكسلات قبل لمس أي شيء.
- تنفيذ كل تعديل ، قصّ، تحجيم، تدوير، سطوع ، كدالة نقية تُعيد صورة جديدة، لا تُغيّر الأصل أبدًا.
- كتابة محلّل يحوّل تعليمات إنجليزية قصيرة إلى استدعاءات تعديل محدّدة المعاملات، ويتصرف بشكل قابل للتنبؤ عندما تكون التعليمة مجهولة.
- بناء رصة سجل بحيث يمكن التراجع عن كل تعديل وإعادة تنفيذه.
- تطبيق تعليمة واحدة على مجلد كامل من الصور وحفظ النتائج مع بيان (manifest) بما أصبح عليه كل ملف.
أين تُشغّل هذا
محليًا باستخدام uv هو المسار الموصى به ، تعمل Pillow في أي Python حقيقي، والنقطة الكاملة لخطوة الدفعة هي لمس عدة ملفات .png على قرصك الخاص، وهو ما يناسب مجلد مشروع محلي تمامًا.
GitHub Codespaces يعمل جيدًا أيضًا: افتح مستودع الدورة كاملًا في Codespace مجاني وكل شيء أدناه يعمل دون تغيير.
Google Colab وKaggle Notebooks وBinder طريقة معقولة لتجربة خط الأنابيب الأساسي. Pillow مثبّتة مسبقًا في Colab وKaggle، ويولّد دفتر الملاحظات أدناه صورته النموذجية الخاصة فيعمل كل خطوة فعلًا. التحفظ الصادق: رفع صورك الخاصة إلى دفتر ملاحظات احتكاك أكبر من توجيه CLI إلى مجلد محلي، فعامل مسار دفتر الملاحظات كصندوق رمل، وuv المحلي كسير العمل الحقيقي.
الإعداد
كل ما تحتاجه قبل كتابة المحرر: مشروع مثبّت عليه Pillow، وصورة نموذجية لتوجيهها إليه (مولّدة بالكود، حتى لا تبحث عن صورة أبدًا).
أعِدَّ المشروع
uv init ai-image-editor
cd ai-image-editor
uv add pillow
Pillow (PIL) هي مكتبة الصور المعيارية الصناعية لـ Python ، المكتبة نفسها خلف عشرات خطوط أنابيب الصور المصغرة، وبلا أي اعتماد على GPU أو سحابة. uv add pillow يثبّتها في بيئة المشروع الخاصة.
ولّد صورة نموذجية
👟 تلميح البداية :
أنشئ sample.png بسكربت صغير يرسم تدرجًا لونيًا سلسًا ، موضوع اختبار حتمي، فكل تعديل تطبّقه له ناتج معروف وقابل للفحص.
# make_sample.py
from PIL import Image
img = Image.new("RGB", (400, 300))
px = img.load()
for y in range(300):
for x in range(400):
px[x, y] = ((x * 255) // 400, (y * 255) // 300, 128)
img.save("sample.png")
ينشئ Image.new("RGB", (400, 300)) لوحة قماش فارغة، ويعيد لك img.load() كائن وصول للبكسلات يمكنك الكتابة من خلاله بمؤشرات مثالية للبكسلات. كتابة لون واحد لكل موضع (x, y) تنتج تدرجًا يمكنك التنبؤ بقيمه الدقيقة مسبقًا ، الخاصية التي تجعل كل “ناتج متوقع” لاحق رقمًا دقيقًا بدلًا من شعور.
✅ قائمة التحقق
- ✅ ينتهي
uv add pillowدون أخطاء. - ✅ ينشئ
uv run python make_sample.pyملفsample.pngفي مجلد مشروعك، بأبعاد 400×300 بكسل.
الخطوة 1: انظر إلى الصورة كما ينظر إليها Python
لا يستطيع محرر إصلاح صورة لا يستطيع وصفها. تقرأ هذه الخطوة ما هي الصورة فعلًا ، صيغتها المخزنة على القرص، وأبعادها بالبكسل، ونمط ألوانها، وأغمق وأسطع قيم بكسل في كل قناة ، قبل تشغيل أي عملية. تعتمد كل خطوة لاحقة على صدق هذه الأرقام الأربعة.
1.1 اقرأ البيانات الوصفية
👟 تلميح البداية :
افتح sample.png واطبع صيغته وحجمه ونمطه وأقصى القيم لكل قناة، زائد بكسلي زاوية لتأكيد فهمك للهندسة.
# editor.py
from PIL import Image
img = Image.open("sample.png")
print("format:", img.format)
print("size:", img.size)
print("mode:", img.mode)
print("extrema:", img.getextrema())
print("corner (0, 0):", img.getpixel((0, 0)))
print("corner (399, 299):", img.getpixel((399, 299)))
يقرأ Image.open قراءة كسولة ، لا يُفكَّر شيء في الذاكرة حتى تطلبه عملية بكسل ، وهو تفصيل واقعي يستحق التذكر: فتح جهاز 400×400 لفحص بيانات وصفية لا يحتاج فك ترميز كاملًا. يُعيد img.getextrema() أدنى/أقصى لكل قناة، ويؤكد getpixel الهندسة بضرب زاوية معروفة.
🎯 الناتج المتوقع :
format: PNG, size: (400, 300), mode: RGB, extrema: ((0, 254), (0, 254), (128, 128)), والزاويتان تطبعان (0, 0, 128) و(254, 254, 128).
🩹 إذا لم يعمل :
إذا ظهر FileNotFoundError، فـ sample.png ليس في المجلد الذي شُغّل منه السكربت ، تحقق من مجلد العمل، لا من الملف. إذا اختلفت الزاوية عند (399, 299) عن (254, 253, 128)، فالتدرج لديك يكتب صيغة مختلفة ، تذكر أن القناتين الحمراء والخضراء تبلغان ذروتهما عند 254 لأن (399 * 255) // 400 و(299 * 255) // 300 تقرّبان إلى 254.
1.2 ميِّز الوصول الهدّام من غير الهدّام
👟 تلميح البداية :
افحص الفرق بين ImageEnhance الذي يعيد صورة جديدة مقابل getpixel/putpixel اللذين يغيّران الكائن المحمَّل ، هذا التمييز هو بذرة رصة التراجع في الخطوة 4.
# editor.py (continued)
from PIL import Image, ImageEnhance
img = Image.open("sample.png")
original_id = id(img)
brighter = ImageEnhance.Brightness(img).enhance(1.5)
print("returns new object:", id(brighter) != original_id)
print("original untouched:", img.getpixel((0, 0)))
print("new is brighter:", brighter.getpixel((0, 0)))
يُعيد ImageEnhance.Brightness(img).enhance(1.5) كائن صورة منفصلًا؛ ما يزال img الأصلي يقرأ بكسلَه القديم عند (0, 0). ذلك التعاقد ، العمليات تعيد كائنات جديدة بينما تبقى المدخلات غير قابلة للتغيير ، هو بالضبط ما يجعل رصة التراجع ممكنة. كان بمجرد أن تغيّر عملية في المكان، تختفي الحالة “قبل” نهائيًا.
🎯 الناتج المتوقع :
returns new object: True, original untouched: (0, 0, 128), وnew is brighter: (0, 0, 192) ، قناة 128 الزرقاء مكبّرة بعامل 1.5.
🩹 إذا لم يعمل :
إذا طبعت original untouched قيمةً لم تضبطها، فالصورة النموذجية استُبدلت بتصدير لاحق ، أعد توليدها بـ make_sample.py. إذا كانت معرّفات كائنات الصورة متساوية، فاستدعيت دالة تغيير في المكان مثل .resize() على مثيل Image مباشرةً بدلًا من المرور عبر المعزّز.
1.3 تحقّق
✅ قائمة التحقق
- ✅ تستطيع الإتيان بمتغيرات البيانات الوصفية الأربعة: الصيغة، الحجم، النمط، القصوى.
- ✅ رأيت بعينيك أن enhance يعيد كائنًا جديدًا ويترك المصدر دون لمس.
- ✅ بكسلا زاويتَي التدرج يطابقان الصيغة التي كتبها سكربت العينة.
🤔 سؤال (أسئلة) سقراطي(ة)
- أبلغ
formatعنPNGبينما الصورة في الذاكرة RGB بلا ألفا. من أين تأتي قيمةformatفعلًا، وماذا سيكونimg.formatلو أنشأتImageفي الذاكرة دون حفظه على القرص أولًا؟ - حوّل عامل السطوع
1.5القناة128إلى192. ماذا يحدث لبكسل هو بالفعل200عند تعزيزه بـ2.0؟ وماذا تفعل Pillow بقيم تتجاوز 255، ولماذا يُذلل ذلك دقة القناة كاملة بصمت؟
الخطوة 2: اكتب التعديلات كدوال نقية
تصبح كل عملية تعديل دالة صغيرة بانضباط واحد: خذ الصورة، وأرجع صورة جديدة، ولا تلمس المدخل أبدًا. الدوال النقية هي ما يتيح لخط الأنابيب تركيب العمليات بأمان والتراجع عنها لاحقًا ، وتجعل كل تعديل قابلًا للاختبار بمعزل بسهولة.
2.1 العمليات الأربع الأساسية
👟 تلميح البداية :
نفّذ crop, resized, rotated, وbrightness ، كلٌّ منها أربعة أسطر أو أقل، وكلٌّ يعيد Image جديدة.
# editor.py (continued)
def crop(img: Image.Image, box: tuple[int, int, int, int]) -> Image.Image:
return img.crop(box)
def resized(img: Image.Image, width: int, height: int) -> Image.Image:
return img.resize((width, height))
def rotated(img: Image.Image, degrees: float) -> Image.Image:
return img.rotate(degrees, expand=True)
def brightness(img: Image.Image, factor: float) -> Image.Image:
return ImageEnhance.Brightness(img).enhance(factor)
sample = Image.open("sample.png")
print("crop:", crop(sample, (0, 0, 200, 150)).size)
print("resize:", resized(sample, 100, 100).size)
print("rotate:", rotated(sample, 90).size)
print("brighten 2x:", brightness(sample, 2.0).getpixel((100, 50)))
الأنواع هي التعاقد: تعلن كل دالة -> Image.Image وتعيد كائنًا جديدًا. يعيد rotate(..., expand=True) تحجيم القماش فتتحول لفة بـ90° من 400×300 إلى 300×400 ، العملية الوحيدة التي يتغير فيها الحجم مرئيًا، وهو ما يجب أن يعكسه ناتجك المتوقع. يثبت brightness ادعاء النقاء: يقرأ sample عند (100, 50) دون تغييره.
🎯 الناتج المتوقع :
يطبع السكربت crop: (200, 150), resize: (100, 100), rotate: (300, 400), ويظهر brighten 2x البكسل عند (100, 50) مقروءًا (126, 84, 255) ، أحمره 63 مزدوجًا إلى 126 وأزرقُه 128 مقصوصًا عند 255.
🩹 إذا لم يعمل :
إذا طبع rotate (400, 300)، فأنت أسقطت expand=True فقصّت Pillow اللفة إلى القماش القديم. إذا قرأ brighten 2x 255 بدلًا من 127، فالقيمة قُصّت لأنها كانت قرب القمة أصلًا ، عيّن بكسلًا أغمق أو استخدم تدرجك 400×300 بدلًا من صورة عشوائية.
2.2 سلسلة التعديلات وتحقق من النقاء من البداية للنهاية
👟 تلميح البداية :
ركّب عمليتين وتأكد أن كلاًّ من النتيجة الوسطية والنتيجة المتسلسلة موجودان ككائنات منفصلة، ثم افحص أن الأصل ما يزال مطابقًا بايتًا ببايت.
# editor.py (continued)
result = resized(rotated(crop(sample, (0, 0, 200, 150)), 90), 100, 100)
print("chained size:", result.size)
edited_chain = [result]
print("all objects distinct:", all(id(sample) != id(o) for o in edited_chain))
print("source pixel untouched:", sample.getpixel((10, 10)))
print("edited pixel differs:", result.getpixel((10, 10)))
قراءة استدعاءات الدوال المتداخلة ، resized(rotated(crop(...), 90), 100, 100) ، تقرأ من الداخل إلى الخارج: قصّ أولًا، ثم لف، ثم تحجيم. لأن كل مرحلة تعيد كائنًا جديدًا، تترك السلسلة أثر صور وسيطة يمكنك فحصها أو تجاهلها، وما يزال sample الأصلي يجيب دون تغيير.
🎯 الناتج المتوقع :
chained size: (100, 100), all objects distinct: True, وsource pixel untouched يطابق التدرج الأصلي، وبكسل (10, 10) من النتيجة المحرَّرة يختلف عن المصدر.
🩹 إذا لم يعمل :
إذا كان chained size خطأً، تتبّع الترتيب: القصّ يقلّص إلى 200×150، واللف يبدّل إلى 150×200، والتحجيم يفرض 100×100. إذا طابق البكسل النهائي المصدر تمامًا، فـ (10, 10) الذي عيّنته نجا من التحويلات الثلاثة دون تغيير مصادفة ، عيّن قرب زاوية حيث يكون التدرج حادًا.
2.3 تحقّق
✅ قائمة التحقق
- ✅ تعيد العمليات الأربع كلها كائنات
Imageجديدة ولا تُغيّر أيٌّ منها وسيطها. - ✅ يغيّر
rotate(..., expand=True)الأبعاد مرئيًا بينما تحفظ البقية محتوى حقيقيًا. - ✅ ينتج تعديل متسلسل كائنًا نهائيًا مميزًا ويترك
sampleدون لمس.
🤔 سؤال (أسئلة) سقراطي(ة)
- يتجاهل
resizedimg.sizeتمامًا. ماذا سينكسر في السلسلة لو غيّرت إحدى هذه الدوال مدخلها بصمت بدلًا من إعادة نسخة؟ سمّ الخطأ المحدد الذي ستسببهcropتُغيّر-في-المكان فيresized(rotated(crop(...), 90), 100, 100). - سطر السطوع الواحد بلا فحص حدود:
enhance(4.0)يقصّ كل شيء إلى 255. هل القصّ عند 255 مقبول، أم ينبغي أن تشتكي الدالة؟ ما معلومات اللون المفقودة نهائيًا في قصّ 255 التي سيحتفظ بها خط معتمد على أعداد عشرية؟
الخطوة 3: علّم المحرر فهم التعليمات
سطح “الذكاء الاصطناعي” لخط الأنابيب محلّل لغة طبيعية صغير: يقرأ جملة مثل crop to 200x150 and rotate 90, يستخرج المعاملات بالعبارات المعتادة (regex)، ويبني تسلسل الدوال النقية الصحيح من الخطوة 2. المحلّل ذكاء اصطناعي أمين: مفردات محدودة، وسلوك حتمي، ويفشل بصوت عالٍ حين لا يفهمك.
3.1 محلّل أوامر ينتج قائمة تعديلات ثابتة
👟 تلميح البداية :
اكتب parse_instruction(text) تعيد قائمة مرتبة من صفوف (operation, args)، فينفصل “ترجم الإنجليزية إلى تعديلات” عن “طبّق التعديلات” ، ويمكن اختبار الاثنين منفصلين.
# editor.py (continued)
import re
from typing import Callable
OPS: dict[str, Callable] = {
"crop": crop, "resize": resized,
"rotate": rotated, "brightness": brightness,
}
def parse_instruction(text: str) -> list[tuple[str, tuple]]:
text = text.lower()
steps: list[tuple[str, tuple]] = []
m = re.search(r"crop to (\d+)x(\d+)", text)
if m:
steps.append(("crop", (0, 0, int(m.group(1)), int(m.group(2)))))
m = re.search(r"rotate (\d+)", text)
if m:
steps.append(("rotate", (float(m.group(1)),)))
m = re.search(r"(brighten|darken) (\d+)%", text)
if m and m.group(1) == "brighten":
steps.append(("brightness", (1 + int(m.group(2)) / 100,)))
elif m:
steps.append(("brightness", (1 - int(m.group(2)) / 100,)))
if not steps:
raise ValueError(f"No recognised edit in: {text!r}")
return steps
print(parse_instruction("crop to 200x150 and rotate 90 and brighten 25%"))
print(parse_instruction("flip horizontally"))
يبحث كل re.search عن نمط واحد ويلحق خطوة واحدة، فيرسم “crop to 200x150, rotate 90” قائمة من عنصرين بالترتيب. تحويل تعليمة غير قابلة للترجمة إلى ValueError بدلًا من لا-عمل صامت خيار تصميم متعمد ، خط دفعة لا يقول شيئًا عن تعليمة مشوّهة سيفسد مجلدًا متظاهرًا بالنجاح.
🎯 الناتج المتوقع :
الطباعة الأولى تظهر [('crop', (0, 0, 200, 150)), ('rotate', (90.0,)), ('brightness', (1.25,))]; الثانية ترفع ValueError: No recognised edit in: 'flip horizontally'.
🩹 إذا لم يعمل :
إذا أعاد flip horizontally قائمة فارغة بصمت، فحارس if not steps: raise ليس في نهاية الدالة. إذا أنتج brighten 25% (1.25,) لكن darken 25% أنتج ترتيب وسائط مكسورًا، تحقق من elif ، darken يجب أن يطرح، لا أن يطابق نمطًا في حساب brighten.
3.2 طبّق تعليمة محللة على صورة
👟 تلميح البداية :
اكتب apply_steps(img, steps) تمشي في القائمة المحللة، تستدعي كل عملية عبر جدول OPS, وتُعيد الصورة النهائية زائد سجلًا بما تغيّر.
# editor.py (continued)
def apply_steps(img: Image.Image, steps: list[tuple[str, tuple]]) -> tuple[Image.Image, list[str]]:
current = img
log: list[str] = []
for name, args in steps:
before = id(current)
current = OPS[name](current, *args)
log.append(f"{name}{args} -> new object: {id(current) != before}")
return current, log
final, log = apply_steps(Image.open("sample.png"), parse_instruction("rotate 90 and crop to 300x200"))
print(final.size)
print(*log, sep="\n")
OPS[name](current, *args) هو القلب المعتمد على الجدول: البحث عن دالة بالاسم في قاموس يحوّل مخرجات المحلّل مباشرةً إلى استدعاء دون درج if/elif. الاحتفاظ بسجل هل أنتجت كل خطوة كائنًا جديدًا يعزز تعاقد النقاء من الخطوة 2 ، ويعطي البيان مكانًا لتسجيل مصدر كل تعديل.
🎯 الناتج المتوقع :
(300, 200) ، اللف أولًا يجعل القماش 300×400، ثم يقصّ القصّ عرضَه ، ويطبع السجل سطرين، كلٌّ منه يُبلّغ True عن إنشاء كائن جديد.
🩹 إذا لم يعمل :
إذا كان الحجم النهائي (200, 300)، فجرت الخطوات قصًا-قبل-لف (تحقق من ترتيب parse_instruction) لأن القصّ يمسك (0,0,300,200) من قماش مُلفوف. إذا أظهر السجل False لأي خطوة، تعدّلت عملية مدخلها ، crop في Pillow في الواقع يشطر قراءةً كسولة، فأربعة مخرجاته قد تشارك الذاكرة؛ استخدمه وفقًا لذلك.
3.3 تحقّق
✅ قائمة التحقق
- ✅ يرسم
parse_instructionالإنجليزية المعروفة إلى صفوف مرتبة(name, args)ويرفع استثناءً للتعليمات المجهولة. - ✅ تنتج
apply_stepsالنتيجة نفسها كاستدعاء العمليات يدويًا. - ✅ يسجّل سجل العمليات مصدر كل خطوة.
🤔 سؤال (أسئلة) سقراطي(ة)
- يطابق المحلّل الأنماط بترتيب ثابت ويلحق كل ما يجده. ماذا يحدث لتعليمة فيها قصّان ،
crop to 200x150 and crop to 100x100? هل يجب أن يخطئ المحلّل عند الغموض، أم يطبّقهما تسلسليًا، ولماذا يهم اختيارك لخط دفعة؟ - يعامل
apply_stepsOPS[name](current, *args)كأنه صالح دائمًا. ماذا يغيّرdict.getمقابل[]في الخطأ الذي يرفعه كودك عندما يُوسَّع المحلّل لاحقًا باسم خطوة لا يملكه جدول العمليات بعد؟
الخطوة 4: أضف التراجع وإعادة التنفيذ
الدوال النقية تعني أن كل حالة لقطة رخيصة. تلف هذه الخطوة خط الأنابيب في فئة Retoucher تخزّن كل حالة صورة في قائمة سجل، مع مؤشر يتحرك للخلف عند التراجع وللأمام عند إعادة التنفيذ ، النموذج نفسه الذي يستخدمه محرر حقيقي، ناقص اهتزاز القرص.
4.1 فئة رصة السجل
👟 تلميح البداية :
ابنِ Retoucher بطرق push, undo, redo, وخاصية current; واجعل push يبتاع ذيل إعادة التنفيذ بحيث يُبطل تعديل جديد بعد تراجع تاريخًا أماميًا.
# editor.py (continued)
class Retoucher:
def __init__(self, image: Image.Image) -> None:
self.history: list[Image.Image] = [image]
self.cursor: int = 0
def push(self, image: Image.Image) -> Image.Image:
self.history = self.history[: self.cursor + 1]
self.history.append(image)
self.cursor = len(self.history) - 1
return image
def undo(self) -> Image.Image:
if self.cursor > 0:
self.cursor -= 1
return self.history[self.cursor]
def redo(self) -> Image.Image:
if self.cursor < len(self.history) - 1:
self.cursor += 1
return self.history[self.cursor]
@property
def current(self) -> Image.Image:
return self.history[self.cursor]
self.history = self.history[: self.cursor + 1] هو السطر الذي يطبّق “التراجع نهائي”: بمجرد أن تتراجع ثم تُحدِث تعديلًا جديدًا، يختفي المستقبل المهجور ويسيطر المسار الجديد. يشير المؤشر دائمًا إلى الإطار الحي، لذا undo/redo حارسان حول حركة مؤشر ، سطر واحد لكلٍّ، وثبات “المؤشر دائمًا فهرس صالح” قائم بالبناء.
🎯 الناتج المتوقع :
يترك push بعد تراجعين ثلاث حالات بالضبط في history; يتوقف التراجع عن الحركة عند الفهرس 0; وتتوقف إعادة التنفيذ عند الفهرس الأخير.
🩹 إذا لم يعمل :
إذا أعاد إعادة التنفيذ إحياء تعديل يجب أن يكون ميتًا، فشريحة الابتتاع لم تُطبَّق قبل الإلحاق ، أعد الترتيب بحيث تُقصّ history أولًا. إذا أعاد التراجع الصورة نفسها دائمًا، فحارس المؤشر if self.cursor > 0 مفقود وتفهرس history[0] دائمًا.
4.2 قُد الرصة بتعليمات حقيقية
👟 تلميح البداية :
ابنِ Retoucher من sample.png, وطبّق تعليمتين عبر push, وتراجع مرتين، وأعد التنفيذ مرة، وتأكد أن حجم كل صورة معادة يطابق الحالة المتوقعة.
# editor.py (continued)
rt = Retoucher(Image.open("sample.png"))
rt.push(apply_steps(rt.current, parse_instruction("crop to 200x150"))[0])
rt.push(apply_steps(rt.current, parse_instruction("rotate 90"))[0])
print("after 2 edits:", [id(rt.current)] and rt.current.size)
rt.undo()
print("back one:", rt.current.size)
rt.undo()
print("to origin:", rt.current.size)
rt.redo()
print("forward one:", rt.current.size)
يغذي rt.current التعليمة التالية، فتتبع حالات الرصة سجل التعديلات: 400×300 → 200×150 → 150×200 → عودة إلى 200×150. كل حالة صورة كاملة، ما يجعل undo صحيحًا تافهًا ، أنت تمشي بين إطارات حقيقية، لا تعيد تشغيل عمليات قد تفشل.
🎯 الناتج المتوقع :
after 2 edits: (150, 200), back one: (200, 150), to origin: (400, 300), forward one: (200, 150) ، الأحجام الأربعة القانونية بالضبط، بهذا الترتيب.
🩹 إذا لم يعمل :
إذا أبلغ to origin عن حجم غير 400، خزّن المُنشئ مرجعًا لكن شيئًا غيّره، لأن الحالات تتشارك الكائنات حين تضغط دون إعادة بناء نقية ، تحقق أنك تضغط نتائج apply_steps, لا تعيد استخدام عملية مغيّرة. إذا تخطى التراجع بعد push جديد حالةً، تحقق أن شريحة الابتتاع تُنفَّذ قبل الإلحاق.
4.3 تحقّق
✅ قائمة التحقق
- ✅ بعد ضغطتين وتراجعين، تحمل الرصة الحالات التي تتوقعها ويصبح تراجع آخر لا-عمل.
- ✅ الضغط بعد تراجع يبتاع أثر إعادة التنفيذ ، لا يستطيع إعادة التنفيذ إحياء تعديل ميت.
- ✅ تستطيع شرح لماذا تصنع الدوال النقية من الخطوة 2 هذه الرصة كلّها قائمة وعدادًا.
🤔 سؤال (أسئلة) سقراطي(ة)
- تخزّن هذه الرصة الصورة الكاملة في كل خطوة. لمسح ضخم بالجيجابكسل هذا أمر سخيف ، ماذا تخزّن بدلًا ليجعل التراجع رخيصًا، وما المعلومة التي ترميها تلك النسخة؟
- يبتاع
pushالذيل بشطرhistory[: cursor + 1]. صف المحتويات الدقيقة للسجل بعد التسلسل: تعديل، تراجع، تعديل، تراجع، إعادة تنفيذ، إعادة تنفيذ. أي إعادة تنفيذ لا-عمل ولماذا؟
الخطوة 5: حرّر مجلدًا دفعةً واحدة مع بيان (manifest)
الوظيفة النهائية لخط الأنابيب هي التي لن يقوم بها إنسان: طبّق التعليمة نفسها على كل صورة في مجلد، واحفظ كل نتيجة دون مسح المصدر، واترك بيانًا (manifest) يسجّل ما صار إليه كل ملف وارد.
5.1 عالج كل ملف PNG في مجلد
👟 تلميح البداية :
اكتب batch_edit(folder, instruction, suffix) تجوب *.png، تتجاوز مجلد مخرجاتها، وتطبّق التعليمة المحللة، وتحفظ في output/.
# editor.py (continued)
import json
from pathlib import Path
def batch_edit(folder: str, instruction: str, suffix: str = "_edited") -> list[dict]:
steps = parse_instruction(instruction)
out = Path(folder) / "output"
out.mkdir(exist_ok=True)
manifest: list[dict] = []
for src in sorted(Path(folder).glob("*.png")):
if "output" in src.parts:
continue
edited, log = apply_steps(Image.open(src), steps)
dest = out / f"{src.stem}{suffix}.png"
edited.save(dest)
manifest.append({"source": src.name, "dest": dest.name, "size": edited.size, "edits": log})
return manifest
m = batch_edit(".", "crop to 200x150 and brighten 20%")
print(json.dumps(m, indent=2))
Path.glob("*.png") زائد حارس "output" in src.parts يُبقي الدفعة من تعديل مخرجاتها السابقة أبدًا. كتابة كل نتيجة تحت output/ بلاحقة ، لا فوق المصدر أبدًا ، هي الفرق بين أمين معرض ومدمر بيانات، ويحوّل البيان الجولة إلى سجل قابل للتدقيق: مصدرًا ووجهةً وحجمًا نهائيًا وسجل التعديلات الدقيق لكل ملف.
🎯 الناتج المتوقع :
يظهر مجلد output/ يحوي sample_edited.png, ويسرد البيان إدخالًا واحدًا بـ size: (200, 150) وسجل تعديلات يسمي خطوتَي القصّ والسطوع.
🩹 إذا لم يعمل :
إذا ظهر sample_edited.png مرتين في البيان ، الثانية كـ output/sample_edited_edited.png ، فالحارس أخطأ، أي أنك تعيد تشغيل الدفعة على مجلد يحوي output/ مسبقًا؛ احذفه أولًا أو شدّد الحارس بـ in src.relative_to(folder).parts. إذا كان البيان فارغًا، فليس هناك ملفات PNG عالية المستوى ، حُفظت العينة في مكان آخر غير المجلد الذي مررته.
5.2 وصّل واجهة السطر الأوامر (CLI)
👟 تلميح البداية :
امنح الدفعة وسائط سطر أوامر حقيقية بـ argparse ، --instruction للتعديل، --folder للهدف، --suffix لتسمية المخرجات.
# editor.py (continued)
import argparse
def main() -> None:
parser = argparse.ArgumentParser(description="Edit images by English instruction.")
parser.add_argument("--instruction", required=True, help='e.g. "crop to 200x150 and rotate 90"')
parser.add_argument("--folder", default=".", help="folder of PNGs to edit")
parser.add_argument("--suffix", default="_edited")
args = parser.parse_args()
try:
manifest = batch_edit(args.folder, args.instruction, args.suffix)
except ValueError as exc:
parser.error(str(exc))
print(json.dumps(manifest, indent=2))
if __name__ == "__main__":
main()
يمنحك argparse --instruction, --folder, و--suffix مجانًا، بما فيه مخرجات -h/--help ومسار parser.error(...) رشيق حين لا تتحلل التعليمة. التقاط ValueError التي يرفعها المحلّل وإعادة رفعها عبر parser.error يجعل الفشل خروجًا نظيفًا برسالة بدلًا من traceback خام.
🎯 الناتج المتوقع :
يطبع uv run python editor.py --instruction "rotate 90" بيانًا إدخاله dest: sample_edited.png وsize: (300, 400).
🩹 إذا لم يعمل :
إذا رفع CLI SystemExit بالرسالة عند إطعامك تعليمة هراء، فهذا مسار parser.error المرغوب ، traceback غير مأسور معناه إسقاط try/except. إذا غابت ملفات المخرجات, تأكد أن --folder يشير إلى المجلد الحامل الفعلي لملفات PNG.
5.3 تحقّق
✅ قائمة التحقق
- ✅ ينتج
batch_editoutput/بملف محرَّر واحد لكل PNG مصدر ولا يمسح مصدرًا أبدًا. - ✅ يسجّل البيان لكل ملف مصدره ووجهته وحجمه النهائي وسجل تعديلاته.
- ✅ يقبل CLI المعاملات عبر أعلام ويفشل نظيفة على تعليمات غير معروفة.
🤔 سؤال (أسئلة) سقراطي(ة)
- يسجّل البيان الآن الحجم والتعديلات لكن لا متوسط بكسل لكل نتيجة. أي خطأ عرض مستقبلي ، لا تكشفه فحوصاتك اليوم ، كان متوسط بكسل مخزّن ليلتقطه، وماذا تكلفة تخزينه؟
- يمكن أن يكون
--folder"."من مجلد ما ومسارًا مطلقًا من آخر. ماذا يحدث لتسميةoutput/لو شغّلت الدفعة نفسها من مجلدي عمل مختلفين مقابل نفس المجلد المطلق؟
⚠️ مآزق شائعة
- تغيير في المكان ثم فقدان ما كان “قبل”.
putpixelالمعتمد على.load()في Pillow وأنماط إعادة الاستخدام الأخرى يعيدون أو يغيّرون الكائن نفسه; إذا نسيت أي تعديل أساسي-> new Image, ستُعيد رصة التراجع من الخطوة 4 كتابة السجل بصمت. - نسيان
expand=Trueفيrotate. بدونه يبقى القماش بحجم ما قبل اللف وتُقصّ الزوايا الملفوفة ، خطأ “صورتي تقطّعت” الكلاسيكي ، ويكسر بهدوء رياضيات الحجم في كل خطوة لاحقة. - لا-عمليات صامتة على تعليمات مجهولة. محلّل يعيد
[]لـ “flip horizontally” سيعالج مجلدًا بالدفعة وهو لا يفعل شيئًا إطلاقًا. رفعValueErrorهو الحارس الذي يجعل الفشل مرئيًا. - دفعة تعدّل مخرجاتها الخاصة. دون حارس
"output" in src.parts(أو تخطٍّ مبني على اللاحقة), يعيد تشغيل متكرر معالجة الملفات المحرَّرة سابقًا، مكدّسًا التعديلات حتى تتعذر معرفتها. - فتح الصور بتوقعات خاطئة عن مرجع الملف.
Image.openكسول ، قراءة.sizeبعد إغلاق مقبض الملف، أو إعادة استخدام مؤشر عبر صيغ، ينتج أخطاء مربكة; أعد الفتح لكل عملية عندما تحتاج ضمانات عن البيانات الكامنة.
ما بنيته للتو
محرر صور كامل يعمل بالتعليمات: يحمّل صورًا حقيقية ويفحصها، ويطبّق قصّ/تحجيم/لف/سطوع كدوال نقية، ويرسم تعليمات إنجليزية بسيطة إلى تعديلات مرتبة، ويدعم التراجع/الإعادة عبر رصة سجل، ويعالج مجلدات كاملة في output/ مع بيان لكل ملف. المهارة القابلة للنقل هنا هي الانضباط تحت “الذكاء الاصطناعي”: حلّل المدخل إلى بيانات محققة، وحافظ على كل تحول نقيًا وقابلًا للعكس، واجعل الأعطال صاخبة لا صامتة.
شغّل نسخة أكمل دون أي إعداد محلي
examples/ai-image-editor/ في مستودع الدورة هو خط الأنابيب كاملًا كدفتر ملاحظات ، توليد العينة، ومخرجات كل خطوة محققة، وجولة الدفعة ، جاهزًا لضغط Run من البداية للنهاية. استنسخه، أو افتح المستودع كاملًا في GitHub Codespace.
إلى أين تذهب من هنا
- وسّع جدول
OPSبـgrayscale,blur, وflipكدوال نقية جديدة ، إضافة عملية الآن تكلف دالة من خمسة أسطر وإدخال قاموس واحد، لا فرعifجديدًا أبدًا. - استبدل محلل العبارات المعتادة باستدعاء نموذج لغوي من مستوى مجاني (انظر مشروع مُراجِع الكود العامل لجدول المزوّدين) حتى تُترجم تعليمات مثل “make it look vintage” إلى استدعاءات
OPSمحدّدة المعاملات. - أضف مقارنة الصور إلى البيان: سجّل هاش الإدراك (perceptual hash) لكل مخرجات, ثم علّم جولات الدفعة حيث أنتجت مصادر متشابهة نتائج متباينة.
- أدمِ رصة التراجع إلى القرص كملف سجل تعديلات لكل صورة، حتى ينجو
Retoucherمن إعادة تشغيل ، الخطوة الأولى نحو محرر غير-تدميري حقيقي.
شارك مشروعك مع الصف
بنيت شيئًا تفخر به؟ examples/student-projects/ معرض لمشاريع قدّمها طلاب آخرون ، وREADME الخاص به يحوي شرحًا كاملًا وودودًا للمبتدئين لإضافة مشروعك عبر pull request، حتى لو لم تستخدم git من قبل قط: عمل fork للمستودع، وإنشاء فرع، وتثبيت ملفاتك، وفتح الـ PR، خطوة بخطوة. لا يُفترض أي خبرة سابقة بـ git.
مرحبًا بك في كتابة Python خارج المتصفح. 🎓
أكمل كل خطوة ثم حدد المشروع كمكتمل لجمع نقاطه.