Знакът на КАГАМИ КАГАМИ
kagami.bg/academy · lesson · machine-readable viewUPDATED 2026-10-03
IDENTITY
module
GX10-04-120 · Video archive search with AI (OpenCLIP + pgvector + FastAPI)
series
GX10 (local AI server class: NVIDIA GB10, e.g. ASUS Ascent GX10 / DGX Spark)
level
Intermediate
duration
about 2 h
prerequisites
A GB10-class machine with DGX OS (Ubuntu, Arm64), Docker, Python 3, one or more RTSP-capable cameras (or a test video file); lesson GX10-04-118 helps for the camera setup
trust_label
UPDATED 2026-10-03 (rewritten; pgvector 0.8.7 README, pgvector-python README, the pgvector Docker image tags and the OpenCLIP README checked as of 2026-10-03) · NOT TESTED (no GB10 machine available during the update; no command in this lesson was run by the authors)
versions
pgvector 0.8.7 (current at the time of the check) · Docker image pgvector/pgvector:pg18 (multi-arch, includes arm64) · PostgreSQL 18 · OpenCLIP: pip package open_clip_torch, example model ViT-B-32 with weights laion2b_s34b_b79k (512-dim embeddings) · Python packages pgvector, psycopg2-binary, fastapi, uvicorn, opencv-python-headless: current releases (not pinned)
language
human view: bg · english edition: /en/academy/gx10/ (same file name)
previous / next
GX10-04-119 Zone alerts / GX10-04-126 Face access control and GDPR
PURPOSE

Build a semantic search over a video archive: sample one frame per interval from each RTSP stream, turn it into an embedding with an OpenCLIP image encoder, store embeddings with camera and time in PostgreSQL with pgvector (HNSW, cosine distance), and expose a small FastAPI endpoint that turns a text description into a text embedding and returns the nearest camera and time moments, so an operator can open the original recording at that time. Only embeddings, camera and time are stored: no frames, no thumbnails. Results are candidates to be confirmed by a person, not identifications. Recording and analysing people has legal requirements (GDPR): a lawyer must be consulted before production use.

KEY CONCEPTS
COMMANDS / PATHS
CHECKLIST
NEXT MODULE

GX10-04-126 Face access control and GDPR · GX10 series index: kagami.bg/academy/gx10/ · offer: Quick experiment (kagami.bg/stalbata/)

SOURCES
TAGS
gx10nvidia-gb10arm64openclippgvectorpostgresqlfastapisemantic-searchvideo-analytics
ОБНОВЕНО · 03.10.2026

Търсене във видеоархив с AI на GX10

Вместо да превърташ часове запис, описваш с думи какво търсиш — и получаваш камера и час, където нещо подобно се вижда. Кадрите се обработват на самата машина и не отиват при облачен доставчик.

⏱ 2 ч Средно GX10 NVIDIA GB10 · 128 GB обща памет OpenCLIP · pgvector · FastAPI
OpenCLIP🔒 локално PostgreSQL + pgvector🔒 локално FastAPI🔒 локално
🔄
ОБНОВЕНО · 03.10.2026 — какво
Урокът е написан наново по документацията на pgvector и OpenCLIP. Махнахме: твърденията „60 минути срещу под 10 секунди“ и „~8 TOPS“ (числа без измерване), несъществуващия пакет „Nemotron Embed“ за инсталиране, паролата и адресите на камерите в кода, миниатюрите на кадрите (изображенията на хора са лични данни — вече не ги пазим), примерите за търсене на „маскирани лица“, правното твърдение, че векторите „не са лични данни“ (не можем да го потвърдим), членовете от закона и надценката на индекса „~20%“ (без източник). Добавихме: пресмятане на мястото, което можеш да пуснеш сам, готов контейнер на pgvector за ARM64, поправката за филтриране с HNSW индекс (итеративно сканиране от pgvector 0.8.0), API, което слуша само на машината и записва кой е търсил, и честна бележка за езика на заявките — моделите са учени предимно на английски. Новост: образът pgvector/pgvector:pg18 е за PostgreSQL 18, който пази данните в /var/lib/postgresql.
⚠️
Какво не сме пускали сами
При проверката нямахме машина от класа GB10. Нито една команда от този урок не е пусната от нас — сверени са с документацията, но няма етикет „ТЕСТВАНО“. Непроверени са още: инсталирането на open_clip_torch с PyTorch за CUDA 13 на ARM64, колко кадъра в секунда обработва твоята машина, точността на търсенето върху твои записи и поведението на многоезични модели.

01Какво ще научиш

02Преди да започнеш

💡
Какво прави и какво не прави тази система
Тя търси кадри, които приличат на описанието. Не е сигурен отговор, не разпознава кой е човекът и не заменя прегледа на самия запис. Резултатът е списък от кандидати — човек отваря оригиналния запис в този час и преценява.

