Практика · Зображення очима компʼютера¶

Лекція: lecture.html · Домашнє: homework.md · Тест: quiz.html

Ми не завантажуватимемо жодного файлу з мережі. Замість цього намалюємо «фото товару з дошки оголошень» прямо з формул — тоді числа в тебе на екрані будуть точнісінько такі самі, як у лекції.

Що зробимо:

  1. згенеруємо синтетичне фото телефона й подивимось на його shape, dtype, min/max;
  2. роздрукуємо фрагмент 8 × 8 пікселів числами;
  3. розкладемо знімок на канали R, G, B;
  4. зробимо сірий двома способами й побачимо, наскільки вони розходяться;
  5. подивимось на HSV і виділимо зелений індикатор одним порогом;
  6. подивимось, як виглядає переплутаний порядок каналів (BGR);
  7. влаштуємо переповнення uint8 і вилікуємо його трьома способами;
  8. покрутимо яскравість і контраст, побудуємо гістограму;
  9. поміряємо, скільки важить фото в памʼяті й скільки — у JPEG.

0 · Що нам знадобиться¶

Три бібліотеки: numpy для масивів, cv2 (OpenCV) для операцій із зображеннями й matplotlib, щоб дивитись на результат очима.

In [1]:
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 · Малюємо фото товару¶

Функція нижче будує «фото телефона на стільниці» з чистої арифметики над масивом. Читається згори вниз: спершу фон, потім тінь, корпус, екран, зелений індикатор, відблиск і нарешті шум матриці.

Дві допоміжні функції рахують, наскільки далеко піксель від скругленого прямокутника — саме так ми отримуємо корпус із закругленими кутами без жодної бібліотеки малювання.

In [2]:
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 — саме в такому порядку ми фото й зібрали, тому показуємо як є.

In [3]:
plt.figure(figsize=(5, 3.8))
plt.imshow(photo)
plt.title("фото товару з оголошення")
plt.axis("off")
plt.show()

print("це синтетичне зображення, згенероване формулами вище")
No description has been provided for this image
це синтетичне зображення, згенероване формулами вище

3 · Паспорт масиву¶

Чотири речі, які варто подивитись у будь-якого зображення перед роботою: форма, тип даних, межі значень і скільки воно займає в памʼяті.

Зверни увагу на порядок у shape: спершу висота, потім ширина, потім канали.

In [4]:
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.

In [5]:
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 рівнів

Тепер той самий фрагмент — картинкою. Праворуч навмисно вимкнено згладжування, щоб було видно окремі квадратики-пікселі.

In [6]:
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 числа, надруковані вище")
No description has been provided for this image
64 квадратики праворуч — це рівно 64 числа, надруковані вище

5 · Три канали окремо¶

Кольорове зображення — це три чорно-білі знімки, накладені один на одного. Подивимось на кожен окремо й порахуємо середню яскравість каналів.

In [7]:
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
No description has been provided for this image

6 · Сірий двома способами¶

Тепер найважливіше порівняння розділу. Порахуємо яскравість одного пікселя — того, що всередині зеленого індикатора, — двома способами й руками.

In [8]:
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 рівнів яскравості

Порахуємо обидва варіанти для всього знімка й подивимось, де саме вони розходяться.

In [9]:
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 рівнів
No description has been provided for this image

Перевірка: наша формула проти бібліотечної¶

cv2.cvtColor рахує ту саму зважену суму, але в цілих числах — заради швидкості. Через це результат може відрізнятись від нашого щонайбільше на одиницю. Перевіримо це assert-ом: якщо колись розійдеться сильніше, зошит одразу впаде.

Функції OpenCV чекають на порядок каналів BGR, тому перед викликом перевертаємо осьову послідовність третьої осі.

In [10]:
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 — на трьох варіантах освітлення.

In [11]:
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
In [12]:
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 без перетворення, помилки не буде — просто кольори поміняються місцями.

In [13]:
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("сині ділянки стали помаранчевими, помаранчеві — синіми, зелені лишились собою")
No description has been provided for this image
сині ділянки стали помаранчевими, помаранчеві — синіми, зелені лишились собою

9 · Переповнення uint8¶

У беззнаковому восьмибітовому числі не існує значень більших за 255. Коли результат не влазить, він рахується за остачею від ділення на 256 — і жодної помилки при цьому не виникає.

In [14]:
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.

In [15]:
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
In [16]:
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 і почали рахуватись від нуля")
No description has been provided for this image
темні плями посередині — це пікселі, що перескочили через 255 і почали рахуватись від нуля

10 · Яскравість і контраст¶

Формула одна: новий = (старий − 128) × k + 128 + b. b рівномірно зсуває всю картинку, k розтягує її відносно середини діапазону.

Рахуємо у float32, щоб проміжний результат мав де поміститись, і обрізаємо лише в самому кінці.

In [17]:
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}")
No description has been provided for this image
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).

In [18]:
# ті самі три експозиції, що на схемі 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} пікселів")
No description has been provided for this image
притемнено ×0.45   межі  26… 96   уперлось у 255:      0 пікселів
як є               межі  59…215   уперлось у 255:      0 пікселів
пересвічено +90    межі 149…255   уперлось у 255:  47011 пікселів

12 · Скільки важить фото¶

Нестиснуте зображення важить рівно стільки, скільки в ньому чисел: одне число uint8 — один байт. Порівняємо це з JPEG (стиснення з втратами) і PNG (без втрат).

In [19]:
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 і намалюємо карту різниці: чим світліше, тим сильніше змінився піксель.

In [20]:
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 рівнів
No description has been provided for this image

Карта праворуч світиться по контурах корпусу й по краю зеленої смужки — саме там, де в зображенні різкі переходи. JPEG описує картинку плавними хвилями, і різкий край такими хвилями наближається погано: навколо нього зʼявляється «дзвеніння».


Завдання¶

🟢 Рівень 1¶

Постав рамку фрагмента в інше місце знімка — наприклад, на середину екрана (PATCH_ROW = 100, PATCH_COL = 150) — і роздрукуй числа знову. Зроблено, якщо ти можеш пояснити словами, чому в новому фрагменті немає стрибка на сто рівнів, а числа змінюються плавно.

🟡 Рівень 2¶

Напиши функцію to_gray_weighted(image), яка перетворює кольоровий масив на сірий без cv2, і перевір її assert-ом проти cv2.cvtColor із допуском в одну одиницю. Зроблено, якщо assert проходить і функція працює на будь-якому зображенні, а не лише на нашому.

🔴 Рівень 3¶

Реалізуй розтягування гістограми: знайди в сірому зображенні мінімум і максимум і перерахуй усі значення так, щоб мінімум став 0, а максимум — 255. Застосуй до притемненого варіанта. Зроблено, якщо гістограма після перетворення займає всю ширину від 0 до 255, а сама картинка стала контрастнішою — і ти можеш показати обидві гістограми поруч.