Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions photon-lib/py/buildAndTest.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
#!/usr/bin/env bash

set -euo pipefail
cd -- "$(dirname -- "$0")"

# Uninstall if it already was installed
python3 -m pip uninstall -y photonlibpy

# Build wheel
python3 -m pip install wheel
python3 setup.py bdist_wheel

# Install whatever wheel was made
Expand Down
2 changes: 1 addition & 1 deletion photon-lib/py/photonlibpy/photonPoseEstimator.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,7 +229,7 @@ def _invalidatePoseCache(self) -> None:
self._poseCacheTimestampSeconds = -1.0

def _checkUpdate(self, oldObj, newObj) -> None:
if oldObj != newObj and oldObj is not None and oldObj is not newObj:
if oldObj != newObj:
self._invalidatePoseCache()

def addHeadingData(
Expand Down
Empty file.
66 changes: 46 additions & 20 deletions photon-lib/py/test/photonPoseEstimator_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@
## along with this program. If not, see <https://www.gnu.org/licenses/>.
###############################################################################

from test import testUtil

import wpimath.units
from photonlibpy import PhotonCamera, PhotonPoseEstimator, PoseStrategy
from photonlibpy.estimation import TargetModel
Expand Down Expand Up @@ -191,7 +193,7 @@ def test_pnpDistanceTrigSolve():
assert bestTarget.fiducialId == 0
assert result.ntReceiveTimestampMicros > 0
# Make test independent of the FPGA time.
result.ntReceiveTimestampMicros = fakeTimestampSecs * 1e6
result.ntReceiveTimestampMicros = int(fakeTimestampSecs * 1e6)

estimator.addHeadingData(
result.getTimestampSeconds(), realPose.rotation().toRotation2d()
Expand All @@ -217,7 +219,7 @@ def test_pnpDistanceTrigSolve():
assert bestTarget.fiducialId == 0
assert result.ntReceiveTimestampMicros > 0
# Make test independent of the FPGA time.
result.ntReceiveTimestampMicros = fakeTimestampSecs * 1e6
result.ntReceiveTimestampMicros = int(fakeTimestampSecs * 1e6)

estimator.addHeadingData(
result.getTimestampSeconds(), realPose.rotation().toRotation2d()
Expand Down Expand Up @@ -289,8 +291,36 @@ def test_multiTagOnCoprocStrategy():
def test_cacheIsInvalidated():
aprilTags = fakeAprilTagFieldLayout()
cameraOne = PhotonCameraInjector()

estimator = PhotonPoseEstimator(
aprilTags, PoseStrategy.LOWEST_AMBIGUITY, cameraOne, Transform3d()
)

# Initial state, expect no timestamp.
assertEquals(-1, estimator._poseCacheTimestampSeconds)

# First result is 17s after epoch start.
timestamps = testUtil.PipelineTimestamps(captureTimestampMicros=17_000_000)
latencySecs = timestamps.pipelineLatencySecs()

# No targets, expect empty result
cameraOne.result = PhotonPipelineResult(
timestamps.receiveTimestampMicros(),
metadata=timestamps.toPhotonPipelineMetadata(),
)
estimatedPose = estimator.update()

assert estimatedPose is None
assertEquals(
timestamps.receiveTimestampMicros() * 1e-6 - latencySecs,
estimator._poseCacheTimestampSeconds,
1e-3,
)

# Set actual result
timestamps.incrementTimeMicros(2_500_000)
result = PhotonPipelineResult(
int(20 * 1e6),
timestamps.receiveTimestampMicros(),
[
PhotonTrackedTarget(
3.0,
Expand All @@ -315,31 +345,21 @@ def test_cacheIsInvalidated():
0.7,
)
],
metadata=PhotonPipelineMetadata(0, int(2 * 1e3), 0),
)

estimator = PhotonPoseEstimator(
aprilTags, PoseStrategy.LOWEST_AMBIGUITY, cameraOne, Transform3d()
metadata=timestamps.toPhotonPipelineMetadata(),
)

# Empty result, expect empty result
cameraOne.result = PhotonPipelineResult(0)
estimatedPose = estimator.update()
assert estimatedPose is None

# Set actual result
cameraOne.result = result
estimatedPose = estimator.update()
assert estimatedPose is not None
assertEquals(20, estimatedPose.timestampSeconds, 0.01)
assertEquals(20 - 2e-3, estimator._poseCacheTimestampSeconds, 1e-3)
expectedTimestamp = timestamps.receiveTimestampMicros() * 1e-6 - latencySecs
assertEquals(expectedTimestamp, estimatedPose.timestampSeconds, 1e-3)
assertEquals(expectedTimestamp, estimator._poseCacheTimestampSeconds, 1e-3)

# And again -- pose cache should mean this is empty
cameraOne.result = result
estimatedPose = estimator.update()
assert estimatedPose is None
# Expect the old timestamp to still be here
assertEquals(20 - 2e-3, estimator._poseCacheTimestampSeconds, 1e-3)
assertEquals(expectedTimestamp, estimator._poseCacheTimestampSeconds, 1e-3)

# Set new field layout -- right after, the pose cache timestamp should be -1
estimator.fieldTags = AprilTagFieldLayout([AprilTag()], 0, 0)
Expand All @@ -350,8 +370,14 @@ def test_cacheIsInvalidated():

assert estimatedPose is not None

assertEquals(20, estimatedPose.timestampSeconds, 0.01)
assertEquals(20 - 2e-3, estimator._poseCacheTimestampSeconds, 1e-3)
assertEquals(expectedTimestamp, estimatedPose.timestampSeconds, 1e-3)
assertEquals(expectedTimestamp, estimator._poseCacheTimestampSeconds, 1e-3)

# Setting a value from None to a non-None should invalidate the cache.
assert estimator.referencePose is None
estimator.referencePose = Pose3d(3, 3, 3, Rotation3d())

assertEquals(-1, estimator._poseCacheTimestampSeconds)


def assertEquals(expected, actual, epsilon=0.0):
Expand Down
65 changes: 65 additions & 0 deletions photon-lib/py/test/testUtil.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Test utilities."""

from photonlibpy.targeting import PhotonPipelineMetadata


class InvalidTestDataException(ValueError):
pass


class PipelineTimestamps:
"""Helper class to ensure timestamps are positive."""

def __init__(
self,
*,
captureTimestampMicros: int,
pipelineLatencyMicros=2_000,
receiveLatencyMicros=1_000,
):
if captureTimestampMicros < 0:
raise InvalidTestDataException("captureTimestampMicros cannot be negative")
if pipelineLatencyMicros <= 0:
raise InvalidTestDataException("pipelineLatencyMicros must be positive")
if receiveLatencyMicros < 0:
raise InvalidTestDataException("receiveLatencyMicros cannot be negative")
self._captureTimestampMicros = captureTimestampMicros
self._pipelineLatencyMicros = pipelineLatencyMicros
self._receiveLatencyMicros = receiveLatencyMicros
self._sequenceID = 0

@property
def captureTimestampMicros(self) -> int:
return self._captureTimestampMicros

@captureTimestampMicros.setter
def captureTimestampMicros(self, micros: int) -> None:
if micros < 0:
raise InvalidTestDataException("captureTimestampMicros cannot be negative")
if micros < self._captureTimestampMicros:
raise InvalidTestDataException("time cannot go backwards")
self._captureTimestampMicros = micros
self._sequenceID += 1

@property
def pipelineLatencyMicros(self) -> int:
return self._pipelineLatencyMicros

def pipelineLatencySecs(self) -> float:
return self.pipelineLatencyMicros * 1e-6

def incrementTimeMicros(self, micros: int) -> None:
self.captureTimestampMicros += micros

def publishTimestampMicros(self) -> int:
return self._captureTimestampMicros + self.pipelineLatencyMicros

def receiveTimestampMicros(self) -> int:
return self.publishTimestampMicros() + self._receiveLatencyMicros

def toPhotonPipelineMetadata(self) -> PhotonPipelineMetadata:
return PhotonPipelineMetadata(
captureTimestampMicros=self.captureTimestampMicros,
publishTimestampMicros=self.publishTimestampMicros(),
sequenceID=self._sequenceID,
)
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,7 @@
import edu.wpi.first.math.numbers.N3;
import edu.wpi.first.math.numbers.N8;
import edu.wpi.first.wpilibj.DriverStation;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import java.util.*;
import org.photonvision.estimation.TargetModel;
import org.photonvision.estimation.VisionEstimation;
import org.photonvision.targeting.PhotonPipelineResult;
Expand Down Expand Up @@ -175,7 +171,7 @@ private void invalidatePoseCache() {
}

private void checkUpdate(Object oldObj, Object newObj) {
if (oldObj != newObj && oldObj != null && !oldObj.equals(newObj)) {
if (!Objects.equals(oldObj, newObj)) {
invalidatePoseCache();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.junit.jupiter.api.Assertions.fail;

Expand All @@ -38,11 +39,13 @@
import edu.wpi.first.math.MatBuilder;
import edu.wpi.first.math.Nat;
import edu.wpi.first.math.VecBuilder;
import edu.wpi.first.math.geometry.Pose2d;
import edu.wpi.first.math.geometry.Pose3d;
import edu.wpi.first.math.geometry.Quaternion;
import edu.wpi.first.math.geometry.Rotation2d;
import edu.wpi.first.math.geometry.Rotation3d;
import edu.wpi.first.math.geometry.Transform3d;
import edu.wpi.first.math.geometry.Translation2d;
import edu.wpi.first.math.geometry.Translation3d;
import edu.wpi.first.math.util.Units;
import java.io.IOException;
Expand Down Expand Up @@ -592,8 +595,8 @@ void cacheIsInvalidated() {
var result =
new PhotonPipelineResult(
0,
20000000,
1100000,
20_000_000,
1_100_000,
1024,
List.of(
new PhotonTrackedTarget(
Expand Down Expand Up @@ -624,6 +627,9 @@ void cacheIsInvalidated() {
PoseStrategy.AVERAGE_BEST_TARGETS,
new Transform3d(new Translation3d(0, 0, 0), new Rotation3d()));

// Initial state, expect no timestamp
assertEquals(-1, estimator.poseCacheTimestampSeconds);

// Empty result, expect empty result
cameraOne.result = new PhotonPipelineResult();
cameraOne.result.metadata.captureTimestampMicros = (long) (1 * 1e6);
Expand Down Expand Up @@ -652,6 +658,12 @@ void cacheIsInvalidated() {
estimatedPose = estimator.update(cameraOne.result);
assertEquals(20, estimatedPose.get().timestampSeconds, .01);
assertEquals(20, estimator.poseCacheTimestampSeconds);

// Setting a value from None to a non-None should invalidate the cache
assertNull(estimator.getReferencePose());
assertEquals(20, estimator.poseCacheTimestampSeconds);
estimator.setReferencePose(new Pose2d(new Translation2d(1, 2), Rotation2d.kZero));
assertEquals(-1, estimator.poseCacheTimestampSeconds, "wtf");
}

@Test
Expand Down
31 changes: 30 additions & 1 deletion photon-lib/src/test/native/cpp/PhotonPoseEstimatorTest.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -412,12 +412,41 @@ TEST(PhotonPoseEstimatorTest, PoseCache) {
EXPECT_NEAR((15_s - 3_ms).to<double>(),
estimatedPose.value().timestamp.to<double>(), 1e-6);

// And again -- now pose cache should be empty
// And again -- pose cache should result in returning std::nullopt
for (const auto& result : cameraOne.GetAllUnreadResults()) {
estimatedPose = estimator.Update(result);
}

EXPECT_FALSE(estimatedPose);

// If the camera produces a result that is > 1 micro second later,
// the pose cache should not be hit.
cameraOne.testResult[0].SetReceiveTimestamp(units::second_t(16));
for (const auto& result : cameraOne.GetAllUnreadResults()) {
estimatedPose = estimator.Update(result);
}

EXPECT_NEAR((16_s - 3_ms).to<double>(),
estimatedPose.value().timestamp.to<double>(), 1e-6);

// And again -- pose cache should result in returning std::nullopt
for (const auto& result : cameraOne.GetAllUnreadResults()) {
estimatedPose = estimator.Update(result);
}

EXPECT_FALSE(estimatedPose);

// Setting ReferencePose should also clear the cache
estimator.SetReferencePose(frc::Pose3d(units::meter_t(1), units::meter_t(2),
units::meter_t(3), frc::Rotation3d()));

for (const auto& result : cameraOne.GetAllUnreadResults()) {
estimatedPose = estimator.Update(result);
}

ASSERT_TRUE(estimatedPose);
EXPECT_NEAR((16_s - 3_ms).to<double>(),
estimatedPose.value().timestamp.to<double>(), 1e-6);
}

TEST(PhotonPoseEstimatorTest, MultiTagOnRioFallback) {
Expand Down
Loading