رفتن به محتوا

تصویر در پایتون: پیکسل، کانال‌های رنگ و آرایه‌ی NumPy

یادگیری عمیق با تصویر معروف شد و ما هم از تصویر شروع می‌کنیم. قبل از هر الگوریتمی باید بدانیم کامپیوتر تصویر را چطور می‌بیند: یک جدول از عددها.

کدها را در Jupyter Notebook اجرا می‌کنیم و این کتابخانه‌ها را لازم داریم:

Terminal window
pip install numpy matplotlib pillow opencv-python
  • NumPy برای کار با آرایه‌های چندبعدی عددی؛
  • Matplotlib برای رسم نمودار و نمایش تصویر؛
  • Pillow که Matplotlib برای خواندن فایل‌های تصویر از آن کمک می‌گیرد؛
  • OpenCV کتابخانه‌ی قدیمی و بسیار غنی پردازش تصویر که هنوز در محصولات واقعی استفاده می‌شود.
import matplotlib.pyplot as plt
import numpy as np
a = plt.imread("images/elephant.jpg")
plt.imshow(a)
عکس یک فیل در علفزار

a یک آرایه‌ی NumPy است. ابعادش را ببینیم:

a.shape # (425, 640, 3)

این سه عدد به ترتیب سطر، ستون و کانال هستند. سطرها ارتفاع تصویرند و ستون‌ها عرض آن؛ پس این عکس ۴۲۵ پیکسل ارتفاع و ۶۴۰ پیکسل عرض دارد. ویندوز همین عکس را «۶۴۰ × ۴۲۵» نشان می‌دهد، چون معمولاً اول عرض را می‌گوییم. حواستان باشد NumPy برعکس، اول ارتفاع را می‌دهد:

height, width, channels = a.shape

عدد سوم، ۳، تعداد کانال‌های رنگ است: قرمز، سبز و آبی (RGB). هر رنگی که روی صفحه می‌بینید ترکیبی از این سه است. اگر در Paint یک رنگ نارنجی را با ابزار انتخاب رنگ بردارید، می‌بینید از ۲۵۵ واحد قرمز، ۱۲۷ واحد سبز و ۳۹ واحد آبی ساخته شده. هر کانال عددی بین ۰ تا ۲۵۵ است.

سه کانال قرمز، سبز و آبی عکس فیل که جداگانه نمایش داده شده‌اند

مقدار یک پیکسل:

a[250, 200] # array([94, 68, 33], dtype=uint8)

سطر ۲۵۰ و ستون ۲۰۰ ترکیبی از ۹۴ قرمز، ۶۸ سبز و ۳۳ آبی است؛ یک رنگ قهوه‌ای. اگر همین سه عدد را در Paint وارد کنید، به همان قهوه‌ای می‌رسید. شمارش سطر و ستون از صفر و از بالا چپ تصویر شروع می‌شود.

با برش (slicing) آرایه می‌توانیم بخشی از تصویر را جدا کنیم: اول بازه‌ی سطرها، بعد ستون‌ها، و برای کانال‌ها : یعنی «به همه دست نزن».

b = a[0:150, 0:200, :] # ۱۵۰ سطر اول و ۲۰۰ ستون اول
plt.imshow(b)
گوشه‌ی بالا چپ عکس فیل، بریده‌شده

می‌خواهیم بخشی از تصویر را سیاه کنیم، یعنی هر سه کانال را صفر کنیم:

a[100:300, 250:450] = 0
# ValueError: assignment destination is read-only

Matplotlib برای سرعت، آرایه را فقط‌خواندنی برمی‌گرداند. کافی است یک کپی بسازیم:

a = np.array(a)
a[100:300, 250:450] = 0
plt.imshow(a)
عکس فیل که یک مستطیل سیاه روی بخشی از آن کشیده شده

اگر به جای ۰ مقدار ۲۵۵ بگذارید، آن ناحیه سفید می‌شود، چون ۲۵۵ بیشترین مقدار هر سه کانال است.

تصویر خاکستری (grayscale) به جای سه کانال یک کانال دارد: ۰ یعنی سیاه مطلق، ۲۵۵ یعنی سفید، و عددهای بین آن سطح‌های مختلف خاکستری‌اند. ساده‌ترین راه، میانگین سه کانال است:

gray = a[:, :, 0] // 3 + a[:, :, 1] // 3 + a[:, :, 2] // 3
gray.shape # (425, 640) — دیگر سه‌بعدی نیست
plt.imshow(gray, cmap="gray")

// تقسیم صحیح است. NumPy عملیات را برداری انجام می‌دهد: سه ماتریس هم‌اندازه را درایه‌به‌درایه جمع می‌کند و یک ماتریس هم‌اندازه برمی‌گرداند؛ بدون حلقه.

cmap="gray" مهم است. Matplotlib برای رسم نمودار ساخته شده و یک آرایه‌ی تک‌کانالی را با یک نقشه‌ی رنگ (colormap) رنگی نشان می‌دهد. با gray می‌گوییم کمترین مقدار را سیاه و بیشترین را سفید نشان بده.

دام سرریز: چرا ترتیب جمع و تقسیم مهم است؟

Section titled “دام سرریز: چرا ترتیب جمع و تقسیم مهم است؟”

