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.
Horizontal field of view: 63.7°
// 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.
| Stage | File | What it contributes |
|---|---|---|
| 1 | hooks/useMousePosition.tsx | getMouseLocation(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) |
| 2 | util/cameras/calculateZoom.ts | getFieldOfView(model, zoom) — the only source of "how many degrees wide is this frame" |
| 3 | util/cameras/calculateImageDims.ts | calculateWidth, pixelsToDegrees, calculateInnerDivSize, calculateCoverInsideDivSize — the pixel⇄degree scale and the letterbox geometry it assumes |
| 4 | util/cameras/transformToPositiveDegrees.ts | transformToPositiveDegrees, 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:
FOV 63.7° — conversion is correct.
getFieldOfView('Q6055-E') → 63.7° // found in CAMERA_MODEL_DETAILS
click 30% right of center → pan offset 19.11° ✅ correctcalculateZoom.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.
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.cMoand compare it byte-for-byte against theCAMERA_MODEL_DETAILSkeys. - Pan and tilt are both
NaNin the payload — either a MobotixM16B-6D6N041(fixed focal, see above) or azoomthat failedparseInt. - Map view cone and actual camera position disagree — the two default-model expressions
(
Q6075-Ein the callers,Q6055-Ein the lookup) have diverged for this camera. Empty-stringcMois the usual trigger. - Overlay readout says
360°when the camera is pointing north — expected; seetransformToPositiveDegrees(0)above. - A pano overlay drifts and snaps as you scroll — a caller passing
precise = falsewhere it needs the unrounded heading. - Horizontal angles are scaled wrong but vertical ones are fine — someone substituted
rect.widthforheight × imgAspectRatio. - Drag fired on a window resize — the
dragStartLocation.x !== 0guard depends on the out-of-bounds{ x: 0, y: 0 }sentinel; check it is still there.
Related
features/camera-movement/img-to-map-calculations— the full reference for the pipeline, including the drag-to-pan and drag-to-zoom handlers and the digital-zoom CSS transform. Start there; this page is the failure-mode companion.- XYMouseTrackerOverlay — hands-on sandboxes for the interaction layer that sits on top of this math.
deep-dives/pano-calibration-bearing— what happens to the compass degree this pipeline produces once an external pano's rotational offset is applied.deep-dives/camera-lease-concurrency— the payload this math produces still needs a lease before the camera will accept it.