diff --git a/gaze/ft-gazed b/gaze/ft-gazed index 1914440..93aa877 100755 --- a/gaze/ft-gazed +++ b/gaze/ft-gazed @@ -10,9 +10,10 @@ again when the file changes: grabber, and ft-eyes' Python in this checkout), else SteamVR's; it switches when ours is installed or removed. GAZE_EYE=auto|left|right the eye bias (gazecal.EyeWeights): auto weights each eye by how far - off it was at your recent nudges; left or right counts that eye twice - as much as the other. Either eye alone carries the gaze when the - other isn't seen. + off it was at your recent nudges (since our tracker's last + calibration: a new one starts them over); left or right counts that + eye twice as much as the other. Either eye alone carries the gaze + when the other isn't seen. GAZE_FUSION=off|on with our tracker: SteamVR's gaze too (gazecal.Fusion, fusion.json). SteamVR's two eyes' mean, corrected by an affine fitted at our calibration's dots, is mixed half and half with ours where both our @@ -272,6 +273,7 @@ class Service: self.cal_mtime = None self.lives = {name: PointerLessons() for name in SOURCES} self.weights = {t: EyeWeights(self.bias) for t in TRACKERS} + self.own_made = None # when our tracker's calibration was made, as its misses knew it self.dirty = False self.fusion_on = read_fusion() self.fusion, self.fusion_mtime = None, None @@ -437,12 +439,14 @@ class Service: for t, misses in (d.get("misses") or {}).items(): if t in self.weights: self.weights[t] = EyeWeights(self.bias, misses) + self.own_made = d.get("own_made") log(", ".join(f"{n} {len(self.lives[n].samples)}" for n in (self.source,) + SIDES) + " lessons") def save_lessons(self): tmp = LESSONS.with_suffix(".tmp") tmp.write_text(json.dumps({"version": 2, "sources": {n: lv.samples for n, lv in self.lives.items() if lv.samples}, - "misses": {t: w.misses for t, w in self.weights.items()}})) + "misses": {t: w.misses for t, w in self.weights.items()}, + "own_made": self.own_made})) tmp.replace(LESSONS) self.dirty = False @@ -996,9 +1000,29 @@ class Service: self.own, self.own_at = json.loads(data), time.monotonic() except ValueError: pass + else: + self.note_own_calibration() else: log(f"our tracker: {data}") + def note_own_calibration(self): + """Our tracker's status names a calibration other than the one its eye misses were + measured on: they start over, and the eyes count alike until each has EyeWeights.MIN + again. The misses say how far each eye's old mapping was off, and a new calibration + replaces both mappings (it can drop an eye, or be someone else's). Live 2026-10-18 a + calibration's own dots fed the right eye misses of the right-only one before it, and the + balance after it was that one's. Whatever made the calibration: the panel, the probe.""" + made = (self.own.get("calibration") or {}).get("made") + if not made or made == self.own_made: + return + w = self.weights["own"] + if self.own_made is not None and any(w.misses): + log(f"our tracker's calibration from {made}: the eye weights start over " + f"(were {', '.join(f'{v:.2f}' for v in w.weights())} from {', '.join(str(len(m)) for m in w.misses)} misses)") + self.weights["own"] = EyeWeights(self.bias) + self.own_made = made + self.dirty = True + def status(self): kind = self.kind tracker = "own" if kind == "own" else "steam" diff --git a/gaze/gazecal.py b/gaze/gazecal.py index 3721f42..29557e7 100644 --- a/gaze/gazecal.py +++ b/gaze/gazecal.py @@ -675,7 +675,10 @@ class EyeWeights: alike until then. Each miss is measured before its lesson teaches anything, so each is a fresh test. On SteamVR's own test (2026-09-29) its calibration dots said the left eye was the better one and new spots said the right, so the misses come from lessons, not the fit. - An eye that isn't seen (None) drops out, and the other carries the gaze alone.""" + An eye that isn't seen (None) drops out, and the other carries the gaze alone. Our + tracker's start over with each new calibration of it (ft-gazed's note_own_calibration). + Not from the new fit's per-eye spread: on 2026-10-18's replays (frame-eyes + one_eye_replay.py) spread and equal weights came out the same, 2.29 degrees median.""" LEAN = 2.0 KEEP = 20 diff --git a/gaze/test/one-eye-calibration-test.py b/gaze/test/one-eye-calibration-test.py index 776db2f..53beba3 100644 --- a/gaze/test/one-eye-calibration-test.py +++ b/gaze/test/one-eye-calibration-test.py @@ -206,6 +206,49 @@ check("ft-gazed, our tracker: both eyes, then the left lost: no jump", (svc.sent[-2], abs(svc.sent[-1][0] - 5.0) < 0.05), ((5.0, 2.0), True)) check("...counted as one eye", svc.counts["one_eye"], 1) + +# --- a new calibration starts the eye weights over (ft-gazed's note_own_calibration) --- +logs = [] +gazed.log = lambda msg: logs.append(msg) +svc = service() +svc.bias, svc.own_made, svc.dirty = "auto", None, False +svc.own = {"calibration": {"made": "2026-10-18 20:58:26", "dots": 27}} +svc.note_own_calibration() +check("the first calibration it hears of: adopted, nothing dropped", (svc.own_made, logs), ("2026-10-18 20:58:26", [])) +for _ in range(6): + svc.weights["own"].add([2.0, 0.5]) # the left eye's mapping was the worse one +before = svc.weights["own"].weights() +check("misses on that calibration lean toward the right eye", before[1] > 0.9, True) +svc.note_own_calibration() +check("the same calibration again: kept", svc.weights["own"].weights(), before) +svc.own = {"calibration": {"made": "2026-10-18 21:11:33", "dots": 26}} +svc.note_own_calibration() +check("a new calibration: the misses start over", svc.weights["own"].misses, [[], []]) +check("...the eyes count alike until each has MIN again", tuple(svc.weights["own"].weights()), (0.5, 0.5)) +check("...said in the log", sum("eye weights start over" in m for m in logs), 1) +check("...and saved with the stamp", (svc.own_made, svc.dirty), ("2026-10-18 21:11:33", True)) +svc.own = {} +svc.note_own_calibration() +check("a status without a calibration changes nothing", svc.own_made, "2026-10-18 21:11:33") + +# The stamp lives with the misses, so a restart keeps them and a calibration made meanwhile doesn't. +svc.lives = {name: gazed.PointerLessons() for name in gazed.SOURCES} +svc.weights["own"].add([1.0, 1.0]) +gazed.STATE.mkdir(parents=True, exist_ok=True) +svc.save_lessons() +again = service() +again.bias, again.lives = "auto", {name: gazed.PointerLessons() for name in gazed.SOURCES} +again.source = "mmap1" +again.load_lessons() +check("after a restart: the stamp and the misses", (again.own_made, again.weights["own"].misses), + ("2026-10-18 21:11:33", [[1.0], [1.0]])) +again.own = {"calibration": {"made": "2026-10-18 21:11:33"}} +again.note_own_calibration() +check("...the same calibration: kept", again.weights["own"].misses, [[1.0], [1.0]]) +again.own = {"calibration": {"made": "2026-10-18 21:30:00"}} +again.note_own_calibration() +check("...one made while it was down: started over", again.weights["own"].misses, [[], []]) + shutil.rmtree(HOME, ignore_errors=True) print("FAILED: " + ", ".join(failures) if failures else "all passed", flush=True) sys.exit(1 if failures else 0)