В статье предлагается рассмотреть практические моменты применения ptz камеры (на примере модели Dahua DH-SD42C212T-HN) для детектирования и классификации объектов. Рассматриваются алгоритмы управления камерой через интерфейс ONVIF, python. Применяются модели (сети): depth-anything, yolov8, yolo-world для детектирования объектов.

Задача


Формулируется просто: необходимо с помощью видеокамеры в заранее неизвестном окружении замкнутого помещения (indoor) определять предметы, классифицировать их.

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

Иными словами: необходимо определять продукты питания и ценники под ними.
Из оборудования — только поворотная ptz камера, без дополнительной подсветки, дальномеров и т.п.

Управление ptz камерой через onvif


Onvif — интерфейс, который позволяет через python получать доступ и управлять ptz камерами.
Для python3 при использовании onvif в основном используется следующий fork — github.com/FalkTannhaeuser/python-onvif-zeep

Камера инициируется достаточно просто:

from onvif import ONVIFCamera
mycam = ONVIFCamera('10.**.**.**', 80, 'admin', 'admin')

mycam.devicemgmt.GetDeviceInformation()
{
    'Manufacturer': 'RVi',
    'Model': 'RVi-2NCRX43512(4.7-56.4)',
    'FirmwareVersion': 'v3.6.0804.1004.18.0.15.17.6',
    'SerialNumber': '16****',
    'HardwareId': 'V060****'}

*здесь иная модель камеры, которая также поддерживает onvif.

Выставить настройки камеры через код:


#set camera settings 
options = media.GetVideoEncoderConfigurationOptions({'ProfileToken':media_profile.token})

configurations_list = media.GetVideoEncoderConfigurations()
video_encoder_configuration = configurations_list[0]
video_encoder_configuration.Quality = options.QualityRange.Min
video_encoder_configuration.Encoding = 'H264' #H264H, H265
video_encoder_configuration.RateControl.FrameRateLimit = 25
video_encoder_configuration.Resolution={
            'Width': 1280,#1920
            'Height': 720 #1080
        }

video_encoder_configuration.Multicast = {
        'Address': {
            'Type': 'IPv4',
            'IPv4Address': '224.1.0.1',
            'IPv6Address': None
        },
        'Port': 40008,
        'TTL': 64,
        'AutoStart': False,
        '_value_1': None,
        '_attr_1': None
    }

video_encoder_configuration.SessionTimeout = timedelta(seconds=60)

request = media.create_type('SetVideoEncoderConfiguration')
request.Configuration = video_encoder_configuration
request.ForcePersistence = True
media.SetVideoEncoderConfiguration(request)

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

mycam = ONVIFCamera(camera_ip, camera_port, camera_login, camera_password)

media = mycam.create_media_service()
ptz = mycam.create_ptz_service()
media_profile = media.GetProfiles()[0]

moverequest = ptz.create_type('AbsoluteMove')
moverequest.ProfileToken = media_profile.token
moverequest.Position = ptz.GetStatus({'ProfileToken': media_profile.token}).Position

Выставим камеру в начальную позицию и создадим команду, чтобы камера ее выполнила:

#start position
moverequest.Position.PanTilt.x = 0.0 #параллельно потолку min шаг +0.05. 1.0 - max положение
moverequest.Position.PanTilt.y = 1.0 #параллельно потолку вниз -0.05 1.0 - max положение
moverequest.Position.Zoom.x = 0.0 #min zoom min шаг +0.05 1.0 - max положение
ptz.AbsoluteMove(moverequest)

Как видно из кода, камера умеет выполнять движения по осям x,y, а также выполнять зуммирование в диапазоне от 0.0 до 1.0. При выполнении зуммирования, объектив какое-то время (обычно 3-5 сек) автоматически фокусируется, и с этим приходится считаться.

В onvif api есть настройка для ручного (manual) выставления фокуса камеры, но добиться ее работы не удалось:

media.GetImagingSettings({'VideoSourceToken': '000'})
    'Focus': {
        'AutoFocusMode': 'MANUAL',
        'DefaultSpeed': 1.0,
        'NearLimit': None,
        'FarLimit': None,
        'Extension': None,
        '_attr_1': None

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

def get_snapshots():
    cap = cv2.VideoCapture(f'rtsp://admin:admin{ip}/cam/realmonitor?channel=1&subtype=0&unicast=true&proto=Onvif')
            #f'rtsp://admin:admin!@10.**.**.**:554/RVi/1/1') )
    ret, frame = cap.read()
    if ret:
            # Генерация уникального имени для снимка на основе времени
            timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
            filename = f'Image/snapshot_{timestamp}.jpg'
            # Сохранение снимка
            cv2.imwrite(filename, frame)
            print(f'Скриншот сохранен как {filename}.')
    else:
            print('Не удалось получить снимок.')
    cap.release()

Снимки с камеры также можно делать через создание onvif image сервиса и далее забирать снимок через requests по url, но этот метод по скорости выполнения уступает получению снимков из видеопотока.

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

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



Здесь пригодится framework depth-anything.
*На момент написания статьи в свет вышла вторая часть — depth-anything2.

Расстояние до объектов с монокамеры


Так как дальномер к камере не прилагается, как и иные тех. средства, упрощающие решение данного вопроса, обратимся к сети, которая «позволяет получать карту глубины» — depth-anything. Данный framework практически бесполезен на значительных расстояниях от камеры, однако в диапазоне до 10 метров показывает неплохие результаты.

Не будем обращаться к вопросу как развернуть framework на локальном pc, сразу перейдем к коду.

Функция «создания глубины» будет иметь примерно следующий вид:

def make_depth(frame):    
    DEVICE = 'cuda' if torch.cuda.is_available() else 'cpu'    
    depth_anything = DepthAnything.from_pretrained(f'LiheYoung/depth_anything_vits14').to(DEVICE).eval()    

    transform = Compose([
        Resize(
            width=518,
            height=518,
            resize_target=False,
            keep_aspect_ratio=True,
            ensure_multiple_of=14,
            resize_method='lower_bound',
            image_interpolation_method=cv2.INTER_CUBIC,
        ),
        NormalizeImage(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]),
        PrepareForNet(),
    ])

    #filename='test.jpg'
    filename=frame
    raw_image = cv2.imread(filename)
    image = cv2.cvtColor(raw_image, cv2.COLOR_BGR2RGB) / 255.0
    #image = cv2.cvtColor(filename, cv2.COLOR_BGR2RGB) / 255.0 
    h, w = image.shape[:2]

    image = transform({'image': image})['image']
    image = torch.from_numpy(image).unsqueeze(0).to(DEVICE)

    with torch.no_grad():
        depth = depth_anything(image)

    depth = F.interpolate(depth[None], (h, w), mode='bilinear', align_corners=False)[0, 0]
    depth = (depth - depth.min()) / (depth.max() - depth.min()) * 255.0

    depth = depth.cpu().numpy().astype(np.uint8)
    depth = np.repeat(depth[..., np.newaxis], 3, axis=-1)

    filename = os.path.basename(filename)
    cv2.imwrite(os.path.join('.', filename[:filename.rfind('.')] + '_depth.jpg'), depth) 

На выходе получаем «глубокие» снимки в «сером» диапазоне. Этот вариант выбран неспроста, хотя depth-anything умеет делать и более красивые варианты:


*здесь, к слову, depth-anything2

Снимок в градациях серого необходим для определения так называемого "порога зуммирования" для камеры.

Что это означает?
Для правильного zoom камеры на область в центре (камера зуммируется только в центр изображения) необходимо определить какого цвета квадратная область (roi) на сером снимке.
То есть необходимо вычислить сумму цветов всех пикселей области и поделить на их количество и сравнить с каким-нибудь цветом пиксела.

Определяем условно центр картинки для:

cv2.rectangle(img, (600, 330), (600+90, 330+70), [255, 0, 0], 2) #нарисуем прямоугольник
roi = img[330:330+70,600:600+90]

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

import numpy as np
white = (255) 
np.sum(cv2.absdiff(roi, white))

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

После этого, можно выполнять команды ptz, выставляя камеру в необходимое положение по zoom и делать снимки.

Из минусов depth-anything:

  • камера может сделать снимок, в квадрат которого попадут более темные области при преобладающих светлых и порог будет определен с погрешностью;
  • при выполнении ptz команды zoom камера тратит от 3-5 сек для выполнения фокусировки, что суммарно приводит к значительному нарастанию времени обработки большого числа изображений;
  • depth-anything практически бесполезен, если расстояние от камеры слишком велико (более 10 метров).

Обработка изображений. Детектирование объектов


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

В данной ситуации, детектировать предполагается напитки в бутылках (большей частью), поэтому первое, что приходит на ум, — выбрать широко распространённую и хорошо себя зарекомендовавшую yolov8. В составе coco-классов данной модели входит класс 'bottle', поэтому дополнительно обучать модель не предполагается.

Однако кроме напитков есть необходимость определения лейблов — ценников под товарами.

Что делать? Дообучать модель?
Но в таком случае потребуется потратить n-е количество времени на разметку ценников.

Да, конечно, можно поискать уже размеченные датасеты, на том же roboflow, например.

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

Особенность ее заключается в том, что модель, в отличие от семейства моделей, к которому она формально принадлежит, исходя из названия, использует так называемый «открытый словарь».
Суть метода в том, что, задавая promt по типу языковых чат-моделей, с помощью yolo-world возможно детектировать практически любые объекты. Небольшую сложность вызывает именно определение нужного слова, которое модель корректно воспримет.
В общем, модель, «она моя», и как любая дама, требует правильных слов в свой адрес.

inference выглядит примерно следующим образом:


# Copyright (c) Tencent Inc. All rights reserved.
import os,cv2;import argparse;import os.path as osp

import torch;import supervision as sv
from mmengine.config import Config, DictAction;from mmengine.runner import Runner;from mmengine.runner.amp import autocast
from mmengine.dataset import Compose;from mmengine.utils import ProgressBar;from mmyolo.registry import RUNNERS

BOUNDING_BOX_ANNOTATOR = sv.BoundingBoxAnnotator()
LABEL_ANNOTATOR = sv.LabelAnnotator()

#https://colab.research.google.com/drive/1F_7S5lSaFM06irBCZqjhbN7MpUXo6WwO?usp=sharing#scrollTo=ozklQl6BnsLI

#python image_demo.py configs/pretrain/yolo_world_l_t2i_bn_2e-4_100e_4x8gpus_obj365v1_goldg_train_lvis_minival.py yolow-v8_l_clipv2_frozen_t2iv2_bn_o365_goldg_pretrain.pth . 'bottle,price_labels' --topk 100 --threshold 0.005 --show --output-dir demo_outputs

#python image_demo.py configs/pretrain/yolo_world_l_t2i_bn_2e-4_100e_4x8gpus_obj365v1_goldg_train_lvis_minival.py yolow-v8_l_clipv2_frozen_t2iv2_bn_o365_goldg_pretrain.pth zoom_30.jpg 'bottle,milk_carton' --topk 100 --threshold 0.005 --output-dir demo_outputs


"""
!wget https://huggingface.co/spaces/stevengrove/YOLO-World/resolve/main/yolow-v8_l_clipv2_frozen_t2iv2_bn_o365_goldg_pretrain.pth?download=true
!mv yolow-v8_l_clipv2_frozen_t2iv2_bn_o365_goldg_pretrain.pth?download=true yolow-v8_l_clipv2_frozen_t2iv2_bn_o365_goldg_pretrain.pth
!wget https://huggingface.co/spaces/stevengrove/YOLO-World/resolve/main/configs/pretrain/yolo_world_l_t2i_bn_2e-4_100e_4x8gpus_obj365v1_goldg_train_lvis_minival.py?download=true
!mv yolo_world_l_t2i_bn_2e-4_100e_4x8gpus_obj365v1_goldg_train_lvis_minival.py?download=true yolo_world_l_t2i_bn_2e-4_100e_4x8gpus_obj365v1_goldg_train_lvis_minival.py
!wget https://media.roboflow.com/notebooks/examples/dog.jpeg
!cp -r yolo_world_l_t2i_bn_2e-4_100e_4x8gpus_obj365v1_goldg_train_lvis_minival.py /content/YOLO-World/configs/pretrain/

"""

#config="yolo_world_l_t2i_bn_2e-4_100e_4x8gpus_obj365v1_goldg_train_lvis_minival.py"

#checkpoint="yolow-v8_l_clipv2_frozen_t2iv2_bn_o365_goldg_pretrain.pth"
#image_path='.'
image="zoom_30.jpg"
text="bottle,yellow_sign"
topk=100
threshold=0.0
device='cuda:0'
show=True
amp=True                       
output_dir='demo_outputs'                        

      
def inference_detector(runner,
                       image_path,
                       texts,
                       max_dets,
                       score_thr,
                       output_dir,
                       use_amp=False,
                       show=False):

    data_info = dict(img_id=0, img_path=image_path, texts=texts)
    data_info = runner.pipeline(data_info)
    data_batch = dict(inputs=data_info['inputs'].unsqueeze(0),
                      data_samples=[data_info['data_samples']])

    with autocast(enabled=use_amp), torch.no_grad():
        output = runner.model.test_step(data_batch)[0]
        pred_instances = output.pred_instances
        pred_instances = pred_instances[
            pred_instances.scores.float() > score_thr]
    if len(pred_instances.scores) > max_dets:
        indices = pred_instances.scores.float().topk(max_dets)[1]
        pred_instances = pred_instances[indices]

    pred_instances = pred_instances.cpu().numpy()
    detections = sv.Detections(xyxy=pred_instances['bboxes'],
                               class_id=pred_instances['labels'],
                               confidence=pred_instances['scores'])

    labels = [
        f"{texts[class_id][0]} {confidence:0.2f}" for class_id, confidence in
        zip(detections.class_id, detections.confidence)
    ]

    # label images
    image = cv2.imread(image)
    image = BOUNDING_BOX_ANNOTATOR.annotate(image, detections)
    image = LABEL_ANNOTATOR.annotate(image, detections, labels=labels)
    #cv2.imwrite(osp.join(output_dir, osp.basename(image_path)), image)
    cv2.imshow('out',image)

##    if show:
##        cv2.imshow(image)
##        k = cv2.waitKey(0)
##        if k == 27:
##            # wait for ESC key to exit
##            cv2.destroyAllWindows()


if __name__ == '__main__':
    #args = parse_args()

    # load config
    cfg = Config.fromfile(
        "yolo_world_l_t2i_bn_2e-4_100e_4x8gpus_obj365v1_goldg_train_lvis_minival.py" )
    #cfg.work_dir = "."
    cfg.load_from = "yolow-v8_l_clipv2_frozen_t2iv2_bn_o365_goldg_pretrain.pth"

    if 'runner_type' not in cfg:
        runner = Runner.from_cfg(cfg)
    else:
        runner = RUNNERS.build(cfg)

    # load text
    if text.endswith('.txt'):
        with open(args.text) as f:
            lines = f.readlines()
        texts = [[t.rstrip('\r\n')] for t in lines] + [[' ']]
    else:
        texts = [[t.strip()] for t in text.split(',')] + [[' ']]

    output_dir = output_dir
    if not osp.exists(output_dir):
        os.mkdir(output_dir)

    runner.call_hook('before_run')
    runner.load_or_resume()
    pipeline = cfg.test_dataloader.dataset.pipeline
    runner.pipeline = Compose(pipeline)
    runner.model.eval()
    
    #images = image

    #progress_bar = ProgressBar(len(images))
    #for image_path in images:

    inference_detector(runner,
                       image,
                       texts,
                       topk,
                       threshold,
                       output_dir=output_dir,
                       use_amp=amp,
                       show=show)
    progress_bar.update()

Здесь помимо прочего, необходимо обратить внимание на поле text=«bottle,yellow_sign».
Это и есть promt, который определяет что детектировать модели.



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

Модель отзывчиво реагирует на изменение параметров, но лучше не увлекаться:



Классификация объектов и их привязка к ценникам


Классификация объектов в подробностях описываться не будет, так как нет ничего уникального в использовании той же yolov8 в задаче классификации вырезанных boxes, которые возвращает yolo-world.
На привязке объектов друг к другу остановимся подробнее.

Код выглядит примерно следующим образом:


from math import hypot

def check_distances(x_price,y_price,w_price):
    min_distance=3000
    for i in list(glob.glob('out2/*.txt')):
        with open (i) as f:
            a=list(map(int, f.read().split(','))) #{x},{y},{w},{h}
                        
            #линия от правого верхнего угла ценника к середине низа предмета
            x1,y1,x2,y2=x_price,y_price,(a[0]+int((a[2]-a[0])/2)),a[3]    #x1,y1 - x,y ценника x2,y2 - середина основания объекта, h -объекта    
            #cv2.line (img, (x1,y1), (x2,y2), (0,255,0), 2)        
            distance1 = int(hypot(x2 - x1, y2 - y1))

            #линия от левого верхнего угла ценника к середине низа предмета
            x1,y1,x2,y2=w_price,y_price,(a[0]+int((a[2]-a[0])/2)),a[3]   #x1,y1 - w,y ценника x2,y2 - середина основания объекта, h -объекта    
            #cv2.line (img, (x1,y1), (x2,y2), (0,255,0), 2)        
            distance2 = int(hypot(x2 - x1, y2 - y1))

            temp=distance1+distance2
               
            if min_distance>temp:            
                min_distance=temp
                filename=i
            
            #print('\n')
    return min_distance,filename

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

Выглядит это примерно так:



На этом все, спасибо за внимание.

Комментарии (5)


  1. Dynasaur
    16.08.2024 12:23

    Поясните, пожалуйста, почему для задачи изучения ценников на прилавке выбрана купольная потолочная камера Dahua DH-SD42C212T-HN ?

    И ещё - камера стоит под 30т.р. - какие её характеристики повлияли на такой выбор? Есть масса камер в разы дешевле и вроде бы не хуже. Почему эта?


    1. zoldaten Автор
      16.08.2024 12:23
      +1

      Предполагалось, что камера должна охватить пространство сразу с 4х сторон от пола до потолка. Т.к. ориентация помещения неизвестна изначально (попытка сделать универсальный алгоритм), камера должна "дотягиваться" и в отдаленные участки помещения на расстояние до 10м. На таком расстоянии не всякая камера "прочитает" ценник, те более если он в морозильной камере.
      Да, есть решения дешевле, но там либо нет антивандального купола, либо дребезг при движении, либо одноплатники нужны.


  1. ret77876
    16.08.2024 12:23

    А на каком железе запускается детектирование объектов? Планируется ли работа в realtime?


  1. vagon333
    16.08.2024 12:23

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

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


    1. ret77876
      16.08.2024 12:23

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