"""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