Skip to content

Commit

Permalink
Matching cameras by path ID (#1015)
Browse files Browse the repository at this point in the history
Allows multiple cameras of the same model to be used while ensuring they stay tied to the physical camera and not the port. Matching occurs when first connecting cameras so swapping the ports while PV is running will swap the virtual cameras until a restart. Currently only tested on Linux.
  • Loading branch information
MrRedness authored Dec 4, 2023
1 parent 469bc0e commit 6db5bc5
Show file tree
Hide file tree
Showing 3 changed files with 207 additions and 41 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ static class TableKeys {
static final String CAM_UNIQUE_NAME = "unique_name";
static final String CONFIG_JSON = "config_json";
static final String DRIVERMODE_JSON = "drivermode_json";
static final String OTHERPATHS_JSON = "otherpaths_json";
static final String PIPELINE_JSONS = "pipeline_jsons";

static final String NETWORK_CONFIG = "networkConfig";
Expand Down Expand Up @@ -147,6 +148,7 @@ private void initDatabase() {
+ " unique_name TINYTEXT PRIMARY KEY,\n"
+ " config_json text NOT NULL,\n"
+ " drivermode_json text NOT NULL,\n"
+ " otherpaths_json text NOT NULL,\n"
+ " pipeline_jsons mediumtext NOT NULL\n"
+ ");";
createCameraTableStatement.execute(sql);
Expand Down Expand Up @@ -295,8 +297,8 @@ private void saveCameras(Connection conn) {
try {
// Replace this camera's row with the new settings
var sqlString =
"REPLACE INTO cameras (unique_name, config_json, drivermode_json, pipeline_jsons) VALUES "
+ "(?,?,?,?);";
"REPLACE INTO cameras (unique_name, config_json, drivermode_json, otherpaths_json, pipeline_jsons) VALUES "
+ "(?,?,?,?,?);";

for (var c : config.getCameraConfigurations().entrySet()) {
PreparedStatement statement = conn.prepareStatement(sqlString);
Expand All @@ -305,6 +307,7 @@ private void saveCameras(Connection conn) {
statement.setString(1, c.getKey());
statement.setString(2, JacksonUtils.serializeToString(config));
statement.setString(3, JacksonUtils.serializeToString(config.driveModeSettings));
statement.setString(4, JacksonUtils.serializeToString(config.otherPaths));

// Serializing a list of abstract classes sucks. Instead, make it into an array
// of strings, which we can later unpack back into individual settings
Expand All @@ -321,7 +324,7 @@ private void saveCameras(Connection conn) {
})
.filter(Objects::nonNull)
.collect(Collectors.toList());
statement.setString(4, JacksonUtils.serializeToString(settings));
statement.setString(5, JacksonUtils.serializeToString(settings));

statement.executeUpdate();
}
Expand Down Expand Up @@ -455,10 +458,11 @@ private HashMap<String, CameraConfiguration> loadCameraConfigs(Connection conn)
query =
conn.prepareStatement(
String.format(
"SELECT %s, %s, %s, %s FROM cameras",
"SELECT %s, %s, %s, %s, %s FROM cameras",
TableKeys.CAM_UNIQUE_NAME,
TableKeys.CONFIG_JSON,
TableKeys.DRIVERMODE_JSON,
TableKeys.OTHERPATHS_JSON,
TableKeys.PIPELINE_JSONS));

var result = query.executeQuery();
Expand All @@ -474,6 +478,8 @@ private HashMap<String, CameraConfiguration> loadCameraConfigs(Connection conn)
var driverMode =
JacksonUtils.deserialize(
result.getString(TableKeys.DRIVERMODE_JSON), DriverModePipelineSettings.class);
var otherPaths =
JacksonUtils.deserialize(result.getString(TableKeys.OTHERPATHS_JSON), String[].class);
List<?> pipelineSettings =
JacksonUtils.deserialize(
result.getString(TableKeys.PIPELINE_JSONS), dummyList.getClass());
Expand All @@ -487,6 +493,7 @@ private HashMap<String, CameraConfiguration> loadCameraConfigs(Connection conn)

config.pipelineSettings = loadedSettings;
config.driveModeSettings = driverMode;
config.otherPaths = otherPaths;
loadedConfigurations.put(uniqueName, config);
}
} catch (SQLException | IOException e) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,52 +196,112 @@ protected List<VisionSource> tryMatchUSBCamImpl(boolean createSources) {
* @param loadedUsbCamConfigs The USB {@link CameraConfiguration}s loaded from disk.
* @return the matched configurations.
*/
private List<CameraConfiguration> matchUSBCameras(
protected List<CameraConfiguration> matchUSBCameras(
List<UsbCameraInfo> detectedCamInfos, List<CameraConfiguration> loadedUsbCamConfigs) {
var detectedCameraList = new ArrayList<>(detectedCamInfos);
ArrayList<CameraConfiguration> cameraConfigurations = new ArrayList<>();

// loop over all the configs loaded from disk
List<CameraConfiguration> unmatchedAfterByID = new ArrayList<>(loadedUsbCamConfigs);

// loop over all the configs loaded from disk, attempting to match each camera
// to a config by path-by-id on linux
for (CameraConfiguration config : loadedUsbCamConfigs) {
UsbCameraInfo cameraInfo;

// attempt matching by path and basename
logger.debug(
"Trying to find a match for loaded camera "
+ config.baseName
+ " with path "
+ config.path);
cameraInfo =
detectedCameraList.stream()
.filter(
usbCameraInfo ->
usbCameraInfo.path.equals(config.path)
&& cameraNameToBaseName(usbCameraInfo.name).equals(config.baseName))
.findFirst()
.orElse(null);

// if path based fails, attempt basename only match
if (cameraInfo == null) {
logger.debug("Failed to match by path and name, falling back to name-only match");
if (config.otherPaths.length == 0) {
logger.debug("No valid path-by-id found for config with name " + config.baseName);
} else {
// attempt matching by path and basename
logger.debug(
"Trying to find a match for loaded camera "
+ config.baseName
+ " with path-by-id "
+ config.otherPaths[0]);
cameraInfo =
detectedCameraList.stream()
.filter(
usbCameraInfo ->
usbCameraInfo.otherPaths.length != 0
&& usbCameraInfo.otherPaths[0].equals(config.otherPaths[0])
&& cameraNameToBaseName(usbCameraInfo.name).equals(config.baseName))
.findFirst()
.orElse(null);

// If we actually matched a camera to a config, remove that camera from the list
// and add it to the output
if (cameraInfo != null) {
logger.debug("Matched the config for " + config.baseName + " to a physical camera!");
detectedCameraList.remove(cameraInfo);
unmatchedAfterByID.remove(config);
cameraConfigurations.add(mergeInfoIntoConfig(config, cameraInfo));
}
}
}

if (!unmatchedAfterByID.isEmpty() && !detectedCameraList.isEmpty()) {
logger.debug("Match by path-by-id failed, falling back to path-only matching");

List<CameraConfiguration> unmatchedAfterByPath = new ArrayList<>(loadedUsbCamConfigs);

// now attempt to match the cameras and configs remaining by normal path
for (CameraConfiguration config : unmatchedAfterByID) {
UsbCameraInfo cameraInfo;

// attempt matching by path and basename
logger.debug(
"Trying to find a match for loaded camera "
+ config.baseName
+ " with path "
+ config.path);
cameraInfo =
detectedCameraList.stream()
.filter(
usbCameraInfo ->
cameraNameToBaseName(usbCameraInfo.name).equals(config.baseName))
usbCameraInfo.path.equals(config.path)
&& cameraNameToBaseName(usbCameraInfo.name).equals(config.baseName))
.findFirst()
.orElse(null);

// If we actually matched a camera to a config, remove that camera from the list
// and add it to the output
if (cameraInfo != null) {
logger.debug("Matched the config for " + config.baseName + " to a physical camera!");
detectedCameraList.remove(cameraInfo);
unmatchedAfterByPath.remove(config);
cameraConfigurations.add(mergeInfoIntoConfig(config, cameraInfo));
}
}

// If we actually matched a camera to a config, remove that camera from the list and add it to
// the output
if (cameraInfo != null) {
logger.debug("Matched the config for " + config.baseName + " to a physical camera!");
detectedCameraList.remove(cameraInfo);
cameraConfigurations.add(mergeInfoIntoConfig(config, cameraInfo));
if (!unmatchedAfterByPath.isEmpty() && !detectedCameraList.isEmpty()) {
logger.debug("Match by ID and path failed, falling back to name-only matching");

// if both path and ID based matching fails, attempt basename only match
for (CameraConfiguration config : unmatchedAfterByPath) {
UsbCameraInfo cameraInfo;

logger.debug("Trying to find a match for loaded camera with name " + config.baseName);

cameraInfo =
detectedCameraList.stream()
.filter(
usbCameraInfo ->
cameraNameToBaseName(usbCameraInfo.name).equals(config.baseName))
.findFirst()
.orElse(null);

// If we actually matched a camera to a config, remove that camera from the list
// and add it to the output
if (cameraInfo != null) {
logger.debug("Matched the config for " + config.baseName + " to a physical camera!");
detectedCameraList.remove(cameraInfo);
cameraConfigurations.add(mergeInfoIntoConfig(config, cameraInfo));
}
}
}
}

// If we have any unmatched cameras left, create a new CameraConfiguration for them here.
// If we have any unmatched cameras left, create a new CameraConfiguration for
// them here.
logger.debug(
"After matching loaded configs " + detectedCameraList.size() + " cameras were unmatched.");
for (UsbCameraInfo info : detectedCameraList) {
Expand All @@ -250,7 +310,7 @@ && cameraNameToBaseName(usbCameraInfo.name).equals(config.baseName))
String uniqueName = baseNameToUniqueName(baseName);

int suffix = 0;
while (containsName(cameraConfigurations, uniqueName)) {
while (containsName(cameraConfigurations, uniqueName) || containsName(uniqueName)) {
suffix++;
uniqueName = String.format("%s (%d)", uniqueName, suffix);
}
Expand Down Expand Up @@ -283,6 +343,27 @@ private CameraConfiguration mergeInfoIntoConfig(CameraConfiguration cfg, UsbCame
cfg.path = info.path;
}

if (cfg.otherPaths.length != info.otherPaths.length) {
logger.debug(
"Updating otherPath config from "
+ Arrays.toString(cfg.otherPaths)
+ " to "
+ Arrays.toString(info.otherPaths));
cfg.otherPaths = info.otherPaths.clone();
} else {
for (int i = 0; i < info.otherPaths.length; i++) {
if (!cfg.otherPaths[i].equals(info.otherPaths[i])) {
logger.debug(
"Updating otherPath config from "
+ Arrays.toString(cfg.otherPaths)
+ " to "
+ Arrays.toString(info.otherPaths));
cfg.otherPaths = info.otherPaths.clone();
break;
}
}
}

return cfg;
}

Expand All @@ -309,7 +390,7 @@ private List<UsbCameraInfo> filterAllowedDevices(List<UsbCameraInfo> allDevices)

private boolean usbCamEquals(UsbCameraInfo a, UsbCameraInfo b) {
return a.path.equals(b.path)
&& a.dev == b.dev
// && a.dev == b.dev (dev is not constant in Windows)
&& a.name.equals(b.name)
&& a.productId == b.productId
&& a.vendorId == b.vendorId;
Expand Down Expand Up @@ -363,4 +444,15 @@ private boolean containsName(
return configList.stream()
.anyMatch(configuration -> configuration.uniqueName.equals(uniqueName));
}

/**
* Check if the current list of known cameras contains the given unique name.
*
* @param uniqueName The unique name.
* @return If the list of cameras contains the unique name.
*/
private boolean containsName(final String uniqueName) {
return VisionModuleManager.getInstance().getModules().stream()
.anyMatch(camera -> camera.visionSource.cameraConfiguration.uniqueName.equals(uniqueName));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,23 +35,90 @@ public void visionSourceTest() {
ConfigManager.getInstance().load();

inst.tryMatchUSBCamImpl();
var config = new CameraConfiguration("secondTestVideo", "dev/video1");
var config3 =
new CameraConfiguration(
"secondTestVideo",
"secondTestVideo1",
"secondTestVideo1",
"dev/video1",
new String[] {"by-id/123"});
var config4 =
new CameraConfiguration(
"secondTestVideo",
"secondTestVideo2",
"secondTestVideo2",
"dev/video2",
new String[] {"by-id/321"});

UsbCameraInfo info1 = new UsbCameraInfo(0, "dev/video0", "testVideo", new String[0], 1, 2);

infoList.add(info1);

inst.registerLoadedConfigs(config);
var sources = inst.tryMatchUSBCamImpl(false);
inst.registerLoadedConfigs(config3, config4);

inst.tryMatchUSBCamImpl(false);

assertTrue(inst.knownUsbCameras.contains(info1));
assertEquals(1, inst.unmatchedLoadedConfigs.size());
assertEquals(2, inst.unmatchedLoadedConfigs.size());

UsbCameraInfo info2 = new UsbCameraInfo(0, "dev/video1", "testVideo", new String[0], 1, 2);

UsbCameraInfo info2 =
new UsbCameraInfo(0, "dev/video1", "secondTestVideo", new String[0], 2, 1);
infoList.add(info2);

var cams = inst.matchUSBCameras(infoList, inst.unmatchedLoadedConfigs);

// assertEquals("testVideo (1)", cams.get(0).uniqueName); // Proper suffixing

inst.tryMatchUSBCamImpl(false);

assertTrue(inst.knownUsbCameras.contains(info2));
assertEquals(2, inst.unmatchedLoadedConfigs.size());

UsbCameraInfo info3 =
new UsbCameraInfo(0, "dev/video2", "secondTestVideo", new String[] {"by-id/123"}, 2, 1);

UsbCameraInfo info4 =
new UsbCameraInfo(0, "dev/video3", "secondTestVideo", new String[] {"by-id/321"}, 3, 1);

infoList.add(info4);

cams = inst.matchUSBCameras(infoList, inst.unmatchedLoadedConfigs);

var cam4 =
cams.stream()
.filter(
cam -> cam.otherPaths.length > 0 && cam.otherPaths[0].equals(config4.otherPaths[0]))
.findFirst()
.orElse(null);
// If this is null, cam4 got matched to config3 instead of config4

assertEquals(cam4.nickname, config4.nickname);

infoList.add(info3);

cams = inst.matchUSBCameras(infoList, inst.unmatchedLoadedConfigs);

inst.tryMatchUSBCamImpl(false);

assertTrue(inst.knownUsbCameras.contains(info2));
assertEquals(2, inst.knownUsbCameras.size());
assertTrue(inst.knownUsbCameras.contains(info3));

var cam3 =
cams.stream()
.filter(
cam -> cam.otherPaths.length > 0 && cam.otherPaths[0].equals(config3.otherPaths[0]))
.findFirst()
.orElse(null);
cam4 =
cams.stream()
.filter(
cam -> cam.otherPaths.length > 0 && cam.otherPaths[0].equals(config4.otherPaths[0]))
.findFirst()
.orElse(null);

assertEquals(cam3.nickname, config3.nickname);
assertEquals(cam4.nickname, config4.nickname);
assertEquals(4, inst.knownUsbCameras.size());
assertEquals(0, inst.unmatchedLoadedConfigs.size());
}
}

0 comments on commit 6db5bc5

Please sign in to comment.