در ریاضی a+b+c3=a3+b3+c3\frac{a+b+c}{3} = \frac{a}{3}+\frac{b}{3}+\frac{c}{3}. پس چرا ننویسیم:

gray_wrong = (a[:, :, 0] + a[:, :, 1] + a[:, :, 2]) // 3

نتیجه را ببینید:

چپ: نسخه‌ی خاکستری درست عکس فیل. راست: نسخه‌ی خراب با نقش‌های عجیب که روشن و تاریک جابه‌جا شده‌اند

جاهایی که روشن بودند تاریک شده‌اند و برعکس. دلیلش نوع داده است:

a.dtype # dtype('uint8')

uint8 یعنی عدد صحیح بدون علامت ۸ بیتی. با ۸ بیت بیشترین عدد ممکن 11111111 در مبنای دو، یعنی ۲۵۵ است. اگر ۱ اضافه کنیم، ۲۵۶ در مبنای دو 100000000 می‌شود که ۹ بیت دارد؛ بیت اضافه دور ریخته می‌شود و ۸ بیت باقی‌مانده همه صفرند. یعنی عدد دور می‌زند (overflow):

x = np.array([250], dtype=np.uint8)
y = np.array([10], dtype=np.uint8)
x + y # array([4], dtype=uint8) چون 260 - 256 = 4

وقتی سه کانال یک پیکسل روشن را با هم جمع می‌کنیم، حاصل از ۲۵۵ رد می‌شود و دور می‌زند. نتیجه آن‌قدر خراب است که بیشترین مقدار در gray_wrong حداکثر ۸۵ (یعنی ۲۵۵ تقسیم بر ۳) است؛ Matplotlib همین ۸۵ را سفید نشان می‌دهد و تصویر کاملاً عوض می‌شود.

دو راه درست:

# ۱) اول تقسیم، بعد جمع (حداکثر 85 + 85 + 85 = 255)
gray = a[:, :, 0] // 3 + a[:, :, 1] // 3 + a[:, :, 2] // 3
# ۲) اول نوع داده را بزرگ کنیم
gray = a.astype(np.int32).sum(axis=2) // 3

روش اول به خاطر گرد کردن هر کانال، حداکثر دو واحد با میانگین واقعی فرق دارد که چشم متوجهش نمی‌شود. کتابخانه‌های حرفه‌ای این جزئیات را خودشان رعایت می‌کنند، ولی وقتی خودتان روی پیکسل‌ها حساب می‌کنید، همیشه به نوع داده فکر کنید.

تمرین تعاملی

سیاه‌وسفید کردن بدون سرریز

تابع to_gray(img) را بنویسید که تصویر رنگی uint8 با شکل (h, w, 3) را بگیرد و تصویر خاکستری با شکل (h, w) برگرداند؛ مقدار هر پیکسل میانگین سه کانال است.

مراقب باشید: جمع کردن مستقیم سه کانال uint8 از ۲۵۵ رد می‌شود و دور می‌زند. مثلاً ‎250 + 10‎ در uint8 می‌شود ۴.

راهنمایی

دو راه دارید: هر کانال را جدا بر ۳ تقسیم کنید و بعد جمع کنید، یا اول نوع داده را با img.astype(np.int32) بزرگ کنید، بعد جمع و تقسیم کنید.

Ctrl + Enter برای اجرا

OpenCV هم تصویر را به آرایه‌ی NumPy تبدیل می‌کند و یک امکان راحت دارد: موقع خواندن می‌تواند مستقیم خاکستری بخواند.

import cv2
img = cv2.imread("images/elephant.jpg") # رنگی
gray = cv2.imread("images/elephant.jpg", 0) # خاکستری؛ شکل (425, 640)

ولی یک تفاوت مهم دارد: OpenCV کانال‌ها را به ترتیب BGR (آبی، سبز، قرمز) نگه می‌دارد، در حالی که Matplotlib، TensorFlow و بیشتر کتابخانه‌ها RGB را فرض می‌کنند. اگر تصویری را که OpenCV خوانده مستقیم با Matplotlib نمایش دهید، قرمز و آبی جابه‌جا می‌شوند. راه‌حل، برعکس کردن ترتیب کانال‌هاست:

plt.imshow(img[:, :, ::-1]) # BGR → RGB
  • تصویر رنگی آرایه‌ای با شکل (ارتفاع، عرض، 3) است و هر درایه عددی بین ۰ تا ۲۵۵.
  • با برش آرایه می‌شود بخشی از تصویر را جدا یا تغییر داد؛ آرایه‌ی Matplotlib را قبل از تغییر با np.array کپی کنید.
  • تصویر خاکستری یک کانال دارد؛ برای نمایشش cmap="gray" لازم است.
  • uint8 بالای ۲۵۵ دور می‌زند؛ قبل از جمع کردن پیکسل‌ها نوع داده را بزرگ کنید یا اول تقسیم کنید.
  • OpenCV ترتیب BGR دارد؛ برای نمایش با Matplotlib از [:, :, ::-1] استفاده کنید.

ادامه: دیتاست هدی، ارقام دست‌نویس فارسی

مقدمه‌ای بر یادگیری عمیق، آموزش رایگان و فارسی یادگیری عمیق.