"""Worker Multi-HMR : capture webcam Mac, inference forward unique
SMPL-X (multi-personne natif), extraction vertices v3d, ecriture State.

Le repo Multi-HMR n'est pas pip-installable — on injecte le clone dans
sys.path au runtime. Chaque humain renvoye contient deja les vertices
SMPL-X decodes (cle `v3d`, shape (10475, 3)) ; pas besoin du decoder
SMPL-X separe en hot path (il reste utile pour les tests).

Cadence cible : 8-12 fps sur M5 (ViT-S). Lissage One Euro sur les
shapes/expression pour limiter le jitter trame-a-trame.
"""
from __future__ import annotations

import logging
import os
import sys
import threading
import time
from pathlib import Path

import numpy as np

from .arkit_joint_map import ARKIT_PELVIS_IDX
from .euro_filter import OneEuroFilter
from .state import PoseKp, SMPLXPerson, State
from .tracker import IoUTracker

LOG = logging.getLogger("multi_hmr")

CACHE = Path.home() / ".cache" / "av-live-multihmr"
CKPT = CACHE / "checkpoints" / "multiHMR_672_S.pt"
SMPLX_PATH = CACHE / "models" / "smplx" / "SMPLX_NEUTRAL.npz"
MULTIHMR_REPO = CACHE / "multi-hmr"
COREML_MLPACKAGE = Path(
    os.environ.get("COREML_MLPACKAGE")
    or str(CACHE / "multihmr_full_672_s.mlpackage"))

IMG_SIZE = 672
N_VERTS = 10475


def arkit_pelvis_z_override(state, pid: int, z_pred: float,
                            fresh_sec: float = 1.0) -> float:
    """Return ARKit pelvis world-z if a fresh ARKit frame exists for
    this pid, otherwise return the Multi-HMR predicted z unchanged.

    Used to resolve Multi-HMR's monocular scale ambiguity: ARKit's
    LiDAR-anchored pelvis position is ground truth in the iPhone
    world frame, which (after extrinsics calibration) is the same
    metric scale as the SMPL-X cam-space output.
    """
    with state.lock():
        arr = state.persons_arkit_joints.get(pid)
        last_t = state.persons_arkit_last_t.get(pid, 0.0)
    if arr is None:
        return float(z_pred)
    if time.perf_counter() - last_t > fresh_sec:
        return float(z_pred)
    return float(arr[ARKIT_PELVIS_IDX, 2])


