সরাসরি পর্বে যান
পর্ব ১৮ · ৭৯-এর মধ্যে · মডিউল ৩

ছবি মানেই array — pixel, channel আর ছোট image processing

Images Are Arrays — Pixels, Channels, uint8 Traps, Slicing, Thresholds and a Hand-Written Convolution

Taught in Bangla, this lesson shows images as NumPy arrays: grayscale and RGB shapes, uint8 and its silent overflow, channels-last versus channels-first, brightness, contrast and BT.601 grayscale, slicing on the real scikit-learn digits, and a hand-written 3×3 convolution checked against SciPy. It finishes with memory sizes, real Pillow and imageio runs, JPEG loss, and privacy of photos of people.

এই পর্বে যা শিখবেন

  • একটি ডিজিটাল ছবির shape, dtype আর channel পড়া, আর channels-last ও channels-first-এর পার্থক্য বোঝা
  • NumPy দিয়ে শূন্য থেকে gradient আর জ্যামিতিক নকশা বানিয়ে plt.imshow দিয়ে দেখা
  • উজ্জ্বলতা, contrast, উল্টানো আর grayscale-কে array-র গণিত হিসেবে লেখা, uint8-এর উপচে পড়া এড়িয়ে
  • scikit-learn-এর আসল ৮×৮ অঙ্কের ছবিতে crop, flip, striding আর threshold চালানো
  • হাতে একটি 3×3 convolution লিখে blur আর edge বের করা, মেমরি হিসাব করা, আর Pillow দিয়ে আসল ফাইল পড়া

আগের পাঁচটি পর্বে NumPy-র মূল যন্ত্রগুলো এসেছে: পর্ব ১৩-এ array, shape আর dtype; পর্ব ১৪-এ indexing, slicing, view আর copy; পর্ব ১৫-এ vectorization আর broadcasting; পর্ব ১৬-এ ufunc, axis আর random; পর্ব ১৭-এ dot product আর matrix. এই পর্বে নতুন কোনো যন্ত্র নয়, সেই যন্ত্রগুলোর প্রথম বড় প্রয়োগ। ছবি এমন এক ধরনের ডেটা, যেখানে array-র প্রতিটি ধারণা চোখে দেখা যায়: ভুল slicing করলে ছবি উল্টে যায়, ভুল dtype হলে আকাশে কালো দাগ পড়ে। কোডগুলো পাতাতেই চলবে, প্রথমবার NumPy আর matplotlib নামতে কয়েক সেকেন্ড লাগতে পারে। ছবি কীভাবে বিট হয় (resolution, colour depth, compression) তার কম্পিউটার-বিজ্ঞানের দিকটি আছে কম্পিউটার ফান্ডামেন্টালস কোর্সের ছবি, শব্দ আর ভিডিও যেভাবে ডেটা হয় পর্বে।

১ · ছবি মানেই সংখ্যার grid — pixel, grayscale আর RGB

বড় কোনো জাতীয় অনুষ্ঠানে স্টেডিয়ামের গ্যালারিতে হাজার হাজার মানুষ রঙিন কার্ড তুলে একটি বিশাল ছবি বানান, এমন দৃশ্য হয়তো দেখেছেন। প্রত্যেকের হাতে একটিমাত্র রঙের কার্ড, কাছ থেকে দেখলে শুধু রঙের ছোপ, দূর থেকে দেখলে পুরো ছবি। ডিজিটাল ছবিও ঠিক তাই। প্রতিটি “আসন” একটি pixel (picture element), আর প্রতিটি pixel-এর রং কয়েকটি সংখ্যা দিয়ে লেখা। আসনগুলো সারি আর কলামে সাজানো, তাই পুরো ছবি একটি সংখ্যার টেবিল, মানে একটি NumPy array.

সাদা-কালো (grayscale) ছবি সবচেয়ে সরল: প্রতিটি pixel-এ একটি সংখ্যা, উজ্জ্বলতা। 0 মানে কালো, 255 মানে সাদা, মাঝের মানগুলো ধূসরের নানা ধাপ। H সারি (height, উচ্চতা) আর W কলাম (width, প্রস্থ) হলে ছবির shape (H, W). খেয়াল করুন ক্রমটি: আগে সারি, তারপর কলাম, ঠিক matrix-এর মতো। তাই a[y, x] লিখলে প্রথম সংখ্যা ওপর থেকে কত নম্বর সারি, দ্বিতীয়টি বাঁ থেকে কত নম্বর কলাম। গণিতের graph-এ আমরা আগে x লিখি; ছবির array-তে আগে y. আর y নিচের দিকে বাড়ে, ওপরের দিকে নয়।

রঙিন (RGB) ছবিতে প্রতিটি pixel-এ তিনটি সংখ্যা: লাল (Red), সবুজ (Green) আর নীল (Blue) আলো কতটা। [255, 0, 0] খাঁটি লাল, [255, 255, 0] হলুদ (লাল আর সবুজ আলো মিশে), [0, 0, 0] কালো, [255, 255, 255] সাদা। তিনটি সংখ্যা থাকার জায়গা একটি তৃতীয় মাত্রা, তাই shape (H, W, 3). শেষ মাত্রার প্রতিটি স্তরকে বলে একটি channel: a[:, :, 0] পুরো ছবির লাল channel, নিজেই একটি (H, W) grayscale ছবি। কিছু ফাইলে চতুর্থ একটি channel থাকে, alpha বা স্বচ্ছতা; তখন shape (H, W, 4), আর এটি একটি পরিচিত ফাঁদ (অংশ ৭)।

একটি ছবি, তিনটি shape — grayscale, channels-last আর channels-first

Grayscale (H, W), RGB channels-last (H, W, 3) and channels-first (3, H, W)

ছবির array-র shape grayscale: (H, W) = (3, 4) W = 4 কলাম (x) → H = 3 সারি (y) ↓ 0 60 120 255 30 90 180 240 10 128 200 250 a[1, 2] = 180 RGB, channels-last: (3, 4, 3) B G R channel = শেষ মাত্রা a[1, 2] = [245, 130, 20] channels-first: (3, 3, 4) a[0] = R a[1] = G a[2] = B রূপান্তর: a.transpose(2, 0, 1)
ছবি ১৮.১ — ওপরে ৩ × ৪ pixel-এর একটি grayscale ছবি; প্রতিটি ঘরের রং তার নিজের মান দিয়েই আঁকা। মাঝে একই আকারের রঙিন ছবি: তিনটি স্তর (R, G, B) একটির পেছনে আরেকটি, আর একটি pixel মানে তিনটি স্তরের একই ঘরের তিনটি সংখ্যা। নিচে একই সংখ্যাগুলো channel আগে রেখে সাজানো; তথ্য একই, শুধু অক্ষের ক্রম আলাদা।

প্রতিটি মান কেন 0–255 — uint8

বেশির ভাগ ছবির ফাইল প্রতিটি channel-এর মান রাখে এক বাইটে, মানে ৮ বিটে। ৮ বিটে ২৮ = ২৫৬টি আলাদা মান রাখা যায়, 0 থেকে 255. NumPy-তে এই dtype-এর নাম uint8: unsigned (ঋণাত্মক নেই) integer, 8 বিট। একটি pixel-এর তিন channel মিলে ২৪ বিট, তাই “24-bit colour” কথাটি শুনে থাকবেন; ২৫৬ × ২৫৬ × ২৫৬ ≈ ১ কোটি ৬৮ লাখ রং। uint8-এর লাভ মেমরিতে: float64-এর তুলনায় আট ভাগের এক ভাগ জায়গা। ক্ষতি গণিতে, যা অংশ ৩-এ দেখবেন। কিছু পেশাদার ক্যামেরা, চিকিৎসার ছবি (যেমন X-ray) বা উপগ্রহের ছবি ১০, ১২ বা ১৬ বিট ব্যবহার করে; তখন dtype uint16 আর পরিসর বড়। আর মেশিন লার্নিং-এর জন্য ছবিকে প্রায়ই float32-এ 0.0–1.0 পরিসরে আনা হয়, ২৫৫ দিয়ে ভাগ করে।

Channels-last বনাম channels-first

ছবি ১৮.১-এর নিচের অংশে যা দেখছেন, তা একই তথ্যের আরেক রকম সাজানো। Pillow, matplotlib, imageio আর OpenCV-এর মতো সাধারণ ছবির লাইব্রেরি channel রাখে শেষে: (H, W, C), যাকে বলে channels-last. Deep learning-এর কিছু লাইব্রেরি, যেমন PyTorch, সাধারণত channel রাখে আগে: (C, H, W), আর একসঙ্গে অনেক ছবি হলে সামনে আরেকটি মাত্রা N (ছবির সংখ্যা), মানে (N, C, H, W). TensorFlow/Keras-এর default সাধারণত channels-last, (N, H, W, C). এগুলো লাইব্রেরি আর সংস্করণভেদে বদলাতে পারে, তাই যে লাইব্রেরি ব্যবহার করবেন তার docs দেখে নিন। এক রূপ থেকে আরেক রূপে যেতে a.transpose(2, 0, 1) (বা np.moveaxis(a, -1, 0)) যথেষ্ট, কোনো সংখ্যা বদলায় না, শুধু অক্ষের ক্রম। এখানে deep learning শেখানো হবে না; শুধু মনে রাখুন, “shape মিলছে না” ধরনের error-এর একটি বড় উৎস এই ক্রম।

মূল ধারণা

ছবি = shape + dtype + অক্ষের অর্থ। একটি ছবির array হাতে পেলে প্রথম কাজ তিনটি প্রশ্ন: shape কত (২টি না ৩টি মাত্রা, শেষটি 3 না 4)? dtype কী (uint8, uint16 না float)? মানের পরিসর কত (0–255, না 0.0–1.0)? এই তিনটি না জেনে কোনো হিসাব শুরু করবেন না।

২ · NumPy দিয়ে নিজের ছবি বানানো — gradient আর নকশা

ছবি বোঝার সবচেয়ে ভালো উপায় নিজে একটি বানানো, pixel ধরে ধরে। দুটি ছবি বানাব। প্রথমটি একটি gradient (রঙের ঢাল): লাল channel বাঁ থেকে ডানে 0 থেকে 255 পর্যন্ত বাড়বে, নীল channel ওপর থেকে নিচে। দ্বিতীয়টি একটি জ্যামিতিক নকশা: ধূসর দাবার ছক, মাঝে একটি কমলা বৃত্ত। কোনো দেশের পতাকা নয়, শুধু আকার আর রঙের অনুশীলন।

দুটি কৌশল কাজে লাগবে। এক, broadcasting (পর্ব ১৫): np.linspace(0, 255, W) একটি দৈর্ঘ্য-W সারি, যা প্রতিটি সারিতে একই মান বসায়; আর [:, None] দিয়ে দৈর্ঘ্য-H একটি কলাম বানালে তা প্রতিটি কলামে একই মান বসায়। দুই, np.mgrid: এটি দুটি array দেয়, একটিতে প্রতিটি pixel-এর সারি নম্বর, আরেকটিতে কলাম নম্বর। তখন বৃত্তের শর্ত (y − 30)² + (x − 45)² ≤ 18² পুরো ছবির ওপর একবারে হিসাব হয়, আর ফল একটি boolean mask (পর্ব ১৪), যা দিয়ে বৃত্তের ভেতরের সব pixel একসঙ্গে রং করা যায়।

import numpy as np
import matplotlib.pyplot as plt

