#!/usr/bin/env python3
"""ft-eyes: our own eye tracker, live.

Reads the eye-camera frames that ft-eyegrab (the root service frametop-eyegrab, installed by
gaze/tracker/install.sh) keeps in /dev/shm/frametop-eyes-cams, finds each eye's pupil and
glint pair (eyes_pupil.py), turns them into a gaze with the saved calibration and a running
slip estimate (eyes_model.py), and publishes the result in /dev/shm/frametop-eyes-gaze,
where ft-gaze reads it as the source "own". It touches /dev/shm/frametop-eyes-want every
second, and ft-eyegrab copies frames only while someone does.

The gaze service (gaze/ft-gazed) runs it in the dev container, with --watch-stdin (it quits
when its stdin closes), while Eye tracker is Own tracker or the gaze probe uses it. The
probe calibrates and teaches it; the gaze pointer's nudges reach it as clicks, from ft-gazed.
By hand: distrobox enter dev -- python3 gaze/tracker/ft-eyes -v

Calibration: ~/.local/state/frametop/gaze/eyes/calibration.json, made by the gaze probe's
calibration with the tracker toggle on Own tracker (or lab/ft-eyes-score --save CAPTURE).
Each eye's shift since the calibration (eyes_model.Shift, taught by clicks) is kept in
state.json next to it. After a restart, or frames stopping for GAP seconds (the headset
off), the next click starts that eye's shift over, since the headset may sit differently now.

Control socket: abstract datagram "@ft_eyes"; each command gets one reply line.
  status                       JSON: calibration, and per eye its shift, clicks, whether a
                               glint jump is applied ("jump", and "jump_px": how far, pixels:
                               the headset slipped since the last click; the gaze service's
                               slip notice reads it), and "reseat" (the next click starts the
                               shift over: the probe asks for a one-dot check then)
  calib-start                  a new calibration: collect dots from now on
  calib-point T0 T1 YAW PITCH  a dot you looked at from T0 to T1 (CLOCK_MONOTONIC_RAW) in
                               that direction (head-relative degrees): "ok N0 N1 SD0 SD1"
                               (frames and spread in px per eye, right first), then
                               " (EYE eye only: WHY)" if one eye wasn't seen enough or moved
                               (the dot is kept for the other), or "fail WHY"
  calib-fit                    fit the dots, save, and start the shifts and clicks over. Each
                               eye is fitted on the dots it was seen at; one seen at too few
                               (eyes_model.MIN_CLICKS) is left out, and the other maps the
                               gaze alone ("..., left not used (9 dots, needs 12)")
  click T YAW PITCH            a click: you were looking there just before T; teaches the
                               shift: "ok DX0 DY0 DX1 DY1" (the shift each eye measured)
  fit                          JSON: the last second of pupil finds per camera, for the fit
                               coach (gaze/fitcoach.py, Observations.summary); works uncalibrated
  finds start PATH | finds stop
                               append every frame looked at to PATH as JSON lines (the eye
                               recorder's own.jsonl): {"t", "cam", "x", "y", "a", "b", "angle",
                               "glints": [[x, y], ...], "gaze": [yaw, pitch] once calibrated,
                               "guard": true if the size guard refused it}, or {"t", "cam",
                               "lost": true}; with the net also "net": {"gaze",
                               "x", "y", "open", "visible"}. "finds stop" replies "ok ROWS"

The eye net (eyes_net.py) instead of the pupil finder: EYES_MODEL in ~/.config/frametop.conf (or
FT_EYES_MODEL), picked on Input Settings' Gaze page: latest, a version's name (gaze/tracker/models.json,
downloaded into gaze/tracker/models/NAME by models/catalog.py), or a model folder from frame-eyes'
training (anything with a "/"); classic or unset is the pupil finder. Each eye's raw gaze from the net
then stands where the pupil centre would: the calibration, the shifts clicks teach and the dots'
spread are in its degrees, and there are no glints (no slip estimate or jump notice). Each model
has its own state folder, net-NAME/ (calibration.json, state.json, clicks.jsonl), so switching
asks for a calibration once per model and the classic one is kept. Read at start: the gaze
service restarts ft-eyes when EYES_MODEL changes. If the model can't load, ft-eyes says
so and uses the pupil finder. While the fit coach asks ("fit") or finds are logged, the pupil
finder runs too, for them.

The pupil finder's size guard (eyes_pupil.SizeGuard): a find much smaller than that eye's usual
size, judged against how the other eye's size compares with its own (both narrow in light),
counts as not found. EYES_SIZE_GUARD in frametop.conf (or FT_EYES_SIZE_GUARD): both (the
default), long (against its own usual size only) or off. Read at start. Each eye's kept and
refused finds, and its usual size's restarts, are in the status ("size_guard_counts", since the
start), and once a minute that it refused any, a log line says how many ("size guard, the last
60 s: right refused 40 of 3100 finds; left refused 0 of 3200 finds").

Environment, for replays (lab/ft-eyes-e2e): FT_EYES_CAMS, FT_EYES_GAZE, FT_EYES_STATE (the
state folder), FT_EYES_SOCKET (the socket's name), FT_EYES_MODEL, FT_EYES_SIZE_GUARD. With
FT_EYES_CAMS set, the want file isn't touched.

/dev/shm/frametop-eyes-gaze, 128 bytes, little-endian (mirrored in ft-gaze.cpp):
   0 u32 seq      odd while it's being written: read it before and after, retry if it moved
   4 u32 version  1
   8 f64 t        the newest frame's time, CLOCK_MONOTONIC_RAW seconds
  16 f32 yaw, pitch  the gaze, head-relative degrees (yaw +left, pitch +up), eyes averaged
  24 u32 flags    bit 0: right eye in it, 1: left eye in it, 2: right shift from clicks, 3: left
  28 u32 n        samples published
  32 f32 x4       right yaw, pitch, left yaw, pitch (NaN when that eye isn't seen)
  48 f32 x4       each eye's shift since the calibration, pixels: right x, y, left x, y
  64 f32 x4       pupil centre, pixels: right x, y, left x, y
"""
import json
import math
import mmap
import os
import select
import socket
import struct
import sys
import threading
import time
from collections import deque
from pathlib import Path

