mirror of
https://github.com/PhotonVision/photonvision
synced 2026-06-29 02:21:41 +00:00
Add camera mismatch banner to dashboard (#1921)
## Description Detects if a camera mismatch is present in any camera and displays a banner in the dashboard for better visibility to the user. All detection occurs in the backend, and is sent to the frontend via use of a mismatch boolean included in each vision module. <img width="1235" alt="image" src="https://github.com/user-attachments/assets/19219a56-c366-4c56-8c4b-cb5a36fe4a04" /> Closes #1920 ## Meta Merge checklist: - [x] Pull Request title is [short, imperative summary](https://cbea.ms/git-commit/) of proposed changes - [x] The description documents the _what_ and _why_ - [ ] If this PR changes behavior or adds a feature, user documentation is updated - [ ] If this PR touches photon-serde, all messages have been regenerated and hashes have not changed unexpectedly - [x] If this PR touches configuration, this is backwards compatible with settings back to v2024.3.1 - [x] If this PR touches pipeline settings or anything related to data exchange, the frontend typing is updated - [ ] If this PR addresses a bug, a regression test for it is added --------- Co-authored-by: Sam Freund <techguy763@gmail.com> Co-authored-by: samfreund <samf.236@proton.me> Co-authored-by: Matt Morley <matthew.morley.ca@gmail.com>
This commit is contained in:
@@ -70,6 +70,8 @@ public class NetworkTablesManager {
|
||||
// Creating the alert up here since it should be persistent
|
||||
private final Alert conflictAlert = new Alert("PhotonAlerts", "", AlertType.kWarning);
|
||||
|
||||
private final Alert mismatchAlert = new Alert("PhotonAlerts", "", AlertType.kWarning);
|
||||
|
||||
public boolean conflictingHostname = false;
|
||||
public String conflictingCameras = "";
|
||||
private String currentMacAddress;
|
||||
@@ -95,6 +97,7 @@ public class NetworkTablesManager {
|
||||
|
||||
// This should start as false, since we don't know if there's a conflict yet
|
||||
conflictAlert.set(false);
|
||||
mismatchAlert.set(false);
|
||||
|
||||
// Get the UI state in sync with the backend. NT should fire a callback when it
|
||||
// first connects to the robot
|
||||
@@ -115,6 +118,14 @@ public class NetworkTablesManager {
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public void setMismatchAlert(boolean on, String message) {
|
||||
if (mismatchAlert != null) {
|
||||
mismatchAlert.set(on);
|
||||
mismatchAlert.setText(message);
|
||||
SmartDashboard.updateValues();
|
||||
}
|
||||
}
|
||||
|
||||
private void logNtMessage(NetworkTableEvent event) {
|
||||
String levelmsg = "DEBUG";
|
||||
LogLevel pvlevel = LogLevel.DEBUG;
|
||||
|
||||
@@ -52,6 +52,7 @@ public class UICameraConfiguration {
|
||||
public double minWhiteBalanceTemp;
|
||||
public double maxWhiteBalanceTemp;
|
||||
public PVCameraInfo matchedCameraInfo;
|
||||
public boolean mismatch;
|
||||
|
||||
// Status for if the underlying device is present and such
|
||||
public boolean isConnected;
|
||||
|
||||
@@ -26,6 +26,7 @@ import com.fasterxml.jackson.annotation.JsonTypeInfo;
|
||||
import com.fasterxml.jackson.annotation.JsonTypeName;
|
||||
import edu.wpi.first.cscore.UsbCameraInfo;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
|
||||
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, include = JsonTypeInfo.As.WRAPPER_OBJECT)
|
||||
@JsonIgnoreProperties(ignoreUnknown = true)
|
||||
@@ -70,8 +71,15 @@ public sealed interface PVCameraInfo {
|
||||
|
||||
CameraType type();
|
||||
|
||||
/**
|
||||
* Default equals implementation that delegates to the implementing class's equals method. This
|
||||
* method checks type compatibility first, then delegates to the actual implementation.
|
||||
*/
|
||||
default boolean equals(PVCameraInfo other) {
|
||||
return uniquePath().equals(other.uniquePath());
|
||||
if (other == null) return false;
|
||||
if (this.type() != other.type()) return false;
|
||||
// Delegate to the actual equals(Object) implementation of this instance
|
||||
return this.equals((Object) other);
|
||||
}
|
||||
|
||||
@JsonTypeName("PVUsbCameraInfo")
|
||||
@@ -125,7 +133,17 @@ public sealed interface PVCameraInfo {
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) return true;
|
||||
if (obj == null) return false;
|
||||
return obj instanceof PVCameraInfo info && equals(info);
|
||||
if (!(obj instanceof PVUsbCameraInfo info)) return false;
|
||||
|
||||
return super.name.equals(info.name)
|
||||
&& super.vendorId == info.vendorId
|
||||
&& super.productId == info.productId
|
||||
&& uniquePath().equals(info.uniquePath());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(super.name, super.vendorId, super.productId, uniquePath());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -191,7 +209,14 @@ public sealed interface PVCameraInfo {
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) return true;
|
||||
if (obj == null) return false;
|
||||
return obj instanceof PVCameraInfo info && equals(info);
|
||||
if (!(obj instanceof PVCSICameraInfo info)) return false;
|
||||
|
||||
return baseName.equals(info.baseName) && path.equals(info.path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(baseName, path);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -248,7 +273,14 @@ public sealed interface PVCameraInfo {
|
||||
public boolean equals(Object obj) {
|
||||
if (this == obj) return true;
|
||||
if (obj == null) return false;
|
||||
return obj instanceof PVFileCameraInfo info && equals(info);
|
||||
if (!(obj instanceof PVFileCameraInfo info)) return false;
|
||||
|
||||
return name.equals(info.name) && path.equals(info.path);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(name, path);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
||||
@@ -93,6 +93,8 @@ public class VisionModule {
|
||||
MJPGFrameConsumer inputVideoStreamer;
|
||||
MJPGFrameConsumer outputVideoStreamer;
|
||||
|
||||
boolean mismatch;
|
||||
|
||||
public VisionModule(PipelineManager pipelineManager, VisionSource visionSource) {
|
||||
logger =
|
||||
new Logger(
|
||||
@@ -100,6 +102,8 @@ public class VisionModule {
|
||||
visionSource.getSettables().getConfiguration().nickname,
|
||||
LogGroup.VisionModule);
|
||||
|
||||
mismatch = false;
|
||||
|
||||
cameraQuirks = visionSource.getCameraConfiguration().cameraQuirks;
|
||||
|
||||
if (visionSource.getCameraConfiguration().cameraQuirks == null)
|
||||
@@ -568,6 +572,8 @@ public class VisionModule {
|
||||
|
||||
ret.deactivated = config.deactivated;
|
||||
|
||||
ret.mismatch = this.mismatch;
|
||||
|
||||
// TODO refactor into helper method
|
||||
var temp = new HashMap<Integer, HashMap<String, Object>>();
|
||||
var videoModes = visionSource.getSettables().getAllVideoModes();
|
||||
|
||||
@@ -31,6 +31,7 @@ import org.photonvision.common.configuration.CameraConfiguration;
|
||||
import org.photonvision.common.configuration.ConfigManager;
|
||||
import org.photonvision.common.dataflow.DataChangeService;
|
||||
import org.photonvision.common.dataflow.events.OutgoingUIEvent;
|
||||
import org.photonvision.common.dataflow.networktables.NetworkTablesManager;
|
||||
import org.photonvision.common.dataflow.websocket.UICameraConfiguration;
|
||||
import org.photonvision.common.dataflow.websocket.UIPhotonConfiguration;
|
||||
import org.photonvision.common.hardware.Platform;
|
||||
@@ -311,16 +312,118 @@ public class VisionSourceManager {
|
||||
.forEach(cameraInfos::add);
|
||||
}
|
||||
|
||||
// FileVisionSources are a bit quirky. They aren't enumerated by the above, but i still want my
|
||||
// FileVisionSources are a bit quirky. They aren't enumerated by the above, but I still want my
|
||||
// UI to look like it ought to work
|
||||
vmm.getModules().stream()
|
||||
.map(it -> it.getCameraConfiguration().matchedCameraInfo)
|
||||
.filter(info -> info instanceof PVCameraInfo.PVFileCameraInfo)
|
||||
.forEach(cameraInfos::add);
|
||||
|
||||
checkMismatches(cameraInfos);
|
||||
|
||||
return cameraInfos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check for mismatches between connected cameras and saved camera configurations.
|
||||
*
|
||||
* <p>Note that if the information for a camera spontaneously changes without it being
|
||||
* disconnected/unplugged and reconnected/replugged, we may experience unexpected behavior.
|
||||
*
|
||||
* @param cameraInfos List of currently connected camera infos, checked against saved configs
|
||||
*/
|
||||
protected void checkMismatches(List<PVCameraInfo> cameraInfos) {
|
||||
// from the listed physical camera infos, match them to the camera configs and check for
|
||||
// mismatches
|
||||
for (VisionModule module : vmm.getModules()) {
|
||||
PVCameraInfo matchedCameraInfo = module.getCameraConfiguration().matchedCameraInfo;
|
||||
// We use unique paths to determine if the module has a camera in the port. If no unique path
|
||||
// is found that matches the module, it's removed from the mismatched set as a disconnected
|
||||
// camera cannot be mismatched.
|
||||
if (!cameraInfos.stream()
|
||||
.map(PVCameraInfo::uniquePath)
|
||||
.toList()
|
||||
.contains(matchedCameraInfo.uniquePath())) {
|
||||
module.mismatch = false;
|
||||
continue;
|
||||
}
|
||||
|
||||
for (PVCameraInfo info : cameraInfos) {
|
||||
// if the unique path doesn't match, skip cause it's not in the same port
|
||||
if (!matchedCameraInfo.uniquePath().equals(info.uniquePath())) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// If the camera info doesn't match, log an error
|
||||
if (!matchedCameraInfo.equals(info) && !module.mismatch) {
|
||||
logger.error("Camera mismatch error!");
|
||||
logger.error("Camera config mismatch for " + matchedCameraInfo.name());
|
||||
logCameraInfoDiff(matchedCameraInfo, info);
|
||||
module.mismatch = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Set the NetworkTables mismatch alert
|
||||
if (vmm.getModules().stream().anyMatch(m -> m.mismatch)) {
|
||||
NetworkTablesManager.getInstance()
|
||||
.setMismatchAlert(
|
||||
true,
|
||||
"Camera mismatch error! See logs for details. ("
|
||||
+ vmm.getModules().stream()
|
||||
.filter(m -> m.mismatch)
|
||||
.map(m -> m.getCameraConfiguration().nickname)
|
||||
.toList()
|
||||
.toString()
|
||||
.replaceAll("[\\[\\]()]", "")
|
||||
+ " affected)");
|
||||
} else {
|
||||
NetworkTablesManager.getInstance().setMismatchAlert(false, "");
|
||||
}
|
||||
}
|
||||
|
||||
/** Log the differences between two PVCameraInfo objects. */
|
||||
private static void logCameraInfoDiff(PVCameraInfo saved, PVCameraInfo current) {
|
||||
String expected = "Expected: Name: " + saved.name();
|
||||
String actual = "Actual: Name: " + current.name();
|
||||
if (saved instanceof PVCameraInfo.PVCSICameraInfo savedCsi
|
||||
&& current instanceof PVCameraInfo.PVCSICameraInfo currentCsi) {
|
||||
expected += " Base Name: " + savedCsi.baseName;
|
||||
actual += " Base Name: " + currentCsi.baseName;
|
||||
}
|
||||
|
||||
expected += " Type: " + saved.type().toString();
|
||||
actual += " Type: " + current.type().toString();
|
||||
|
||||
if (saved instanceof PVCameraInfo.PVUsbCameraInfo savedUsb
|
||||
&& current instanceof PVCameraInfo.PVUsbCameraInfo currentUsb) {
|
||||
expected +=
|
||||
" Device Number: "
|
||||
+ savedUsb.dev
|
||||
+ " Vendor ID: "
|
||||
+ savedUsb.vendorId
|
||||
+ " Product ID: "
|
||||
+ savedUsb.productId;
|
||||
actual +=
|
||||
" Device Number: "
|
||||
+ currentUsb.dev
|
||||
+ " Vendor ID: "
|
||||
+ currentUsb.vendorId
|
||||
+ " Product ID: "
|
||||
+ currentUsb.productId;
|
||||
}
|
||||
|
||||
expected += " Path: " + saved.path();
|
||||
actual += " Path: " + current.path();
|
||||
expected += " Unique Path: " + saved.uniquePath();
|
||||
actual += " Unique Path: " + current.uniquePath();
|
||||
expected += " Other Paths: " + Arrays.toString(saved.otherPaths());
|
||||
actual += " Other Paths: " + Arrays.toString(current.otherPaths());
|
||||
|
||||
logger.error(expected);
|
||||
logger.error(actual);
|
||||
}
|
||||
|
||||
private static List<PVCameraInfo> filterAllowedDevices(List<PVCameraInfo> allDevices) {
|
||||
Platform platform = Platform.getCurrentPlatform();
|
||||
ArrayList<PVCameraInfo> filteredDevices = new ArrayList<>();
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
package org.photonvision.vision.processes;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import edu.wpi.first.cscore.UsbCameraInfo;
|
||||
@@ -273,4 +274,55 @@ public class VisionSourceManagerTest {
|
||||
assertEquals(2, vsm.getVsmState().disabledConfigs.size());
|
||||
assertEquals(1, vsm.vmm.getModules().size());
|
||||
}
|
||||
|
||||
@Test
|
||||
public void testMismatch() throws InterruptedException {
|
||||
var vsm = new TestVsm();
|
||||
|
||||
// Create a saved camera configuration that expects a device at /dev/video0 with a name
|
||||
PVCameraInfo savedInfo =
|
||||
PVCameraInfo.fromUsbCameraInfo(
|
||||
new UsbCameraInfo(
|
||||
0, "/dev/video0", "CamA", new String[] {"/dev/v4l/by-path/1"}, 111, 222));
|
||||
CameraConfiguration savedConf = new CameraConfiguration(savedInfo);
|
||||
savedConf.deactivated = false;
|
||||
savedConf.nickname = "SavedCam";
|
||||
|
||||
// Register the saved config so VSM creates a VisionModule
|
||||
vsm.registerLoadedConfigs(List.of(savedConf));
|
||||
|
||||
// Now simulate a connected camera at same uniquePath but with a different name (mismatch)
|
||||
List<PVCameraInfo> currentInfo =
|
||||
List.of(
|
||||
PVCameraInfo.fromUsbCameraInfo(
|
||||
new UsbCameraInfo(
|
||||
0,
|
||||
"/dev/video0",
|
||||
"CamDifferent",
|
||||
new String[] {"/dev/v4l/by-path/1"},
|
||||
111,
|
||||
222)));
|
||||
|
||||
// Trigger state evaluation
|
||||
vsm.checkMismatches(currentInfo);
|
||||
|
||||
// The module should have detected a mismatch
|
||||
assertTrue(vsm.getVisionModules().stream().anyMatch(m -> m.mismatch));
|
||||
|
||||
// Now simulate the device being disconnected
|
||||
currentInfo = List.of();
|
||||
vsm.checkMismatches(currentInfo);
|
||||
|
||||
// Mismatch should be cleared when device is disconnected
|
||||
assertFalse(vsm.getVisionModules().stream().anyMatch(m -> m.mismatch));
|
||||
|
||||
// Test with a matching camera info
|
||||
currentInfo = List.of(savedInfo);
|
||||
vsm.checkMismatches(currentInfo);
|
||||
|
||||
// The mismatch should be cleared
|
||||
assertFalse(vsm.getVisionModules().stream().anyMatch(m -> m.mismatch));
|
||||
|
||||
vsm.teardown();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user