Сегментация и детектирование

Задачи компьютерного зрения¶

В ходе предыдущих занятий мы подробно рассмотрели задачу классификации изображений.

Но порой недостаточно знать, что на изображении есть объект определенного класса. Важно, где именно расположен объект. В ряде случаев нужно знать еще и точные границы объекта. Например, если речь идет о рентгеновском снимке или изображении клеток ткани, полученном с микроскопа.

Определение того, какие фрагменты изображения принадлежат объектам определенных классов — это задача сегментации (segmentation).

Если нас интересуют не индивидуальные объекты, а только тип (класс) объекта, которым занят конкретный пиксель (как в случае с клетками под микроскопом), то говорят о семантической сегментации (semantic segmentation).

Если нас интересуют конкретные объекты, и при этом достаточно знать только область, в которой объект локализован, то это задача детектирования (Detection)

В качестве примера такой задачи можно рассмотреть подсчет количество китов на спутниковом снимке.

Если же важны и индивидуальные объекты, и их точные границы, то это уже задача Instance segmentation. Например, для автопилота важно не только знать, что перед ним несколько автомобилей, но и отличить, где именно находится ближний, а где — дальний.

Dataset COCO — Common Objects in Context¶

Прежде чем говорить о способах решения этих задач, надо разобраться с форматами входных данных. Сделаем это на примере датасета COCO.

COCO — один из наиболее популярных датасатов, содержащий данные для сегментации и детектирования. Он содержит более трёхсот тысяч изображений, большая часть из которых размечена и содержит следующую информацию:

  • категории;
  • маски;
  • ограничивающие боксы (bounding boxes);
  • описания (captions);
  • ключевые точки (keypoints);
  • и многое другое.

Формат разметки изображений, использованный в этом датасете, нередко используется и в других наборах данных. Как правило, он упоминается просто как "COCO format".

Загрузим датасет.

In [1]:
!wget -qN "http://images.cocodataset.org/annotations/annotations_trainval2017.zip"
# !wget -qN "https://edunet.kea.su/repo/EduNet-web_dependencies/datasets/annotations_trainval2017.zip"
!unzip -qn annotations_trainval2017.zip

Для работы с датасетом используется пакет pycocotools.

Подробнее о том, как создать свой COCO датасет с нуля.

In [2]:
from pycocotools.coco import COCO

coco = COCO("annotations/instances_val2017.json")
loading annotations into memory...
Done (t=0.56s)
creating index...
index created!

Рассмотрим формат аннотаций на примере одной записи.

In [3]:
catIds = coco.getCatIds(catNms=["cat"])  # cat's IDs
print("class ID(cat) =", catIds)

imgIds = coco.getImgIds(catIds=catIds)  # Filtering dataset by tag
print("All images: %i" % len(imgIds))
class ID(cat) = [17]
All images: 184

Рассмотрим метаданные.

In [4]:
img_list = coco.loadImgs(imgIds[0])  # 1 example
img_metadata = img_list[0]
img_metadata
Out[4]:
{'license': 5,
 'file_name': '000000416256.jpg',
 'coco_url': 'http://images.cocodataset.org/val2017/000000416256.jpg',
 'height': 375,
 'width': 500,
 'date_captured': '2013-11-18 04:05:18',
 'flickr_url': 'http://farm1.staticflickr.com/34/64280333_7acf38cfb3_z.jpg',
 'id': 416256}

Посмотрим на изображение.

In [5]:
import matplotlib.pyplot as plt
import requests
from PIL import Image
from io import BytesIO


def coco2pil(url):
    response = requests.get(url)
    return Image.open(BytesIO(response.content))


I = coco2pil(img_metadata["coco_url"])
plt.axis("off")
plt.imshow(I)
plt.show()

Категории в COCO¶

Давайте посмотрим на категории в датасете.

In [6]:
cats = coco.loadCats(coco.getCatIds())  # loading categories
num2cat = {}
print("COCO categories: ")

iterator = iter(cats)
cat = next(iterator)
for i in range(0, 91):
    if i == cat["id"]:
        num2cat[cat["id"]] = cat["name"]
        name = cat["name"]
        if i < 90:
            cat = next(iterator)
    else:
        name = "---"

    print(f"{i:2}. {name:20}", end="")

    if not i % 6:
        print("\n")
COCO categories: 
 0. ---                 

 1. person               2. bicycle              3. car                  4. motorcycle           5. airplane             6. bus                 

 7. train                8. truck                9. boat                10. traffic light       11. fire hydrant        12. ---                 

13. stop sign           14. parking meter       15. bench               16. bird                17. cat                 18. dog                 

19. horse               20. sheep               21. cow                 22. elephant            23. bear                24. zebra               

25. giraffe             26. ---                 27. backpack            28. umbrella            29. ---                 30. ---                 

31. handbag             32. tie                 33. suitcase            34. frisbee             35. skis                36. snowboard           

37. sports ball         38. kite                39. baseball bat        40. baseball glove      41. skateboard          42. surfboard           

43. tennis racket       44. bottle              45. ---                 46. wine glass          47. cup                 48. fork                

49. knife               50. spoon               51. bowl                52. banana              53. apple               54. sandwich            

55. orange              56. broccoli            57. carrot              58. hot dog             59. pizza               60. donut               

61. cake                62. chair               63. couch               64. potted plant        65. bed                 66. ---                 

67. dining table        68. ---                 69. ---                 70. toilet              71. ---                 72. tv                  

73. laptop              74. mouse               75. remote              76. keyboard            77. cell phone          78. microwave           

79. oven                80. toaster             81. sink                82. refrigerator        83. ---                 84. book                

85. clock               86. vase                87. scissors            88. teddy bear          89. hair drier          90. toothbrush          

Категория 0 используется для обозначения класса фона. Некоторые номера категорий не заняты.

Также существуют надкатегории.

In [7]:
print(f"cats[2]: {cats[2]}")
print(f"cats[3]: {cats[3]}")

nms = set([cat["supercategory"] for cat in cats])
print("COCO supercategories: \n{}".format("\t".join(nms)))
cats[2]: {'supercategory': 'vehicle', 'id': 3, 'name': 'car'}
cats[3]: {'supercategory': 'vehicle', 'id': 4, 'name': 'motorcycle'}
COCO supercategories: 
indoor	appliance	outdoor	animal	sports	vehicle	furniture	person	electronic	accessory	food	kitchen

Разметка данных¶

Помимо метаданных нам доступна разметка (подробнее о разметке). Давайте её загрузим и отобразим.

In [8]:
annIds = coco.getAnnIds(imgIds=img_metadata["id"])
anns = coco.loadAnns(annIds)

plt.imshow(I)
plt.axis("off")
coco.showAnns(anns)
plt.show()

На изображении можно увидеть разметку пикселей изображения по классам. То есть, пиксели из объектов, относящихся к интересующим классам, приписываются к классу этого объекта. К примеру, можно увидеть объекты двух классов: "cat" и "keyboard".

Давайте теперь посмотрим, из чего состоит разметка.

In [9]:
def dump_anns(anns):
    for i, a in enumerate(anns):
        print(f"\n#{i}")
        for k in a.keys():
            if k == "category_id" and num2cat.get(a[k], None):
                print(k, ": ", a[k], num2cat[a[k]])  # Show cat. name
            else:
                print(k, ": ", a[k])


dump_anns(anns)
#0
segmentation :  [[157.26, 120.16, 165.32, 104.84, 191.94, 86.29, 198.39, 95.97, 193.55, 103.23, 188.71, 118.55, 188.71, 130.65, 191.13, 140.32, 204.03, 145.16, 211.29, 145.16, 262.1, 150.81, 280.65, 150.81, 290.32, 146.77, 300.0, 138.71, 304.03, 136.29, 322.58, 131.45, 324.19, 131.45, 327.42, 143.55, 341.94, 139.52, 367.74, 144.35, 368.55, 146.77, 337.1, 157.26, 312.9, 163.71, 295.16, 168.55, 299.19, 181.45, 305.65, 201.61, 293.55, 239.52, 288.71, 233.87, 265.32, 227.42, 225.0, 221.77, 199.19, 217.74, 170.97, 211.29, 159.68, 207.26, 149.19, 204.03, 137.1, 195.97, 130.65, 191.13, 117.74, 175.81, 134.68, 150.81, 147.58, 134.68]]
area :  15465.184599999999
iscrowd :  0
image_id :  416256
bbox :  [117.74, 86.29, 250.81, 153.23]
category_id :  17 cat
id :  51846

#1
segmentation :  [[122.48, 272.75, 284.67, 309.77, 293.93, 313.33, 313.86, 252.82, 315.29, 238.58, 153.67, 210.81, 123.06, 259.93, 121.64, 267.77]]
area :  12449.917949999995
iscrowd :  0
image_id :  416256
bbox :  [121.64, 210.81, 193.65, 102.52]
category_id :  76 keyboard
id :  1115755

#2
segmentation :  [[28.95, 191.52, 88.37, 123.57, 138.31, 142.22, 125.98, 155.17, 122.5, 166.87, 122.5, 177.93, 122.82, 182.04, 86.79, 227.87]]
area :  5851.351
iscrowd :  0
image_id :  416256
bbox :  [28.95, 123.57, 109.36, 104.3]
category_id :  76 keyboard
id :  1975408

Заметим, что аннотация изображения может состоять из описаний нескольких объектов, каждое из которых содержит следующую информацию:

  • segmentation — последовательность пар чисел ($x$, $y$), координат каждой из вершин "оболочки" объекта;
  • area — площадь объекта;
  • iscrowd — несколько объектов (например, толпа людей), в этом случае информация о границах объекта (маска) хранится в RLE формате;
  • image_id — идентификатор изображения, к которому принадлежит описываемый объект;
  • bbox — будет рассмотрен далее в ходе лекции;
  • category_id — идентификатор категории, к которой относится данный объект;
  • id — идентификатор самого объекта.

Попробуем посмотреть на пример, в котором iscrowd = True .

In [10]:
catIds = coco.getCatIds(catNms=["people"])
annIds = coco.getAnnIds(catIds=catIds, iscrowd=True)
anns = coco.loadAnns(annIds[0:1])

