From 7b67f6bebf3e5aacdb9b0df75369c4b5da40b34b Mon Sep 17 00:00:00 2001
From: Mohammad Durrani <46766905+mdurrani808@users.noreply.github.com>
Date: Mon, 15 Jan 2024 22:28:34 -0500
Subject: [PATCH] Add RKNN / Object Detection Pipeline (#1144)
Tested on Orange Pi 5 and Cool Pi 4B. Merge with parts of the OpenCV DNN PR.
Adds support for YOLOv5s models for Rockchip CPUs with a NPU. Right now hard coded to a note model from alex_idk. Very much still incubating and largely untested.
---
.styleguide | 1 +
build.gradle | 2 +
photon-client/index.html | 2 +-
photon-client/package-lock.json | 8 +-
photon-client/package.json | 6 +-
.../dashboard/CameraAndPipelineSelectCard.vue | 12 +-
.../components/dashboard/ConfigOptions.vue | 28 +++-
.../dashboard/tabs/ObjectDetectionTab.vue | 35 +++++
.../components/dashboard/tabs/TargetsTab.vue | 16 ++
.../stores/settings/GeneralSettingsStore.ts | 6 +-
.../src/types/PhotonTrackingTypes.ts | 4 +
photon-client/src/types/PipelineTypes.ts | 32 +++-
photon-client/src/types/SettingTypes.ts | 1 +
photon-client/src/types/WebsocketDataTypes.ts | 3 +-
photon-client/vite.config.ts | 19 +--
photon-core/build.gradle | 4 +-
.../common/configuration/ConfigManager.java | 7 +
.../NeuralNetworkModelManager.java | 98 +++++++++++++
.../configuration/PhotonConfiguration.java | 4 +-
.../dataflow/websocket/UIDataPublisher.java | 1 +
.../common/hardware/Platform.java | 19 ++-
.../org/photonvision/jni/PhotonJNICommon.java | 25 ++--
.../org/photonvision/jni/RknnDetectorJNI.java | 138 ++++++++++++++++++
.../photonvision/mrcal/MrCalJNILoader.java | 28 +++-
.../org/photonvision/vision/pipe/CVPipe.java | 4 +
.../vision/pipe/impl/ArucoDetectionPipe.java | 7 +
.../vision/pipe/impl/Calibrate3dPipe.java | 2 +-
.../impl}/Calibrate3dPipeline.java | 8 +-
.../pipe/impl/NeuralNetworkPipeResult.java | 32 ++++
.../vision/pipe/impl/RknnDetectionPipe.java | 69 +++++++++
.../vision/pipeline/CVPipeline.java | 11 +-
.../vision/pipeline/CVPipelineSettings.java | 3 +-
.../pipeline/ObjectDetectionPipeline.java | 94 ++++++++++++
.../ObjectDetectionPipelineSettings.java | 34 +++++
.../vision/pipeline/PipelineType.java | 5 +-
.../pipeline/result/CVPipelineResult.java | 25 +++-
.../vision/processes/PipelineManager.java | 19 ++-
.../vision/target/TrackedTarget.java | 62 ++++++++
.../vision/pipeline/Calibrate3dPipeTest.java | 1 +
.../src/main/java/org/photonvision/Main.java | 17 ++-
.../src/main/resources/models/labels.txt | 1 +
.../models/note-640-640-yolov5s.rknn | Bin 0 -> 8261246 bytes
42 files changed, 830 insertions(+), 63 deletions(-)
create mode 100644 photon-client/src/components/dashboard/tabs/ObjectDetectionTab.vue
create mode 100644 photon-core/src/main/java/org/photonvision/common/configuration/NeuralNetworkModelManager.java
create mode 100644 photon-core/src/main/java/org/photonvision/jni/RknnDetectorJNI.java
rename photon-core/src/main/java/org/photonvision/vision/{pipeline => pipe/impl}/Calibrate3dPipeline.java (97%)
create mode 100644 photon-core/src/main/java/org/photonvision/vision/pipe/impl/NeuralNetworkPipeResult.java
create mode 100644 photon-core/src/main/java/org/photonvision/vision/pipe/impl/RknnDetectionPipe.java
create mode 100644 photon-core/src/main/java/org/photonvision/vision/pipeline/ObjectDetectionPipeline.java
create mode 100644 photon-core/src/main/java/org/photonvision/vision/pipeline/ObjectDetectionPipelineSettings.java
create mode 100644 photon-server/src/main/resources/models/labels.txt
create mode 100644 photon-server/src/main/resources/models/note-640-640-yolov5s.rknn
diff --git a/.styleguide b/.styleguide
index ff0e63b45..376e3b22c 100644
--- a/.styleguide
+++ b/.styleguide
@@ -18,6 +18,7 @@ modifiableFileExclude {
\.dll$
\.webp$
\.ico$
+ \.rknn$
gradlew
}
diff --git a/build.gradle b/build.gradle
index b70cf7850..6d4dfd9f7 100644
--- a/build.gradle
+++ b/build.gradle
@@ -30,9 +30,11 @@ ext {
joglVersion = "2.4.0-rc-20200307"
javalinVersion = "5.6.2"
photonGlDriverLibVersion = "dev-v2023.1.0-9-g75fc678"
+ rknnVersion = "dev-v2024.0.0-30-g001b5ec"
frcYear = "2024"
mrcalVersion = "dev-v2024.0.0-7-gc976aaa";
+
pubVersion = versionString
isDev = pubVersion.startsWith("dev")
diff --git a/photon-client/index.html b/photon-client/index.html
index 3547e54f0..533f161dd 100644
--- a/photon-client/index.html
+++ b/photon-client/index.html
@@ -1,4 +1,4 @@
-
+
diff --git a/photon-client/package-lock.json b/photon-client/package-lock.json
index a3794eaed..5ce803504 100644
--- a/photon-client/package-lock.json
+++ b/photon-client/package-lock.json
@@ -31,7 +31,7 @@
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.19.2",
"npm-run-all": "^4.1.5",
- "prettier": "^3.1.1",
+ "prettier": "3.2.2",
"sass": "~1.32",
"sass-loader": "^13.3.2",
"terser": "^5.14.2",
@@ -3917,9 +3917,9 @@
}
},
"node_modules/prettier": {
- "version": "3.1.1",
- "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.1.1.tgz",
- "integrity": "sha512-22UbSzg8luF4UuZtzgiUOfcGM8s4tjBv6dJRT7j275NXsy2jb4aJa4NNveul5x4eqlF1wuhuR2RElK71RvmVaw==",
+ "version": "3.2.2",
+ "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.2.2.tgz",
+ "integrity": "sha512-HTByuKZzw7utPiDO523Tt2pLtEyK7OibUD9suEJQrPUCYQqrHr74GGX6VidMrovbf/I50mPqr8j/II6oBAuc5A==",
"dev": true,
"bin": {
"prettier": "bin/prettier.cjs"
diff --git a/photon-client/package.json b/photon-client/package.json
index a69b55602..a2ab77c4c 100644
--- a/photon-client/package.json
+++ b/photon-client/package.json
@@ -27,17 +27,17 @@
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.3.2",
- "@vue/eslint-config-prettier": "^9.0.0",
- "@vue/eslint-config-typescript": "^12.0.0",
- "prettier": "^3.1.1",
"@types/node": "^16.11.45",
"@types/three": "^0.160.0",
"@vitejs/plugin-vue2": "^2.3.1",
+ "@vue/eslint-config-prettier": "^9.0.0",
+ "@vue/eslint-config-typescript": "^12.0.0",
"@vue/tsconfig": "^0.5.1",
"deepmerge": "^4.3.1",
"eslint": "^8.56.0",
"eslint-plugin-vue": "^9.19.2",
"npm-run-all": "^4.1.5",
+ "prettier": "3.2.2",
"sass": "~1.32",
"sass-loader": "^13.3.2",
"terser": "^5.14.2",
diff --git a/photon-client/src/components/dashboard/CameraAndPipelineSelectCard.vue b/photon-client/src/components/dashboard/CameraAndPipelineSelectCard.vue
index cec1db4f9..a42264186 100644
--- a/photon-client/src/components/dashboard/CameraAndPipelineSelectCard.vue
+++ b/photon-client/src/components/dashboard/CameraAndPipelineSelectCard.vue
@@ -7,6 +7,7 @@ import { computed, ref } from "vue";
import PvIcon from "@/components/common/pv-icon.vue";
import PvInput from "@/components/common/pv-input.vue";
import { PipelineType } from "@/types/PipelineTypes";
+import { useSettingsStore } from "@/stores/settings/GeneralSettingsStore";
const changeCurrentCameraIndex = (index: number) => {
useCameraSettingsStore().setCurrentCameraIndex(index, true);
@@ -24,6 +25,8 @@ const changeCurrentCameraIndex = (index: number) => {
case PipelineType.Aruco:
pipelineType.value = WebsocketPipelineType.Aruco;
break;
+ case PipelineType.ObjectDetection:
+ pipelineType.value = WebsocketPipelineType.ObjectDetection;
}
};
@@ -154,6 +157,9 @@ const pipelineTypesWrapper = computed<{ name: string; value: number }[]>(() => {
{ name: "AprilTag", value: WebsocketPipelineType.AprilTag },
{ name: "Aruco", value: WebsocketPipelineType.Aruco }
];
+ if (useSettingsStore().general.rknnSupported) {
+ pipelineTypes.push({ name: "Object Detection", value: WebsocketPipelineType.ObjectDetection });
+ }
if (useCameraSettingsStore().isDriverMode) {
pipelineTypes.push({ name: "Driver Mode", value: WebsocketPipelineType.DriverMode });
@@ -208,6 +214,9 @@ useCameraSettingsStore().$subscribe((mutation, state) => {
case PipelineType.Aruco:
pipelineType.value = WebsocketPipelineType.Aruco;
break;
+ case PipelineType.ObjectDetection:
+ pipelineType.value = WebsocketPipelineType.ObjectDetection;
+ break;
}
});
@@ -354,7 +363,8 @@ useCameraSettingsStore().$subscribe((mutation, state) => {
{ name: 'Reflective', value: WebsocketPipelineType.Reflective },
{ name: 'Colored Shape', value: WebsocketPipelineType.ColoredShape },
{ name: 'AprilTag', value: WebsocketPipelineType.AprilTag },
- { name: 'Aruco', value: WebsocketPipelineType.Aruco }
+ { name: 'Aruco', value: WebsocketPipelineType.Aruco },
+ { name: 'Object Detection', value: WebsocketPipelineType.ObjectDetection }
]"
/>
diff --git a/photon-client/src/components/dashboard/ConfigOptions.vue b/photon-client/src/components/dashboard/ConfigOptions.vue
index 6ed0156f9..c1023f217 100644
--- a/photon-client/src/components/dashboard/ConfigOptions.vue
+++ b/photon-client/src/components/dashboard/ConfigOptions.vue
@@ -8,6 +8,7 @@ import ThresholdTab from "@/components/dashboard/tabs/ThresholdTab.vue";
import ContoursTab from "@/components/dashboard/tabs/ContoursTab.vue";
import AprilTagTab from "@/components/dashboard/tabs/AprilTagTab.vue";
import ArucoTab from "@/components/dashboard/tabs/ArucoTab.vue";
+import ObjectDetectionTab from "@/components/dashboard/tabs/ObjectDetectionTab.vue";
import OutputTab from "@/components/dashboard/tabs/OutputTab.vue";
import TargetsTab from "@/components/dashboard/tabs/TargetsTab.vue";
import PnPTab from "@/components/dashboard/tabs/PnPTab.vue";
@@ -40,6 +41,10 @@ const allTabs = Object.freeze({
tabName: "Aruco",
component: ArucoTab
},
+ objectDetectionTab: {
+ tabName: "Object Detection",
+ component: ObjectDetectionTab
+ },
outputTab: {
tabName: "Output",
component: OutputTab
@@ -75,6 +80,7 @@ const getTabGroups = (): ConfigOption[][] => {
allTabs.contoursTab,
allTabs.apriltagTab,
allTabs.arucoTab,
+ allTabs.objectDetectionTab,
allTabs.outputTab
],
[allTabs.targetsTab, allTabs.pnpTab, allTabs.map3dTab]
@@ -82,14 +88,21 @@ const getTabGroups = (): ConfigOption[][] => {
} else if (lgAndDown) {
return [
[allTabs.inputTab],
- [allTabs.thresholdTab, allTabs.contoursTab, allTabs.apriltagTab, allTabs.arucoTab, allTabs.outputTab],
+ [
+ allTabs.thresholdTab,
+ allTabs.contoursTab,
+ allTabs.apriltagTab,
+ allTabs.arucoTab,
+ allTabs.objectDetectionTab,
+ allTabs.outputTab
+ ],
[allTabs.targetsTab, allTabs.pnpTab, allTabs.map3dTab]
];
} else if (xl) {
return [
[allTabs.inputTab],
[allTabs.thresholdTab],
- [allTabs.contoursTab, allTabs.apriltagTab, allTabs.arucoTab, allTabs.outputTab],
+ [allTabs.contoursTab, allTabs.apriltagTab, allTabs.arucoTab, allTabs.objectDetectionTab, allTabs.outputTab],
[allTabs.targetsTab, allTabs.pnpTab, allTabs.map3dTab]
];
}
@@ -103,17 +116,20 @@ const tabGroups = computed(() => {
const allow3d = useCameraSettingsStore().currentPipelineSettings.solvePNPEnabled;
const isAprilTag = useCameraSettingsStore().currentWebsocketPipelineType === WebsocketPipelineType.AprilTag;
const isAruco = useCameraSettingsStore().currentWebsocketPipelineType === WebsocketPipelineType.Aruco;
+ const isObjectDetection =
+ useCameraSettingsStore().currentWebsocketPipelineType === WebsocketPipelineType.ObjectDetection;
return getTabGroups()
.map((tabGroup) =>
tabGroup.filter(
(tabConfig) =>
!(!allow3d && tabConfig.tabName === "3D") && //Filter out 3D tab any time 3D isn't calibrated
- !((!allow3d || isAprilTag || isAruco) && tabConfig.tabName === "PnP") && //Filter out the PnP config tab if 3D isn't available, or we're doing AprilTags
- !((isAprilTag || isAruco) && tabConfig.tabName === "Threshold") && //Filter out threshold tab if we're doing AprilTags
- !((isAprilTag || isAruco) && tabConfig.tabName === "Contours") && //Filter out contours if we're doing AprilTags
+ !((!allow3d || isAprilTag || isAruco || isObjectDetection) && tabConfig.tabName === "PnP") && //Filter out the PnP config tab if 3D isn't available, or we're doing AprilTags
+ !((isAprilTag || isAruco || isObjectDetection) && tabConfig.tabName === "Threshold") && //Filter out threshold tab if we're doing AprilTags
+ !((isAprilTag || isAruco || isObjectDetection) && tabConfig.tabName === "Contours") && //Filter out contours if we're doing AprilTags
!(!isAprilTag && tabConfig.tabName === "AprilTag") && //Filter out apriltag unless we actually are doing AprilTags
- !(!isAruco && tabConfig.tabName === "Aruco") //Filter out aruco unless we actually are doing Aruco
+ !(!isAruco && tabConfig.tabName === "Aruco") &&
+ !(!isObjectDetection && tabConfig.tabName === "Object Detection") //Filter out aruco unless we actually are doing Aruco
)
)
.filter((it) => it.length); // Remove empty tab groups
diff --git a/photon-client/src/components/dashboard/tabs/ObjectDetectionTab.vue b/photon-client/src/components/dashboard/tabs/ObjectDetectionTab.vue
new file mode 100644
index 000000000..646c1fcb2
--- /dev/null
+++ b/photon-client/src/components/dashboard/tabs/ObjectDetectionTab.vue
@@ -0,0 +1,35 @@
+
+
+
+
+
useCameraSettingsStore().changeCurrentPipelineSetting({ confidence: value }, false)"
+ />
+
+
diff --git a/photon-client/src/components/dashboard/tabs/TargetsTab.vue b/photon-client/src/components/dashboard/tabs/TargetsTab.vue
index 87d32dcb4..1e2caf893 100644
--- a/photon-client/src/components/dashboard/tabs/TargetsTab.vue
+++ b/photon-client/src/components/dashboard/tabs/TargetsTab.vue
@@ -48,6 +48,10 @@ const resetCurrentBuffer = () => {
>
Fiducial ID
+
+ Class |
+ Confidence |
+
Pitch θ° |
Yaw θ° |
@@ -85,6 +89,18 @@ const resetCurrentBuffer = () => {
>
{{ target.fiducialId }}
+
+ {{ useStateStore().currentPipelineResults?.classNames[target.classId] }}
+ |
+
+ {{ target.confidence.toFixed(2) }}
+ |
{{ target.pitch.toFixed(2) }}° |
{{ target.yaw.toFixed(2) }}° |
diff --git a/photon-client/src/stores/settings/GeneralSettingsStore.ts b/photon-client/src/stores/settings/GeneralSettingsStore.ts
index c7fc264df..54b41a58b 100644
--- a/photon-client/src/stores/settings/GeneralSettingsStore.ts
+++ b/photon-client/src/stores/settings/GeneralSettingsStore.ts
@@ -27,7 +27,8 @@ export const useSettingsStore = defineStore("settings", {
gpuAcceleration: undefined,
hardwareModel: undefined,
hardwarePlatform: undefined,
- mrCalWorking: true
+ mrCalWorking: true,
+ rknnSupported: false
},
network: {
ntServerAddress: "",
@@ -99,7 +100,8 @@ export const useSettingsStore = defineStore("settings", {
hardwareModel: data.general.hardwareModel || undefined,
hardwarePlatform: data.general.hardwarePlatform || undefined,
gpuAcceleration: data.general.gpuAcceleration || undefined,
- mrCalWorking: data.general.mrCalWorking
+ mrCalWorking: data.general.mrCalWorking,
+ rknnSupported: data.general.rknnSupported
};
this.lighting = data.lighting;
this.network = data.networkSettings;
diff --git a/photon-client/src/types/PhotonTrackingTypes.ts b/photon-client/src/types/PhotonTrackingTypes.ts
index 4606738bb..596a7ab66 100644
--- a/photon-client/src/types/PhotonTrackingTypes.ts
+++ b/photon-client/src/types/PhotonTrackingTypes.ts
@@ -54,6 +54,8 @@ export interface PhotonTarget {
ambiguity: number;
// -1 if not set
fiducialId: number;
+ confidence: number;
+ classId: number;
// undefined if 3d isn't enabled
pose?: Transform3d;
}
@@ -70,4 +72,6 @@ export interface PipelineResult {
targets: PhotonTarget[];
// undefined if multitag failed or non-tag pipeline
multitagResult?: MultitagResult;
+ // Object detection class names -- empty if not doing object detection
+ classNames: string[];
}
diff --git a/photon-client/src/types/PipelineTypes.ts b/photon-client/src/types/PipelineTypes.ts
index 5c8dd47cb..740572a8d 100644
--- a/photon-client/src/types/PipelineTypes.ts
+++ b/photon-client/src/types/PipelineTypes.ts
@@ -5,7 +5,8 @@ export enum PipelineType {
Reflective = 2,
ColoredShape = 3,
AprilTag = 4,
- Aruco = 5
+ Aruco = 5,
+ ObjectDetection = 6
}
export enum AprilTagFamily {
@@ -281,14 +282,39 @@ export const DefaultArucoPipelineSettings: ArucoPipelineSettings = {
doSingleTargetAlways: false
};
+export interface ObjectDetectionPipelineSettings extends PipelineSettings {
+ pipelineType: PipelineType.ObjectDetection;
+ confidence: number;
+ nms: number;
+ box_thresh: number;
+}
+export type ConfigurableObjectDetectionPipelineSettings = Partial<
+ Omit
+> &
+ ConfigurablePipelineSettings;
+export const DefaultObjectDetectionPipelineSettings: ObjectDetectionPipelineSettings = {
+ ...DefaultPipelineSettings,
+ pipelineType: PipelineType.ObjectDetection,
+ cameraGain: 20,
+ targetModel: TargetModel.InfiniteRechargeHighGoalOuter,
+ ledMode: true,
+ outputShowMultipleTargets: false,
+ cameraExposure: 6,
+ confidence: 0.9,
+ nms: 0.45,
+ box_thresh: 0.25
+};
+
export type ActivePipelineSettings =
| ReflectivePipelineSettings
| ColoredShapePipelineSettings
| AprilTagPipelineSettings
- | ArucoPipelineSettings;
+ | ArucoPipelineSettings
+ | ObjectDetectionPipelineSettings;
export type ActiveConfigurablePipelineSettings =
| ConfigurableReflectivePipelineSettings
| ConfigurableColoredShapePipelineSettings
| ConfigurableAprilTagPipelineSettings
- | ConfigurableArucoPipelineSettings;
+ | ConfigurableArucoPipelineSettings
+ | ConfigurableObjectDetectionPipelineSettings;
diff --git a/photon-client/src/types/SettingTypes.ts b/photon-client/src/types/SettingTypes.ts
index aebdaa920..f9509db18 100644
--- a/photon-client/src/types/SettingTypes.ts
+++ b/photon-client/src/types/SettingTypes.ts
@@ -7,6 +7,7 @@ export interface GeneralSettings {
hardwareModel?: string;
hardwarePlatform?: string;
mrCalWorking: boolean;
+ rknnSupported: boolean;
}
export interface MetricData {
diff --git a/photon-client/src/types/WebsocketDataTypes.ts b/photon-client/src/types/WebsocketDataTypes.ts
index ec74bc0fd..171ff9251 100644
--- a/photon-client/src/types/WebsocketDataTypes.ts
+++ b/photon-client/src/types/WebsocketDataTypes.ts
@@ -101,5 +101,6 @@ export enum WebsocketPipelineType {
Reflective = 0,
ColoredShape = 1,
AprilTag = 2,
- Aruco = 3
+ Aruco = 3,
+ ObjectDetection = 4
}
diff --git a/photon-client/vite.config.ts b/photon-client/vite.config.ts
index 127f388f7..a508c5ec7 100644
--- a/photon-client/vite.config.ts
+++ b/photon-client/vite.config.ts
@@ -10,25 +10,22 @@ export default defineConfig({
plugins: [
Vue2(),
Components({
- resolvers: [
- VuetifyResolver()
- ],
+ resolvers: [VuetifyResolver()],
dts: true,
transformer: "vue2",
- types: [{
- from: "vue-router",
- names: ["RouterLink", "RouterView"]
- }],
+ types: [
+ {
+ from: "vue-router",
+ names: ["RouterLink", "RouterView"]
+ }
+ ],
version: 2.7
})
],
css: {
preprocessorOptions: {
sass: {
- additionalData: [
- "@import \"@/assets/styles/variables.scss\"",
- ""
- ].join("\n")
+ additionalData: ['@import "@/assets/styles/variables.scss"', ""].join("\n")
}
}
},
diff --git a/photon-core/build.gradle b/photon-core/build.gradle
index 8ccde803a..396388a77 100644
--- a/photon-core/build.gradle
+++ b/photon-core/build.gradle
@@ -37,7 +37,9 @@ dependencies {
implementation 'org.zeroturnaround:zt-zip:1.14'
implementation "org.xerial:sqlite-jdbc:3.41.0.0"
-
+ def rknnjniversion = "dev-v2024.0.0-44-g8022c40"
+ implementation "org.photonvision:rknn_jni-jni:$rknnjniversion:linuxarm64"
+ implementation "org.photonvision:rknn_jni-java:$rknnjniversion"
implementation "org.photonvision:photon-libcamera-gl-driver-jni:$photonGlDriverLibVersion:linuxarm64"
implementation "org.photonvision:photon-libcamera-gl-driver-java:$photonGlDriverLibVersion"
diff --git a/photon-core/src/main/java/org/photonvision/common/configuration/ConfigManager.java b/photon-core/src/main/java/org/photonvision/common/configuration/ConfigManager.java
index e162f6aa1..73fd9a4c0 100644
--- a/photon-core/src/main/java/org/photonvision/common/configuration/ConfigManager.java
+++ b/photon-core/src/main/java/org/photonvision/common/configuration/ConfigManager.java
@@ -296,4 +296,11 @@ public class ConfigManager {
}
}
}
+
+ /** Get (and create if not present) the subfolder where ML models are stored */
+ public File getModelsDirectory() {
+ var ret = new File(configDirectoryFile, "models");
+ if (!ret.exists()) ret.mkdirs();
+ return ret;
+ }
}
diff --git a/photon-core/src/main/java/org/photonvision/common/configuration/NeuralNetworkModelManager.java b/photon-core/src/main/java/org/photonvision/common/configuration/NeuralNetworkModelManager.java
new file mode 100644
index 000000000..b8b677c56
--- /dev/null
+++ b/photon-core/src/main/java/org/photonvision/common/configuration/NeuralNetworkModelManager.java
@@ -0,0 +1,98 @@
+/*
+ * Copyright (C) Photon Vision.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.photonvision.common.configuration;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.util.List;
+import org.photonvision.common.logging.LogGroup;
+import org.photonvision.common.logging.Logger;
+
+public class NeuralNetworkModelManager {
+ private static NeuralNetworkModelManager INSTANCE;
+ private static final Logger logger = new Logger(NeuralNetworkModelManager.class, LogGroup.Config);
+
+ private final String MODEL_NAME = "note-640-640-yolov5s.rknn";
+ private File defaultModelFile;
+ private List labels;
+
+ public static NeuralNetworkModelManager getInstance() {
+ if (INSTANCE == null) {
+ INSTANCE = new NeuralNetworkModelManager();
+ }
+ return INSTANCE;
+ }
+
+ /**
+ * Perform initial setup and extract default model from JAR to the filesystem
+ *
+ * @param modelsFolder Where models live
+ */
+ public void initialize(File modelsFolder) {
+ var modelResourcePath = "/models/" + MODEL_NAME;
+ this.defaultModelFile = new File(modelsFolder, MODEL_NAME);
+ extractResource(modelResourcePath, defaultModelFile);
+
+ File labelsFile = new File(modelsFolder, "labels.txt");
+ var labelResourcePath = "/models/" + labelsFile.getName();
+ extractResource(labelResourcePath, labelsFile);
+
+ try {
+ labels = Files.readAllLines(Paths.get(labelsFile.getPath()));
+ } catch (IOException e) {
+ logger.error("Error reading labels.txt", e);
+ }
+ }
+
+ private void extractResource(String resourcePath, File outputFile) {
+ try (var in = NeuralNetworkModelManager.class.getResourceAsStream(resourcePath)) {
+ if (in == null) {
+ logger.error("Failed to find jar resource at " + resourcePath);
+ return;
+ }
+
+ if (!outputFile.exists()) {
+ try (FileOutputStream fos = new FileOutputStream(outputFile)) {
+ int read = -1;
+ byte[] buffer = new byte[1024];
+ while ((read = in.read(buffer)) != -1) {
+ fos.write(buffer, 0, read);
+ }
+ } catch (IOException e) {
+ logger.error("Error extracting resource to " + outputFile.toPath().toString(), e);
+ }
+ } else {
+ logger.info(
+ "File " + outputFile.toPath().toString() + " already exists. Skipping extraction.");
+ }
+ } catch (IOException e) {
+ logger.error("Error finding jar resource " + resourcePath, e);
+ }
+ }
+
+ public File getDefaultRknnModel() {
+ return defaultModelFile;
+ }
+
+ public List getLabels() {
+ return labels;
+ }
+}
diff --git a/photon-core/src/main/java/org/photonvision/common/configuration/PhotonConfiguration.java b/photon-core/src/main/java/org/photonvision/common/configuration/PhotonConfiguration.java
index 2adde1539..9fb3ec4a7 100644
--- a/photon-core/src/main/java/org/photonvision/common/configuration/PhotonConfiguration.java
+++ b/photon-core/src/main/java/org/photonvision/common/configuration/PhotonConfiguration.java
@@ -27,6 +27,7 @@ import org.photonvision.PhotonVersion;
import org.photonvision.common.hardware.Platform;
import org.photonvision.common.networking.NetworkUtils;
import org.photonvision.common.util.SerializationUtils;
+import org.photonvision.jni.RknnDetectorJNI;
import org.photonvision.mrcal.MrCalJNILoader;
import org.photonvision.raspi.LibCameraJNILoader;
import org.photonvision.vision.calibration.CameraCalibrationCoefficients;
@@ -142,7 +143,8 @@ public class PhotonConfiguration {
LibCameraJNILoader.isSupported()
? "Zerocopy Libcamera Working"
: ""); // TODO add support for other types of GPU accel
- generalSubmap.put("mrCalWorking", MrCalJNILoader.isWorking());
+ generalSubmap.put("mrCalWorking", MrCalJNILoader.getInstance().isLoaded());
+ generalSubmap.put("rknnSupported", RknnDetectorJNI.getInstance().isLoaded());
generalSubmap.put("hardwareModel", hardwareConfig.deviceName);
generalSubmap.put("hardwarePlatform", Platform.getPlatformName());
settingsSubmap.put("general", generalSubmap);
diff --git a/photon-core/src/main/java/org/photonvision/common/dataflow/websocket/UIDataPublisher.java b/photon-core/src/main/java/org/photonvision/common/dataflow/websocket/UIDataPublisher.java
index 17ed24092..f1d754d00 100644
--- a/photon-core/src/main/java/org/photonvision/common/dataflow/websocket/UIDataPublisher.java
+++ b/photon-core/src/main/java/org/photonvision/common/dataflow/websocket/UIDataPublisher.java
@@ -52,6 +52,7 @@ public class UIDataPublisher implements CVPipelineResultConsumer {
uiTargets.add(t.toHashMap());
}
dataMap.put("targets", uiTargets);
+ dataMap.put("classNames", result.objectDetectionClassNames);
// Only send Multitag Results if they are present, similar to 3d pose
if (result.multiTagResult.estimatedPose.isPresent) {
diff --git a/photon-core/src/main/java/org/photonvision/common/hardware/Platform.java b/photon-core/src/main/java/org/photonvision/common/hardware/Platform.java
index ba2e50773..509ea0cd9 100644
--- a/photon-core/src/main/java/org/photonvision/common/hardware/Platform.java
+++ b/photon-core/src/main/java/org/photonvision/common/hardware/Platform.java
@@ -43,6 +43,7 @@ public enum Platform {
true,
OSType.LINUX,
true), // Raspberry Pi 3/4 with a 64-bit image
+ LINUX_RK3588_64("Linux AARCH 64-bit with RK3588", "linuxarm64", false, OSType.LINUX, true),
LINUX_AARCH64(
"Linux AARCH64", "linuxarm64", false, OSType.LINUX, true), // Jetson Nano, Jetson TX2
@@ -94,6 +95,10 @@ public enum Platform {
return currentPlatform.osType == OSType.LINUX;
}
+ public static boolean isRK3588() {
+ return Platform.isOrangePi() || Platform.isCoolPi4b();
+ }
+
public static boolean isRaspberryPi() {
return currentPlatform.isPi;
}
@@ -186,7 +191,11 @@ public enum Platform {
return LINUX_32;
} else if (RuntimeDetector.isArm64()) {
// TODO - os detection needed?
- return LINUX_AARCH64;
+ if (isOrangePi()) {
+ return LINUX_RK3588_64;
+ } else {
+ return LINUX_AARCH64;
+ }
} else if (RuntimeDetector.isArm32()) {
return LINUX_ARM32;
} else {
@@ -204,6 +213,14 @@ public enum Platform {
return fileHasText("/proc/cpuinfo", "Raspberry Pi");
}
+ private static boolean isOrangePi() {
+ return fileHasText("/proc/device-tree/model", "Orange Pi 5");
+ }
+
+ private static boolean isCoolPi4b() {
+ return fileHasText("/proc/device-tree/model", "CoolPi 4B");
+ }
+
private static boolean isJetsonSBC() {
// https://forums.developer.nvidia.com/t/how-to-recognize-jetson-nano-device/146624
return fileHasText("/proc/device-tree/model", "NVIDIA Jetson");
diff --git a/photon-core/src/main/java/org/photonvision/jni/PhotonJNICommon.java b/photon-core/src/main/java/org/photonvision/jni/PhotonJNICommon.java
index 9375c8664..edd24425c 100644
--- a/photon-core/src/main/java/org/photonvision/jni/PhotonJNICommon.java
+++ b/photon-core/src/main/java/org/photonvision/jni/PhotonJNICommon.java
@@ -26,12 +26,15 @@ import org.photonvision.common.logging.LogGroup;
import org.photonvision.common.logging.Logger;
public abstract class PhotonJNICommon {
- static boolean libraryLoaded = false;
+ public abstract boolean isLoaded();
+
+ public abstract void setLoaded(boolean state);
+
protected static Logger logger = null;
- protected static synchronized void forceLoad(Class> clazz, List libraries)
- throws IOException {
- if (libraryLoaded) return;
+ protected static synchronized void forceLoad(
+ PhotonJNICommon instance, Class> clazz, List libraries) throws IOException {
+ if (instance.isLoaded()) return;
if (logger == null) logger = new Logger(clazz, LogGroup.Camera);
for (var libraryName : libraries) {
@@ -42,7 +45,7 @@ public abstract class PhotonJNICommon {
var in = clazz.getResourceAsStream("/nativelibraries/" + arch_name + "/" + nativeLibName);
if (in == null) {
- libraryLoaded = false;
+ instance.setLoaded(false);
return;
}
@@ -69,15 +72,11 @@ public abstract class PhotonJNICommon {
break;
}
}
- libraryLoaded = true;
+ instance.setLoaded(true);
}
- protected static synchronized void forceLoad(Class> clazz, String libraryName)
- throws IOException {
- forceLoad(clazz, List.of(libraryName));
- }
-
- public static boolean isWorking() {
- return libraryLoaded;
+ protected static synchronized void forceLoad(
+ PhotonJNICommon instance, Class> clazz, String libraryName) throws IOException {
+ forceLoad(instance, clazz, List.of(libraryName));
}
}
diff --git a/photon-core/src/main/java/org/photonvision/jni/RknnDetectorJNI.java b/photon-core/src/main/java/org/photonvision/jni/RknnDetectorJNI.java
new file mode 100644
index 000000000..4f76f6539
--- /dev/null
+++ b/photon-core/src/main/java/org/photonvision/jni/RknnDetectorJNI.java
@@ -0,0 +1,138 @@
+/*
+ * Copyright (C) Photon Vision.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.photonvision.jni;
+
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.stream.Collectors;
+import org.photonvision.common.logging.LogGroup;
+import org.photonvision.common.logging.Logger;
+import org.photonvision.common.util.TestUtils;
+import org.photonvision.rknn.RknnJNI;
+import org.photonvision.rknn.RknnJNI.RknnResult;
+import org.photonvision.vision.opencv.CVMat;
+import org.photonvision.vision.pipe.impl.NeuralNetworkPipeResult;
+
+public class RknnDetectorJNI extends PhotonJNICommon {
+ private static final Logger logger = new Logger(RknnDetectorJNI.class, LogGroup.General);
+ private boolean isLoaded;
+ private static RknnDetectorJNI instance = null;
+
+ private RknnDetectorJNI() {
+ isLoaded = false;
+ }
+
+ public static RknnDetectorJNI getInstance() {
+ if (instance == null) instance = new RknnDetectorJNI();
+
+ return instance;
+ }
+
+ public static synchronized void forceLoad() throws IOException {
+ TestUtils.loadLibraries();
+
+ forceLoad(getInstance(), RknnDetectorJNI.class, List.of("rga", "rknnrt", "rknn_jni"));
+ }
+
+ @Override
+ public boolean isLoaded() {
+ return isLoaded;
+ }
+
+ @Override
+ public void setLoaded(boolean state) {
+ isLoaded = state;
+ }
+
+ public static class RknnObjectDetector {
+ long objPointer = -1;
+ private List labels;
+ private final Object lock = new Object();
+
+ private static final CopyOnWriteArrayList detectors = new CopyOnWriteArrayList<>();
+
+ public RknnObjectDetector(String modelPath, List labels) {
+ synchronized (lock) {
+ objPointer = RknnJNI.create(modelPath, labels.size());
+ detectors.add(objPointer);
+ System.out.println(
+ "Created " + objPointer + "! Detectors: " + Arrays.toString(detectors.toArray()));
+ }
+ this.labels = labels;
+ }
+
+ public List getClasses() {
+ return labels;
+ }
+
+ /**
+ * Detect forwards using this model
+ *
+ * @param in The image to process
+ * @param nmsThresh Non-maximum supression threshold. Probably should not change
+ * @param boxThresh Minimum confidence for a box to be added. Basically just confidence
+ * threshold
+ */
+ public List detect(CVMat in, double nmsThresh, double boxThresh) {
+ RknnResult[] ret;
+ synchronized (lock) {
+ // We can technically be asked to detect and the lock might be acquired _after_ release has
+ // been called. This would mean objPointer would be invalid which would call everything to
+ // explode.
+ if (objPointer > 0) {
+ ret = RknnJNI.detect(objPointer, in.getMat().getNativeObjAddr(), nmsThresh, boxThresh);
+ } else {
+ logger.warn("Detect called after destroy -- giving up");
+ return List.of();
+ }
+ }
+ if (ret == null) {
+ return List.of();
+ }
+ return List.of(ret).stream()
+ .map(it -> new NeuralNetworkPipeResult(it.rect, it.class_id, it.conf))
+ .collect(Collectors.toList());
+ }
+
+ public void release() {
+ synchronized (lock) {
+ if (objPointer > 0) {
+ RknnJNI.destroy(objPointer);
+ detectors.remove(objPointer);
+ System.out.println(
+ "Killed " + objPointer + "! Detectors: " + Arrays.toString(detectors.toArray()));
+ objPointer = -1;
+ } else {
+ logger.error("RKNN Detector has already been destroyed!");
+ }
+ }
+ }
+ }
+
+ // public static void createRknnDetector() {
+ // objPointer =
+ // RknnJNI.create(
+ // NeuralNetworkModelManager.getInstance()
+ // .getDefaultRknnModel()
+ // .getAbsolutePath()
+ // .toString(),
+ // NeuralNetworkModelManager.getInstance().getLabels().size());
+ // }
+}
diff --git a/photon-core/src/main/java/org/photonvision/mrcal/MrCalJNILoader.java b/photon-core/src/main/java/org/photonvision/mrcal/MrCalJNILoader.java
index c967d083d..dc5aea5eb 100644
--- a/photon-core/src/main/java/org/photonvision/mrcal/MrCalJNILoader.java
+++ b/photon-core/src/main/java/org/photonvision/mrcal/MrCalJNILoader.java
@@ -24,6 +24,19 @@ import org.photonvision.common.util.TestUtils;
import org.photonvision.jni.PhotonJNICommon;
public class MrCalJNILoader extends PhotonJNICommon {
+ private boolean isLoaded;
+ private static MrCalJNILoader instance = null;
+
+ private MrCalJNILoader() {
+ isLoaded = false;
+ }
+
+ public static synchronized MrCalJNILoader getInstance() {
+ if (instance == null) instance = new MrCalJNILoader();
+
+ return instance;
+ }
+
public static synchronized void forceLoad() throws IOException {
// Force load opencv
TestUtils.loadLibraries();
@@ -32,6 +45,7 @@ public class MrCalJNILoader extends PhotonJNICommon {
if (Platform.isWindows()) {
// Order is correct to match dependencies of libraries
forceLoad(
+ MrCalJNILoader.getInstance(),
MrCalJNILoader.class,
List.of(
"libamd",
@@ -47,11 +61,21 @@ public class MrCalJNILoader extends PhotonJNICommon {
"mrcal_jni"));
} else {
// Nothing else to do on linux
- forceLoad(MrCalJNILoader.class, List.of("mrcal_jni"));
+ forceLoad(MrCalJNILoader.getInstance(), MrCalJNILoader.class, List.of("mrcal_jni"));
}
- if (!MrCalJNILoader.isWorking()) {
+ if (!MrCalJNILoader.getInstance().isLoaded()) {
throw new IOException("Unable to load mrcal JNI!");
}
}
+
+ @Override
+ public boolean isLoaded() {
+ return isLoaded;
+ }
+
+ @Override
+ public void setLoaded(boolean state) {
+ isLoaded = state;
+ }
}
diff --git a/photon-core/src/main/java/org/photonvision/vision/pipe/CVPipe.java b/photon-core/src/main/java/org/photonvision/vision/pipe/CVPipe.java
index d941f51cd..74d9fdc98 100644
--- a/photon-core/src/main/java/org/photonvision/vision/pipe/CVPipe.java
+++ b/photon-core/src/main/java/org/photonvision/vision/pipe/CVPipe.java
@@ -33,6 +33,10 @@ public abstract class CVPipe {
this.params = params;
}
+ public P getParams() {
+ return this.params;
+ }
+
/**
* Runs the process for the pipe.
*
diff --git a/photon-core/src/main/java/org/photonvision/vision/pipe/impl/ArucoDetectionPipe.java b/photon-core/src/main/java/org/photonvision/vision/pipe/impl/ArucoDetectionPipe.java
index 1ea4fcd2c..4d0cdb55e 100644
--- a/photon-core/src/main/java/org/photonvision/vision/pipe/impl/ArucoDetectionPipe.java
+++ b/photon-core/src/main/java/org/photonvision/vision/pipe/impl/ArucoDetectionPipe.java
@@ -44,6 +44,13 @@ public class ArucoDetectionPipe
@Override
protected List process(CVMat in) {
var imgMat = in.getMat();
+
+ // Sanity check -- image should not be empty
+ if (imgMat.empty()) {
+ // give up is best we can do here
+ return List.of();
+ }
+
var detections = photonDetector.detect(imgMat);
// manually do corner refinement ourselves
if (params.useCornerRefinement) {
diff --git a/photon-core/src/main/java/org/photonvision/vision/pipe/impl/Calibrate3dPipe.java b/photon-core/src/main/java/org/photonvision/vision/pipe/impl/Calibrate3dPipe.java
index 2ac685766..1f9b32020 100644
--- a/photon-core/src/main/java/org/photonvision/vision/pipe/impl/Calibrate3dPipe.java
+++ b/photon-core/src/main/java/org/photonvision/vision/pipe/impl/Calibrate3dPipe.java
@@ -77,7 +77,7 @@ public class Calibrate3dPipe
CameraCalibrationCoefficients ret;
var start = System.nanoTime();
- if (MrCalJNILoader.isWorking() && params.useMrCal) {
+ if (MrCalJNILoader.getInstance().isLoaded() && params.useMrCal) {
logger.debug("Calibrating with mrcal!");
ret = calibrateMrcal(in);
} else {
diff --git a/photon-core/src/main/java/org/photonvision/vision/pipeline/Calibrate3dPipeline.java b/photon-core/src/main/java/org/photonvision/vision/pipe/impl/Calibrate3dPipeline.java
similarity index 97%
rename from photon-core/src/main/java/org/photonvision/vision/pipeline/Calibrate3dPipeline.java
rename to photon-core/src/main/java/org/photonvision/vision/pipe/impl/Calibrate3dPipeline.java
index 9dc4c522c..1a23cf4f1 100644
--- a/photon-core/src/main/java/org/photonvision/vision/pipeline/Calibrate3dPipeline.java
+++ b/photon-core/src/main/java/org/photonvision/vision/pipe/impl/Calibrate3dPipeline.java
@@ -15,7 +15,7 @@
* along with this program. If not, see .
*/
-package org.photonvision.vision.pipeline;
+package org.photonvision.vision.pipe.impl;
import edu.wpi.first.math.util.Units;
import java.util.ArrayList;
@@ -36,10 +36,10 @@ import org.photonvision.vision.frame.FrameThresholdType;
import org.photonvision.vision.opencv.CVMat;
import org.photonvision.vision.opencv.ImageRotationMode;
import org.photonvision.vision.pipe.CVPipe.CVPipeResult;
-import org.photonvision.vision.pipe.impl.CalculateFPSPipe;
-import org.photonvision.vision.pipe.impl.Calibrate3dPipe;
-import org.photonvision.vision.pipe.impl.FindBoardCornersPipe;
import org.photonvision.vision.pipe.impl.FindBoardCornersPipe.FindBoardCornersPipeResult;
+import org.photonvision.vision.pipeline.CVPipeline;
+import org.photonvision.vision.pipeline.Calibration3dPipelineSettings;
+import org.photonvision.vision.pipeline.UICalibrationData;
import org.photonvision.vision.pipeline.result.CVPipelineResult;
import org.photonvision.vision.pipeline.result.CalibrationPipelineResult;
diff --git a/photon-core/src/main/java/org/photonvision/vision/pipe/impl/NeuralNetworkPipeResult.java b/photon-core/src/main/java/org/photonvision/vision/pipe/impl/NeuralNetworkPipeResult.java
new file mode 100644
index 000000000..575c64e0e
--- /dev/null
+++ b/photon-core/src/main/java/org/photonvision/vision/pipe/impl/NeuralNetworkPipeResult.java
@@ -0,0 +1,32 @@
+/*
+ * Copyright (C) Photon Vision.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.photonvision.vision.pipe.impl;
+
+import org.opencv.core.Rect2d;
+
+public class NeuralNetworkPipeResult {
+ public NeuralNetworkPipeResult(Rect2d box2, Integer classIdx, Float confidence) {
+ box = box2;
+ this.classIdx = classIdx;
+ this.confidence = confidence;
+ }
+
+ public final int classIdx;
+ public final Rect2d box;
+ public final double confidence;
+}
diff --git a/photon-core/src/main/java/org/photonvision/vision/pipe/impl/RknnDetectionPipe.java b/photon-core/src/main/java/org/photonvision/vision/pipe/impl/RknnDetectionPipe.java
new file mode 100644
index 000000000..fc1a2ac81
--- /dev/null
+++ b/photon-core/src/main/java/org/photonvision/vision/pipe/impl/RknnDetectionPipe.java
@@ -0,0 +1,69 @@
+/*
+ * Copyright (C) Photon Vision.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.photonvision.vision.pipe.impl;
+
+import java.util.List;
+import org.photonvision.common.configuration.NeuralNetworkModelManager;
+import org.photonvision.jni.RknnDetectorJNI.RknnObjectDetector;
+import org.photonvision.vision.opencv.CVMat;
+import org.photonvision.vision.opencv.Releasable;
+import org.photonvision.vision.pipe.CVPipe;
+
+public class RknnDetectionPipe
+ extends CVPipe, RknnDetectionPipe.RknnDetectionPipeParams>
+ implements Releasable {
+ private RknnObjectDetector detector;
+
+ public RknnDetectionPipe() {
+ // For now this is hard-coded to defaults. Should be refactored into set pipe params, though.
+ // And ideally a little wrapper helper for only changing native stuff on content change created.
+ this.detector =
+ new RknnObjectDetector(
+ NeuralNetworkModelManager.getInstance().getDefaultRknnModel().getAbsolutePath(),
+ NeuralNetworkModelManager.getInstance().getLabels());
+ }
+
+ @Override
+ protected List process(CVMat in) {
+ var frame = in.getMat();
+
+ // Make sure we don't get a weird empty frame
+ if (frame.empty()) {
+ return List.of();
+ }
+
+ return detector.detect(in, params.nms, params.confidence);
+ }
+
+ public static class RknnDetectionPipeParams {
+ public double confidence;
+ public double nms;
+ public int max_detections;
+
+ public RknnDetectionPipeParams() {}
+ }
+
+ public List getClassNames() {
+ return detector.getClasses();
+ }
+
+ @Override
+ public void release() {
+ detector.release();
+ }
+}
diff --git a/photon-core/src/main/java/org/photonvision/vision/pipeline/CVPipeline.java b/photon-core/src/main/java/org/photonvision/vision/pipeline/CVPipeline.java
index 3df5b268f..e211b42de 100644
--- a/photon-core/src/main/java/org/photonvision/vision/pipeline/CVPipeline.java
+++ b/photon-core/src/main/java/org/photonvision/vision/pipeline/CVPipeline.java
@@ -21,9 +21,11 @@ import org.photonvision.vision.camera.QuirkyCamera;
import org.photonvision.vision.frame.Frame;
import org.photonvision.vision.frame.FrameStaticProperties;
import org.photonvision.vision.frame.FrameThresholdType;
+import org.photonvision.vision.opencv.Releasable;
import org.photonvision.vision.pipeline.result.CVPipelineResult;
-public abstract class CVPipeline {
+public abstract class CVPipeline
+ implements Releasable {
protected S settings;
protected FrameStaticProperties frameStaticProperties;
protected QuirkyCamera cameraQuirks;
@@ -75,4 +77,11 @@ public abstract class CVPipeline.
+ */
+
+package org.photonvision.vision.pipeline;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.photonvision.vision.frame.Frame;
+import org.photonvision.vision.frame.FrameThresholdType;
+import org.photonvision.vision.pipe.CVPipe.CVPipeResult;
+import org.photonvision.vision.pipe.impl.*;
+import org.photonvision.vision.pipe.impl.RknnDetectionPipe.RknnDetectionPipeParams;
+import org.photonvision.vision.pipeline.result.CVPipelineResult;
+import org.photonvision.vision.target.TrackedTarget;
+import org.photonvision.vision.target.TrackedTarget.TargetCalculationParameters;
+
+public class ObjectDetectionPipeline
+ extends CVPipeline {
+ private final CalculateFPSPipe calculateFPSPipe = new CalculateFPSPipe();
+ private final RknnDetectionPipe rknnPipe = new RknnDetectionPipe();
+
+ private static final FrameThresholdType PROCESSING_TYPE = FrameThresholdType.NONE;
+
+ public ObjectDetectionPipeline() {
+ super(PROCESSING_TYPE);
+ settings = new ObjectDetectionPipelineSettings();
+ }
+
+ public ObjectDetectionPipeline(ObjectDetectionPipelineSettings settings) {
+ super(PROCESSING_TYPE);
+ this.settings = settings;
+ }
+
+ @Override
+ protected void setPipeParamsImpl() {
+ // this needs to be based off of the current backend selected!!
+ var params = new RknnDetectionPipeParams();
+ params.confidence = settings.confidence;
+ params.nms = settings.nms;
+ rknnPipe.setParams(params);
+ }
+
+ @Override
+ protected CVPipelineResult process(Frame input_frame, ObjectDetectionPipelineSettings settings) {
+ long sumPipeNanosElapsed = 0;
+
+ // ***************** change based on backend ***********************
+
+ CVPipeResult> ret = rknnPipe.run(input_frame.colorImage);
+ sumPipeNanosElapsed += ret.nanosElapsed;
+ List targetList;
+
+ targetList = ret.output;
+ var names = rknnPipe.getClassNames();
+
+ input_frame.colorImage.getMat().copyTo(input_frame.processedImage.getMat());
+
+ // ***************** change based on backend ***********************
+
+ List targets = new ArrayList<>();
+
+ for (var t : targetList) {
+ targets.add(
+ new TrackedTarget(
+ t,
+ new TargetCalculationParameters(
+ false, null, null, null, null, frameStaticProperties)));
+ }
+
+ var fpsResult = calculateFPSPipe.run(null);
+ var fps = fpsResult.output;
+
+ return new CVPipelineResult(sumPipeNanosElapsed, fps, targets, input_frame, names);
+ }
+
+ @Override
+ public void release() {
+ rknnPipe.release();
+ }
+}
diff --git a/photon-core/src/main/java/org/photonvision/vision/pipeline/ObjectDetectionPipelineSettings.java b/photon-core/src/main/java/org/photonvision/vision/pipeline/ObjectDetectionPipelineSettings.java
new file mode 100644
index 000000000..f074bf1f5
--- /dev/null
+++ b/photon-core/src/main/java/org/photonvision/vision/pipeline/ObjectDetectionPipelineSettings.java
@@ -0,0 +1,34 @@
+/*
+ * Copyright (C) Photon Vision.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see .
+ */
+
+package org.photonvision.vision.pipeline;
+
+public class ObjectDetectionPipelineSettings extends AdvancedPipelineSettings {
+ public double confidence;
+ public double nms; // non maximal suppression
+
+ public ObjectDetectionPipelineSettings() {
+ super();
+ this.pipelineType = PipelineType.ObjectDetection; // TODO: FIX this
+ this.outputShowMultipleTargets = true;
+ cameraExposure = 20;
+ cameraAutoExposure = false;
+ ledMode = false;
+ confidence = .9;
+ nms = .45;
+ }
+}
diff --git a/photon-core/src/main/java/org/photonvision/vision/pipeline/PipelineType.java b/photon-core/src/main/java/org/photonvision/vision/pipeline/PipelineType.java
index a2f6346b8..4e9b71967 100644
--- a/photon-core/src/main/java/org/photonvision/vision/pipeline/PipelineType.java
+++ b/photon-core/src/main/java/org/photonvision/vision/pipeline/PipelineType.java
@@ -17,6 +17,8 @@
package org.photonvision.vision.pipeline;
+import org.photonvision.vision.pipe.impl.Calibrate3dPipeline;
+
@SuppressWarnings("rawtypes")
public enum PipelineType {
Calib3d(-2, Calibrate3dPipeline.class),
@@ -24,7 +26,8 @@ public enum PipelineType {
Reflective(0, ReflectivePipeline.class),
ColoredShape(1, ColoredShapePipeline.class),
AprilTag(2, AprilTagPipeline.class),
- Aruco(3, ArucoPipeline.class);
+ Aruco(3, ArucoPipeline.class),
+ ObjectDetection(4, ObjectDetectionPipeline.class);
public final int baseIndex;
public final Class clazz;
diff --git a/photon-core/src/main/java/org/photonvision/vision/pipeline/result/CVPipelineResult.java b/photon-core/src/main/java/org/photonvision/vision/pipeline/result/CVPipelineResult.java
index 4186fd91e..89339571d 100644
--- a/photon-core/src/main/java/org/photonvision/vision/pipeline/result/CVPipelineResult.java
+++ b/photon-core/src/main/java/org/photonvision/vision/pipeline/result/CVPipelineResult.java
@@ -32,10 +32,20 @@ public class CVPipelineResult implements Releasable {
public final List targets;
public final Frame inputAndOutputFrame;
public MultiTargetPNPResult multiTagResult;
+ public final List objectDetectionClassNames;
public CVPipelineResult(
double processingNanos, double fps, List targets, Frame inputFrame) {
- this(processingNanos, fps, targets, new MultiTargetPNPResult(), inputFrame);
+ this(processingNanos, fps, targets, new MultiTargetPNPResult(), inputFrame, List.of());
+ }
+
+ public CVPipelineResult(
+ double processingNanos,
+ double fps,
+ List targets,
+ Frame inputFrame,
+ List classNames) {
+ this(processingNanos, fps, targets, new MultiTargetPNPResult(), inputFrame, classNames);
}
public CVPipelineResult(
@@ -44,10 +54,21 @@ public class CVPipelineResult implements Releasable {
List targets,
MultiTargetPNPResult multiTagResult,
Frame inputFrame) {
+ this(processingNanos, fps, targets, multiTagResult, inputFrame, List.of());
+ }
+
+ public CVPipelineResult(
+ double processingNanos,
+ double fps,
+ List targets,
+ MultiTargetPNPResult multiTagResult,
+ Frame inputFrame,
+ List classNames) {
this.processingNanos = processingNanos;
this.fps = fps;
this.targets = targets != null ? targets : Collections.emptyList();
this.multiTagResult = multiTagResult;
+ this.objectDetectionClassNames = classNames;
this.inputAndOutputFrame = inputFrame;
}
@@ -57,7 +78,7 @@ public class CVPipelineResult implements Releasable {
double fps,
List targets,
MultiTargetPNPResult multiTagResult) {
- this(processingNanos, fps, targets, multiTagResult, null);
+ this(processingNanos, fps, targets, multiTagResult, null, List.of());
}
public boolean hasTargets() {
diff --git a/photon-core/src/main/java/org/photonvision/vision/processes/PipelineManager.java b/photon-core/src/main/java/org/photonvision/vision/processes/PipelineManager.java
index 52144e20e..7e9d5d34d 100644
--- a/photon-core/src/main/java/org/photonvision/vision/processes/PipelineManager.java
+++ b/photon-core/src/main/java/org/photonvision/vision/processes/PipelineManager.java
@@ -27,6 +27,7 @@ import org.photonvision.common.dataflow.DataChangeService;
import org.photonvision.common.dataflow.events.OutgoingUIEvent;
import org.photonvision.common.logging.LogGroup;
import org.photonvision.common.logging.Logger;
+import org.photonvision.vision.pipe.impl.Calibrate3dPipeline;
import org.photonvision.vision.pipeline.*;
@SuppressWarnings({"rawtypes", "unused"})
@@ -41,7 +42,7 @@ public class PipelineManager {
protected final DriverModePipeline driverModePipeline = new DriverModePipeline();
/** Index of the currently active pipeline. Defaults to 0. */
- private int currentPipelineIndex = 0;
+ private int currentPipelineIndex = DRIVERMODE_INDEX;
/** The currently active pipeline. */
private CVPipeline currentUserPipeline = driverModePipeline;
@@ -188,6 +189,11 @@ public class PipelineManager {
return;
}
+ // Cleanup potential old native resources before swapping over
+ if (currentUserPipeline != null) {
+ currentUserPipeline.release();
+ }
+
currentPipelineIndex = newIndex;
if (newIndex >= 0) {
var desiredPipelineSettings = userPipelineSettings.get(currentPipelineIndex);
@@ -212,6 +218,11 @@ public class PipelineManager {
logger.debug("Creating Aruco Pipeline");
currentUserPipeline = new ArucoPipeline((ArucoPipelineSettings) desiredPipelineSettings);
break;
+ case ObjectDetection:
+ logger.debug("Creating ObjectDetection Pipeline");
+ currentUserPipeline =
+ new ObjectDetectionPipeline(
+ (ObjectDetectionPipelineSettings) desiredPipelineSettings);
default:
// Can be calib3d or drivermode, both of which are special cases
break;
@@ -313,6 +324,12 @@ public class PipelineManager {
added.pipelineNickname = nickname;
return added;
}
+ case ObjectDetection:
+ {
+ var added = new ObjectDetectionPipelineSettings();
+ added.pipelineNickname = nickname;
+ return added;
+ }
default:
{
logger.error("Got invalid pipeline type: " + type);
diff --git a/photon-core/src/main/java/org/photonvision/vision/target/TrackedTarget.java b/photon-core/src/main/java/org/photonvision/vision/target/TrackedTarget.java
index 61354c687..396e92a22 100644
--- a/photon-core/src/main/java/org/photonvision/vision/target/TrackedTarget.java
+++ b/photon-core/src/main/java/org/photonvision/vision/target/TrackedTarget.java
@@ -27,6 +27,7 @@ import org.opencv.core.Mat;
import org.opencv.core.MatOfPoint;
import org.opencv.core.MatOfPoint2f;
import org.opencv.core.Point;
+import org.opencv.core.Rect2d;
import org.opencv.core.RotatedRect;
import org.photonvision.common.util.SerializationUtils;
import org.photonvision.common.util.math.MathUtils;
@@ -38,6 +39,7 @@ import org.photonvision.vision.opencv.CVShape;
import org.photonvision.vision.opencv.Contour;
import org.photonvision.vision.opencv.DualOffsetValues;
import org.photonvision.vision.opencv.Releasable;
+import org.photonvision.vision.pipe.impl.NeuralNetworkPipeResult;
public class TrackedTarget implements Releasable {
public final Contour m_mainContour;
@@ -65,6 +67,9 @@ public class TrackedTarget implements Releasable {
private Mat m_cameraRelativeTvec, m_cameraRelativeRvec;
+ private int m_classId = -1;
+ private double m_confidence = -1;
+
public TrackedTarget(
PotentialTarget origTarget, TargetCalculationParameters params, CVShape shape) {
this.m_mainContour = origTarget.m_mainContour;
@@ -154,6 +159,61 @@ public class TrackedTarget implements Releasable {
m_robotOffsetPoint = new Point();
}
+ public TrackedTarget(
+ Rect2d box, int class_id, double confidence, TargetCalculationParameters params) {
+ m_targetOffsetPoint = new Point(box.x + box.width / 2.0, box.y + box.height / 2.0);
+ m_robotOffsetPoint = new Point();
+
+ var yawPitch =
+ TargetCalculations.calculateYawPitch(
+ params.cameraCenterPoint.x,
+ box.x + box.width / 2.0,
+ params.horizontalFocalLength,
+ params.cameraCenterPoint.y,
+ box.y + box.height / 2.0,
+ params.verticalFocalLength);
+ m_yaw = yawPitch.getFirst();
+ m_pitch = yawPitch.getSecond();
+
+ Point[] cornerPoints =
+ new Point[] {
+ // Box.x/y is the top-left corner, not the center
+ new Point(box.x, box.y), // tl
+ new Point(box.x + box.width, box.y), // tr
+ new Point(box.x + box.width, box.y + box.height), // br
+ new Point(box.x, box.y + box.height), // bl
+ };
+
+ m_targetCorners = List.of(cornerPoints);
+ MatOfPoint contourMat = new MatOfPoint(cornerPoints);
+ m_approximateBoundingPolygon = new MatOfPoint2f(cornerPoints);
+
+ m_mainContour = new Contour(contourMat);
+ m_area = m_mainContour.getArea() / params.imageArea * 100;
+
+ m_classId = class_id;
+ m_confidence = confidence;
+ }
+
+ public TrackedTarget(
+ NeuralNetworkPipeResult t, TargetCalculationParameters targetCalculationParameters) {
+ this(t.box, t.classIdx, t.confidence, targetCalculationParameters);
+ }
+
+ /**
+ * @return Returns the confidence of the detection ranging from 0 - 1.
+ */
+ public double getConfidence() {
+ return m_confidence;
+ }
+
+ /**
+ * @return O-indexed class index for the detected object.
+ */
+ public double getClassID() {
+ return m_classId;
+ }
+
public TrackedTarget(
ArucoDetectionResult result,
AprilTagPoseEstimate tagPose,
@@ -388,6 +448,8 @@ public class TrackedTarget implements Releasable {
ret.put("skew", getSkew());
ret.put("area", getArea());
ret.put("ambiguity", getPoseAmbiguity());
+ ret.put("confidence", m_confidence);
+ ret.put("classId", m_classId);
var bestCameraToTarget3d = getBestCameraToTarget3d();
if (bestCameraToTarget3d != null) {
diff --git a/photon-core/src/test/java/org/photonvision/vision/pipeline/Calibrate3dPipeTest.java b/photon-core/src/test/java/org/photonvision/vision/pipeline/Calibrate3dPipeTest.java
index 924e7c6fe..f854ece39 100644
--- a/photon-core/src/test/java/org/photonvision/vision/pipeline/Calibrate3dPipeTest.java
+++ b/photon-core/src/test/java/org/photonvision/vision/pipeline/Calibrate3dPipeTest.java
@@ -44,6 +44,7 @@ import org.photonvision.vision.frame.FrameDivisor;
import org.photonvision.vision.frame.FrameStaticProperties;
import org.photonvision.vision.frame.FrameThresholdType;
import org.photonvision.vision.opencv.CVMat;
+import org.photonvision.vision.pipe.impl.Calibrate3dPipeline;
public class Calibrate3dPipeTest {
@BeforeAll
diff --git a/photon-server/src/main/java/org/photonvision/Main.java b/photon-server/src/main/java/org/photonvision/Main.java
index 469cec876..8ae7c8fd9 100644
--- a/photon-server/src/main/java/org/photonvision/Main.java
+++ b/photon-server/src/main/java/org/photonvision/Main.java
@@ -27,6 +27,7 @@ import java.util.stream.Collectors;
import org.apache.commons.cli.*;
import org.photonvision.common.configuration.CameraConfiguration;
import org.photonvision.common.configuration.ConfigManager;
+import org.photonvision.common.configuration.NeuralNetworkModelManager;
import org.photonvision.common.dataflow.networktables.NetworkTablesManager;
import org.photonvision.common.hardware.HardwareManager;
import org.photonvision.common.hardware.PiVersion;
@@ -37,6 +38,7 @@ import org.photonvision.common.logging.Logger;
import org.photonvision.common.networking.NetworkManager;
import org.photonvision.common.util.TestUtils;
import org.photonvision.common.util.numbers.IntegerCouple;
+import org.photonvision.jni.RknnDetectorJNI;
import org.photonvision.mrcal.MrCalJNILoader;
import org.photonvision.raspi.LibCameraJNILoader;
import org.photonvision.server.Server;
@@ -348,7 +350,15 @@ public class Main {
} catch (IOException e) {
logger.error("Failed to load libcamera-JNI!", e);
}
-
+ try {
+ if (Platform.isRK3588()) {
+ RknnDetectorJNI.forceLoad();
+ } else {
+ logger.error("Platform does not support RKNN based machine learning!");
+ }
+ } catch (IOException e) {
+ logger.error("Failed to load rknn-JNI!", e);
+ }
try {
MrCalJNILoader.forceLoad();
} catch (IOException e) {
@@ -364,7 +374,6 @@ public class Main {
} catch (ParseException e) {
logger.error("Failed to parse command-line options!", e);
}
-
CVMat.enablePrint(false);
PipelineProfiler.enablePrint(false);
@@ -399,6 +408,10 @@ public class Main {
NetworkTablesManager.getInstance()
.setConfig(ConfigManager.getInstance().getConfig().getNetworkConfig());
+ logger.info("Loading ML models");
+ NeuralNetworkModelManager.getInstance()
+ .initialize(ConfigManager.getInstance().getModelsDirectory());
+
if (!isTestMode) {
logger.debug("Loading VisionSourceManager...");
VisionSourceManager.getInstance()
diff --git a/photon-server/src/main/resources/models/labels.txt b/photon-server/src/main/resources/models/labels.txt
new file mode 100644
index 000000000..519dd581e
--- /dev/null
+++ b/photon-server/src/main/resources/models/labels.txt
@@ -0,0 +1 @@
+note
diff --git a/photon-server/src/main/resources/models/note-640-640-yolov5s.rknn b/photon-server/src/main/resources/models/note-640-640-yolov5s.rknn
new file mode 100644
index 0000000000000000000000000000000000000000..e94204aaee9a10565f01a55aa22cb2b7f5243e8c
GIT binary patch
literal 8261246
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 |