Skip to content

Commit caa5561

Browse files
veilletteclaude
andcommitted
Fix magnifier crosshair alignment and clean up codebase
- Fix magnifier crosshair to sit directly on cursor by calculating position relative to magnifier canvas bounds - Handle video aspect ratio differences (letterboxing/pillarboxing) when computing magnified region coordinates - Extract magic numbers into named constants (MAG_BORDER_WIDTH, MAG_CROSSHAIR_RADIUS, etc.) - Use shared VIDEO_WIDTH/VIDEO_HEIGHT from SimModel - Remove unused videoUrlProperty from SimModel - Switch video time sync from polling to event-driven (timeupdate) - Remove redundant step() methods from VideoPlayerNode and SimScreenView Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 5f37404 commit caa5561

4 files changed

Lines changed: 144 additions & 79 deletions

File tree

src/screen-name/model/SimModel.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,13 @@ import {
77
} from "scenerystack/axon";
88
import { Matrix3, Range, Transform3, Vector2 } from "scenerystack/dot";
99
import { TRACK_COLORS } from "../../TrackLabColors.js";
10+
import { OpenCVTracker } from "../../tracking/OpenCVTracker.js";
1011
import type { Track, TrackPoint } from "./Track.js";
1112

13+
// Video display dimensions (used by tracker and views)
14+
export const VIDEO_WIDTH = 640;
15+
export const VIDEO_HEIGHT = 360;
16+
1217
// ── Calibration unit type ──────────────────────────────────────────────────
1318
export const CALIBRATION_UNITS = ["mm", "cm", "m", "km", "in", "ft"] as const;
1419
export type CalibrationUnit = (typeof CALIBRATION_UNITS)[number];
@@ -21,8 +26,6 @@ const LAYOUT_CENTER_X = 512; // 1024 / 2
2126
const LAYOUT_CENTER_Y = 309; // 618 / 2
2227
const VIDEO_CENTER_X = LAYOUT_CENTER_X; // 512
2328
const VIDEO_CENTER_Y = LAYOUT_CENTER_Y - 20; // 289
24-
const VIDEO_WIDTH = 640;
25-
const VIDEO_HEIGHT = 360;
2629
const CALIB_HALF_LEN = 100; // pixels from center to each calibration endpoint
2730

