OpenCV: базові операції

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

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

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

ІнструментНавіщо тут бібліотека

У темі 01 ми не використали жодної бібліотеки: канали, сірий, HSV, яскравість, гістограма виявились арифметикою над масивом NumPy. Тоді навіщо OpenCV? Є рівно три речі, яких чистий NumPy не робить.

Декодування форматів. Файл photo.jpg — не масив, а стиснений потік байтів, із якого піксель без розпакування не дістати. Писати декодер JPEG самотужки — проєкт на місяці.

Швидкість геометрії. Зменшити кадр 1920 × 1080 означає порахувати два мільйони вихідних пікселів. Цикл на чистому Python іде секунди, OpenCV робить те саме приблизно за мілісекунду — поміряємо в розділі 12. Різниця не в мові, а в тому, що всередині код на C++, векторизований під інструкції процесора.

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

Звідки дивні дрібниці в API. OpenCV — бібліотека на C++ із тонкою обгорткою для Python. Звідси і швидкість, і те, що функції беруть точку як (x, y), хоча NumPy індексує навпаки; що прапорці — цілі числа; що помилка часто повертається значенням, а не винятком. Усі три дивини ми зустрінемо в наступних двох розділах.

На межі між OpenCV і NumPy перетворення немає: cv2.resize приймає звичайний масив і повертає звичайний масив, тож усе з теми про NumPy тут працює без змін.

ФайлиЧитання й запис

img = cv2.imread("photo.jpg") # файл → масив cv2.imwrite("out.png", img) # масив → файл

imread повертає масив uint8 форми «висота × ширина × 3» із порядком каналів BGR — пастка з теми 01. imwrite обирає формат за розширенням імені.

Пастка, на якій спотикаються всі

Що буде, якщо файлу немає? Досвід Python підказує виняток. OpenCV поводиться інакше:

>>> img = cv2.imread("/nope/nothing.png") [ WARN:0@0.308] global loadsave.cpp:278 findDecoder imread_('/nope/nothing.png'): can't open/read file >>> print(img) None

Винятку немає — лише рядок зі словом WARN у stderr. Функція повертає None, виконання йде далі, і програма падає значно пізніше й в іншому місці: на img.shape з повідомленням AttributeError: 'NoneType' object has no attribute 'shape'. Людина шукає помилку в обробці, хоча вона була в шляху за двадцять рядків вище.

Правило назавжди. Після кожного imread перевіряй результат явно: if img is None: raise FileNotFoundError(path). Один рядок перетворює мовчазну поломку на гучну — той самий підхід, що й у темі про винятки: помилка має падати якнайближче до причини.
прапорець читаннящо повертаєколи брати
без прапорцямасив H × W × 3, BGRзвичайний випадок
cv2.IMREAD_GRAYSCALEмасив H × W, один каналколір не потрібен
cv2.IMREAD_UNCHANGEDяк у файлі, з альфа-каналомPNG із прозорістю: H × W × 4

Перша ціна: скільки коштує запис

форматбайтів на дискупікселів змінилосьнайбільша похибка
сирий масив у памʼяті230 400——
PNG128 38800
JPEG, якість 9012 36776 708 із 76 80084 рівні

PNG стискає без утрат — масив після читання побайтово той самий. JPEG менший у 18.6 раза, зате 99.9% пікселів мають інші значення: у середньому кожен зсунувся на 3 рівні, а один — на 84 з 255. Для фото в оголошенні це неважливо; для задачі, де модель шукає ледь помітну подряпину, — може бути вирішальним.

ПамʼятьЗріз — це вид, а не копія

Найдешевша операція — узяти прямокутний шматок, і синтаксис знайомий із теми про NumPy: patch = img[100:200, 50:150]. Вона не копіює жодного байта: patch дивиться в ту саму памʼять, просто починає з іншого місця. NumPy зве це видом (view).

>>> patch = img[40:120, 110:210] >>> np.shares_memory(patch, img) True >>> img[60, 150, 0] # синій канал одного пікселя шматка 133 >>> patch[:, :, 0] = 255 # «підправимо тільки шматок» >>> img[60, 150, 0] 255

Оригінал зіпсовано без жодного попередження. Це та сама механіка спільного володіння, що й у списках Python (розділ «Копія, якої немає»), тільки псується не список на десять чисел, а фотографія на 76 800 пікселів. Ліки — один виклик: img[40:120, 110:210].copy().