# One thread for numpy's BLAS and OpenMP, set before numpy loads: it would start one per core
# (8 here) for small arrays that never need them. eyes_pupil keeps OpenCV to one as well.
for _var in ("OPENBLAS_NUM_THREADS", "OMP_NUM_THREADS"):
    os.environ.setdefault(_var, "1")

import numpy as np  # noqa: E402

sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import eyes_model  # noqa: E402
import eyes_pupil  # noqa: E402
import fitcoach  # noqa: E402

def load_net():
    """The eye net EYES_MODEL picks (FT_EYES_MODEL wins), or None (the pupil finder). models/catalog.py
    resolves it: classic (or unset: the catalog's default), latest (the newest version, once downloaded),
    a downloaded version's name, or a folder of one's own (anything with a "/")."""
    try:
        sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "models"))
        import catalog
        r = catalog.resolve("eyes")
    except Exception as e:  # noqa: BLE001 (a broken catalog mustn't stop the tracker)
        print(f"ft-eyes: the model catalog didn't load ({type(e).__name__}: {e}); using the pupil finder",
              file=sys.stderr, flush=True)
        return None
    if not r["folder"]:
        if r["setting"] not in ("classic", "steamvr"):
            print(f"ft-eyes: EYES_MODEL={r['setting']}: {r['why']}; using the pupil finder", file=sys.stderr, flush=True)
        return None
    try:
        import eyes_net
        return eyes_net.Net(r["folder"])
    except Exception as e:  # noqa: BLE001 (any failure: say so and track with the pupil finder)
        print(f"ft-eyes: the eye net in {r['folder']} didn't load ({type(e).__name__}: {e}); using the pupil finder",
              file=sys.stderr, flush=True)
        return None


NET = load_net()


def size_guard_rule():
    """EYES_SIZE_GUARD in ~/.config/frametop.conf (FT_EYES_SIZE_GUARD wins): eyes_pupil.SizeGuard's
    rule, both (the default), long or off. Read at start."""
    v = os.environ.get("FT_EYES_SIZE_GUARD")
    if v is None:
        v = ""
        try:
            for line in (Path.home() / ".config" / "frametop.conf").read_text().splitlines():
                k, _, val = line.split("#", 1)[0].partition("=")
                if k.strip() == "EYES_SIZE_GUARD":
                    v = val.strip().strip("\"'")
        except OSError:
            pass
    v = v.strip().lower()
    return v if v in eyes_pupil.GUARD_RULES else "both"


SIZE_GUARD = size_guard_rule()
GUARD_REPORT = 60.0  # s between the size guard's log lines (only when it refused something)