dump_anns(anns)
img = coco.loadImgs(anns[0]["image_id"])[0]
I = coco2pil(img["coco_url"])
plt.imshow(I)
coco.showAnns(anns)  # People in the stands
seg = anns[0]["segmentation"]
print("Counts", len(seg["counts"]))
print("Size", seg["size"])
plt.axis("off")
plt.show()
#0
segmentation :  {'counts': [272, 2, 4, 4, 4, 4, 2, 9, 1, 2, 16, 43, 143, 24, 5, 8, 16, 44, 141, 25, 8, 5, 17, 44, 140, 26, 10, 2, 17, 45, 129, 4, 5, 27, 24, 5, 1, 45, 127, 38, 23, 52, 125, 40, 22, 53, 123, 43, 20, 54, 122, 46, 18, 54, 121, 54, 12, 53, 119, 57, 11, 53, 117, 59, 13, 51, 117, 59, 13, 51, 117, 60, 11, 52, 117, 60, 10, 52, 118, 60, 9, 53, 118, 61, 8, 52, 119, 62, 7, 52, 119, 64, 1, 2, 2, 51, 120, 120, 120, 101, 139, 98, 142, 96, 144, 93, 147, 90, 150, 87, 153, 85, 155, 82, 158, 76, 164, 66, 174, 61, 179, 57, 183, 54, 186, 52, 188, 49, 191, 47, 193, 21, 8, 16, 195, 20, 13, 8, 199, 18, 222, 17, 223, 16, 224, 16, 224, 15, 225, 15, 225, 15, 225, 15, 225, 15, 225, 15, 225, 15, 225, 15, 225, 15, 225, 14, 226, 14, 226, 14, 39, 1, 186, 14, 39, 3, 184, 14, 39, 4, 183, 13, 40, 6, 181, 14, 39, 7, 180, 14, 39, 9, 178, 14, 39, 10, 177, 14, 39, 11, 176, 14, 38, 14, 174, 14, 36, 19, 171, 15, 33, 32, 160, 16, 30, 35, 159, 18, 26, 38, 158, 19, 23, 41, 157, 20, 19, 45, 156, 21, 15, 48, 156, 22, 10, 53, 155, 23, 9, 54, 154, 23, 8, 55, 154, 24, 7, 56, 153, 24, 6, 57, 153, 25, 5, 57, 153, 25, 5, 58, 152, 25, 4, 59, 152, 26, 3, 59, 152, 26, 3, 59, 152, 27, 1, 60, 152, 27, 1, 60, 152, 86, 154, 80, 160, 79, 161, 42, 8, 29, 161, 41, 11, 22, 2, 3, 161, 40, 13, 18, 5, 3, 161, 40, 15, 2, 5, 8, 7, 2, 161, 40, 24, 6, 170, 35, 30, 4, 171, 34, 206, 34, 41, 1, 164, 34, 39, 3, 164, 34, 37, 5, 164, 34, 35, 10, 161, 36, 1, 3, 28, 17, 155, 41, 27, 16, 156, 41, 26, 17, 156, 41, 26, 16, 157, 27, 4, 10, 25, 16, 158, 27, 6, 8, 11, 2, 12, 6, 2, 7, 159, 27, 7, 14, 3, 4, 19, 6, 160, 26, 8, 22, 18, 5, 161, 26, 8, 22, 18, 4, 162, 26, 8, 23, 15, 4, 164, 23, 11, 23, 11, 7, 165, 19, 17, 22, 9, 6, 167, 19, 22, 18, 8, 3, 170, 18, 25, 16, 7, 1, 173, 17, 28, 15, 180, 17, 30, 12, 181, 16, 34, 6, 184, 15, 225, 14, 226, 13, 227, 12, 228, 11, 229, 10, 230, 9, 231, 9, 231, 9, 231, 9, 231, 8, 232, 8, 232, 8, 232, 8, 232, 8, 232, 8, 232, 7, 233, 7, 233, 7, 233, 7, 233, 8, 232, 8, 232, 8, 232, 9, 231, 9, 231, 9, 231, 10, 230, 10, 230, 11, 229, 13, 227, 14, 226, 16, 224, 17, 223, 19, 221, 23, 217, 31, 3, 5, 201, 39, 201, 39, 201, 39, 201, 39, 201, 39, 201, 40, 200, 40, 200, 41, 199, 41, 199, 41, 199, 22, 8, 12, 198, 22, 12, 8, 198, 22, 14, 6, 198, 22, 15, 6, 197, 22, 16, 5, 197, 22, 17, 5, 196, 22, 18, 4, 196, 22, 19, 4, 195, 22, 19, 5, 194, 22, 20, 4, 194, 25, 21, 1, 193, 27, 213, 29, 211, 30, 210, 35, 6, 6, 193, 49, 191, 50, 190, 50, 190, 51, 189, 51, 189, 52, 188, 53, 187, 53, 187, 54, 186, 54, 186, 54, 186, 55, 185, 55, 185, 55, 185, 55, 185, 55, 185, 55, 185, 55, 185, 55, 185, 55, 185, 55, 185, 55, 185, 55, 185, 55, 185, 55, 185, 55, 185, 28, 1, 26, 185, 23, 11, 21, 185, 20, 17, 17, 186, 18, 21, 15, 186, 16, 23, 14, 187, 14, 25, 14, 187, 14, 26, 12, 188, 14, 28, 10, 188, 14, 226, 14, 226, 16, 224, 17, 223, 19, 221, 20, 220, 22, 218, 24, 18, 3, 12, 3, 180, 25, 10, 1, 4, 6, 10, 6, 178, 28, 7, 12, 8, 8, 177, 49, 3, 12, 176, 65, 175, 67, 173, 69, 171, 53, 3, 14, 170, 37, 20, 9, 4, 1, 169, 36, 21, 8, 175, 35, 22, 7, 176, 34, 23, 7, 176, 34, 23, 6, 177, 35, 22, 6, 177, 35, 22, 8, 175, 35, 23, 9, 173, 35, 205, 36, 204, 39, 201, 43, 197, 48, 36, 1, 155, 48, 35, 3, 154, 49, 33, 5, 154, 48, 32, 6, 155, 49, 27, 10, 155, 51, 24, 11, 154, 54, 21, 11, 155, 56, 19, 11, 155, 56, 18, 11, 156, 56, 17, 11, 157, 56, 16, 12, 157, 56, 14, 13, 159, 56, 12, 13, 160, 61, 5, 14, 162, 78, 165, 75, 167, 73, 168, 72, 170, 70, 171, 69, 173, 67, 176, 64, 179, 61, 182, 58, 183, 57, 185, 54, 187, 53, 188, 51, 191, 49, 192, 47, 195, 45, 196, 43, 198, 42, 199, 40, 201, 38, 203, 36, 205, 34, 207, 32, 210, 28, 213, 26, 216, 22, 221, 16, 228, 8, 10250], 'size': [240, 320]}
area :  18419
iscrowd :  1
image_id :  448263
bbox :  [1, 0, 276, 122]
category_id :  1 person
id :  900100448263
Counts 869
Size [240, 320]

Что такое run-length encoding (RLE)

Видео-разбор

Используя методы из pycocotools, можно преобразовать набор вершин "оболочки" сегментируемого объекта в более удобный, но менее компактный вид — маску объекта.

In [11]:
import numpy as np

annIds = coco.getAnnIds(imgIds=[448263])
anns = coco.loadAnns(annIds)
msk = np.zeros(seg["size"])

fig, ax = plt.subplots(nrows=4, ncols=4, figsize=(10, 10))


i = 0
for row in range(4):
    for col in range(4):
        ann = anns[i]
        msk = coco.annToMask(ann)
        ax[row, col].imshow(msk, cmap="gray")
        ax[row, col].set_title(num2cat[anns[i]["category_id"]])
        ax[row, col].axis("off")
        i += 1

plt.show()

В некоторых случаях попиксельная разметка изображения может быть избыточной. К примеру, если необходимо посчитать количество человек на изображении, то достаточно просто каким-то образом промаркировать каждого из них, после чего посчитать количество наших "отметок". Одним из вариантов маркировки является "обведение" объекта рамкой (bounding box), внутри которой он находится. Такая информация об объектах также сохранена в аннотациях формата COCO.

In [12]:
from PIL import Image, ImageDraw

annIds = coco.getAnnIds(imgIds=[448263])
anns = coco.loadAnns(annIds)
draw = ImageDraw.Draw(I)

colors = {1: "white", 40: "lime"}  # person - white, glove - lime
for ann in anns:
    x, y, width, heigth = ann["bbox"]  # bounding box here
    color = colors.get(ann["category_id"], None)
    if color:
        draw.rectangle((x, y, x + width, y + heigth), outline=color, width=2)
plt.imshow(I)
plt.show()

Семантическая сегментация (Semantic segmentation)¶

Сегментация изображения — задача поиска групп пикселей, каждая из которых характеризует один смысловой объект.

Технически это выглядит так. Есть набор изображений:

Для каждого изображения есть маска W x H:

Маска задает класс объекта для каждого пикселя: [ x, y $\to$ class_num ]

Набор таких изображений с масками — это и есть наш датасет, на нем мы учимся.

На вход модель получает новое изображение:

и должна предсказать метку класса для каждого пикселя (маску).

Получим такую маску из COCO:

In [13]:
!wget -qN "http://images.cocodataset.org/annotations/annotations_trainval2017.zip"
# !wget -qN "https://edunet.kea.su/repo/EduNet-web_dependencies/datasets/annotations_trainval2017.zip"
!unzip -qn annotations_trainval2017.zip
In [14]:
import numpy as np
import matplotlib.pyplot as plt
import requests
from io import BytesIO
from PIL import Image, ImageDraw
from pycocotools.coco import COCO
from IPython.display import clear_output

def coco2pil(url):
    response = requests.get(url)
    return Image.open(BytesIO(response.content))


coco = COCO("annotations/instances_val2017.json")
clear_output()

annIds = coco.getAnnIds(imgIds=[448263])
anns = coco.loadAnns(annIds)
img = coco.loadImgs(anns[0]["image_id"])[0]
I = coco2pil(img["coco_url"])

semantic_seg_person_mask = np.zeros(I.size[::-1], dtype=bool)  # WxH -> HxW

for ann in anns:
    msk = coco.annToMask(ann)  # HxW
    if ann["category_id"] == 1 and not ann["iscrowd"]:  # single person:
        # semantic_seg_person_mask = msk | semantic_seg_person_mask  # union
        semantic_seg_person_mask += msk.astype(bool)

semantic_seg_person_mask = semantic_seg_person_mask > 0  # binarize
plt.imshow(semantic_seg_person_mask, cmap="gray")
plt.show()

Способы предсказания класса для каждого пикселя¶

Давайте подумаем о том, как такую задачу можно решить.

Из самой постановки задачи видно, что это задача классификации. Только не всего изображения, а каждого пикселя.

a) Наивный

Простейшим вариантом решения является использование так называемого "скользящего окна" — последовательное рассмотрение фрагментов изображения. В данном случае интересующими фрагментами будут небольшие зоны, окружающие каждый из пикселей изображения. К каждому из таких фрагментов применяется свёрточная нейронная сеть, предсказывающая, к какому классу относится центральный пиксель.

б) Разумный

Понятно, что запускать классификатор для каждого пикселя абсолютно неэффективно, так как для одного изображения потребуется $H*W$ запусков.

Можно пойти другим путем: получить карту признаков и по ней делать предсказание для всех пикселей разом.

Для этого потребуется поменять привычную нам архитектуру сверточной сети следующим образом:

  • убрать слои, уменьшающие пространственные размеры;

  • убрать линейный слой в конце, заменив его сверточным.

Теперь пространственные размеры выхода (W,H) будут равны ширине и высоте исходного изображения.

Количество выходных каналов будет равно количеству классов, которые мы учимся предсказывать.

Тогда можно использовать значения каждой из карт активаций на выходе последнего слоя сети как ненормированное значение вероятности принадлежности (core) каждого из пикселей к тому или иному классу.

То есть номер канала с наибольшим значением будет соответствовать классу объекта, который изображает данный пиксель.

In [15]:
import torch

last_layer_output = torch.randn((3, 32, 32))  # class_num, W,H
print("Output of last layer shape", last_layer_output.shape)  # activation slice
mask = torch.argmax(last_layer_output, dim=0)  # class_nums prediction
print("One class mask shape", mask.shape)
print("Predictions for all classes \n", mask[:5, :5])
Output of last layer shape torch.Size([3, 32, 32])
One class mask shape torch.Size([32, 32])
Predictions for all classes 
 tensor([[0, 0, 2, 0, 1],
        [2, 2, 0, 1, 2],
        [1, 1, 2, 1, 1],
        [0, 2, 2, 2, 2],
        [1, 0, 1, 0, 1]])

Чтобы на выходе сети получить количество каналов, равное количеству классов, используется свертка 1x1.

В лекции про сверточные сети мы говорили о том, что свертку 1x1 можно рассматривать как аналог полносвязного слоя. Именно так она тут и работает.

Проблемы:

  • чтобы рецептивное поле нейронов на последних слоях было сопоставимо с размером изображения, требуется много сверточных слоев ($L$ раз свёртка $3\times3$ $\to$ рецептивное поле $(1+2L)$);
  • свертки медленно работают на полноразмерных картах активации.

в) Эффективный

Используем стандартную сверточную сеть, но полносвязные слои заменим на сверточные.

Fully Convolutional Networks¶

Fully Convolutional Networks for Semantic Segmentation

Сокращенно FCN. Для того, чтобы не было путаницы с Fully Connected Network, последние именуют MLP (Multi Layer Perceptron).

За основу берется обычная сверточная сеть для классификации:

Source: Fully Convolutional Networks for Semantic Segmentation

Такую сеть можно построить, взяв за основу другую сверточную архитектуру (backbone), например, ResNet50 или VGG16.

Source: Fully Convolutional Networks for Semantic Segmentation

И затем заменить полносвязные слои на свертки.

Source: Fully Convolutional Networks for Semantic Segmentation

В конце добавить upsample слой до нужных нам размеров.

На вход такая модель может получать изображение произвольного размера. Для задач сегментации изменение размеров входного изображения приводит к потере важной информации о границах.

Разжимающий слой¶

Как реализовать декодировщик?

Интерполяция при увеличении разрешения¶

Вспомним, как повышают разрешение для обычных изображений, а уже затем перейдем к картам признаков.

Допустим, требуется увеличить изображение размером 2x2 до размера 4x4.

Source: Bilinear_interpolation

Если для интерполяции используются значения четырех соседних пикселей, то такая интерполяция называется билинейной. В качестве интерполированного значения используется взвешенное среднее этих четырёх пикселей.

In [16]:
import seaborn as sns


def img_to_heatmap(img, ax, title):  # Magik method to show img as heatmap
    ax.axis("off")
    ax.set_title(title)
    array = np.array(img)
    array = array[None, None, :]
    sns.heatmap(array[0][0], annot=True, ax=ax, lw=1, cbar=False)


# Fake image
raw = np.array([[1, 3, 0, 1], [3, 3, 3, 7], [8, 1, 8, 7], [6, 1, 1, 1]], dtype=np.uint8)
pil = Image.fromarray(raw)

interp_nn = pil.resize((8, 8), resample=Image.NEAREST)
interp_bl = pil.resize((8, 8), resample=Image.BILINEAR)

# plot result
fig, ax = plt.subplots(ncols=3, figsize=(15, 5), sharex=True, sharey=True)
img_to_heatmap(raw, ax[0], "Raster dataset")
img_to_heatmap(interp_nn, ax[1], "Nearest neighbor interpolation")
img_to_heatmap(interp_bl, ax[2], "Bilinear interpolation")
plt.show()

Билинейная интерполяция позволяет избавиться от резких границ, которые возникают при увеличении методом ближайшего соседа. Существуют и другие виды интерполяции, использующие большее количество соседних пикселей.

Upsample в PyTorch¶

К чему был этот разговор об увеличении картинок?

Оказывается, для увеличения пространственного разрешения карт признаков (feature maps) можно применять те же методы, что и для изображений.

Для увеличения пространственного разрешения карт признаков (карт активаций), в PyTorch используется класс nn.Upsample. В нём доступны все упомянутые методы интерполяции, а также трилинейная интерполяция — аналог билинейной интерполяции, используемый для работы с трёхмерными пространственными данными (к примеру, видео).

[doc] nn.functional.interpolate

Таким образом, мы можем использовать Upsample внутри нашего разжимающего блока.

Загрузим изображение:

In [17]:
!wget -q https://edunet.kea.su/repo/EduNet-content/L12/out/semantic_segmentation_1.png -O cat.png
In [18]:
from torch import nn
import torchvision.transforms.functional as TF