Коли .copy() обовʼязковий. Збираєшся писати у шматок — копіюй. Тільки читати (порахувати середнє, передати у функцію, яка повертає новий масив) — не копіюй, це зайва памʼять. Найпідступніший випадок — функції малювання з розділу 10: вони пишуть у масив на місці, і вид дає їм повний доступ до оригіналу.

Вид проти копії

Ліворуч — оригінальне фото, праворуч — вирізаний шматок екрана. Кнопкою малюємо по шматку. Перемикач вирішує, чи був зроблений .copy().

що правимо
спільна памʼять—
байтів скопійовано—
пікселів оригіналу зіпсовано0
Натисни «замалювати» в обох положеннях перемикача. Зріз ділить памʼять з оригіналом: 0 скопійованих байтів — і 8 000 зіпсованих пікселів у самому фото. .copy() коштує 24 000 байтів — і псує рівно нуль. Ціна безпеки відома наперед: висота × ширина × канали.

КоординатиОбрізка й дві системи координат

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

NumPy індексує «рядок, стовпець» — img[y, x]: масив зберігається рядками, тому перша вісь — вісь рядків, і shape у темі 01 виявився (висота, ширина, канали). Функції OpenCV беруть точку як (x, y) — як у шкільній системі координат, тільки з віссю Y вниз. Обидві угоди розумні; проблема в тому, що вони стоять поруч:

color = img[60, 210] # y=60, x=210 — NumPy cv2.circle(img, (210, 60), 9, (0, 0, 255), -1) # x=210, y=60
спроба 1 · переплутали в NumPy

Фото має 240 рядків і 320 стовпців, тож точка «рядок 200, стовпець 300» існує. Пишемо img[300, 200], переплутавши місцями:

IndexError: index 300 is out of bounds for axis 0 with size 240

Гучно, одразу, з точним поясненням.

спроба 2 · переплутали в OpenCV

Та сама помилка у функції малювання: cv2.circle(img, (200, 300), 9, ...). Жодного повідомлення. Порахуймо, скільки пікселів вона змінила:

змінених пікселів: 0

Точка (x=200, y=300) лежить нижче кадру, а OpenCV обрізає все, що виходить за межі. Для малювання це правильно — інакше лінія, що зачепила край, кидала б виняток. Для помилки в порядку координат це найгірше з можливого: рамка не намалювалась, і ніхто про це не сказав.

Як не плутати. Розпаковуй форму в іменовані змінні одразу: height, width = img.shape[:2]. Тоді (width, height) у виклику resize уже не переплутаєш.

Одна точка, дві адреси

Тягни маркер по фото або крути повзунки. Бірюзовий хрест — там, куди точку кладе NumPy. Рожевий — куди її покладе OpenCV, якщо переплутати числа місцями.

NumPy—
OpenCV—
якщо переставити—
промах, пікселів—
Прожени обидва повзунки до країв. Поки стовпець менший за 240, переставлені координати дають просто іншу точку кадру — помилку видно очима. Щойно стовпець перевищує 239, переставлена точка вилітає за нижній край: NumPy кине IndexError, а OpenCV змінить нуль пікселів і промовчить. Рожева смуга праворуч — саме та зона, де помилка стає невидимою.

У OpenCV є й власна обрізка — cv2.getRectSubPix(img, (ширина, висота), (x, y)). Вона бере центр шматка, а не кут, і вміє центр дробовий: тоді результат інтерполюється між пікселями. На цілому центрі вона дає той самий масив, що й зріз, — і це перевіряється одним np.array_equal: числу тут можна довіритись саме тому, що його легко перевірити.

МасштабЗміна розміру

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

small = cv2.resize(img, (160, 120)) # (ширина, висота)! small = cv2.resize(img, None, fx=0.5, fy=0.5) # або в частках

Розмір передається як (ширина, висота) — знову порядок «ікс, ігрек», протилежний до shape, і плутанина тут коштує найдорожче: результат виглядає майже правильно.

Інтерполяція — не косметика

Коли 400 × 400 стає 50 × 50, коефіцієнт зменшення дорівнює 8, отже, на кожен вихідний піксель припадає квадрат 8 × 8, тобто 64 вхідних. Спосіб, яким із них робиться одне число, зветься інтерполяцією.

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