CAMS = os.environ.get("FT_EYES_CAMS", "/dev/shm/frametop-eyes-cams")  # overrides for replays
OUT = os.environ.get("FT_EYES_GAZE", "/dev/shm/frametop-eyes-gaze")
WANT = None if "FT_EYES_CAMS" in os.environ else Path("/dev/shm/frametop-eyes-want")
OUT_SIZE = 128
STATE_BASE = Path(os.environ.get("FT_EYES_STATE", Path.home() / ".local/state/frametop/gaze/eyes"))
FEATURE = "net:" + NET.name if NET else "pupil"   # what the calibration maps: each model has its own
CALIBRATION = (STATE_BASE / ("net-" + NET.name) if NET else STATE_BASE) / "calibration.json"
W, H = 512, 400
FRESH = 0.03          # an eye's reading counts toward the output for this long (s)
LOST_EVERY = 3        # while an eye is lost, search the whole frame only every 3rd frame
EYES = ("right", "left")
STATE = CALIBRATION.parent / "state.json"
CLICKS = CALIBRATION.parent / "clicks.jsonl"
SOCKET = "\0" + os.environ.get("FT_EYES_SOCKET", "ft_eyes")
HISTORY = 12.0        # seconds of pupil positions kept per eye, for dots and clicks (the pointer's
                      # clicks come from ft-gazed up to 10 s after the look)
GAP = 3.0             # s without frames: the headset was off, and may sit differently now
CLICK_BEFORE = 0.3    # a click's frames: the 300 ms before it (like the probe's fixation)
CALIB_MIN = 15        # frames an eye needs in a calibration dot's window
CALIB_SPREAD = 2.5 if NET else 4.0   # px (the net: degrees): more than this and the eye moved during the dot
CLASSIC_FOR = 3.0     # s: with the net, the pupil finder runs this long after the fit coach asks
# Waiting for frames. They come only as a counter in shared memory, so there's nothing to block
# on: sleep until a camera's next frame is due, then look every POLL. ft-eyegrab passes each one
# on within a ms or two of when its camera starts the one after, so they arrive 11.1 ms apart,
# give or take that.
PERIOD = 1 / 90       # s between a camera's frames
EARLY = 0.002         # start looking this long before a frame is due
POLL = 0.001          # then this often until it comes
STALLED = 0.1         # s without a frame: that camera stopped (headset off), and isn't waited for
IDLE_POLL = 0.02      # how often to look while both are stopped


class Cams:
    """The shared frames. Header and entry layout: ft-eyegrab.c, share_head_t/share_entry_t."""

    def __init__(self):
        fd = os.open(CAMS, os.O_RDONLY)
        try:
            self.ino = os.fstat(fd).st_ino
            self.mm = mmap.mmap(fd, 0, prot=mmap.PROT_READ)
        finally:
            os.close(fd)
        magic, version, w, h, self.slots, self.esize = struct.unpack_from("<6I", self.mm, 0)
        if magic != 0x31434546 or version != 1 or (w, h) != (W, H):
            raise RuntimeError(f"{CAMS}: unexpected header")

    def replaced(self):
        """ft-eyegrab restarted: it makes a new file, and this one is stale."""
        try:
            return os.stat(CAMS).st_ino != self.ino
        except OSError:
            return True

    def count(self, cam):
        return struct.unpack_from("<Q", self.mm, 24 + 8 * cam)[0]

    def tracker(self):
        return struct.unpack_from("<I", self.mm, 40)[0]

    def frame(self, cam, n):
        """Frame n of a camera as (time, array), or None if it was overwritten meanwhile."""
        off = 64 + (cam * self.slots + n % self.slots) * self.esize
        for _ in range(3):
            seq = struct.unpack_from("<Q", self.mm, off)[0]
            if seq & 1:
                continue
            img = np.frombuffer(self.mm, np.uint8, W * H, off + 64).reshape(H, W).copy()
            t, got = struct.unpack_from("<dQ", self.mm, off + 8)
            if struct.unpack_from("<Q", self.mm, off)[0] == seq and got == n:
                return t, img
        return None


class Out:
    def __init__(self):
        fd = os.open(OUT, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600)
        try:
            os.ftruncate(fd, OUT_SIZE)
            self.mm = mmap.mmap(fd, OUT_SIZE)
        finally:
            os.close(fd)
        self.seq = (struct.unpack_from("<I", self.mm, 0)[0] + 1) & ~1  # even: at rest
        self.n = 0

    def write(self, t, gaze, flags, eyes, slips, pupils):
        struct.pack_into("<I", self.mm, 0, self.seq + 1)  # odd while writing
        self.n += 1
        struct.pack_into("<IdffII12f", self.mm, 4, 1, t, gaze[0], gaze[1], flags, self.n,
                         *eyes, *slips, *pupils)
        self.seq = (self.seq + 2) & 0xFFFFFFFE
        struct.pack_into("<I", self.mm, 0, self.seq)


