Практика · Карта задач компʼютерного зору¶

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

Одне синтетичне фото товару з дошки оголошень — два телефони на столі — і пʼять різних розміток для нього. Мета: побачити руками, що задачі компʼютерного зору відрізняються не картинкою на вході, а формою відповіді на виході.

Що зробимо:

  1. згенеруємо сцену з двома телефонами — без жодних завантажень із мережі;
  2. створимо для неї розмітку всіх пʼяти типів: мітку, рамки, семантичну маску, instance-маски й «згенероване» зображення;
  3. порівняємо, скільки місця займає кожна розмітка;
  4. порахуємо IoU двох рамок руками й перевіримо формулу прямим підрахунком пікселів;
  5. порахуємо IoU масок і побачимо, що він суворіший за IoU рамок;
  6. покажемо різницю семантичної та instance-розмітки числом — через cv2.connectedComponents;
  7. оцінимо, скільки часу зайняла б розмітка сотні таких фото кожним зі способів.
In [1]:
import numpy as np
import cv2
import matplotlib.pyplot as plt

print("numpy", np.__version__, "· OpenCV", cv2.__version__)
numpy 2.5.1 · OpenCV 5.0.0

1 · Сцена¶

Фото беремо не з мережі, а малюємо самі: так у всіх, хто запустить зошит, будуть однакові числа, і їх можна звірити з лекцією.

Кожен телефон описуємо пʼятьма числами: центр, ширина, висота й кут повороту. Далі нам знадобиться відповідь на питання «чи належить піксель телефону» — і ось тут є проста хитрість. Прямокутник повернутий, і перевіряти «всередині чи ні» напряму незручно. Тому повертаємо не прямокутник, а саму точку — у зворотний бік. Після такого повороту прямокутник стає звичайним, зі сторонами вздовж осей, і перевірка зводиться до двох порівнянь: чи не завелика координата по ширині й по висоті.

In [2]:
IMAGE_WIDTH = 360
IMAGE_HEIGHT = 240

phone_left = {"cx": 100, "cy": 124, "w": 66, "h": 120, "angle": -26}
phone_right = {"cx": 170, "cy": 126, "w": 66, "h": 118, "angle": 14}


def phone_mask(phone, width=IMAGE_WIDTH, height=IMAGE_HEIGHT):
    """Маска телефона: True там, де піксель належить телефону."""
    angle = np.radians(phone["angle"])
    # координати кожного пікселя картинки одразу двома масивами
    ys, xs = np.mgrid[0:height, 0:width]
    dx = xs - phone["cx"]
    dy = ys - phone["cy"]
    # повертаємо точку у власні осі телефона — тоді прямокутник стає звичайним
    local_x = dx * np.cos(angle) + dy * np.sin(angle)
    local_y = -dx * np.sin(angle) + dy * np.cos(angle)
    return (np.abs(local_x) <= phone["w"] / 2) & (np.abs(local_y) <= phone["h"] / 2)


mask_left = phone_mask(phone_left)
mask_right = phone_mask(phone_right)

print("форма маски:", mask_left.shape, "тип:", mask_left.dtype)
print("пікселів у лівому телефоні: ", int(mask_left.sum()))
print("пікселів у правому телефоні:", int(mask_right.sum()))
print("пікселів, які накривають обидва:", int((mask_left & mask_right).sum()))
форма маски: (240, 360) тип: bool
пікселів у лівому телефоні:  7915
пікселів у правому телефоні: 7803
пікселів, які накривають обидва: 806

Тепер саме «фото»: світла стільниця й два темні апарати. Це звичайний масив висота × ширина × 3 — по три числа яскравості на кожен піксель.

In [3]:
def render_photo():
    """Синтетичне фото товару: стільниця, на ній два телефони."""
    photo = np.full((IMAGE_HEIGHT, IMAGE_WIDTH, 3), (207, 199, 184), dtype=np.uint8)
    # ледь помітні смуги, щоб фон не був мертвою заливкою
    photo[::26] = (195, 186, 169)
    # правий телефон малюємо другим — він лежить зверху й перекриває лівий
    photo[mask_left] = (60, 68, 80)
    photo[mask_right] = (47, 54, 64)
    return photo


photo = render_photo()
print("фото:", photo.shape, photo.dtype, "→", photo.size, "чисел на вході")

plt.figure(figsize=(5, 3.4))
plt.imshow(photo)
plt.axis("off")
plt.title("вхід: одне фото 360 × 240")
plt.show()
фото: (240, 360, 3) uint8 → 259200 чисел на вході
No description has been provided for this image

2 · Розмітка №1 — одна мітка на все фото¶