методщо робитьчитаєstd шуму
INTER_NEARESTбере один піксель — найближчий до центра1 із 6473.99
INTER_CUBICзважує 16 сусідів гладкою кривою16 із 6452.81
INTER_LINEARусереднює 4 сусідні пікселі4 із 6436.82
INTER_AREAусереднює всі, що потрапили в клітинку64 із 649.28
Дрібниця, яка ловить професіоналів. При цілому непарному коефіцієнті зменшення INTER_LINEAR вироджується в INTER_NEAREST: точка вибірки лягає рівно в центр вхідного пікселя, ваги трьох сусідів обнуляються, і метод читає один піксель замість чотирьох. Поміряно на cv2.resize: при коефіцієнті 2 std дорівнює 37.19, при 3 — 73.70, тобто рівно стільки, скільки дає NEAREST. Інтерактив нижче відтворює це на коефіцієнтах 3, 5, 7 і 9. Слово «цілому» тут важливе: коефіцієнт 400 ÷ 133 = 3.007 уже не цілий, точка вибірки не лягає в центр пікселя, і виродження не настає.

Різниця між першим і останнім рядком — у вісім разів. INTER_NEAREST викинув 63 пікселі з 64 і зберіг увесь шум того одного, що лишився; INTER_AREA усереднив усі 64, а середнє 64 незалежних чисел коливається приблизно у √64 = 8 разів менше, ніж кожне з них.

Правило дослівно. Зменшуєш — INTER_AREA. Збільшуєш — INTER_LINEAR (швидко) або INTER_CUBIC (гладкіше). INTER_NEAREST — тільки для масок із номерами класів, де усереднювати не можна в принципі: середнє між класом 2 і класом 4 дало б клас 3, якого на цьому пікселі немає. Замовчування OpenCV — INTER_LINEAR, і воно не найкраще для зменшення. Наслідок видно на дрібницях: при NEAREST подряпина завширшки в піксель має 1 шанс із 64 вижити, при AREA вона лишить блідий, але наявний слід.

Що саме викидає інтерполяція

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

метод
вхідних на 1 вихідний—
прочитано з них—
std шуму після—
очікувано при усередненні—
Прожени коефіцієнт від 2 до 10 у кожному з трьох методів. Стовпчик «прочитано» показує головне: NEAREST завжди читає рівно один вхідний піксель, скільки б їх не було в клітинці. Стовпчик «std» показує наслідок. Права колонка — теоретичне значення для чесного усереднення, std ÷ коефіцієнт; AREA лягає на нього майже точно, і це не збіг.

Пропорції: третій варіант, про який забувають

Модель хоче квадрат 224 × 224, фото має 320 × 240. Здається, варіантів два. Насправді їх три, і кожен щось віддає.

спосібщо втрачаємоколи брати
розтягнутигеометрію: коло стає оваломколи форма неважлива
обрізати, тоді зменшитичастину кадру назавждиколи предмет посередині
вписати й доповнити полямичастину площі під порожні полязамовчування детекції

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

Три способи вписати фото у квадрат

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

пропорції змінились у—
кадру обрізано—
площі під поля—
коефіцієнт вписування—
Постав ширину 320, а висоту 80. Розтягування змінює пропорції в 3 рази — телефон стає невпізнанним. Обрізання забирає дві третини кадру. Вписування зберігає все, але дві третини результату — порожні поля. Жоден із трьох не безкоштовний; вирішує те, що саме тобі потрібне цілим.

ГеометріяПовороти й афінні перетворення

Піксель, повернутий на 30°, майже ніколи не потрапляє рівно в клітинку сітки, тому кожен вихідний піксель доводиться збирати з кількох вхідних — знову інтерполяцією. В OpenCV це два кроки:

M = cv2.getRotationMatrix2D((w/2, h/2), angle=30, scale=1.0) out = cv2.warpAffine(img, M, (w, h))

Матриця M — таблиця 2 × 3, шість чисел. Вона задає афінне перетворення: правило, яке кожній точці ставить у відповідність нову. «Афінне» означає, що прямі лишаються прямими, а паралельні — паралельними. Правило записується двома рядками, і в них немає нічого, крім множення й додавання:

x′ = M00·x + M01·y + M02   ·   y′ = M10·x + M11·y + M12

Тут x, y — координати до перетворення, x′, y′ — після; перші два числа рядка відповідають за поворот і масштаб, третє — за перенос. Порахуймо одну точку руками.

крок 1 · матриця для 30°

