photonlibpy 2025.0.0b2__py3-none-any.whl → 2025.0.0b4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -11,7 +11,22 @@ from ..estimation import RotTrlTransform3d
11
11
 
12
12
 
13
13
  class SimCameraProperties:
14
- def __init__(self, path: str | None = None, width: int = 0, height: int = 0):
14
+ """Calibration and performance values for this camera.
15
+
16
+ The resolution will affect the accuracy of projected(3d to 2d) target corners and similarly
17
+ the severity of image noise on estimation(2d to 3d).
18
+
19
+ The camera intrinsics and distortion coefficients describe the results of calibration, and how
20
+ to map between 3d field points and 2d image points.
21
+
22
+ The performance values (framerate/exposure time, latency) determine how often results should
23
+ be updated and with how much latency in simulation. High exposure time causes motion blur which
24
+ can inhibit target detection while moving. Note that latency estimation does not account for
25
+ network latency and the latency reported will always be perfect.
26
+ """
27
+
28
+ def __init__(self):
29
+ """Default constructor which is the same as {@link #PERFECT_90DEG}"""
15
30
  self.resWidth: int = -1
16
31
  self.resHeight: int = -1
17
32
  self.camIntrinsics: np.ndarray = np.zeros((3, 3)) # [3,3]
@@ -24,63 +39,52 @@ class SimCameraProperties:
24
39
  self.latencyStdDev: seconds = 0.0
25
40
  self.viewplanes: list[np.ndarray] = [] # [3,1]
26
41
 
27
- if path is None:
28
- self.setCalibration(960, 720, fovDiag=Rotation2d(math.radians(90.0)))
29
- else:
30
- raise Exception("not yet implemented")
31
-
32
- def setCalibration(
33
- self,
34
- width: int,
35
- height: int,
36
- *,
37
- fovDiag: Rotation2d | None = None,
38
- newCamIntrinsics: np.ndarray | None = None,
39
- newDistCoeffs: np.ndarray | None = None,
40
- ):
41
- # Should be an inverted XOR on the args to differentiate between the signatures
42
-
43
- has_fov_args = fovDiag is not None
44
- has_matrix_args = newCamIntrinsics is not None and newDistCoeffs is not None
42
+ self.setCalibrationFromFOV(960, 720, fovDiag=Rotation2d(math.radians(90.0)))
45
43
 
46
- if (has_fov_args and has_matrix_args) or (
47
- not has_matrix_args and not has_fov_args
48
- ):
49
- raise Exception("not a correct function sig")
44
+ def setCalibrationFromFOV(
45
+ self, width: int, height: int, fovDiag: Rotation2d
46
+ ) -> None:
47
+ if fovDiag.degrees() < 1.0 or fovDiag.degrees() > 179.0:
48
+ fovDiag = Rotation2d.fromDegrees(max(min(fovDiag.degrees(), 179.0), 1.0))
49
+ logging.error("Requested invalid FOV! Clamping between (1, 179) degrees...")
50
50
 
51
- if has_fov_args:
52
- if fovDiag.degrees() < 1.0 or fovDiag.degrees() > 179.0:
53
- fovDiag = Rotation2d.fromDegrees(
54
- max(min(fovDiag.degrees(), 179.0), 1.0)
55
- )
56
- logging.error(
57
- "Requested invalid FOV! Clamping between (1, 179) degrees..."
58
- )
51
+ resDiag = math.sqrt(width * width + height * height)
52
+ diagRatio = math.tan(fovDiag.radians() / 2.0)
53
+ fovWidth = Rotation2d(math.atan((diagRatio * (width / resDiag)) * 2))
54
+ fovHeight = Rotation2d(math.atan(diagRatio * (height / resDiag)) * 2)
59
55
 
