Практика · Зображення очима компʼютера¶
Лекція: lecture.html · Домашнє: homework.md · Тест: quiz.html
Ми не завантажуватимемо жодного файлу з мережі. Замість цього намалюємо «фото товару з дошки оголошень» прямо з формул — тоді числа в тебе на екрані будуть точнісінько такі самі, як у лекції.
Що зробимо:
- згенеруємо синтетичне фото телефона й подивимось на його
shape,dtype,min/max; - роздрукуємо фрагмент 8 × 8 пікселів числами;
- розкладемо знімок на канали R, G, B;
- зробимо сірий двома способами й побачимо, наскільки вони розходяться;
- подивимось на HSV і виділимо зелений індикатор одним порогом;
- подивимось, як виглядає переплутаний порядок каналів (BGR);
- влаштуємо переповнення
uint8і вилікуємо його трьома способами; - покрутимо яскравість і контраст, побудуємо гістограму;
- поміряємо, скільки важить фото в памʼяті й скільки — у JPEG.
0 · Що нам знадобиться¶
Три бібліотеки: numpy для масивів, cv2 (OpenCV) для операцій із зображеннями
й matplotlib, щоб дивитись на результат очима.
import numpy as np
import cv2
import matplotlib.pyplot as plt
%matplotlib inline
print("numpy ", np.__version__)
print("opencv ", cv2.__version__)
numpy 2.5.1 opencv 5.0.0
1 · Малюємо фото товару¶
Функція нижче будує «фото телефона на стільниці» з чистої арифметики над масивом. Читається згори вниз: спершу фон, потім тінь, корпус, екран, зелений індикатор, відблиск і нарешті шум матриці.
Дві допоміжні функції рахують, наскільки далеко піксель від скругленого прямокутника — саме так ми отримуємо корпус із закругленими кутами без жодної бібліотеки малювання.
IMAGE_HEIGHT, IMAGE_WIDTH = 240, 320
def rounded_gap(row_grid, col_grid, left, top, right, bottom, radius):
"""Квадрат відстані від пікселя до скругленого прямокутника.
Усередині прямокутника дає 0, тому одна й та сама функція годиться
і щоб заповнити фігуру, і щоб намалювати мʼяку тінь навколо неї.
"""
gap_x = np.maximum(np.maximum(left + radius - col_grid,
col_grid - (right - radius)), 0.0)
gap_y = np.maximum(np.maximum(top + radius - row_grid,
row_grid - (bottom - radius)), 0.0)
return gap_x * gap_x + gap_y * gap_y
def sensor_noise(height, width):
"""Детермінований «шум матриці».
Звичайний генератор випадкових чисел дав би різні картинки в різних
середовищах. Тут кожне значення однозначно визначається номером
елемента, тому знімок побайтово однаковий у всіх.
"""
index = np.arange(height * width * 3, dtype=np.int64)
value = (index + 1) * 16807 % 2147483647
value = value ^ (value >> 13)
value = value * 48271 % 2147483647
value = value ^ (value >> 17)
value = value * 16807 % 2147483647
return (value % 11 - 5).reshape(height, width, 3).astype(np.float64)
def make_phone_photo():
"""Синтетичне фото телефона: масив (240, 320, 3) типу uint8, порядок каналів RGB."""
row_grid, col_grid = np.mgrid[0:IMAGE_HEIGHT, 0:IMAGE_WIDTH].astype(np.float64)
down = row_grid / IMAGE_HEIGHT # 0 угорі, 1 унизу
right = col_grid / IMAGE_WIDTH # 0 ліворуч, 1 праворуч
photo = np.zeros((IMAGE_HEIGHT, IMAGE_WIDTH, 3), dtype=np.float64)
# стільниця: тепла бежева поверхня, трохи темніша знизу
photo[:, :, 0] = 214.0 - 26.0 * down + 10.0 * right
photo[:, :, 1] = 201.0 - 24.0 * down + 8.0 * right
photo[:, :, 2] = 182.0 - 20.0 * down + 6.0 * right
# тінь: той самий силует корпусу, зсунутий вниз-вправо і розмитий по відстані
distance = np.sqrt(rounded_gap(row_grid, col_grid, 107, 37, 235, 229, 18.0)) - 18.0
darkness = np.clip((18.0 - distance) / 18.0, 0.0, 1.0)
photo *= (1.0 - 0.30 * darkness)[:, :, None]
# корпус телефона
body = rounded_gap(row_grid, col_grid, 96, 24, 224, 216, 18.0) <= 18.0 ** 2
sheen = 10.0 * (1.0 - down) # пластик угорі ловить більше світла
photo[:, :, 0] = np.where(body, 58.0 + sheen, photo[:, :, 0])
photo[:, :, 1] = np.where(body, 64.0 + sheen, photo[:, :, 1])
photo[:, :, 2] = np.where(body, 74.0 + sheen, photo[:, :, 2])
# екран: заставка з діагональним переходом від синього до помаранчевого
screen = rounded_gap(row_grid, col_grid, 105, 33, 215, 207, 6.0) <= 6.0 ** 2
ramp = ((col_grid - 105.0) / 110.0 + (row_grid - 33.0) / 174.0) / 2.0
photo[:, :, 0] = np.where(screen, 26.0 + 206.0 * ramp, photo[:, :, 0])
photo[:, :, 1] = np.where(screen, 58.0 + 66.0 * ramp, photo[:, :, 1])
photo[:, :, 2] = np.where(screen, 170.0 - 128.0 * ramp, photo[:, :, 2])
# зелена смужка індикатора на екрані
indicator = rounded_gap(row_grid, col_grid, 117, 170, 203, 186, 5.0) <= 5.0 ** 2
photo[:, :, 0] = np.where(indicator, 40.0, photo[:, :, 0])
photo[:, :, 1] = np.where(indicator, 200.0, photo[:, :, 1])
photo[:, :, 2] = np.where(indicator, 90.0, photo[:, :, 2])
# відблиск на склі: світла смуга під кутом, тільки в межах екрана
band = (col_grid - 105.0) * 0.80 + (row_grid - 33.0) * 0.55
glare = np.clip(1.0 - np.abs(band - 74.0) / 30.0, 0.0, 1.0)
photo += (glare * glare * 95.0 * screen)[:, :, None]
photo += sensor_noise(IMAGE_HEIGHT, IMAGE_WIDTH)
# обрізаємо по межах типу і лише тоді переводимо в uint8
return np.clip(photo, 0, 255).astype(np.uint8)
photo = make_phone_photo()
print("фото готове:", photo.shape)
фото готове: (240, 320, 3)
2 · Дивимось очима¶
matplotlib чекає на порядок каналів R, G, B — саме в такому порядку ми фото й зібрали,
тому показуємо як є.
plt.figure(figsize=(5, 3.8))
plt.imshow(photo)
plt.title("фото товару з оголошення")
plt.axis("off")
plt.show()
print("це синтетичне зображення, згенероване формулами вище")
це синтетичне зображення, згенероване формулами вище
3 · Паспорт масиву¶
Чотири речі, які варто подивитись у будь-якого зображення перед роботою: форма, тип даних, межі значень і скільки воно займає в памʼяті.
Зверни увагу на порядок у shape: спершу висота, потім ширина, потім канали.
print("shape (висота, ширина, канали):", photo.shape)
print("dtype :", photo.dtype)
print("мінімум і максимум :", photo.min(), photo.max())
print("усього чисел :", photo.size)
print("памʼяті, байтів :", photo.nbytes)
print("памʼяті, КіБ :", photo.nbytes / 1024)
# одне число, один піксель і одна ціла площина — три різні зрізи того самого масиву
print()
print("photo[176, 150, 1] →", photo[176, 150, 1], " (зелений канал одного пікселя)")
print("photo[176, 150] →", photo[176, 150], " (усі три канали цього пікселя)")
print("photo[:, :, 1] →", photo[:, :, 1].shape, " (уся зелена площина)")
shape (висота, ширина, канали): (240, 320, 3) dtype : uint8 мінімум і максимум : 23 235 усього чисел : 230400 памʼяті, байтів : 230400 памʼяті, КіБ : 225.0 photo[176, 150, 1] → 203 (зелений канал одного пікселя) photo[176, 150] → [ 45 203 93] (усі три канали цього пікселя) photo[:, :, 1] → (240, 320) (уся зелена площина)
4 · Фрагмент 8 × 8 як таблиця чисел¶
Головна думка теми: картинка й таблиця чисел — це одне й те саме. Візьмемо квадрат 8 на 8 пікселів на межі корпусу й стільниці й покажемо його двічі.
Щоб на кожен піксель припадало одне число, переведемо фрагмент у відтінки сірого
за формулою з лекції: Y = 0.299·R + 0.587·G + 0.114·B.
red = photo[:, :, 0].astype(np.float64)
green = photo[:, :, 1].astype(np.float64)
blue = photo[:, :, 2].astype(np.float64)
# ваги враховують, що око найчутливіше до зеленого і майже не бачить синього
gray_weighted = np.round(0.299 * red + 0.587 * green + 0.114 * blue).astype(np.uint8)
PATCH_ROW, PATCH_COL = 58, 92 # ліва межа корпусу телефона
patch = gray_weighted[PATCH_ROW:PATCH_ROW + 8, PATCH_COL:PATCH_COL + 8]
print("той самий фрагмент числами:\n")
print(patch)
print()
print("ліві чотири колонки — стільниця, праві чотири — корпус")
print("стрибок у першому рядку між колонками 95 і 96:",
int(patch[0, 3]) - int(patch[0, 4]), "рівнів")
той самий фрагмент числами: [[188 183 187 181 72 70 72 73] [189 184 184 177 72 71 68 74] [187 185 182 178 68 70 72 70] [190 188 179 183 72 68 67 72] [192 188 181 175 69 72 69 67] [190 185 181 182 73 71 71 68] [191 184 180 181 72 67 68 72] [189 182 183 174 74 70 68 70]] ліві чотири колонки — стільниця, праві чотири — корпус стрибок у першому рядку між колонками 95 і 96: 109 рівнів
Тепер той самий фрагмент — картинкою. Праворуч навмисно вимкнено згладжування, щоб було видно окремі квадратики-пікселі.
figure, axes = plt.subplots(1, 2, figsize=(8, 3.6))
axes[0].imshow(photo)
axes[0].add_patch(plt.Rectangle((PATCH_COL, PATCH_ROW), 8, 8,
edgecolor="crimson", facecolor="none", linewidth=1.5))
axes[0].set_title("де саме взято фрагмент")
axes[0].axis("off")
axes[1].imshow(patch, cmap="gray", vmin=0, vmax=255, interpolation="nearest")
axes[1].set_title("фрагмент 8 × 8 у збільшенні")
axes[1].axis("off")
plt.tight_layout()
plt.show()
print("64 квадратики праворуч — це рівно 64 числа, надруковані вище")
64 квадратики праворуч — це рівно 64 числа, надруковані вище
5 · Три канали окремо¶
Кольорове зображення — це три чорно-білі знімки, накладені один на одного. Подивимось на кожен окремо й порахуємо середню яскравість каналів.
channel_names = ["червоний R", "зелений G", "синій B"]
for index, name in enumerate(channel_names):
plane = photo[:, :, index]
print(f"{name:<12} середнє {plane.mean():6.2f} межі {plane.min():3d}…{plane.max():3d}")
figure, axes = plt.subplots(1, 4, figsize=(12, 2.8))
axes[0].imshow(photo)
axes[0].set_title("оригінал")
axes[0].axis("off")
for index, name in enumerate(channel_names):
axes[index + 1].imshow(photo[:, :, index], cmap="gray", vmin=0, vmax=255)
axes[index + 1].set_title(name)
axes[index + 1].axis("off")
plt.tight_layout()
plt.show()
червоний R середнє 173.08 межі 23…233 зелений G середнє 159.54 межі 54…235 синій B середнє 150.47 межі 39…219
6 · Сірий двома способами¶
Тепер найважливіше порівняння розділу. Порахуємо яскравість одного пікселя — того, що всередині зеленого індикатора, — двома способами й руками.
INDICATOR_ROW, INDICATOR_COL = 176, 150
pixel = photo[INDICATOR_ROW, INDICATOR_COL]
pixel_red, pixel_green, pixel_blue = (int(pixel[0]), int(pixel[1]), int(pixel[2]))
print("піксель індикатора: R =", pixel_red, " G =", pixel_green, " B =", pixel_blue)
print()
simple_mean = (pixel_red + pixel_green + pixel_blue) / 3
print(f"просте середнє : ({pixel_red} + {pixel_green} + {pixel_blue}) / 3 = {simple_mean:.2f}")
print(f"за вагами ока : 0.299 × {pixel_red} = {0.299 * pixel_red:.3f}")
print(f" 0.587 × {pixel_green} = {0.587 * pixel_green:.3f}")
print(f" 0.114 × {pixel_blue} = {0.114 * pixel_blue:.3f}")
weighted = 0.299 * pixel_red + 0.587 * pixel_green + 0.114 * pixel_blue
print(f" сума = {weighted:.3f}")
print()
print("різниця між двома способами:", round(weighted - simple_mean, 2), "рівнів яскравості")
піксель індикатора: R = 45 G = 203 B = 93
просте середнє : (45 + 203 + 93) / 3 = 113.67
за вагами ока : 0.299 × 45 = 13.455
0.587 × 203 = 119.161
0.114 × 93 = 10.602
сума = 143.218
різниця між двома способами: 29.55 рівнів яскравості
Порахуємо обидва варіанти для всього знімка й подивимось, де саме вони розходяться.
gray_simple = np.round(photo.mean(axis=2)).astype(np.uint8)
difference = np.abs(gray_weighted.astype(np.int16) - gray_simple.astype(np.int16))
print("середнє розходження двох способів:", round(difference.mean(), 2), "рівнів")
print("найбільше розходження :", int(difference.max()), "рівнів")
figure, axes = plt.subplots(1, 3, figsize=(11, 2.9))
axes[0].imshow(gray_simple, cmap="gray", vmin=0, vmax=255)
axes[0].set_title("просте середнє")
axes[1].imshow(gray_weighted, cmap="gray", vmin=0, vmax=255)
axes[1].set_title("за вагами ока")
axes[2].imshow(difference, cmap="magma", vmin=0, vmax=35)
axes[2].set_title("де вони розходяться")
for axis in axes:
axis.axis("off")
plt.tight_layout()
plt.show()
середнє розходження двох способів: 4.78 рівнів найбільше розходження : 32 рівнів
Перевірка: наша формула проти бібліотечної¶
cv2.cvtColor рахує ту саму зважену суму, але в цілих числах — заради швидкості.
Через це результат може відрізнятись від нашого щонайбільше на одиницю.
Перевіримо це assert-ом: якщо колись розійдеться сильніше, зошит одразу впаде.
Функції OpenCV чекають на порядок каналів BGR, тому перед викликом перевертаємо осьову послідовність третьої осі.
photo_bgr = photo[:, :, ::-1].copy() # той самий знімок у порядку, звичному для OpenCV
gray_opencv = cv2.cvtColor(photo_bgr, cv2.COLOR_BGR2GRAY)
gap = np.abs(gray_weighted.astype(np.int16) - gray_opencv.astype(np.int16))
share = (gap > 0).mean() * 100
assert gap.max() <= 1, "наша формула розійшлася з OpenCV більше ніж на одиницю!"
print("✅ збігається: найбільша різниця з cv2.cvtColor —", int(gap.max()), "рівень")
print(f" і трапляється вона лише в {share:.2f}% пікселів")
✅ збігається: найбільша різниця з cv2.cvtColor — 1 рівень і трапляється вона лише в 0.02% пікселів
7 · HSV: один поріг замість трьох¶
У HSV колір розкладено на відтінок (H), насиченість (S) і світлість (V). Головна вигода: відтінок майже не залежить від того, скільки впало світла.
Перевіримо це чесним дослідом. Спробуємо знайти зелений індикатор двома правилами — одним порогом по відтінку й трьома порогами по RGB — на трьох варіантах освітлення.
hsv = cv2.cvtColor(photo_bgr, cv2.COLOR_BGR2HSV)
print("піксель індикатора у HSV:", hsv[INDICATOR_ROW, INDICATOR_COL],
" (H від 0 до 179, S і V від 0 до 255)")
# скільки пікселів індикатора є насправді — рахуємо з геометрії, якою його малювали
row_grid, col_grid = np.mgrid[0:IMAGE_HEIGHT, 0:IMAGE_WIDTH].astype(np.float64)
indicator_truth = rounded_gap(row_grid, col_grid, 117, 170, 203, 186, 5.0) <= 5.0 ** 2
print("пікселів індикатора насправді:", int(indicator_truth.sum()))
піксель індикатора у HSV: [ 69 198 203] (H від 0 до 179, S і V від 0 до 255) пікселів індикатора насправді: 1439
def found_by_hue(image_rgb):
"""Одна умова: відтінок потрапляє в зелений сектор кольорового кола."""
hue = cv2.cvtColor(image_rgb[:, :, ::-1].copy(), cv2.COLOR_BGR2HSV)[:, :, 0]
return ((hue >= 45) & (hue <= 75)).sum()
def found_by_rgb(image_rgb):
"""Три умови одночасно: зеленого багато, червоного мало, синього мало."""
red_plane = image_rgb[:, :, 0].astype(np.int16)
green_plane = image_rgb[:, :, 1].astype(np.int16)
blue_plane = image_rgb[:, :, 2].astype(np.int16)
return ((green_plane > 150) & (red_plane < 120) & (blue_plane < 150)).sum()
darker = np.clip(photo.astype(np.int16) * 0.55, 0, 255).astype(np.uint8)
lighter = np.clip(photo.astype(np.int16) + 60, 0, 255).astype(np.uint8)
print(f"{'освітлення':<18}{'один поріг H':>14}{'три пороги RGB':>17}")
for label, variant in [("як є", photo), ("притемнили ×0.55", darker), ("висвітлили +60", lighter)]:
print(f"{label:<18}{found_by_hue(variant):>14}{found_by_rgb(variant):>17}")
print()
print("треба знайти:", int(indicator_truth.sum()), "пікселів у кожному рядку")
освітлення один поріг H три пороги RGB як є 1439 1439 притемнили ×0.55 1439 0 висвітлили +60 1439 568 треба знайти: 1439 пікселів у кожному рядку
8 · Пастка BGR¶
OpenCV зберігає канали в порядку синій-зелений-червоний. Якщо віддати такий масив
у matplotlib без перетворення, помилки не буде — просто кольори поміняються місцями.
figure, axes = plt.subplots(1, 2, figsize=(8.5, 3.4))
axes[0].imshow(photo)
axes[0].set_title("правильно: RGB")
axes[1].imshow(photo_bgr) # той самий масив, але канали не в тому порядку
axes[1].set_title("масив OpenCV без cvtColor")
for axis in axes:
axis.axis("off")
plt.tight_layout()
plt.show()
print("сині ділянки стали помаранчевими, помаранчеві — синіми, зелені лишились собою")
сині ділянки стали помаранчевими, помаранчеві — синіми, зелені лишились собою
9 · Переповнення uint8¶
У беззнаковому восьмибітовому числі не існує значень більших за 255. Коли результат не влазить, він рахується за остачею від ділення на 256 — і жодної помилки при цьому не виникає.
small = np.array([250], dtype=np.uint8)
print("250 + 10 у uint8 :", (small + 10)[0], " ← 260 не існує, лишилось 260 − 256")
print("250 + 10 через cv2.add :", cv2.add(small, np.array([10], dtype=np.uint8),
dtype=cv2.CV_8U)[0][0])
print("250 + 10 через int16 :", (small.astype(np.int16) + 10)[0])
print("250 + 10 через int16 і clip :",
np.clip(small.astype(np.int16) + 10, 0, 255).astype(np.uint8)[0])
250 + 10 у uint8 : 4 ← 260 не існує, лишилось 260 − 256 250 + 10 через cv2.add : 255 250 + 10 через int16 : 260 250 + 10 через int16 і clip : 255
Тепер те саме на всьому знімку. Додамо 40 до кожного числа й порахуємо, скільки з них вилетить за 255.
BRIGHTNESS_STEP = 40
overflow_count = int((photo.astype(np.int16) + BRIGHTNESS_STEP > 255).sum())
print("чисел усього :", photo.size)
print("вилетить за 255 :", overflow_count)
print("це частка :", round(overflow_count / photo.size * 100, 2), "%")
brighter_broken = photo + BRIGHTNESS_STEP # так робити не можна
brighter_opencv = cv2.add(photo, np.full(photo.shape, BRIGHTNESS_STEP, dtype=np.uint8))
brighter_manual = np.clip(photo.astype(np.int16) + BRIGHTNESS_STEP,
0, 255).astype(np.uint8)
assert np.array_equal(brighter_opencv, brighter_manual), \
"cv2.add і ручне обрізання мали дати однаковий результат!"
print()
print("✅ cv2.add і np.clip дають однаковий масив — обидва обрізають по 255")
чисел усього : 230400 вилетить за 255 : 8237 це частка : 3.58 % ✅ cv2.add і np.clip дають однаковий масив — обидва обрізають по 255
figure, axes = plt.subplots(1, 3, figsize=(11.5, 2.9))
axes[0].imshow(photo)
axes[0].set_title("як було")
axes[1].imshow(brighter_broken)
axes[1].set_title("photo + 40 у uint8")
axes[2].imshow(brighter_manual)
axes[2].set_title("з обрізанням")
for axis in axes:
axis.axis("off")
plt.tight_layout()
plt.show()
print("темні плями посередині — це пікселі, що перескочили через 255 і почали рахуватись від нуля")
темні плями посередині — це пікселі, що перескочили через 255 і почали рахуватись від нуля
10 · Яскравість і контраст¶
Формула одна: новий = (старий − 128) × k + 128 + b.
b рівномірно зсуває всю картинку, k розтягує її відносно середини діапазону.
Рахуємо у float32, щоб проміжний результат мав де поміститись, і обрізаємо
лише в самому кінці.
def adjust(image, contrast=1.0, brightness=0):
"""Яскравість і контраст однією формулою, без ризику переповнення."""
result = (image.astype(np.float32) - 128.0) * contrast + 128.0 + brightness
return np.clip(result, 0, 255).astype(np.uint8)
settings = [(1.0, 0), (1.0, 60), (1.8, 0), (0.5, 0)]
figure, axes = plt.subplots(1, 4, figsize=(12, 2.8))
for axis, (contrast, brightness) in zip(axes, settings):
adjusted = adjust(photo, contrast, brightness)
axis.imshow(adjusted)
axis.set_title(f"k = {contrast}, b = {brightness}\nсереднє {adjusted.mean():.0f}")
axis.axis("off")
plt.tight_layout()
plt.show()
for contrast, brightness in settings:
adjusted = adjust(photo, contrast, brightness)
print(f"k = {contrast:<4} b = {brightness:<4} → середнє {adjusted.mean():6.1f}, "
f"межі {adjusted.min():3d}…{adjusted.max():3d}")
k = 1.0 b = 0 → середнє 161.0, межі 23…235 k = 1.0 b = 60 → середнє 218.2, межі 83…255 k = 1.8 b = 0 → середнє 183.8, межі 0…255 k = 0.5 b = 0 → середнє 144.3, межі 75…181
11 · Гістограма¶
Гістограма показує, скільки в кадрі пікселів кожної яскравості. За нею одразу видно, чи знімок недоекспонований (уся маса зліва) чи пересвічений (стовпчик на 255).
# ті самі три експозиції, що на схемі 4 в лекції: множення і зсув
darkened = np.clip(gray_weighted * 0.45, 0, 255).astype(np.uint8)
overexposed = np.clip(gray_weighted.astype(np.int16) + 90, 0, 255).astype(np.uint8)
variants = [
("притемнено ×0.45", darkened),
("як є", gray_weighted),
("пересвічено +90", overexposed),
]
figure, axes = plt.subplots(2, 3, figsize=(11, 5))
for column, (label, image) in enumerate(variants):
axes[0, column].imshow(image, cmap="gray", vmin=0, vmax=255)
axes[0, column].set_title(label)
axes[0, column].axis("off")
axes[1, column].hist(image.ravel(), bins=64, range=(0, 255), color="#0f766e")
axes[1, column].set_xlim(0, 255)
axes[1, column].set_xlabel("яскравість")
axes[1, column].set_ylabel("пікселів")
plt.tight_layout()
plt.show()
for label, image in variants:
print(f"{label:<18} межі {image.min():3d}…{image.max():3d} "
f"уперлось у 255: {int((image == 255).sum()):6d} пікселів")
притемнено ×0.45 межі 26… 96 уперлось у 255: 0 пікселів як є межі 59…215 уперлось у 255: 0 пікселів пересвічено +90 межі 149…255 уперлось у 255: 47011 пікселів
12 · Скільки важить фото¶
Нестиснуте зображення важить рівно стільки, скільки в ньому чисел: одне число uint8 —
один байт. Порівняємо це з JPEG (стиснення з втратами) і PNG (без втрат).
raw_bytes = photo.nbytes
print(f"нестиснуте: {raw_bytes} Б = {raw_bytes / 1024:.1f} КіБ")
print()
print(f"{'формат':<14}{'байтів':>10}{'стиснення':>12}{'середня зміна':>16}{'найгірша':>10}")
for quality in (95, 90, 75, 20):
ok, buffer = cv2.imencode(".jpg", photo_bgr, [cv2.IMWRITE_JPEG_QUALITY, quality])
restored = cv2.imdecode(buffer, cv2.IMREAD_COLOR)[:, :, ::-1]
change = np.abs(restored.astype(np.int16) - photo.astype(np.int16))
print(f"JPEG q={quality:<8}{len(buffer):>10}{raw_bytes / len(buffer):>11.1f}×"
f"{change.mean():>16.2f}{int(change.max()):>10}")
ok, png_buffer = cv2.imencode(".png", photo_bgr)
png_restored = cv2.imdecode(png_buffer, cv2.IMREAD_COLOR)[:, :, ::-1]
print(f"{'PNG':<14}{len(png_buffer):>10}{raw_bytes / len(png_buffer):>11.1f}×"
f"{np.abs(png_restored.astype(np.int16) - photo.astype(np.int16)).mean():>16.2f}"
f"{int(np.abs(png_restored.astype(np.int16) - photo.astype(np.int16)).max()):>10}")
нестиснуте: 230400 Б = 225.0 КіБ формат байтів стиснення середня зміна найгірша JPEG q=95 21618 10.7× 2.84 93 JPEG q=90 12367 18.6× 3.01 84 JPEG q=75 5893 39.1× 3.28 83 JPEG q=20 2773 83.1× 4.68 101 PNG 128388 1.8× 0.00 0
Подивимось, де саме JPEG псує зображення. Візьмемо якість 75 і намалюємо карту різниці: чим світліше, тим сильніше змінився піксель.
ok, buffer = cv2.imencode(".jpg", photo_bgr, [cv2.IMWRITE_JPEG_QUALITY, 75])
restored = cv2.imdecode(buffer, cv2.IMREAD_COLOR)[:, :, ::-1]
change_map = np.abs(restored.astype(np.int16) - photo.astype(np.int16)).max(axis=2)
worst_row, worst_col = np.unravel_index(change_map.argmax(), change_map.shape)
print("найгірший піксель:", (int(worst_row), int(worst_col)),
"— різниця", int(change_map.max()), "рівнів")
figure, axes = plt.subplots(1, 3, figsize=(11.5, 2.9))
axes[0].imshow(photo)
axes[0].set_title("оригінал")
axes[1].imshow(restored)
axes[1].set_title("після JPEG q=75")
axes[2].imshow(change_map, cmap="inferno")
axes[2].set_title("де саме змінилось")
for axis in axes:
axis.axis("off")
plt.tight_layout()
plt.show()
найгірший піксель: (171, 201) — різниця 83 рівнів
Карта праворуч світиться по контурах корпусу й по краю зеленої смужки — саме там, де в зображенні різкі переходи. JPEG описує картинку плавними хвилями, і різкий край такими хвилями наближається погано: навколо нього зʼявляється «дзвеніння».
Завдання¶
🟢 Рівень 1¶
Постав рамку фрагмента в інше місце знімка — наприклад, на середину екрана
(PATCH_ROW = 100, PATCH_COL = 150) — і роздрукуй числа знову.
Зроблено, якщо ти можеш пояснити словами, чому в новому фрагменті немає
стрибка на сто рівнів, а числа змінюються плавно.
🟡 Рівень 2¶
Напиши функцію to_gray_weighted(image), яка перетворює кольоровий масив на сірий
без cv2, і перевір її assert-ом проти cv2.cvtColor із допуском в одну одиницю.
Зроблено, якщо assert проходить і функція працює на будь-якому зображенні,
а не лише на нашому.
🔴 Рівень 3¶
Реалізуй розтягування гістограми: знайди в сірому зображенні мінімум і максимум і перерахуй усі значення так, щоб мінімум став 0, а максимум — 255. Застосуй до притемненого варіанта. Зроблено, якщо гістограма після перетворення займає всю ширину від 0 до 255, а сама картинка стала контрастнішою — і ти можеш показати обидві гістограми поруч.