Найдешевша відповідь: одне число. Класифікація нічого не каже ні про кількість предметів, ні про їхнє місце.

In [4]:
CLASS_NAMES = ["стіл", "телефон"]
image_label = 1

print("розмітка №1 — мітка на все зображення:", CLASS_NAMES[image_label])
print("чисел у відповіді:", 1)
розмітка №1 — мітка на все зображення: телефон
чисел у відповіді: 1

3 · Розмітка №2 — рамки¶

Рамка (bounding box) — це чотири числа: координати лівого верхнього й правого нижнього кутів. Домовимось, що x2 і y2 — це перший піксель за рамкою: тоді ширина рахується просто як x2 - x1, без плутанини з одиницею.

In [5]:
def bounding_box(mask):
    """Найменший прямокутник, що містить усі пікселі маски."""
    rows_with_object = np.any(mask, axis=1)
    cols_with_object = np.any(mask, axis=0)
    y1, y2 = np.where(rows_with_object)[0][[0, -1]]
    x1, x2 = np.where(cols_with_object)[0][[0, -1]]
    return int(x1), int(y1), int(x2) + 1, int(y2) + 1


def box_area(box):
    return (box[2] - box[0]) * (box[3] - box[1])


box_left = bounding_box(mask_left)
box_right = bounding_box(mask_right)
boxes = [(image_label, *box_left), (image_label, *box_right)]

print("розмітка №2 — список рамок:")
for class_id, x1, y1, x2, y2 in boxes:
    print(f"  {CLASS_NAMES[class_id]}: ({x1}, {y1}) — ({x2}, {y2}),"
          f" площа {(x2 - x1) * (y2 - y1)} пікселів")
print("чисел у відповіді:", len(boxes) * 5, "(клас плюс чотири координати на кожен предмет)")
розмітка №2 — список рамок:
  телефон: (45, 56) — (156, 193), площа 15207 пікселів
  телефон: (124, 61) — (217, 192), площа 12183 пікселів
чисел у відповіді: 10 (клас плюс чотири координати на кожен предмет)

Рамка прямокутна, а телефон лежить під кутом — отже, всередину рамки неминуче потрапляє стільниця. Порахуймо, скільки саме.

In [6]:
left_box_area = box_area(box_left)
left_phone_pixels = int(mask_left.sum())
table_inside_box = left_box_area - left_phone_pixels

print("площа рамки лівого телефона:", left_box_area, "пікселів")
print("з них сам телефон:          ", left_phone_pixels)
print("з них стільниця:            ", table_inside_box)
print(f"частка зайвого в рамці: {100 * table_inside_box / left_box_area:.1f} %")
площа рамки лівого телефона: 15207 пікселів
з них сам телефон:           7915
з них стільниця:             7292
частка зайвого в рамці: 48.0 %

4 · Розмітка №3 — семантична маска¶

Тепер мітку отримує кожен піксель: 0 — стіл, 1 — телефон. Розмір відповіді підскакує з десяти чисел до розміру всього зображення.

In [7]:
semantic_mask = np.zeros((IMAGE_HEIGHT, IMAGE_WIDTH), dtype=np.uint8)
semantic_mask[mask_left | mask_right] = 1

phone_pixels_total = int((semantic_mask == 1).sum())
print("розмітка №3 — семантична маска:", semantic_mask.shape, semantic_mask.dtype)
print("пікселів класу «телефон»:", phone_pixels_total,
      f"({100 * phone_pixels_total / semantic_mask.size:.1f} % кадру)")
print("чисел у відповіді:", semantic_mask.size)
розмітка №3 — семантична маска: (240, 360) uint8
пікселів класу «телефон»: 14912 (17.3 % кадру)
чисел у відповіді: 86400

5 · Розмітка №4 — instance¶

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

In [8]:
instance_map = np.zeros((IMAGE_HEIGHT, IMAGE_WIDTH), dtype=np.uint8)
instance_map[mask_left] = 1
# правий записуємо після лівого — там, де вони перекриваються, лишиться номер 2
instance_map[mask_right] = 2

instance_masks = [(image_label, instance_map == 1), (image_label, instance_map == 2)]

print("розмітка №4 — instance:", len(instance_masks), "окремі маски")
for number, (class_id, mask) in enumerate(instance_masks, start=1):
    print(f"  предмет {number}: {CLASS_NAMES[class_id]}, видимих пікселів {int(mask.sum())}")
print("чисел у відповіді:", instance_map.size)
розмітка №4 — instance: 2 окремі маски
  предмет 1: телефон, видимих пікселів 7109
  предмет 2: телефон, видимих пікселів 7803
