photonlibpy 2025.0.0b3__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.
- photonlibpy/estimation/openCVHelp.py +105 -0
- photonlibpy/estimation/rotTrlTransform3d.py +19 -0
- photonlibpy/estimation/targetModel.py +63 -0
- photonlibpy/estimation/visionEstimation.py +22 -0
- photonlibpy/networktables/NTTopicSet.py +7 -0
- photonlibpy/photonCamera.py +68 -1
- photonlibpy/simulation/photonCameraSim.py +109 -3
- photonlibpy/simulation/simCameraProperties.py +139 -0
- photonlibpy/simulation/visionSystemSim.py +92 -0
- photonlibpy/simulation/visionTargetSim.py +10 -0
- photonlibpy/version.py +2 -2
- {photonlibpy-2025.0.0b3.dist-info → photonlibpy-2025.0.0b4.dist-info}/METADATA +2 -3
- {photonlibpy-2025.0.0b3.dist-info → photonlibpy-2025.0.0b4.dist-info}/RECORD +15 -15
- {photonlibpy-2025.0.0b3.dist-info → photonlibpy-2025.0.0b4.dist-info}/WHEEL +0 -0
- {photonlibpy-2025.0.0b3.dist-info → photonlibpy-2025.0.0b4.dist-info}/top_level.txt +0 -0
@@ -26,6 +26,10 @@ from .visionTargetSim import VisionTargetSim
|
|
26
26
|
|
27
27
|
|
28
28
|
class PhotonCameraSim:
|
29
|
+
"""A handle for simulating :class:`.PhotonCamera` values. Processing simulated targets through this
|
30
|
+
class will change the associated PhotonCamera's results.
|
31
|
+
"""
|
32
|
+
|
29
33
|
kDefaultMinAreaPx: float = 100
|
30
34
|
|
31
35
|
def __init__(
|
@@ -35,6 +39,21 @@ class PhotonCameraSim:
|
|
35
39
|
minTargetAreaPercent: float | None = None,
|
36
40
|
maxSightRange: meters | None = None,
|
37
41
|
):
|
42
|
+
"""Constructs a handle for simulating :class:`.PhotonCamera` values. Processing simulated targets
|
43
|
+
through this class will change the associated PhotonCamera's results.
|
44
|
+
|
45
|
+
By default, this constructor's camera has a 90 deg FOV with no simulated lag if props!
|
46
|
+
By default, the minimum target area is 100 pixels and there is no maximum sight range unless both params are passed to override.
|
47
|
+
|
48
|
+
|
49
|
+
:param camera: The camera to be simulated
|
50
|
+
:param prop: Properties of this camera such as FOV and FPS
|
51
|
+
:param minTargetAreaPercent: The minimum percentage(0 - 100) a detected target must take up of
|
52
|
+
the camera's image to be processed. Match this with your contour filtering settings in the
|
53
|
+
PhotonVision GUI.
|
54
|
+
:param maxSightRangeMeters: Maximum distance at which the target is illuminated to your camera.
|
55
|
+
Note that minimum target area of the image is separate from this.
|
56
|
+
"""
|
38
57
|
|
39
58
|
self.minTargetAreaPercent: float = 0.0
|
40
59
|
self.maxSightRange: float = 1.0e99
|
@@ -103,22 +122,39 @@ class PhotonCameraSim:
|
|
103
122
|
return self.videoSimFrameRaw
|
104
123
|
|
105
124
|
def canSeeTargetPose(self, camPose: Pose3d, target: VisionTargetSim) -> bool:
|
125
|
+
"""Determines if this target's pose should be visible to the camera without considering its
|
126
|
+
projected image points. Does not account for image area.
|
127
|
+
|
128
|
+
:param camPose: Camera's 3d pose
|
129
|
+
:param target: Vision target containing pose and shape
|
130
|
+
|
131
|
+
:returns: If this vision target can be seen before image projection.
|
132
|
+
"""
|
133
|
+
|
106
134
|
rel = CameraTargetRelation(camPose, target.getPose())
|
107
135
|
return (
|
108
136
|
(
|
137
|
+
# target translation is outside of camera's FOV
|
109
138
|
abs(rel.camToTargYaw.degrees())
|
110
139
|
< self.prop.getHorizFOV().degrees() / 2.0
|
111
140
|
and abs(rel.camToTargPitch.degrees())
|
112
141
|
< self.prop.getVertFOV().degrees() / 2.0
|
113
142
|
)
|
114
143
|
and (
|
144
|
+
# camera is behind planar target and it should be occluded
|
115
145
|
not target.getModel().getIsPlanar()
|
116
146
|
or abs(rel.targtoCamAngle.degrees()) < 90
|
117
147
|
)
|
148
|
+
# target is too far
|
118
149
|
and rel.camToTarg.translation().norm() <= self.maxSightRange
|
119
150
|
)
|
120
151
|
|
121
152
|
def canSeeCorner(self, points: np.ndarray) -> bool:
|
153
|
+
"""Determines if all target points are inside the camera's image.
|
154
|
+
|
155
|
+
:param points: The target's 2d image points
|
156
|
+
"""
|
157
|
+
|
122
158
|
assert points.shape[1] == 1
|
123
159
|
assert points.shape[2] == 2
|
124
160
|
for pt in points:
|
@@ -130,51 +166,88 @@ class PhotonCameraSim:
|
|
130
166
|
or y < 0
|
131
167
|
or y > self.prop.getResHeight()
|
132
168
|
):
|
133
|
-
return False
|
169
|
+
return False # point is outside of resolution
|
134
170
|
|
135
171
|
return True
|
136
172
|
|
137
173
|
def consumeNextEntryTime(self) -> float | None:
|
174
|
+
"""Determine if this camera should process a new frame based on performance metrics and the time
|
175
|
+
since the last update. This returns an Optional which is either empty if no update should occur
|
176
|
+
or a Long of the timestamp in microseconds of when the frame which should be received by NT. If
|
177
|
+
a timestamp is returned, the last frame update time becomes that timestamp.
|
178
|
+
|
179
|
+
:returns: Optional long which is empty while blocked or the NT entry timestamp in microseconds if
|
180
|
+
ready
|
181
|
+
"""
|
182
|
+
# check if this camera is ready for another frame update
|
138
183
|
now = int(wpilib.Timer.getFPGATimestamp() * 1e6)
|
139
184
|
timestamp = 0
|
140
185
|
iter = 0
|
186
|
+
# prepare next latest update
|
141
187
|
while now >= self.nextNtEntryTime:
|
142
188
|
timestamp = int(self.nextNtEntryTime)
|
143
189
|
frameTime = int(self.prop.estSecUntilNextFrame() * 1e6)
|
144
190
|
self.nextNtEntryTime += frameTime
|
145
191
|
|
192
|
+
# if frame time is very small, avoid blocking
|
146
193
|
iter += 1
|
147
194
|
if iter > 50:
|
148
195
|
timestamp = now
|
149
196
|
self.nextNtEntryTime = now + frameTime
|
150
197
|
break
|
151
198
|
|
199
|
+
# return the timestamp of the latest update
|
152
200
|
if timestamp != 0:
|
153
201
|
return timestamp
|
154
202
|
|
203
|
+
# or this camera isn't ready to process yet
|
155
204
|
return None
|
156
205
|
|
157
206
|
def setMinTargetAreaPercent(self, areaPercent: float) -> None:
|
207
|
+
"""The minimum percentage(0 - 100) a detected target must take up of the camera's image to be
|
208
|
+
processed.
|
209
|
+
"""
|
158
210
|
self.minTargetAreaPercent = areaPercent
|
159
211
|
|
160
212
|
def setMinTargetAreaPixels(self, areaPx: float) -> None:
|
213
|
+
"""The minimum number of pixels a detected target must take up in the camera's image to be
|
214
|
+
processed.
|
215
|
+
"""
|
161
216
|
self.minTargetAreaPercent = areaPx / self.prop.getResArea() * 100.0
|
162
217
|
|
163
218
|
def setMaxSightRange(self, range: meters) -> None:
|
219
|
+
"""Maximum distance at which the target is illuminated to your camera. Note that minimum target
|
220
|
+
area of the image is separate from this.
|
221
|
+
"""
|
164
222
|
self.maxSightRange = range
|
165
223
|
|
166
224
|
def enableRawStream(self, enabled: bool) -> None:
|
225
|
+
"""Sets whether the raw video stream simulation is enabled.
|
226
|
+
|
227
|
+
Note: This may increase loop times.
|
228
|
+
"""
|
167
229
|
self.videoSimRawEnabled = enabled
|
168
230
|
raise Exception("Raw stream not implemented")
|
169
231
|
|
170
232
|
def enableDrawWireframe(self, enabled: bool) -> None:
|
233
|
+
"""Sets whether a wireframe of the field is drawn to the raw video stream.
|
234
|
+
|
235
|
+
Note: This will dramatically increase loop times.
|
236
|
+
"""
|
171
237
|
self.videoSimWireframeEnabled = enabled
|
172
238
|
raise Exception("Wireframe not implemented")
|
173
239
|
|
174
240
|
def setWireframeResolution(self, resolution: float) -> None:
|
241
|
+
"""Sets the resolution of the drawn wireframe if enabled. Drawn line segments will be subdivided
|
242
|
+
into smaller segments based on a threshold set by the resolution.
|
243
|
+
|
244
|
+
:param resolution: Resolution as a fraction(0 - 1) of the video frame's diagonal length in
|
245
|
+
pixels
|
246
|
+
"""
|
175
247
|
self.videoSimWireframeResolution = resolution
|
176
248
|
|
177
249
|
def enableProcessedStream(self, enabled: bool) -> None:
|
250
|
+
"""Sets whether the processed video stream simulation is enabled."""
|
178
251
|
self.videoSimProcEnabled = enabled
|
179
252
|
raise Exception("Processed stream not implemented")
|
180
253
|
|
@@ -187,25 +260,32 @@ class PhotonCameraSim:
|
|
187
260
|
|
188
261
|
targets.sort(key=distance, reverse=True)
|
189
262
|
|
263
|
+
# all targets visible before noise
|
190
264
|
visibleTgts: list[typing.Tuple[VisionTargetSim, np.ndarray]] = []
|
265
|
+
# all targets actually detected by camera (after noise)
|
191
266
|
detectableTgts: list[PhotonTrackedTarget] = []
|
192
267
|
|
268
|
+
# basis change from world coordinates to camera coordinates
|
193
269
|
camRt = RotTrlTransform3d.makeRelativeTo(cameraPose)
|
194
270
|
|
195
271
|
for tgt in targets:
|
272
|
+
# pose isn't visible, skip to next
|
196
273
|
if not self.canSeeTargetPose(cameraPose, tgt):
|
197
274
|
continue
|
198
275
|
|
276
|
+
# find target's 3d corner points
|
199
277
|
fieldCorners = tgt.getFieldVertices()
|
200
278
|
isSpherical = tgt.getModel().getIsSpherical()
|
201
|
-
if isSpherical:
|
279
|
+
if isSpherical: # target is spherical
|
202
280
|
model = tgt.getModel()
|
281
|
+
# orient the model to the camera (like a sprite/decal) so it appears similar regardless of view
|
203
282
|
fieldCorners = model.getFieldVertices(
|
204
283
|
TargetModel.getOrientedPose(
|
205
284
|
tgt.getPose().translation(), cameraPose.translation()
|
206
285
|
)
|
207
286
|
)
|
208
287
|
|
288
|
+
# project 3d target points into 2d image points
|
209
289
|
imagePoints = OpenCVHelp.projectPoints(
|
210
290
|
self.prop.getIntrinsics(),
|
211
291
|
self.prop.getDistCoeffs(),
|
@@ -213,9 +293,11 @@ class PhotonCameraSim:
|
|
213
293
|
fieldCorners,
|
214
294
|
)
|
215
295
|
|
296
|
+
# spherical targets need a rotated rectangle of their midpoints for visualization
|
216
297
|
if isSpherical:
|
217
298
|
center = OpenCVHelp.avgPoint(imagePoints)
|
218
299
|
l: int = 0
|
300
|
+
# reference point (left side midpoint)
|
219
301
|
for i in range(4):
|
220
302
|
if imagePoints[i, 0, 0] < imagePoints[l, 0, 0].x:
|
221
303
|
l = i
|
@@ -239,6 +321,7 @@ class PhotonCameraSim:
|
|
239
321
|
for i in range(4):
|
240
322
|
if i != t and i != l and i != b:
|
241
323
|
r = i
|
324
|
+
# create RotatedRect from midpoints
|
242
325
|
rect = cv.RotatedRect(
|
243
326
|
(center[0, 0], center[0, 1]),
|
244
327
|
(
|
@@ -247,16 +330,23 @@ class PhotonCameraSim:
|
|
247
330
|
),
|
248
331
|
-angles[r],
|
249
332
|
)
|
333
|
+
# set target corners to rect corners
|
250
334
|
imagePoints = np.array([[p[0], p[1], p[2]] for p in rect.points()])
|
251
335
|
|
336
|
+
# save visible targets for raw video stream simulation
|
252
337
|
visibleTgts.append((tgt, imagePoints))
|
338
|
+
# estimate pixel noise
|
253
339
|
noisyTargetCorners = self.prop.estPixelNoise(imagePoints)
|
340
|
+
# find the minimum area rectangle of target corners
|
254
341
|
minAreaRect = OpenCVHelp.getMinAreaRect(noisyTargetCorners)
|
255
342
|
minAreaRectPts = minAreaRect.points()
|
343
|
+
# find the (naive) 2d yaw/pitch
|
256
344
|
centerPt = minAreaRect.center
|
257
345
|
centerRot = self.prop.getPixelRot(centerPt)
|
346
|
+
# find contour area
|
258
347
|
areaPercent = self.prop.getContourAreaPercent(noisyTargetCorners)
|
259
348
|
|
349
|
+
# projected target can't be detected, skip to next
|
260
350
|
if (
|
261
351
|
not self.canSeeCorner(noisyTargetCorners)
|
262
352
|
or not areaPercent >= self.minTargetAreaPercent
|
@@ -265,6 +355,7 @@ class PhotonCameraSim:
|
|
265
355
|
|
266
356
|
pnpSim: PnpResult | None = None
|
267
357
|
if tgt.fiducialId >= 0 and len(tgt.getFieldVertices()) == 4:
|
358
|
+
# single AprilTag solvePNP
|
268
359
|
pnpSim = OpenCVHelp.solvePNP_Square(
|
269
360
|
self.prop.getIntrinsics(),
|
270
361
|
self.prop.getDistCoeffs(),
|
@@ -295,6 +386,7 @@ class PhotonCameraSim:
|
|
295
386
|
|
296
387
|
# Video streams disabled for now
|
297
388
|
if self.videoSimRawEnabled:
|
389
|
+
# TODO Video streams disabled for now port and uncomment when implemented
|
298
390
|
# VideoSimUtil::UpdateVideoProp(videoSimRaw, prop);
|
299
391
|
# cv::Size videoFrameSize{prop.GetResWidth(), prop.GetResHeight()};
|
300
392
|
# cv::Mat blankFrame = cv::Mat::zeros(videoFrameSize, CV_8UC1);
|
@@ -312,6 +404,7 @@ class PhotonCameraSim:
|
|
312
404
|
|
313
405
|
if len(visibleLayoutTags) > 1:
|
314
406
|
usedIds = [tag.ID for tag in visibleLayoutTags]
|
407
|
+
# sort target order sorts in ascending order by default
|
315
408
|
usedIds.sort()
|
316
409
|
pnpResult = VisionEstimation.estimateCamPosePNP(
|
317
410
|
self.prop.getIntrinsics(),
|
@@ -323,10 +416,16 @@ class PhotonCameraSim:
|
|
323
416
|
if pnpResult is not None:
|
324
417
|
multiTagResults = MultiTargetPNPResult(pnpResult, usedIds)
|
325
418
|
|
419
|
+
# put this simulated data to NT
|
326
420
|
self.heartbeatCounter += 1
|
421
|
+
now_micros = wpilib.Timer.getFPGATimestamp() * 1e6
|
327
422
|
return PhotonPipelineResult(
|
328
423
|
metadata=PhotonPipelineMetadata(
|
329
|
-
self.heartbeatCounter,
|
424
|
+
self.heartbeatCounter,
|
425
|
+
int(now_micros - latency * 1e6),
|
426
|
+
int(now_micros),
|
427
|
+
# Pretend like we heard a pong recently
|
428
|
+
int(np.random.uniform(950, 1050)),
|
330
429
|
),
|
331
430
|
targets=detectableTgts,
|
332
431
|
multitagResult=multiTagResults,
|
@@ -335,6 +434,13 @@ class PhotonCameraSim:
|
|
335
434
|
def submitProcessedFrame(
|
336
435
|
self, result: PhotonPipelineResult, receiveTimestamp: float | None
|
337
436
|
):
|
437
|
+
"""Simulate one processed frame of vision data, putting one result to NT. Image capture timestamp
|
438
|
+
overrides :meth:`.PhotonPipelineResult.getTimestampSeconds` for more
|
439
|
+
precise latency simulation.
|
440
|
+
|
441
|
+
:param result: The pipeline result to submit
|
442
|
+
:param receiveTimestamp: The (sim) timestamp when this result was read by NT in microseconds. If not passed image capture time is assumed be (current time - latency)
|
443
|
+
"""
|
338
444
|
if receiveTimestamp is None:
|
339
445
|
receiveTimestamp = wpilib.Timer.getFPGATimestamp() * 1e6
|
340
446
|
receiveTimestamp = int(receiveTimestamp)
|
@@ -11,7 +11,22 @@ from ..estimation import RotTrlTransform3d
|
|
11
11
|
|
12
12
|
|
13
13
|
class SimCameraProperties:
|
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
|
+
|
14
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]
|
@@ -38,14 +53,18 @@ class SimCameraProperties:
|
|
38
53
|
fovWidth = Rotation2d(math.atan((diagRatio * (width / resDiag)) * 2))
|
39
54
|
fovHeight = Rotation2d(math.atan(diagRatio * (height / resDiag)) * 2)
|
40
55
|
|
56
|
+
# assume no distortion
|
41
57
|
newDistCoeffs = np.zeros((8, 1))
|
42
58
|
|
59
|
+
# assume centered principal point (pixels)
|
43
60
|
cx = width / 2.0 - 0.5
|
44
61
|
cy = height / 2.0 - 0.5
|
45
62
|
|
63
|
+
# use given fov to determine focal point (pixels)
|
46
64
|
fx = cx / math.tan(fovWidth.radians() / 2.0)
|
47
65
|
fy = cy / math.tan(fovHeight.radians() / 2.0)
|
48
66
|
|
67
|
+
# create camera intrinsics matrix
|
49
68
|
newCamIntrinsics = np.array([[fx, 0.0, cx], [0.0, fy, cy], [0.0, 0.0, 1.0]])
|
50
69
|
|
51
70
|
self.setCalibrationFromIntrinsics(
|
@@ -65,6 +84,7 @@ class SimCameraProperties:
|
|
65
84
|
self.camIntrinsics = newCamIntrinsics
|
66
85
|
self.distCoeffs = newDistCoeffs
|
67
86
|
|
87
|
+
# left, right, up, and down view planes
|
68
88
|
p = [
|
69
89
|
Translation3d(
|
70
90
|
1.0,
|
@@ -110,16 +130,33 @@ class SimCameraProperties:
|
|
110
130
|
self.errorStdDevPx = newErrorStdDevPx
|
111
131
|
|
112
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
|
+
|
113
138
|
self.frameSpeed = max(1.0 / fps, self.exposureTime)
|
114
139
|
|
115
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
|
+
|
116
146
|
self.exposureTime = newExposureTime
|
117
147
|
self.frameSpeed = max(self.frameSpeed, self.exposureTime)
|
118
148
|
|
119
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
|
+
"""
|
120
154
|
self.vgLatency = newAvgLatency
|
121
155
|
|
122
156
|
def setLatencyStdDev(self, newLatencyStdDev: seconds):
|
157
|
+
"""
|
158
|
+
:param latencyStdDevMs: The standard deviation in milliseconds of the latency
|
159
|
+
"""
|
123
160
|
self.latencyStdDev = newLatencyStdDev
|
124
161
|
|
125
162
|
def getResWidth(self) -> int:
|
@@ -156,21 +193,43 @@ class SimCameraProperties:
|
|
156
193
|
return self.latencyStdDev
|
157
194
|
|
158
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
|
+
|
159
202
|
return cv.contourArea(cv.convexHull(points)) / self.getResArea() * 100.0
|
160
203
|
|
161
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."""
|
162
206
|
fx = self.camIntrinsics[0, 0]
|
207
|
+
# account for principal point not being centered
|
163
208
|
cx = self.camIntrinsics[0, 2]
|
164
209
|
xOffset = cx - pixelX
|
165
210
|
return Rotation2d(fx, xOffset)
|
166
211
|
|
167
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
|
+
|
168
219
|
fy = self.camIntrinsics[1, 1]
|
220
|
+
# account for principal point not being centered
|
169
221
|
cy = self.camIntrinsics[1, 2]
|
170
222
|
yOffset = cy - pixelY
|
171
223
|
return Rotation2d(fy, -yOffset)
|
172
224
|
|
173
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
|
+
|
174
233
|
return Rotation3d(
|
175
234
|
0.0,
|
176
235
|
self.getPixelPitch(point[1]).radians(),
|
@@ -178,6 +237,27 @@ class SimCameraProperties:
|
|
178
237
|
)
|
179
238
|
|
180
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
|
+
|
181
261
|
fx = self.camIntrinsics[0, 0]
|
182
262
|
cx = self.camIntrinsics[0, 2]
|
183
263
|
xOffset = cx - point[0]
|
@@ -191,11 +271,13 @@ class SimCameraProperties:
|
|
191
271
|
return Rotation3d(0.0, pitch.radians(), yaw.radians())
|
192
272
|
|
193
273
|
def getHorizFOV(self) -> Rotation2d:
|
274
|
+
# sum of FOV left and right principal point
|
194
275
|
left = self.getPixelYaw(0)
|
195
276
|
right = self.getPixelYaw(self.resWidth)
|
196
277
|
return left - right
|
197
278
|
|
198
279
|
def getVertFOV(self) -> Rotation2d:
|
280
|
+
# sum of FOV above and below principal point
|
199
281
|
above = self.getPixelPitch(0)
|
200
282
|
below = self.getPixelPitch(self.resHeight)
|
201
283
|
return below - above
|
@@ -208,9 +290,34 @@ class SimCameraProperties:
|
|
208
290
|
def getVisibleLine(
|
209
291
|
self, camRt: RotTrlTransform3d, a: Translation3d, b: Translation3d
|
210
292
|
) -> typing.Tuple[float | None, float | None]:
|
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
|
211
317
|
relA = camRt.applyTranslation(a)
|
212
318
|
relB = camRt.applyTranslation(b)
|
213
319
|
|
320
|
+
# check if both ends are behind camera
|
214
321
|
if relA.X() <= 0.0 and relB.X() <= 0.0:
|
215
322
|
return (None, None)
|
216
323
|
|
@@ -221,6 +328,7 @@ class SimCameraProperties:
|
|
221
328
|
aVisible = True
|
222
329
|
bVisible = True
|
223
330
|
|
331
|
+
# check if the ends of the line segment are visible
|
224
332
|
for normal in self.viewplanes:
|
225
333
|
aVisibility = av.dot(normal)
|
226
334
|
if aVisibility < 0:
|
@@ -229,38 +337,54 @@ class SimCameraProperties:
|
|
229
337
|
bVisibility = bv.dot(normal)
|
230
338
|
if bVisibility < 0:
|
231
339
|
bVisible = False
|
340
|
+
# both ends are outside at least one of the same viewplane
|
232
341
|
if aVisibility <= 0 and bVisibility <= 0:
|
233
342
|
return (None, None)
|
234
343
|
|
344
|
+
# both ends are inside frustum
|
235
345
|
if aVisible and bVisible:
|
236
346
|
return (0.0, 1.0)
|
237
347
|
|
348
|
+
# parametrized (t=0 at a, t=1 at b) intersections with viewplanes
|
238
349
|
intersections = [float("nan"), float("nan"), float("nan"), float("nan")]
|
239
350
|
|
240
351
|
# Optionally 3x1 vector
|
241
352
|
ipts: typing.List[np.ndarray | None] = [None, None, None, None]
|
242
353
|
|
354
|
+
# find intersections
|
243
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));
|
244
361
|
a_projn = (av.dot(normal) / normal.dot(normal)) * normal
|
245
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
|
246
365
|
if abs(abv.dot(normal)) < 1.0e-5:
|
247
366
|
continue
|
248
367
|
intersections[i] = a_projn.dot(a_projn) / -(abv.dot(a_projn))
|
249
368
|
|
369
|
+
# // vector a to the viewplane
|
250
370
|
apv = intersections[i] * abv
|
371
|
+
# av + apv = intersection point
|
251
372
|
intersectpt = av + apv
|
252
373
|
ipts[i] = intersectpt
|
253
374
|
|
375
|
+
# // discard intersections outside the camera frustum
|
254
376
|
for j in range(1, len(self.viewplanes)):
|
255
377
|
if j == 0:
|
256
378
|
continue
|
257
379
|
oi = (i + j) % len(self.viewplanes)
|
258
380
|
onormal = self.viewplanes[oi]
|
381
|
+
# if the dot of the intersection point with any plane normal is negative, it is outside
|
259
382
|
if intersectpt.dot(onormal) < 0:
|
260
383
|
intersections[i] = float("nan")
|
261
384
|
ipts[i] = None
|
262
385
|
break
|
263
386
|
|
387
|
+
# // discard duplicate intersections
|
264
388
|
if ipts[i] is None:
|
265
389
|
continue
|
266
390
|
|
@@ -275,6 +399,7 @@ class SimCameraProperties:
|
|
275
399
|
ipts[i] = None
|
276
400
|
break
|
277
401
|
|
402
|
+
# determine visible segment (minimum and maximum t)
|
278
403
|
inter1 = float("nan")
|
279
404
|
inter2 = float("nan")
|
280
405
|
for inter in intersections:
|
@@ -284,6 +409,7 @@ class SimCameraProperties:
|
|
284
409
|
else:
|
285
410
|
inter2 = inter
|
286
411
|
|
412
|
+
# // two viewplane intersections
|
287
413
|
if not math.isnan(inter2):
|
288
414
|
max_ = max(inter1, inter2)
|
289
415
|
min_ = min(inter1, inter2)
|
@@ -292,16 +418,19 @@ class SimCameraProperties:
|
|
292
418
|
if bVisible:
|
293
419
|
max_ = 1
|
294
420
|
return (min_, max_)
|
421
|
+
# // one viewplane intersection
|
295
422
|
elif not math.isnan(inter1):
|
296
423
|
if aVisible:
|
297
424
|
return (0, inter1)
|
298
425
|
if bVisible:
|
299
426
|
return (inter1, 1)
|
300
427
|
return (inter1, None)
|
428
|
+
# no intersections
|
301
429
|
else:
|
302
430
|
return (None, None)
|
303
431
|
|
304
432
|
def estPixelNoise(self, points: np.ndarray) -> np.ndarray:
|
433
|
+
"""Returns these points after applying this camera's estimated noise."""
|
305
434
|
assert points.shape[1] == 1, points.shape
|
306
435
|
assert points.shape[2] == 2, points.shape
|
307
436
|
if self.avgErrorPx == 0 and self.errorStdDevPx == 0:
|
@@ -309,6 +438,7 @@ class SimCameraProperties:
|
|
309
438
|
|
310
439
|
noisyPts: list[list] = []
|
311
440
|
for p in points:
|
441
|
+
# // error pixels in random direction
|
312
442
|
error = np.random.normal(self.avgErrorPx, self.errorStdDevPx, 1)[0]
|
313
443
|
errorAngle = np.random.uniform(-math.pi, math.pi)
|
314
444
|
noisyPts.append(
|
@@ -324,16 +454,25 @@ class SimCameraProperties:
|
|
324
454
|
return retval
|
325
455
|
|
326
456
|
def estLatency(self) -> seconds:
|
457
|
+
"""
|
458
|
+
:returns: Noisy estimation of a frame's processing latency
|
459
|
+
"""
|
460
|
+
|
327
461
|
return max(
|
328
462
|
float(np.random.normal(self.avgLatency, self.latencyStdDev, 1)[0]),
|
329
463
|
0.0,
|
330
464
|
)
|
331
465
|
|
332
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
|
333
471
|
return self.frameSpeed + max(0.0, self.estLatency() - self.frameSpeed)
|
334
472
|
|
335
473
|
@classmethod
|
336
474
|
def PERFECT_90DEG(cls) -> typing.Self:
|
475
|
+
"""960x720 resolution, 90 degree FOV, "perfect" lagless camera"""
|
337
476
|
return cls()
|
338
477
|
|
339
478
|
@classmethod
|