class Eye:
    def __init__(self, eye):
        self.eye = eye
        self.cal = None
        self.slip = None
        self.shift = eyes_model.Shift()
        self.history = deque()  # (t, x, y, glint mid x, y)
        self.last = None        # the previous pupil (window hint)
        self.size = eyes_pupil.PupilSize()   # its recent size: much smaller finds are something else
        self.guard = None       # both eyes' usual sizes (eyes_pupil.SizeGuard, Tracker's), for the pupil finder
        self.refused = 0
        self.gaze = None        # (t, yaw, pitch, x, y)
        self.lost = 0
        self.frames = self.found = 0
        self.work = 0.0
        self.last_t = None      # the previous frame's time
        self.obs = None         # the fit coach's window (fitcoach.Observations), shared by both eyes
        self.fed = None         # (frame time, CLOCK_MONOTONIC_RAW then) of the newest frame fed
        self.finds = None       # the finds log (Tracker.finds), shared by both eyes
        self.coach = [0.0]      # with the net: until when the fit coach wants pupils (monotonic), shared

    def use(self, cal, shift=None):
        self.cal = cal if cal is not None and cal.has("pupil", self.eye) else None
        self.slip = eyes_model.SlipTracker(cal, self.eye, window=eyes_model.JUMP_WINDOW) if self.cal else None
        self.shift = shift or eyes_model.Shift()
        self.gaze = None

    def feed(self, t, img):
        self.frames += 1
        if self.last_t is not None and t - self.last_t > GAP:
            self.shift.reseat()
        self.last_t = t
        if NET:
            return self.feed_net(t, img)
        if self.last is None:
            self.lost += 1
            if self.lost % LOST_EVERY:
                return
        t0 = time.perf_counter()
        p = eyes_pupil.find_pupil(img, self.last, self.size.smallest(t))
        self.size.add(t, p)
        # A find the size guard refuses counts as not found, and the search stays where the last one was.
        refused = p is not None and self.guard is not None and not self.guard.keep(self.eye, t, p["a"])
        if refused:
            self.refused += 1
        else:
            self.last = p
        if self.obs is not None:
            self.obs.add(self.eye, t, p)
            self.fed = (t, time.clock_gettime(time.CLOCK_MONOTONIC_RAW))
        if p is not None and not refused:
            self.found += 1
            self.lost = 0
            pair = eyes_pupil.glint_pair(p)
            mid = eyes_model.pair_mid(pair) if pair else (math.nan, math.nan)
            self.history.append((t, p["x"], p["y"], mid[0], mid[1]))
            while self.history and self.history[0][0] < t - HISTORY:
                self.history.popleft()
            if self.cal:
                if pair:
                    self.slip.add(t, (p["x"], p["y"]), mid)
                self.shift.glint(self.slip.get(), t)
                g = self.cal.gaze(self.eye, p["x"], p["y"], self.shift.value)
                self.gaze = (t, float(g[0]), float(g[1]), p["x"], p["y"])
        if self.finds is not None:
            self.finds.write(self, t, p, refused=refused)
        self.work += time.perf_counter() - t0

    def feed_net(self, t, img):
        """The net's raw gaze is the feature: history, calibration and shifts as for a pupil."""
        t0 = time.perf_counter()
        r = NET(img, self.eye)
        classic = self.finds is not None or time.monotonic() < self.coach[0]
        p = None
        if classic:
            p = eyes_pupil.find_pupil(img, self.last, self.size.smallest(t))
            self.last = p
            self.size.add(t, p)
            if self.obs is not None:
                self.obs.add(self.eye, t, p)
                self.fed = (t, time.clock_gettime(time.CLOCK_MONOTONIC_RAW))
        if r["visible"] >= 0.5:
            self.found += 1
            fx, fy = r["gaze"]
            self.history.append((t, fx, fy, math.nan, math.nan))
            while self.history and self.history[0][0] < t - HISTORY:
                self.history.popleft()
            if self.cal:
                g = self.cal.gaze(self.eye, fx, fy, self.shift.value)
                self.gaze = (t, float(g[0]), float(g[1]), r["x"], r["y"])
        if self.finds is not None:
            self.finds.write(self, t, p, r)
        self.work += time.perf_counter() - t0

    def window(self, t0, t1):
        """Median pupil and glint midpoint over [t0, t1], the frame count, and the spread."""
        rows = np.array([r for r in self.history if t0 <= r[0] <= t1]).reshape(-1, 5)
        if len(rows) == 0:
            return None
        pupil = np.median(rows[:, 1:3], axis=0)
        spread = float(np.median(np.hypot(*(rows[:, 1:3] - pupil).T)))
        mids = rows[~np.isnan(rows[:, 3]), 3:5]
        mid = np.median(mids, axis=0) if len(mids) >= 3 else None
        return dict(pupil=pupil, mid=mid, n=len(rows), spread=spread)