чисел у відповіді: 86400

6 · Розмітка №5 — генерація¶

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

In [9]:
studio_phone = {"cx": 180, "cy": 120, "w": 76, "h": 140, "angle": 0}
studio_photo = np.full((IMAGE_HEIGHT, IMAGE_WIDTH, 3), (242, 240, 236), dtype=np.uint8)
studio_photo[phone_mask(studio_phone)] = (47, 54, 64)

print("розмітка №5 — вихід сам є зображенням:", studio_photo.shape)
print("чисел у відповіді:", studio_photo.size)

plt.figure(figsize=(5, 3.4))
plt.imshow(studio_photo)
plt.axis("off")
plt.title("вихід генерації: нове зображення")
plt.show()
розмітка №5 — вихід сам є зображенням:
 (240, 360, 3)
чисел у відповіді: 259200
No description has been provided for this image

7 · Скільки місця займає кожна розмітка¶

Вхід у всіх пʼяти випадках однаковий. Порівняймо виходи.

In [10]:
annotations = [
    ("мітка", np.array([image_label], dtype=np.uint8)),
    ("рамки", np.array(boxes, dtype=np.int32)),
    ("семантична маска", semantic_mask),
    ("instance-карта", instance_map),
    ("нове зображення", studio_photo),
]

print(f"{'розмітка':<20}{'чисел':>10}{'байт':>10}{'разів більше за мітку':>24}")
for name, array in annotations:
    print(f"{name:<20}{array.size:>10}{array.nbytes:>10}{array.size:>24}")
print()
print("вхідне фото для порівняння:", photo.size, "чисел")
розмітка                 чисел      байт   разів більше за мітку
мітка                        1         1                       1
рамки                       10        40                      10
семантична маска         86400     86400                   86400
instance-карта           86400     86400                   86400
нове зображення         259200    259200                  259200

вхідне фото для порівняння: 259200 чисел

Масив на 86 400 чисел звучить страшно, але маска чудово стискається — у ній величезні однорідні ділянки. Перевіримо це, закодувавши маску в PNG.

In [11]:
for name, array in [("семантична маска", semantic_mask * 255),
                    ("instance-карта", instance_map * 120)]:
    success, png_buffer = cv2.imencode(".png", array)
    print(f"{name}: {array.size} чисел, а у файлі PNG — {len(png_buffer)} байт")
print()
print("Місце на диску тут не проблема. Проблема — час людини, яка цю маску обводила.")
семантична маска: 86400 чисел, а у файлі PNG — 1346 байт
instance-карта: 86400 чисел, а у файлі PNG — 1547 байт

Місце на диску тут не проблема. Проблема — час людини, яка цю маску обводила.

8 · IoU двох рамок, порахований руками¶

IoU (intersection over union) — площа перетину, поділена на площу обʼєднання. Візьмімо розмічену рамку правого телефона й уявімо, що модель видала таку саму рамку, але зсунуту на 24 пікселі праворуч і на 20 вниз.

In [12]:
ground_truth_box = box_right
shift_x, shift_y = 24, 20
predicted_box = (ground_truth_box[0] + shift_x, ground_truth_box[1] + shift_y,
                 ground_truth_box[2] + shift_x, ground_truth_box[3] + shift_y)

print("розмітка:", ground_truth_box, "площа", box_area(ground_truth_box))
print("модель:  ", predicted_box, "площа", box_area(predicted_box))
розмітка: (124, 61, 217, 192) площа 12183
модель:   (148, 81, 241, 212) площа 12183

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

In [13]:
inter_x1 = max(ground_truth_box[0], predicted_box[0])
inter_y1 = max(ground_truth_box[1], predicted_box[1])
inter_x2 = min(ground_truth_box[2], predicted_box[2])
inter_y2 = min(ground_truth_box[3], predicted_box[3])

# якщо рамки не торкаються, одна зі сторін виходить відʼємною — тоді площа нульова
inter_width = max(0, inter_x2 - inter_x1)
inter_height = max(0, inter_y2 - inter_y1)
intersection_area = inter_width * inter_height

# спільну частину не можна рахувати двічі, тому віднімаємо її один раз
union_area = box_area(ground_truth_box) + box_area(predicted_box) - intersection_area
iou_by_hand = intersection_area / union_area

print(f"перетин: {inter_x2} - {inter_x1} = {inter_width} завширшки,"
      f" {inter_y2} - {inter_y1} = {inter_height} заввишки")
print(f"площа перетину:    {inter_width} × {inter_height} = {intersection_area}")
print(f"площа обʼєднання:  {box_area(ground_truth_box)} + {box_area(predicted_box)}"
      f" - {intersection_area} = {union_area}")