2831
// Initial tool positions (view / pixel space)
@@ -74,7 +77,9 @@ export class SimModel {
7477
public readonly isPlayingProperty = new BooleanProperty(false);
7578
public readonly currentTimeProperty = new Property<number>(0);
7679
public readonly durationProperty = new Property<number>(0);
77-
public readonly videoUrlProperty = new Property<string | null>(null);
80+
81+
// ── OpenCV Tracker (computational service) ────────────────────────────
82+
public readonly tracker = new OpenCVTracker(VIDEO_WIDTH, VIDEO_HEIGHT);
7883

7984
// ── Overlay visibility ────────────────────────────────────────────────
8085
public readonly axesVisibleProperty = new BooleanProperty(true);
@@ -176,7 +181,6 @@ export class SimModel {
176181
this.isPlayingProperty.reset();
177182
this.currentTimeProperty.reset();
178183
this.durationProperty.reset();
179-
this.videoUrlProperty.reset();
180184
this.axesVisibleProperty.reset();
181185
this.calibrationVisibleProperty.reset();
182186
this.magnifyVideoProperty.reset();
@@ -191,6 +195,7 @@ export class SimModel {
191195
this.activeTrackIdProperty.value = null;
192196
this.canAddTrackProperty.value = true;
193197
this.nextSymbolCode = 65;
198+
this.tracker.dispose();
194199
}
195200

196201
// eslint-disable-next-line @typescript-eslint/no-unused-vars

src/screen-name/view/AutoTrackerNode.ts

Lines changed: 10 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,8 @@ import {
1212
import { PhetFont } from "scenerystack/scenery-phet";
1313
import { Tandem } from "scenerystack/tandem";
1414
import TrackLabColors from "../../TrackLabColors.js";
15-
import { OpenCVTracker } from "../../tracking/OpenCVTracker.js";
16-
import type { SimModel } from "../model/SimModel.js";
15+
import { type SimModel, VIDEO_HEIGHT, VIDEO_WIDTH } from "../model/SimModel.js";
1716

18-
const VIDEO_W = 640;
19-
const VIDEO_H = 360;
2017
const MAX_TRAIL = 150;
2118
const CROSSHAIR_SIZE = 16;
2219
const FRAME_DURATION = 1 / 30; // assumes 30 fps
@@ -39,7 +36,7 @@ const FRAME_DURATION = 1 / 30; // assumes 30 fps
3936
* DOM node at position (0,0).
4037
*/
4138
export class AutoTrackerNode extends Node {
42-
private readonly tracker: OpenCVTracker;
39+
private readonly model: SimModel;
4340
private readonly trail: Array<{ x: number; y: number }> = [];
4441

4542
private readonly hintText: Text;
@@ -59,10 +56,10 @@ export class AutoTrackerNode extends Node {
5956
) {
6057
super({ visible: false });
6158

62-
this.tracker = new OpenCVTracker(VIDEO_W, VIDEO_H);
59+
this.model = model;
6360

6461
// ── Transparent hit area (receives drag events) ───────────────────────
65-
const hitArea = new Rectangle(0, 0, VIDEO_W, VIDEO_H, {
62+
const hitArea = new Rectangle(0, 0, VIDEO_WIDTH, VIDEO_HEIGHT, {
6663
fill: "transparent",
6764
cursor: "crosshair",
6865
tagName: "div",
@@ -75,7 +72,7 @@ export class AutoTrackerNode extends Node {
7572
font: new PhetFont({ size: 15, weight: "bold" }),
7673
fill: TrackLabColors.trackerHintFillProperty,
7774
});
78-
this.hintText.center = new Vector2(VIDEO_W / 2, VIDEO_H / 2);
75+
this.hintText.center = new Vector2(VIDEO_WIDTH / 2, VIDEO_HEIGHT / 2);
7976
this.addChild(this.hintText);
8077

8178
// ── Selection rectangle ───────────────────────────────────────────────
@@ -120,7 +117,7 @@ export class AutoTrackerNode extends Node {
120117
const dragListener = new DragListener({
121118
start: (event) => {
122119
this.trail.length = 0;
123-
this.tracker.dispose();
120+
this.model.tracker.dispose();
124121
this.setCrosshairVisible(false);
125122
this.trailPath.shape = null;
126123
this.trailPath.visible = false;
@@ -161,7 +158,7 @@ export class AutoTrackerNode extends Node {
161158
if (region.w > 4 && region.h > 4) {
162159
// initFromVideo is async (loads WASM on first call); tracking begins
163160
// automatically once `ready` becomes true.
164-
this.tracker.initFromVideo(videoElement, region).catch((err) => {
161+
this.model.tracker.initFromVideo(videoElement, region).catch((err) => {
165162
console.error("[AutoTracker] Tracking initialisation failed:", err);
166163
this.hintText.visible = true;
167164
});
@@ -175,8 +172,8 @@ export class AutoTrackerNode extends Node {
175172

176173
// ── Track on every video frame ────────────────────────────────────────
177174
const onFrame = () => {
178-
if (!this.visible || !this.tracker.ready) return;
179-
const pt = this.tracker.track(videoElement);
175+
if (!this.visible || !this.model.tracker.ready) return;
176+
const pt = this.model.tracker.track(videoElement);
180177
if (!pt) return;
181178

182179
this.trail.push(pt);
@@ -241,7 +238,7 @@ export class AutoTrackerNode extends Node {
241238

242239
/** Clear tracking state (template, trail, visuals). */
243240
public reset(): void {
244-
this.tracker.dispose();
241+
this.model.tracker.dispose();
245242
this.trail.length = 0;
246243
this.selecting = false;
247244
this.selectionRect.visible = false;

src/screen-name/view/SimScreenView.ts

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,4 @@ export class SimScreenView extends ScreenView {
8484
});
8585
this.addChild(resetAllButton);
8686
}
87-
88-
public override step(dt: number): void {
89-
super.step(dt);
90-
this.videoPlayerNode.step();
91-
}
9287
}

src/screen-name/view/VideoPlayerNode.ts

Lines changed: 125 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,11 @@ import {
2626
import { ComboBox, type ComboBoxItem, Slider } from "scenerystack/sun";
2727
import { Tandem } from "scenerystack/tandem";
2828
import TrackLabColors from "../../TrackLabColors.js";
29-
import type { SimModel } from "../model/SimModel.js";
29+
import {
30+
VIDEO_HEIGHT,
31+
VIDEO_WIDTH,
32+
type SimModel,
33+
} from "../model/SimModel.js";
3034
import { AutoTrackerNode } from "./AutoTrackerNode.js";
3135
import { WebcamPanel } from "./WebcamPanel.js";
3236

@@ -84,8 +88,8 @@ export class VideoPlayerNode extends Node {
8488

8589
// ── HTML video element ─────────────────────────────────────────────────
8690
this.videoElement = document.createElement("video");
87-
this.videoElement.width = 640;
88-
this.videoElement.height = 360;
91+
this.videoElement.width = VIDEO_WIDTH;
92+
this.videoElement.height = VIDEO_HEIGHT;
8993
this.videoElement.preload = "metadata";
9094
this.videoElement.crossOrigin = "anonymous";
9195
this.videoElement.style.display = "block";
@@ -112,6 +116,13 @@ export class VideoPlayerNode extends Node {
112116
model.isPlayingProperty.value = false;
113117
});
114118

119+
// Sync model time from video during playback (event-driven, not polled)
120+
this.videoElement.addEventListener("timeupdate", () => {
121+
if (!this.isScrubbing) {
122+
model.currentTimeProperty.value = this.videoElement.currentTime;
123+
}
124+
});
125+
115126
// ── Auto-tracking overlay ──────────────────────────────────────────────
116127
// Layered directly on top of the video element at (0,0), so its local
117128
// coordinates correspond 1:1 to video-pixel coordinates.
@@ -165,13 +176,20 @@ export class VideoPlayerNode extends Node {
165176
// ── Magnifier (zoomed view near the cursor) ─────────────────────────────
166177
const MAG_SIZE = 100; // Canvas diameter in pixels
167178
const MAG_ZOOM = 4; // Magnification factor
179+
const MAG_BORDER_WIDTH = 2;
180+
const MAG_CROSSHAIR_RADIUS = 8;
181+
const MAG_CROSSHAIR_GAP = 2;
182+
const MAG_CROSSHAIR_LINE_WIDTH = 1;
183+
const MAG_CROSSHAIR_COLOR = "rgba(255,255,255,0.8)";
168184

169185
const magCanvas = document.createElement("canvas");
170186
magCanvas.width = MAG_SIZE;
171187
magCanvas.height = MAG_SIZE;
188+
// borderRadius + boxShadow give a round shadow; no CSS border so the
189+
// element's layout box stays exactly MAG_SIZE × MAG_SIZE and aligns
190+
// pixel-perfectly with the Scenery cursor node.
172191
Object.assign(magCanvas.style, {
173192
borderRadius: "50%",
174-
border: "2px solid white",
175193
boxShadow: "0 2px 8px rgba(0,0,0,0.5)",
176194
});
177195
const magCtx = magCanvas.getContext("2d");
@@ -182,25 +200,67 @@ export class VideoPlayerNode extends Node {
182200
magnifierNode.visible = false;
183201
magnifierNode.pickable = false;
184202

185-
/** Redraws the magnifier canvas showing a zoomed region of the video. */
186-
const updateMagnifier = (localX: number, localY: number) => {
187-
// drawImage uses the video's intrinsic (natural) resolution as source
188-
// coordinates, but localX/localY are in display space (0–640, 0–360).
189-
// Scale to intrinsic pixels so the source rectangle is centered correctly.
190-
const scaleX =
191-
this.videoElement.videoWidth > 0
192-
? this.videoElement.videoWidth / this.videoElement.width
193-
: 1;
194-
const scaleY =
195-
this.videoElement.videoHeight > 0
196-
? this.videoElement.videoHeight / this.videoElement.height
197-
: 1;
198-
199-
// Source rectangle in intrinsic pixels, centered on the cursor position.
200-
const srcW = (MAG_SIZE / MAG_ZOOM) * scaleX;
201-
const srcH = (MAG_SIZE / MAG_ZOOM) * scaleY;
202-
const sx = localX * scaleX - srcW / 2;
203-
const sy = localY * scaleY - srcH / 2;
203+
/**
204+
* Computes the rendered video bounds within the display element,
205+
* accounting for letterboxing/pillarboxing when aspect ratios differ.
206+
*/
207+
const getRenderedVideoBounds = () => {
208+
const displayW = this.videoElement.width;
209+
const displayH = this.videoElement.height;
210+
const videoW = this.videoElement.videoWidth || displayW;
211+
const videoH = this.videoElement.videoHeight || displayH;
212+
213+
const displayAspect = displayW / displayH;
214+
const videoAspect = videoW / videoH;
215+
216+
let renderedW: number;
217+
let renderedH: number;
218+
let offsetX: number;
219+
let offsetY: number;
220+
221+
if (videoAspect > displayAspect) {
222+
// Video is wider than display: letterboxing (bars on top/bottom)
223+
renderedW = displayW;
224+
renderedH = displayW / videoAspect;
225+
offsetX = 0;
226+
offsetY = (displayH - renderedH) / 2;
227+
} else {
228+
// Video is taller than display: pillarboxing (bars on sides)
229+
renderedH = displayH;
230+
renderedW = displayH * videoAspect;
231+
offsetX = (displayW - renderedW) / 2;
232+
offsetY = 0;
233+
}
234+
235+
return { renderedW, renderedH, offsetX, offsetY, videoW, videoH };
236+
};
237+
238+
/** Redraws the magnifier canvas showing a zoomed region of the video.
239+
* @param localX - cursor X in overlay coordinates (video region to magnify)
240+
* @param localY - cursor Y in overlay coordinates (video region to magnify)
241+
* @param crosshairX - X position to draw crosshair within the canvas
242+
* @param crosshairY - Y position to draw crosshair within the canvas
243+
*/
244+
const updateMagnifier = (
245+
localX: number,
246+
localY: number,
247+
crosshairX: number,
248+
crosshairY: number,
249+
) => {
250+
const { renderedW, renderedH, offsetX, offsetY, videoW, videoH } =
251+
getRenderedVideoBounds();
252+
253+
// Convert overlay coordinates to video intrinsic coordinates,
254+
// accounting for letterboxing/pillarboxing offset and scale.
255+
const videoX = ((localX - offsetX) / renderedW) * videoW;
256+
const videoY = ((localY - offsetY) / renderedH) * videoH;
257+
258+
// Source rectangle size in intrinsic pixels for the desired zoom level.
259+
// The magnifier shows (MAG_SIZE / MAG_ZOOM) display pixels worth of video.
260+
const srcW = (MAG_SIZE / MAG_ZOOM) * (videoW / renderedW);
261+
const srcH = (MAG_SIZE / MAG_ZOOM) * (videoH / renderedH);
262+
const sx = videoX - srcW / 2;
263+
const sy = videoY - srcH / 2;
204264

205265
// Clear and draw the magnified portion
206266
magCtx.clearRect(0, 0, MAG_SIZE, MAG_SIZE);
@@ -226,31 +286,48 @@ export class VideoPlayerNode extends Node {
226286

227287
magCtx.restore();
228288

229-
// Draw crosshair on top (center of magnifier)
230-
const center = MAG_SIZE / 2;
231-
const crossR = 8;
232-
const crossGap = 2;
233-
magCtx.strokeStyle = "rgba(255,255,255,0.8)";
234-
magCtx.lineWidth = 1;
289+
// White border ring drawn on-canvas so it doesn't affect the DOM
290+
// element's layout box size (avoids a CSS border offset).
291+
magCtx.strokeStyle = "white";
292+
magCtx.lineWidth = MAG_BORDER_WIDTH;
293+
magCtx.beginPath();
294+
magCtx.arc(
295+
MAG_SIZE / 2,
296+
MAG_SIZE / 2,
297+
MAG_SIZE / 2 - MAG_BORDER_WIDTH / 2,
298+
0,
299+
Math.PI * 2,
300+
);
301+
magCtx.stroke();
302+
303+
// Draw crosshair at the cursor position (aligns with the cursor on screen)
304+
magCtx.strokeStyle = MAG_CROSSHAIR_COLOR;
305+
magCtx.lineWidth = MAG_CROSSHAIR_LINE_WIDTH;
235306
magCtx.beginPath();
236307
// Horizontal segments
237-
magCtx.moveTo(center - crossR, center);
238-
magCtx.lineTo(center - crossGap, center);
239-
magCtx.moveTo(center + crossGap, center);
240-
magCtx.lineTo(center + crossR, center);
308+
magCtx.moveTo(crosshairX - MAG_CROSSHAIR_RADIUS, crosshairY);
309+
magCtx.lineTo(crosshairX - MAG_CROSSHAIR_GAP, crosshairY);
310+
magCtx.moveTo(crosshairX + MAG_CROSSHAIR_GAP, crosshairY);
311+
magCtx.lineTo(crosshairX + MAG_CROSSHAIR_RADIUS, crosshairY);
241312
// Vertical segments
242-
magCtx.moveTo(center, center - crossR);
243-
magCtx.lineTo(center, center - crossGap);
244-
magCtx.moveTo(center, center + crossGap);
245-
magCtx.lineTo(center, center + crossR);
313+
magCtx.moveTo(crosshairX, crosshairY - MAG_CROSSHAIR_RADIUS);
314+
magCtx.lineTo(crosshairX, crosshairY - MAG_CROSSHAIR_GAP);
315+
magCtx.moveTo(crosshairX, crosshairY + MAG_CROSSHAIR_GAP);
316+
magCtx.lineTo(crosshairX, crosshairY + MAG_CROSSHAIR_RADIUS);
246317
magCtx.stroke();
247318
};
248319

249-
const digitizingOverlay = new Rectangle(0, 0, 640, 360, {
250-
fill: "transparent",
251-
cursor: "none", // system cursor hidden; cursorNode takes its place
252-
visible: false,
253-
});
320+
const digitizingOverlay = new Rectangle(
321+
0,
322+
0,
323+
VIDEO_WIDTH,
324+
VIDEO_HEIGHT,
325+
{
326+
fill: "transparent",
327+
cursor: "none", // system cursor hidden; cursorNode takes its place
328+
visible: false,
329+
},
330+
);
254331
digitizingOverlay.addChild(cursorNode);
255332
digitizingOverlay.addChild(magnifierNode);
256333

@@ -266,17 +343,20 @@ export class VideoPlayerNode extends Node {
266343
// Centre the magnifier on the cursor, clamped to stay within the overlay
267344
const magX = Math.max(
268345
0,
269-
Math.min(localPt.x - MAG_SIZE / 2, 640 - MAG_SIZE),
346+
Math.min(localPt.x - MAG_SIZE / 2, VIDEO_WIDTH - MAG_SIZE),
270347
);
271348
const magY = Math.max(
272349
0,
273-
Math.min(localPt.y - MAG_SIZE / 2, 360 - MAG_SIZE),
350+
Math.min(localPt.y - MAG_SIZE / 2, VIDEO_HEIGHT - MAG_SIZE),
274351
);
275352
magnifierNode.x = magX;
276353
magnifierNode.y = magY;
277354

278355
if (model.magnifyVideoProperty.value) {
279-
updateMagnifier(localPt.x, localPt.y);
356+
// Crosshair position within the magnifier canvas (cursor relative to magnifier)
357+
const crosshairX = localPt.x - magX;
358+
const crosshairY = localPt.y - magY;
359+
updateMagnifier(localPt.x, localPt.y, crosshairX, crosshairY);
280360
magnifierNode.visible = true;
281361
} else {
282362
magnifierNode.visible = false;
@@ -560,18 +640,6 @@ export class VideoPlayerNode extends Node {
560640
});
561641
}
562642

563-
public step(): void {
564-
if (!this.isScrubbing) {
565-
const t = this.videoElement.currentTime;
566-
if (
567-
Number.isFinite(t) &&
568-
Math.abs(this.model.currentTimeProperty.value - t) > 0.016
569-
) {
570-
this.model.currentTimeProperty.value = t;
571-
}
572-
}
573-
}
574-
575643
/** Pause playback and advance by exactly one frame (1/30 s). */
576644
public stepForward(): void {
577645
this.model.isPlayingProperty.value = false;

0 commit comments

Comments
 (0)