def upsample(pil, ax, mode="nearest"):
    tensor = TF.to_tensor(pil)
    # Create upsample instance

    if mode == "nearest":
        upsampler = nn.Upsample(scale_factor=2, mode=mode)
    else:
        upsampler = nn.Upsample(scale_factor=2, mode=mode, align_corners=True)

    tensor_128 = upsampler(tensor.unsqueeze(0))  # add batch dimension
    # Convert tensor to pillow
    img_128 = tensor_128.squeeze()
    img_128_pil = TF.to_pil_image(img_128.clamp(min=0, max=1))
    ax.imshow(img_128_pil)
    ax.set_title(mode)


# Load and show image in Pillow format
pic = Image.open("cat.png")
pil_64 = pic.resize((64, 64))
fig, ax = plt.subplots(ncols=4, figsize=(15, 5))
ax[0].imshow(pil_64)
ax[0].set_title("Raw")

# Upsample with Pytorch
upsample(pil_64, mode="nearest", ax=ax[1])
upsample(pil_64, mode="bilinear", ax=ax[2])
upsample(pil_64, mode="bicubic", ax=ax[3])
plt.show()

Обратите внимание на то, что в данном случае каждое из пространственных измерений изображения увеличилось в 2 раза, но при необходимости возможно использовать увеличение в иное, в том числе не целое, количество раз, используя параметр scale_factor

Слои Upsample обычно комбинируют вместе со сверточными, это рекомендованный способ увеличения пространственных размеров карт признаков.

In [19]:
model = nn.Sequential(
    nn.Upsample(scale_factor=2), nn.Conv2d(3, 16, kernel_size=3, padding=1), nn.ReLU()
)

dummy_input = torch.randn((0, 3, 32, 32))
out = model(dummy_input)
print(out.shape)
torch.Size([0, 16, 64, 64])

Другие способы "разжать" карту признаков¶

MaxUnpooling¶

Помимо свёртки, на этапе снижения размерности также используются слои pooling'а. Наиболее популярным вариантом является MaxPooling, сохраняющий значение только наибольшего элемента внутри сегмента. Для того, чтобы обратить данную операцию субдискретизации, был предложен MaxUnpooling слой.

Данный слой требует сохранения индексов максимальных элементов внутри сегментов — при обратной операции максимальное значение помещается на место, в котором был максимальный элемент сегмента до соответствующей субдискретизации. Соответственно, каждому слою MaxUnpooling должен соответствовать слой MaxPooling, что визуально можно представить следующим образом:

Документация к MaxPool2d

Документация к MaxUnpool2d

In [20]:
import requests
from io import BytesIO

torch.use_deterministic_algorithms(False, warn_only=False)


def coco2pil(url):
    print(url)
    response = requests.get(url)
    return Image.open(BytesIO(response.content))


def tensor_show(tensor, title="", ax=ax):
    img = TF.to_pil_image(tensor.squeeze()).convert("RGB")
    ax.set_title(title + str(img.size))
    ax.imshow(img)


pool = nn.MaxPool2d(
    kernel_size=2, return_indices=True
)  # False by default(get indexes to upsample)
unpool = nn.MaxUnpool2d(kernel_size=2)

pil = coco2pil("http://images.cocodataset.org/val2017/000000448263.jpg")

fig, ax = plt.subplots(ncols=5, figsize=(20, 5), sharex=True, sharey=True)

ax[0].set_title("original " + str(pil.size))
ax[0].imshow(pil)
tensor = TF.to_tensor(pil).unsqueeze(0)
print("Orginal shape", tensor.shape)

# Downsample
tensor_half_res, indexes1 = pool(tensor)
tensor_show(tensor_half_res, "1/2 down ", ax=ax[1])

tensor_q_res, indexes2 = pool(tensor_half_res)
tensor_show(tensor_q_res, "1/4 down ", ax=ax[2])
print("Downsample shape", indexes2.shape)

# Upsample
tensor_half_res1 = unpool(tensor_q_res, indexes2)
tensor_show(tensor_half_res1, "1/2 up ", ax=ax[3])


tensor_recovered = unpool(tensor_half_res1, indexes1)
tensor_show(tensor_recovered, "full size up ", ax=ax[4])
print("Upsample shape", tensor_recovered.shape)
plt.show()
http://images.cocodataset.org/val2017/000000448263.jpg
Orginal shape torch.Size([1, 3, 240, 320])
Downsample shape torch.Size([1, 3, 60, 80])
Upsample shape torch.Size([1, 3, 240, 320])

Transposed convolution¶

Способы восстановления пространственных размерностей, которые мы рассмотрели, не содержали обучаемых параметров.

Для повышения пространственного разрешения карты признаков можно использовать операцию Transposed convolution, в которой, как в обычной свертке, есть обучаемые параметры. Альтернативное название: Fractionally strided convolution.

Иногда некорректно называется обратной сверткой или Deconvolution.

Обычная свертка

Операция обычной свертки накладывает фильтр-ядро на фрагмент карты, выполняет поэлементное умножение, а затем сложение, превращая один фрагмент входа в один пиксель выхода.

Transposed convolution, наоборот, проходит по всем пикселям входа и умножает их на обучаемое ядро свертки. При этом каждый одиночный пиксель превращается в фрагмент. Там, где фрагменты накладываются друг на друга, значения попиксельно суммируются.

Если вход имеет несколько каналов, то Transposed convolution применяет отдельный обучаемый фильтр к каждому каналу, а результат суммирует.

Параметр stride отвечает за дополнительный сдвиг каждого фрагмента на выходе. Используя Transposed convolution с параметром stride = 2, можно повышать размер карты признаков приблизительно в два раза, добавляя на нее мелкие детали.

Transposed convolution

В отличие от обычной свертки, параметр padding Transposed convolution отвечает не за увеличение исходной карты признаков, а, наоборот, за "срезание" внешнего края карты-выхода. Это может быть полезно, потому что карта строится с перекрытием фрагментов, полученных из соседних пикселей, но по периметру результат формируется без перекрытия и может иметь более низкое качество.

Как правило, размер ядра kernel_size выбирают кратным stride, чтобы избавиться от артефактов при частичном наложении фрагментов, например:

kernel_size = 4
stride = 2

При таких значениях имеет смысл установить padding=2, чтобы убрать внешние два пикселя со всех сторон выходной карты признаков, полученные без перекрытия.

Блог-пост про 2d свертки с помощью перемножения матриц

Откуда слово Transposed в названии (раздел 4.1)

Документация к ConvTranspose2d

torch.nn.ConvTranspose2d(in_channels, out_channels, kernel_size,
                         stride=1, padding=0, ...)

где:

  • in_channels, out_channels — количество каналов в входной и выходной карте признаков,
  • kernel_size — размер ядра свертки Transpose convolution,
  • stride — шаг свертки Transpose convolution,
  • padding— размер отступов, устанавливаемых по краям входной карты признаков.

Пример использования:

In [21]:
input = torch.randn(1, 16, 16, 16)  # define dummy input
print("Original size", input.shape)

downsample = nn.Conv2d(16, 16, 3, stride=2, padding=1)  # define downsample layer
upsample = nn.ConvTranspose2d(16, 16, 3, stride=2, padding=1)  # define upsample layer

# let`s downsample and upsample input
with torch.no_grad():
    output_1 = downsample(input)
    print("Downsampled size", output_1.size())

    output_2 = upsample(output_1, output_size=input.size())
    print("Upsampled size", output_2.size())

# plot results
fig, ax = plt.subplots(ncols=3, figsize=(15, 5), sharex=True, sharey=True)
sns.heatmap(input[0, 0, :, :], ax=ax[0], cbar=False, vmin=-2, vmax=2)
ax[0].set_title("Input")
sns.heatmap(output_1[0, 0, :, :], ax=ax[1], cbar=False, vmin=-2, vmax=2)
ax[1].set_title("Downsampled")
sns.heatmap(output_2[0, 0, :, :], ax=ax[2], cbar=False, vmin=-2, vmax=2)
ax[2].set_title("Upsampled")
plt.show()
Original size torch.Size([1, 16, 16, 16])
Downsampled size torch.Size([1, 16, 8, 8])
Upsampled size torch.Size([1, 16, 16, 16])

Пирамида признаков¶

Возникает вопрос: не потеряется ли информация о мелких деталях изображения при передаче через центральный блок сети, где пространственное разрешение минимально? Такая проблема существует.

Те, кто изучал классические методы машинного зрения, помнят, что при извлечении дескрипторов особых точек(SIFT) использовалась так называемая пирамида изображений.

Идея состоит в последовательном уменьшении (масштабировании) изображения и последовательном извлечении признаков в разных разрешениях.

При уменьшении пространственных размеров мы естественным образом получаем карты признаков с разным пространственным разрешением.

Их можно использовать одновременно как в качестве входа для новых сверток, так и для получения предсказаний.

На этой модели построены FPN-сети: Feature Pyramid Networks for Object Detection.

Source: Fully Convolutional Networks for Semantic Segmentation

После того, как все карты признаков будут увеличены до одного размера, они суммируются.

Примеры использования в PyTorch:

  1. Fully-Convolutional Network model with ResNet-50 and ResNet-101 backbones,
  2. Models for Semantic Segmentation.

Предобученная модель была обучена на части датасета COCO train2017 (на 20 категориях, представленных так же в датасете Pascal VOC). Использовались следующие классы:

['__background__', 'aeroplane', 'bicycle', 'bird', 'boat', 'bottle', 'bus', 'car', 'cat', 'chair', 'cow', 'diningtable', 'dog', 'horse', 'motorbike', 'person', 'pottedplant', 'sheep', 'sofa', 'train', 'tvmonitor']

In [22]:
import torchvision
from torchvision import transforms


def coco2pil(url):
    print(url)
    response = requests.get(url)
    return Image.open(BytesIO(response.content))


# load resnet50
fcn_model = torchvision.models.segmentation.fcn_resnet50(
    weights="FCN_ResNet50_Weights.DEFAULT", num_classes=21
)

classes = [
    "__background__",
    "aeroplane",
    "bicycle",
    "bird",
    "boat",
    "bottle",
    "bus",
    "car",
    "cat",
    "chair",
    "cow",
    "diningtable",
    "dog",
    "horse",
    "motorbike",
    "person",
    "pottedplant",
    "sheep",
    "sofa",
    "train",
    "tvmonitor",
]

transform = transforms.Compose(
    [
        transforms.ToTensor(),
        transforms.Normalize(
            mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]
        ),  # ImageNet
    ]
)

pil_img = coco2pil("http://images.cocodataset.org/val2017/000000448263.jpg")
input_tensor = transform(pil_img)

with torch.no_grad():
    output = fcn_model(input_tensor.unsqueeze(0))
Downloading: "https://download.pytorch.org/models/fcn_resnet50_coco-1167a1af.pth" to /root/.cache/torch/hub/checkpoints/fcn_resnet50_coco-1167a1af.pth
100%|██████████| 135M/135M [00:00<00:00, 236MB/s]
http://images.cocodataset.org/val2017/000000448263.jpg

Возвращаются 2 массива

  • out — каждый пиксель отражает ненормированную вероятность, соответствующую предсказанию каждого класса.
In [23]:
print("output keys: ", output.keys())  # Ordered dictionary
print("out: ", output["out"].shape, "Batch, class_num, h, w")

output_predictions = output["out"][0].argmax(0)  # for first element of batch
print(f"output_predictions: {output_predictions.shape}")

fig = plt.figure(figsize=(10, 10))
plt.imshow(pil_img)
plt.axis("off")
plt.show()

indexes = output_predictions
semantic_seg_person_predict = np.zeros(pil_img.size).astype(bool)

# plot all classes predictions
fig, ax = plt.subplots(nrows=4, ncols=5, figsize=(10, 10))
i = 0  # counter
for row in range(4):
    for col in range(5):
        mask = torch.zeros(indexes.shape)
        mask[indexes == i] = 255

        ax[row, col].set_title(classes[i])
        ax[row, col].imshow(mask)
        ax[row, col].axis("off")
        i += 1

plt.show()
output keys:  odict_keys(['out', 'aux'])
out:  torch.Size([1, 21, 240, 320]) Batch, class_num, h, w
output_predictions: torch.Size([240, 320])
In [24]:
semantic_seg_person_predict = torch.zeros(indexes.shape)
semantic_seg_person_predict[indexes == 15] = 1  # to obtain binary mask
semantic_seg_person_predict = (
    semantic_seg_person_predict.numpy()
)  # for skliarn compability

plt.imshow(semantic_seg_person_predict, cmap="gray")
plt.show()

IoU — оценка точности¶

Как оценить качество предсказаний, полученных от модели?

Базовой метрикой является Intersection over Union (IoU), она же коэффициент Жаккара (Jaccard index)

Имеется предсказание модели (фиолетовая маска) и целевая разметка, сделанная человеком (красная маска)*.

Source: Intersection over Union

Необходимо оценить качество предсказания.

*Для простоты в примере маски прямоугольные, но та же логика будет работать для масок произвольной формы.

Метрика считается как отношение площади пересечения к площади объединения двух масок:

$$ \large IoU = \frac{|T \cap P|}{|T \cup P|} $$

T — True mask, P — predicted mask.

Если маски совпадут на 100%, то значение метрики будет равно 1, и это наилучший результат. При пустом пересечении IoU будет нулевым. Значения метрики лежат в интервале $[0..1]$.

В терминах ошибок первого/второго рода IoU можно записать как:

$$ \large IoU = \frac{TP}{TP + FP + FN} $$

TP — True positive — пересечение (обозначено желтым),