class Finds:
    """The finds log (the "finds" command): a JSON line per frame looked at."""

    def __init__(self, path):
        self.f = open(path, "a", buffering=1 << 16)
        self.rows = 0

    def write(self, eye, t, p, net=None, refused=False):
        if p is None:
            row = {"t": round(t, 6), "cam": eye.eye, "lost": True}
        else:
            row = {"t": round(t, 6), "cam": eye.eye, "x": round(float(p["x"]), 2), "y": round(float(p["y"]), 2),
                   "a": round(float(p["a"]), 2), "b": round(float(p["b"]), 2), "angle": round(float(p["angle"]), 1),
                   "glints": [[round(float(x), 1), round(float(y), 1)] for x, y in p.get("glints") or []]}
            if eye.gaze and eye.gaze[0] == t and not NET:
                row["gaze"] = [round(eye.gaze[1], 3), round(eye.gaze[2], 3)]
            if refused:
                row["guard"] = True
        if net is not None:
            row["net"] = {"gaze": [round(v, 3) for v in net["gaze"]], "x": round(net["x"], 1), "y": round(net["y"], 1),
                          "open": round(net["open"], 3), "visible": round(net["visible"], 3)}
            if eye.gaze and eye.gaze[0] == t:
                row["gaze"] = [round(eye.gaze[1], 3), round(eye.gaze[2], 3)]
        self.f.write(json.dumps(row) + "\n")
        self.rows += 1

    def close(self):
        self.f.close()
        return self.rows


