Skip to main content

When Pixels Become Degrees

When an operator clicks a spot in a camera image, four separate modules cooperate to turn that click into an absolute pan/tilt/zoom command. Every one of them is pure arithmetic on numbers with no runtime validation, so a bad input at stage 2 does not throw — it produces a plausible-looking wrong angle that the camera dutifully obeys.

This page is the companion to features/camera-movement/img-to-map-calculations, which documents the pipeline as a reference. This page covers the parts that bite: the camera-model fallback, the 360-mode asymmetry, and the rounding round-trip loss in the pano scroll math. Everything below is verified against source.

Before reading the stages, aim the camera yourself — the relationship between zoom and angular movement is the whole idea, and it takes about five seconds to feel it.

Try itClick a spot in the frame, then change the zoom and click the same spot again.

Horizontal field of view: 63.7°

ts
// click anywhere in the frame above
POST /api/ptz  { pan: 142, tilt: -3, zoom: 1 }

Notice what happened when you zoomed in: the same click produced a smaller pan offset. Field of view is the conversion factor between pixels and degrees, so it has to be part of every calculation. Which is exactly what makes the next section dangerous.

The pipeline

The four stages are not a chain — getFieldOfView and the dimension helpers both feed transformMousePositionToImage, which is the single funnel every interaction goes through. transformToPositiveDegrees sits after it, on the display and pano-scroll side.

StageFileWhat it contributes
1hooks/useMousePosition.tsxgetMouseLocation(ref) — window-level mousemove / touchmove listeners, throttled at 100 ms, that publish mousePositionInRef only when the event target is inside the overlay's parent element (so a portaled dropdown overlapping the image is not mistaken for an image hover)
2util/cameras/calculateZoom.tsgetFieldOfView(model, zoom) — the only source of "how many degrees wide is this frame"
3util/cameras/calculateImageDims.tscalculateWidth, pixelsToDegrees, calculateInnerDivSize, calculateCoverInsideDivSize — the pixel⇄degree scale and the letterbox geometry it assumes
4util/cameras/transformToPositiveDegrees.tstransformToPositiveDegrees, convertScrollOffsetToDegrees, convertDegreesToScrollOffset — signed PTZ degrees to compass degrees, and the 360 strip's scroll position

The camera-model fallback

This is the highest-consequence footgun in the whole pipeline. See it happen:

Try itPick a camera model. The last one isn't in the lookup table.
in lookup table

FOV 63.7° — conversion is correct.

ts
getFieldOfView('Q6055-E')  →  63.7°   // found in CAMERA_MODEL_DETAILS
click 30% right of center  →  pan offset 19.11°   ✅ correct

calculateZoom.ts holds a CAMERA_MODEL_DETAILS record keyed by exact model string. Today it has five entries: Q6055-E, Q6045-E-MkII, Q6075-E, Q6115-E, M16B-6D6N041. Every read goes through one helper:

function lookup_camera_details_with_default(model: string): any {
let result = CAMERA_MODEL_DETAILS[model];
if (result == null) {
result = CAMERA_MODEL_DETAILS['Q6055-E'];
}
return result;
}

No warning, no telemetry, no null return. An unrecognized model string — a new camera type, a vendor-prefixed string like AXIS Q6075-E, a trailing space, an empty cMo — resolves to the Q6055-E optical spec and the math proceeds as if nothing happened.

Pixels to degrees

transformMousePositionToImage in hooks/useMousePosition.tsx is the funnel. Two things in it are easy to get wrong when reading it:

It uses height × imgAspectRatio, never rect.width. The image is letterboxed inside the overlay div, so the overlay is usually wider than the image. calculateInnerDivSize in calculateImageDims.ts is what produces that letterboxed geometry, and the transform has to undo it by reconstructing the image's true pixel width from the overlay height and the image's aspect ratio. Substituting rect.width is a silent horizontal scale error.

The Y axis is flipped. Screen y grows downward, tilt grows upward, hence the (1 - mouseY / rect.height).

Both results are rounded to one decimal (Math.round(val * 10) / 10), and the function returns { x: 0, y: 0 } when the pointer is outside the overlay or the ref is unmounted. That sentinel is load-bearing: handleMouseUpOnSwipe and handleMouseUpOnZoom both guard with dragStartLocation.x !== 0 specifically so a resize-induced zero does not fire a movement.

The 360-mode asymmetry

When the overlay is mounted for the pano (IImage.tsx passes useImageFov={false} and usedAsOverlayFor='360'), the horizontal and vertical spans stop agreeing with each other:

const panUnitsAcross = useImageFov ? camFov : 360;
const tiltUnitsUpandDown = useImageFov
? camFov / imgAspectRatio
: camFov / (1920 / 1080);

The horizontal span becomes a full 360° mapped across height × imgAspectRatio pixels — for a pano that is the whole strip width, not the viewport, which is why pixelsOffset (the strip's scroll translation) is subtracted from mouseX and why mouseX legitimately goes negative or past the overlay width.

The vertical span, though, stays camFov / (1920/1080) — still derived from the camera's optical FOV at its current zoom, and hardcoded against 16:9 rather than the strip's own imgAspectRatio. Note what that implies: the tilt degrees a pano click produces depend on whatever zoom value the selected camera happens to be sitting at, even though the stitched strip was captured at some fixed zoom of its own. Treat pano tilt as approximate; pano pan is the reliable axis.

note

The prose in img-to-map-calculations gives the 360 tilt span as 360 / (1920/1080) ≈ 202.5°. Source says camFov / (1920/1080). The formula above is what runs.

Degrees to compass, and back to scroll pixels

transformToPositiveDegrees.ts handles the display side. Its three exports are small enough to read in full, and two of them have edges worth knowing.

Debugging checklist

  • Camera lands consistently off, proportionally worse near the frame edges — model lookup miss. Log cameraSelected.cMo and compare it byte-for-byte against the CAMERA_MODEL_DETAILS keys.
  • Pan and tilt are both NaN in the payload — either a Mobotix M16B-6D6N041 (fixed focal, see above) or a zoom that failed parseInt.
  • Map view cone and actual camera position disagree — the two default-model expressions (Q6075-E in the callers, Q6055-E in the lookup) have diverged for this camera. Empty-string cMo is the usual trigger.
  • Overlay readout says 360° when the camera is pointing north — expected; see transformToPositiveDegrees(0) above.
  • A pano overlay drifts and snaps as you scroll — a caller passing precise = false where it needs the unrounded heading.
  • Horizontal angles are scaled wrong but vertical ones are fine — someone substituted rect.width for height × imgAspectRatio.
  • Drag fired on a window resize — the dragStartLocation.x !== 0 guard depends on the out-of-bounds { x: 0, y: 0 } sentinel; check it is still there.