FP — False Positive (остаток фиолетового прямоугольника),

FN — False Negative (остаток красного прямоугольника).

На базе этой метрики строится ряд производных от нее метрик, таких как Mean Average Precision, которую мы рассмотрим в разделе Детектирование.

In [25]:
import matplotlib.pyplot as plt

plt.figure(figsize=(15, 10))


plt.subplot(1, 3, 1)
plt.axis("off")
plt.title("GT mask")
plt.imshow(semantic_seg_person_mask)

plt.subplot(1, 3, 2)
plt.axis("off")
plt.title("Predicted mask")
plt.imshow(semantic_seg_person_predict)

plt.subplot(1, 3, 3)
plt.title("GT & Predict overlap")
plt.axis("off")
tmp = semantic_seg_person_predict * 2 + semantic_seg_person_mask
plt.imshow(tmp)
plt.show()

Дополнительная информация:

Intersection over Union

SKLearn

SMP (func)

Torchmetrics(obj)

Torchvision (bbox)

In [26]:
from sklearn.metrics import jaccard_score

# wait vectors, so we flatten the data
y_true = semantic_seg_person_mask.flatten()
y_pred = semantic_seg_person_predict.flatten()
iou = jaccard_score(y_true, y_pred)

print(f"IoU = {iou:.2f}")
IoU = 0.48
In [27]:
from sklearn.metrics import confusion_matrix

# https://stackoverflow.com/questions/31324218/scikit-learn-how-to-obtain-true-positive-true-negative-false-positive-and-fal
# or use:  smp.metrics.get_stats
# https://smp.readthedocs.io/en/latest/metrics.html#segmentation_models_pytorch.metrics.functional.get_stats
tn, fp, fn, tp = confusion_matrix(y_true, y_pred, labels=[0, 1]).ravel()
iou = tp / (tp + fp + fn)
print(f"IoU = {iou:.2f}")
IoU = 0.48

Loss функции для сегментации¶

Source: Loss functions for image segmentation

Distribution-based loss¶

Так как задача сегментации сводится к задаче классификации, то можно использовать Cross-Entropy Loss, BCE, или Focal Loss, с которыми мы знакомы.

Binary Cross-Entropy (BCE)¶

Если предсказывается маска для объектов единственного класса(target.shape = 1xHxW), то задача сводится к бинарной классификации, так как каждый канал на выходе последнего слоя выдает предсказание для единственного класса.

Это позволяет заменить Softmax в Cross-Entropy Loss на сигмоиду, а функцию потерь — на бинарную кросс-энтропию (BCE).

In [28]:
import torch

mask_class_1 = torch.randint(0, 2, (1, 64, 64))  # [0 , 1]
one_class_out = torch.randn(1, 1, 64, 64)
print(mask_class_1.shape)
print(one_class_out.shape)
torch.Size([1, 64, 64])
torch.Size([1, 1, 64, 64])

Применяем BCEWithLogitsLoss

In [29]:
from torch import nn

bce_loss_wl = nn.BCEWithLogitsLoss()  # Sigmoid inside
loss = bce_loss_wl(
    one_class_out, mask_class_1.float().unsqueeze(0)
)  # both params must have equal size
print(loss)
tensor(0.8085)

Если последний слой модели — это Сигмоида, то можем использовать BCELoss.

In [30]:
norm_one_class_out = one_class_out.sigmoid()

bce_loss = nn.BCELoss()
loss = bce_loss(
    norm_one_class_out, mask_class_1.float().unsqueeze(0)
)  # both params must have equal size
print(loss)
tensor(0.8085)

CrossEntropyLoss для одного класса работать не будет:

In [31]:
cross_entropy = nn.CrossEntropyLoss()
loss = cross_entropy(one_class_out, mask_class_1.float().unsqueeze(0))
print(loss)
tensor(-0.)

Так как softmax от единственного входа всегда равен 1.

In [32]:
one_class_out.softmax(dim=1).unique()
Out[32]:
tensor([1.])

Multilabel

Если предсказывается несколько классов и target имеет форму $N \times W \times H$ (multilabel), то маска каждого хранится в отдельном канале:

Source: An overview of semantic image segmentation
In [33]:
import torch

mask_class1 = torch.randint(0, 2, (1, 64, 64))  # [0 , 1]
mask_class2 = torch.randint(0, 2, (1, 64, 64))

target = torch.cat((mask_class1, mask_class2))

print(target.shape)
torch.Size([2, 64, 64])

Видим, что форма выхода модели совпадает с формой тензора масок:

In [34]:
two_class_out = torch.randn(1, 2, 64, 64)
print(two_class_out.shape)
torch.Size([1, 2, 64, 64])

Мы можем посчитать BCE поэлементно, предварительно преобразовав target во float

In [35]:
from torch import nn

# https://pytorch.org/docs/stable/generated/torch.nn.BCEWithLogitsLoss.html

bce_loss = nn.BCEWithLogitsLoss()  # Sigmoid inside
float_target = target.float()  # add batch and convert ot float
loss = bce_loss(
    two_class_out, float_target.unsqueeze(0)
)  # both params must have equal size
print(loss)
tensor(0.8036)

Или CrossEntropyLoss

In [36]:
cross_entropy = nn.CrossEntropyLoss()
# If containing class probabilities, same shape as the input and each value should be between [0,1][0,1].
loss = cross_entropy(two_class_out, float_target.unsqueeze(0))
print(loss)
tensor(0.9137)

Результаты не совпадут, так как после Sigmoid и Softmax получатся разные вероятности.

Cross-Entropy¶

Если маска задана одним каналом, в котором классы пронумерованы целыми числами (multiclass):

Source: An overview of semantic image segmentation
In [37]:
sq_target = target.argmax(0)
sq_target.shape
Out[37]:
torch.Size([64, 64])

То логично использовать CrossEntropyLoss

Input: Shape (C), (N,C) or (N,C,d1​,d2​,...,dK​) with K≥1 in the case of K-dimensional loss.

Target: If containing class indices, shape (), (N) or (N,d1,d2,...,dK)
In [38]:
# https://pytorch.org/docs/stable/generated/torch.nn.CrossEntropyLoss.html
cross_entropy = nn.CrossEntropyLoss()

loss = cross_entropy(two_class_out, sq_target.unsqueeze(0))
print(loss)
tensor(0.9150)

Region-based loss¶

К Region-based loss относятся функции потерь, основанные на оценки площади пересечения масок.

Jaccard Loss¶

В отличие от accuracy, рассчет IoU (Jaccard index):

$\large IoU = JaccardIndex = \dfrac{ TP }{TP + FP + FN} \in [0,1]$

можно произвести дифференцируемым образом.

И тогда метрику можно превратить в функцию потерь, инвертировав ее:

$\large Jaccard Loss = 1 - IoU$

В PyTorch эта функция потерь не реализована, поэтому для ее использования установим библиотеку SMP.

In [39]:
!pip install -q segmentation-models-pytorch

clear_output()
In [40]:
import segmentation_models_pytorch as smp

iou_loss = smp.losses.JaccardLoss(smp.losses.MULTILABEL_MODE, from_logits=True)
print("IoU Loss", iou_loss(two_class_out, float_target.unsqueeze(0)))
IoU Loss tensor(0.6630)

*При наличии на выходе модели сигмоиды или другой функции, преобразующей логиты в вероятности, параметр from_logits следует уствновить равным False

Dice loss¶

Другой популярной метрикой для оценки качества сегментации является Dice коэффициент:

$$ \large \displaystyle Dice = \frac{2|A \cap B|}{|A| + |B|} $$

Концептульно он похож на IoU, но при выражении через ошибки первого и второго рода будет видно, что он совпадет с F1-мерой:

$$ \large Dice = \frac{2|A \cap B|}{|A| + |B|} = \dfrac{ 2TP }{2TP + FP + FN} = F1\_score ∈ [0,1]$$

F1 Score = Dice Coefficient

Его вычисление не сложно произвести дифференцируемым образом:

$ |A \cap B| = \begin{bmatrix} 0.01 & 0.03 & 0.02 & 0.02 \\ 0.05 & 0.12 & 0.09 & 0.07 \\ 0.89 & 0.85 & 0.88 & 0.91 \\ 0.99 & 0.97 & 0.95 & 0.97 \end{bmatrix} * \begin{bmatrix} 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 \\ 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 \end{bmatrix} \xrightarrow{\ element-wise \ multiply \ } \begin{bmatrix} 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 \\ 0.89 & 0.85 & 0.88 & 0.91 \\ 0.99 & 0.97 & 0.95 & 0.97 \end{bmatrix} \xrightarrow{\ sum \ } 7.41$

$\qquad \qquad \qquad \qquad \text{prediction} \qquad \qquad \qquad \quad \text{target}$

$ |A| = \begin{bmatrix} 0.01 & 0.03 & 0.02 & 0.02 \\ 0.05 & 0.12 & 0.09 & 0.07 \\ 0.89 & 0.85 & 0.88 & 0.91 \\ 0.99 & 0.97 & 0.95 & 0.97 \end{bmatrix} \xrightarrow{\ sum \ } 7.82 \qquad \qquad|B| = \begin{bmatrix} 0 & 0 & 0 & 0 \\ 0 & 0 & 0 & 0 \\ 1 & 1 & 1 & 1 \\ 1 & 1 & 1 & 1 \end{bmatrix} \xrightarrow{\ sum \ } 8 $

$ \Large Dice = \frac{2 \sum\limits_{pixels}y_{true}y_{pred}}{\sum\limits_{pixels}y_{true} + \sum\limits_{pixels}y_{pred}}$

И затем превратить в функцию потерь:

$ \Large DiceLoss = 1 - Dice = 1 - \frac{2\sum\limits_{pixels}y_{true}y_{pred}}{\sum\limits_{pixels}y_{true} + \sum\limits_{pixels}y_{pred}} $

Эта функция потерь также отсутствует в PyTorch, поэтому воспользуемся библиотекой SMP.

In [41]:
dice_loss = smp.losses.DiceLoss(smp.losses.MULTILABEL_MODE, from_logits=True)

print(two_class_out.shape, target.shape)
print("DICE Loss", dice_loss(two_class_out, float_target.unsqueeze(0)))
torch.Size([1, 2, 64, 64]) torch.Size([2, 64, 64])
DICE Loss tensor(0.4959)

Концептуально DiceLoss и JaccardLoss похожи. Но JaccardLoss сильнее штрафует модель на выбросах.

Хороший обзор loss функций для семантической сегментации можно найти в репозитории: Loss functions for image segmentation

U-Net: Convolutional Networks for Biomedical Image Segmentation¶

Сохраним большую часть традиционной структуры сети, включая слои downsampling (снижение пространственных размеров), извлечем основные признаки, а затем восстановим пространственные размеры.

Автокодировщик¶

Эта архитектура повторяет архитектуру автокодировщика (autoencoder)

Такая архитектура довольно популярна и применяется не только для сегментации, но и в следующих задачах:

  • сглаживание шума;
  • снижение размерности $\to$ вектор-признак;
  • генерация данных.

Этому будет посвящена одна из следующих лекций, сейчас же мы детально рассмотрим разжимающий блок.

Признаки, полученные при сжатии, скопируем и передадим в разжимающие слои, где карты признаков будут иметь соответствующее пространственное разрешение:

Так же, как и ResNet, этот механизм носит название skip connection, но признаки не суммируются, а конкатенируются.

Рассмотренная нами схема используется в U-Net. Эта популярная модель для сегментации медицинских изображений изначально была предложена в статье U-Net: Convolutional Networks for Biomedical Image Segmentation (Ronneberger et al., 2015) для анализа медицинских изображений.

Архитектура U-Net
Source: U-Net: Convolutional Networks for Biomedical Image Segmentation (Ronneberger et al., 2015)

Реализация на PyTorch

U-Net на PyTorch Hub

Стоит обратить особое внимание на серые стрелки на схеме: они соответствуют операции конкатенации копий ранее полученных карт активаций по аналогии с DenseNet. Чтобы это было возможно, необходимо поддерживать соответствие между размерами карт активаций в процессах снижения и повышения пространственных размерностей. Для этой цели изменения размеров происходят только при операциях MaxPool и MaxUnpool — в обоих случаях в два раза.

В коде прямой проход может быть реализован, например, вот так:

def forward(self, x):
    out1 = self.block1(x) #  ------------------------------>
    out_pool1 = self.pool1(out1)

    out2 = self.block2(out_pool1)
    out_pool2 = self.pool2(out2)

    out3 = self.block3(out_pool2)
    out_pool3 = self.pool2(out3)

    out4 = self.block4(out_pool3)
    # return up
    out_up1 = self.up1(out4)

    out_cat1 = torch.cat((out_up1, out3), dim=1)
    out5 = self.block5(out_cat1)
    out_up2 = self.up2(out5)

    out_cat2 = torch.cat((out_up2, out2), dim=1)
    out6 = self.block6(out_cat2)
    out_up3 = self.up3(out6)

    out_cat3 = torch.cat((out_up3, out1), dim=1) # <-------
    out = self.block7(out_cat3)

    return out

После upsample блоков ReLU не используется.

Обзор DeepLabv3+ (2018)¶

DeepLab — семейство моделей для сегментации, значительно развивавшееся в течение четырёх лет. Основой данного рода моделей является использование atrous (dilated) convolutions и, начиная со второй модели, atrous spatial pyramid pooling, опирающейся на spatial pyramid pooling.

Encoder-Decoder with Atrous Separable Convolution for Semantic Image Segmentation (Chen et al., 2018)