60
- resDiag = math.sqrt(width * width + height * height)
61
- diagRatio = math.tan(fovDiag.radians() / 2.0)
62
- fovWidth = Rotation2d(math.atan((diagRatio * (width / resDiag)) * 2))
63
- fovHeight = Rotation2d(math.atan(diagRatio * (height / resDiag)) * 2)
56
+ # assume no distortion
57
+ newDistCoeffs = np.zeros((8, 1))
64
58
 
65
- newDistCoeffs = np.zeros((8, 1))
59
+ # assume centered principal point (pixels)
60
+ cx = width / 2.0 - 0.5
61
+ cy = height / 2.0 - 0.5
66
62
 
67
- cx = width / 2.0 - 0.5
68
- cy = height / 2.0 - 0.5
63
+ # use given fov to determine focal point (pixels)
64
+ fx = cx / math.tan(fovWidth.radians() / 2.0)
65
+ fy = cy / math.tan(fovHeight.radians() / 2.0)
69
66
 
70
- fx = cx / math.tan(fovWidth.radians() / 2.0)
71
- fy = cy / math.tan(fovHeight.radians() / 2.0)
67
+ # create camera intrinsics matrix
68
+ newCamIntrinsics = np.array([[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]])
72
69
 
73
- newCamIntrinsics = np.array([[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]])
70
+ self.setCalibrationFromIntrinsics(
71
+ width, height, newCamIntrinsics, newDistCoeffs
72
+ )
74
73
 
75
- # really convince python we are doing the right thing
76
- assert newCamIntrinsics is not None
77
- assert newDistCoeffs is not None
74
+ def setCalibrationFromIntrinsics(
75
+ self,
76
+ width: int,
77
+ height: int,
78
+ newCamIntrinsics: np.ndarray,
79
+ newDistCoeffs: np.ndarray,
80
+ ) -> None:
78
81
 
79
82
  self.resWidth = width
80
83
  self.resHeight = height
81
84
  self.camIntrinsics = newCamIntrinsics
82
85
  self.distCoeffs = newDistCoeffs
83
86
 