class Tracker:
    def __init__(self):
        self.eyes = [Eye(0), Eye(1)]
        self.cal = None
        self.dots = []  # calibration dots so far, in ft-eyes-score's click form
        self.calibrating = False
        self.fit = fitcoach.Observations()
        coach = [0.0]
        self.guard = eyes_pupil.SizeGuard(SIZE_GUARD)
        for e in self.eyes:
            e.obs, e.coach, e.guard = self.fit, coach, self.guard
        if CALIBRATION.exists():
            self.cal = eyes_model.Calibration.load(CALIBRATION)
            if self.cal.info.get("feature", "pupil") != FEATURE:
                print(f"ft-eyes: {CALIBRATION} is for {self.cal.info.get('feature', 'pupil')}, not {FEATURE}:"
                      " calibrate again", file=sys.stderr, flush=True)
                self.cal = None
        if self.cal:
            if not self.cal.spread:
                self.cal.spread = spread_from_dots(self.cal)
        shifts = {}
        try:
            d = json.loads(STATE.read_text())
            if self.cal and d.get("calibration") == self.cal.info.get("made"):
                shifts = {int(k): eyes_model.Shift.from_json(v) for k, v in d.get("shift", {}).items()}
        except (OSError, ValueError):
            pass
        for e in self.eyes:
            e.use(self.cal, shifts.get(e.eye))
            # Kept from the last run, but the headset may have been off since: the first
            # click starts the history over (the saved shift is used until then).
            e.shift.reseat()

    def save_state(self):
        d = {"calibration": self.cal.info.get("made") if self.cal else None,
             "shift": {e.eye: e.shift.to_json() for e in self.eyes}}
        tmp = STATE.with_suffix(".tmp")
        tmp.write_text(json.dumps(d))
        tmp.replace(STATE)

    def command(self, line):
        w = line.split()
        if not w:
            return "fail empty"
        if w[0] == "status":
            return json.dumps(self.status())
        if w[0] == "finds" and len(w) >= 2:
            rows = None
            for e in self.eyes:
                if e.finds is not None:
                    rows = e.finds.close()
                    break
            for e in self.eyes:
                e.finds = None
            if w[1] == "stop":
                return f"ok {rows or 0}"
            if w[1] == "start" and len(w) == 3:
                log = Finds(w[2])
                for e in self.eyes:
                    e.finds = log
                return "ok"
            return "fail finds start PATH | finds stop"
        if w[0] == "fit":
            self.eyes[0].coach[0] = time.monotonic() + CLASSIC_FOR
            # In frame time, which a replay's frames keep from the recording: the newest frame's,
            # plus how long ago it came (frames stopping, the headset off, empty the window).
            fed = [e.fed for e in self.eyes if e.fed]
            if not fed:
                return json.dumps(self.fit.summary(0.0))
            t, at = max(fed)
            return json.dumps(self.fit.summary(t + time.clock_gettime(time.CLOCK_MONOTONIC_RAW) - at))
        if w[0] == "calib-start":
            self.dots, self.calibrating = [], True
            return "ok"
        if w[0] == "calib-point" and len(w) == 5:
            if not self.calibrating:
                return "fail no calibration started"
            t0, t1, yaw, pitch = map(float, w[1:])
            got = {e.eye: e.window(t0, t1) for e in self.eyes}
            # Each eye that was seen and held still counts: the dot is taken with one eye if
            # the other was lost (looking far toward that eye's ear, say) or jumped about.
            why = {}
            for c, g in got.items():
                if g is None or g["n"] < CALIB_MIN:
                    why[c] = f"the {EYES[c]} eye was seen in only {0 if g is None else g['n']} frames"
                elif g["spread"] > CALIB_SPREAD:
                    why[c] = f"the {EYES[c]} eye moved ({g['spread']:.1f} px)"
            if len(why) == 2:
                moved = [c for c in (0, 1) if "moved" in why[c]]
                if moved:
                    return "fail " + why[moved[0]]
                return "fail neither eye was seen enough ({})".format(
                    ", ".join(f"{EYES[c]} {0 if got[c] is None else got[c]['n']} frames" for c in (0, 1)))
            self.dots.append(dict(truth=(yaw, pitch), eye={c: dict(pupil=g["pupil"], mid=g["mid"])
                                                           for c, g in got.items() if c not in why}))
            n = [0 if got[c] is None else got[c]["n"] for c in (0, 1)]
            spread = [math.nan if got[c] is None else got[c]["spread"] for c in (0, 1)]
            reply = "ok {} {} {:.2f} {:.2f}".format(*n, *spread)
            for c, text in why.items():
                reply += f" ({EYES[1 - c]} eye only: {text})"
            return reply
        if w[0] == "calib-fit":
            if len(self.dots) < eyes_model.MIN_CLICKS:
                return f"fail only {len(self.dots)} dots (need {eyes_model.MIN_CLICKS})"
            seen = {c: sum(1 for k in self.dots if c in k["eye"]) for c in (0, 1)}
            cal = eyes_model.Calibration.fit(self.dots, {"made": time.strftime("%Y-%m-%d %H:%M:%S"),
                                                       "dots": len(self.dots), "from": "probe calibration",
                                                       "feature": FEATURE,
                                                       "eye_dots": {EYES[c]: seen[c] for c in (0, 1)}})
            if not cal.eyes():
                return "fail neither eye was seen at enough dots ({}; need {})".format(
                    ", ".join(f"{EYES[c]} {seen[c]}" for c in (0, 1)), eyes_model.MIN_CLICKS)
            errs = []
            for k in self.dots:
                g = cal.combine({c: cal.gaze(c, *v["pupil"]) for c, v in k["eye"].items() if cal.has("pupil", c)})
                if g is not None:
                    errs.append(float(np.hypot(*(g - k["truth"]))))
            if CALIBRATION.exists():
                CALIBRATION.replace(CALIBRATION.with_name(time.strftime("calibration-%Y%m%d-%H%M%S.json")))
            cal.save(CALIBRATION)
            self.cal, self.calibrating = cal, False
            for e in self.eyes:
                e.use(cal)
            self.save_state()
            with open(CALIBRATION.with_name("calibration-dots.jsonl"), "a") as f:
                for k in self.dots:
                    f.write(json.dumps({"made": cal.info["made"], "truth": k["truth"],
                                        "eye": {c: {"pupil": v["pupil"].tolist(),
                                                    "mid": None if v["mid"] is None else v["mid"].tolist()}
                                                for c, v in k["eye"].items()}}) + "\n")
            return (f"ok {len(self.dots)} dots, fit median {np.median(errs):.2f} deg, eyes "
                    + ", ".join(f"{EYES[c]} {cal.spread[c]:.2f} ({seen[c]} dots)" if c in cal.spread
                                else f"{EYES[c]} not used ({seen[c]} dots, needs {eyes_model.MIN_CLICKS})"
                                for c in (0, 1)))
        if w[0] == "click" and len(w) == 4:
            if not self.cal:
                return "fail not calibrated"
            t, yaw, pitch = map(float, w[1:])
            out, rec = [], {"time": time.time(), "t": t, "truth": [yaw, pitch], "eyes": {}}
            for e in self.eyes:
                g = e.window(t - CLICK_BEFORE, t)
                if g is None or g["n"] < 5 or not e.cal:
                    out += ["nan", "nan"]
                    continue
                d = self.cal.click_shift(e.eye, g["pupil"], (yaw, pitch))
                before = e.shift.value.tolist()
                e.shift.click(d, e.slip.value if e.slip else None)
                rec["eyes"][e.eye] = {"pupil": g["pupil"].tolist(), "measured": d.tolist(), "before": before,
                                      "after": e.shift.value.tolist()}
                out += [f"{d[0]:.2f}", f"{d[1]:.2f}"]
            self.save_state()
            with open(CLICKS, "a") as f:
                f.write(json.dumps(rec) + "\n")
            return "ok " + " ".join(out)
        return f"fail unknown command {w[0]}"

    def status(self):
        return {"calibration": self.cal.info if self.cal else None, "calibrating": self.calibrating,
                "mapped": [EYES[c] for c in self.cal.eyes()] if self.cal else None,
                "feature": FEATURE, "model": NET.folder if NET else None,
                "dots": len(self.dots), "size_guard": None if NET else self.guard.rule,
                "size_guard_counts": None if NET else {EYES[e]: self.guard.counts(e) for e in (0, 1)},
                "eyes": {EYES[e.eye]: {"shift": e.shift.value.tolist(), "clicks": len(e.shift.meas),
                                       "jump": bool(np.any(e.shift.jump)), "jump_px": e.shift.jump.tolist(),
                                       "reseat": e.shift.reseated} for e in self.eyes}}


