photonlibpy 2025.0.0a0__py3-none-any.whl → 2025.0.0b2__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/__init__.py +2 -2
- photonlibpy/estimation/__init__.py +5 -0
- photonlibpy/estimation/cameraTargetRelation.py +25 -0
- photonlibpy/estimation/openCVHelp.py +200 -0
- photonlibpy/estimation/rotTrlTransform3d.py +32 -0
- photonlibpy/estimation/targetModel.py +137 -0
- photonlibpy/estimation/visionEstimation.py +91 -0
- photonlibpy/generated/MultiTargetPNPResultSerde.py +12 -0
- photonlibpy/generated/PhotonPipelineMetadataSerde.py +23 -4
- photonlibpy/generated/PhotonPipelineResultSerde.py +19 -2
- photonlibpy/generated/PhotonTrackedTargetSerde.py +40 -0
- photonlibpy/generated/PnpResultSerde.py +19 -0
- photonlibpy/generated/TargetCornerSerde.py +12 -0
- photonlibpy/generated/__init__.py +0 -1
- photonlibpy/networktables/NTTopicSet.py +64 -0
- photonlibpy/networktables/__init__.py +1 -0
- photonlibpy/packet.py +123 -8
- photonlibpy/photonCamera.py +10 -7
- photonlibpy/photonPoseEstimator.py +5 -5
- photonlibpy/simulation/__init__.py +5 -0
- photonlibpy/simulation/photonCameraSim.py +408 -0
- photonlibpy/simulation/simCameraProperties.py +661 -0
- photonlibpy/simulation/videoSimUtil.py +2 -0
- photonlibpy/simulation/visionSystemSim.py +237 -0
- photonlibpy/simulation/visionTargetSim.py +50 -0
- photonlibpy/targeting/TargetCorner.py +5 -1
- photonlibpy/targeting/__init__.py +1 -1
- photonlibpy/targeting/multiTargetPNPResult.py +10 -4
- photonlibpy/targeting/photonPipelineResult.py +12 -5
- photonlibpy/targeting/photonTrackedTarget.py +13 -5
- photonlibpy/version.py +2 -2
- {photonlibpy-2025.0.0a0.dist-info → photonlibpy-2025.0.0b2.dist-info}/METADATA +6 -2
- photonlibpy-2025.0.0b2.dist-info/RECORD +36 -0
- {photonlibpy-2025.0.0a0.dist-info → photonlibpy-2025.0.0b2.dist-info}/WHEEL +1 -1
- photonlibpy-2025.0.0a0.dist-info/RECORD +0 -22
- {photonlibpy-2025.0.0a0.dist-info → photonlibpy-2025.0.0b2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,408 @@
|
|
1
|
+
import math
|
2
|
+
import typing
|
3
|
+
|
4
|
+
import cscore as cs
|
5
|
+
import cv2 as cv
|
6
|
+
import numpy as np
|
7
|
+
import robotpy_apriltag
|
8
|
+
import wpilib
|
9
|
+
from wpimath.geometry import Pose3d, Transform3d
|
10
|
+
from wpimath.units import meters, seconds
|
11
|
+
|
12
|
+
from ..estimation import OpenCVHelp, RotTrlTransform3d, TargetModel, VisionEstimation
|
13
|
+
from ..estimation.cameraTargetRelation import CameraTargetRelation
|
14
|
+
from ..networktables.NTTopicSet import NTTopicSet
|
15
|
+
from ..photonCamera import PhotonCamera
|
16
|
+
from ..targeting import (
|
17
|
+
MultiTargetPNPResult,
|
18
|
+
PhotonPipelineMetadata,
|
19
|
+
PhotonPipelineResult,
|
20
|
+
PhotonTrackedTarget,
|
21
|
+
PnpResult,
|
22
|
+
TargetCorner,
|
23
|
+
)
|
24
|
+
from .simCameraProperties import SimCameraProperties
|
25
|
+
from .visionTargetSim import VisionTargetSim
|
26
|
+
|
27
|
+
|
28
|
+
class PhotonCameraSim:
|
29
|
+
kDefaultMinAreaPx: float = 100
|
30
|
+
|
31
|
+
def __init__(
|
32
|
+
self,
|
33
|
+
camera: PhotonCamera,
|
34
|
+
props: SimCameraProperties | None = None,
|
35
|
+
minTargetAreaPercent: float | None = None,
|
36
|
+
maxSightRange: meters | None = None,
|
37
|
+
):
|
38
|
+
|
39
|
+
self.minTargetAreaPercent: float = 0.0
|
40
|
+
self.maxSightRange: float = 1.0e99
|
41
|
+
self.videoSimRawEnabled: bool = False
|
42
|
+
self.videoSimWireframeEnabled: bool = False
|
43
|
+
self.videoSimWireframeResolution: float = 0.1
|
44
|
+
self.videoSimProcEnabled: bool = True
|
45
|
+
self.ts = NTTopicSet()
|
46
|
+
self.heartbeatCounter: int = 0
|
47
|
+
self.nextNtEntryTime = int(wpilib.Timer.getFPGATimestamp() * 1e6)
|
48
|
+
self.tagLayout = robotpy_apriltag.loadAprilTagLayoutField(
|
49
|
+
robotpy_apriltag.AprilTagField.k2024Crescendo
|
50
|
+
)
|
51
|
+
|
52
|
+
if (
|
53
|
+
camera is not None
|
54
|
+
and props is None
|
55
|
+
and minTargetAreaPercent is None
|
56
|
+
and maxSightRange is None
|
57
|
+
):
|
58
|
+
props = SimCameraProperties.PERFECT_90DEG()
|
59
|
+
elif (
|
60
|
+
camera is not None
|
61
|
+
and props is not None
|
62
|
+
and minTargetAreaPercent is not None
|
63
|
+
and maxSightRange is not None
|
64
|
+
):
|
65
|
+
pass
|
66
|
+
elif (
|
67
|
+
camera is not None
|
68
|
+
and props is not None
|
69
|
+
and minTargetAreaPercent is None
|
70
|
+
and maxSightRange is None
|
71
|
+
):
|
72
|
+
pass
|
73
|
+
else:
|
74
|
+
raise Exception("Invalid Constructor Called")
|
75
|
+
|
76
|
+
self.cam = camera
|
77
|
+
self.prop = props
|
78
|
+
self.setMinTargetAreaPixels(PhotonCameraSim.kDefaultMinAreaPx)
|
79
|
+
|
80
|
+
# TODO Check fps is right
|
81
|
+
self.videoSimRaw = cs.CvSource(
|
82
|
+
self.cam.getName() + "-raw",
|
83
|
+
cs.VideoMode.PixelFormat.kGray,
|
84
|
+
self.prop.getResWidth(),
|
85
|
+
self.prop.getResHeight(),
|
86
|
+
1,
|
87
|
+
)
|
88
|
+
self.videoSimFrameRaw = np.zeros(
|
89
|
+
(self.prop.getResWidth(), self.prop.getResHeight())
|
90
|
+
)
|
91
|
+
|
92
|
+
# TODO Check fps is right
|
93
|
+
self.videoSimProcessed = cs.CvSource(
|
94
|
+
self.cam.getName() + "-processed",
|
95
|
+
cs.VideoMode.PixelFormat.kGray,
|
96
|
+
self.prop.getResWidth(),
|
97
|
+
self.prop.getResHeight(),
|
98
|
+
1,
|
99
|
+
)
|
100
|
+
self.videoSimFrameProcessed = np.zeros(
|
101
|
+
(self.prop.getResWidth(), self.prop.getResHeight())
|
102
|
+
)
|
103
|
+
|
104
|
+
self.ts.subTable = self.cam._cameraTable
|
105
|
+
self.ts.updateEntries()
|
106
|
+
|
107
|
+
# Handle this last explicitly for this function signature because the other constructor is called in the initialiser list
|
108
|
+
if (
|
109
|
+
camera is not None
|
110
|
+
and props is not None
|
111
|
+
and minTargetAreaPercent is not None
|
112
|
+
and maxSightRange is not None
|
113
|
+
):
|
114
|
+
self.minTargetAreaPercent = minTargetAreaPercent
|
115
|
+
self.maxSightRange = maxSightRange
|
116
|
+
|
117
|
+
def getCamera(self) -> PhotonCamera:
|
118
|
+
return self.cam
|
119
|
+
|
120
|
+
def getMinTargetAreaPercent(self) -> float:
|
121
|
+
return self.minTargetAreaPercent
|
122
|
+
|
123
|
+
def getMinTargetAreaPixels(self) -> float:
|
124
|
+
return self.minTargetAreaPercent / 100.0 * self.prop.getResArea()
|
125
|
+
|
126
|
+
def getMaxSightRange(self) -> meters:
|
127
|
+
return self.maxSightRange
|
128
|
+
|
129
|
+
def getVideoSimRaw(self) -> cs.CvSource:
|
130
|
+
return self.videoSimRaw
|
131
|
+
|
132
|
+
def getVideoSimFrameRaw(self) -> np.ndarray:
|
133
|
+
return self.videoSimFrameRaw
|
134
|
+
|
135
|
+
def canSeeTargetPose(self, camPose: Pose3d, target: VisionTargetSim) -> bool:
|
136
|
+
rel = CameraTargetRelation(camPose, target.getPose())
|
137
|
+
return (
|
138
|
+
(
|
139
|
+
abs(rel.camToTargYaw.degrees())
|
140
|
+
< self.prop.getHorizFOV().degrees() / 2.0
|
141
|
+
and abs(rel.camToTargPitch.degrees())
|
142
|
+
< self.prop.getVertFOV().degrees() / 2.0
|
143
|
+
)
|
144
|
+
and (
|
145
|
+
not target.getModel().getIsPlanar()
|
146
|
+
or abs(rel.targtoCamAngle.degrees()) < 90
|
147
|
+
)
|
148
|
+
and rel.camToTarg.translation().norm() <= self.maxSightRange
|
149
|
+
)
|
150
|
+
|
151
|
+
def canSeeCorner(self, points: np.ndarray) -> bool:
|
152
|
+
assert points.shape[1] == 1
|
153
|
+
assert points.shape[2] == 2
|
154
|
+
for pt in points:
|
155
|
+
x = pt[0, 0]
|
156
|
+
y = pt[0, 1]
|
157
|
+
if (
|
158
|
+
x < 0
|
159
|
+
or x > self.prop.getResWidth()
|
160
|
+
or y < 0
|
161
|
+
or y > self.prop.getResHeight()
|
162
|
+
):
|
163
|
+
return False
|
164
|
+
|
165
|
+
return True
|
166
|
+
|
167
|
+
def consumeNextEntryTime(self) -> float | None:
|
168
|
+
now = int(wpilib.Timer.getFPGATimestamp() * 1e6)
|
169
|
+
timestamp = 0
|
170
|
+
iter = 0
|
171
|
+
while now >= self.nextNtEntryTime:
|
172
|
+
timestamp = int(self.nextNtEntryTime)
|
173
|
+
frameTime = int(self.prop.estSecUntilNextFrame() * 1e6)
|
174
|
+
self.nextNtEntryTime += frameTime
|
175
|
+
|
176
|
+
iter += 1
|
177
|
+
if iter > 50:
|
178
|
+
timestamp = now
|
179
|
+
self.nextNtEntryTime = now + frameTime
|
180
|
+
break
|
181
|
+
|
182
|
+
if timestamp != 0:
|
183
|
+
return timestamp
|
184
|
+
|
185
|
+
return None
|
186
|
+
|
187
|
+
def setMinTargetAreaPercent(self, areaPercent: float) -> None:
|
188
|
+
self.minTargetAreaPercent = areaPercent
|
189
|
+
|
190
|
+
def setMinTargetAreaPixels(self, areaPx: float) -> None:
|
191
|
+
self.minTargetAreaPercent = areaPx / self.prop.getResArea() * 100.0
|
192
|
+
|
193
|
+
def setMaxSightRange(self, range: meters) -> None:
|
194
|
+
self.maxSightRange = range
|
195
|
+
|
196
|
+
def enableRawStream(self, enabled: bool) -> None:
|
197
|
+
raise Exception("Raw stream not implemented")
|
198
|
+
# self.videoSimRawEnabled = enabled
|
199
|
+
|
200
|
+
def enableDrawWireframe(self, enabled: bool) -> None:
|
201
|
+
raise Exception("Wireframe not implemented")
|
202
|
+
# self.videoSimWireframeEnabled = enabled
|
203
|
+
|
204
|
+
def setWireframeResolution(self, resolution: float) -> None:
|
205
|
+
self.videoSimWireframeResolution = resolution
|
206
|
+
|
207
|
+
def enableProcessedStream(self, enabled: bool) -> None:
|
208
|
+
raise Exception("Processed stream not implemented")
|
209
|
+
# self.videoSimProcEnabled = enabled
|
210
|
+
|
211
|
+
def process(
|
212
|
+
self, latency: seconds, cameraPose: Pose3d, targets: list[VisionTargetSim]
|
213
|
+
) -> PhotonPipelineResult:
|
214
|
+
# Sort targets by distance to camera - furthest to closest
|
215
|
+
def distance(target: VisionTargetSim):
|
216
|
+
return target.getPose().translation().distance(cameraPose.translation())
|
217
|
+
|
218
|
+
targets.sort(key=distance, reverse=True)
|
219
|
+
|
220
|
+
visibleTgts: list[
|
221
|
+
typing.Tuple[VisionTargetSim, list[typing.Tuple[float, float]]]
|
222
|
+
] = []
|
223
|
+
detectableTgts: list[PhotonTrackedTarget] = []
|
224
|
+
|
225
|
+
camRt = RotTrlTransform3d.makeRelativeTo(cameraPose)
|
226
|
+
|
227
|
+
for tgt in targets:
|
228
|
+
if not self.canSeeTargetPose(cameraPose, tgt):
|
229
|
+
continue
|
230
|
+
|
231
|
+
fieldCorners = tgt.getFieldVertices()
|
232
|
+
isSpherical = tgt.getModel().getIsSpherical()
|
233
|
+
if isSpherical:
|
234
|
+
model = tgt.getModel()
|
235
|
+
fieldCorners = model.getFieldVertices(
|
236
|
+
TargetModel.getOrientedPose(
|
237
|
+
tgt.getPose().translation(), cameraPose.translation()
|
238
|
+
)
|
239
|
+
)
|
240
|
+
|
241
|
+
imagePoints = OpenCVHelp.projectPoints(
|
242
|
+
self.prop.getIntrinsics(),
|
243
|
+
self.prop.getDistCoeffs(),
|
244
|
+
camRt,
|
245
|
+
fieldCorners,
|
246
|
+
)
|
247
|
+
|
248
|
+
if isSpherical:
|
249
|
+
center = OpenCVHelp.avgPoint(imagePoints)
|
250
|
+
l: int = 0
|
251
|
+
for i in range(4):
|
252
|
+
if imagePoints[i, 0, 0] < imagePoints[l, 0, 0].x:
|
253
|
+
l = i
|
254
|
+
|
255
|
+
lc = imagePoints[l]
|
256
|
+
angles = [
|
257
|
+
0.0,
|
258
|
+
] * 4
|
259
|
+
t = (l + 1) % 4
|
260
|
+
b = (l + 1) % 4
|
261
|
+
for i in range(4):
|
262
|
+
if i == l:
|
263
|
+
continue
|
264
|
+
ic = imagePoints[i]
|
265
|
+
angles[i] = math.atan2(lc[0, 1] - ic[0, 1], ic[0, 0] - lc[0, 0])
|
266
|
+
if angles[i] >= angles[t]:
|
267
|
+
t = i
|
268
|
+
if angles[i] <= angles[b]:
|
269
|
+
b = i
|
270
|
+
for i in range(4):
|
271
|
+
if i != t and i != l and i != b:
|
272
|
+
r = i
|
273
|
+
rect = cv.RotatedRect(
|
274
|
+
center,
|
275
|
+
(
|
276
|
+
imagePoints[r, 0, 0] - lc[0, 0],
|
277
|
+
imagePoints[b, 0, 1] - imagePoints[t, 0, 1],
|
278
|
+
),
|
279
|
+
-angles[r],
|
280
|
+
)
|
281
|
+
imagePoints = rect.points()
|
282
|
+
|
283
|
+
visibleTgts.append((tgt, imagePoints))
|
284
|
+
noisyTargetCorners = self.prop.estPixelNoise(imagePoints)
|
285
|
+
minAreaRect = OpenCVHelp.getMinAreaRect(noisyTargetCorners)
|
286
|
+
minAreaRectPts = minAreaRect.points()
|
287
|
+
centerPt = minAreaRect.center
|
288
|
+
centerRot = self.prop.getPixelRot(centerPt)
|
289
|
+
areaPercent = self.prop.getContourAreaPercent(noisyTargetCorners)
|
290
|
+
|
291
|
+
if (
|
292
|
+
not self.canSeeCorner(noisyTargetCorners)
|
293
|
+
or not areaPercent >= self.minTargetAreaPercent
|
294
|
+
):
|
295
|
+
continue
|
296
|
+
|
297
|
+
pnpSim: PnpResult | None = None
|
298
|
+
if tgt.fiducialId >= 0 and len(tgt.getFieldVertices()) == 4:
|
299
|
+
pnpSim = OpenCVHelp.solvePNP_Square(
|
300
|
+
self.prop.getIntrinsics(),
|
301
|
+
self.prop.getDistCoeffs(),
|
302
|
+
tgt.getModel().getVertices(),
|
303
|
+
noisyTargetCorners,
|
304
|
+
)
|
305
|
+
|
306
|
+
smallVec: list[TargetCorner] = []
|
307
|
+
for corner in minAreaRectPts:
|
308
|
+
smallVec.append(TargetCorner(corner[0], corner[1]))
|
309
|
+
|
310
|
+
cornersFloat = OpenCVHelp.pointsToTargetCorners(noisyTargetCorners)
|
311
|
+
|
312
|
+
detectableTgts.append(
|
313
|
+
PhotonTrackedTarget(
|
314
|
+
yaw=math.degrees(-centerRot.Z()),
|
315
|
+
pitch=math.degrees(-centerRot.Y()),
|
316
|
+
area=areaPercent,
|
317
|
+
skew=math.degrees(centerRot.X()),
|
318
|
+
fiducialId=tgt.fiducialId,
|
319
|
+
detectedCorners=cornersFloat,
|
320
|
+
minAreaRectCorners=smallVec,
|
321
|
+
bestCameraToTarget=pnpSim.best if pnpSim else Transform3d(),
|
322
|
+
altCameraToTarget=pnpSim.alt if pnpSim else Transform3d(),
|
323
|
+
poseAmbiguity=pnpSim.ambiguity if pnpSim else -1,
|
324
|
+
)
|
325
|
+
)
|
326
|
+
|
327
|
+
# Video streams disabled for now
|
328
|
+
if self.enableRawStream:
|
329
|
+
# VideoSimUtil::UpdateVideoProp(videoSimRaw, prop);
|
330
|
+
# cv::Size videoFrameSize{prop.GetResWidth(), prop.GetResHeight()};
|
331
|
+
# cv::Mat blankFrame = cv::Mat::zeros(videoFrameSize, CV_8UC1);
|
332
|
+
# blankFrame.assignTo(videoSimFrameRaw);
|
333
|
+
pass
|
334
|
+
if self.enableProcessedStream:
|
335
|
+
# VideoSimUtil::UpdateVideoProp(videoSimProcessed, prop);
|
336
|
+
pass
|
337
|
+
|
338
|
+
multiTagResults: MultiTargetPNPResult | None = None
|
339
|
+
|
340
|
+
visibleLayoutTags = VisionEstimation.getVisibleLayoutTags(
|
341
|
+
detectableTgts, self.tagLayout
|
342
|
+
)
|
343
|
+
|
344
|
+
if len(visibleLayoutTags) > 1:
|
345
|
+
usedIds = [tag.ID for tag in visibleLayoutTags]
|
346
|
+
usedIds.sort()
|
347
|
+
pnpResult = VisionEstimation.estimateCamPosePNP(
|
348
|
+
self.prop.getIntrinsics(),
|
349
|
+
self.prop.getDistCoeffs(),
|
350
|
+
detectableTgts,
|
351
|
+
self.tagLayout,
|
352
|
+
TargetModel.AprilTag36h11(),
|
353
|
+
)
|
354
|
+
if pnpResult is not None:
|
355
|
+
multiTagResults = MultiTargetPNPResult(pnpResult, usedIds)
|
356
|
+
|
357
|
+
self.heartbeatCounter += 1
|
358
|
+
return PhotonPipelineResult(
|
359
|
+
metadata=PhotonPipelineMetadata(
|
360
|
+
self.heartbeatCounter, int(latency * 1e6), 1000000
|
361
|
+
),
|
362
|
+
targets=detectableTgts,
|
363
|
+
multitagResult=multiTagResults,
|
364
|
+
)
|
365
|
+
|
366
|
+
def submitProcessedFrame(
|
367
|
+
self, result: PhotonPipelineResult, receiveTimestamp: float | None
|
368
|
+
):
|
369
|
+
if receiveTimestamp is None:
|
370
|
+
receiveTimestamp = wpilib.Timer.getFPGATimestamp() * 1e6
|
371
|
+
receiveTimestamp = int(receiveTimestamp)
|
372
|
+
|
373
|
+
self.ts.latencyMillisEntry.set(result.getLatencyMillis(), receiveTimestamp)
|
374
|
+
|
375
|
+
newPacket = PhotonPipelineResult.photonStruct.pack(result)
|
376
|
+
self.ts.rawBytesEntry.set(newPacket.getData(), receiveTimestamp)
|
377
|
+
|
378
|
+
hasTargets = result.hasTargets()
|
379
|
+
self.ts.hasTargetEntry.set(hasTargets, receiveTimestamp)
|
380
|
+
if not hasTargets:
|
381
|
+
self.ts.targetPitchEntry.set(0.0, receiveTimestamp)
|
382
|
+
self.ts.targetYawEntry.set(0.0, receiveTimestamp)
|
383
|
+
self.ts.targetAreaEntry.set(0.0, receiveTimestamp)
|
384
|
+
self.ts.targetPoseEntry.set(Transform3d(), receiveTimestamp)
|
385
|
+
self.ts.targetSkewEntry.set(0.0, receiveTimestamp)
|
386
|
+
else:
|
387
|
+
bestTarget = result.getBestTarget()
|
388
|
+
|
389
|
+
self.ts.targetPitchEntry.set(bestTarget.getPitch(), receiveTimestamp)
|
390
|
+
self.ts.targetYawEntry.set(bestTarget.getYaw(), receiveTimestamp)
|
391
|
+
self.ts.targetAreaEntry.set(bestTarget.getArea(), receiveTimestamp)
|
392
|
+
self.ts.targetSkewEntry.set(bestTarget.getSkew(), receiveTimestamp)
|
393
|
+
|
394
|
+
self.ts.targetPoseEntry.set(
|
395
|
+
bestTarget.getBestCameraToTarget(), receiveTimestamp
|
396
|
+
)
|
397
|
+
|
398
|
+
intrinsics = self.prop.getIntrinsics()
|
399
|
+
intrinsicsView = intrinsics.flatten().tolist()
|
400
|
+
self.ts.cameraIntrinsicsPublisher.set(intrinsicsView, receiveTimestamp)
|
401
|
+
|
402
|
+
distortion = self.prop.getDistCoeffs()
|
403
|
+
distortionView = distortion.flatten().tolist()
|
404
|
+
self.ts.cameraDistortionPublisher.set(distortionView, receiveTimestamp)
|
405
|
+
|
406
|
+
self.ts.heartbeatPublisher.set(self.heartbeatCounter, receiveTimestamp)
|
407
|
+
|
408
|
+
self.ts.subTable.getInstance().flush()
|