87
+ # left, right, up, and down view planes
84
88
  p = [
85
89
  Translation3d(
86
90
  1.0,
@@ -126,16 +130,33 @@ class SimCameraProperties:
126
130
  self.errorStdDevPx = newErrorStdDevPx
127
131
 
128
132
  def setFPS(self, fps: hertz):
133
+ """
134
+ :param fps: The average frames per second the camera should process at. :strong:`Exposure time limits
135
+ FPS if set!`
136
+ """
137
+
129
138
  self.frameSpeed = max(1.0 / fps, self.exposureTime)
130
139
 
131
140
  def setExposureTime(self, newExposureTime: seconds):
141
+ """
142
+ :param newExposureTime: The amount of time the "shutter" is open for one frame. Affects motion
143
+ blur. **Frame speed(from FPS) is limited to this!**
144
+ """
145
+
132
146
  self.exposureTime = newExposureTime
133
147
  self.frameSpeed = max(self.frameSpeed, self.exposureTime)
134
148
 
135
149
  def setAvgLatency(self, newAvgLatency: seconds):
150
+ """
151
+ :param newAvgLatency: The average latency (from image capture to data published) in milliseconds
152
+ a frame should have
153
+ """
136
154
  self.vgLatency = newAvgLatency
137
155
 
138
156
  def setLatencyStdDev(self, newLatencyStdDev: seconds):
157
+ """
158
+ :param latencyStdDevMs: The standard deviation in milliseconds of the latency
159
+ """
139
160
  self.latencyStdDev = newLatencyStdDev
140
161
 
141
162
  def getResWidth(self) -> int:
@@ -171,31 +192,72 @@ class SimCameraProperties:
171
192
  def getLatencyStdDev(self) -> seconds:
172
193
  return self.latencyStdDev
173
194
 
174
- def getContourAreaPercent(self, points: list[typing.Tuple[float, float]]) -> float:
175
- return (
176
- cv.contourArea(cv.convexHull(np.array(points))) / self.getResArea() * 100.0
177
- )
195
+ def getContourAreaPercent(self, points: np.ndarray) -> float:
196
+ """The percentage(0 - 100) of this camera's resolution the contour takes up in pixels of the
197
+ image.
198
+
199
+ :param points: Points of the contour
200
+ """
201
+
202
+ return cv.contourArea(cv.convexHull(points)) / self.getResArea() * 100.0
178
203
 
179
204
  def getPixelYaw(self, pixelX: float) -> Rotation2d:
205
+ """The yaw from the principal point of this camera to the pixel x value. Positive values left."""
180
206
  fx = self.camIntrinsics[0, 0]
207
+ # account for principal point not being centered
181
208
  cx = self.camIntrinsics[0, 2]
182
209
  xOffset = cx - pixelX
183
210
  return Rotation2d(fx, xOffset)
184
211
 
185
212
  def getPixelPitch(self, pixelY: float) -> Rotation2d:
213
+ """The pitch from the principal point of this camera to the pixel y value. Pitch is positive down.
214
+
215
+ Note that this angle is naively computed and may be incorrect. See {@link
216
+ #getCorrectedPixelRot(Point)}.
217
+ """
218
+
186
219
  fy = self.camIntrinsics[1, 1]
220
+ # account for principal point not being centered
187
221
  cy = self.camIntrinsics[1, 2]
188
222
  yOffset = cy - pixelY
189
223
  return Rotation2d(fy, -yOffset)
190
224
 
191
- def getPixelRot(self, point: typing.Tuple[int, int]) -> Rotation3d:
225
+ def getPixelRot(self, point: cv.typing.Point2f) -> Rotation3d:
226
+ """Finds the yaw and pitch to the given image point. Yaw is positive left, and pitch is positive
227
+ down.
228
+
229
+ Note that pitch is naively computed and may be incorrect. See {@link
230
+ #getCorrectedPixelRot(Point)}.
231
+ """
232
+
192
233
  return Rotation3d(
193
234
  0.0,
194
235
  self.getPixelPitch(point[1]).radians(),
195
236
  self.getPixelYaw(point[0]).radians(),
196
237
  )
197
238
 
198
- def getCorrectedPixelRot(self, point: typing.Tuple[float, float]) -> Rotation3d:
239
+ def getCorrectedPixelRot(self, point: cv.typing.Point2f) -> Rotation3d:
240
+ """Gives the yaw and pitch of the line intersecting the camera lens and the given pixel
241
+ coordinates on the sensor. Yaw is positive left, and pitch positive down.
242
+
243
+ The pitch traditionally calculated from pixel offsets do not correctly account for non-zero
244
+ values of yaw because of perspective distortion (not to be confused with lens distortion)-- for
245
+ example, the pitch angle is naively calculated as:
246
+
247
+ <pre>pitch = arctan(pixel y offset / focal length y)</pre>
248
+
249
+ However, using focal length as a side of the associated right triangle is not correct when the
250
+ pixel x value is not 0, because the distance from this pixel (projected on the x-axis) to the
251
+ camera lens increases. Projecting a line back out of the camera with these naive angles will
252
+ not intersect the 3d point that was originally projected into this 2d pixel. Instead, this
253
+ length should be:
254
+
255
+ <pre>focal length y ⟶ (focal length y / cos(arctan(pixel x offset / focal length x)))</pre>
256
+
257
+ :returns: Rotation3d with yaw and pitch of the line projected out of the camera from the given
258
+ pixel (roll is zero).
259
+ """
260
+
199
261
  fx = self.camIntrinsics[0, 0]
200
262
  cx = self.camIntrinsics[0, 2]
201
263
  xOffset = cx - point[0]
@@ -209,11 +271,13 @@ class SimCameraProperties:
209
271
  return Rotation3d(0.0, pitch.radians(), yaw.radians())
210
272
 
211
273
  def getHorizFOV(self) -> Rotation2d:
274
+ # sum of FOV left and right principal point
212
275
  left = self.getPixelYaw(0)
213
276
  right = self.getPixelYaw(self.resWidth)
214
277
  return left - right
215
278
 
216
279
  def getVertFOV(self) -> Rotation2d:
280
+ # sum of FOV above and below principal point
217
281
  above = self.getPixelPitch(0)
218
282
  below = self.getPixelPitch(self.resHeight)
219
283
  return below - above
@@ -226,9 +290,34 @@ class SimCameraProperties:
226
290
  def getVisibleLine(
227
291
  self, camRt: RotTrlTransform3d, a: Translation3d, b: Translation3d
228
292
  ) -> typing.Tuple[float | None, float | None]:
229
- relA = camRt.apply(a)
230
- relB = camRt.apply(b)
231
-
293
+ """Determines where the line segment defined by the two given translations intersects the camera's
294
+ frustum/field-of-vision, if at all.
295
+
296
+ The line is parametrized so any of its points <code>p = t * (b - a) + a</code>. This method
297
+ returns these values of t, minimum first, defining the region of the line segment which is
298
+ visible in the frustum. If both ends of the line segment are visible, this simply returns {0,
299
+ 1}. If, for example, point b is visible while a is not, and half of the line segment is inside
300
+ the camera frustum, {0.5, 1} would be returned.
301
+
302
+ :param camRt: The change in basis from world coordinates to camera coordinates. See {@link
303
+ RotTrlTransform3d#makeRelativeTo(Pose3d)}.
304
+ :param a: The initial translation of the line
305
+ :param b: The final translation of the line
306
+
307
+ :returns: A Pair of Doubles. The values may be null:
308
+
309
+ - {Double, Double} : Two parametrized values(t), minimum first, representing which
310
+ segment of the line is visible in the camera frustum.
311
+ - {Double, null} : One value(t) representing a single intersection point. For example,
312
+ the line only intersects the intersection of two adjacent viewplanes.
313
+ - {null, null} : No values. The line segment is not visible in the camera frustum.
314
+ """
315
+
316
+ # translations relative to the camera
317
+ relA = camRt.applyTranslation(a)
318
+ relB = camRt.applyTranslation(b)
319
+
320
+ # check if both ends are behind camera
232
321
  if relA.X() <= 0.0 and relB.X() <= 0.0:
233
322
  return (None, None)
234
323
 
@@ -239,6 +328,7 @@ class SimCameraProperties:
239
328
  aVisible = True
240
329
  bVisible = True
241
330
 
331
+ # check if the ends of the line segment are visible
242
332
  for normal in self.viewplanes:
243
333
  aVisibility = av.dot(normal)
244
334
  if aVisibility < 0:
@@ -247,39 +337,55 @@ class SimCameraProperties:
247
337
  bVisibility = bv.dot(normal)
248
338
  if bVisibility < 0:
249
339
  bVisible = False
340
+ # both ends are outside at least one of the same viewplane
250
341
  if aVisibility <= 0 and bVisibility <= 0:
251
342
  return (None, None)
252
343
 
344
+ # both ends are inside frustum
253
345
  if aVisible and bVisible:
254
346
  return (0.0, 1.0)
255
347
 
348
+ # parametrized (t=0 at a, t=1 at b) intersections with viewplanes
256
349
  intersections = [float("nan"), float("nan"), float("nan"), float("nan")]
257
350
 
258
351
  # Optionally 3x1 vector
259
352
  ipts: typing.List[np.ndarray | None] = [None, None, None, None]
260
353
 
354
+ # find intersections
261
355
  for i, normal in enumerate(self.viewplanes):
356
+
357
+ # // we want to know the value of t when the line intercepts this plane
358
+ # // parametrized: v = t * ab + a, where v lies on the plane
359
+ # // we can find the projection of a onto the plane normal
360
+ # // a_projn = normal.times(av.dot(normal) / normal.dot(normal));
262
361
  a_projn = (av.dot(normal) / normal.dot(normal)) * normal
263
362
 
363
+ # // this projection lets us determine the scalar multiple t of ab where
364
+ # // (t * ab + a) is a vector which lies on the plane
264
365
  if abs(abv.dot(normal)) < 1.0e-5:
265
366
  continue
266
367
  intersections[i] = a_projn.dot(a_projn) / -(abv.dot(a_projn))
267
368
 
369
+ # // vector a to the viewplane
268
370
  apv = intersections[i] * abv
371
+ # av + apv = intersection point
269
372
  intersectpt = av + apv
270
373
  ipts[i] = intersectpt
271
374
 
375
+ # // discard intersections outside the camera frustum
272
376
  for j in range(1, len(self.viewplanes)):
273
377
  if j == 0:
274
378
  continue
275
379
  oi = (i + j) % len(self.viewplanes)
276
380
  onormal = self.viewplanes[oi]
381
+ # if the dot of the intersection point with any plane normal is negative, it is outside
277
382
  if intersectpt.dot(onormal) < 0:
278
383
  intersections[i] = float("nan")
279
384
  ipts[i] = None
280
385
  break
281
386
 
282
- if not ipts[i]:
387
+ # // discard duplicate intersections
388
+ if ipts[i] is None:
283
389
  continue
284
390
 
285
391
  for j in range(i - 1, 0 - 1):
@@ -293,6 +399,7 @@ class SimCameraProperties:
293
399
  ipts[i] = None
294
400
  break
295
401
 
402
+ # determine visible segment (minimum and maximum t)
296
403
  inter1 = float("nan")
297
404
  inter2 = float("nan")
298
405
  for inter in intersections:
@@ -302,6 +409,7 @@ class SimCameraProperties:
302
409
  else:
303
410
  inter2 = inter
304
411
 
412
+ # // two viewplane intersections
305
413
  if not math.isnan(inter2):
306
414
  max_ = max(inter1, inter2)
307
415
  min_ = min(inter1, inter2)
@@ -310,16 +418,19 @@ class SimCameraProperties:
310
418
  if bVisible:
311
419
  max_ = 1
312
420
  return (min_, max_)
421
+ # // one viewplane intersection
313
422
  elif not math.isnan(inter1):
314
423
  if aVisible:
315
424
  return (0, inter1)
316
425
  if bVisible:
317
426
  return (inter1, 1)
318
427
  return (inter1, None)
428
+ # no intersections
319
429
  else:
320
430
  return (None, None)
321
431
 
322
432
  def estPixelNoise(self, points: np.ndarray) -> np.ndarray:
433
+ """Returns these points after applying this camera's estimated noise."""
323
434
  assert points.shape[1] == 1, points.shape
324
435
  assert points.shape[2] == 2, points.shape
325
436
  if self.avgErrorPx == 0 and self.errorStdDevPx == 0:
@@ -327,6 +438,7 @@ class SimCameraProperties:
327
438
 
328
439
  noisyPts: list[list] = []
329
440
  for p in points:
441
+ # // error pixels in random direction
330
442
  error = np.random.normal(self.avgErrorPx, self.errorStdDevPx, 1)[0]
331
443
  errorAngle = np.random.uniform(-math.pi, math.pi)
332
444
  noisyPts.append(
@@ -342,22 +454,31 @@ class SimCameraProperties:
342
454
  return retval
343
455
 
344
456
  def estLatency(self) -> seconds:
457
+ """
458
+ :returns: Noisy estimation of a frame's processing latency
459
+ """
460
+
345
461
  return max(
346
462
  float(np.random.normal(self.avgLatency, self.latencyStdDev, 1)[0]),
347
463
  0.0,
348
464
  )
349
465
 
350
466
  def estSecUntilNextFrame(self) -> seconds:
467
+ """
468
+ :returns: Estimate how long until the next frame should be processed in milliseconds
469
+ """
470
+ # // exceptional processing latency blocks the next frame
351
471
  return self.frameSpeed + max(0.0, self.estLatency() - self.frameSpeed)
352
472
 
353
473
  @classmethod
354
474
  def PERFECT_90DEG(cls) -> typing.Self:
475
+ """960x720 resolution, 90 degree FOV, "perfect" lagless camera"""
355
476
  return cls()
356
477
 
357
478
  @classmethod
358
479
  def PI4_LIFECAM_320_240(cls) -> typing.Self:
359
480
  prop = cls()
360
- prop.setCalibration(
481
+ prop.setCalibrationFromIntrinsics(
361
482
  320,
362
483
  240,
363
484
  newCamIntrinsics=np.array(
@@ -391,7 +512,7 @@ class SimCameraProperties:
391
512
  @classmethod
392
513
  def PI4_LIFECAM_640_480(cls) -> typing.Self:
393
514
  prop = cls()
394
- prop.setCalibration(
515
+ prop.setCalibrationFromIntrinsics(
395
516
  640,
396
517
  480,
397
518
  newCamIntrinsics=np.array(
@@ -425,7 +546,7 @@ class SimCameraProperties:
425
546
  @classmethod
426
547
  def LL2_640_480(cls) -> typing.Self:
427
548
  prop = cls()
428
- prop.setCalibration(
549
+ prop.setCalibrationFromIntrinsics(
429
550
  640,
430
551
  480,
431
552
  newCamIntrinsics=np.array(
@@ -459,7 +580,7 @@ class SimCameraProperties:
459
580
  @classmethod
460
581
  def LL2_960_720(cls) -> typing.Self:
461
582
  prop = cls()
462
- prop.setCalibration(
583
+ prop.setCalibrationFromIntrinsics(
463
584
  960,
464
585
  720,
465
586
  newCamIntrinsics=np.array(
@@ -493,7 +614,7 @@ class SimCameraProperties:
493
614
  @classmethod
494
615
  def LL2_1280_720(cls) -> typing.Self:
495
616
  prop = cls()
496
- prop.setCalibration(
617
+ prop.setCalibrationFromIntrinsics(
497
618
  1280,
498
619
  720,
499
620
  newCamIntrinsics=np.array(
@@ -527,7 +648,7 @@ class SimCameraProperties:
527
648
  @classmethod
528
649
  def OV9281_640_480(cls) -> typing.Self:
529
650
  prop = cls()
530
- prop.setCalibration(
651
+ prop.setCalibrationFromIntrinsics(
531
652
  640,
532
653
  480,
533
654
  newCamIntrinsics=np.array(
@@ -561,7 +682,7 @@ class SimCameraProperties:
561
682
  @classmethod
562
683
  def OV9281_800_600(cls) -> typing.Self:
563
684
  prop = cls()
564
- prop.setCalibration(
685
+ prop.setCalibrationFromIntrinsics(
565
686
  800,
566
687
  600,
567
688
  newCamIntrinsics=np.array(
@@ -595,7 +716,7 @@ class SimCameraProperties:
595
716
  @classmethod
596
717
  def OV9281_1280_720(cls) -> typing.Self:
597
718
  prop = cls()
598
- prop.setCalibration(
719
+ prop.setCalibrationFromIntrinsics(
599
720
  1280,
600
721
  720,
601
722
  newCamIntrinsics=np.array(
@@ -629,7 +750,7 @@ class SimCameraProperties:
629
750
  @classmethod
630
751
  def OV9281_1920_1080(cls) -> typing.Self:
631
752
  prop = cls()
632
- prop.setCalibration(
753
+ prop.setCalibrationFromIntrinsics(
633
754
  1920,
634
755
  1080,
635
756
  newCamIntrinsics=np.array(