Для повороту на кут α навколо центра (cx, cy) без зміни масштабу OpenCV будує:

M = [ cos α sin α (1−cos α)·cx − sin α·cy ] [ −sin α cos α sin α·cx + (1−cos α)·cy ]

Для α = 30° косинус дорівнює 0.8660, синус — 0.5000; центр фото — (160, 120):

M02 = (1 − 0.8660)·160 − 0.5·120 = 21.44 − 60 = −38.56 M12 = 0.5·160 + (1 − 0.8660)·120 = 80 + 16.08 = 96.08
крок 2 · перевіряємо на центрі

Центр має лишитись на місці. Підставимо x = 160, y = 120:

x′ = 0.8660·160 + 0.5·120 − 38.56 = 138.56 + 60 − 38.56 = 160 y′ = −0.5·160 + 0.8660·120 + 96.08 = −80 + 103.92 + 96.08 = 120

Збіглося — жодної магії, два множення й додавання на координату. А тепер лівий верхній кут, точка (0, 0): x′ = −38.56, y′ = 96.08. Ікс отримав відʼємне значення — після повороту ця точка опинилась ліворуч від кадру й у кадр уже не потрапить.

warpAffine малює результат у полотно того розміру, який ти вказав третім аргументом; усе, що виїхало за межі, зникає. Скільки саме — легко поміряти, пропустивши через ту саму матрицю білий прямокутник розміру кадру.

кутпікселів лишилосьчастка кадруполотно, яке вмістило б усе
5°73 58995.8%339 × 266
15°68 60489.3%371 × 314
30°63 92883.2%397 × 367
45°61 67480.3%395 × 395
90°57 60075.0%240 × 320

Поворот на непомітні 5° уже викидає кожен двадцять пʼятий піксель, а на 30° зникає шоста частина кадру — і зникає з кутів. Якщо поворот робиться для аугментації, частина предметів так само тихо покидає кадр разом із їхньою розміткою. Полотно, яке вмістить усе, рахується з тих самих кутів:

нова ширина = h·|sin α| + w·|cos α|  ·  нова висота = h·|cos α| + w·|sin α|

Вертикальні риски — це модуль, значення без знаку: для розміру напрямок нахилу неважливий. Для 30° маємо 240·0.5 + 320·0.866 = 397.1 і 240·0.866 + 320·0.5 = 367.8; розмір полотна — ціле число пікселів, тож дробову частину відкидаємо й дістаємо 397 × 367 — ті самі числа, що в таблиці. Лишається зсунути картинку в центр нового полотна:

M[0, 2] += new_w / 2 - w / 2 M[1, 2] += new_h / 2 - h / 2 out = cv2.warpAffine(img, M, (new_w, new_h))
Дзеркалення — окремий, дешевий випадок. Воно не потребує ані матриці, ані інтерполяції: пікселі просто читаються у зворотному порядку. cv2.flip(img, 1) дзеркалить по горизонталі, 0 — по вертикалі, -1 — обидва. Те саме робить зріз img[:, ::-1], і результати збігаються побайтово — рівність двох масивів перевіряється одним np.array_equal. Різниця в тому, що flip віддає суцільний масив, а зріз — вид зі зворотним кроком, який деякі функції відмовляться приймати.

Куди дівається кадр при повороті

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

полотно
розмір полотна—
пікселів лишилось—
частка кадру—
порожньо в полотні—
Прожени кут від 0 до 90 в обох режимах. У старому полотні втрата росте до 25% при 90° — просто тому, що кадр не квадратний. У перерахованому не втрачається нічого, зате порожні кути займають до половини полотна (при 45°), і далі ці порожні пікселі теж доведеться чимось заповнити.

МежіМежі: чим заповнити порожнечу

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

out = cv2.copyMakeBorder(img, 1, 1, 1, 1, cv2.BORDER_REFLECT_101) # угору, вниз, ліворуч, праворуч, режим

Ті самі режими приймають майже всі функції, що працюють з околом пікселя. Візьмімо найменше можливе зображення — масив 3 × 3 зі значеннями 0-8, додамо рамку в один піксель і подивимось на верхній рядок.

режимверхній рядок після рамкищо робить
BORDER_CONSTANT0 0 0 0 0кладе задане число, за замовчуванням нуль
BORDER_REPLICATE0 0 1 2 2розмазує крайній піксель назовні
BORDER_REFLECT0 0 1 2 2дзеркалить разом із самим краєм
BORDER_REFLECT_1014 3 4 5 4дзеркалить, не повторюючи край