class MultiHMRWorker:
    def __init__(self, state: State, num_persons: int = 4,
                 target_fps: float = 10.0, device: str = "mps",
                 det_thresh: float = 0.3,
                 nms_kernel_size: int = 5,
                 motion_gate: float = 5.0,
                 camera_index: int = -1,
                 backend: str | None = None) -> None:
        self.state = state
        self.num_persons = num_persons
        self.period = 1.0 / max(1.0, target_fps)
        self.device = device
        self.det_thresh = det_thresh
        self.nms_kernel_size = nms_kernel_size
        # Motion gate : si la diff moyenne par pixel (sur frame 672x672
        # downsamplee a 112x112 pour speed) est < motion_gate, on skip
        # l'inference et on reutilise les v3d precedents. Seuil en
        # unites 0-255. Mettre <=0 pour desactiver.
        self.motion_gate = motion_gate
        # -1 = auto-select Mac BuiltInWideAngleCamera (cf _camera_select)
        self.camera_index = camera_index
        # backend: 'pytorch' (default) or 'coreml'. CoreML uses the
        # .mlpackage at COREML_MLPACKAGE, bypasses MPS torch, and runs
        # on ANE/GPU/CPU via CoreML.framework natively (3-4x faster).
        self.backend = (backend
                        or os.environ.get("MULTIHMR_BACKEND", "pytorch")
                        ).strip().lower()
        self._stop = threading.Event()
        self._thread: threading.Thread | None = None
        self._smooth_shape = [
            [OneEuroFilter(0.8, 0.05) for _ in range(10)]
            for _ in range(num_persons)
        ]
        self._smooth_expr = [
            [OneEuroFilter(1.0, 0.08) for _ in range(10)]
            for _ in range(num_persons)
        ]
        # iou_threshold bas + max_miss eleve + prediction velocity
        # (cf tracker.py) pour resister aux occlusions et au mouvement
        # rapide. Multi-HMR a 3 fps -> 30 frames = 10s de survie.
        self._tracker = IoUTracker(iou_threshold=0.15, max_miss=30)
        # Lazily-loaded CoreML backend for predict_once (single-shot,
        # off-thread). Independent of the worker thread's _run_coreml
        # backend instance — predict_once must work even without start().
        self._coreml_backend_singleshot = None

    @staticmethod
    def is_available() -> bool:
        backend = os.environ.get("MULTIHMR_BACKEND", "pytorch").strip().lower()
        if backend == "coreml":
            return COREML_MLPACKAGE.exists()
        if backend == "remote":
            try:
                from .multihmr_remote import MultiHMRRemoteBackend
                return MultiHMRRemoteBackend.is_available()
            except Exception:  # noqa: BLE001
                return False
        return CKPT.exists() and SMPLX_PATH.exists() and MULTIHMR_REPO.exists()

    def start(self) -> None:
        self._thread = threading.Thread(
            target=self._run, name="multi_hmr", daemon=True)
        self._thread.start()

    def stop(self) -> None:
        self._stop.set()

    def _get_or_load_coreml_backend(self):
        """Lazily load the CoreML backend for single-shot inference.

        Returns the cached `MultiHMRCoreMLBackend` instance, or None if
        the backend cannot be imported / the .mlpackage is missing.
        Thread-safe enough for our use (calibration CLI is single-
        threaded; the worker thread uses its own backend in _run_coreml).
        """
        if self._coreml_backend_singleshot is not None:
            return self._coreml_backend_singleshot
        try:
            from .multihmr_coreml import MultiHMRCoreMLBackend
            backend = MultiHMRCoreMLBackend(COREML_MLPACKAGE)
        except (ImportError, FileNotFoundError) as e:
            LOG.info("predict_once: CoreML backend unavailable: %s", e)
            return None
        except Exception as e:  # noqa: BLE001
            LOG.warning("predict_once: CoreML backend init failed: %s", e)
            return None
        self._coreml_backend_singleshot = backend
        return backend

    def predict_once(self, rgb_image):
        """Single-shot SMPL-X prediction on one RGB image.

        Args:
            rgb_image: (H, W, 3) uint8 RGB array. Will be center-
                cropped + resized to 672x672 internally.

        Returns:
            First `SMPLXPerson` detection (pid=0) or None if no
            humans pass the detection threshold.

        Raises:
            NotImplementedError: if the CoreML backend is unavailable
                (PyTorch single-shot path is TBD).
        """
        backend = self._get_or_load_coreml_backend()
        if backend is None:
            raise NotImplementedError(
                "CoreML backend unavailable; PyTorch single-shot path TBD")

        try:
            import cv2
        except ImportError as e:
            raise NotImplementedError(
                "opencv-python required for predict_once: %s" % e)

        rgb = np.asarray(rgb_image)
        if rgb.ndim != 3 or rgb.shape[2] != 3:
            raise ValueError(
                f"rgb_image must be (H,W,3), got {rgb.shape}")
        h, w = rgb.shape[:2]
        if (h, w) != (IMG_SIZE, IMG_SIZE):
            side = min(h, w)
            y0 = (h - side) // 2
            x0 = (w - side) // 2
            rgb = rgb[y0:y0 + side, x0:x0 + side]
            rgb = cv2.resize(rgb, (IMG_SIZE, IMG_SIZE))

        img = rgb.transpose(2, 0, 1).astype(np.float32) / 255.0
        focal = float(IMG_SIZE)
        K_np = np.array([[focal, 0.0, IMG_SIZE / 2.0],
                         [0.0, focal, IMG_SIZE / 2.0],
                         [0.0, 0.0, 1.0]], dtype=np.float32)

        humans = backend.infer(img, K_np, det_thresh=self.det_thresh)
        if not humans:
            return None

        hh = humans[0]
        v3d = hh["v3d"].detach().cpu().numpy()
        return SMPLXPerson(
            pid=0,
            vertices_3d=np.ascontiguousarray(v3d, dtype=np.float32),
        )

    def _run(self) -> None:
        if self.backend == "coreml":
            self._run_coreml(remote=False)
            return
        if self.backend == "remote":
            self._run_coreml(remote=True)
            return
        self._run_pytorch()

    def _run_pytorch(self) -> None:
        if str(MULTIHMR_REPO) not in sys.path:
            sys.path.insert(0, str(MULTIHMR_REPO))
        # Multi-HMR demo.py tire pyrender / pyvista (OpenGL offscreen) et
        # multi_hmr_anny (anny package non public). Aucun n'est necessaire
        # pour l'inference brute : on stubbe.
        import types as _t
        for mod in ("pyrender", "pyvista", "anny"):
            if mod not in sys.modules:
                sys.modules[mod] = _t.ModuleType(mod)
        try:
            import torch
            import cv2
            # Import direct du Model (sans passer par demo.load_model qui
            # depend de multi_hmr_anny).
            from model import Model  # type: ignore
        except ImportError as e:
            LOG.error("deps manquantes : %s — uv sync --extra multihmr "
                      "et bash scripts/setup_multihmr.sh", e)
            return

        if self.device == "mps" and not torch.backends.mps.is_available():
            LOG.warning("MPS unavailable, falling back to cpu")
            device = "cpu"
        else:
            device = self.device

        ckpt_name = CKPT.stem
        # SMPLX_DIR='models' et MEAN_PARAMS='models/smpl_mean_params.npz'
        # sont relatifs au cwd. On bascule dans le repo Multi-HMR pour la
        # construction du modele puis on revient.
        prev_cwd = os.getcwd()
        try:
            os.chdir(MULTIHMR_REPO)
            torch_device = torch.device(device)
            ckpt = torch.load(str(CKPT), map_location=torch_device,
                              weights_only=False)
            kwargs = {k: v for k, v in vars(ckpt["args"]).items()}
            kwargs["type"] = ckpt["args"].train_return_type
            kwargs["img_size"] = ckpt["args"].img_size[0]
            model = Model(**kwargs).to(torch_device)
            model.load_state_dict(ckpt["model_state_dict"], strict=False)
            model.eval()
            # MPS mixed precision via torch.autocast : ~1.3-1.7x sur
            # ViT-S backbone, casts auto vers float16 pour les matmuls
            # gardant l'accumulator en float32 (necessaire MPS sinon
            # "Destination NDArray and Accumulator NDArray cannot have
            # different datatype" sur MPSNDArrayMatrixMultiplication).
            # Disable via env MULTIHMR_AUTOCAST=0.
            # autocast MPS teste 2026-05-13 : plus lent (400ms vs 270ms
            # baseline) car overhead de cast dans le forward. Defaut OFF.
            # Opt-in via MULTIHMR_AUTOCAST=1.
            self._use_autocast = (
                device == "mps"
                and os.environ.get("MULTIHMR_AUTOCAST", "0") == "1")
            if self._use_autocast:
                LOG.info("Multi-HMR PyTorch : MPS autocast (fp16) enabled")
            # torch.compile teste 2026-05-13 : plante en runtime avec
            # `TypeError: torch.Size() takes an iterable of 'int' (item
            # is 'FakeTensor')`. Multi-HMR a du shape-arithmetic non
            # traceable, on garde le eager.
        except Exception as e:
            LOG.error("Multi-HMR load failed: %s", e)
            os.chdir(prev_cwd)
            return
        finally:
            os.chdir(prev_cwd)
        LOG.info("Multi-HMR loaded (%s) on %s", ckpt_name, device)

        # Camera intrinsics (focale = img_size par defaut). batch dim 1.
        focal = float(IMG_SIZE)
        K = torch.tensor([[[focal, 0.0, IMG_SIZE / 2.0],
                           [0.0, focal, IMG_SIZE / 2.0],
                           [0.0, 0.0, 1.0]]], device=device)

        # Capture AVFoundation native — selection par device-type, pas
        # par index cv2 (qui ne suit pas l'ordre AVFoundation et finit
        # parfois sur l'iPhone Continuity).
        from ._av_capture import AVCapture, find_builtin_device, enumerate_devices
        if self.camera_index >= 0:
            devs = enumerate_devices()
            if self.camera_index >= len(devs):
                LOG.error("camera_index %d hors de %d devices",
                          self.camera_index, len(devs))
                return
            info = devs[self.camera_index]
        else:
            info = find_builtin_device()
            if info is None:
                LOG.error("aucune BuiltInWideAngleCamera trouvee")
                return
        cap = AVCapture(info)
        if not cap.start():
            LOG.error("AVCapture start failed pour %s", info["name"])
            return
        LOG.info("camera ouverte %s (%s)", info["name"], info["type"])
        frame_count = 0
        persons_count = 0
        skipped_static = 0
        next_heartbeat = time.monotonic() + 5.0
        # Frame thumbnail precedent pour motion gate (112x112 gray).
        prev_thumb: np.ndarray | None = None

        while not self._stop.is_set():
            t_cap_start = time.monotonic()
            ok, frame_bgr = cap.read(timeout_s=0.5)
            if not ok or frame_bgr is None:
                time.sleep(self.period)
                continue

            t_pre_start = time.monotonic()
            # Crop/resize au carre 896 pour matcher Multi-HMR
            h, w = frame_bgr.shape[:2]
            if (h, w) != (IMG_SIZE, IMG_SIZE):
                # Center-crop + resize
                side = min(h, w)
                y0 = (h - side) // 2
                x0 = (w - side) // 2
                frame_bgr = frame_bgr[y0:y0 + side, x0:x0 + side]
                frame_bgr = cv2.resize(frame_bgr, (IMG_SIZE, IMG_SIZE))

            # Motion gate : downsample en 112x112 gris, diff vs frame
            # precedente. Si bouge peu, skip l'inference (re-utilise
            # les v3d deja en state).
            if self.motion_gate > 0:
                thumb = cv2.cvtColor(
                    cv2.resize(frame_bgr, (112, 112)),
                    cv2.COLOR_BGR2GRAY)
                if prev_thumb is not None:
                    diff_mean = float(np.mean(
                        cv2.absdiff(thumb, prev_thumb)))
                    if diff_mean < self.motion_gate:
                        prev_thumb = thumb
                        skipped_static += 1
                        time.sleep(self.period)
                        continue
                prev_thumb = thumb

            frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
            # Publish to state for DINOv2 reid in MeshRigger.
            with self.state.lock():
                self.state.last_frame_rgb = frame_rgb
                self.state.last_frame_rgb_t = time.monotonic()
            tensor = torch.from_numpy(frame_rgb).permute(2, 0, 1).float()
            tensor = (tensor / 255.0).unsqueeze(0).to(device)

            t_inf_start = time.monotonic()
            try:
                with torch.no_grad():
                    if getattr(self, "_use_autocast", False):
                        with torch.autocast(device_type="mps",
                                            dtype=torch.float16):
                            humans = model(
                                tensor,
                                is_training=False,
                                nms_kernel_size=self.nms_kernel_size,
                                det_thresh=self.det_thresh,
                                K=K,
                            )
                    else:
                        humans = model(
                            tensor,
                            is_training=False,
                            nms_kernel_size=self.nms_kernel_size,
                            det_thresh=self.det_thresh,
                            K=K,
                        )
            except Exception as e:
                LOG.warning("inference failed: %s", e)
                time.sleep(self.period)
                continue

            t_post_start = time.monotonic()
            t_now = time.monotonic()
            # Count frame + heartbeat regardless of detection — keeps the
            # FPS metric meaningful when nobody is in the camera view.
            frame_count += 1
            persons_count += len(humans) if humans else 0
            if t_now >= next_heartbeat:
                fps = frame_count / 5.0
                avg = persons_count / max(1, frame_count)
                LOG.info(
                    "hb: %.1f fps, %.2f persons/frame, %d skipped (static)",
                    fps, avg, skipped_static)
                frame_count = 0
                persons_count = 0
                skipped_static = 0
                next_heartbeat = t_now + 5.0
            if not humans:
                with self.state.lock():
                    self.state.persons_smplx = []
                inf_ms = (t_post_start - t_inf_start) * 1e3
                LOG.debug("frame (no detect): inf=%.1fms", inf_ms)
                time.sleep(self.period)
                continue

            # Dedup intra-frame : Multi-HMR peut retourner plusieurs
            # detections pour la meme personne. On combine bbox 2D IoU
            # ET distance pelvis 3D : drop ssi IoU > 0.4 ET dist < 30 cm.
            # Comme ca deux personnes qui se chevauchent en 2D (une
            # devant l'autre) restent distinctes grace au z.
            cand: list[tuple[
                float, float, float, float, float,
                np.ndarray, int]] = []
            for i, h in enumerate(humans):
                v = h["v3d"].detach().cpu().numpy()
                xmin = float(v[:, 0].min())
                ymin = float(v[:, 1].min())
                xmax = float(v[:, 0].max())
                ymax = float(v[:, 1].max())
                sc_raw = h.get("scores", 1.0)
                score = float(sc_raw.item()) if hasattr(
                    sc_raw, "item") else float(sc_raw)
                transl = h.get("transl_pelvis", h.get("transl"))
                pelv = transl.detach().cpu().numpy().flatten()[:3]
                cand.append((score, xmin, ymin, xmax, ymax, pelv, i))
            cand.sort(key=lambda c: -c[0])
            keep_idx: list[int] = []
            kept: list[tuple[
                float, float, float, float, np.ndarray]] = []
            for sc, x0, y0, x1, y1, pelv, src_i in cand:
                a_area = max(0.0, x1 - x0) * max(0.0, y1 - y0)
                drop = False
                for (kx0, ky0, kx1, ky1, kpelv) in kept:
                    ix0 = max(x0, kx0); iy0 = max(y0, ky0)
                    ix1 = min(x1, kx1); iy1 = min(y1, ky1)
                    iw = max(0.0, ix1 - ix0); ih = max(0.0, iy1 - iy0)
                    inter = iw * ih
                    if a_area <= 0 or inter <= 0:
                        continue
                    k_area = (kx1 - kx0) * (ky1 - ky0)
                    iou = inter / (a_area + k_area - inter + 1e-9)
                    pelv_d = float(np.linalg.norm(pelv - kpelv))
                    # Drop seulement si TRES proches en 3D ET grand
                    # overlap 2D. Seuils volontairement conservateurs
                    # pour ne pas fusionner deux personnes serrees.
                    if iou > 0.55 and pelv_d < 0.20:
                        drop = True
                        break
                if not drop:
                    keep_idx.append(src_i)
                    kept.append((x0, y0, x1, y1, pelv))
                    if len(keep_idx) >= self.num_persons:
                        break
            n_raw = len(humans)
            humans = [humans[i] for i in keep_idx]
            n_keep = len(humans)
            if n_raw != n_keep:
                LOG.debug("dedup: %d -> %d (raw det_thresh=%.2f)",
                          n_raw, n_keep, self.det_thresh)

            # Tracking via bbox approximee depuis verts projetes (xy)
            bboxes = []
            for h in humans:
                v = h["v3d"].detach().cpu().numpy()  # (10475, 3)
                xmin, ymin = float(v[:, 0].min()), float(v[:, 1].min())
                xmax, ymax = float(v[:, 0].max()), float(v[:, 1].max())
                bboxes.append([PoseKp(x=xmin, y=ymin, c=1.0),
                               PoseKp(x=xmax, y=ymax, c=1.0)])
            ids = self._tracker.update(bboxes)

            persons: list[SMPLXPerson] = []
            for i, hh in enumerate(humans[:n_keep]):
                pid = ids[i] if i < len(ids) else i
                if pid < 0:
                    continue

                v3d = hh["v3d"].detach().cpu().numpy()
                transl = hh.get("transl_pelvis", hh.get("transl"))
                transl_np = transl.detach().cpu().numpy().flatten()
                if transl_np.size >= 3:
                    transl_np = transl_np.copy()
                    transl_np[2] = arkit_pelvis_z_override(
                        self.state, pid, float(transl_np[2]))

                shape_raw = hh["shape"].detach().cpu().numpy().flatten()
                expr_raw = hh["expression"].detach().cpu().numpy().flatten()

                # Skip persons with NaN/Inf vertices or transl : MPS can
                # occasionally emit garbage that propagates to AVLiveBody
                # as spikes / holes. We drop the frame for that pid and
                # let the receiver's retain window keep the last good mesh.
                if (not np.isfinite(v3d).all()
                        or not np.isfinite(transl_np).all()):
                    LOG.warning("Multi-HMR NaN/Inf at pid=%d, skipping", pid)
                    continue
                # Sanity clamp on extreme vertex magnitudes (humans are
                # ~2 m ; vertices outside [-5, 5] m are model glitches).
                if float(np.abs(v3d).max()) > 5.0:
                    LOG.warning(
                        "Multi-HMR v3d extreme |max|=%.1f at pid=%d, skipping",
                        float(np.abs(v3d).max()), pid,
                    )
                    continue

                pid_c = pid % self.num_persons
                shape_n = min(10, len(shape_raw))
                expr_n = min(10, len(expr_raw))
                shape_smooth = np.zeros(10, dtype=np.float32)
                expr_smooth = np.zeros(10, dtype=np.float32)
                for k in range(shape_n):
                    shape_smooth[k] = self._smooth_shape[pid_c][k](
                        float(shape_raw[k]), t_now)
                for k in range(expr_n):
                    expr_smooth[k] = self._smooth_expr[pid_c][k](
                        float(expr_raw[k]), t_now)

                persons.append(SMPLXPerson(
                    pid=int(pid),
                    vertices_3d=np.ascontiguousarray(v3d, dtype=np.float32),
                    translation=np.ascontiguousarray(transl_np[:3], dtype=np.float32),
                    confidence=float(hh.get("scores", 1.0)) if not hasattr(
                        hh.get("scores", None), "item") else float(
                        hh["scores"].item()),
                    betas=np.ascontiguousarray(shape_smooth, dtype=np.float32),
                    expression=np.ascontiguousarray(expr_smooth, dtype=np.float32),
                ))

            with self.state.lock():
                self.state.persons_smplx = persons
                self.state.smplx_last_t = t_now

            t_end = time.monotonic()
            dt_total = (t_end - t_cap_start) * 1e3
            if LOG.isEnabledFor(logging.DEBUG) or dt_total > 100.0:
                LOG.log(
                    logging.DEBUG if dt_total <= 100.0 else logging.WARNING,
                    "frame: cap=%.1f pre=%.1f inf=%.1f post=%.1fms total=%.1fms",
                    (t_pre_start - t_cap_start) * 1e3,
                    (t_inf_start - t_pre_start) * 1e3,
                    (t_post_start - t_inf_start) * 1e3,
                    (t_end - t_post_start) * 1e3,
                    dt_total,
                )

            dt = time.monotonic() - t_cap_start
            if dt < self.period:
                time.sleep(self.period - dt)

        cap.stop()
        LOG.info("multi_hmr worker stopped")

    # ------------------------------------------------------------------
    # CoreML backend
    # ------------------------------------------------------------------
    def _run_coreml(self, remote: bool = False) -> None:
        """CoreML inference path (ANE+GPU+CPU via Apple's framework).

        Mirrors _run_pytorch but loads the .mlpackage via pyobjc + the
        CoreML.framework, bypassing torch/MPS entirely. ~3-4x faster
        on M5 (28.8ms median vs ~100ms with MPS).

        If ``remote=True``, the local CoreML backend is swapped for a
        TCP client (``MultiHMRRemoteBackend``) that talks to a server
        running the same mlpackage on a faster Mac (macm1, M1 Max).
        """
        try:
            import cv2
        except ImportError as e:
            LOG.error("opencv-python missing: %s", e)
            return
        try:
            if remote:
                from .multihmr_remote import MultiHMRRemoteBackend
                host = os.environ.get(
                    "MULTIHMR_REMOTE_HOST", "192.168.0.175")
                port = int(os.environ.get(
                    "MULTIHMR_REMOTE_PORT", "57140"))
                backend = MultiHMRRemoteBackend(host=host, port=port)
                LOG.info("Multi-HMR remote backend (%s:%d)", host, port)
            else:
                from .multihmr_coreml import MultiHMRCoreMLBackend
                backend = MultiHMRCoreMLBackend(COREML_MLPACKAGE)
        except Exception as e:  # noqa: BLE001
            LOG.error("CoreML backend init failed: %s", e)
            return

        focal = float(IMG_SIZE)
        K_np = np.array([[focal, 0.0, IMG_SIZE / 2.0],
                         [0.0, focal, IMG_SIZE / 2.0],
                         [0.0, 0.0, 1.0]], dtype=np.float32)

        from ._av_capture import (
            AVCapture, find_builtin_device, enumerate_devices)
        if self.camera_index >= 0:
            devs = enumerate_devices()
            if self.camera_index >= len(devs):
                LOG.error("camera_index %d hors de %d devices",
                          self.camera_index, len(devs))
                return
            info = devs[self.camera_index]
        else:
            info = find_builtin_device()
            if info is None:
                LOG.error("aucune BuiltInWideAngleCamera trouvee")
                return
        cap = AVCapture(info)
        if not cap.start():
            LOG.error("AVCapture start failed pour %s", info["name"])
            return
        LOG.info("camera ouverte %s (%s) [%s backend]",
                 info["name"], info["type"],
                 "remote" if remote else "coreml")

        frame_count = 0
        persons_count = 0
        skipped_static = 0
        fresh_count = 0
        next_heartbeat = time.monotonic() + 5.0
        prev_thumb: np.ndarray | None = None

        while not self._stop.is_set():
            t_cap_start = time.monotonic()
            ok, frame_bgr = cap.read(timeout_s=0.5)
            if not ok or frame_bgr is None:
                time.sleep(self.period)
                continue

            t_pre_start = time.monotonic()
            h, w = frame_bgr.shape[:2]
            if (h, w) != (IMG_SIZE, IMG_SIZE):
                side = min(h, w)
                y0 = (h - side) // 2
                x0 = (w - side) // 2
                frame_bgr = frame_bgr[y0:y0 + side, x0:x0 + side]
                frame_bgr = cv2.resize(frame_bgr, (IMG_SIZE, IMG_SIZE))

            if self.motion_gate > 0:
                thumb = cv2.cvtColor(
                    cv2.resize(frame_bgr, (112, 112)),
                    cv2.COLOR_BGR2GRAY)
                if prev_thumb is not None:
                    diff_mean = float(np.mean(
                        cv2.absdiff(thumb, prev_thumb)))
                    if diff_mean < self.motion_gate:
                        prev_thumb = thumb
                        skipped_static += 1
                        time.sleep(self.period)
                        continue
                prev_thumb = thumb

            frame_rgb = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGB)
            with self.state.lock():
                self.state.last_frame_rgb = frame_rgb
                self.state.last_frame_rgb_t = time.monotonic()
            img = frame_rgb.transpose(2, 0, 1).astype(np.float32) / 255.0

            t_inf_start = time.monotonic()
            try:
                humans = backend.infer(img, K_np, det_thresh=self.det_thresh)
            except Exception as e:  # noqa: BLE001
                LOG.warning("coreml inference failed: %s", e)
                time.sleep(self.period)
                continue

            # Async remote backend may return None when no fresh result
            # is ready yet — reuse the previous frame's humans so the
            # visualiser keeps drawing instead of clearing.
            if humans is None:
                humans = getattr(self, "_last_humans", []) or []
                reused_humans = True
            else:
                self._last_humans = humans
                reused_humans = False
                fresh_count += 1

            t_post_start = time.monotonic()
            t_now = time.monotonic()
            frame_count += 1
            persons_count += len(humans) if humans else 0
            if reused_humans:
                LOG.debug("hb[remote]: reusing %d cached humans "
                          "(no fresh result)", len(humans))
            if t_now >= next_heartbeat:
                fps = frame_count / 5.0
                fresh_fps = fresh_count / 5.0
                avg = persons_count / max(1, frame_count)
                LOG.info(
                    "hb[coreml]: %.1f fps (fresh=%.1f), %.2f persons/frame, "
                    "%d skipped", fps, fresh_fps, avg, skipped_static)
                frame_count = 0
                persons_count = 0
                fresh_count = 0
                skipped_static = 0
                next_heartbeat = t_now + 5.0

            if not humans:
                with self.state.lock():
                    self.state.persons_smplx = []
                time.sleep(self.period)
                continue

            # If async backend reused last humans, keep state untouched and
            # spin to the next frame without re-running dedup/tracker/
            # smoothing (saves ~3-5 ms CPU per loop iteration and avoids
            # walking the One-Euro filter forward on stale data).
            if reused_humans:
                dt = time.monotonic() - t_cap_start
                if dt < self.period:
                    time.sleep(self.period - dt)
                continue

            # Dedup intra-frame (same logic as pytorch path).
            cand: list[tuple[
                float, float, float, float, float,
                np.ndarray, int]] = []
            for i, hh in enumerate(humans):
                v = hh["v3d"].detach().cpu().numpy()
                xmin = float(v[:, 0].min()); ymin = float(v[:, 1].min())
                xmax = float(v[:, 0].max()); ymax = float(v[:, 1].max())
                score = float(hh["scores"].item())
                pelv = hh["transl_pelvis"].detach().cpu().numpy(
                    ).flatten()[:3]
                cand.append((score, xmin, ymin, xmax, ymax, pelv, i))
            cand.sort(key=lambda c: -c[0])
            keep_idx: list[int] = []
            kept: list[tuple[float, float, float, float, np.ndarray]] = []
            for sc, x0, y0, x1, y1, pelv, src_i in cand:
                a_area = max(0.0, x1 - x0) * max(0.0, y1 - y0)
                drop = False
                for (kx0, ky0, kx1, ky1, kpelv) in kept:
                    ix0 = max(x0, kx0); iy0 = max(y0, ky0)
                    ix1 = min(x1, kx1); iy1 = min(y1, ky1)
                    iw = max(0.0, ix1 - ix0); ih = max(0.0, iy1 - iy0)
                    inter = iw * ih
                    if a_area <= 0 or inter <= 0:
                        continue
                    k_area = (kx1 - kx0) * (ky1 - ky0)
                    iou = inter / (a_area + k_area - inter + 1e-9)
                    pelv_d = float(np.linalg.norm(pelv - kpelv))
                    if iou > 0.55 and pelv_d < 0.20:
                        drop = True
                        break
                if not drop:
                    keep_idx.append(src_i)
                    kept.append((x0, y0, x1, y1, pelv))
                    if len(keep_idx) >= self.num_persons:
                        break
            humans = [humans[i] for i in keep_idx]
            n_keep = len(humans)

            bboxes = []
            for hh in humans:
                v = hh["v3d"].detach().cpu().numpy()
                xmin, ymin = float(v[:, 0].min()), float(v[:, 1].min())
                xmax, ymax = float(v[:, 0].max()), float(v[:, 1].max())
                bboxes.append([PoseKp(x=xmin, y=ymin, c=1.0),
                               PoseKp(x=xmax, y=ymax, c=1.0)])
            ids = self._tracker.update(bboxes)

            persons: list[SMPLXPerson] = []
            for i, hh in enumerate(humans[:n_keep]):
                pid = ids[i] if i < len(ids) else i
                if pid < 0:
                    continue
                v3d = hh["v3d"].detach().cpu().numpy()
                transl_np = hh["transl_pelvis"].detach().cpu().numpy().flatten()
                if transl_np.size >= 3:
                    transl_np = transl_np.copy()
                    transl_np[2] = arkit_pelvis_z_override(
                        self.state, pid, float(transl_np[2]))
                shape_raw = hh["shape"].detach().cpu().numpy().flatten()
                expr_raw = hh["expression"].detach().cpu().numpy().flatten()

                pid_c = pid % self.num_persons
                shape_n = min(10, len(shape_raw))
                expr_n = min(10, len(expr_raw))
                shape_smooth = np.zeros(10, dtype=np.float32)
                expr_smooth = np.zeros(10, dtype=np.float32)
                for k in range(shape_n):
                    shape_smooth[k] = self._smooth_shape[pid_c][k](
                        float(shape_raw[k]), t_now)
                for k in range(expr_n):
                    expr_smooth[k] = self._smooth_expr[pid_c][k](
                        float(expr_raw[k]), t_now)

                persons.append(SMPLXPerson(
                    pid=int(pid),
                    vertices_3d=np.ascontiguousarray(v3d, dtype=np.float32),
                    translation=np.ascontiguousarray(
                        transl_np[:3], dtype=np.float32),
                    confidence=float(hh["scores"].item()),
                    betas=np.ascontiguousarray(shape_smooth, dtype=np.float32),
                    expression=np.ascontiguousarray(expr_smooth, dtype=np.float32),
                ))

            with self.state.lock():
                self.state.persons_smplx = persons
                self.state.smplx_last_t = t_now

            t_end = time.monotonic()
            dt_total = (t_end - t_cap_start) * 1e3
            if LOG.isEnabledFor(logging.DEBUG) or dt_total > 100.0:
                LOG.log(
                    logging.DEBUG if dt_total <= 100.0 else logging.WARNING,
                    "frame[coreml]: cap=%.1f pre=%.1f inf=%.1f "
                    "post=%.1fms total=%.1fms",
                    (t_pre_start - t_cap_start) * 1e3,
                    (t_inf_start - t_pre_start) * 1e3,
                    (t_post_start - t_inf_start) * 1e3,
                    (t_end - t_post_start) * 1e3,
                    dt_total,
                )

            dt = time.monotonic() - t_cap_start
            if dt < self.period:
                time.sleep(self.period - dt)

        cap.stop()
        LOG.info("multi_hmr coreml worker stopped")
