* Vision pass (Phase C): track-before-detect. Every candidate blob is * converted to WORLD az/el at frame time and fed to a small track table; * the plane is whichever track MOVES like the ADS-B prediction says the * plane moves (clouds are world-static, noise is incoherent). The locked
()
| 905 | * Everything is referenced to FRAME TIME via per-frame arrival timestamps. |
| 906 | */ |
| 907 | private async visionTick(): Promise<void> { |
| 908 | const cfg = this.cfg(); |
| 909 | const tracking = |
| 910 | (this.mode === "auto" || this.mode === "manual") && this.current !== null; |
| 911 | if (!cfg.vision.enabled || !tracking) { |
| 912 | this.lastDetection = null; |
| 913 | this.tracks.reset(); |
| 914 | this.prevLuma = null; |
| 915 | this.prevAim = null; |
| 916 | this.corrAz = 0; |
| 917 | this.corrEl = 0; |
| 918 | return; |
| 919 | } |
| 920 | if (this.visionBusy) return; |
| 921 | const frame = this.video.latestFrame(); |
| 922 | // Captured WITH the frame — by the time the detector finishes, a newer |
| 923 | // frame may have arrived and overwritten the stream's timestamp. |
| 924 | const frameArrivedAt = this.video.latestFrameAt(); |
| 925 | const pose = this.driver().getPose(); |
| 926 | if (!frame || !pose || !this.video.status().running) return; |
| 927 | |
| 928 | const hfov = hfovFromZoomUnits(pose.zoomUnits, cfg.zoom.fovLut); |
| 929 | const vfov = hfov * (9 / 16); |
| 930 | const aim = worldFromMount(pose, cfg.mount); |
| 931 | const predicted = this.lastState?.target.predicted ?? null; |
| 932 | |
| 933 | // Frame-time reference: the camera and the plane both moved while this |
| 934 | // frame crossed the RTSP/MJPEG pipeline. Arrival is timestamped exactly; |
| 935 | // encodeLagMs covers the residual (exposure -> encode -> RTSP -> decode). |
| 936 | const preT = Date.now(); |
| 937 | const frameT = (frameArrivedAt > 0 ? frameArrivedAt : preT) - cfg.vision.encodeLagMs; |
| 938 | const sign = Math.sign(cfg.mount.panGain) || 1; |
| 939 | const clamp01 = (v: number) => Math.min(1, Math.max(0, v)); |
| 940 | |
| 941 | // Where should the plane be in THIS frame? The locked track's WORLD |
| 942 | // position advanced to frame time (world-frame stickiness — it moves |
| 943 | // with both the camera and the plane by construction), else ADS-B. |
| 944 | let exX = 0.5; |
| 945 | let exY = 0.5; |
| 946 | const lockedBefore = this.tracks.lockedTrack(); |
| 947 | const atPre = this.aimAt(frameT); |
| 948 | if (lockedBefore && atPre) { |
| 949 | const p = lockedBefore.positionAt(frameT); |
| 950 | const cosE = Math.max(0.2, Math.cos((atPre.aimEl * Math.PI) / 180)); |
| 951 | exX = clamp01(0.5 + (norm180(p.azDeg - atPre.aimAz) * cosE * sign) / atPre.hfov); |
| 952 | exY = clamp01(0.5 - (p.elDeg - atPre.aimEl) / (atPre.hfov * (9 / 16))); |
| 953 | } else if (predicted) { |
| 954 | const dAz = |
| 955 | norm180(predicted.azDeg - aim.azDeg) * |
| 956 | Math.cos((predicted.elDeg * Math.PI) / 180); |
| 957 | exX = clamp01(0.5 + dAz / hfov); |
| 958 | exY = clamp01(0.5 - (predicted.elDeg - aim.elDeg) / vfov); |
| 959 | } |
| 960 | |
| 961 | this.visionBusy = true; |
| 962 | try { |
| 963 | // --- camera-motion-compensated detection --- |
| 964 | // Decode this frame and diff it against the previous one AFTER cancelling |
no test coverage detected