Реализация на PyTorch

Source: Semantic Image Segmentation with Deep Convolutional Neural Networks and Quick Shift

Atrous (Dilated) Convolution¶

Dilated convolution (расширенная свертка) — это тип свертки, который "раздувает" ядро, как бы вставляя отверстия между элементами ядра. Дополнительный параметр (скорость расширения, dilation) указывает, насколько сильно расширяется ядро.

Фактически в такой свертке входные пиксели (признаки) участвуют через один (два, три ...).

Расширенные свертки позволяют значительно увеличить рецептивное поле и хорошо показывают себя при решении задач семантической сегментации изображений.

torch.nn.Conv2d(in_channels, out_channels, kernel_size, stride=1,
                padding=0, dilation=1, ...)

где:

  • in_channels, out_channels — количество каналов в входной и выходной карте признаков,
  • kernel_size — размер ядра свертки,
  • stride — шаг свертки,
  • padding — размер отступов, устанавливаемых по краям входной карты признаков,
  • dilation — скорость расширения свертки.

[doc] nn.Conv2d

Дополнительная информация про Atrous convolution¶

In [42]:
# for plot
def plot_conv2d(input, conv, output, dilation=1):
    fig, ax = plt.subplots(ncols=3, figsize=(15, 5), sharex=False, sharey=False)
    # input
    sns.heatmap(
        input[0][0],
        ax=ax[0],
        annot=True,
        fmt=".0f",
        cbar=False,
        vmin=0,
        vmax=20,
        linewidths=1,
    )
    # kernel
    sns.heatmap(
        conv.weight.detach()[0, 0, :, :],
        ax=ax[1],
        annot=True,
        fmt=".0f",
        cbar=False,
        vmin=0,
        vmax=20,
        linewidths=1,
    )
    # output
    sns.heatmap(
        output[0, 0, :, :],
        ax=ax[2],
        annot=True,
        fmt=".0f",
        cbar=False,
        vmin=0,
        vmax=20,
        linewidths=1,
    )
    # titles
    ax[0].set_title("Input \nshape: " + str(input.shape))
    ax[1].set_title("Kernel \nshape: " + str(conv.weight.shape))
    ax[2].set_title("Output \nshape: " + str(output.shape))
    fig.suptitle("Dilation = " + str(dilation), y=1.05)

    plt.show()
In [43]:
# Atrous example
with torch.no_grad():
    # define dummy input
    # https://stackoverflow.com/questions/58584413/black-formatter-ignore-specific-multi-line-code
    # fmt: off
    input = torch.tensor([[[[1, 2, 3],
                            [1, 2, 3],
                            [1, 2, 3]]]], dtype=torch.float)
    # fmt: on
    # define conv layer, dilation = 1
    conv = nn.Conv2d(1, 1, kernel_size=2, dilation=1, bias=False)
    # define kernel weights
    conv.weight = nn.Parameter(torch.tensor([[[[1, 2], [2, 1]]]], dtype=torch.float))
    output = conv(input)
    plot_conv2d(input, conv, output, dilation=1)
In [44]:
# change dilation to 2
with torch.no_grad():
    conv = nn.Conv2d(
        1, 1, kernel_size=2, dilation=2, bias=False
    )  # Fell free to change dilation
    conv.weight = nn.Parameter(torch.tensor([[[[1, 2], [2, 1]]]], dtype=torch.float))
    output = conv(input)
    plot_conv2d(input, conv, output, dilation=2)
In [45]:
with torch.no_grad():
    input = torch.tensor([[[[0, 1, 0], [1, 1, 1], [0, 1, 0]]]], dtype=torch.float)
    output = conv(input)
    plot_conv2d(input, conv, output, dilation=2)

Segmentation models PyTorch (SMP)¶

Библиотека на базе PyTorch для сегментации.

In [46]:
!pip install -q segmentation-models-pytorch

Можем комбинировать декодер с разными энкодерами

In [47]:
import segmentation_models_pytorch as smp
from segmentation_models_pytorch.encoders import get_preprocessing_fn
import torch

# 'mit_b0' Mix Vision Transformer Backbone from SegFormer pretrained on Imagenet
preprocess_input = get_preprocessing_fn("mit_b0", pretrained="imagenet")

# MixVisionTransformer encoder does not support in_channels setting other than 3
# supported by FPN only for encoder depth = 5
model = smp.FPN("mit_b0", in_channels=3, classes=10, encoder_depth=5)

# ... Train model on your dataset

dummy_input = torch.randn([1, 3, 64, 64])

mask = model(dummy_input)
print(mask.shape)  # torch.Size([1, 1, 64, 64])
Downloading: "https://github.com/qubvel/segmentation_models.pytorch/releases/download/v0.0.2/mit_b0.pth" to /root/.cache/torch/hub/checkpoints/mit_b0.pth
100%|██████████| 13.7M/13.7M [00:00<00:00, 121MB/s]
torch.Size([1, 10, 64, 64])

Совместимость с timm¶

Существует библиотека pytorch-image-models (timm = Torch IMage Models), в которой собрано большое количество моделей для работы с изображениями.

Описание библиотеки и примеры использования в HuggingFace.

In [48]:
import timm

model_names = timm.list_models(pretrained=True)
print("Total pretrained models: ", len(model_names))
Total pretrained models:  1163

Можно искать модели по шаблону

In [49]:
model_names = timm.list_models("*mobilenet*small*")
print(model_names)
['mobilenetv3_small_050', 'mobilenetv3_small_075', 'mobilenetv3_small_100', 'tf_mobilenetv3_small_075', 'tf_mobilenetv3_small_100', 'tf_mobilenetv3_small_minimal_100']

Smoke test

In [50]:
timm_mobilenet = timm.create_model("mobilenetv3_small_050", pretrained=True)
Downloading model.safetensors:   0%|          | 0.00/6.42M [00:00<?, ?B/s]
In [51]:
out = timm_mobilenet(dummy_input)
print(out.shape)
torch.Size([1, 1000])

Совместимость с SMP¶

Можно использовать большинство моделей из timm в качестве энкодеров.

Список совместимых моделей.

При этом к названию модели, которое передается в конструктор класса SMP, нужно добавить префикс tu-:

In [52]:
smp_timm_model = smp.DeepLabV3("tu-mobilenetv3_small_050", in_channels=3, classes=80)
smp_timm_model.eval()
print("Created DeepLab with mobileNet encoder")
Created DeepLab with mobileNet encoder

Smoke test

In [53]:
mask = smp_timm_model(dummy_input)
print(mask.shape)
torch.Size([1, 80, 64, 64])

Детектирование (Object detection)¶

Детектирование — задача компьютерного зрения, в которой требуется определить местоположение конкретных объектов на изображении.

При этом вычислять точные границы объектов не требуется, достаточно определить только ограничивающие прямоугольники (bounding boxes), в которых находятся объекты.

В общем случае объекты могут принадлежать к различным классам, и объектов одного класса на изображении может быть несколько.

Детектирование единственного объекта¶

Начнём с простой ситуации.

Пусть нас интересуют объекты только одного класса, и мы знаем, что такой объект на изображении есть и он один.

К примеру, мы разрабатываем систему распознавания документов:

Source: MIDV-500: a dataset for identity document analysis and recognition on mobile devices in video stream

На вход модели подаётся изображение, и предсказать требуется область, в которой объект локализован. Область (bounding box) определяется набором координат вершин*. Собственно эти координаты и должна предсказать модель.

* Если наложить условие, что стороны многоугольника должны быть параллельны сторонам изображения, то можно ограничиться предсказанием 2-х координат.

Предсказание координат¶

Если задачу семантической сегментации получилось свести к классификации, то здесь будем использовать регрессию, поскольку предсказывать нужно не номер класса, а набор чисел.

В зависимости от требований эти числа могут нести разный смысл, например:

  • координаты центра + ширина и высота,
  • координаты правого верхнего и левого нижнего углов,
  • координаты вершин многоугольника ...

Но в любом случае задача остается регрессионной.

Зафиксируем seeds для воспроизводимости результата.

In [54]:
import torch
import random
import numpy as np


def fix_seeds():
    torch.manual_seed(42)
    random.seed(42)
    np.random.seed(42)


fix_seeds()

Решается она так:

Берем сверточную сеть и меняем последний полносвязный слой таким образом, чтобы количество выходов совпадало с количеством координат, которые нам нужно предсказать.

Так для предсказания двух точек потребуется четыре выхода ( x1 , y1 , x2, y2 )

In [55]:
from torchvision.models import resnet18
from torch import nn

# load pretrained model
resnet_detector = resnet18(weights="ResNet18_Weights.DEFAULT")

# Change "head" to predict coordinates (x1,y1 x2,y2)
resnet_detector.fc = nn.Linear(resnet_detector.fc.in_features, 4)  # x1,y1 x2,y2
Downloading: "https://download.pytorch.org/models/resnet18-f37072fd.pth" to /root/.cache/torch/hub/checkpoints/resnet18-f37072fd.pth
100%|██████████| 44.7M/44.7M [00:00<00:00, 197MB/s]

Для обучения такой модели придется заменить функцию потерь на регрессионную, например, MSE.

In [56]:
criterion = nn.MSELoss()

# This is a random example. Don't expect good results
input = torch.rand((1, 3, 224, 224))
target = torch.tensor([[0.1, 0.1, 0.5, 0.5]])  # x1,y1 x2,y2 or x,y w,h
print(f"Target: {target}")
output = resnet_detector(input)
loss = criterion(output, target)
print(f"Output: {output}")
print(f"Loss: {loss}")
Target: tensor([[0.1000, 0.1000, 0.5000, 0.5000]])
Output: tensor([[ 0.0432,  0.5333, -0.1924,  0.8475]], grad_fn=<AddmmBackward0>)
Loss: 0.19780878722667694

Координаты обычно предсказываются в процентах от длины и ширины изображения. Таким образом, если bounding box целиком помещается на изображении, обе координаты будут находиться в интервале $[0 .. 1]$.

Примеры предсказывания точек

Recent Progress in Appearance-based Action Recognition (Humphreys et al., 2020)

По такому принципу работают многие модели для поиска различных ключевых точек. Например: на лице (facial landmarks) или теле человека.

Multitask loss¶

Координаты прямоугольников мы предсказывать научились.

Теперь усложним задачу: объект остается один, но может принадлежать к различным классам.

Единственный объект

То есть к задаче локализации добавляется классификация.

Задачу классификации мы умеем решать:

Остается объединить классификацию с регрессией:

Для этого нужно одновременно предсказывать:

  • вероятность принадлежности к классам,
  • координаты ограничивающего прямогульника (bounding box).

Тогда выход последнего слоя будет иметь размер: $$N + 4$$

где N — количество классов (1000 для ImageNet),

а 4 числа — это координаты одного boundig box (x1,y1,x2,y2 или x,y,w,h).

Как описать функцию потерь для такой модели?

Можно суммировать loss для классификации и loss для регрессии.

$ \large L_{total} = L_{crossentropy}+L_{mse}$

Однако значения разных loss могут иметь разные порядки, поэтому приходится подбирать весовые коэффициенты для каждого слагаемого. В общем случае функция потерь будет иметь вид:

$$\large L_{total} = \sum _iw_iL_i,$$

где $w_i$ — весовые коэффициенты каждой из функций потерь.

Они являются гиперпараметрами модели и требуют подбора.

Подбор весов для каждой компоненты loss¶

Можно подбирать веса компонентов loss в процессе обучения. Для этого к модели добавляется дополнительный слой:

Multi-Task Learning Using Uncertainty to Weigh Losses for Scene Geometry and Semantics(Alex Kendall et al., 2018)

Пример реализации MultiTask learning

Детектирование нескольких объектов¶

Как быть, если объектов несколько?

Для каждого объекта нужно вернуть координаты (x1, y1, x2, y2) и класс (0 .. N). Соответственно, количество выходов модели надо увеличивать.

Но нам неизвестно заранее, сколько объектов будет на изображении:

Source: Stanford University CS231n: Detection and Segmentation

Cкользящее окно Одним из вариантов решения этой проблемы является применение классификатора ко всем возможным местоположениям объектов. Классификатор предсказывает, есть ли на выбранном фрагменте изображения один из интересующих нас объектов. Если нет, то фрагмент классифицируется как "фон".

Source: Stanford University CS231n: Detection and Segmentation

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

Object proposal¶

Чтобы сократить количество возможных окон для детекции, применяются разные способы:

  1. На исходном изображении эвристический алгоритм предсказывает области, где скорее всего находятся объекты (~2K) (R-CNN).
  2. Эти области предсказывает нейросеть (подсеть) (~2K) (Two stage detectors: Faster R-CNN).
  3. Эвристически задаются центры и размеры окон, которые плотно покрывают все изображение(~ 10K окон), (One stage detector: SSD, YOLO).
  4. Нейросеть предсказывает вероятность нахождения объекта и его размеры для каждой точки на карте признаков, так как на последних слоях карты признаков небольшие. (Anchor free: FCOS)
  5. Если в качестве кодировщика используется сеть на базе архитектуры ViT, то предсказание делается для каждого patch (1K <) (DETR).

Общая схема работы детектора:

  • Изображение единожды пропускается через базовую часть сети (encoder), дальнейшие предсказания делаются на карте признаков.
  • Во всех случаях предсказываются смещение и масштабирование для финальных bounding boxes.
  • Для каждого bounding box предсказывается класс.
  • Предсказывается заведомо больше bounding boxes, чем может быть объектов на изображении.

Loss

Лосс складывается из лосс для классификации $L_{conf}$ и лосс для детекции $L_{loc}$

$$\large L_{det} = L_{conf} + \alpha L_{loc}$$

При этом в $L_{loc}$ учитываются не все предсказанные bounding box, а только те, которые наилучшем образом пересекаются с GT (bbox из разметки). Фильтрация может проходить по порогу или при помощи алгоритма.

NMS¶

Теперь возникает другая проблема: в районе объекта алгоритм генерирует множество ограничивающих прямоугольников (bounding box), которые частично перекрывают друг друга.

Чтобы избавиться от них, используется алгоритм NMS (Non maxima suppression). Его задача — избавиться от bounding boxes, которые накладаваются на истинный:

Здесь $B$ — это массив всех bounding boxes, $C$ — массив предсказаний модели относительно наличия объекта в соответствующем bounding box.

Для оценки схожести обычно используется метрика IoU(same == IoU), а значение IoU ($\lambda_{nms}$), при котором bounding boxes считаются принадлежащими одному объекту, является гиперпараметром (часто 0.5).

В PyTorch алгоритм NMS доступен в модуле torchvision.ops

torchvision.ops.nms(boxes, scores, iou_threshold), где:

  • boxes — массив bounding box,
  • scores — предсказанная оценка,
  • iou_threshold — порог IoU, NMS отбрасывает все перекрывающиеся поля с $IoU> iou\_threshold$

Документация

Soft NMS

Backbone для детекторов¶

Path Aggregation Network Module(PAN)¶

В backbones, использующихся в детекторах на базе сверточных сетей, применяются подходы, развивающие идеи FPN.

Source: Path Aggregation Network for Instance Segmentation

а) Строится пирамида признаков, при этом некоторые карты признаков дополняются признакими с более ранних слоев (красный пунктир).

b) Затем на основе последнего слоя FPN строится еще одна (Bottom-up), и опять новые карты признаков дополняются признаками, полученными на первом уровне (зеленый пунктир).

Swin Transformer (2021)¶

Aug 2021 Swin Transformer: Hierarchical Vision Transformer using Shifted Windows

Применять ViT напрямую для задач сегментации и детектирования не слишком эффективно, так как при больших размерах patch (16x16) не получится получить точные границы объектов.

А при уменьшении размеров patch будет требоваться все больше ресурсов, так как сложность self-attention $O(n^{2})$ пропорциональна квадрату количества элементов на входе.

Source: Swin Transformer: Hierarchical Vision Transformer using Shifted Windows

Авторы решают проблему при помощи двух усовершенствований.

Self-attention применяется не ко всему изображению сразу, а к его большим фрагментам, окнами.

На первый взгляд это возвращает проблему сверток, про которую мы говорили:

Указываем сети, куда смотреть, и это мешает оценить взаимное влияние признаков, расположенных на разных углах карты.

Чтобы не допустить этой проблемы, на каждом следующем transformer-слое окно сдвигается.

Source: Swin Transformer: Hierarchical Vision Transformer using Shifted Windows

Таким образом сеть может выучить влияние любого патча на любой. При этом не требуется увеличивать количество входов self-attention блока, и количество вычислений не растет.

Далее пространственные размеры карт признаков уменьшаются аналогично тому, как это происходит в сверточных сетях. Для сегментирования и детектирования используется принцип FPN: признаки с разных пространственных карт агрегируются для предсказания.

Source: Swin Transformer: Hierarchical Vision Transformer using Shifted Windows

Patch merging здесь — это конкатенация эмбеддингов с последующей подачей на вход линейного слоя.

Фрагменты из 4-х эмбеддингов 2x2xC конкатенируются. Получаем один тензор 1x1x4C.

Затем подаем его на вход линейному слою, уменьшающему число каналов в 2 раза, получаем новый эмбеддинг размерностью 1x1x2C.

Таким образом, в отличие от традиционных трансформер-архитектур, размер embedding здесь меняется.

Source: Swin Transformer: Hierarchical Vision Transformer using Shifted Windows

Такой подход позволил достичь SOTA результатов как в задаче классификации, так и в задачах детектирования и сегментации. Авторы статьи позиционируют Swin Transformer как backbone решения широкого круга задач CV.

YOLO¶

Полезные источники:

  • You Only Look Once: Unified, Real-Time Object Detection (Redmon et. al., 2015)
  • YOLO9000: Better, Faster, Stronger (Redmon et. al., 2015)
  • YOLOv3: An Incremental Improvement (Redmon et. al., 2018)
  • YOLOv4: Optimal Speed and Accuracy of Object Detection (Bochkovskiy et al., 2020)
  • YOLOv5 (Glenn Jocher Ultralytics, June 2020)
  • YOLOX: Exceeding YOLO Series in 2021 (Ge et al., June 2021)

  • YOLOv7: Trainable bag-of-freebies sets new state-of-the-art for real-time object detectors(Chien-Yao Wang et. al. July 2022)

  • YOLOv6: A Single-Stage Object Detection Framework for Industrial Applications(Chuyi Li et. al., Sept 2022)

  • YOLOv8, Ultralytics, Dec. 2022 (documentation !)

  • YOLO6v3 YOLOv6 v3.0: A Full-Scale Reloading Chuyi Li et. al., Dec. 2023

Source: YOLOv6 v3.0: A Full-Scale Reloading

Первая версия YOLO вышла в том же году, что и SSD. На тот момент детектор несколько проигрывал SSD в точности.

Однако благодаря усилиям Joseph Redmon проект поддерживался и развивался в течение нескольких лет.

3-я версия детектора оказалась настолько удачной, что даже в 2021 можно было прочесть: "YOLOv3, one of the most widely used detectors in industry".

Последующие версии создавались разными авторами. Полагаю, что правильно считать их разными форками YOLOv3, а не новыми версиями. Даже нумерация условна, например, статья про v7 датируется более ранней датой, чем v6.

В прикладных задачах я бы рекомендовал использовать YOLOv8, так как авторы выложили на свой сайт документацию, а точность при скорости порядка 100 fps у всех современных моделей почти одинакова.

В настоящий момент можно сказать, что YOLO — это оптимальный детектор по соотношению качества распознавания к скорости.

Подробнее можно посмотреть в блокноте.

Мы же запустим одну из последних моделей.

YOLOv8

По этой модели не публиковалась статья, зато есть документация

Попробуем запустить ее.

In [57]:
!pip install -q ultralytics
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 607.6/607.6 kB 9.8 MB/s eta 0:00:00

Инстанцируем модель по названию. Полный список предобученных моделей.

In [58]:
from ultralytics import YOLO

yolo8 = YOLO("yolov8n.pt")
Downloading https://github.com/ultralytics/assets/releases/download/v0.0.0/yolov8n.pt to 'yolov8n.pt'...
100%|██████████| 6.23M/6.23M [00:00<00:00, 84.2MB/s]

Детектируем объекты на изображении из COCO.

Из коробки работает с изображениями в разных форматах (даже url), автоматически меняет размер входного изображения, возвращает объект с результатами.

Подробнее ознакомиться можно в документации.

In [59]:
results = yolo8.predict(
    "http://images.cocodataset.org/val2017/000000448263.jpg",  # baseball
    conf=0.25,  # for NMS
    iou=0.7,
)  # for NMS
Downloading http://images.cocodataset.org/val2017/000000448263.jpg to '000000448263.jpg'...
100%|██████████| 36.9k/36.9k [00:00<00:00, 1.41MB/s]
image 1/1 /content/000000448263.jpg: 480x640 3 persons, 1 baseball glove, 100.7ms
Speed: 13.2ms preprocess, 100.7ms inference, 37.6ms postprocess per image at shape (1, 3, 480, 640)

В качестве результата возвращается список объектов, содержащих полную информацию о детектировании.

In [60]:
print(len(results))  # contains detections for one image
1

У него есть методы для получения списка координат предсказанных bounding box после NMS:

In [61]:
print(results[0].boxes.data)  # x1,y2,x2,y2,conf,class_num
tensor([[ 87.5404,   7.1557, 231.3847, 235.4177,   0.8296,   0.0000],
        [ 16.3276, 122.7995,  54.5783, 171.0505,   0.8294,  35.0000],
        [ 18.5687,  11.8264,  78.2339, 228.6577,   0.7626,   0.0000],
        [ 88.2422,  12.8856, 303.3497, 232.0106,   0.3741,   0.0000]], device='cuda:0')

Может даже создать картинку с нарисоваными bounding box.

In [62]:
%matplotlib inline
import matplotlib.pyplot as plt

plt.figure(figsize=(10, 6))
pil_with_bbox = results[0].plot()
plt.imshow(pil_with_bbox)  # BGR?
plt.show()

Похоже, объект results хранит картинку в BGR формате, переведем в RGB:

In [63]:
plt.figure(figsize=(10, 6))
plt.imshow(pil_with_bbox[..., ::-1])  # BGR->RGB
plt.show()

Instance Segmentation¶

Mask R-CNN (Detectron) — концептуально простая, гибкая и общая схема сегментации объектов. Подход эффективно обнаруживает объекты на изображении и одновременно генерирует высококачественную маску сегментации для каждого объекта.

Метод, названный Mask R-CNN, расширяет Faster R-CNN, который мы обсуждали ранее, добавляя ветвь для предсказания маски объекта параллельно с существующей ветвью для распознавания bounding boxes.

Код доступен по ссылке

Source: Stanford University CS231n: Detection and Segmentation

Модель Mask R-CNN

Пример запуска Mask R-CNN в документации PyTorch

Загрузим картинку

In [64]:
!wget -q "http://images.cocodataset.org/val2017/000000448263.jpg"
In [65]:
from PIL import Image
from torchvision.transforms import ToTensor

I = Image.open("000000448263.jpg")
t = ToTensor()(I)

Загрузим модель

In [66]:
from IPython.display import clear_output
from torchvision.models.detection import (
    maskrcnn_resnet50_fpn,
    MaskRCNN_ResNet50_FPN_Weights,
)

mask_rcnn = maskrcnn_resnet50_fpn(weights=MaskRCNN_ResNet50_FPN_Weights.DEFAULT)
clear_output()
mask_rcnn.eval()
predictions = mask_rcnn(t.unsqueeze(0))

Для каждого изображения возвращается словарь

In [67]:
print(predictions[0].keys())
dict_keys(['boxes', 'labels', 'scores', 'masks'])

Каждый ключ содержит тензор, количество элементов в котором равно количеству детектированных объектов.

In [68]:
predictions[0]["masks"].shape
Out[68]:
torch.Size([61, 1, 240, 320])
In [69]:
import matplotlib.pyplot as plt
import numpy as np


def show_masks(masks, classes=None):

    fig, ax = plt.subplots(nrows=4, ncols=8, figsize=(20, 10))
    i = 0
    for row in range(4):
        for col in range(8):
            if classes is not None:
                ax[row, col].set_title(int(classes[i]))
            ax[row, col].imshow(masks[i])
            ax[row, col].axis("off")
            i += 1
            if i >= len(masks):
                plt.show()
                return

    plt.show()


show_masks(
    predictions[0]["masks"].detach().squeeze(1), predictions[0]["labels"].detach()
)

Маски не бинарные, при необходимости можем их бинаризовать

In [70]:
mask = predictions[0]["masks"][2].detach().squeeze(0)
mask = mask > 0.5
plt.imshow(mask, cmap="gray")
plt.show()

У YOLOv8 заявлена поддержка сегментации.

In [71]:
!pip install -q ultralytics
In [72]:
from ultralytics import YOLO


yolo_seg = YOLO("yolov8m-seg.pt")
clear_output()
In [73]:
seg_results = yolo_seg(I)
0: 480x640 4 persons, 1 baseball glove, 79.5ms
Speed: 1.7ms preprocess, 79.5ms inference, 2.4ms postprocess per image at shape (1, 3, 480, 640)
In [74]:
seg_results[0].masks.shape
Out[74]:
torch.Size([5, 480, 640])
In [75]:
seg_results[0].masks.data.shape
Out[75]:
torch.Size([5, 480, 640])
In [76]:
seg_results[0].boxes.cls
Out[76]:
tensor([35.,  0.,  0.,  0.,  0.], device='cuda:0')
In [77]:
plt.figure(figsize=(10, 6))
pil_with_bbox = seg_results[0].plot(boxes=True, masks=True)
plt.imshow(pil_with_bbox[..., ::-1])  # BGR?
plt.show()

Panoptic Segmentation¶

COCO panoptic

Source: Introduction to Panoptic Segmentation: A Tutorial

OWL-ViT (2022)¶

Vision Transformer for Open-World Localization

Simple Open-Vocabulary Object Detection with Vision Transformers

Код

OWL-ViT — трансформер, осуществляющий детектирование по произвольному текстовому запросу.

Модели на вход подается изображение и набор текстов. Модель предсказывает bbox , и сравнивает их эмбеддинги с эмбеддингами текстов.

Source: Simple Open-Vocabulary Object Detection with Vision Transformers
  • Лосс для bbox такой же, как в DETR (bipartite matching).
  • Эмбеддинги учатся аналогично CLIP.
In [78]:
!pip install -q transformers
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 7.4/7.4 MB 50.2 MB/s eta 0:00:00
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 7.8/7.8 MB 69.9 MB/s eta 0:00:00

Создаем модель и processor.

processor — это класс, отвечающий за предобработку и постобработку данных.

In [79]:
import torch
from transformers import OwlViTProcessor, OwlViTForObjectDetection
from IPython.display import clear_output

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

