
В статье предлагается рассмотреть практические моменты применения 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
Общий смысл в том, что от центра нижней части детектированного объекта проводятся условные линии к верхним углам детектированного ценника. И, в зависимости где эти дистанции минимальны, можно сделать вывод какой ценник к какому товару принадлежит. То есть, под каким товаром ближе всего расположен ценник. Вот такая математика на ровном месте, так сказать.
Выглядит это примерно так:

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