03Стъпки

  1. Идеята

    Модел от типа CLIP има две „ръце“: едната превръща картинка в вектор, другата — текст в вектор в същото пространство. Колкото два вектора са по-близо, толкова картинката и текстът са по-сходни. Така:

    ЧастЗадача
    indexer.pyНа всеки няколко секунди взима кадър от потока, прави вектор и го записва с камера и час
    PostgreSQL + pgvectorПази векторите и намира най-близките до даден вектор
    search_api.pyПревръща твоето описание във вектор и връща най-близките камера и час

    Не пазим самите кадри и миниатюри: в таблицата има само вектор, камера и час. Оригиналният запис си остава там, където го пази системата за видеонаблюдение.

  2. Колко място ще заеме

    Първо пресметни, после строй. Според документацията на pgvector един вектор с n числа заема 4 × n + 8 байта. Размерът на индекса е допълнително и го измери сам (pg_relation_size) — не го налучкваме с процент.

    python
    cameras = 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 секунди (без индекса). По-рядката извадка пести място, но може да пропусне кратко събитие — ползвай я, ако това е приемливо за твоя случай.

  3. PostgreSQL с pgvector

    Най-лесно е готовият образ на pgvector. Според Docker Hub към 03.10.2026 образът pgvector/pgvector:pg18 (pgvector 0.8.7) е за няколко архитектури, включително ARM64. PostgreSQL 18 пази данните в /var/lib/postgresql — затова томът се закача там.

    bash
    docker 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: пред порта значи, че базата се вижда само от машината.

  4. Python среда и модел

    Влез във виртуална среда и инсталирай пакетите. PyTorch за твоята машина вземи по урока за откриване на обекти.

    bash
    pip install open_clip_torch pgvector psycopg2-binary fastapi uvicorn opencv-python-headless numpy pillow

    Малка проба — моделът се зарежда и дава вектор с очаквания брой числа. Теглата се свалят при първото пускане. Примерът е като в документацията на OpenCLIP; ViT-B-32 е сравнително малък модел, подходящ за начало.

    python
    import 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
  5. Таблиците и индексът

    sql · schema.sql
    CREATE 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()
    );
    bash
    docker exec -i vss-db psql -U postgres -d vss < schema.sql
    💡
    Защо филтърът по камера и време иска внимание
    При приблизителен индекс (HNSW) условието WHERE се прилага след обхождането на индекса и затова може да върне по-малко редове от поискани. От pgvector 0.8.0 има итеративно сканиране, което обхожда още от индекса, когато трябва — ще го включим в API-то. Размерът на индекса и колко точен е той е добре да измериш на твои данни.
  6. indexer.py: от потока до базата

    Един процес за всяка камера. Адресът на потока идва от променлива на средата — не го пишем във файл. Скриптът чете потока непрекъснато, но прави вектор само на всеки N секунди.

    python · indexer.py
    import 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]))
    bash
    export 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). Чак тогава добавяй още.

  7. search_api.py: описание → камера и час

    API-то превръща описанието във вектор със същия модел и пита базата за най-близките редове. Връща камера, час и сходство — не картина. Оператор отваря записа в този час.

    python · search_api.py
    import 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:

    bash
    uvicorn 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 е истински, а не въведен от всеки произволно.
  8. Какво да търсиш и как да четеш резултата

    Моделите от този тип са учени предимно на английски текст. Затова най-сигурно е описанието да е на английски; ако оператор пише на български, преведи заявката преди търсенето (например с локален езиков модел). Многоезични варианти има, но не сме ги проверявали — изпробвай ги на свои записи.

    Примерно описаниеКакво търсишКак да го четеш
    "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.

  9. Хората в кадъра: правна рамка

    ⚖️
    Записът и анализът на хора имат правни изисквания
    Видеонаблюдението, включително автоматичното търсене в записи с хора, е обработване на лични данни и попада под правилата за защита на личните данни (GDPR) и българското право. Дали векторите също се броят за лични данни, на какво основание се търси, кой има право да търси, колко дълго се пази и как се информират хората — това са въпроси, които зависят от обекта и целта. Препоръка: преди да пуснеш система с реални камери, провери с юрист и запиши решенията му. Този урок е технически и не е правна консултация.
    • Технически добра практика: пази само вектор, камера и час — без кадри, без миниатюри. Не добавяй разпознаване на лица.
    • Ограничи кой може да търси и записвай всяко търсене.
    • Определи срок за изтриване и го прилагай по график.

04Проверка

Тест

1. Какво връща това търсене?

2. Какво печелиш и какво рискуваш с един кадър на 10 секунди вместо на 2?

3. Защо с HNSW индекс и филтър по камера и време може да получиш по-малко резултати от поискани?

4. Какво е най-разумното, преди да пуснеш търсенето върху записи с реални хора?

05Какво следва

06Източници

  1. pgvector 🔒 локално — инсталация, HNSW, филтриране, итеративно сканиране, размер на вектора.
  2. pgvector-python — поддръжка за psycopg2 (register_vector).
  3. Docker Hub: pgvector/pgvector — етикети на образите (pg18).
  4. OpenCLIP 🔒 локално — инсталация, зареждане на модел, encode_image, encode_text.
  5. FastAPI 🔒 локално — документация.