Как определять объекты с ptz камеры

dskeww8z6awyfvi9cdb9whhlxte.jpeg

В статье предлагается рассмотреть практические моменты применения 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, но этот метод по скорости выполнения уступает получению снимков из видеопотока.

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

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

pmgqmcsdlsa2a1qdqzc5k2pghz8.jpeg

Здесь пригодится 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 умеет делать и более красивые варианты:
8z0o8pnfe3nj76m5pu_e9zgyboi.png
*здесь, к слову, 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, который определяет что детектировать модели.

waakclkg4ybqpbro23w9y-z2cqo.png

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

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

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


Классификация объектов в подробностях описываться не будет, так как нет ничего уникального в использовании той же 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

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

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

© Habrahabr.ru