H, W = 60, 90                                   # 60 সারি, 90 কলাম
# ১) RGB gradient: লাল বাঁ থেকে ডানে, নীল ওপর থেকে নিচে বাড়ে
grad = np.zeros((H, W, 3), dtype=np.uint8)
grad[:, :, 0] = np.linspace(0, 255, W).astype(np.uint8)           # প্রতিটি সারিতে একই
grad[:, :, 2] = np.linspace(0, 255, H).astype(np.uint8)[:, None]  # প্রতিটি কলামে একই

# ২) জ্যামিতিক নকশা: দাবার ছক, ওপরে একটি কমলা বৃত্ত
yy, xx = np.mgrid[0:H, 0:W]                     # প্রতিটি pixel-এর সারি আর কলাম নম্বর
board = ((yy // 15 + xx // 15) % 2).astype(np.uint8) * 200 + 30   # 30 বা 230
pat = np.stack([board, board, board], axis=-1)  # ধূসর ছক, 3টি channel
circle = (yy - 30) ** 2 + (xx - 45) ** 2 <= 18 ** 2
pat[circle] = [245, 130, 20]                    # বৃত্তের ভেতরের সব pixel কমলা

print("grad:", grad.shape, grad.dtype, "nbytes =", grad.nbytes)
print("বাঁ-ওপরের কোণ :", grad[0, 0])
print("ডান-নিচের কোণ :", grad[-1, -1])
print("বৃত্তের pixel কতটি:", int(circle.sum()))
print("pat-এর মাঝের pixel:", pat[30, 45], " কোণের pixel:", pat[0, 0])

fig, ax = plt.subplots(1, 2, figsize=(7, 2.6))
ax[0].imshow(grad)
ax[0].set_title("RGB gradient (60 x 90)")
ax[1].imshow(pat)
ax[1].set_title("Checkerboard + circle")
for a in ax:
    a.set_xlabel("column (x)")
ax[0].set_ylabel("row (y)")
plt.tight_layout()
plt.show()

ব্রাউজারে (Pyodide) আসল আউটপুট, নিচে ছবিসহ:

grad: (60, 90, 3) uint8 nbytes = 16200
বাঁ-ওপরের কোণ : [0 0 0]
ডান-নিচের কোণ : [255   0 255]
বৃত্তের pixel কতটি: 1009
pat-এর মাঝের pixel: [245 130  20]  কোণের pixel: [30 30 30]

মেমরির হিসাব মিলিয়ে নিন: ৬০ × ৯০ × ৩ = ১৬,২০০টি মান, প্রতিটি এক বাইট, তাই nbytes = 16200. বাঁ-ওপরের কোণে লাল আর নীল দুটিই 0, তাই কালো; ডান-নিচের কোণে দুটিই 255, সবুজ 0, তাই লাল আর নীল মিশে বেগুনি (magenta). ডান-ওপরের কোণ খাঁটি লাল, বাঁ-নিচের কোণ খাঁটি নীল; ছবিতে মিলিয়ে দেখুন। বৃত্তে ১,০০৯টি pixel: ব্যাসার্ধ ১৮-এর বৃত্তের ক্ষেত্রফল π × ১৮² ≈ ১,০১৮, আর pixel গুনলে কাছাকাছি একটি পূর্ণসংখ্যা আসে, কারণ কিনারার pixel হয় পুরো ভেতরে, নয় পুরো বাইরে। দাবার ছকে (yy // 15 + xx // 15) % 2 প্রতি ১৫ pixel পরপর 0 আর 1 পাল্টায়, তারপর × 200 + 30 দিয়ে তা 30 (প্রায় কালো) বা 230 (প্রায় সাদা)। np.stack(..., axis=-1) একই ধূসর মান তিনটি channel-এ বসিয়েছে; R = G = B হলে রং সবসময় ধূসর। ছবির অক্ষে খেয়াল করুন, matplotlib সারি ০-কে ওপরে রাখে, ঠিক array-র মতো।

imshow dtype দেখে পরিসর ধরে নেয়। RGB ছবি uint8 হলে matplotlib ধরে নেয় 0–255; float হলে ধরে নেয় 0.0–1.0, আর তার বাইরের মান কেটে দিয়ে একটি সতর্কবার্তা দেয়। তাই 0–255 পরিসরের float ছবি দেখাতে গেলে প্রায় পুরো সাদা আসে। আর একটি মাত্র channel-এর (2D) array দিলে matplotlib রং বানায় একটি colormap দিয়ে, default-এ হলুদ-সবুজ-বেগুনি viridis, ধূসর নয়; ধূসর চাইলে cmap="gray", আর উজ্জ্বলতা নিজের মান অনুযায়ী দেখাতে vmin=0, vmax=255. নইলে matplotlib ছবির সবচেয়ে ছোট মানকে কালো আর সবচেয়ে বড়টিকে সাদা ধরে, ফলে প্রায় কালো একটি ছবিও উজ্জ্বল দেখাতে পারে।

৩ · উজ্জ্বলতা, contrast আর উল্টানো — uint8-এর উপচে পড়া

ছবি উজ্জ্বল করা মানে প্রতিটি মানে একটি সংখ্যা যোগ; ছবির ভাষায় brightness. Contrast বাড়ানো মানে মাঝের ধূসর (128) থেকে দূরত্বকে গুণ করে বাড়ানো, যাতে অন্ধকার আরও অন্ধকার আর উজ্জ্বল আরও উজ্জ্বল হয়। উল্টানো (negative) মানে 255 − মান: কালো সাদা হয়, সাদা কালো। তিনটিই একটি সূত্র, যা broadcasting-এর কারণে পুরো ছবিতে একবারে চলে:

উজ্জ্বল = a + b  ·  contrast = (a − 128) × k + 128  ·  উল্টানো = 255 − a

এখানে a ছবির array, b যোগ করার সংখ্যা, আর k contrast-এর গুণক (১-এর বেশি হলে contrast বাড়ে)। গণিত সোজা, ফাঁদ dtype-এ। uint8-এ ২৫৫-এর ওপরে কোনো মান নেই। চায়ের দোকানের পুরোনো তিন অঙ্কের মিটারের কথা ভাবুন: ৯৯৯-এর পর ০০০। uint8-এর মিটার ২৫৫-এর পর ০-তে ফেরে। একে বলে integer overflow বা wrap-around, আর NumPy array-র হিসাবে এটি ঘটে কোনো error বা সতর্কবার্তা ছাড়াই। নিচের ধাপে একটি মানের ভেতরে কী ঘটে দেখুন।

1a = np.array([250], dtype=np.uint8)2s = a + 10          # binary যোগ3# ৮ বিটের বাইরেরটি বাদ4w = a.astype(np.int16) + 105safe = np.clip(w, 0, 255).astype(np.uint8)
মান (Watch)
a (binary)
যোগফল
dtype
ফল
  1. শুরুর মান। 250 binary-তে 11111010, ঠিক ৮টি বিট। uint8-এর ঘরে আর কোনো বিট নেই।
  2. যোগ। 10 হলো 00001010. যোগফল 260 লিখতে ৯টি বিট লাগে: 1 00000100. কিন্তু ফলের dtype-ও uint8, কারণ NumPy 2-তে uint8 array আর একটি ছোট Python পূর্ণসংখ্যার হিসাবে array-র dtype-ই থাকে।
  3. উপচে পড়া। নবম বিটটি রাখার জায়গা নেই, তাই বাদ পড়ে। বাকি থাকে 00000100 = 4. গণিতে এটি 260 mod 256 = 4. উজ্জ্বল করতে গিয়ে প্রায়-সাদা pixel প্রায়-কালো হয়ে গেল, আর কোনো error নেই।
  4. নিরাপদ পথ, প্রথম ধাপ। যোগের আগে array-টিকে চওড়া dtype-এ নেওয়া হলো (int16, পরিসর −32768 থেকে 32767). এখন 250 + 10 = 260 ঠিকঠাক থাকে।
  5. নিরাপদ পথ, দ্বিতীয় ধাপ। clipping: np.clip 255-এর বেশি সব মানকে 255 আর 0-এর কম সব মানকে 0 বানায়। তারপর আবার uint8. ফল 255, মানে সাদা, যা উজ্জ্বল করার স্বাভাবিক অর্থ।

এবার পুরো একটি ধূসর ঢালে (ramp) একই কাজ করে চোখে দেখি: বাঁ থেকে ডানে 0 থেকে 254 পর্যন্ত মান, তার সঙ্গে 80 যোগ, একবার সরাসরি uint8-এ আর একবার clip করে। সঙ্গে একটি pixel-এ উজ্জ্বলতা, উল্টানো আর contrast-ও।

import numpy as np
import matplotlib.pyplot as plt

px = np.array([250, 128, 5], dtype=np.uint8)    # একটি pixel-এর R, G, B
print("px + 10           :", px + 10)            # uint8-এ উপচে পড়ে
wide = px.astype(np.int16) + 10                 # আগে চওড়া dtype
print("int16-এ + 10      :", wide)
print("clip করে uint8    :", np.clip(wide, 0, 255).astype(np.uint8))
print("উল্টানো 255 - px  :", 255 - px)
c = (px.astype(np.float32) - 128) * 1.5 + 128   # contrast 1.5 গুণ
print("contrast x1.5     :", np.clip(c, 0, 255).round().astype(np.uint8))

ramp = np.tile(np.arange(0, 256, 2, dtype=np.uint8), (24, 1))  # 24 x 128 ধূসর ঢাল
wrap = ramp + 80                                               # ভুল পথ
safe = np.clip(ramp.astype(np.int16) + 80, 0, 255).astype(np.uint8)
print("ramp", ramp.shape, ramp.dtype, "| ডান দিকের মান:", ramp[0, -3:])
print("wrap:", wrap[0, -3:], " safe:", safe[0, -3:])

fig, ax = plt.subplots(3, 1, figsize=(6, 3.2))
for a, im, t in zip(ax, [ramp, wrap, safe],
                    ["original", "+80 in uint8 (wraps)", "+80 with np.clip"]):
    a.imshow(im, cmap="gray", vmin=0, vmax=255)
    a.set_title(t, fontsize=9)
    a.set_axis_off()
plt.tight_layout()
plt.show()

ব্রাউজারে (Pyodide) আসল আউটপুট:

px + 10           : [  4 138  15]
int16-এ + 10      : [260 138  15]
clip করে uint8    : [255 138  15]
উল্টানো 255 - px  : [  5 127 250]
contrast x1.5     : [255 128   0]
ramp (24, 128) uint8 | ডান দিকের মান: [250 252 254]
wrap: [74 76 78]  safe: [255 255 255]

প্রথম লাইনে লাল channel-এর 250 হয়ে গেছে 4, বাকি দুটি ঠিক আছে, কারণ তারা সীমার ভেতরে। এক pixel-এর এই ভুল ছবিতে কেমন দেখায়, মাঝের ফিতাটি দেখাচ্ছে: ডান দিকের উজ্জ্বল অংশটি (মূলে 176 বা তার বেশি) হঠাৎ কালো থেকে আবার শুরু হয়েছে, wrap-এ 254 + 80 = 334 → 78. নিচের ফিতায় clip করা ফল: ডান দিক সাদায় স্থির, যা স্বাভাবিক। Contrast-এ 5 থেকে 128-এর দূরত্ব 123, দেড় গুণ হলে 184.5, তাই 128 − 184.5 = −56.5, যা clip হয়ে 0; 250 একইভাবে 311 → 255; আর 128 নিজের জায়গায়, কারণ মাঝের মান contrast-এ নড়ে না। উল্টানোতে 255 − 250 = 5. একটি সূক্ষ্ম কথা: 255 - px uint8-এ নিরাপদ, কারণ 0 থেকে 255-এর যেকোনো মানের জন্য ফল আবার 0–255-এর ভেতরে।

NumPy 2-এর নিয়ম, সংক্ষেপে। uint8 array-র সঙ্গে uint8-এর পরিসরের একটি Python পূর্ণসংখ্যা (যেমন 10) যোগ করলে ফল uint8-ই থাকে। পরিসরের বাইরের সংখ্যা (যেমন a + 300) দিলে NumPy 2 OverflowError দেয়। np.int16 ধরনের একটি NumPy সংখ্যা যোগ করলে ফল int16-এ ওঠে, আর Python float (যেমন a + 10.0) দিলে float64. একটি একক NumPy সংখ্যায় (np.uint8(250) + np.uint8(10)) উপচে পড়লে NumPy একটি RuntimeWarning দেয়, কিন্তু পুরো array-তে সাধারণত দেয় না। নিয়মগুলো এসেছে NumPy-র NEP 50 প্রস্তাব থেকে; NumPy 1-এর পুরোনো আচরণ কিছু ক্ষেত্রে আলাদা ছিল। লেখকের যন্ত্রে NumPy 2.5.4 দিয়ে এগুলো নিজে চালিয়ে দেখা।

৪ · রঙিন থেকে ধূসর — ওজন দেওয়া গড়

রঙিন ছবিকে grayscale করার সবচেয়ে সহজ চিন্তা: তিনটি channel-এর সাধারণ গড়। কাজ চলে, কিন্তু চোখে ঠিক লাগে না। কারণ মানুষের চোখ তিনটি রঙে সমান সংবেদনশীল নয়; একই শক্তির সবুজ আলো আমাদের কাছে লালের চেয়ে উজ্জ্বল, আর নীল সবচেয়ে কম উজ্জ্বল মনে হয়। তাই টেলিভিশনের যুগ থেকেই ধূসর মান হিসাব করা হয় ওজন দেওয়া গড় দিয়ে। সবচেয়ে পরিচিত ওজনগুলো ITU-R BT.601 নামের মানদণ্ডের (standard-definition টেলিভিশনের জন্য), যার শিকড় আরও পুরোনো NTSC রঙিন টেলিভিশনে:

Y = 0.299 R + 0.587 G + 0.114 B

এখানে Y হলো উজ্জ্বলতার মান, আর R, G, B তিনটি channel. তিনটি ওজনের যোগফল ঠিক ১, তাই সাদা (255, 255, 255) থেকে ধূসর মান 255-ই আসে। সূক্ষ্ম দিকটি সৎভাবে বলা দরকার: মানদণ্ডে এই ওজন বসানো হয় gamma-সংশোধিত মানের ওপর, আর তার ফলকে বলে luma, পদার্থবিদ্যার আসল আলোর উজ্জ্বলতা (luminance) নয়। HD টেলিভিশনের BT.709 মানদণ্ডে ওজন আলাদা (প্রায় 0.2126, 0.7152, 0.0722). Pillow-এর convert("L") ডকুমেন্টেশন অনুযায়ী BT.601-এর ওজনই ব্যবহার করে। ছোট কাজে এই পার্থক্য প্রায় চোখে পড়ে না; নিখুঁত রঙের কাজে (ছবি সম্পাদনা, চিকিৎসার ছবি) পড়ে। নিচের ধাপে অংশ ২-এর কমলা pixel ধরে হিসাবটি দেখুন।

1r, g, b = 245, 130, 202y  = 0.299 * r3y += 0.587 * g4y += 0.114 * b5gray = np.uint8(round(y))
মান (Watch)
লালের অংশ
সবুজের অংশ
নীলের অংশ
y
gray
  1. Pixel. কমলা রঙের একটি pixel: লাল 245, সবুজ 130, নীল 20. ধূসর মান হবে একটিমাত্র সংখ্যা।
  2. লালের অংশ। লালের ওজন 0.299, তাই 0.299 × 245 = 73.255.
  3. সবুজের অংশ। সবুজের ওজন সবচেয়ে বেশি, 0.587. সবুজ মান লালের প্রায় অর্ধেক হলেও তার অংশ 76.31, লালের চেয়েও বেশি। মোট এখন 149.565.
  4. নীলের অংশ। নীলের ওজন মাত্র 0.114, আর মানও কম: 2.28. মোট 151.845.
  5. Round আর uint8. 151.845-এর নিকটতম পূর্ণসংখ্যা 152. ধূসর ছবির এই pixel-এর মান 152, মাঝারি ধূসরের একটু ওপরে।
  6. তুলনা। তিনটির সাধারণ গড় নিলে আসত প্রায় 132, অনেক গাঢ়। ওজন দেওয়া গড় কমলাকে চোখে যতটা উজ্জ্বল লাগে, তার কাছাকাছি একটি ধূসর দেয়।

পুরো ছবিতে এই হিসাব এক লাইনে হয়, matrix-গুণের সাহায্যে (পর্ব ১৭): (H, W, 3) আকারের array-কে দৈর্ঘ্য-৩ ওজনের vector দিয়ে @ করলে শেষ মাত্রাটি গুণ-যোগে মিশে যায়, ফল (H, W):

w = np.array([0.299, 0.587, 0.114])
gray = np.round(img @ w).astype(np.uint8)     # (H, W, 3) @ (3,) → (H, W)
# একই কাজ: (img * w).sum(axis=-1)

অংশ ৭-এর সত্যিকারের run-এ এই এক লাইনের ফল Pillow-এর convert("L")-এর সঙ্গে প্রতিটি pixel-এ মিলেছে, সর্বোচ্চ পার্থক্য ছিল ১, যা আসে Pillow-এর পূর্ণসংখ্যার আনুমানিক হিসাব আর rounding-এর ছোট তফাত থেকে।

৫ · কাটা, উল্টানো, ছোট করা আর সাদা-কালো — আসল অঙ্কের ছবিতে

এবার বানানো ছবি নয়, আসল ডেটা। scikit-learn-এর সঙ্গে আসে load_digits: হাতে লেখা ০ থেকে ৯-এর ১,৭৯৭টি ছোট ছবি। scikit-learn-এর docs অনুযায়ী এগুলো UCI Machine Learning Repository-র “Optical Recognition of Handwritten Digits” ডেটাসেটের একটি অংশের কপি। মূল ৩২ × ৩২ সাদা-কালো ছবিকে ৪ × ৪ ব্লকে ভাগ করে প্রতিটি ব্লকের কালো বিন্দু গুনে ৮ × ৮ বানানো হয়েছিল, তাই প্রতিটি মান 0 থেকে 16. ডেটা package-এর ভেতরেই থাকে, তাই কোনো network লাগে না। প্রথমবার scikit-learn নামতে প্রায় ১০ সেকেন্ড লাগতে পারে।

এই অংশের চারটি কাজই পর্ব ১৪-এর indexing আর slicing, নতুন কিছু নয়, শুধু এখন অর্থ চোখে দেখা যায়। Slicing-এ a[start:stop:step] প্রতিটি অক্ষে আলাদা করে লেখা হয়, কমা দিয়ে।

কাজকোডকী ঘটে
Crop (কেটে নেওয়া)a[1:7, 2:6]সারি ১–৬, কলাম ২–৫; একটি জানালা
বাঁ-ডান উল্টানোa[:, ::-1]কলামের ক্রম উল্টো; আয়নার ছবি
ওপর-নিচ উল্টানোa[::-1, :]সারির ক্রম উল্টো
ছোট করা (striding)a[::2, ::2]প্রতি দুই সারি আর দুই কলামের একটি
৯০° ঘোরানোnp.rot90(a)ঘড়ির কাঁটার উল্টো দিকে
Threshold(a > t).astype(np.uint8)t-এর বেশি হলে 1, নইলে 0

উল্টানো, crop আর striding তিনটিই view দেয়, নতুন copy নয়: একই মেমরির সংখ্যা, শুধু অন্যভাবে পড়া। তাই ১২ মেগাপিক্সেলের ছবি উল্টাতে কোনো সময়ই প্রায় লাগে না। কিন্তু view বদলালে মূল ছবিও বদলায়; আলাদা রাখতে চাইলে .copy(). Downsampling বা ছবি ছোট করার সবচেয়ে সস্তা পথ striding, তবে এটি মাঝের pixel-গুলো ফেলে দেয়, ফলে সূক্ষ্ম নকশায় (যেমন শাড়ির চিকন ডোরা) অদ্ভুত ঢেউয়ের মতো দাগ (aliasing) আসতে পারে। ভালো পথ হলো প্রতিটি ব্লকের গড় নেওয়া, অনুশীলন ৪-এ দেখবেন। Thresholding একটি ধূসর ছবিকে দুই মানে ভাঙে: কাগজ আর কালি, পটভূমি আর বস্তু। নিচের কোডে চারটিই একসঙ্গে।

import numpy as np
import matplotlib.pyplot as plt
from sklearn.datasets import load_digits

digits = load_digits()                  # scikit-learn-এর সঙ্গে আসা ডেটা
print("images:", digits.images.shape, digits.images.dtype)
print("মানের পরিসর:", digits.images.min(), "থেকে", digits.images.max())

img = digits.images[0]                  # প্রথম ছবি, 8 x 8
print("label:", digits.target[0])
print(img.astype(int))

bw = (img > 8).astype(np.uint8)         # thresholding: 8-এর বেশি হলে 1
flip = img[:, ::-1]                     # বাঁ-ডান উল্টানো, শুধু slicing
crop = img[1:7, 2:6]                    # মাঝের 6 x 4 অংশ
print("সাদা-কালো:\n", bw)
small = img[::2, ::2]                   # striding: প্রতি দুইটির একটি
print("crop:", crop.shape, "| small:", small.shape)
print("flip কি view?", np.shares_memory(flip, img))

fig, ax = plt.subplots(1, 4, figsize=(8, 2.4))
for a, im, t in zip(ax, [img, bw, flip, crop],
                    ["digit (8x8)", "threshold > 8", "flipped", "crop 6x4"]):
    a.imshow(im, cmap="gray_r")
    a.set_title(t, fontsize=9)
    a.set_axis_off()
plt.tight_layout()
plt.show()

ব্রাউজারে (Pyodide) আসল আউটপুট:

images: (1797, 8, 8) float64
মানের পরিসর: 0.0 থেকে 16.0
label: 0
[[ 0  0  5 13  9  1  0  0]
 [ 0  0 13 15 10 15  5  0]
 [ 0  3 15  2  0 11  8  0]
 [ 0  4 12  0  0  8  8  0]
 [ 0  5  8  0  0  9  8  0]
 [ 0  4 11  0  1 12  7  0]
 [ 0  2 14  5 10 12  0  0]
 [ 0  0  6 13 10  0  0  0]]
সাদা-কালো:
 [[0 0 0 1 1 0 0 0]
 [0 0 1 1 1 1 0 0]
 [0 0 1 0 0 1 0 0]
 [0 0 1 0 0 0 0 0]
 [0 0 0 0 0 1 0 0]
 [0 0 1 0 0 1 0 0]
 [0 0 1 0 1 1 0 0]
 [0 0 0 1 1 0 0 0]]
crop: (6, 4) | small: (4, 4)
flip কি view? True

কয়েকটি জিনিস লক্ষ করুন। (১) পুরো ডেটা একটি 3D array, (1797, 8, 8): প্রথম মাত্রা ছবির নম্বর, অনেকটা অংশ ১-এর N-এর মতো। (২) dtype float64, uint8 নয়, আর পরিসর 0–16, 0–255 নয়; তাই অংশ ১-এর “তিনটি প্রশ্ন” সবসময় করতে হয়। (৩) সংখ্যার টেবিলেই “0”-টি দেখা যাচ্ছে: বড় মানগুলো একটি ডিম্বাকৃতি আঁকে, মাঝখানে শূন্য। (৪) Threshold 8 দিলে সাদা-কালো ছবিতে ডিম্বের ডান কিনারা সারি ৩-এ আর বাঁ কিনারা সারি ৪-এ হারিয়ে গেছে, কারণ সেখানে মান ঠিক 8, আর শর্ত “8-এর বেশি”। Threshold বাছাই একটি সিদ্ধান্ত, আর ভুল বাছাইয়ে আকার ভাঙে; বাস্তবে Otsu-র পদ্ধতির মতো কৌশল ডেটা দেখে threshold ঠিক করে। (৫) np.shares_memory বলছে উল্টানো ছবি মূলের মেমরিই ব্যবহার করছে, মানে view. ছবিতে cmap="gray_r" (উল্টো ধূসর) দেওয়া হয়েছে, যাতে বড় মান কালো, কাগজে কালির মতো।

এই ৬৪টি সংখ্যাই একটি feature vector. ছবিকে img.reshape(-1) (বা digits.data) দিয়ে ৬৪ দৈর্ঘ্যের একটি সারিতে খুলে দিলে প্রতিটি pixel একটি feature, আর পুরো ডেটাসেট একটি (1797, 64) টেবিল। পর্ব ৫৭-এ k-Nearest Neighbors ঠিক এভাবে দুটি ছবির “দূরত্ব” মেপে অঙ্ক চিনবে। তবে খেয়াল করুন, এভাবে খুলে দিলে কোন pixel কার পাশে ছিল সেই তথ্য model নিজে থেকে পায় না; পরের অংশের convolution ঠিক এই পাশাপাশি থাকার তথ্যটি কাজে লাগায়।

৬ · 3×3 convolution — ঝাপসা করা আর কিনারা খোঁজা

এতক্ষণের প্রতিটি কাজ ছিল এক pixel-এর কাজ: প্রতিটি মান নিজের ওপর নির্ভর করে বদলেছে। কিন্তু “এখানে কি একটি কিনারা আছে?” প্রশ্নের উত্তর এক pixel জানে না; পাশের pixel-গুলোর সঙ্গে তুলনা লাগে। Convolution সেই কাজের হাতিয়ার। একটি ছোট সংখ্যার টেবিল, যাকে বলে kernel বা filter, ছবির ওপর জানালার মতো সরতে থাকে। প্রতিটি অবস্থানে জানালার নিচের ৯টি pixel-কে kernel-এর ৯টি সংখ্যা দিয়ে গুণ করে যোগ করা হয়, আর সেই যোগফল হয় ফলের ছবির একটি pixel.

ক্রিকেট মাঠে পিচ পরীক্ষার কথা ভাবুন: আম্পায়ার পিচের একটি ছোট অংশে হাত রেখে দেখেন, তারপর একটু সরে আবার দেখেন। হাতের তালু হলো kernel, আর তার সংখ্যাগুলো ঠিক করে কোন বৈশিষ্ট্য খোঁজা হচ্ছে।

  • Blur (ঝাপসা): নয়টি ঘরেই 1/9. প্রতিটি নতুন pixel তার চারপাশের ৯টির গড়, তাই হঠাৎ পরিবর্তন মসৃণ হয়ে যায়, ছোট দানা (noise) কমে।
  • Sobel-x (খাড়া কিনারা): বাঁ কলামে −1, −2, −1; মাঝে 0; ডানে 1, 2, 1. সমান অংশে বাঁ আর ডান কাটাকাটি হয়ে ফল 0; বাঁ থেকে ডানে উজ্জ্বলতা বাড়লে ফল বড় ধনাত্মক, কমলে বড় ঋণাত্মক। এভাবে edge detection হয়। Sobel operator-এর নাম Irwin Sobel-এর নামে; প্রচলিত বিবরণ অনুযায়ী তিনি আর Gary Feldman ১৯৬৮ সালে Stanford-এ ধারণাটি উপস্থাপন করেছিলেন।

নিচের ধাপে অংশের কোডের ১২ × ১২ ছবিটি ধরুন: কালো জমিনে মাঝে একটি উজ্জ্বল বর্গ (মান 200), সারি আর কলাম ৩ থেকে ৮. Kernel বসানো হচ্ছে ফলের e[4, 1] ঘরের জন্য, মানে ছবির সারি ৪–৬ আর কলাম ১–৩-এর জানালায়, যেখানে বর্গের বাঁ কিনারা শুরু।

1patch = img[4:7, 1:4]2সারি ১: (-1)(0) + (0)(0) + (1)(200)3সারি ২: (-2)(0) + (0)(0) + (2)(200)4সারি ৩: (-1)(0) + (0)(0) + (1)(200)5blur = patch.sum() / 96patch = img[4:7, 3:6]   # দুই ঘর ডানে
মান (Watch)
patch
সারির ফল
চলতি যোগ
ফলের ঘর
  1. জানালা। ছবির সারি ৪–৬, কলাম ১–৩. বাঁ আর মাঝের কলাম কালো (0), ডান কলাম বর্গের অংশ (200).
  2. প্রথম সারি। Kernel-এর প্রথম সারি [-1, 0, 1] দিয়ে গুণ: 0 + 0 + 200 = 200.
  3. দ্বিতীয় সারি। মাঝের সারির ওজন দ্বিগুণ, [-2, 0, 2]: 0 + 0 + 400 = 400. চলতি যোগ 600.
  4. তৃতীয় সারি আর ফল। আবার 200, মোট 800 = 200 × (1 + 2 + 1). এটিই এই ছবিতে সম্ভাব্য সবচেয়ে বড় মান: এখানে একটি স্পষ্ট খাড়া কিনারা, বাঁয়ে অন্ধকার থেকে ডানে উজ্জ্বল।
  5. একই জানালায় blur. Blur kernel সব ঘরে 1/9, তাই ফল জানালার গড়: তিনটি 200 আর ছয়টি 0, যোগ 600, ভাগ 9 ≈ 66.7. কালো আর 200-এর মাঝে একটি মাঝারি ধূসর, মানে কিনারা নরম হলো।
  6. সমান অংশে। জানালা দুই ঘর ডানে সরিয়ে কলাম ৩–৫: নয়টিই 200. প্রতি সারিতে −200 + 0 + 200 = 0, ফল 0. পুরো বর্গের ভেতরে Sobel-x শূন্য দেয়; শুধু কিনারায় সাড়া দেয়।

এখন পুরো ছবিতে। কোডে দুটি loop আছে, কিন্তু তা pixel ধরে নয়, kernel-এর ৯টি ঘর ধরে: প্রতিটি ঘরের জন্য পুরো ছবির একটি সরানো slice-কে সেই ঘরের সংখ্যা দিয়ে গুণ করে যোগ করা হয়। ৯টি vectorized ধাপ, ছবি যত বড়ই হোক। ফল যাচাই করা হয়েছে SciPy-র তৈরি function দিয়ে।

import numpy as np
import matplotlib.pyplot as plt
from scipy.signal import correlate2d

img = np.zeros((12, 12))                 # কালো জমিন
img[3:9, 3:9] = 200                      # মাঝে একটি উজ্জ্বল বর্গ

def conv3x3(a, k):
    """3x3 kernel ঘুরিয়ে বসানো, 'valid': ফল (H-2) x (W-2)."""
    H, W = a.shape
    out = np.zeros((H - 2, W - 2))
    for i in range(3):                   # kernel-এর 9টি ঘর
        for j in range(3):
            out += k[i, j] * a[i:i + H - 2, j:j + W - 2]
    return out

blur = np.ones((3, 3)) / 9               # গড় করা kernel
sobel_x = np.array([[-1, 0, 1],
                    [-2, 0, 2],
                    [-1, 0, 1]])         # বাঁ থেকে ডানে মান বদল খোঁজে

b = conv3x3(img, blur)
e = conv3x3(img, sobel_x)
print("ফলের shape:", b.shape)
print("blur সারি 4:", b[4].round(1))
print("edge সারি 4:", e[4])
print("scipy-র সঙ্গে মেলে?", np.allclose(e, correlate2d(img, sobel_x, "valid")))

fig, ax = plt.subplots(1, 3, figsize=(7.5, 2.6))
for a, im, t in zip(ax, [img, b, e], ["input 12x12", "blur 3x3", "Sobel-x edges"]):
    a.imshow(im, cmap="gray")
    a.set_title(t, fontsize=9)
    a.set_axis_off()
plt.tight_layout()
plt.show()

ব্রাউজারে (Pyodide) আসল আউটপুট:

ফলের shape: (10, 10)
blur সারি 4: [  0.   66.7 133.3 200.  200.  200.  200.  133.3  66.7   0. ]
edge সারি 4: [   0.  800.  800.    0.    0.    0.    0. -800. -800.    0.]
scipy-র সঙ্গে মেলে? True

ফল ১২ × ১২ নয়, ১০ × ১০: kernel-এর পুরো জানালা ছবির ভেতরে রাখতে হলে প্রতিটি পাশে এক সারি আর এক কলাম কম পড়ে। একে বলে “valid” convolution; সাধারণ সূত্র, n × n ছবিতে k × k kernel দিলে ফল (n − k + 1) × (n − k + 1). কিনারার বাইরে শূন্য বসিয়ে (padding) আকার সমান রাখা যায়। Blur-এর সারিতে কিনারা এখন ধাপে ধাপে: 0, 66.7, 133.3, 200, মানে তিন ঘর জুড়ে নরম ঢাল। Edge-এর সারিতে বাঁ কিনারায় +800 (অন্ধকার থেকে উজ্জ্বল), ডান কিনারায় −800 (উজ্জ্বল থেকে অন্ধকার), মাঝে 0; ধাপের ছবিতে যে 800 হাতে পেয়েছিলেন, এটি সেই e[4, 1]. ছবিতে −800 কালো আর +800 সাদা দেখায়, মাঝের 0 ধূসর। Sobel-x শুধু খাড়া কিনারা দেখে; বর্গের ওপর-নিচের শোয়ানো কিনারা ধরতে kernel-টি ঘুরিয়ে নিতে হয় (Sobel-y), অনুশীলন ২-এ ভাববেন।

নামের একটি গোলমাল। গণিত আর signal processing-এ convolution মানে kernel-কে আগে ১৮০° উল্টে তারপর সরানো; না উল্টে সরানোর নাম cross-correlation. উপরের conv3x3 আসলে cross-correlation, তাই তা SciPy-র correlate2d-এর সঙ্গে মিলেছে। convolve2d দিলে Sobel-x-এর চিহ্ন উল্টে যেত। Deep learning-এর লাইব্রেরিগুলো সাধারণত cross-correlation-ই চালায় কিন্তু নাম দেয় convolution; kernel যেহেতু শেখা হয়, উল্টানো হলো কি না তাতে শেখার ক্ষমতা বদলায় না।

Convolutional network এই সংখ্যাগুলো নিজে শেখে

উপরের দুটি kernel মানুষ ভেবেচিন্তে বানিয়েছে। কয়েক দশক ধরে কম্পিউটার ভিশন এভাবেই চলেছে: বিশেষজ্ঞরা কিনারা, কোণ আর নকশা খোঁজার filter হাতে নকশা করতেন। Convolutional neural network (CNN) ধারণাটি উল্টে দেয়: kernel-এর ৯টি সংখ্যা শুরুতে প্রায় এলোমেলো, আর প্রশিক্ষণের সময় ডেটা থেকে ধাপে ধাপে ঠিক হয়। অনেক kernel পাশাপাশি আর অনেক স্তর পরপর থাকে, তাই প্রথম স্তর হয়তো কিনারা খোঁজে, পরের স্তর কিনারা মিলিয়ে কোণ, তার পরেরটি আরও বড় আকার। প্রকাশিত বিভিন্ন গবেষণায় দেখা গেছে, প্রশিক্ষিত network-এর প্রথম স্তরের অনেক kernel দেখতে হাতে বানানো edge filter-এর মতোই হয়। তবে network “কী দেখছে” তা মানুষের মতো বোঝে, এমন দাবি করা যায় না; এগুলো শেখা সংখ্যা, যা কখনো খুব অদ্ভুত কারণেও সঠিক উত্তর দেয়। ধারণাটির হাতে-কলমে রূপ পর্ব ৬৮-এ (neural network-এর ধারণা) আসবে, আর CNN-এর পুরো গল্প পরের একটি deep learning কোর্সের বিষয়।

৭ · মেমরি আর আসল ফাইল — H × W × C × বাইট, Pillow আর imageio

একটি ফোন-ছবি মেমরিতে কত বড়

ছবির array-র মেমরি হিসাব একটি গুণ মাত্র:

বাইট = H × W × C × (প্রতি মানের বাইট)

এখানে C হলো channel-এর সংখ্যা (grayscale-এ ১, RGB-তে ৩, RGBA-তে ৪), আর প্রতি মানের বাইট uint8-এ ১, float32-এ ৪, float64-এ ৮. ফোনের ক্যামেরার “১২ মেগাপিক্সেল” মানে প্রায় ১ কোটি ২০ লাখ pixel, যেমন ৪০০০ × ৩০০০. নিচের সংখ্যাগুলো লেখকের যন্ত্রে সূত্র দিয়েই হিসাব করা (অংশের শেষের run-এ ছাপা হয়েছে); এখানে ১ MiB = ১০,৪৮,৫৭৬ বাইট।

ছবিH × W × Cuint8float32
অঙ্কের ছবি (load_digits)8 × 8 × 1৬৪ বাইট২৫৬ বাইট
ছোট model-এর ইনপুট224 × 224 × 3১,৫০,৫২৮ বাইট ≈ ১৪৭ KiB≈ ৫৮৮ KiB
Full HD পর্দা1080 × 1920 × 3≈ ৫.৯ MiB≈ ২৩.৭ MiB
১২ মেগাপিক্সেল ফোন-ছবি3000 × 4000 × 3৩,৬০,০০,০০০ বাইট ≈ ৩৪.৩ MiB≈ ১৩৭.৩ MiB
৪৮ মেগাপিক্সেল ফোন-ছবি6000 × 8000 × 3≈ ১৩৭.৩ MiB≈ ৫৪৯.৩ MiB

গ্যালারিতে একই ছবির JPEG ফাইল হয়তো কয়েক MB, কারণ ফাইলটি সংকুচিত; খুললেই তা পুরো array. তাই ১০০টি ফোন-ছবি float32-এ একসঙ্গে মেমরিতে নিলে ১৩ GiB-এর বেশি লাগে, যা সাধারণ ল্যাপটপে আঁটে না। বাস্তবের তিনটি সমাধান: ছবি আগে ছোট করা (model প্রায়ই ২২৪ × ২২৪-এর মতো ছোট ইনপুট নেয়), একসঙ্গে সব না খুলে ছোট ছোট দলে (batch) পড়া, আর যতক্ষণ সম্ভব uint8-এ রাখা, float-এ রূপান্তর একেবারে শেষ মুহূর্তে।

আসল ফাইল পড়া — একটি সত্যিকারের run

ব্রাউজারের Python-এ ফাইল পড়ার চেয়ে নিজের যন্ত্রে করাটাই বাস্তব কাজের কাছাকাছি। তাই লেখক নিজের যন্ত্রে একটি venv-এ (পর্ব ৭) Python 3.14.4, NumPy 2.5.4, Pillow 13.0.0 আর imageio 2.38.1 (লেখার সময়ের সংস্করণ) নিয়ে একটি ছোট script চালিয়েছেন। ছবিটি script নিজেই বানায়: ১২০ × ১৬০-এর একটি সবুজ-নীল gradient আর একটি হলুদ বৃত্ত, কোনো মানুষের ছবি বা অন্যের তোলা ছবি নয়। তারপর PNG হিসেবে সেভ করে Pillow আর imageio দুটি দিয়েই আবার পড়ে।

import numpy as np
from PIL import Image
import imageio.v3 as iio

# ... 120 x 160 uint8 ছবি img বানানো (gradient + বৃত্ত) ...
Image.fromarray(img).save("pattern.png")

im = Image.open("pattern.png")
print("Pillow:", im.size, im.mode)
a = np.asarray(im)
print("array:", a.shape, a.dtype, "nbytes =", a.nbytes)
print("মাঝের pixel:", a[60, 80], " কোণের pixel:", a[0, 0])

b = iio.imread("pattern.png")
print("imageio:", b.shape, b.dtype, "একই?", np.array_equal(a, b))

g_pil = np.asarray(im.convert("L"))
g_own = np.round(a @ np.array([0.299, 0.587, 0.114])).astype(np.uint8)
diff = np.abs(g_pil.astype(int) - g_own.astype(int))
print("gray:", g_pil.shape, g_pil.dtype, "সর্বোচ্চ পার্থক্য:", diff.max())

সত্যিকারের run-এর আউটপুট (ফোল্ডারের পথ বদলে নিরপেক্ষ করা হয়েছে; শেষ দুটি লাইন একই script-এর মেমরি অংশ থেকে):

~/bazarghar-ai/images$ python make_and_read.py
ফাইলের আকার: 1055 bytes
Pillow: (160, 120) RGB
array: (120, 160, 3) uint8 nbytes = 57600
মাঝের pixel: [250 200  30]  কোণের pixel: [0 0 0]
imageio: (120, 160, 3) uint8 একই? True
gray: (120, 160) uint8 সর্বোচ্চ পার্থক্য: 1
12 MP: 36,000,000 bytes = 34.3 MiB (uint8), 137.3 MiB (float32)
48 MP: 144,000,000 bytes = 137.3 MiB (uint8), 549.3 MiB (float32)

সবচেয়ে গুরুত্বপূর্ণ লাইনটি দ্বিতীয়টি: Pillow-এর im.size দেয় (160, 120), মানে (প্রস্থ, উচ্চতা), অথচ NumPy array-র shape (120, 160, 3), মানে (উচ্চতা, প্রস্থ, channel). একই ছবি, উল্টো ক্রম। Pillow-এর resize((w, h))-ও প্রস্থ আগে নেয়। এই একটি ফাঁদ থেকে অনেক “ছবি কাত হয়ে গেছে” বা “shape মিলছে না” বাগ আসে। বাকি লাইনগুলো যা বলে: ৫৭,৬০০ বাইটের array-টি ডিস্কে মাত্র ১,০৫৫ বাইটের PNG, কারণ মসৃণ gradient খুব ভালো সংকুচিত হয়; Pillow আর imageio হুবহু একই array দিয়েছে; আর নিজের BT.601 হিসাব Pillow-এর convert("L")-এর সঙ্গে সর্বোচ্চ ১ মানের তফাতে মিলেছে।

একই যন্ত্রে আরেকটি ছোট run-এ একই ছবিকে JPEG (quality 90) হিসেবে সেভ করে আবার পড়া হয়েছিল:

~/bazarghar-ai/images$ python jpeg_check.py
JPEG: 3485 bytes (120, 160, 3) uint8
PNG বনাম JPEG সর্বোচ্চ পার্থক্য: 159  গড়: 1.93
RGBA হিসেবে: (120, 160, 4)

এখানে দুটি শিক্ষা। এক, JPEG একটি lossy compression: সেভ করে আবার পড়লে সংখ্যা বদলে যায়। গড় পার্থক্য প্রায় ২, কিন্তু হলুদ বৃত্ত আর নীল-সবুজ জমিনের তীক্ষ্ণ কিনারায় কোনো একটি মান ১৫৯ পর্যন্ত সরেছে; JPEG রঙের তথ্য সাধারণত উজ্জ্বলতার চেয়ে কম রেজোলিউশনে রাখে, তাই রঙের কিনারায় ভুল বেশি। মজার ব্যাপার, এই কৃত্রিম ছবিতে JPEG ফাইল PNG-এর চেয়েও বড় হয়েছে; ফটোগ্রাফে সাধারণত উল্টো। দুই, convert("RGBA") করলে চতুর্থ channel আসে; কিছু PNG ফাইল নিজেই RGBA, আর তখন (H, W, 3) ধরে নেওয়া কোড ভেঙে যায়। নিরাপদ অভ্যাস: Image.open(path).convert("RGB"), তারপর shape যাচাই।

মানুষের ছবি মানে ব্যক্তিগত তথ্য। মুখের ছবি দিয়ে মানুষকে চেনা যায়, তাই ছবিও personal data. ফোনে তোলা ছবির ফাইলে প্রায়ই EXIF metadata থাকে: তোলার সময়, ফোনের মডেল, আর লোকেশন চালু থাকলে GPS অবস্থান। একটি ছবি অনলাইনে দিলে বা কোনো ডেটাসেটে দিলে অজান্তে বাড়ির ঠিকানাও চলে যেতে পারে। কাজের নিয়ম: অনুমতি ছাড়া কারও ছবি সংগ্রহ বা ব্যবহার নয়; শেখার কাজে নিজের বানানো ছবি বা লাইসেন্স স্পষ্ট এমন খোলা ডেটাসেট; শেয়ার করার আগে metadata মুছে ফেলা (Pillow-এ শুধু pixel নিয়ে নতুন ছবি সেভ করলে EXIF সাধারণত যায় না, তবু যাচাই করুন); আর কারও ছবি কোনো অনলাইন AI টুলে পেস্ট না করা। ইউরোপের GDPR-এর মতো আইনে মানুষ চেনার জন্য ব্যবহৃত biometric তথ্য বিশেষ সুরক্ষার শ্রেণিতে পড়ে। গোপনীয়তার নকশা নিয়ে বিস্তারিত আছে সাইবার নিরাপত্তা কোর্সের Privacy engineering পর্বে, আর দায়িত্বশীল AI-এর অভ্যাস পর্ব ৭২-এ আসবে।

আরও গভীরে। ছবির ফাইল-ফরম্যাটের পেছনে আছে মানদণ্ড: JPEG-এর মূল মানদণ্ড ITU-T T.81 / ISO/IEC 10918-1 (১৯৯২), যা discrete cosine transform দিয়ে ৮ × ৮ ব্লকে তথ্য সংকুচিত করে; PNG হলো lossless, W3C-র সুপারিশ হিসেবে ১৯৯৬ সালে আসে। রঙের মান যে সরলরৈখিক নয়, তার কারণ gamma: sRGB-তে সংরক্ষিত 128 আসল আলোর অর্ধেক নয়, প্রায় এক-পঞ্চমাংশ। তাই নিখুঁত কাজে (দুটি ছবি মেশানো, সঠিক blur) আগে মানগুলোকে “linear” আলোয় নিয়ে হিসাব করে পরে ফেরানো হয়।

পরীক্ষা ও ইন্টারভিউতে (বিশ্ববিদ্যালয়, চাকরি বা ইন্টারভিউর পরীক্ষায় এ ধরনের প্রশ্ন সাধারণ):

  • ১০৮০ × ১৯২০ RGB uint8 ছবির NumPy shape আর মেমরি? (1080, 1920, 3); 1080 × 1920 × 3 = ৬২,২০,৮০০ বাইট, প্রায় ৫.৯ MiB.
  • uint8-এ 200 + 100 কত? 300 mod 256 = 44; নিরাপদ পথ চওড়া dtype, তারপর np.clip(…, 0, 255).
  • Grayscale-এ সাধারণ গড় কেন নয়? চোখ সবুজে বেশি, নীলে কম সংবেদনশীল; BT.601-এ 0.299, 0.587, 0.114 ওজন।
  • ২৮ × ২৮ ছবিতে padding ছাড়া 3 × 3 kernel দিলে ফলের আকার? (28 − 3 + 1) = ২৬, তাই ২৬ × ২৬.
  • Channels-first আর channels-last? (C, H, W) বনাম (H, W, C); একই তথ্য, অক্ষের ক্রম ভিন্ন; transpose(2, 0, 1) দিয়ে রূপান্তর।

৮ · AI-এর যুগে — AI সহকারীর লেখা ছবির কোড যাচাই

“এই ছবিটা একটু উজ্জ্বল করে grayscale করে দাও” লিখলে একটি AI সহকারী মুহূর্তে কোড দেবে, আর ছবি একটা আসবেও। সমস্যা হলো ছবির কোডের ভুল প্রায়ই চোখে ধরা পড়ে না: কয়েকটি pixel কালো হয়ে গেল, বা ছবি সামান্য কাত, বা model-এ পাঠানো ছবির channel উল্টো। AI সহকারীরা ছবির কাজে প্রায়ই যা করে:

  • uint8 ছবিতে সরাসরি img + 50 বা img * 1.2 লেখে, overflow বা dtype বদলের কথা না ভেবে; বা float ছবি 0–255 পরিসরে রেখে imshow-এ পাঠায়।
  • Pillow-এর size (প্রস্থ, উচ্চতা) আর NumPy-র shape (উচ্চতা, প্রস্থ) গুলিয়ে ফেলে; RGBA বা grayscale ফাইলেও তিনটি channel ধরে নেয়।
  • OpenCV-র ছবি (যা সাধারণত BGR ক্রমে channel রাখে) আর Pillow-এর RGB মিশিয়ে ফেলে, ফলে লাল আর নীল অদলবদল হয়; deep learning-এ পাঠানোর আগে channels-first-এ নিতে ভোলে।
  • পুরোনো বা বানানো API লেখে: সরিয়ে দেওয়া scipy.misc.imread, পুরোনো Image.ANTIALIAS (এখন Image.Resampling.LANCZOS), বা NumPy 2-এ সরানো np.float; warning চাপা দেয়, আর শুধু train ডেটায় মাপা ফল দেখায়।

ছবির কোড চাওয়ার সময় shape, dtype, পরিসর আর লাইব্রেরির সংস্করণ নিজেই বলে দিন, আর ফলের যাচাই চেয়ে নিন। খেয়াল করুন, প্রম্পটে কোনো আসল ছবি নেই, মানুষের ছবি তো নয়ই।

আমি Python 3.14, NumPy 2.x আর Pillow ব্যবহার করছি। আমার ছবিটি Image.open(path).convert("RGB") দিয়ে পড়া, তাই NumPy array-র shape (H, W, 3), dtype uint8, পরিসর 0–255. ১. ছবির উজ্জ্বলতা 40 বাড়ানো আর contrast 1.3 গুণ করার একটি function লেখো, যা overflow এড়াতে আগে চওড়া dtype নেবে আর শেষে np.clip করে uint8 ফেরত দেবে। ২. ITU-R BT.601 ওজন দিয়ে grayscale বানাও, আর ফলের shape ও dtype কী হবে তা লেখো। ৩. Pillow-এর size আর NumPy shape-এর ক্রমের পার্থক্য এক লাইনে মন্তব্যে লেখো। ৪. ৪×৪ আকারের একটি ছোট array-তে ৩টি assert দিয়ে test দাও, যার একটি 250 + 40 যেন 255 হয় তা যাচাই করবে। কোনো function বা parameter আমার সংস্করণে আছে কি না নিশ্চিত না হলে বলো, বানিয়ে লিখবে না।

AI-এর ছবির কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন, তারপর “→ উত্তর” চাপুন।

প্র ০১প্রথম ডিজিটাল ছবিগুলো কেমন ছিল, আর “বর্গাকার pixel” কি একমাত্র সম্ভাব্য পথ ছিল?

প্রচলিত ইতিহাস অনুযায়ী প্রথম স্ক্যান করা ডিজিটাল ছবিগুলোর একটি তৈরি হয়েছিল ১৯৫৭ সালে, যুক্তরাষ্ট্রের National Bureau of Standards-এ (আজকের NIST). Russell Kirsch আর তাঁর সহকর্মীরা SEAC নামের একটি প্রাথমিক কম্পিউটারের সঙ্গে একটি স্ক্যানার জুড়ে তাঁর শিশুপুত্রের একটি ছবি স্ক্যান করেন। ছবিটি ছিল প্রায় ১৭৬ × ১৭৬ pixel-এর, আর প্রতিটি pixel প্রথমে ছিল শুধু সাদা বা কালো, মানে এক বিট। আজকের ভাষায় এটি একটি (176, 176) আকারের boolean array, যা অংশ ৫-এর threshold করা অঙ্কের ছবির মতো। বিভিন্ন সূত্রে খুঁটিনাটি (আকার, ঠিক কোন মাস) সামান্য আলাদা, তবে মূল ঘটনা নিয়ে মতৈক্য আছে।

মজার ব্যাপার, Kirsch নিজে বহু বছর পরে বলেছিলেন যে pixel-কে বর্গাকার ধরা ছিল একটি সহজ কিন্তু “বোকা” সিদ্ধান্ত, আর ২০১০-এর দশকে তিনি পরিবর্তনশীল আকারের pixel দিয়ে ছবির আরও মসৃণ রূপ দেখানোর কাজ প্রকাশ করেন। প্রশ্নটি গভীর: বর্গাকার, সমান দূরত্বের grid কোনো প্রাকৃতিক নিয়ম নয়, একটি নকশার সিদ্ধান্ত। চোখের রেটিনায় আলোক-সংবেদী কোষ grid-এ সাজানো নয়, কেন্দ্রে ঘন, কিনারায় পাতলা। কিছু গবেষণা ষড়ভুজ (hexagonal) grid নিয়ে কাজ করেছে, কারণ সেখানে প্রতিটি pixel-এর ছয়টি প্রতিবেশী সমান দূরত্বে।

তবু বর্গাকার grid টিকে গেল, কারণ তা array-র সঙ্গে নিখুঁত মেলে: সারি আর কলাম মানে দুটি index, slicing মানে আয়তক্ষেত্র কাটা, convolution মানে সরানো slice. ক্যামেরার সেন্সর, পর্দা, ফাইল-ফরম্যাট আর NumPy একই grid ধরে নেয়, তাই পুরো ব্যবস্থাটি একসঙ্গে কাজ করে। সবচেয়ে “স্বাভাবিক” মনে হওয়া রূপও প্রায়ই একটি পুরোনো সিদ্ধান্ত, যা সবার সুবিধার বলে টিকে গেছে।

মূল উপলব্ধি: Pixel-এর grid ছবির একমাত্র সম্ভাব্য রূপ নয়, কিন্তু array-র গণিতের সঙ্গে এত ভালো মেলে যে পুরো ecosystem তার ওপর দাঁড়িয়েছে; প্রতিনিধিত্বের পছন্দই ঠিক করে কোন হিসাব সহজ।

প্র ০২Grayscale-এর ওজন 0.299, 0.587, 0.114 কোথা থেকে এলো, আর কেন একাধিক মানদণ্ড আছে?

এই সংখ্যাগুলোর গল্প টেলিভিশনের। ১৯৫০-এর দশকের শুরুতে যুক্তরাষ্ট্রে রঙিন টেলিভিশনের মানদণ্ড ঠিক করছিল NTSC নামের একটি কমিটি। একটি বড় শর্ত ছিল: নতুন রঙিন সম্প্রচার পুরোনো সাদা-কালো টেলিভিশনেও দেখা যেতে হবে। সমাধান হলো সংকেতকে দুই ভাগে পাঠানো: একটি উজ্জ্বলতার অংশ (যা সাদা-কালো সেট দেখায়) আর আলাদা রঙের অংশ। উজ্জ্বলতার অংশ বানাতে লাল, সবুজ আর নীলকে মেশাতে হয় এমন অনুপাতে, যাতে মানুষের চোখে ফলটি স্বাভাবিক লাগে। সেই সময়ের ফসফর আর চোখের সংবেদনশীলতার মাপ থেকে মোটামুটি এই ওজনগুলো আসে। পরে ১৯৮০-এর দশকের শুরুতে ডিজিটাল ভিডিওর জন্য CCIR (আজকের ITU-R) Recommendation 601 একই ওজন গ্রহণ করে, তাই নাম BT.601.

কেন সবুজের ওজন এত বেশি? চোখের রেটিনার শঙ্কু কোষগুলোর সংবেদনশীলতা মিলিয়ে মানুষের চোখ দিনের আলোয় সবুজ-হলুদ অঞ্চলের আলোতে সবচেয়ে সংবেদনশীল। নীল আলোর একই শক্তি আমাদের কাছে অনেক কম উজ্জ্বল লাগে। অংশ ৪-এর ধাপে দেখেছেন, এর ফলে কমলার ধূসর মান সাধারণ গড়ের চেয়ে অনেক উজ্জ্বল আসে।

কিন্তু ফসফর বদলায়, পর্দা বদলায়। HD টেলিভিশনের জন্য ITU-R BT.709 (১৯৯০ সালে প্রথম) আধুনিক পর্দার রঙের মৌলিক বিন্দু ধরে নতুন ওজন দেয়, প্রায় 0.2126, 0.7152, 0.0722; আর আরও নতুন BT.2020-এ ওজন আবার আলাদা। তাই কোন ছবির জন্য কোন ওজন “সঠিক”, তা নির্ভর করে ছবিটি কোন রঙের পরিসরে (color space) সংরক্ষিত। আরেকটি সূক্ষ্মতা হলো gamma: এই ওজন সাধারণত gamma-সংশোধিত মানে বসে, যার ফল luma; আসল আলোর luminance পেতে আগে মানগুলো linear করতে হয়। বাস্তবে বেশির ভাগ লাইব্রেরি সরলতার জন্য BT.601-এর ওজন সরাসরি uint8 মানে বসায়, আর train ও test-এ একই নিয়ম থাকলে মেশিন লার্নিং-এ সাধারণত সমস্যা হয় না।

মূল উপলব্ধি: “ধূসর” কোনো একক গাণিতিক সত্য নয়; তা চোখের জীববিদ্যা, পুরোনো প্রযুক্তির সীমা আর মানদণ্ডের ইতিহাসের মিশ্রণ, তাই ওজন বাছাইয়ের আগে ছবির উৎস জানা দরকার।

প্র ০৩হাতে নকশা করা filter থেকে শেখা filter — কম্পিউটার ভিশনে এই বদল কীভাবে এলো, আর এর দাম কী?

Convolution-এর ধারণা কম্পিউটার ভিশনের প্রায় শুরু থেকেই ছিল। অংশ ৬-এর Sobel operator ১৯৬০-এর দশকের শেষ দিকের। এর সমান্তরালে জীববিজ্ঞানে একটি প্রভাবশালী আবিষ্কার হয়েছিল: David Hubel আর Torsten Wiesel ১৯৫০-এর দশকের শেষ আর ১৯৬০-এর দশকের শুরুতে বিড়ালের দৃষ্টি-কর্টেক্সে এমন স্নায়ুকোষ পান, যারা নির্দিষ্ট কোণের কিনারায় সাড়া দেয়; এই কাজের জন্য তাঁরা ১৯৮১ সালে নোবেল পুরস্কারের অংশীদার হন। কিনারা খোঁজাকে তাই দৃষ্টির প্রথম ধাপ মনে করা স্বাভাবিক ছিল।

এই অনুপ্রেরণায় Kunihiko Fukushima ১৯৮০ সালে Neocognitron প্রস্তাব করেন, যেখানে স্তরে স্তরে স্থানীয় নকশা খোঁজা হয়। ১৯৮৯ সালের দিকে Yann LeCun আর সহকর্মীরা backpropagation দিয়ে convolution-এর kernel শেখানো একটি network দিয়ে হাতে লেখা ডাক-কোড (zip code) চেনার কাজ দেখান; পরে ১৯৯৮ সালের LeNet-5 প্রবন্ধটি বহুল পরিচিত। তবু প্রায় দুই দশক ধরে বড় ছবির কাজে হাতে বানানো feature (যেমন SIFT, HOG) আর তার ওপর সাধারণ classifier প্রধান পদ্ধতি ছিল। বদলটি স্পষ্ট হয় ২০১২ সালে, যখন AlexNet নামের একটি বড় CNN, GPU-তে প্রশিক্ষিত, ImageNet প্রতিযোগিতায় আগের পদ্ধতিগুলোর চেয়ে অনেক কম ভুল করে। AlexNet-এর প্রবন্ধেই দেখানো হয়েছিল যে প্রথম স্তরের শেখা kernel-গুলো দেখতে নানা কোণের কিনারা আর রঙের দাগের মতো, মানে মানুষ যা হাতে বানাত, ডেটা থেকে প্রায় তা-ই এসেছে।

দাম কী? প্রথমত, শেখা filter-এর জন্য প্রচুর লেবেল-দেওয়া ছবি আর গণনাশক্তি লাগে; ছোট ডেটায় হাতে বানানো পদ্ধতি এখনো কখনো ভালো বা যথেষ্ট। দ্বিতীয়ত, ব্যাখ্যা কঠিন: Sobel কী খোঁজে তা এক লাইনে বলা যায়, লাখো শেখা সংখ্যার network কেন একটি সিদ্ধান্ত নিল, তা বলা যায় না। তৃতীয়ত, শেখা filter ডেটার পক্ষপাতও শেখে; model কখনো বস্তুর বদলে পটভূমি বা জলছাপ দেখে উত্তর দেয় (shortcut learning), আর প্রায় অদৃশ্য পরিবর্তনেও (adversarial example) ভুল করতে পারে।

মূল উপলব্ধি: Convolution-এর গণিত একই রয়ে গেছে; বদলেছে kernel-এর সংখ্যা কে ঠিক করে। ডেটা থেকে শেখা অনেক বেশি শক্তিশালী, কিন্তু তার দাম ডেটা, গণনা আর ব্যাখ্যার স্বচ্ছতায়।

প্র ০৪JPEG-এর মতো lossy compression কি AI-এর ছবির ডেটাকে প্রভাবিত করে? “একই ছবি” আসলে কতটা একই?

JPEG-এর মানদণ্ড প্রকাশিত হয় ১৯৯২ সালে, আর আজও এটি পৃথিবীর সবচেয়ে বেশি ব্যবহৃত ছবির ফরম্যাটগুলোর একটি। এর মূল কৌশল মানুষের চোখের দুর্বলতা কাজে লাগানো: ছবিকে ৮ × ৮ ব্লকে ভাগ করে প্রতিটি ব্লককে discrete cosine transform দিয়ে “তরঙ্গের” মিশ্রণে প্রকাশ করা, তারপর সূক্ষ্ম তরঙ্গগুলোর তথ্য মোটা করে কেটে ফেলা, যা চোখে সহজে ধরা পড়ে না। সঙ্গে রঙের তথ্য প্রায়ই অর্ধেক রেজোলিউশনে রাখা হয়, কারণ চোখ রঙের চেয়ে উজ্জ্বলতার খুঁটিনাটি বেশি দেখে। অংশ ৭-এর run-এ দেখেছেন, এর ফলে সেভ করে আবার পড়লে সংখ্যা বদলায়, আর সবচেয়ে বেশি বদলায় রঙের তীক্ষ্ণ কিনারায়।

মানুষের চোখের জন্য যা অদৃশ্য, model-এর জন্য তা অদৃশ্য নয়। কয়েকটি বাস্তব সমস্যা দেখা দেয়। এক, train আর ব্যবহারের ডেটার অমিল: model শিখল উচ্চমানের ছবিতে, কিন্তু ব্যবহারের সময় পেল messaging অ্যাপে কয়েকবার পাঠানো, বারবার সংকুচিত ছবি। প্রকাশিত বিভিন্ন গবেষণায় দেখা গেছে compression, ঝাপসা হওয়া বা noise-এর মতো সাধারণ বিকৃতিতে ছবির classifier-এর নির্ভুলতা উল্লেখযোগ্যভাবে কমতে পারে। দুই, shortcut: যদি একটি শ্রেণির সব ছবি এক উৎস থেকে আসে (ধরুন একটি হাসপাতালের যন্ত্র, একটি নির্দিষ্ট JPEG সেটিং) আর অন্য শ্রেণির ছবি আরেক উৎস থেকে, model আসল বিষয় না দেখে compression-এর ছাপ দেখেই শ্রেণি অনুমান করতে পারে। তিন, পুনরুৎপাদনযোগ্যতা: একই ছবি ভিন্ন লাইব্রেরি বা সংস্করণে decode করলে কয়েকটি pixel-এ সামান্য ভিন্ন মান আসতে পারে, ফলে ফল পুরোপুরি মেলে না।

করণীয়? মূল ডেটা যতটা সম্ভব lossless (PNG বা কাঁচা ফরম্যাটে) রাখা, উৎস আর ফরম্যাট নথিতে লেখা, আর train-এর সময় ইচ্ছে করে নানা মাত্রার compression, ঝাপসা আর উজ্জ্বলতার বদল যোগ করা (data augmentation), যাতে model বাস্তবের বিকৃতি সহ্য করতে শেখে।

মূল উপলব্ধি: চোখে “একই” দুটি ছবি সংখ্যায় একই নয়; ছবির ডেটায় ফাইল-ফরম্যাট আর তার ইতিহাসও ডেটার অংশ, আর model সেটিও শিখে ফেলতে পারে।

প্র ০৫মানুষের মুখের ছবি নিয়ে কাজ করার আগে কী কী ভাবতে হয়? কেন “ছবি তো অনলাইনেই আছে” যুক্তি যথেষ্ট নয়?

এই পর্বের কোডে কোনো মানুষের ছবি নেই, আর তা ইচ্ছাকৃত। অংশ ১ থেকে ৬-এর প্রতিটি কৌশল একটি মুখের ছবিতেও সমানভাবে চলে, আর ঠিক এই কারণেই সাবধানতা দরকার। মুখ মানুষের সবচেয়ে স্থায়ী পরিচয়-চিহ্নগুলোর একটি: পাসওয়ার্ড বদলানো যায়, মুখ বদলানো যায় না। তাই ইউরোপের GDPR (২০১৮ সাল থেকে কার্যকর) মানুষকে চেনার উদ্দেশ্যে প্রক্রিয়া করা biometric তথ্যকে “বিশেষ শ্রেণির” ব্যক্তিগত তথ্য ধরে, যার ব্যবহারে কঠোর শর্ত আছে।

“ছবি তো অনলাইনেই আছে” যুক্তির দুর্বলতা জনসমক্ষে আলোচিত কয়েকটি ঘটনায় স্পষ্ট হয়েছে। একটি মুখ-চেনার কোম্পানি সামাজিক মাধ্যম আর ওয়েবসাইট থেকে বিপুল সংখ্যক ছবি সংগ্রহ করে একটি খোঁজার ব্যবস্থা বানানোর অভিযোগে ইউরোপের কয়েকটি দেশের তথ্য-সুরক্ষা কর্তৃপক্ষের জরিমানা আর নিষেধাজ্ঞার মুখে পড়েছে বলে সংবাদ আর কর্তৃপক্ষের প্রকাশিত সিদ্ধান্তে জানা যায়। গবেষণার জগতেও কয়েকটি বড় মুখের ডেটাসেট সম্মতি নিয়ে প্রশ্ন ওঠার পর সরিয়ে নেওয়া হয়েছে বলে জানা যায়। মূল কথা: কেউ একটি ছবি বন্ধুদের দেখানোর জন্য দিয়েছেন মানে এই নয় যে তিনি তা একটি মুখ-চেনার model-কে শেখানোর জন্য দিয়েছেন। উদ্দেশ্যের এই সীমা (purpose limitation) তথ্য-সুরক্ষার একটি মৌলিক নীতি।

এর বাইরে দুটি প্রযুক্তিগত দিক আছে। এক, ছবির ফাইলের EXIF metadata-তে GPS অবস্থান আর সময় থাকতে পারে, যা ছবির বিষয়ের চেয়েও বেশি কিছু প্রকাশ করে। দুই, মুখ-চেনা আর মুখ বিশ্লেষণের ব্যবস্থায় সব জনগোষ্ঠীর ক্ষেত্রে ভুলের হার সমান নয়; ২০১৮ সালের “Gender Shades” গবেষণায় (Joy Buolamwini আর Timnit Gebru) কয়েকটি বাণিজ্যিক লিঙ্গ-শ্রেণিকরণ ব্যবস্থায় গাঢ় ত্বকের নারীদের ক্ষেত্রে ভুলের হার অনেক বেশি পাওয়া যায়। যেখানে কোনো জনগোষ্ঠীর প্রতিনিধিত্বশীল ডেটা কম, সেখানে এই প্রশ্ন আরও জরুরি হতে পারে।

কাজের নিয়ম তাই সরল: সম্মতি আর স্পষ্ট উদ্দেশ্য ছাড়া মুখের ছবি নয়; দরকারের চেয়ে বেশি সংগ্রহ নয়; metadata মুছে রাখা; আর মডেলের ভুল কোন গোষ্ঠীর ওপর বেশি পড়ে, তা আলাদা করে মাপা।

মূল উপলব্ধি: ছবির array-র গণিত নিরপেক্ষ, কিন্তু ছবিতে যে মানুষ আছেন তাঁর অধিকার আছে; প্রযুক্তিগতভাবে সম্ভব হওয়া আর ব্যবহারের অনুমতি থাকা এক জিনিস নয়।

অনুশীলন

প্রথমটি হিসাবের, দ্বিতীয়টি আউটপুট অনুমানের, তৃতীয়টি নকশা ও সমালোচনার, শেষটি কোড লেখার।

  1. হিসাব: (ক) ১০৮০ × ১৯২০ আকারের একটি RGB ছবি uint8-এ কত বাইট, আর float32-এ কত MiB? (খ) ২২৪ × ২২৪ RGB-র ৩২টি ছবির একটি batch float32-এ কত MiB? (গ) (100, 150, 200) pixel-এর BT.601 ধূসর মান কত? (ঘ) ৬৪ × ৬৪ grayscale ছবিতে padding ছাড়া পরপর দুবার 3 × 3 convolution চালালে ফলের আকার কত?

    (ক) 1080 × 1920 × 3 = ৬২,২০,৮০০ বাইট (uint8, প্রায় ৫.৯৩ MiB). float32-এ চার গুণ: ২,৪৮,৮৩,২০০ বাইট = ২৪৮৮৩২০০ / ১০৪৮৫৭৬ ≈ ২৩.৭৩ MiB.

    (খ) 32 × 224 × 224 × 3 × 4 = ১,৯২,৬৭,৫৮৪ বাইট ≈ ১৮.৩৮ MiB. এটি শুধু ইনপুট; model-এর ভেতরের মধ্যবর্তী ফল এর চেয়ে অনেক বেশি জায়গা নিতে পারে।

    (গ) 0.299 × 100 + 0.587 × 150 + 0.114 × 200 = 29.9 + 88.05 + 22.8 = 140.75, round করে ১৪১.

    (ঘ) প্রথমবার 64 − 3 + 1 = ৬২, দ্বিতীয়বার 62 − 3 + 1 = ৬০; ফল ৬০ × ৬০. (সংখ্যাগুলো লেখকের যন্ত্রে Python দিয়ে মিলিয়ে দেখা।)

  2. আউটপুট অনুমান করুন: চালানোর আগে প্রতিটি লাইনের আউটপুট লিখুন। তারপর ভাবুন: অংশ ৬-এর ১২ × ১২ বর্গের ছবিতে Sobel-y (Sobel-x-এর transpose, sobel_x.T) চালালে ফলের সারি 4-এ কী আসবে?
    import numpy as np
    a = np.array([[10, 20, 30],
                  [40, 50, 60]], dtype=np.uint8)
    print(a.shape, a[:, ::-1].tolist())
    print(a[::-1, 0])
    print(255 - a[0])
    print(a[1] + 210)
    (2, 3) [[30, 20, 10], [60, 50, 40]]
    [40 10]
    [245 235 225]
    [250   4  14]

    প্রথম লাইনে shape (2, 3): ২ সারি, ৩ কলাম; [:, ::-1] প্রতিটি সারির কলাম উল্টায়। দ্বিতীয় লাইনে [::-1, 0] সারির ক্রম উল্টে প্রথম কলাম নেয়: 40, তারপর 10. তৃতীয় লাইনে উল্টানো, সব সীমার ভেতরে। শেষ লাইনটিই ফাঁদ: 40 + 210 = 250 ঠিক আছে, কিন্তু 50 + 210 = 260 → 4, আর 60 + 210 = 270 → 14, কারণ uint8 মান 256 দিয়ে ভাগশেষ নেয়। (লেখকের যন্ত্রে NumPy 2.5.4-এ চালিয়ে মিলিয়ে দেখা।)

    Sobel-y-এর kernel হলো [[-1, -2, -1], [0, 0, 0], [1, 2, 1]], যা ওপর থেকে নিচে উজ্জ্বলতার বদল খোঁজে। সারি 4-এর জানালা ছবির সারি ৪–৬, যা পুরোটাই বর্গের ভেতরে বা বাইরে খাড়াভাবে একই; ওপরের আর নিচের সারি সমান, তাই প্রতিটি ঘর 0. Sobel-y সাড়া দেবে শুধু বর্গের ওপরের আর নিচের কিনারার সারিতে (ফলের সারি ১–২ আর ৭–৮)।

  3. নকশা ও সমালোচনা: একটি AI সহকারী একটি ফোন-ছবিকে model-এর জন্য তৈরি করতে নিচের কোড দিয়েছে। অন্তত পাঁচটি সমস্যা চিহ্নিত করুন, আর একটি ভালো সংস্করণ লিখুন।
    img = Image.open("customer_selfie.jpg")
    a = np.array(img)
    h, w = img.size
    a = a + 40                        # একটু উজ্জ্বল
    gray = a.mean(axis=2)
    small = a[::10, ::10]
    x = small / 255
    plt.imshow(gray); plt.title("গ্রাহকের ছবি")
    • গোপনীয়তা: গ্রাহকের selfie একটি ব্যক্তিগত তথ্য; সম্মতি, উদ্দেশ্য আর EXIF metadata-র প্রশ্ন আগে। শেখার কাজে বানানো ছবি ব্যবহার করুন।
    • Overflow: uint8-এ a + 40, উজ্জ্বল অংশ কালো হয়ে যাবে। চওড়া dtype, তারপর np.clip.
    • উল্টো ক্রম: img.size দেয় (প্রস্থ, উচ্চতা), তাই h, w অদলবদল হয়েছে।
    • Channel ধরে নেওয়া: RGBA বা grayscale JPEG-এ axis=2 ভুল ফল বা error দেবে; আগে .convert("RGB"). আর সাধারণ গড় নয়, BT.601 ওজন।
    • ছোট করা: [::10, ::10] ৯৯% pixel ফেলে দেয়, aliasing আনে; ভালো পথ Pillow-এর resize উপযুক্ত resampling দিয়ে, বা ব্লকের গড়।
    • দেখানো: 2D ছবিতে cmap="gray" না দিলে viridis রং আসবে; matplotlib-এর শিরোনামে বাংলা অক্ষর ঠিকমতো না-ও আসতে পারে, ইংরেজি দিন।
    img = Image.open("pattern.png").convert("RGB")   # নিজের বানানো ছবি
    a = np.asarray(img)                               # (H, W, 3) uint8
    H, W = a.shape[:2]
    bright = np.clip(a.astype(np.int16) + 40, 0, 255).astype(np.uint8)
    gray = np.round(bright @ np.array([0.299, 0.587, 0.114])).astype(np.uint8)
    small = np.asarray(img.resize((W // 10, H // 10),
                                  Image.Resampling.LANCZOS))  # (প্রস্থ, উচ্চতা)
    x = small.astype(np.float32) / 255                # 0.0–1.0, শেষ মুহূর্তে
    plt.imshow(gray, cmap="gray", vmin=0, vmax=255); plt.title("Brightened gray")
  4. কোড লিখুন: একটি ২D array-কে ২ × ২ ব্লকের গড় নিয়ে অর্ধেক করার function avg_pool2(a) লিখুন, loop ছাড়া (ইঙ্গিত: reshape). x = np.arange(16).reshape(4, 4)-এ striding x[::2, ::2] আর আপনার function-এর ফল তুলনা করুন। কোনটি মূল তথ্য বেশি রাখে?
    def avg_pool2(a):
        H, W = a.shape
        a = a[:H - H % 2, :W - W % 2]          # বিজোড় হলে শেষ সারি/কলাম বাদ
        return a.reshape(H // 2, 2, W // 2, 2).mean(axis=(1, 3))
    
    x = np.arange(16).reshape(4, 4)
    print(x[::2, ::2])
    print(avg_pool2(x))

    আসল আউটপুট (লেখকের যন্ত্রে, NumPy 2.5.4):

    [[ 0  2]
     [ 8 10]]
    [[ 2.5  4.5]
     [10.5 12.5]]

    reshape(H // 2, 2, W // 2, 2) প্রতিটি সারি-জোড়া আর কলাম-জোড়াকে আলাদা অক্ষে নিয়ে যায়; তারপর অক্ষ ১ আর ৩ ধরে গড় নিলে প্রতিটি ২ × ২ ব্লকের গড়। Striding প্রতিটি ব্লকের শুধু বাঁ-ওপরের মানটি নেয় (0, 2, 8, 10), বাকি তিনটি ফেলে দেয়; গড় চারটিকেই হিসাবে রাখে (প্রথম ব্লক 0, 1, 4, 5-এর গড় 2.5). তাই গড় বেশি তথ্য রাখে আর aliasing কমায়। Convolutional network-এ এই ধারণাকে বলে average pooling; সর্বোচ্চ মান নিলে max pooling. শেষে .round().astype(np.uint8) দিলে আবার ছবির dtype-এ ফেরা যায়।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমনিজের বানানো একটি PNG ছবিকে একটি channels-first model-এর ইনপুট বানানোর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 📂 Pillow দিয়ে ফাইল খোলা আর .convert("RGB")
  2. 🔢 NumPy array-তে নিয়ে shape (H, W, 3) আর dtype uint8 যাচাই
  3. 📐 model-এর চাওয়া আকারে resize (প্রস্থ আগে দিয়ে)
  4. 🎚️ float32-এ রূপান্তর আর 255 দিয়ে ভাগ করে 0.0–1.0
  5. 🔄 transpose(2, 0, 1) দিয়ে (3, H, W)

১০৮০ পিক্সেল উঁচু আর ১৯২০ পিক্সেল চওড়া একটি RGB ছবি Pillow দিয়ে খুলে np.asarray করলে array-র shape কী হবে?

  • (1920, 1080)
  • (1080, 1920, 3)
  • (3, 1080, 1920)
  • (1920, 1080, 3)

NumPy 2-তে a = np.array([250], dtype=np.uint8); print(a + 10) — আউটপুট কী?

  • [260]
  • [255]
  • [4]
  • OverflowError

ITU-R BT.601-এর grayscale সূত্র Y = 0.299 R + 0.587 G + 0.114 B-এ সবুজের ওজন সবচেয়ে বেশি কেন?

  • মানুষের চোখ সবুজ আলোয় সবচেয়ে সংবেদনশীল
  • সবুজ channel ফাইলে বেশি বাইট নেয়
  • বেশির ভাগ ছবিতে সবুজ রং বেশি থাকে
  • তিনটি ওজনের যোগফল ১ রাখার জন্য

একটি ছবির array-র shape (100, 200, 3). a[::2, ::2] আর a[:, ::-1]-এর shape যথাক্রমে কী?

  • (50, 100, 1) আর (100, 200, 3)
  • (50, 100, 3) আর (100, 100, 3)
  • (50, 200, 3) আর (100, 200, 3)
  • (50, 100, 3) আর (100, 200, 3)

একটি সমান উজ্জ্বলতার (সব pixel 200) অংশে 3 × 3 Sobel-x kernel [[-1, 0, 1], [-2, 0, 2], [-1, 0, 1]] বসালে ফল কত?

  • 1800
  • 0
  • 800
  • 200

১২ মেগাপিক্সেলের (৩০০০ × ৪০০০) একটি RGB ছবি মেমরিতে float32 array হিসেবে খুললে প্রায় কত জায়গা লাগে?

  • প্রায় ১৩৭ MiB
  • প্রায় ৩৪ MiB
  • প্রায় ৩–৪ MB, JPEG ফাইলের সমান
  • প্রায় ২৭৫ MiB

২৮ × ২৮ আকারের একটি grayscale ছবিতে padding ছাড়া ("valid") একটি 3 × 3 convolution চালালে ফলের আকার কত?

  • 28 × 28
  • 25 × 25
  • 26 × 26
  • 9 × 9

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন, অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. ছবি = array: grayscale (H, W), RGB (H, W, 3), আগে সারি পরে কলাম; deep learning-এর কিছু লাইব্রেরি চায় (C, H, W), আর Pillow-এর size দেয় (প্রস্থ, উচ্চতা)।
  2. uint8 মানে 0–255, আর সীমা পেরোলে চুপচাপ ঘুরে আসে (250 + 10 = 4); যোগ-গুণের আগে চওড়া dtype, পরে np.clip.
  3. Grayscale ওজন দেওয়া গড় (BT.601: 0.299, 0.587, 0.114), এক লাইনে img @ w; উল্টানো 255 - a, contrast মাঝের 128 থেকে দূরত্বের গুণ।
  4. Crop, flip আর striding শুধু slicing, ফল view; threshold একটি তুলনা, আর তার মান বাছাই একটি সিদ্ধান্ত।
  5. 3×3 convolution: ৯টি গুণ আর একটি যোগ; blur গড়, Sobel কিনারা; ফলের আকার n − k + 1; CNN kernel-এর সংখ্যা ডেটা থেকে শেখে। মেমরি H × W × C × বাইট, আর মানুষের ছবি ব্যক্তিগত তথ্য।

পরের পর্বে: NumPy-র গতি। একটি array মেমরিতে কীভাবে বসে, stride আর view কেন প্রায় বিনা খরচে, dtype বাছাই কতটা জায়গা আর সময় বাঁচায়, আর কখন সাধারণ loop-ই ঠিক পথ।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান