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

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