Два спостереження, обидва неочевидні. Перше: у рамці в один піксель REPLICATE і REFLECT дають однаковий рядок, бо дзеркало REFLECT проходить по зовнішній межі крайнього пікселя й першим відображенням стає він сам; різниця зʼявляється з другого пікселя рамки.

Друге: REFLECT_101 дає 4 3 4 5 4 — копію другого рядка масиву, бо його дзеркало проходить по центру крайнього пікселя й край не подвоюється. Саме цей режим стоїть за замовчуванням майже скрізь, і причина практична: подвоєний край створює штучну «сходинку» — два однакові значення поспіль там, де їх у зображенні не було, — а операції, які шукають перепади, приймають її за справжню деталь.

Це знадобиться вже в наступній темі. Згортка рахує кожен піксель по його околу, а для пікселів на краю окіл частково лежить за межами зображення — без відповіді на питання «що там» операція просто не визначена. Тема 04 починається саме з цього вибору, тож ці чотири рядки таблиці там знадобляться дослівно.

Що саме підставляється за краєм

Масив 3 × 3 зі значеннями 0-8 у центрі, рамка завтовшки в два пікселі навколо. У кожній клітинці — число, яке туди потрапить.

режим
верхній рядок—
лівий стовпець—
край подвоєно—
перепад на межі фото—
Перемикайся між REFLECT і REFLECT_101. Картинка змінюється ледь помітно, а числа — цілком: REFLECT дає перший рядок 4 3 3 4 5 5 4 із подвоєними значеннями, REFLECT_101 — 8 7 6 7 8 7 6 без жодного подвоєння. Стовпчик «перепад на межі фото» рахує середню різницю яскравостей через край на справжньому фрагменті знімка: у CONSTANT вона найбільша, бо чорна рамка сама по собі є різким перепадом.

КолірКольорові простори в роботі

Що таке канали, чим сірий відрізняється від середнього трьох і навіщо HSV — розібрано в темі 01. Тут — тільки те, як цим користуватись.

gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) # 3 канали → 1 hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) # відтінок і тон rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) # для matplotlib

Імʼя коду читається дослівно: з чого, у що. COLOR_BGR2GRAY — та сама зважена сума 0.299·R + 0.587·G + 0.114·B, що й у темі 01, і вона викидає дві третини даних.

Маска за кольором

На екрані нашого телефона є зелена смужка індикатора. cv2.inRange бере зображення й дві межі й повертає маску: масив тієї самої висоти й ширини, де 255 стоїть там, де всі три канали вклались у межі. Це вже знайомий із теми 02 формат відповіді, тільки отриманий не моделлю, а трьома нерівностями.

hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) mask = cv2.inRange(hsv, (40, 80, 60), (85, 255, 255)) # зелений print(int((mask > 0).sum())) # 1439 пікселів

Перше число трійки — відтінок, воно й відповідає за «зелений»; друге й третє задані широко: «будь-який достатньо насичений і не надто темний». Ось чому — присвітімо фото нерівномірно, ніби збоку падає лампа, і подивімось на піксель індикатора з двох боків:

піксель індикаторавідтінок Hяскравість V
ближче до тіні69178
ближче до лампи69226

Яскравість змінилась на 48 одиниць, відтінок — узагалі ні. Тому поріг за відтінком переживає зміну освітлення, а поріг за сирими каналами BGR — ні: там змінюються всі три числа одразу.

Чесно про наш приклад. На синтетичному фото, де зелений є рівно в одному місці, просте правило по каналах BGR дасть ті самі 1 439 пікселів, що й HSV, — тут вигравати нема в кого. Числа з таблиці показують механізм переваги HSV, але побачити її наслідок можна лише на справжньому знімку, де по стільниці гуляють відблиски, а поруч лежить щось зеленувате.

Далі маску найпростіше обвести: x, y, w, h = cv2.boundingRect(mask) дає найменший прямокутник, що вміщує всі ненульові пікселі, — у нас це 117 170 87 17. Чотири числа — рівно та форма відповіді, яку в темі 02 віддавав детектор.

ПорігПороги: від пікселів до маски

Найкоротший шлях від пікселів до маски — поріг: усе, що яскравіше за число t, — біле, решта — чорне.