processor = OwlViTProcessor.from_pretrained(
    "google/owlvit-base-patch32",
)
model = OwlViTForObjectDetection.from_pretrained(
    "google/owlvit-base-patch32",
)
model.eval()
model.to(device)

clear_output()
print("loaded")  # suppress huge output of model structure
loaded

Загрузим изображение

In [80]:
!wget -qN "http://images.cocodataset.org/val2017/000000448263.jpg"
In [81]:
from PIL import Image


img = Image.open("000000448263.jpg")
img
Out[81]:

Создадим набор текстовых описаний для поиска

In [82]:
texts = ["cap", "botle", "text", "boy", "player"]  #
In [83]:
batch = [img]
batch_size = len(batch)
with torch.inference_mode():
    inputs = processor(
        text=texts * batch_size,  # copy the same text for all images
        images=[img],  # batch of images in PIL format
        return_tensors="pt",
    )
    inputs = inputs.to(device)
    outputs = model(**inputs)  # return object of type OwlViTObjectDetectionOutput
In [84]:
print("bboxex", outputs["pred_boxes"].shape)  # batch * total_boxes * 4 coords per box
print("logits", outputs["logits"].shape)  # batch * total_boxes * text_count
bboxex torch.Size([1, 576, 4])
logits torch.Size([1, 576, 5])

Постобработка

При предобработке изображения могут масштабироваться (resize). Чтобы получить корректные bbox на выходе, в метод можно подать список исходных размеров изображений.

In [85]:
size = img.size[::-1]  # WH -> HW
target_sizes = torch.tensor([size])
print(target_sizes)
tensor([[240, 320]])
In [86]:
results = processor.post_process_object_detection(
    threshold=0.1, outputs=outputs, target_sizes=target_sizes.to(device)  #
)

print(results)
[{'scores': tensor([0.1027, 0.1171], device='cuda:0'), 'labels': tensor([0, 3], device='cuda:0'), 'boxes': tensor([[ 94.7761,   6.7190, 147.3550,  33.3295],
        [ 18.7188,  12.6309,  81.0806, 229.1255]], device='cuda:0')}]
In [87]:
from torchvision.utils import draw_bounding_boxes
from torchvision.ops import box_convert
import matplotlib.pyplot as plt
import numpy as np


def draw_bbox(img, bb, color, labels=None, xywh=True):
    t_img = torch.tensor(img).permute(2, 0, 1)  # to tensor CHW
    bb = bb[:, :4].clone().detach()  # take only coords
    if xywh:
        bb = box_convert(bb, "xywh", "xyxy")  # convert from COCO format
    img_with_bb = draw_bounding_boxes(t_img, bb, colors=color, width=2, labels=labels)
    return img_with_bb.permute(1, 2, 0).numpy()  # back to numpy HWC


def show_owl_results(results):
    labels = []
    for i, text_idx in enumerate(results[0]["labels"]):
        labels.append(texts[text_idx] + f" {results[0]['scores'][i]:.2f}")
    image_with_bb = draw_bbox(
        np.array(img), results[0]["boxes"], "lime", labels=labels, xywh=False
    )
    plt.figure(figsize=(8, 6))
    plt.imshow(image_with_bb)
    plt.show()


show_owl_results(results)

Чтобы получить больше результатов, меняем порог:

In [88]:
coarse_results = processor.post_process_object_detection(
    threshold=0.07,  # change threshold
    outputs=outputs,
    target_sizes=target_sizes.to(device),
)

show_owl_results(coarse_results)

Ёще пример.

SAM (2023)¶

Segment Anything

Модель возвращает набор масок, соответствующих входу. Классы объектов не используются.

В качестве входа могут подаваться:

  • набор точек,
  • набор bounding box,
  • маски,
  • текст (поддержка в коде пока не реализована),
  • изображение.

Source: Segment Anything (Kirillov et al., 2023)

Обучалась на огромном датасете, частично размеченном в unsupervise режиме

Source: Segment Anything (Kirillov et al., 2023)

Установим пакет

In [89]:
!pip install -q git+https://github.com/facebookresearch/segment-anything.git
  Preparing metadata (setup.py) ... done
  Building wheel for segment-anything (setup.py) ... done

Загружаем веса из репозитория Facebook Research

In [90]:
# ViT-H
!wget -qN https://dl.fbaipublicfiles.com/segment_anything/sam_vit_h_4b8939.pth
#!wget -qN https://edunet.kea.su/repo/EduNet-web_dependencies/weights/sam_vit_h_4b8939.pth

Создаем encoder

In [91]:
import torch
from segment_anything import sam_model_registry  # SamPredictor, sam_model_registry

# model_type = 'vit_h'
sam = sam_model_registry["vit_h"](checkpoint="sam_vit_h_4b8939.pth")

device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
sam.to(device=device)
Out[91]:
Sam(
  (image_encoder): ImageEncoderViT(
    (patch_embed): PatchEmbed(
      (proj): Conv2d(3, 1280, kernel_size=(16, 16), stride=(16, 16))
    )
    (blocks): ModuleList(
      (0-31): 32 x Block(
        (norm1): LayerNorm((1280,), eps=1e-06, elementwise_affine=True)
        (attn): Attention(
          (qkv): Linear(in_features=1280, out_features=3840, bias=True)
          (proj): Linear(in_features=1280, out_features=1280, bias=True)
        )
        (norm2): LayerNorm((1280,), eps=1e-06, elementwise_affine=True)
        (mlp): MLPBlock(
          (lin1): Linear(in_features=1280, out_features=5120, bias=True)
          (lin2): Linear(in_features=5120, out_features=1280, bias=True)
          (act): GELU(approximate='none')
        )
      )
    )
    (neck): Sequential(
      (0): Conv2d(1280, 256, kernel_size=(1, 1), stride=(1, 1), bias=False)
      (1): LayerNorm2d()
      (2): Conv2d(256, 256, kernel_size=(3, 3), stride=(1, 1), padding=(1, 1), bias=False)
      (3): LayerNorm2d()
    )
  )
  (prompt_encoder): PromptEncoder(
    (pe_layer): PositionEmbeddingRandom()
    (point_embeddings): ModuleList(
      (0-3): 4 x Embedding(1, 256)
    )
    (not_a_point_embed): Embedding(1, 256)
    (mask_downscaling): Sequential(
      (0): Conv2d(1, 4, kernel_size=(2, 2), stride=(2, 2))
      (1): LayerNorm2d()
      (2): GELU(approximate='none')
      (3): Conv2d(4, 16, kernel_size=(2, 2), stride=(2, 2))
      (4): LayerNorm2d()
      (5): GELU(approximate='none')
      (6): Conv2d(16, 256, kernel_size=(1, 1), stride=(1, 1))
    )
    (no_mask_embed): Embedding(1, 256)
  )
  (mask_decoder): MaskDecoder(
    (transformer): TwoWayTransformer(
      (layers): ModuleList(
        (0-1): 2 x TwoWayAttentionBlock(
          (self_attn): Attention(
            (q_proj): Linear(in_features=256, out_features=256, bias=True)
            (k_proj): Linear(in_features=256, out_features=256, bias=True)
            (v_proj): Linear(in_features=256, out_features=256, bias=True)
            (out_proj): Linear(in_features=256, out_features=256, bias=True)
          )
          (norm1): LayerNorm((256,), eps=1e-05, elementwise_affine=True)
          (cross_attn_token_to_image): Attention(
            (q_proj): Linear(in_features=256, out_features=128, bias=True)
            (k_proj): Linear(in_features=256, out_features=128, bias=True)
            (v_proj): Linear(in_features=256, out_features=128, bias=True)
            (out_proj): Linear(in_features=128, out_features=256, bias=True)
          )
          (norm2): LayerNorm((256,), eps=1e-05, elementwise_affine=True)
          (mlp): MLPBlock(
            (lin1): Linear(in_features=256, out_features=2048, bias=True)
            (lin2): Linear(in_features=2048, out_features=256, bias=True)
            (act): ReLU()
          )
          (norm3): LayerNorm((256,), eps=1e-05, elementwise_affine=True)
          (norm4): LayerNorm((256,), eps=1e-05, elementwise_affine=True)
          (cross_attn_image_to_token): Attention(
            (q_proj): Linear(in_features=256, out_features=128, bias=True)
            (k_proj): Linear(in_features=256, out_features=128, bias=True)
            (v_proj): Linear(in_features=256, out_features=128, bias=True)
            (out_proj): Linear(in_features=128, out_features=256, bias=True)
          )
        )
      )
      (final_attn_token_to_image): Attention(
        (q_proj): Linear(in_features=256, out_features=128, bias=True)
        (k_proj): Linear(in_features=256, out_features=128, bias=True)
        (v_proj): Linear(in_features=256, out_features=128, bias=True)
        (out_proj): Linear(in_features=128, out_features=256, bias=True)
      )
      (norm_final_attn): LayerNorm((256,), eps=1e-05, elementwise_affine=True)
    )
    (iou_token): Embedding(1, 256)
    (mask_tokens): Embedding(4, 256)
    (output_upscaling): Sequential(
      (0): ConvTranspose2d(256, 64, kernel_size=(2, 2), stride=(2, 2))
      (1): LayerNorm2d()
      (2): GELU(approximate='none')
      (3): ConvTranspose2d(64, 32, kernel_size=(2, 2), stride=(2, 2))
      (4): GELU(approximate='none')
    )
    (output_hypernetworks_mlps): ModuleList(
      (0-3): 4 x MLP(
        (layers): ModuleList(
          (0-1): 2 x Linear(in_features=256, out_features=256, bias=True)
          (2): Linear(in_features=256, out_features=32, bias=True)
        )
      )
    )
    (iou_prediction_head): MLP(
      (layers): ModuleList(
        (0-1): 2 x Linear(in_features=256, out_features=256, bias=True)
        (2): Linear(in_features=256, out_features=4, bias=True)
      )
    )
  )
)

Загрузим изображение

In [92]:
!wget -q https://edunet.kea.su/repo/EduNet-content/L12/out/semantic_segmentation_1.png -O cat.png
In [93]:
!wget -q "http://images.cocodataset.org/val2017/000000448263.jpg"
In [94]:
from PIL import Image
import numpy as np

img = Image.open("000000448263.jpg")
np_im = np.array(img)  # HWC format
img
Out[94]:

Создадим эмбеддинг (на CPU выполняется долго) и предскажем все маски (подробнее об автоматической генерации масок).

In [95]:
%%time
from segment_anything import SamAutomaticMaskGenerator

mask_generator = SamAutomaticMaskGenerator(sam)
masks = mask_generator.generate(np_im)
CPU times: user 6.23 s, sys: 31.3 ms, total: 6.26 s
Wall time: 6.71 s

На выходе получаем список

In [96]:
masks[0]
Out[96]:
{'segmentation': array([[False, False, False, ..., False, False, False],
        [False, False, False, ..., False, False, False],
        [False, False, False, ..., False, False, False],
        ...,
        [False, False, False, ..., False, False, False],
        [False, False, False, ..., False, False, False],
        [False, False, False, ..., False, False, False]]),
 'area': 5943,
 'bbox': [104, 40, 108, 82],
 'predicted_iou': 1.0488086938858032,
 'point_coords': [[175.0, 86.25]],
 'stability_score': 0.9864819645881653,
 'crop_box': [0, 0, 320, 240]}
In [97]:
masks[0]["segmentation"].shape
Out[97]:
(240, 320)
In [98]:
# https://github.com/facebookresearch/segment-anything/blob/main/notebooks/automatic_mask_generator_example.ipynb
import matplotlib.pyplot as plt


def show_anns(anns):
    if len(anns) == 0:
        return
    sorted_anns = sorted(anns, key=(lambda x: x["area"]), reverse=True)
    ax = plt.gca()
    ax.set_autoscale_on(False)

    img = np.ones(
        (
            sorted_anns[0]["segmentation"].shape[0],
            sorted_anns[0]["segmentation"].shape[1],
            4,
        )
    )
    img[:, :, 3] = 0
    for ann in sorted_anns:
        m = ann["segmentation"]
        color_mask = np.concatenate([np.random.random(3), [0.35]])
        img[m] = color_mask
    ax.imshow(img)
In [99]:
plt.figure(figsize=(10, 8))
plt.imshow(img)
show_anns(masks)
plt.axis("off")
plt.show()

Предсказываем по точкам. Сначала создаем эмбеддинг. Он хранится внутри модели.

In [100]:
%%time

from segment_anything import SamPredictor


predictor = SamPredictor(sam)
predictor.set_image(np_im)  # create embedding
CPU times: user 1.98 s, sys: 16.2 ms, total: 1.99 s
Wall time: 2.06 s

Теперь получаем предсказания, указав точки, которые относятся к объекту и фону:

In [101]:
masks, scores, logits = predictor.predict(
    point_coords=np.array([[200, 200], [1, 1]]),  # point coords
    point_labels=np.array([1, 0]),  # 1 - object(foreground), 0 - background
    # box
    # mask_input
    multimask_output=True,  # return 1 or 3 masks because of the ambiguous input
)
In [102]:
print("Masks count", len(masks))
print("Scores", scores)
Masks count 3
Scores [    0.85097     0.95786     0.98574]
In [103]:
print(masks[0].shape)
(240, 320)
In [104]:
def show_mask(mask, ax, random_color=False):
    if random_color:
        color = np.concatenate([np.random.random(3), np.array([0.6])], axis=0)
    else:
        color = np.array([30 / 255, 144 / 255, 255 / 255, 0.6])
    h, w = mask.shape[-2:]
    mask_image = mask.reshape(h, w, 1) * color.reshape(1, 1, -1)
    ax.imshow(mask_image)