print(f"IoU = {intersection_area} / {union_area} = {iou_by_hand:.4f}")
перетин: 217 - 148 = 69 завширшки, 192 - 81 = 111 заввишки
площа перетину:    69 × 111 = 7659
площа обʼєднання:  12183 + 12183 - 7659 = 16707
IoU = 7659 / 16707 = 0.4584

Перевірка: формула проти прямого підрахунку пікселів¶

Формула — це коротка дорога. Довга дорога: намалювати обидві рамки масивами й просто порахувати пікселі. Якщо два способи дають те саме число, формулі можна вірити.

In [14]:
def rectangle_mask(box):
    """Прямокутник як маска — щоб рахувати площі просто підрахунком пікселів."""
    mask = np.zeros((IMAGE_HEIGHT, IMAGE_WIDTH), dtype=bool)
    mask[box[1]:box[3], box[0]:box[2]] = True
    return mask


truth_pixels = rectangle_mask(ground_truth_box)
predicted_pixels = rectangle_mask(predicted_box)
iou_by_pixels = (truth_pixels & predicted_pixels).sum() / (truth_pixels | predicted_pixels).sum()

assert np.isclose(iou_by_hand, iou_by_pixels), "формула розійшлася з підрахунком пікселів!"
print("формула:            ", round(iou_by_hand, 4))
print("підрахунок пікселів:", round(float(iou_by_pixels), 4))
print("✅ збігається")
формула:             0.4584
підрахунок пікселів: 0.4584
✅ збігається

Поріг 0.5 і чотири пікселі¶

Рамку вважають влучною, якщо IoU не менший за 0.5. Наш зсув дає менше — а тепер зменшимо його на чотири пікселі по кожній осі.

In [15]:
def box_iou(first_box, second_box):
    """IoU двох рамок за формулою — та сама арифметика, згорнута у функцію."""
    x1 = max(first_box[0], second_box[0])
    y1 = max(first_box[1], second_box[1])
    x2 = min(first_box[2], second_box[2])
    y2 = min(first_box[3], second_box[3])
    intersection = max(0, x2 - x1) * max(0, y2 - y1)
    union = box_area(first_box) + box_area(second_box) - intersection
    return intersection / union


for dx, dy in [(24, 20), (20, 16)]:
    moved_box = (ground_truth_box[0] + dx, ground_truth_box[1] + dy,
                 ground_truth_box[2] + dx, ground_truth_box[3] + dy)
    value = box_iou(ground_truth_box, moved_box)
    print(f"зсув ({dx:2d}, {dy:2d}) → IoU {value:.4f} —",
          "зараховано" if value >= 0.5 else "не зараховано")
зсув (24, 20) → IoU 0.4584 —
 не зараховано
зсув (20, 16) → IoU 0.5256 — зараховано

9 · IoU масок суворіший за IoU рамок¶

Для сегментації IoU рахують так само, тільки площі — це кількість пікселів у маскі, а не добуток сторін. Візьмімо маску правого телефона й зсунемо її копію на 8 пікселів праворуч і 6 вниз — так, ніби модель трохи промахнулась.

In [16]:
shifted_mask = np.zeros_like(mask_right)
shifted_mask[6:, 8:] = mask_right[:-6, :-8]

mask_intersection = int((mask_right & shifted_mask).sum())
mask_union = int((mask_right | shifted_mask).sum())
mask_iou = mask_intersection / mask_union

# ті самі дві маски, але порівняні лише своїми рамками
boxes_iou = box_iou(bounding_box(mask_right), bounding_box(shifted_mask))

print(f"перетин масок:    {mask_intersection} пікселів")
print(f"обʼєднання масок: {mask_union} пікселів")
print(f"IoU по масках: {mask_iou:.4f}")
print(f"IoU по рамках тих самих масок: {boxes_iou:.4f}")
print()
print("Рамка прощає похибку по кутах, маска — ні, тому маскова оцінка нижча.")
перетин масок:    6495 пікселів
обʼєднання масок: 9111 пікселів
IoU по масках: 0.7129
IoU по рамках тих самих масок: 0.7732

Рамка прощає похибку по кутах, маска — ні, тому маскова оцінка нижча.

10 · Семантична проти instance: різниця в одному числі¶

Спокуслива думка: візьмімо семантичну маску й порахуємо в ній звʼязні плями — скільки плям, стільки й предметів. cv2.connectedComponents робить саме це: знаходить групи сусідніх ненульових пікселів і нумерує їх. Нуль — це фон, тому предметів на одну менше, ніж повернутих міток.

