Практика · Карта задач компʼютерного зору¶
Лекція: lecture.html · Домашнє завдання: homework.md · Тест: quiz.html
Одне синтетичне фото товару з дошки оголошень — два телефони на столі — і пʼять різних розміток для нього. Мета: побачити руками, що задачі компʼютерного зору відрізняються не картинкою на вході, а формою відповіді на виході.
Що зробимо:
- згенеруємо сцену з двома телефонами — без жодних завантажень із мережі;
- створимо для неї розмітку всіх пʼяти типів: мітку, рамки, семантичну маску, instance-маски й «згенероване» зображення;
- порівняємо, скільки місця займає кожна розмітка;
- порахуємо IoU двох рамок руками й перевіримо формулу прямим підрахунком пікселів;
- порахуємо IoU масок і побачимо, що він суворіший за IoU рамок;
- покажемо різницю семантичної та instance-розмітки числом —
через
cv2.connectedComponents; - оцінимо, скільки часу зайняла б розмітка сотні таких фото кожним зі способів.
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 · Сцена¶
Фото беремо не з мережі, а малюємо самі: так у всіх, хто запустить зошит, будуть однакові числа, і їх можна звірити з лекцією.
Кожен телефон описуємо пʼятьма числами: центр, ширина, висота й кут повороту. Далі нам знадобиться відповідь на питання «чи належить піксель телефону» — і ось тут є проста хитрість. Прямокутник повернутий, і перевіряти «всередині чи ні» напряму незручно. Тому повертаємо не прямокутник, а саму точку — у зворотний бік. Після такого повороту прямокутник стає звичайним, зі сторонами вздовж осей, і перевірка зводиться до двох порівнянь: чи не завелика координата по ширині й по висоті.
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 — по три числа яскравості на кожен піксель.
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 чисел на вході
2 · Розмітка №1 — одна мітка на все фото¶
Найдешевша відповідь: одне число. Класифікація нічого не каже ні про кількість предметів, ні про їхнє місце.
CLASS_NAMES = ["стіл", "телефон"]
image_label = 1
print("розмітка №1 — мітка на все зображення:", CLASS_NAMES[image_label])
print("чисел у відповіді:", 1)
розмітка №1 — мітка на все зображення: телефон чисел у відповіді: 1
3 · Розмітка №2 — рамки¶
Рамка (bounding box) — це чотири числа: координати лівого верхнього й правого нижнього
кутів. Домовимось, що x2 і y2 — це перший піксель за рамкою: тоді ширина
рахується просто як x2 - x1, без плутанини з одиницею.
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 (клас плюс чотири координати на кожен предмет)
Рамка прямокутна, а телефон лежить під кутом — отже, всередину рамки неминуче потрапляє стільниця. Порахуймо, скільки саме.
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 — телефон. Розмір відповіді підскакує з десяти чисел до розміру всього зображення.
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¶
Та сама роздільність, але тепер кожен предмет має власний номер. Правий телефон лежить зверху, тому на спільних пікселях видно саме його.
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 — генерація¶
У генерації «розмітки» в звичному сенсі немає взагалі: вихід моделі сам є зображенням. Зробимо «студійний» варіант того самого товару — телефон рівно, на світлому фоні.
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
7 · Скільки місця займає кожна розмітка¶
Вхід у всіх пʼяти випадках однаковий. Порівняймо виходи.
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.
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 вниз.
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
Тепер крок за кроком. Перетин двох прямокутників — теж прямокутник: його ліва межа — більша з двох лівих, права — менша з двох правих. Так само по вертикалі.
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
Перевірка: формула проти прямого підрахунку пікселів¶
Формула — це коротка дорога. Довга дорога: намалювати обидві рамки масивами й просто порахувати пікселі. Якщо два способи дають те саме число, формулі можна вірити.
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. Наш зсув дає менше — а тепер зменшимо його на чотири пікселі по кожній осі.
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 вниз — так, ніби модель трохи промахнулась.
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 робить саме це: знаходить групи
сусідніх ненульових пікселів і нумерує їх. Нуль — це фон, тому предметів на одну менше,
ніж повернутих міток.
Розсунемо телефони й подивимось, коли прийом працює, а коли ні.
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-розмітка дає двійку в будь-якому положенні, бо предмети розділені в самій формі відповіді. Подивимось на це очима.
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))
звʼязних областей у семантичній маскі: 1 предметів в instance-розмітці: 2
11 · Скільки коштувала б розмітка сотні таких фото¶
Числа нижче — орієнтир порядку величини, а не вимір: реальний час залежить від інструмента, складності контуру й кваліфікації розмітника. Але співвідношення стійке.
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 відповідних рамок.
Зроблено, якщо: видно, що крива масок лежить нижче за криву рамок майже скрізь,
і ти можеш пояснити словами, чому саме.