plt.imshow(img)
show_mask(masks[2], plt.gca())
plt.axis("off")
plt.show()

Оценка качества детекции¶

mAP — mean Average Precision¶

AP (Average Precision — средняя точность) — это площадь под сглаженной PR-кривой.

m (mean) — усредненная для разных порогов IoU.

Разберемся, что это значит.

Precision & recall¶

Precision измеряет, насколько точны предсказания сети (т.е. процент правильных предсказаний)

Recall измеряет, насколько хорошо сеть находит все положительные срабатывания (positives). Например, мы можем найти 80% возможных положительных срабатываний в наших K лучших предсказаниях.

Вот их математические определения:

$\displaystyle\mathrm{Precision} = \frac{TP}{TP+FP}$

$\displaystyle\mathrm{Recall} = \frac{TP}{TP+FN}$

где $TP$ — True Positive, $TN$ — True Negative, $FP$ — False Positive, $FN$ — False Negative.

Например, мы пытаемся детектировать яблоки на фотографиях. Предположим, мы обработали 20 фотографий (на 10 фотографиях по одному яблоку и на 10 фотографиях яблок нет) и обнаружили что:

  • в 7 случаях наша нейросеть обнаружила яблоко там, где оно было на самом деле (True Positive);
  • в 3 случаях не обнаружила яблоко там, где оно было (False Negative);
  • в 4 случаях обнаружила яблоко там, где его не было (False Positive);
  • в 6 случаях правильно определила, что на фотографии яблок нет (True Negative).

Посчитаем precision и recall:

In [105]:
def precision(TP, FP):
    return TP / (TP + FP)


def recall(TP, FN):
    return TP / (TP + FN)


pres = precision(TP=7, FP=4)
rec = recall(TP=7, FN=3)

print("Precision = %.2f" % pres)
print("Recall = %.2f" % rec)
Precision = 0.64
Recall = 0.70

PRCurve¶

Зная Precision и Recall для разных порогов, можно построить так называемую PR-кривую.

Как построить PR curve.

При помощи таких кривых часто оценивают модели в задачах классификации, когда данные не сбалансированы.

Для получения значений Precision и Recall требуются:

  • Ground True метки;
  • уверенность модели в каждом предсказании.
In [106]:
import numpy as np

y_true = np.array([1, 1, 0])  # Labels
y_pred = np.array([0.8, 0.1, 0.2])  # Predictions

Этого достаточно для того, чтобы построить PR-кривую для предсказаний одного класса.

In [107]:
from sklearn.metrics import precision_recall_curve
import matplotlib.pyplot as plt


def pr_curve(y_true, y_pred):
    plt.figure(figsize=(6, 4))
    precision, recall, thresholds = precision_recall_curve(y_true, y_pred)
    plt.plot(recall, precision, marker="o")
    for i, t in enumerate(thresholds):
        plt.annotate(str(t), (recall[i], precision[i]))
    plt.ylim([0, 1.1])
    plt.xlabel("recall")
    plt.ylabel("precision")
    plt.title("PR curve")
    plt.show()


pr_curve(y_true, y_pred)

IoU вместо метки класса¶

При детектировании у нас нет меток класса для предсказанных bounding box.

Есть предсказанные детектором bbox в виде:

In [108]:
import pandas as pd

detections = np.array(
    [
        [290, 50, 170, 160, 0.7, 53],  # x, y, w, h, confidence, class_num
        [10, 200, 190, 180, 0.8, 53],
        [310, 250, 120, 130, 0.75, 53],
    ]
)
pd.DataFrame(
    data=detections, columns=["x", "y", "width", "height", "confidence", "class_num"]
)
Out[108]:
x y width height confidence class_num
0 290.0 50.0 170.0 160.0 0.70 53.0
1 10.0 200.0 190.0 180.0 0.80 53.0
2 310.0 250.0 120.0 130.0 0.75 53.0

И есть Ground True bounding box из разметки:

In [109]:
!wget -qN "http://images.cocodataset.org/annotations/annotations_trainval2017.zip"
# !wget -qN "https://edunet.kea.su/repo/EduNet-web_dependencies/datasets/annotations_trainval2017.zip"
!unzip -qn annotations_trainval2017.zip
In [110]:
from pycocotools.coco import COCO
from IPython.display import clear_output

coco = COCO("annotations/instances_val2017.json")
clear_output()

apples_img_id = 60855  # if of some image with apples
apple_cat_id = 53  # apple

ann_id = coco.getAnnIds(imgIds=[apples_img_id])
anns = coco.loadAnns(ann_id)

gt_bbox = []
for ann in anns:
    if ann["category_id"] == apple_cat_id:
        gt_bbox.append(ann["bbox"] + [ann["category_id"]])


pd.DataFrame(data=gt_bbox, columns=["x", "y", "width", "height", "class_num"])
Out[110]:
x y width height class_num
0 269.44 61.96 154.91 161.48 53
1 24.26 208.04 177.04 176.13 53

Идея состоит в том, чтобы посчитать максимальный IoU между этими bounding box и использовать его вместо метки класса (True/False).

In [111]:
import torch
import skimage
from torchvision.utils import draw_bounding_boxes
from torchvision.ops import box_convert


def draw_bbox(img, bb_xywh, color):
    t_img = torch.tensor(img).permute(2, 0, 1)  # to tensor CHW
    xywh = torch.tensor(bb_xywh)[:, :4]  # take only coords
    bb = box_convert(xywh, "xywh", "xyxy")  # convert from COCO format
    img_with_bb = draw_bounding_boxes(t_img, bb, colors=color, width=4)
    return img_with_bb.permute(1, 2, 0).numpy()  # back to numpy HWC


img_info = coco.loadImgs(apples_img_id)

img = skimage.io.imread(img_info[0]["coco_url"])
img = draw_bbox(img, detections, "blue")
img = draw_bbox(img, gt_bbox, "lime")
plt.figure(figsize=(8, 6))
plt.imshow(img)
plt.show()

Чтобы получить максимальное значение IoU для каждого предсказанного bbox, воспользуемся функцией torchvision.ops.box_iou.

Перед этим преобразуем формат координат к (x1,y1,x2,y2):

In [112]:
from torchvision.ops import box_convert

gt = box_convert(torch.tensor(gt_bbox)[:, :4], "xywh", "xyxy")
pred = box_convert(torch.tensor(detections)[:, :4], "xywh", "xyxy")

И получим IoU для всех возможных пар bbox:

In [113]:
from torchvision.ops import box_iou

pairwise_iou = box_iou(gt, pred)
print(pairwise_iou)
tensor([[0.6153, 0.0000, 0.0000],
        [0.0000, 0.8595, 0.0000]], dtype=torch.float64)

Нас интересует только максимальный:

In [114]:
iou, _ = pairwise_iou.max(dim=0)
print(iou)
tensor([0.6153, 0.8595, 0.0000], dtype=torch.float64)

Запишем эти значения в таблицу.

In [115]:
import pandas as pd

predictions = torch.tensor(detections)[:, 4]
predictions = torch.vstack((predictions, iou)).T

pd.DataFrame(data=predictions, columns=["confidence", "iou"])
Out[115]:
confidence iou
0 0.70 0.615274
1 0.80 0.859463
2 0.75 0.000000

Теперь будем использовать IoU как GT метку класса: если он больше некоторого порога, то предсказание верное.

В нашем случае будем считать, что если IoU≥0.5, то предсказание правильное.

In [116]:
gt = iou > 0.5

predictions = torch.vstack((predictions[:, 0], gt)).T
detection_results = pd.DataFrame(data=predictions, columns=["confidence", "gt"])
detection_results.head()
Out[116]:
confidence gt
0 0.70 1.0
1 0.80 1.0
2 0.75 0.0

Теперь у нас есть все необходимое для того, чтобы построить PR-кривую:

In [117]:
from sklearn.metrics import precision_recall_curve

plt.figure(figsize=(6, 4))
precision, recall, thresholds = precision_recall_curve(gt, predictions[:, 0])
plt.plot(recall, precision, marker="o")
plt.ylim([0, 1.1])
plt.show()

Average Precision¶

AP это площадь под PR кривой.

$\large \displaystyle AP = \int_0^1p(r)dr$

Имея значения Precision и Recall, можно посчитать AP:

In [118]:
from sklearn.metrics import average_precision_score

ap = average_precision_score(gt, predictions[:, 0])
print("AP", ap)
AP 0.8333333333333333

Сравним это значение с площадью под кривой:

In [119]:
from sklearn import metrics

auc = metrics.auc(recall, precision)
print("AUC", auc)
AUC 0.7916666666666666

Как видим, они не совпали. Дело в том, что при подсчете AP кривую сглаживают:

In [120]:
import numpy as np


def smooth_precision(precision):
    smooth_prec = []
    for i in range(1, len(precision) + 1):
        max = precision[:i].max()
        smooth_prec.append(max)
    return smooth_prec


detection_results = detection_results.sort_values("confidence", ascending=False)
precision, recall, thresholds = precision_recall_curve(
    detection_results["gt"], detection_results["confidence"]
)

smoothed_precision = smooth_precision(precision)
plt.figure(figsize=(6, 4))
plt.plot(recall, precision, marker="o")
plt.plot(recall, smoothed_precision, marker="o")
plt.ylim([0, 1.1])
plt.show()
In [121]:
smooth_auc = metrics.auc(recall, smoothed_precision)
print("smooth AUC ", smooth_auc)
smooth AUC  0.8333333333333333

COCO mAP¶

Мы посчитали AP для одного класса, но в датасете их много. Логично усреднить значения AP для разных классов. В некоторых бенчмарках после такого усреднения к названию метрики добавляют приставку m(mean)

AP is averaged over all categories. Traditionally, this is called “mean average precision” (mAP). We make no distinction between AP and mAP

AP для разных порогов.¶

Мы выбрали для IoU порог 0.5.

Но можно посчитать AP для разных порогов, и затем тоже усреднить. В этом случае в названии метрики указывается минимальный и максимальный пороги. Например AP@[.5:.95] соответствует среднему AP для IoU от 0.5 до 0.95 с шагом 0.05.

In [122]:
!pip install -q torchmetrics
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 729.2/729.2 kB 10.1 MB/s eta 0:00:00
In [123]:
from torchmetrics.detection.mean_ap import MeanAveragePrecision

t_gt_bbox = torch.tensor(gt_bbox)
t_detections = torch.tensor(detections)


map_obj = MeanAveragePrecision("xywh", iou_thresholds=[0.5, 0.55, 0.6, 0.65])
results = map_obj(
    preds=[
        {
            "boxes": t_detections[:, :4],  # xywh
            "scores": t_detections[:, 4],  # confidence
            "labels": t_detections[:, 5].int(),  # class num
        }
    ],
    target=[
        {"boxes": t_gt_bbox[:, :4], "labels": t_gt_bbox[:, 4].int()}  # xywh  # class num
    ],
)

print("mAP@[0.5:0.65] = ", results["map"].item())
mAP@[0.5:0.65] =  0.7524752616882324

Практические соображения¶

  • Сегментация: если данных мало и они специфические, используйте UNet.
  • Если данных достаточно, используйте DeepLab в качестве baseline.
  • Используйте пакеты SMP и torchmetrics.
  • Для детектирования используйте YOLOv8 или YOLOv6.
  • Если ваша задача связана с трекингом, обратитесь к преподавателю.

Список использованной литературы

COCO

Подробнее о том, как создать свой COCO датасет с нуля.

Подробнее о разметке COCO

Что такое run-length encoding (RLE)

Видеоразбор Run-length encoding

Семантическая сегментация

The Beginner’s Guide to Semantic Segmentation

Encoder-Decoder with Atrous Separable Convolution for Semantic Image Segmentation (Chen et al., 2018)

Spatial Pyramid Pooling in Deep Convolutional Networks for Visual Recognition (He et al., 2014)

Блог-пост про семантическую сегментацию

Детектирование

Recent Progress in Appearance-based Action Recognition (Humphreys et al., 2020)

Статья про Selective Search

SSD: Single Shot MultiBox Detector (Liu et al., 2015)

Focal Loss for Dense Object Detection (Lin et al., 2017)

Статья про Focal Loss от Facebook AI Research

Feature Pyramid Networks for Object Detection (Sergelius et al., 2016)

What is Feature Pyramid Network (FPN)?

You Only Look Once: Unified, Real-Time Object Detection (Redmon et. al., 2015)

YOLO9000: Better, Faster, Stronger (Redmon et. al., 2015)

YOLOv3: An Incremental Improvement (Redmon et. al., 2018)

YOLOv4: Optimal Speed and Accuracy of Object Detection (Bochkovskiy et al., 2020)

YOLOv5 (Glenn Jocher)

YOLOX: Exceeding YOLO Series in 2021 (Ge et al., 2021)

Hard Example Mining

Training Region-based Object Detectors with Online Hard Example Mining (Shrivastava et al., 2016)

Loss Rank Mining: A General Hard Example Mining Method for Real-time Detectors (Yu et al., 2018)

DINO

Emerging Properties in Self-Supervised Vision Transformers (Caron et al., 2021)

Отличное видеообъяснение статьи DINO

Другое

U-Net: Convolutional Networks for Biomedical Image Segmentation (Ronneberger et al., 2015)

Блог-пост про 2D-свертки с помощью перемножения матриц