Търсене във видеоархив с AI на GX10
Вместо да превърташ часове запис, описваш с думи какво търсиш — и получаваш камера и час, където нещо подобно се вижда. Кадрите се обработват на самата машина и не отиват при облачен доставчик.
pgvector/pgvector:pg18 е за PostgreSQL 18, който пази данните в /var/lib/postgresql.
open_clip_torch с PyTorch за CUDA 13 на ARM64, колко кадъра в секунда обработва твоята машина, точността на търсенето върху твои записи и поведението на многоезични модели.01Какво ще научиш
- Как един модел превръща и кадър, и изречение в списък от числа, които могат да се сравняват.
- Как да пазиш тези списъци в PostgreSQL с pgvector и да намираш най-близките.
- Как да пресметнеш колко място ще заеме архивът, преди да го построиш.
- Как да направиш малко API за търсене, което се вижда само от машината.
- Какво е честно да очакваш от такова търсене и какво да уредиш с юрист, преди да го ползваш върху записи на хора.
02Преди да започнеш
- Машина от класа NVIDIA GB10 (например ASUS Ascent GX10 или DGX Spark) с DGX OS и достъп до терминала ѝ.
- Docker и Python 3. За PyTorch с поддръжка на GB10 виж стъпката за среда в урока за откриване на обекти (версиите за CUDA 13).
- Камера с RTSP поток или обикновен видеофайл за проба. За първите стъпки файлът е достатъчен.
- Правна проверка: виж кутията в стъпка 9. Преди да обработваш записи на реални хора, говори с юрист.
03Стъпки
-
Идеята
Модел от типа CLIP има две „ръце“: едната превръща картинка в вектор, другата — текст в вектор в същото пространство. Колкото два вектора са по-близо, толкова картинката и текстът са по-сходни. Така:
Част Задача indexer.py На всеки няколко секунди взима кадър от потока, прави вектор и го записва с камера и час PostgreSQL + pgvector Пази векторите и намира най-близките до даден вектор search_api.py Превръща твоето описание във вектор и връща най-близките камера и час Не пазим самите кадри и миниатюри: в таблицата има само вектор, камера и час. Оригиналният запис си остава там, където го пази системата за видеонаблюдение.
-
Колко място ще заеме
Първо пресметни, после строй. Според документацията на pgvector един вектор с n числа заема
4 × n + 8байта. Размерът на индекса е допълнително и го измери сам (pg_relation_size) — не го налучкваме с процент.pythoncameras = 16 days = 30 dim = 512 # зависи от модела; за ViT-B-32 е 512 def stored(interval_sec): frames = cameras * days * (86400 / interval_sec) gib = frames * (4 * dim + 8) / 1024**3 return int(frames), gib for sec in (2, 10): frames, gib = stored(sec) print(f"1 кадър на {sec} с: {frames:,} вектора, около {gib:.1f} GiB без индекса")За 16 камери и 30 дни това е около 20,7 милиона вектора и ~40 GiB при един кадър на 2 секунди, и около 4,1 милиона вектора и ~8 GiB при един кадър на 10 секунди (без индекса). По-рядката извадка пести място, но може да пропусне кратко събитие — ползвай я, ако това е приемливо за твоя случай.
-
PostgreSQL с pgvector
Най-лесно е готовият образ на pgvector. Според Docker Hub към 03.10.2026 образът
pgvector/pgvector:pg18(pgvector 0.8.7) е за няколко архитектури, включително ARM64. PostgreSQL 18 пази данните в/var/lib/postgresql— затова томът се закача там.bashdocker run -d --name vss-db \ -e POSTGRES_PASSWORD='<парола>' \ -p 127.0.0.1:5432:5432 \ -v vss-data:/var/lib/postgresql \ pgvector/pgvector:pg18 docker exec vss-db psql -U postgres -c "CREATE DATABASE vss" docker exec vss-db psql -U postgres -d vss -c "CREATE EXTENSION IF NOT EXISTS vector"Паролата си измисли сам и не я записвай във файл с кода.
127.0.0.1:пред порта значи, че базата се вижда само от машината. -
Python среда и модел
Влез във виртуална среда и инсталирай пакетите. PyTorch за твоята машина вземи по урока за откриване на обекти.
bashpip install open_clip_torch pgvector psycopg2-binary fastapi uvicorn opencv-python-headless numpy pillowМалка проба — моделът се зарежда и дава вектор с очаквания брой числа. Теглата се свалят при първото пускане. Примерът е като в документацията на OpenCLIP;
ViT-B-32е сравнително малък модел, подходящ за начало.pythonimport open_clip, torch from PIL import Image device = "cuda" if torch.cuda.is_available() else "cpu" model, _, preprocess = open_clip.create_model_and_transforms( "ViT-B-32", pretrained="laion2b_s34b_b79k", device=device) tokenizer = open_clip.get_tokenizer("ViT-B-32") model.eval() with torch.no_grad(): img = preprocess(Image.open("test_frame.jpg")).unsqueeze(0).to(device) v = model.encode_image(img) print("брой числа във вектора:", v.shape[-1]) # очакваме 512 -
Таблиците и индексът
sql · schema.sqlCREATE TABLE IF NOT EXISTS frame_embeddings ( id BIGSERIAL PRIMARY KEY, camera_id VARCHAR(64) NOT NULL, ts TIMESTAMPTZ NOT NULL, embedding vector(512) NOT NULL -- броят числа зависи от модела ); -- приблизително търсене на най-близки (косинусово разстояние) CREATE INDEX IF NOT EXISTS idx_fe_embedding ON frame_embeddings USING hnsw (embedding vector_cosine_ops); -- за филтриране по камера и време CREATE INDEX IF NOT EXISTS idx_fe_camera_ts ON frame_embeddings (camera_id, ts); -- кой, какво и кога е търсил CREATE TABLE IF NOT EXISTS search_log ( id BIGSERIAL PRIMARY KEY, operator_id VARCHAR(64) NOT NULL, query TEXT NOT NULL, camera_id VARCHAR(64), time_from TIMESTAMPTZ, time_to TIMESTAMPTZ, results_count INT, searched_at TIMESTAMPTZ NOT NULL DEFAULT NOW() );bashdocker exec -i vss-db psql -U postgres -d vss < schema.sql💡Защо филтърът по камера и време иска вниманиеПри приблизителен индекс (HNSW) условиетоWHEREсе прилага след обхождането на индекса и затова може да върне по-малко редове от поискани. От pgvector 0.8.0 има итеративно сканиране, което обхожда още от индекса, когато трябва — ще го включим в API-то. Размерът на индекса и колко точен е той е добре да измериш на твои данни. -
indexer.py: от потока до базата
Един процес за всяка камера. Адресът на потока идва от променлива на средата — не го пишем във файл. Скриптът чете потока непрекъснато, но прави вектор само на всеки N секунди.
python · indexer.pyimport os import sys import time from datetime import datetime, timezone import cv2 import open_clip import psycopg2 import torch from PIL import Image from pgvector.psycopg2 import register_vector device = "cuda" if torch.cuda.is_available() else "cpu" model, _, preprocess = open_clip.create_model_and_transforms( "ViT-B-32", pretrained="laion2b_s34b_b79k", device=device) model.eval() def embed_frame(frame_bgr): img = Image.fromarray(cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)) tensor = preprocess(img).unsqueeze(0).to(device) with torch.no_grad(): f = model.encode_image(tensor) f = f / f.norm(dim=-1, keepdim=True) # нормализиран вектор return f[0].float().cpu().numpy() def run(camera_id, url_env, interval_sec): url = os.environ[url_env] # адресът идва от средата conn = psycopg2.connect(os.environ["DATABASE_URL"]) register_vector(conn) cap = cv2.VideoCapture(url) last = 0.0 while True: ok, frame = cap.read() if not ok: # прекъсната връзка: опитай пак cap.release() time.sleep(5) cap = cv2.VideoCapture(url) continue now = time.monotonic() if now - last < interval_sec: continue last = now vec = embed_frame(frame) with conn, conn.cursor() as cur: # "with conn" потвърждава записа cur.execute( "INSERT INTO frame_embeddings (camera_id, ts, embedding) VALUES (%s, %s, %s)", (camera_id, datetime.now(timezone.utc), vec)) if __name__ == "__main__": # python indexer.py CAM-01 CAM01_RTSP_URL 10 run(sys.argv[1], sys.argv[2], float(sys.argv[3]))bashexport DATABASE_URL='postgresql://postgres:<парола>@localhost:5432/vss' export CAM01_RTSP_URL='rtsp://<потребител>:<парола>@<адрес-на-камерата>:554/<път-на-потока>' python indexer.py CAM-01 CAM01_RTSP_URL 10Започни с една камера, остави я няколко минути и провери, че в таблицата се появяват редове (
SELECT COUNT(*) FROM frame_embeddings). Чак тогава добавяй още. -
search_api.py: описание → камера и час
API-то превръща описанието във вектор със същия модел и пита базата за най-близките редове. Връща камера, час и сходство — не картина. Оператор отваря записа в този час.
python · search_api.pyimport os from datetime import datetime from typing import Optional import open_clip import psycopg2 import torch from fastapi import FastAPI from pgvector.psycopg2 import register_vector from pydantic import BaseModel, Field device = "cuda" if torch.cuda.is_available() else "cpu" model, _, _ = open_clip.create_model_and_transforms( "ViT-B-32", pretrained="laion2b_s34b_b79k", device=device) tokenizer = open_clip.get_tokenizer("ViT-B-32") model.eval() app = FastAPI(title="Търсене във видеоархив") class SearchRequest(BaseModel): operator_id: str # кой търси (за дневника) query: str # описание, по-добре на английски camera_id: Optional[str] = None from_time: Optional[datetime] = None to_time: Optional[datetime] = None top_k: int = Field(default=5, ge=1, le=20) class Hit(BaseModel): camera_id: str ts: datetime similarity: float @app.post("/search", response_model=list[Hit]) def search(req: SearchRequest): with torch.no_grad(): tokens = tokenizer([req.query]).to(device) t = model.encode_text(tokens) t = t / t.norm(dim=-1, keepdim=True) qvec = t[0].float().cpu().numpy() where, params = [], [] if req.camera_id: where.append("camera_id = %s") params.append(req.camera_id) if req.from_time: where.append("ts >= %s") params.append(req.from_time) if req.to_time: where.append("ts <= %s") params.append(req.to_time) where_sql = ("WHERE " + " AND ".join(where)) if where else "" sql = f""" SELECT camera_id, ts, 1 - (embedding <=> %s) AS similarity FROM frame_embeddings {where_sql} ORDER BY embedding <=> %s LIMIT %s """ conn = psycopg2.connect(os.environ["DATABASE_URL"]) try: register_vector(conn) with conn, conn.cursor() as cur: cur.execute("SET LOCAL hnsw.iterative_scan = strict_order") cur.execute(sql, [qvec, *params, qvec, req.top_k]) rows = cur.fetchall() cur.execute( "INSERT INTO search_log (operator_id, query, camera_id, time_from, time_to, results_count) " "VALUES (%s, %s, %s, %s, %s, %s)", (req.operator_id, req.query, req.camera_id, req.from_time, req.to_time, len(rows))) finally: conn.close() return [Hit(camera_id=r[0], ts=r[1], similarity=float(r[2])) for r in rows] @app.get("/health") def health(): return {"status": "ok"}Пусни го така, че да слуша само на машината, и опитай с
curl:bashuvicorn search_api:app --host 127.0.0.1 --port 8120 curl -X POST http://127.0.0.1:8120/search \ -H "Content-Type: application/json" \ -d '{"operator_id": "op-1", "query": "a delivery truck at the gate", "camera_id": "CAM-01", "top_k": 5}'⚠️API-то няма входТози пример не проверява кой го вика. Преди да го отвориш към мрежата, добави удостоверяване (например зад обратен прокси с вход) и ограничи кой може да търси. Дневникътsearch_logе полезен само акоoperator_idе истински, а не въведен от всеки произволно. -
Какво да търсиш и как да четеш резултата
Моделите от този тип са учени предимно на английски текст. Затова най-сигурно е описанието да е на английски; ако оператор пише на български, преведи заявката преди търсенето (например с локален езиков модел). Многоезични варианти има, но не сме ги проверявали — изпробвай ги на свои записи.
Примерно описание Какво търсиш Как да го четеш "a delivery truck at the gate" Камион при входа Отвори записа в този час и провери "a person carrying a large box" Човек с голям пакет Кандидати, не доказателство "several people standing near the entrance" Група при входа Сходството не е вероятност "an open gate at night" Отворена порта нощем Ограничи по камера и час Числото
similarityпоказва само подредба — по-високото е по-близо. То не е процент сигурност. Проверявай винаги самия запис.Изтриването на стари вектори е отделна стъпка. Примерът ползва 30 дни, но реалният срок е решение, което се взема с юриста:
bash# пускай веднъж дневно (например през планировчика на системата) psql "$DATABASE_URL" -c "DELETE FROM frame_embeddings WHERE ts < NOW() - INTERVAL '30 days'" psql "$DATABASE_URL" -c "DELETE FROM search_log WHERE searched_at < NOW() - INTERVAL '90 days'"Според документацията на pgvector почистването (
VACUUM) при HNSW индекси може да е бавно — ускорява се, ако първо преизградиш индекса сREINDEX INDEX CONCURRENTLY. -
Хората в кадъра: правна рамка
⚖️Записът и анализът на хора имат правни изискванияВидеонаблюдението, включително автоматичното търсене в записи с хора, е обработване на лични данни и попада под правилата за защита на личните данни (GDPR) и българското право. Дали векторите също се броят за лични данни, на какво основание се търси, кой има право да търси, колко дълго се пази и как се информират хората — това са въпроси, които зависят от обекта и целта. Препоръка: преди да пуснеш система с реални камери, провери с юрист и запиши решенията му. Този урок е технически и не е правна консултация.- Технически добра практика: пази само вектор, камера и час — без кадри, без миниатюри. Не добавяй разпознаване на лица.
- Ограничи кой може да търси и записвай всяко търсене.
- Определи срок за изтриване и го прилагай по график.
04Проверка
- Контейнерът с pgvector работи, разширението е създадено в базата
vss, томът е закачен в/var/lib/postgresql. - Моделът се зарежда и пробният кадър дава вектор с очаквания брой числа.
- Адресът на камерата е в променлива на средата, не във файл.
- След няколко минути индексиране в
frame_embeddingsима редове от една камера. - Няколко описания на английски връщат правдоподобни моменти; потвърждаваш ги, като отваряш оригиналния запис.
- Измерил си размера на таблицата и на индекса (
pg_relation_size) и си го сравнил с пресмятането от стъпка 2. - API-то слуша само на машината; преди да го отвориш към мрежата, си добавил вход.
- Графикът за изтриване е пуснат със срок, уточнен с юрист; преди да насочиш реална камера към хора — говорил си с него.
Тест
1. Какво връща това търсене?
2. Какво печелиш и какво рискуваш с един кадър на 10 секунди вместо на 2?
3. Защо с HNSW индекс и филтър по камера и време може да получиш по-малко резултати от поискани?
4. Какво е най-разумното, преди да пуснеш търсенето върху записи с реални хора?
05Какво следва
06Източници
- pgvector 🔒 локално — инсталация, HNSW, филтриране, итеративно сканиране, размер на вектора.
- pgvector-python — поддръжка за psycopg2 (
register_vector). - Docker Hub: pgvector/pgvector — етикети на образите (pg18).
- OpenCLIP 🔒 локално — инсталация, зареждане на модел,
encode_image,encode_text. - FastAPI 🔒 локално — документация.