def spread_from_dots(cal):
    """The eyes' fit spreads for a calibration saved without them, from its dots in
    calibration-dots.jsonl ({} if they aren't there: the eyes are then weighted alike)."""
    try:
        lines = CALIBRATION.with_name("calibration-dots.jsonl").read_text().splitlines()
    except OSError:
        return {}
    dots = []
    for line in lines:
        d = json.loads(line)
        if d.get("made") == cal.info.get("made"):
            dots.append(dict(truth=d["truth"], eye={
                int(c): dict(pupil=np.array(v["pupil"]), mid=None if v["mid"] is None else np.array(v["mid"]))
                for c, v in d["eye"].items()}))
    return eyes_model.Calibration.fit(dots).spread if dots else {}


class Want:
    """Touches the want file every second, so ft-eyegrab keeps copying frames."""

    def __init__(self):
        self.at = 0.0

    def __call__(self):
        if WANT is None or time.monotonic() - self.at < 1.0:
            return
        self.at = time.monotonic()
        try:
            fd = os.open(WANT, os.O_WRONLY | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
            os.utime(fd)
            os.close(fd)
        except OSError as e:
            print(f"ft-eyes: {WANT}: {e}", file=sys.stderr, flush=True)


def wait_for_cams(sock, tracker, want):
    while True:
        want()
        try:
            return Cams()
        except (OSError, ValueError, RuntimeError):
            serve(sock, tracker)
            select.select([sock], [], [], 0.2)


def serve(sock, tracker):
    while True:
        try:
            data, addr = sock.recvfrom(512)
        except BlockingIOError:
            return
        try:
            reply = tracker.command(data.decode(errors="replace").strip())
        except Exception as ex:  # a bad command must not take the tracker down
            reply = f"fail {type(ex).__name__}: {ex}"
        if addr:
            try:
                sock.sendto(reply.encode(), addr)
            except OSError:
                pass


def below_steamvr():
    """Nice 10 and SCHED_BATCH, for this thread and those it starts. ft-eyes runs in the dev
    container's podman scope, where the gaze service's unit doesn't reach it, so it ran at
    nice 0 on the cores vrcompositor and vrserver use. Batch also lets a waking ft-eyes wait
    for the running task's turn instead of taking the core: a frame a few ms late costs the
    gaze little, a late compositor frame costs a dropped frame in the headset."""
    try:
        os.setpriority(os.PRIO_PROCESS, 0, max(os.getpriority(os.PRIO_PROCESS, 0), 10))
    except OSError as e:
        print(f"ft-eyes: nice: {e}", file=sys.stderr, flush=True)
    try:
        os.sched_setscheduler(0, os.SCHED_BATCH, os.sched_param(0))
    except (OSError, AttributeError) as e:
        print(f"ft-eyes: SCHED_BATCH: {e}", file=sys.stderr, flush=True)


def guard_line(guard, was):
    """The size guard since `was` (its counts then): (its counts now, a log line, or "" if it
    refused nothing). Its own line, once a minute: ft-gazed keeps it (it drops the fps lines)."""
    now = {e: guard.counts(e) for e in (0, 1)}
    d = {e: {k: now[e][k] - was[e][k] for k in now[e]} for e in (0, 1)}
    if not any(d[e]["refused"] for e in (0, 1)):
        return now, ""
    parts = [f"{EYES[e]} refused {d[e]['refused']} of {d[e]['kept'] + d[e]['refused']} finds"
             + (f", {d[e]['restarts']} restarts" if d[e]["restarts"] else "") for e in (0, 1)]
    return now, f"size guard, the last {GUARD_REPORT:.0f} s: " + "; ".join(parts)


def main():
    below_steamvr()
    verbose = "-v" in sys.argv
    if "--watch-stdin" in sys.argv:
        # Run by ft-gazed through distrobox, which doesn't pass a stop on: quit when our
        # stdin (its pipe) closes.
        def watch():
            while sys.stdin.buffer.read(4096):
                pass
            os._exit(0)
        threading.Thread(target=watch, daemon=True).start()
    want = Want()
    CALIBRATION.parent.mkdir(parents=True, exist_ok=True)
    tracker = Tracker()
    eyes = tracker.eyes
    out = Out()
    sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
    sock.bind(SOCKET)
    sock.setblocking(False)
    cal = tracker.cal
    print("ft-eyes: " + (f"the eye net {NET.name} ({NET.folder}); " if NET else "")
          + (f"calibration from {cal.info.get('made')} ({cal.info.get('from', cal.info.get('capture'))})"
             + ("" if len(cal.eyes()) == 2 else f", the {' and '.join(EYES[c] for c in cal.eyes())} eye only")
             if cal else "not calibrated: run the probe's calibration with the Own tracker")
          + f"; waiting for {CAMS}", file=sys.stderr, flush=True)
    cams = wait_for_cams(sock, tracker, want)
    print("ft-eyes: frames found, tracking", file=sys.stderr, flush=True)
    seen = [cams.count(0), cams.count(1)]
    report = guard_at = time.monotonic()
    guard_was = {e: tracker.guard.counts(e) for e in (0, 1)}
    arrived = [0.0, 0.0]  # when each camera's newest frame was seen (monotonic)
    while True:
        serve(sock, tracker)
        want()
        new = False
        for c in (0, 1):
            n = cams.count(c)
            if n == seen[c]:
                continue
            seen[c] = n
            arrived[c] = time.monotonic()
            got = cams.frame(c, n - 1)  # only the newest: never fall behind
            if got:
                eyes[c].feed(*got)
                new = True
        if new:
            latest = max((e.gaze[0] for e in eyes if e.gaze), default=None)
            use = [e for e in eyes if e.gaze and latest - e.gaze[0] < FRESH]
            if use:
                yaw, pitch = tracker.cal.combine({e.eye: e.gaze[1:3] for e in use})
                flags, per, shifts, pupils = 0, [], [], []
                for i, e in enumerate(eyes):
                    fresh = e in use
                    flags |= (1 << i) if fresh else 0
                    flags |= (4 << i) if e.shift.meas else 0
                    per += [e.gaze[1], e.gaze[2]] if fresh else [math.nan, math.nan]
                    shifts += [float(v) for v in e.shift.value]
                    pupils += [e.gaze[3], e.gaze[4]] if fresh else [math.nan, math.nan]
                out.write(latest, (yaw, pitch), flags, per, shifts, pupils)
        now = time.monotonic()
        if now - report >= 5:
            if verbose:
                parts = []
                for e in eyes:
                    s = e.shift.value
                    parts.append(f"{EYES[e.eye]} {e.frames / 5:.0f} fps, found {e.found / max(e.frames, 1):.0%}"
                                 + (f" ({e.refused} refused by size)" if e.refused else "") + ", "
                                 f"{e.work / max(e.frames, 1) * 1000:.2f} ms/frame, shift ({s[0]:+.1f},{s[1]:+.1f})"
                                 f" from {len(e.shift.meas)} clicks" + (", jump" if np.any(e.shift.jump) else ""))
                    e.frames = e.found = e.refused = 0
                    e.work = 0.0
                print("ft-eyes: " + "; ".join(parts), file=sys.stderr, flush=True)
            report = now
            if now - guard_at >= GUARD_REPORT:
                guard_at, guard_was, line = now, *guard_line(tracker.guard, guard_was)
                if line and not NET:
                    print("ft-eyes: " + line, file=sys.stderr, flush=True)
            if cams.replaced():
                print("ft-eyes: frames went away; waiting", file=sys.stderr, flush=True)
                cams = wait_for_cams(sock, tracker, want)
                seen = [cams.count(0), cams.count(1)]
        # Until the next frame is due (a command on the socket wakes us sooner).
        wait = IDLE_POLL
        for c in (0, 1):
            if now - arrived[c] < STALLED:
                wait = min(wait, max(arrived[c] + PERIOD - EARLY - now, POLL))
        select.select([sock], [], [], wait)


if __name__ == "__main__":
    try:
        main()
    except KeyboardInterrupt:
        pass