t, mask = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY) t, mask = cv2.threshold(gray, 127, 255, cv2.THRESH_BINARY_INV)

Функція повертає дві речі: використаний поріг і маску. Перше здається зайвим, бо ми ж самі його передали, — але звідки взяти число 127? На конкретному знімку правильний поріг залежить від освітлення. Метод Оцу (Otsu) шукає його сам, і ідея зрозуміла без формул: поріг ділить пікселі на дві купи, і хороший поріг — той, після якого всередині кожної купи значення схожі між собою, а самі купи далеко одна від одної. Метод перебирає всі 256 можливих порогів по гістограмі, тому працює швидко — перебір іде не по пікселях, а по 256 стовпчиках.

t, mask = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)

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

Де глобальний поріг ламається

Продавець сфотографував наліпку з IMEI на коробці. Треба відокремити темні цифри від світлого паперу — перший крок будь-якого розпізнавання тексту. Наліпку знято при лампі збоку: лівий край майже в тіні, правий пересвічений; цифри всюди однакові, папір усюди однаковий, змінюється лише освітлення. Мірою візьмімо IoU — той самий перетин, поділений на обʼєднання, тільки рахований по пікселях маски.

спосібпорігIoU з цифрамиправильних пікселів
назвати все папером—0.0%89.6%
глобальний, найкращий можливий4267.0%96.2%
глобальний, Оцу15824.5%68.1%
адаптивний, блок 31—97.7%99.8%
Оцу при рівному світлі13899.9%100.0%

Останній рядок: при рівному освітленні Оцу знаходить поріг 138 і відділяє цифри практично ідеально — метод чудовий, коли його припущення виконується. Третій рядок: варто додати лампу збоку, і той самий метод дає 24.5%, бо він чесно поділив картинку на темну й світлу половини, от тільки ця межа проходить по кадру, а не по цифрах.

Другий рядок найважливіший: навіть найкращий із усіх 256 глобальних порогів дає лише 67.0%. Причина фундаментальна: найтемніший піксель паперу має 31 рівень, а найсвітліший піксель цифри — 192. Діапазони перекриваються, тож одного числа, яке відділило б перше від другого, не існує. Перший рядок контрольний: цифри займають 10.4% наліпки, тож маска «усе папір» дає 89.6% правильних пікселів, не роблячи нічого. Ось чому в таблиці стоїть IoU — частка правильних пікселів на розріджених масках підлещує будь-який метод.

Адаптивний поріг

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

mask = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, cv2.THRESH_BINARY_INV, 31, 6) # розмір блоку ↑ ↑ константа C

Для кожного пікселя береться квадрат 31 × 31 навколо нього, рахується середня яскравість і від неї віднімається 6; це число й стає порогом саме для цього пікселя. Результат — IoU 97.7% замість 24.5%: освітлення зникло з задачі, бо всередині квадрата воно однакове і для пікселя, і для його околу, а різниця між ними від лампи не залежить.

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

Три пороги на нерівно освітленій наліпці

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

спосіб
IoU з цифрами—
правильних пікселів—
знайдено пікселів—
цифр насправді—
Постав лампу на нуль і перемкнись на «Оцу» — потім поверни лампу назад. При рівному світлі автоматичний поріг дає IoU 100%, при повній лампі — близько 44%. У режимі «глобальний» найкраще, чого вдається досягти повзунком, — близько 70%, і то у вузькому вікні порогів. У режимі «адаптивний» повзунок міняє розмір блоку: IoU тримається вище 89% на всьому діапазоні й помітно просідає лише на найменшому блоці 3 × 3, вужчому за сам штрих цифри.

РозміткаМалювання поверх

Тема 02 обіцяла рамку детектора. Чотири числа з розділу 8 у нас є — лишилось намалювати.

cv2.rectangle(img, (117, 170), (204, 187), (0, 200, 0), 2) cv2.circle(img, (160, 178), 4, (0, 0, 255), -1) cv2.line(img, (0, 120), (319, 120), (255, 0, 0), 1) cv2.putText(img, "indicator", (117, 165), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 200, 0), 1, cv2.LINE_AA)

Але найважливіше не в аргументах. Подивись іще раз: результат нікуди не присвоюється. Усі чотири функції міняють масив на місці, і оригінальні значення тих пікселів утрачено назавжди. Тепер згадай розділ 3 і склади дві половинки:

crop = img[170:187, 117:204] # вид, спільна памʼять cv2.rectangle(crop, (0, 0), (86, 16), (0, 200, 0), 2) # зелена рамка тепер і в img теж
Малюй завжди по копії. vis = img.copy(), малюємо по vis, показуємо vis, а img лишається чистим для обчислень. Копія кадру 1080p коштує близько половини мілісекунди — незрівнянно дешевше, ніж день пошуку відповіді на питання, чому метрики моделі впали після того, як хтось увімкнув візуалізацію.

ПоказЯк це показати

cv2.imshow відкриває власне вікно системи: показує миттєво, у справжньому масштабі, уміє оновлюватись у циклі — це стандартний спосіб дивитись відео покадрово. Вимагає обслуговування:

cv2.imshow("вікно", img) cv2.waitKey(0) # чекати натискання клавіші cv2.destroyAllWindows()

У зошиті Jupyter цей спосіб не працює — зошит виконується на сервері, який може не мати ані екрана, ані віконної системи, тому виклик або нічого не покаже, або підвісить ядро. У ноутбуці беремо matplotlib:

plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB)) plt.axis("off") plt.show()

Перетворення тут не косметичне: matplotlib чекає RGB, OpenCV віддає BGR. Забудеш cvtColor — телефон стане синім, а стільниця блакитною, бо перший і третій канали помінялись місцями. Чому BGR узагалі існує — у темі 01.

Дві дрібниці, які економлять годину. Без waitKey вікно imshow зʼявиться й одразу зникне — саме ця функція віддає керування системі. А одноканальне зображення plt.imshow(gray) без аргументів розфарбує жовто-фіолетовим: це замовчувана палітра matplotlib, а не помилка в даних. Потрібне cmap="gray" й обовʼязково vmin=0, vmax=255, інакше matplotlib розтягне контраст під фактичні межі й темна картинка виглядатиме нормальною.

ЦінаСкільки це коштує

У реальній задачі це не одне фото, а потік кадрів. Поміряймо на кадрі 1920 × 1080 × 3 в один потік, беручи мінімум із шістдесяти прогонів.

операціямілісекундчастка кадру при 25 к/с
img.copy()0.531.3%
resize → 960 × 540, NEAREST0.711.8%
flip по горизонталі0.862.2%
cvtColor BGR → GRAY0.962.4%
resize → 960 × 540, AREA1.052.6%
resize → 960 × 540, LINEAR1.072.7%
cvtColor BGR → HSV5.7414.3%
warpAffine, поворот на 12°14.3035.8%

Права колонка береться так: 25 кадрів за секунду дають на кожен кадр рівно 40 мілісекунд — і в цей бюджет має вміститись читання, обробка, модель і показ. Копія плюс зменшення плюс сірий — 2.54 мс, тобто 6.4%, практично безкоштовно. Додаємо HSV — уже 8.28 мс, пʼята частина. Додаємо warpAffine, щоб вирівняти нахилений кадр, — 22.58 мс: більше половини бюджету пішло на підготовку, і на розпізнавання лишилось 17 мілісекунд. Для більшості моделей цього замало.

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

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

МістокКуди далі

Обіцянку виконано: ми навчились читати файл і не спіткнутись об None, вирізати шматок і не зіпсувати оригінал, зменшувати, не втрачаючи дрібниць, повертати, знаючи ціну в пікселях, робити маску порогом і малювати рамку. Тепер подивись на весь список операцій згори — він розпадається рівно на дві купки.

що робить операціяприклади
переставляє пікселі, не міняючи значеньобрізка, flip, поворот, зміна розміру, рамка
міняє кожен піксель окремо, не дивлячись на сусідівcvtColor, threshold, inRange, яскравість

Жодна операція цієї теми не питала: а що там у сусіда? Значення кожного вихідного пікселя або переїхало з іншого місця, або порахувалось із самого себе. Єдиний виняток — інтерполяція при зменшенні; вважай це першим натяком.

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

Саме така операція — тема наступного заняття. Тема 04 · Згортка вручну розбирає перше перетворення, де значення пікселя залежить від сусідів: ми зберемо його з нуля на NumPy, порівняємо з cv2.filter2D через np.allclose, зʼясуємо неприємну правду про те, що ця функція насправді рахує, і доведемо справу до детектора меж. Межові режими з розділу 7 знадобляться там уже в першому абзаці — бо в пікселя на краю кадру частина сусідів просто відсутня.