feat(agent): desktop GPU agent container — CCIP + figure crops over HTTP (#114)
The last piece: a Dockerised desktop-GPU worker that talks to FC ONLY over HTTP (lease → fetch pixels → detect figures + CCIP-embed → submit), so Redis/Postgres stay private. New top-level agent/ (outside CI scope — verified by running it): - fc_agent/worker.py: the lease/compute/submit loop, concurrency 1, start/pause/ stop (stop frees the card; unprocessed leases expire + re-queue). - fc_agent/models.py: imgutils wrappers — detect_person (figures) + CCIP embed. The two API seams to verify against the installed dghs-imgutils (flagged). - fc_agent/media.py: stills + video frame sampling (ffmpeg) at FC's cadence → per-frame instances (the bag). - fc_agent/crops.py: vendored crop primitive. client.py: the FC HTTP client. - fc_agent/app.py: FastAPI localhost control UI (start/pause/stop + progress + queue depth). Dockerfile (CUDA + onnxruntime-gpu + ffmpeg) + requirements + README (token → build → run --gpus all → Start; CPU-fallback path). This completes the CCIP pipeline end to end: agent produces region CCIP vectors → RegionService stores → matcher suggests characters → rail. Verified by running on the desktop (not CI). README calls out the imgutils API + model-string checks. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ttrj5P7upUTueSfoJcxEqa
This commit is contained in:
@@ -0,0 +1,127 @@
|
||||
"""The lease → fetch → detect+embed → submit loop, with start/pause/stop control.
|
||||
|
||||
Concurrency is 1 (one image at a time) so the GPU footprint stays small and a
|
||||
stop frees the card promptly. Stop halts leasing + finishes the current item;
|
||||
unprocessed leases expire and the server re-queues them — nothing is lost.
|
||||
"""
|
||||
import threading
|
||||
import time
|
||||
|
||||
from . import media, models
|
||||
from .client import FcClient
|
||||
from .config import Config
|
||||
from .crops import crop_region
|
||||
|
||||
|
||||
class Worker:
|
||||
def __init__(self, cfg: Config):
|
||||
self.cfg = cfg
|
||||
self.client = FcClient(cfg.fc_url, cfg.token, cfg.agent_id)
|
||||
self._state = "idle" # idle | running | paused | stopping
|
||||
self._lock = threading.Lock()
|
||||
self._thread: threading.Thread | None = None
|
||||
self.processed = 0
|
||||
self.errors = 0
|
||||
self.current = None
|
||||
|
||||
# --- control -----------------------------------------------------------
|
||||
def start(self):
|
||||
with self._lock:
|
||||
if self._state in ("running", "paused"):
|
||||
self._state = "running"
|
||||
return
|
||||
self._state = "running"
|
||||
self._thread = threading.Thread(target=self._run, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
def pause(self):
|
||||
with self._lock:
|
||||
if self._state == "running":
|
||||
self._state = "paused"
|
||||
|
||||
def resume(self):
|
||||
with self._lock:
|
||||
if self._state == "paused":
|
||||
self._state = "running"
|
||||
|
||||
def stop(self):
|
||||
with self._lock:
|
||||
if self._state in ("running", "paused"):
|
||||
self._state = "stopping"
|
||||
|
||||
def status(self) -> dict:
|
||||
with self._lock:
|
||||
state = self._state
|
||||
return {
|
||||
"state": state, "processed": self.processed,
|
||||
"errors": self.errors, "current": self.current,
|
||||
}
|
||||
|
||||
# --- loop --------------------------------------------------------------
|
||||
def _run(self):
|
||||
while True:
|
||||
with self._lock:
|
||||
st = self._state
|
||||
if st == "stopping":
|
||||
break
|
||||
if st == "paused":
|
||||
time.sleep(1)
|
||||
continue
|
||||
try:
|
||||
jobs = self.client.lease(self.cfg.batch_size)
|
||||
except Exception:
|
||||
time.sleep(self.cfg.poll_idle_seconds)
|
||||
continue
|
||||
if not jobs:
|
||||
time.sleep(self.cfg.poll_idle_seconds)
|
||||
continue
|
||||
ids = [j["job_id"] for j in jobs]
|
||||
for job in jobs:
|
||||
with self._lock:
|
||||
if self._state == "stopping":
|
||||
break
|
||||
self._process(job)
|
||||
self.client.heartbeat(ids) # keep the rest of the batch alive
|
||||
with self._lock:
|
||||
self._state = "idle"
|
||||
|
||||
def _process(self, job: dict):
|
||||
self.current = job.get("image_id")
|
||||
try:
|
||||
data = self.client.fetch_image(job["image_url"])
|
||||
if media.is_video(job.get("mime", "")):
|
||||
frames = media.sample_frames(
|
||||
data, job.get("frame_interval_seconds", 4.0),
|
||||
job.get("max_frames", 64),
|
||||
) or [(None, media.load_image(data))]
|
||||
else:
|
||||
frames = [(None, media.load_image(data))]
|
||||
|
||||
regions = []
|
||||
ev = self.cfg.ccip_model or "ccip-default"
|
||||
dv = f"person-{self.cfg.detector_level}"
|
||||
for t, frame in frames:
|
||||
figs = models.detect_figures(frame, self.cfg.detector_level)
|
||||
if not figs:
|
||||
figs = [((0.0, 0.0, 1.0, 1.0), None)] # whole-frame fallback
|
||||
for bbox, score in figs:
|
||||
crop = crop_region(frame, bbox)
|
||||
if crop is None:
|
||||
continue
|
||||
vec = models.ccip_vector(crop, self.cfg.ccip_model or None)
|
||||
regions.append({
|
||||
"kind": "figure",
|
||||
"bbox": list(bbox),
|
||||
"frame_time": t,
|
||||
"score": score,
|
||||
"ccip_embedding": vec,
|
||||
"embedding_version": ev,
|
||||
"detector_version": dv,
|
||||
})
|
||||
self.client.submit(job["job_id"], regions, ["figure", "face"])
|
||||
self.processed += 1
|
||||
except Exception as exc: # noqa: BLE001 — report + move on
|
||||
self.errors += 1
|
||||
self.client.fail(job["job_id"], str(exc)[:500])
|
||||
finally:
|
||||
self.current = None
|
||||
Reference in New Issue
Block a user