@leverege/ptz-camera
v0.3.0
Published
JS port of py-ptz-camera (geometric modeling for PTZ cameras: kinematics, projection, frustums).
Readme
@leverege/ptz-camera
JS port of py-ptz-camera — geometric modeling for PTZ cameras: kinematic chain, forward projection (3D → pixel), inverse kinematics (world point → pan/tilt/zoom), FOV computation, and frustum geometry for visualization.
The Python sister package owns the calibration optimizer (PTZCalibrationParameters, calibrate(...)); this JS port covers the geometric-modeling half only — what a viewer needs to render, project, and reason about a configured camera. Wire-format dicts produced by to_dict() on either side are interchangeable.
Installation
npm install @leverege/ptz-cameraQuick start
import {
PTZCamera, PTZIntrinsics, Angle, Transformation
} from '@leverege/ptz-camera'
// 1. Define the camera hardware (intrinsics).
const ident = Transformation.identity()
const intrinsics = new PTZIntrinsics({
sensorWidthMm: 6.28, sensorHeightMm: 4.71,
imageWidth: 1920, imageHeight: 1080,
focalWideMm: 4.4, focalTeleMm: 132.0,
cxOffsetMm: 0.0, cyOffsetMm: 0.0,
zoomMin: 1, zoomMax: 9999,
panMinDeg: -180, panMaxDeg: 180,
tiltMinDeg: -20, tiltMaxDeg: 90,
TMountToPan: ident,
panAxis: [ 0, 0, -1 ],
TPanToTilt: ident,
tiltAxis: [ 0, -1, 0 ],
TTiltToSensor: ident,
})
// 2. Place the camera in the world.
import { math } from '@leverege/ptz-camera'
const RMount = math.fromEulerXYZDeg([ 0, 0, 90 ])
const camera = new PTZCamera({
t: [ 0, 0, 10000 ], // mm
R: RMount,
intrinsics,
})
// 3. Use it.
camera.projectPoints([ 5000, 3000, 0 ], Angle.degrees(-30), Angle.degrees(-60), 5000)
camera.xyzToPtd(5000, 3000, 0)
camera.getFov(5000)Kinematic Chain
A PTZ camera is modeled as a chain of rigid-body transforms from the world frame to the image plane:
world
| T_world_to_mount (extrinsics: position + orientation)
mount
| T_mount_to_pan (usually identity)
pan frame
| R(pan_axis, pan) -- rotation around pan_axis
| T_pan_to_tilt -- offset from pan pivot to tilt pivot
tilt frame
| R(tilt_axis, tilt) -- rotation around tilt_axis
| T_tilt_to_sensor -- offset from tilt pivot to sensor
sensor frame (X=forward, Y=left, Z=up)
| R_PROJECTION -- fixed sensor → projection rotation
projection frame (X=right, Y=down, Z=forward)Most cameras leave the inner transforms (T_mount_to_pan, T_pan_to_tilt, T_tilt_to_sensor) at identity. Non-identity values model mechanical offsets (e.g. tilt pivot 50 mm right of the pan axis) that cause parallax visible at close range.
PTZCamera Methods
Pan/tilt are passed as Angle instances (use Angle.degrees(...)).
Vectorized methods accept either a single [x, y, z] or an array of points.
getCameraRotation(pan, tilt) -> number[3][3]
Full kinematic-chain rotation. Columns are (right, down, forward) in world coordinates.
const R = camera.getCameraRotation(Angle.degrees(-30), Angle.degrees(-60))getBasis(pan, tilt) -> [right, down, forward]
The three orthonormal basis vectors as length-3 arrays.
const [ right, down, forward ] = camera.getBasis(Angle.degrees(-30), Angle.degrees(-60))getDirection(pan, tilt) -> number[3]
Viewing direction unit vector (the forward basis).
const dir = camera.getDirection(Angle.degrees(-30), Angle.degrees(-60))getOpticalCenter(pan, tilt) -> number[3]
Optical center position in world coordinates. With no kinematic offsets this equals camera.t regardless of pan/tilt.
const center = camera.getOpticalCenter(Angle.degrees(-30), Angle.degrees(-60))getFov(zoom) -> [hfov, vfov]
Horizontal and vertical FOV at a given zoom, returned as Angle instances.
const [ hfov, vfov ] = camera.getFov(5000)
console.log(`FOV: ${hfov.toDegrees().toFixed(1)} x ${vfov.toDegrees().toFixed(1)}°`)projectPoints(points, pan, tilt, zoom) -> Array<[u, v, valid]>
Project world points to image pixels. Always returns an array of [u, v, valid] triples (length matches the input). valid is 1 if the point is in front of the camera, 0 otherwise (in which case u and v are zeroed).
// Single point.
const [ [ u, v, valid ] ] = camera.projectPoints(
[ 5000, 3000, 0 ],
Angle.degrees(-30), Angle.degrees(-60),
5000,
)
// Vectorized.
const results = camera.projectPoints(
[ [ 5000, 3000, 0 ], [ 5500, 3200, 0 ], [ 4800, 2800, 0 ] ],
Angle.degrees(-30), Angle.degrees(-60),
5000,
)isVisible(points, pan, tilt, zoom, marginPx=0) -> boolean[]
Whether each point falls inside the camera's visible frame: in front of the camera and projected pixel coords are within [marginPx, imageSize - marginPx].
const points = [
[ 5000, 3000, 0 ], // likely in frame
[ -9999, 0, 0 ], // off to the side
]
const visible = camera.isVisible(points, Angle.degrees(-30), Angle.degrees(-60), 5000)
// visible -> [true, false]
// Tighten: require points be at least 50 px from any edge.
const inset = camera.isVisible(points, Angle.degrees(-30), Angle.degrees(-60), 5000, 50)xyzToPtd(x, y, z) -> [pan, tilt, distance] | null
Find (pan, tilt) that aims the camera at the world point. Returns null if the target coincides with the optical center.
const result = camera.xyzToPtd(5000, 3000, 0)
if (result !== null) {
const [ pan, tilt, distanceMm ] = result
}When the camera has translation offsets in the kinematic chain, the optical center moves with pan/tilt; the solver iterates until convergence. Without translation offsets, one iteration is exact.
xyzrToPtz(x, y, z, radius, marginFactor=1.0) -> [pan, tilt, zoom] | null
Find (pan, tilt, zoom) to frame a sphere of given radius (mm) around a world point. Zoom is chosen so the sphere fills the tighter FOV dimension.
// Frame a 200mm-radius object at (5000, 3000, 0) with 1.5x margin.
const result = camera.xyzrToPtz(5000, 3000, 0, 200, 1.5)xyzToPtz(points, marginFactor=1.0) -> [pan, tilt, zoom] | null
Find (pan, tilt, zoom) to frame all given points within the FOV. Returns null if any point is behind the camera or outside the widest FOV.
const result = camera.xyzToPtz([
[ 5000, 3000, 0 ],
[ 5500, 3200, 0 ],
[ 4800, 2800, 0 ],
], 1.2)toDict() -> object / PTZCamera.fromDict(data) -> PTZCamera
Serialize a complete camera (mount extrinsics + full intrinsics) to a flat dict, and reconstruct from it. Field naming is snake_case to match the Python py-ptz-camera wire format — dicts produced by either side are interchangeable.
const data = camera.toDict()
// data: { mount_t, mount_r, sensor_*, image_*, focal_*, cx/cy_offset_mm,
// zoom_min/max, pan/tilt_min/max_deg, pan/tilt_axis,
// pan_t, pan_r, tilt_t, tilt_r, sensor_t, sensor_r }
const restored = PTZCamera.fromDict(data)Geometry primitives
Angle
Explicit degree/radian handling.
import { Angle } from '@leverege/ptz-camera'
const a = Angle.degrees(45)
const b = Angle.radians(0.785)
a.toDegrees() // 45
a.toRadians() // 0.785...
a.add(b)
a.sub(Angle.degrees(10))
a.mul(2)
a.div(3)
a.normalize([ -180, 180 ]) // wrap to rangeTransformation
Rigid body transformation (rotation + translation).
import { Transformation } from '@leverege/ptz-camera'
Transformation.identity()
Transformation.fromTranslation([ 100, 200, 300 ])
Transformation.fromAxisAngle([ 0, 0, 1 ], Angle.degrees(90))
Transformation.fromEulerDeg([ 10, 20, 30 ], [ 100, 200, 300 ])
const T = Transformation.fromEulerDeg([ 10, 20, 30 ])
T.R // 3x3 row-major rotation matrix
T.t // length-3 translation vector
T.toEulerDeg()
T.inverse()
T.compose(other)
T.apply(point) // single [x,y,z] or array of pointsFrustum
Camera frustum geometry for visualization.
import { Frustum } from '@leverege/ptz-camera'
const pan = Angle.degrees(-30)
const tilt = Angle.degrees(-60)
const [ hfov, vfov ] = camera.getFov(5000)
const frustum = Frustum.fromCameraBasis({
position: camera.getOpticalCenter(pan, tilt),
basis: camera.getBasis(pan, tilt),
hfov,
vfov,
distance: 10000, // mm
})
// frustum.position -- camera optical center
// frustum.direction -- forward unit vector
// frustum.corners -- 4 corners at given distance
// frustum.lines -- line segments for drawing the frustumLegacyCalibrationParameters
The 5-parameter legacy calibration format (xOffset, yOffset, zOffset, panOffset, tiltOffset) used by older device data. Only valid for inverted-mount configurations (tiltOffset ∈ [175°, 185°]).
import { LegacyCalibrationParameters } from '@leverege/ptz-camera'
const legacy = new LegacyCalibrationParameters({
xOffset: 10000, yOffset: 0, zOffset: 11000,
panOffset: 180, tiltOffset: 180,
})
const data = legacy.toDict()
const back = LegacyCalibrationParameters.fromDict(data)Package Structure
src/
index.js -- public API re-exports
math.js -- vector/matrix ops, Euler/Rodrigues, nadir flip
angle.js -- Angle
transformation.js -- Transformation
intrinsics.js -- PTZIntrinsics
camera.js -- PTZCamera, R_PROJECTION
frustum.js -- Frustum
legacy-calibration.js -- LegacyCalibrationParametersRelationship to py-ptz-camera
This package mirrors the py-ptz-camera Python library. Wire-format dicts produced by to_dict() (Python) and toDict() (JS) are interchangeable in both directions, so a camera serialized in Python can be reconstructed in JS and vice versa.
What's NOT ported from Python (deliberately scoped out):
PTZCalibrationParametersandPTZCalibrationBounds— the calibration-optimizer wrappers.calibrate()— the Differential Evolution + L-BFGS-B + bootstrap pipeline.
Calibration is a Python-only concern. The JS package consumes calibration results, it doesn't produce them.
Development
npm install
npm test # vitest run
npm run test:watch