Розсунемо телефони й подивимось, коли прийом працює, а коли ні.

In [17]:
def semantic_mask_with_gap(gap):
    """Семантична маска сцени, де правий телефон зсунуто праворуч на gap пікселів."""
    moved_phone = dict(phone_right)
    moved_phone["cx"] = phone_right["cx"] + gap
    return (mask_left | phone_mask(moved_phone)).astype(np.uint8)


print(f"{'розсув, px':>11}{'звʼязних областей':>20}{'предметів насправді':>22}")
for gap in [0, 10, 20, 25, 30, 45]:
    number_of_labels, _ = cv2.connectedComponents(semantic_mask_with_gap(gap))
    print(f"{gap:>11}{number_of_labels - 1:>20}{2:>22}")
 розсув, px   звʼязних областей   предметів насправді
          0                   1                     2
         10                   1                     2
         20                   1                     2
         25                   1                     2
         30                   2                     2
         45                   2                     2

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

In [18]:
figure, axes = plt.subplots(1, 2, figsize=(9, 3.2))

axes[0].imshow(semantic_mask, cmap="Greys", vmin=0, vmax=1)
axes[0].set_title("семантична: один клас — один колір")
axes[1].imshow(instance_map, cmap="viridis", vmin=0, vmax=2)
axes[1].set_title("instance: свій номер кожному предмету")
for axis in axes:
    axis.axis("off")
plt.tight_layout()
plt.show()

components_in_semantic, _ = cv2.connectedComponents(semantic_mask)
print("звʼязних областей у семантичній маскі:", components_in_semantic - 1)
print("предметів в instance-розмітці:        ", len(instance_masks))
No description has been provided for this image
звʼязних областей у семантичній маскі: 1
предметів в instance-розмітці:         2

11 · Скільки коштувала б розмітка сотні таких фото¶

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

In [19]:
IMAGES_TO_LABEL = 100
OBJECTS_PER_IMAGE = 2

plans = [
    ("класифікація", IMAGES_TO_LABEL * 3),
    ("детекція", IMAGES_TO_LABEL * OBJECTS_PER_IMAGE * 15),
    ("сегментація", IMAGES_TO_LABEL * OBJECTS_PER_IMAGE * 180),
]

print(f"{'постановка':<16}{'секунд':>9}{'хвилин':>10}{'годин':>9}")
for name, seconds in plans:
    print(f"{name:<16}{seconds:>9}{seconds / 60:>10.1f}{seconds / 3600:>9.2f}")

cheapest_seconds = plans[0][1]
print()
print("детекція дорожча за класифікацію у", plans[1][1] // cheapest_seconds, "разів")
print("сегментація дорожча за класифікацію у", plans[2][1] // cheapest_seconds, "разів")
постановка         секунд    хвилин    годин
класифікація          300       5.0     0.08
детекція             3000      50.0     0.83
сегментація         36000     600.0    10.00

детекція дорожча за класифікацію у 10 разів
сегментація дорожча за класифікацію у 120 разів

Що з цього винести¶

  • Вхід один — виходи різні: 1, 10, 86 400, 86 400 і 259 200 чисел.
  • Прямокутна рамка навколо поверненого предмета містить майже половину фону — це не неточність моделі, а властивість самої форми відповіді.
  • IoU 0.4584 і 0.5256 відрізняються зсувом на чотири пікселі, а за угодою це «промах» і «влучання».
  • Семантична маска не рахує предмети. Лічильник звʼязних областей рятує лише доти, доки предмети не торкаються.
  • Розмітка масок коштує у 120 разів дорожче за мітки. Це рішення про дані, і воно зазвичай важливіше за вибір моделі.

Завдання¶

🟢 Рівень 1 — База¶

Додай у сцену третій телефон і повтори для нього всі пʼять розміток. Зроблено, якщо: надруковано площу його маски, його рамку й частку стільниці всередині цієї рамки.

🟡 Рівень 2 — Плюс¶

Знайди перебором, за якого розсуву gap семантична маска перестає бути однією областю (перевіряй значення від 0 до 60 з кроком 1). Зроблено, якщо: надруковано найменший gap, за якого cv2.connectedComponents повертає дві області, і поруч — кількість спільних пікселів при цьому розсуві.

🔴 Рівень 3 — Виклик¶

Напиши функцію mask_iou(first_mask, second_mask) і побудуй графік: як міняється IoU масок, коли копію маски зсувати вздовж осі X від −30 до +30 пікселів. На тому самому графіку намалюй IoU відповідних рамок. Зроблено, якщо: видно, що крива масок лежить нижче за криву рамок майже скрізь, і ти можеш пояснити словами, чому саме.