mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-10-06 14:52:47 +03:00
Refactor model scene definitions (#24508)
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
CI / AMD64 Build (push) Canceled after 0s
CI / AMD64 Smoke Test (push) Canceled after 0s
CI / ARM Build (push) Canceled after 0s
CI / Jetson Jetpack 6 (push) Canceled after 0s
CI / AMD64 Extra Build (push) Canceled after 0s
CI / ARM Extra Build (push) Canceled after 0s
CI / Synaptics Build (push) Canceled after 0s
CI / Assemble and push default build (push) Canceled after 0s
* Refactor model scene definitions * Cleanup * Validate model paths in the UI * Handle form validation
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,135 @@
|
||||
/**
|
||||
* Camera detect scene tests -- MEDIUM tier.
|
||||
*
|
||||
* Scenes are free-form names declared by the configured models, so a camera
|
||||
* only needs to pick one when there is a choice to make. The choices are the
|
||||
* scenes of the configured models, plus a saved scene that no model uses.
|
||||
*/
|
||||
|
||||
import { readFileSync } from "node:fs";
|
||||
import { resolve, dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { test, expect } from "../../fixtures/frigate-test";
|
||||
import type { Page } from "@playwright/test";
|
||||
import { configFactory } from "../../fixtures/mock-data/config";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const CONFIG_SCHEMA = JSON.parse(
|
||||
readFileSync(
|
||||
resolve(__dirname, "../../fixtures/mock-data/config-schema.json"),
|
||||
"utf-8",
|
||||
),
|
||||
);
|
||||
|
||||
const SETTINGS_URL = "/settings?page=cameraDetect&camera=front_door";
|
||||
|
||||
async function installRoutes(
|
||||
page: Page,
|
||||
models: { scene: string; devices: string[] }[],
|
||||
cameraScene?: string,
|
||||
) {
|
||||
const config = configFactory({
|
||||
models,
|
||||
...(cameraScene
|
||||
? { cameras: { front_door: { detect: { scene: cameraScene } } } }
|
||||
: {}),
|
||||
} as never);
|
||||
|
||||
await page.route("**/api/config/schema.json", (route) =>
|
||||
route.fulfill({ json: CONFIG_SCHEMA }),
|
||||
);
|
||||
await page.route("**/api/config", (route) =>
|
||||
route.request().method() === "GET"
|
||||
? route.fulfill({ json: config })
|
||||
: route.fulfill({ json: { success: true } }),
|
||||
);
|
||||
await page.route("**/api/config/raw_paths", (route) =>
|
||||
route.fulfill({ json: {} }),
|
||||
);
|
||||
}
|
||||
|
||||
test.describe("camera detect scene @medium", () => {
|
||||
test("is hidden when there is only one model", async ({ frigateApp }) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "default", devices: ["cpu"] },
|
||||
]);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
const root = frigateApp.page.locator("#pageRoot");
|
||||
await expect(root).toContainText("Detect FPS");
|
||||
await expect(root).not.toContainText("Detect scene");
|
||||
});
|
||||
|
||||
test("offers the default and configured model scenes", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "default", devices: ["cpu"] },
|
||||
{ scene: "thermal", devices: ["openvino:GPU.0"] },
|
||||
]);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
const root = frigateApp.page.locator("#pageRoot");
|
||||
await expect(root).toContainText("Detect scene");
|
||||
|
||||
await frigateApp.page.locator("#root_scene").click();
|
||||
const options = frigateApp.page.getByRole("option");
|
||||
await expect(options).toHaveText(["Default", "thermal"]);
|
||||
});
|
||||
|
||||
test("a saved scene no model uses stays editable", async ({ frigateApp }) => {
|
||||
// the camera falls back to the default model, but its saved scene would
|
||||
// silently take effect if a model for it were added later
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
[{ scene: "default", devices: ["cpu"] }],
|
||||
"garage",
|
||||
);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
|
||||
"Detect scene",
|
||||
);
|
||||
|
||||
await frigateApp.page.locator("#root_scene").click();
|
||||
await expect(frigateApp.page.getByRole("option")).toHaveText([
|
||||
"Default",
|
||||
"garage",
|
||||
]);
|
||||
});
|
||||
|
||||
test("default is only offered when a default model exists", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
[
|
||||
{ scene: "thermal", devices: ["cpu"] },
|
||||
{ scene: "visible", devices: ["openvino:GPU.0"] },
|
||||
],
|
||||
"thermal",
|
||||
);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
await frigateApp.page.locator("#root_scene").click();
|
||||
await expect(frigateApp.page.getByRole("option")).toHaveText([
|
||||
"thermal",
|
||||
"visible",
|
||||
]);
|
||||
});
|
||||
|
||||
test("one model every camera selects needs no choice", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
[{ scene: "thermal", devices: ["cpu"] }],
|
||||
"thermal",
|
||||
);
|
||||
await frigateApp.goto(SETTINGS_URL);
|
||||
|
||||
const root = frigateApp.page.locator("#pageRoot");
|
||||
await expect(root).toContainText("Detect FPS");
|
||||
await expect(root).not.toContainText("Detect scene");
|
||||
});
|
||||
});
|
||||
@@ -129,19 +129,19 @@ const openPage = async (frigateApp: {
|
||||
test.describe("Detection models settings @high", () => {
|
||||
test("renders a card per configured model", async ({ frigateApp }) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["cpu"] },
|
||||
{ scene: "default", devices: ["cpu"] },
|
||||
{ scene: "outdoor", devices: ["edgetpu:pci:0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
const root = frigateApp.page.locator("#pageRoot");
|
||||
await expect(root).toContainText("All cameras");
|
||||
await expect(root).toContainText("Outdoor");
|
||||
await expect(root).toContainText("Default");
|
||||
await expect(root).toContainText("outdoor");
|
||||
});
|
||||
|
||||
test("unlimited hardware offers a detector count", async ({ frigateApp }) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["openvino:GPU.0"] },
|
||||
{ scene: "default", devices: ["openvino:GPU.0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
@@ -169,7 +169,7 @@ test.describe("Detection models settings @high", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["openvino:GPU.0", "openvino:GPU.0"] },
|
||||
{ scene: "default", devices: ["openvino:GPU.0", "openvino:GPU.0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
@@ -190,7 +190,7 @@ test.describe("Detection models settings @high", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["edgetpu:pci:0"] },
|
||||
{ scene: "default", devices: ["edgetpu:pci:0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
@@ -206,7 +206,7 @@ test.describe("Detection models settings @high", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["edgetpu:pci:0"] },
|
||||
{ scene: "default", devices: ["edgetpu:pci:0"] },
|
||||
{ scene: "outdoor", devices: ["edgetpu:pci:1"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
@@ -221,7 +221,7 @@ test.describe("Detection models settings @high", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["openvino:GPU.0"] },
|
||||
{ scene: "default", devices: ["openvino:GPU.0"] },
|
||||
{ scene: "outdoor", devices: ["openvino:GPU.1"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
@@ -234,23 +234,141 @@ test.describe("Detection models settings @high", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("adding a model appends a card with an unused scene", async ({
|
||||
test("adding a model appends a card with a scene to name", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [{ scene: "all", devices: ["cpu"] }]);
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "default", devices: ["cpu"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await frigateApp.page.getByRole("button", { name: "Add model" }).click();
|
||||
|
||||
// "all" is taken, so the new card takes the next available scene
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toContainText("Indoor");
|
||||
// the new card's scene starts empty, and a scene is required to save
|
||||
const scene = frigateApp.page.locator("#models-1-scene");
|
||||
await expect(scene).toHaveValue("");
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: /^Save$/ }),
|
||||
).toBeDisabled();
|
||||
|
||||
// the card keeps focus while its scene is typed
|
||||
await scene.fill("thermal");
|
||||
await expect(scene).toHaveValue("thermal");
|
||||
await expect(scene).toBeFocused();
|
||||
});
|
||||
|
||||
test("one model file cannot be split across scenes", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{
|
||||
scene: "default",
|
||||
devices: ["openvino:GPU.0"],
|
||||
path: "/config/model_cache/yolo.onnx",
|
||||
},
|
||||
{
|
||||
scene: "driveway",
|
||||
devices: ["openvino:GPU.1"],
|
||||
path: "/config/model_cache/other.onnx",
|
||||
},
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await frigateApp.page
|
||||
.locator("#root_1_path")
|
||||
.fill("/config/model_cache/yolo.onnx");
|
||||
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
|
||||
"The Default and driveway models use the same model file",
|
||||
);
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: /^Save$/ }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("two models without a path share the detector's default model", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "default", devices: ["cpu"] },
|
||||
{ scene: "driveway", devices: ["cpu"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
|
||||
"The Default and driveway models use the same model file",
|
||||
);
|
||||
});
|
||||
|
||||
test("another spelling of the same path is the same model", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{
|
||||
scene: "default",
|
||||
devices: ["openvino:GPU.0"],
|
||||
path: "/config/model_cache/yolo.onnx",
|
||||
},
|
||||
{
|
||||
scene: "driveway",
|
||||
devices: ["openvino:GPU.1"],
|
||||
path: "/config//model_cache/./tmp/../yolo.onnx",
|
||||
},
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
|
||||
"The Default and driveway models use the same model file",
|
||||
);
|
||||
});
|
||||
|
||||
test("one model file may run on different detectors", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{
|
||||
scene: "default",
|
||||
devices: ["openvino:GPU.0"],
|
||||
path: "/config/model_cache/yolo.onnx",
|
||||
},
|
||||
{
|
||||
scene: "driveway",
|
||||
devices: ["onnx"],
|
||||
path: "/config/model_cache/yolo.onnx",
|
||||
},
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
|
||||
"driveway",
|
||||
);
|
||||
await expect(frigateApp.page.locator("#pageRoot")).not.toContainText(
|
||||
"models use the same model file",
|
||||
);
|
||||
});
|
||||
|
||||
test("two models cannot share a scene", async ({ frigateApp }) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "default", devices: ["cpu"] },
|
||||
{ scene: "thermal", devices: ["edgetpu:pci:0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
await frigateApp.page.locator("#models-1-scene").fill("default");
|
||||
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
|
||||
"Each model must use a different scene",
|
||||
);
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: /^Save$/ }),
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
test("hardware is summarized rather than listed device by device", async ({
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["openvino:GPU.0", "openvino:GPU.0"] },
|
||||
{ scene: "default", devices: ["openvino:GPU.0", "openvino:GPU.0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
@@ -269,7 +387,7 @@ test.describe("Detection models settings @high", () => {
|
||||
frigateApp.page,
|
||||
[
|
||||
{
|
||||
scene: "all",
|
||||
scene: "default",
|
||||
devices: ["openvino:GPU.0"],
|
||||
path: "plus://abc123",
|
||||
plus: PLUS_MODEL,
|
||||
@@ -294,7 +412,7 @@ test.describe("Detection models settings @high", () => {
|
||||
frigateApp.page,
|
||||
[
|
||||
{
|
||||
scene: "all",
|
||||
scene: "default",
|
||||
devices: ["openvino:GPU.0"],
|
||||
path: "/config/custom.onnx",
|
||||
},
|
||||
@@ -325,7 +443,7 @@ test.describe("Detection models settings @high", () => {
|
||||
frigateApp.page,
|
||||
[
|
||||
{
|
||||
scene: "all",
|
||||
scene: "default",
|
||||
devices: ["openvino:GPU.0"],
|
||||
path: "plus://abc123",
|
||||
plus: PLUS_MODEL,
|
||||
@@ -359,7 +477,7 @@ test.describe("Detection models settings @high", () => {
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{
|
||||
scene: "all",
|
||||
scene: "default",
|
||||
devices: ["openvino:GPU.0"],
|
||||
path: "plus://abc123",
|
||||
width: 320,
|
||||
@@ -383,7 +501,13 @@ test.describe("Detection models settings @high", () => {
|
||||
// says nothing; which device it was built for is what the user picks on
|
||||
await installRoutes(
|
||||
frigateApp.page,
|
||||
[{ scene: "all", devices: ["hailo:PCIe"], path: "/config/custom.hef" }],
|
||||
[
|
||||
{
|
||||
scene: "default",
|
||||
devices: ["hailo:PCIe"],
|
||||
path: "/config/custom.hef",
|
||||
},
|
||||
],
|
||||
true,
|
||||
HAILO_PLUS_MODELS,
|
||||
true,
|
||||
@@ -413,7 +537,7 @@ test.describe("Detection models settings @high", () => {
|
||||
// that must not read as an edit.
|
||||
await installRoutes(frigateApp.page, [
|
||||
{
|
||||
scene: "all",
|
||||
scene: "default",
|
||||
devices: ["openvino:GPU.0", "openvino:GPU.0"],
|
||||
path: "/config/model_cache/abc123",
|
||||
width: 320,
|
||||
@@ -438,12 +562,14 @@ test.describe("Detection models settings @high", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["openvino:GPU.0"] },
|
||||
{ scene: "default", devices: ["openvino:GPU.0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
const root = frigateApp.page.locator("#pageRoot");
|
||||
await expect(root).toContainText("The environment this model is for");
|
||||
await expect(root).toContainText(
|
||||
"A name for the cameras this model is for",
|
||||
);
|
||||
await expect(root).toContainText(
|
||||
"The hardware this model runs its detection on",
|
||||
);
|
||||
@@ -454,7 +580,7 @@ test.describe("Detection models settings @high", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["edgetpu:pci:0"] },
|
||||
{ scene: "default", devices: ["edgetpu:pci:0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
@@ -465,10 +591,10 @@ test.describe("Detection models settings @high", () => {
|
||||
});
|
||||
|
||||
test("removing the default model blocks saving", async ({ frigateApp }) => {
|
||||
// a camera that names no scene runs the "all" model, so deleting it would
|
||||
// a camera that names no scene runs the default model, so deleting it would
|
||||
// leave those cameras with nothing to fall back to
|
||||
await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["cpu"] },
|
||||
{ scene: "default", devices: ["cpu"] },
|
||||
{ scene: "outdoor", devices: ["edgetpu:pci:0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
@@ -479,7 +605,7 @@ test.describe("Detection models settings @high", () => {
|
||||
.click();
|
||||
|
||||
await expect(frigateApp.page.locator("#pageRoot")).toContainText(
|
||||
"One model must use a scene of 'All cameras'",
|
||||
"One model must use the default scene",
|
||||
);
|
||||
await expect(
|
||||
frigateApp.page.getByRole("button", { name: /^Save$/ }),
|
||||
@@ -490,7 +616,7 @@ test.describe("Detection models settings @high", () => {
|
||||
// shareable hardware can report several addressable units; every one of
|
||||
// them must be reachable, not just the first
|
||||
const saves = await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["openvino:GPU.0"] },
|
||||
{ scene: "default", devices: ["openvino:GPU.0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
@@ -508,7 +634,7 @@ test.describe("Detection models settings @high", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
const saves = await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["openvino:GPU.0", "openvino:GPU.1"] },
|
||||
{ scene: "default", devices: ["openvino:GPU.0", "openvino:GPU.1"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
@@ -531,7 +657,7 @@ test.describe("Detection models settings @high", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
const saves = await installRoutes(frigateApp.page, [
|
||||
{ scene: "all", devices: ["edgetpu:pci:0"] },
|
||||
{ scene: "default", devices: ["edgetpu:pci:0"] },
|
||||
]);
|
||||
await openPage(frigateApp);
|
||||
|
||||
|
||||
@@ -483,7 +483,7 @@ test.describe("System — Health hardware pane @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: { models: [{ scene: "all", devices: ["openvino:GPU"] }] },
|
||||
config: { models: [{ scene: "default", devices: ["openvino:GPU"] }] },
|
||||
stats: {
|
||||
...QUIET_STATS,
|
||||
detectors: { "openvino:GPU": { inference_speed: 12.3 } },
|
||||
@@ -500,7 +500,7 @@ test.describe("System — Health hardware pane @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: { models: [{ scene: "all", devices: ["hailo"] }] },
|
||||
config: { models: [{ scene: "default", devices: ["hailo"] }] },
|
||||
stats: QUIET_STATS,
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
@@ -516,7 +516,7 @@ test.describe("System — Health hardware pane @medium", () => {
|
||||
frigateApp,
|
||||
}) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: { models: [{ scene: "all", devices: ["openvino:AUTO"] }] },
|
||||
config: { models: [{ scene: "default", devices: ["openvino:AUTO"] }] },
|
||||
stats: {
|
||||
...QUIET_STATS,
|
||||
detectors: { "openvino:AUTO": { inference_speed: 12 } },
|
||||
@@ -536,7 +536,7 @@ test.describe("System — Health hardware pane @medium", () => {
|
||||
}) => {
|
||||
// the default image runs onnx on the CPU and the probe reports nothing
|
||||
await frigateApp.installDefaults({
|
||||
config: { models: [{ scene: "all", devices: ["onnx"] }] },
|
||||
config: { models: [{ scene: "default", devices: ["onnx"] }] },
|
||||
stats: { ...QUIET_STATS, detectors: { onnx: { inference_speed: 40 } } },
|
||||
});
|
||||
await frigateApp.goto("/system#health");
|
||||
@@ -549,7 +549,7 @@ test.describe("System — Health hardware pane @medium", () => {
|
||||
|
||||
test("detection row warns on slow inference", async ({ frigateApp }) => {
|
||||
await frigateApp.installDefaults({
|
||||
config: { models: [{ scene: "all", devices: ["openvino:GPU"] }] },
|
||||
config: { models: [{ scene: "default", devices: ["openvino:GPU"] }] },
|
||||
stats: {
|
||||
...QUIET_STATS,
|
||||
detectors: { "openvino:GPU": { inference_speed: 60 } },
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
},
|
||||
"scene": {
|
||||
"label": "Detect scene",
|
||||
"description": "The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'all' run the model configured with a scene of 'all'."
|
||||
"description": "The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'default' run the model configured with a scene of 'default'."
|
||||
},
|
||||
"fps": {
|
||||
"label": "Detect FPS",
|
||||
|
||||
@@ -277,10 +277,10 @@
|
||||
},
|
||||
"models": {
|
||||
"label": "Detection models",
|
||||
"description": "Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene.",
|
||||
"description": "Object detection models and the hardware each one runs on. Cameras pick a model by matching their detect.scene against a model's scene, falling back to the 'default' model.",
|
||||
"scene": {
|
||||
"label": "Model scene",
|
||||
"description": "The camera environment this model is used for. Cameras select a model by setting detect.scene to a matching value, and 'all' is used by any camera that does not set one."
|
||||
"description": "A name for the camera environment this model is used for, such as 'thermal'. Cameras select a model by setting detect.scene to a matching value, and the 'default' model is used by any camera that does not set one."
|
||||
},
|
||||
"devices": {
|
||||
"label": "Detection hardware",
|
||||
@@ -468,7 +468,7 @@
|
||||
},
|
||||
"scene": {
|
||||
"label": "Detect scene",
|
||||
"description": "The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'all' run the model configured with a scene of 'all'."
|
||||
"description": "The environment this camera looks at, used to pick which of the configured models runs on it. Cameras left on 'default' run the model configured with a scene of 'default'."
|
||||
},
|
||||
"fps": {
|
||||
"label": "Detect FPS",
|
||||
|
||||
@@ -33,6 +33,8 @@
|
||||
"dimensionMustBeEven": "Must be an even number."
|
||||
},
|
||||
"models": {
|
||||
"defaultRequired": "One model must use a scene of 'All cameras'. Without it, any camera that does not choose a scene has no model to fall back to."
|
||||
"defaultRequired": "One model must use the default scene. Without it, any camera that does not choose a scene has no model to fall back to.",
|
||||
"sceneDuplicate": "Each model must use a different scene.",
|
||||
"sameModel": "The {{scene}} and {{other}} models use the same model file. Running one model under several scenes only splits the detection work across separate detectors, which is slower than one model with all of the hardware listed under it."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1986,7 +1986,7 @@
|
||||
"fpsGreaterThanFive": "Setting the detect FPS higher than 5 is not recommended. Higher values may cause performance issues and will not provide any benefit.",
|
||||
"disabled": "Object detection is disabled. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function.",
|
||||
"runtimeDisabled": "Object detection is enabled in your config, but it is currently turned off for this camera. Snapshots, review items, and enrichments such as face recognition, license plate recognition, and Generative AI will not function until it is turned back on from the camera's live view, or until the active profile stops disabling it.",
|
||||
"sceneWithoutModel": "No detection model is configured for this scene, so this camera falls back to the model with a scene of 'All cameras'. Add a model for this scene to give the camera its own.",
|
||||
"sceneWithoutModel": "No detection model is configured for this scene, so this camera falls back to the Default model. Add a model for this scene to give the camera its own.",
|
||||
"resolutionShouldBeMultipleOfFour": "For best results, detect width and height should be multiples of 4. Other even values may produce visual artifacts or slight distortion in the detect stream.",
|
||||
"aspectRatioMismatch": "The width and height you've entered don't match the aspect ratio of your current detect resolution. This may produce a stretched or distorted image.",
|
||||
"maxFramesSet": "Setting max frames overrides default behavior and disables stationary object tracking. There are very few situations where this is needed, use with caution.",
|
||||
@@ -2036,20 +2036,17 @@
|
||||
},
|
||||
"detectionModels": {
|
||||
"title": "Detection models",
|
||||
"description": "Configure the object detection models and the hardware each one runs on. Cameras choose a model by their scene in the camera's detect settings.",
|
||||
"description": "Configure the object detection models and the hardware each one runs on. Add a model for a scene only when some cameras need a differently trained model, such as thermal cameras. Cameras choose a model by their scene in the camera's detect settings.",
|
||||
"addModel": "Add model",
|
||||
"cameras_one": "{{count}} camera",
|
||||
"cameras_other": "{{count}} cameras",
|
||||
"scene": {
|
||||
"label": "Scene",
|
||||
"description": "The environment this model is for. Cameras pick a model by setting the same scene in their detect settings, and the model with a scene of all is used by any camera that does not set one."
|
||||
"placeholder": "thermal",
|
||||
"description": "A name for the cameras this model is for. Cameras pick a model by setting the same scene in their detect settings, and the Default model is used by any camera that does not set one. Use scenes to run different models, not to split one model across hardware, since models that use the same model file are combined."
|
||||
},
|
||||
"scenes": {
|
||||
"all": "All cameras",
|
||||
"indoor": "Indoor",
|
||||
"outdoor": "Outdoor",
|
||||
"indoor_thermal": "Indoor thermal",
|
||||
"outdoor_thermal": "Outdoor thermal"
|
||||
"default": "Default"
|
||||
},
|
||||
"hardware": {
|
||||
"label": "Hardware",
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { HiddenFieldContext } from "@/types/configForm";
|
||||
import { DEFAULT_SCENE, getSceneChoices } from "@/utils/modelUtil";
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
// picking a scene only means something when there is more than one choice,
|
||||
// which includes a saved scene that no model uses
|
||||
const hideSceneWithoutChoice = (ctx: HiddenFieldContext): string[] =>
|
||||
getSceneChoices(ctx).length > 1 ? [] : ["scene"];
|
||||
|
||||
const detect: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/camera_specific",
|
||||
@@ -123,7 +130,7 @@ const detect: SectionConfigOverrides = {
|
||||
docLink: "/configuration/object_detectors#running-more-than-one-model",
|
||||
condition: (ctx) => {
|
||||
const scene = ctx.formData?.scene as string | undefined;
|
||||
if (!scene || scene === "all") return false;
|
||||
if (!scene || scene === DEFAULT_SCENE) return false;
|
||||
const models = ctx.fullConfig?.models;
|
||||
if (!models) return false;
|
||||
return !models.some((model) => model.scene === scene);
|
||||
@@ -194,6 +201,7 @@ const detect: SectionConfigOverrides = {
|
||||
scene: {
|
||||
"ui:options": {
|
||||
enumI18nPrefix: "detectionModels.scenes",
|
||||
enumI18nOptional: true,
|
||||
},
|
||||
},
|
||||
annotation_offset: {
|
||||
@@ -202,7 +210,7 @@ const detect: SectionConfigOverrides = {
|
||||
},
|
||||
},
|
||||
},
|
||||
hiddenFields: ["enabled_in_config"],
|
||||
hiddenFields: ["enabled_in_config", hideSceneWithoutChoice],
|
||||
advancedFields: [
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FormValidation } from "@rjsf/utils";
|
||||
import type { TFunction } from "i18next";
|
||||
import { validateDetectDimensions } from "./detect";
|
||||
import { validateFfmpegInputRoles } from "./ffmpeg";
|
||||
import { validateDefaultModelExists } from "./models";
|
||||
import { validateModelScenes } from "./models";
|
||||
import { validateProxyRoleHeader } from "./proxy";
|
||||
|
||||
export type SectionValidation = (
|
||||
@@ -30,8 +30,7 @@ export function getSectionValidation({
|
||||
}
|
||||
|
||||
if (sectionPath === "models") {
|
||||
return (formData, errors) =>
|
||||
validateDefaultModelExists(formData, errors, t);
|
||||
return (formData, errors) => validateModelScenes(formData, errors, t);
|
||||
}
|
||||
|
||||
if (sectionPath === "proxy" && level === "global") {
|
||||
|
||||
@@ -1,16 +1,64 @@
|
||||
import type { FormValidation } from "@rjsf/utils";
|
||||
import type { TFunction } from "i18next";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import { DEFAULT_SCENE, getSceneLabel } from "@/utils/modelUtil";
|
||||
|
||||
const DEFAULT_SCENE = "all";
|
||||
/** The model the backend loads for a detector when no path is configured. */
|
||||
function defaultModelPath(detector: string): string | undefined {
|
||||
if (detector === "cpu" || detector.endsWith("_tfl")) {
|
||||
return "/cpu_model.tflite";
|
||||
}
|
||||
|
||||
if (detector === "edgetpu") {
|
||||
return "/edgetpu_model.tflite";
|
||||
}
|
||||
|
||||
if (detector === "openvino") {
|
||||
return "/openvino-model/ssdlite_mobilenet_v2.xml";
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* A camera that names no scene runs the model whose scene is `all`. Without one
|
||||
* the backend rejects the config outright once a second model exists, and with
|
||||
* a single model it silently runs every camera on whatever that model is. Both
|
||||
* are surprising, so require the default to be present.
|
||||
* Collapse `//`, `.`, and `..` so spellings of one file path compare equal,
|
||||
* as the backend's realpath does. Symlinks and copies can only be seen by the
|
||||
* backend, which still combines them at startup.
|
||||
*/
|
||||
export function validateDefaultModelExists(
|
||||
function normalizeModelPath(path: string): string {
|
||||
// plus://<id> and other URLs are compared as written
|
||||
if (path.includes("://")) {
|
||||
return path;
|
||||
}
|
||||
|
||||
const segments: string[] = [];
|
||||
|
||||
for (const segment of path.split("/")) {
|
||||
if (segment === "" || segment === ".") {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (segment === ".." && segments.length && segments.at(-1) !== "..") {
|
||||
segments.pop();
|
||||
} else if (segment !== ".." || !path.startsWith("/")) {
|
||||
segments.push(segment);
|
||||
}
|
||||
}
|
||||
|
||||
return `${path.startsWith("/") ? "/" : ""}${segments.join("/")}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* A camera that names no scene runs the model whose scene is `default`.
|
||||
* Without one the backend rejects the config outright once a second model
|
||||
* exists, and with a single model it silently runs every camera on whatever
|
||||
* that model is. Both are surprising, so require the default to be present.
|
||||
* Scenes are free-form names, so also check that no two models share one, and
|
||||
* that one model isn't split across scenes. The backend combines models that
|
||||
* load the same file on the same detector, so running one model per scene only
|
||||
* splits the detection work across separate detectors.
|
||||
*/
|
||||
export function validateModelScenes(
|
||||
formData: unknown,
|
||||
errors: FormValidation,
|
||||
t: TFunction,
|
||||
@@ -19,13 +67,54 @@ export function validateDefaultModelExists(
|
||||
return errors;
|
||||
}
|
||||
|
||||
const hasDefault = formData.some(
|
||||
(model) => isJsonObject(model) && model.scene === DEFAULT_SCENE,
|
||||
const scenes = formData.map((model) =>
|
||||
isJsonObject(model) && typeof model.scene === "string" ? model.scene : "",
|
||||
);
|
||||
|
||||
if (!hasDefault) {
|
||||
if (!scenes.includes(DEFAULT_SCENE)) {
|
||||
errors.addError?.(t("models.defaultRequired", { ns: "config/validation" }));
|
||||
}
|
||||
|
||||
if (new Set(scenes).size !== scenes.length) {
|
||||
errors.addError?.(t("models.sceneDuplicate", { ns: "config/validation" }));
|
||||
}
|
||||
|
||||
// detector and path -> the scene of the first model using them
|
||||
const seen = new Map<string, string>();
|
||||
|
||||
formData.forEach((model, index) => {
|
||||
if (!isJsonObject(model)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const devices = Array.isArray(model.devices) ? model.devices : [];
|
||||
const detector =
|
||||
typeof devices[0] === "string" ? devices[0].split(":")[0] : "";
|
||||
const path =
|
||||
typeof model.path === "string" && model.path
|
||||
? normalizeModelPath(model.path)
|
||||
: defaultModelPath(detector);
|
||||
|
||||
if (!path) {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${detector}|${path}`;
|
||||
const other = seen.get(key);
|
||||
|
||||
if (other === undefined) {
|
||||
seen.set(key, scenes[index]);
|
||||
return;
|
||||
}
|
||||
|
||||
errors.addError?.(
|
||||
t("models.sameModel", {
|
||||
ns: "config/validation",
|
||||
scene: getSceneLabel(t, other),
|
||||
other: getSceneLabel(t, scenes[index]),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import { HiddenFieldContext, JsonObject, JsonValue } from "@/types/configForm";
|
||||
import { getEffectiveAttributeLabels } from "@/utils/configUtil";
|
||||
import { getSceneChoices } from "@/utils/modelUtil";
|
||||
|
||||
/**
|
||||
* Sections that require special handling at the global level.
|
||||
@@ -38,6 +39,8 @@ export function isSpecialCaseSection(
|
||||
* Modify schema for sections that need defaults stripped or other modifications.
|
||||
*
|
||||
* - genai: Inject a default provider value on the additionalProperties shape.
|
||||
* - detect: Scenes are free-form names, so offer the configured model scenes
|
||||
* (and a saved scene no model uses) as the choices for `scene`.
|
||||
* - objects: Promote tracked attribute labels (face, license_plate, courier
|
||||
* logos) from `filters.additionalProperties` to explicit
|
||||
* `filters.properties.<attr>` entries with a restricted FilterConfig
|
||||
@@ -58,6 +61,10 @@ export function modifySchemaForSection(
|
||||
return modifyObjectsSchema(schema, ctx);
|
||||
}
|
||||
|
||||
if (sectionPath === "detect") {
|
||||
return modifyDetectSchema(schema, ctx);
|
||||
}
|
||||
|
||||
if (!isSpecialCaseSection(sectionPath, level)) {
|
||||
return schema;
|
||||
}
|
||||
@@ -88,6 +95,24 @@ export function modifySchemaForSection(
|
||||
return schema;
|
||||
}
|
||||
|
||||
function modifyDetectSchema(
|
||||
schema: RJSFSchema,
|
||||
ctx: HiddenFieldContext | undefined,
|
||||
): RJSFSchema {
|
||||
const properties = (schema as { properties?: Record<string, RJSFSchema> })
|
||||
.properties;
|
||||
|
||||
if (!ctx || !properties?.scene) return schema;
|
||||
|
||||
return {
|
||||
...schema,
|
||||
properties: {
|
||||
...properties,
|
||||
scene: { ...properties.scene, enum: getSceneChoices(ctx) },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a stripped FilterConfig schema for tracked attribute filters
|
||||
* (face, license_plate, etc.). Keeps only the fields meaningful for
|
||||
|
||||
@@ -22,14 +22,8 @@ import {
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -39,6 +33,7 @@ import type { ConfigFormContext } from "@/types/configForm";
|
||||
import useSWR from "swr";
|
||||
import { DetectionHardware } from "@/types/hardware";
|
||||
import { summarizeDevices } from "@/utils/detectionHardware";
|
||||
import { DEFAULT_SCENE, getSceneLabel } from "@/utils/modelUtil";
|
||||
import { HardwarePicker } from "./HardwarePicker";
|
||||
import { ModelSourcePicker } from "./ModelSourcePicker";
|
||||
|
||||
@@ -103,16 +98,6 @@ const getItemProperties = (
|
||||
return schema.properties as Record<string, RJSFSchema>;
|
||||
};
|
||||
|
||||
const getSceneOptions = (itemSchema: RJSFSchema | undefined): string[] => {
|
||||
const scene = getItemProperties(itemSchema).scene as
|
||||
Record<string, unknown> | undefined;
|
||||
const values = scene?.enum;
|
||||
|
||||
return Array.isArray(values)
|
||||
? values.filter((v): v is string => typeof v === "string")
|
||||
: [];
|
||||
};
|
||||
|
||||
export function ModelsField(props: FieldProps) {
|
||||
const {
|
||||
schema,
|
||||
@@ -147,10 +132,15 @@ export function ModelsField(props: FieldProps) {
|
||||
((uiSchema as { items?: UiSchema } | undefined)?.items ?? {}) as UiSchema,
|
||||
[uiSchema],
|
||||
);
|
||||
const sceneOptions = useMemo(() => getSceneOptions(itemSchema), [itemSchema]);
|
||||
const SchemaField = registry.fields.SchemaField;
|
||||
|
||||
const [openByIndex, setOpenByIndex] = useState<Record<number, boolean>>({});
|
||||
// scenes are edited in place, so cards need a key that survives a rename and
|
||||
// doesn't hand a deleted card's state (such as the model source tab) to the
|
||||
// next one
|
||||
const [cardKeys, setCardKeys] = useState<number[]>(() =>
|
||||
models.map((_, index) => index),
|
||||
);
|
||||
|
||||
// shared with HardwarePicker through the SWR cache, so this is not a second
|
||||
// request
|
||||
@@ -164,34 +154,53 @@ export function ModelsField(props: FieldProps) {
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setCardKeys((previous) => {
|
||||
if (previous.length === models.length) {
|
||||
return previous;
|
||||
}
|
||||
|
||||
const next = previous.slice(0, models.length);
|
||||
let key = Math.max(-1, ...previous);
|
||||
while (next.length < models.length) {
|
||||
key += 1;
|
||||
next.push(key);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}, [models.length]);
|
||||
|
||||
const cameras = formContext?.fullConfig?.cameras;
|
||||
const savedModels = formContext?.fullConfig?.models;
|
||||
|
||||
// `plus` is a readonly field stripped from the form data, so read it from the
|
||||
// full config. Match on scene rather than index, which shifts when a model is
|
||||
// added or removed.
|
||||
const savedPlusForScene = useCallback(
|
||||
(scene: string | undefined) =>
|
||||
savedModels?.find((saved) => saved.scene === scene)?.plus,
|
||||
// full config. Match on path rather than index, which shifts when a model is
|
||||
// added or removed, or scene, which can be renamed.
|
||||
const savedPlusForPath = useCallback(
|
||||
(path: unknown) =>
|
||||
typeof path === "string"
|
||||
? savedModels?.find((saved) => saved.path === path)?.plus
|
||||
: undefined,
|
||||
[savedModels],
|
||||
);
|
||||
|
||||
// a model serves the cameras naming its scene, and like the backend, the
|
||||
// "all" model also serves every camera whose scene has no model of its own
|
||||
// default model also serves every camera whose scene has no model of its own
|
||||
const cameraCountForScene = useCallback(
|
||||
(scene: string | undefined): number => {
|
||||
if (!cameras) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const modelScenes = new Set(models.map((model) => model.scene ?? "all"));
|
||||
const modelScenes = new Set(
|
||||
models.map((model) => model.scene ?? DEFAULT_SCENE),
|
||||
);
|
||||
|
||||
return Object.values(cameras).filter((camera) => {
|
||||
const cameraScene = camera?.detect?.scene ?? "all";
|
||||
const servedBy = modelScenes.has(cameraScene) ? cameraScene : "all";
|
||||
return servedBy === (scene ?? "all");
|
||||
const cameraScene = camera?.detect?.scene ?? DEFAULT_SCENE;
|
||||
const servedBy = modelScenes.has(cameraScene)
|
||||
? cameraScene
|
||||
: DEFAULT_SCENE;
|
||||
return servedBy === (scene ?? DEFAULT_SCENE);
|
||||
}).length;
|
||||
},
|
||||
[cameras, models],
|
||||
@@ -207,13 +216,15 @@ export function ModelsField(props: FieldProps) {
|
||||
}
|
||||
|
||||
(model.devices ?? []).forEach((device) => {
|
||||
claimed[device] = model.scene ?? String(currentIndex + 1);
|
||||
claimed[device] = model.scene
|
||||
? getSceneLabel(t, model.scene)
|
||||
: String(currentIndex + 1);
|
||||
});
|
||||
});
|
||||
|
||||
return claimed;
|
||||
},
|
||||
[models],
|
||||
[models, t],
|
||||
);
|
||||
|
||||
const updateModel = useCallback(
|
||||
@@ -229,12 +240,15 @@ export function ModelsField(props: FieldProps) {
|
||||
const base = itemSchema
|
||||
? (applySchemaDefaults(itemSchema) as DetectionModel)
|
||||
: ({} as DetectionModel);
|
||||
const taken = new Set(models.map((model) => model.scene));
|
||||
const scene = sceneOptions.find((option) => !taken.has(option));
|
||||
|
||||
onChange([...models, { ...base, scene, devices: [] }], fieldPathId.path);
|
||||
// the default scene is almost always taken, so leave the new model's
|
||||
// scene for the user to name
|
||||
onChange(
|
||||
[...models, { ...base, scene: "", devices: [] }],
|
||||
fieldPathId.path,
|
||||
);
|
||||
setOpenByIndex((previous) => ({ ...previous, [models.length]: true }));
|
||||
}, [models, itemSchema, sceneOptions, onChange, fieldPathId.path]);
|
||||
setCardKeys((previous) => [...previous, Math.max(-1, ...previous) + 1]);
|
||||
}, [models, itemSchema, onChange, fieldPathId.path]);
|
||||
|
||||
const handleRemoveModel = useCallback(
|
||||
(index: number) => {
|
||||
@@ -254,6 +268,9 @@ export function ModelsField(props: FieldProps) {
|
||||
});
|
||||
return next;
|
||||
});
|
||||
setCardKeys((previous) =>
|
||||
previous.filter((_, currentIndex) => currentIndex !== index),
|
||||
);
|
||||
},
|
||||
[models, onChange, fieldPathId.path],
|
||||
);
|
||||
@@ -319,16 +336,16 @@ export function ModelsField(props: FieldProps) {
|
||||
<div className="space-y-4">
|
||||
{models.map((model, index) => {
|
||||
const open = openByIndex[index] ?? true;
|
||||
const takenScenes = new Set(
|
||||
models
|
||||
.filter((_, currentIndex) => currentIndex !== index)
|
||||
.map((other) => other.scene),
|
||||
);
|
||||
const sceneErrors = (
|
||||
(errorSchema as Record<string, ErrorSchema> | undefined)?.[index] as
|
||||
Record<string, ErrorSchema> | undefined
|
||||
)?.scene?.__errors;
|
||||
|
||||
return (
|
||||
// keyed by scene, which is unique per model, so deleting a card
|
||||
// doesn't hand its state (such as the model source tab) to the next
|
||||
<Card key={`${baseId}-${model.scene ?? index}`} className="w-full">
|
||||
<Card
|
||||
key={`${baseId}-${cardKeys[index] ?? index}`}
|
||||
className="w-full"
|
||||
>
|
||||
<Collapsible
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) =>
|
||||
@@ -342,9 +359,7 @@ export function ModelsField(props: FieldProps) {
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<CollapsibleTrigger asChild>
|
||||
<CardTitle className="flex-1 cursor-pointer text-sm">
|
||||
<span>
|
||||
{t(`detectionModels.scenes.${model.scene ?? "all"}`)}
|
||||
</span>
|
||||
<span>{getSceneLabel(t, model.scene)}</span>
|
||||
<span className="mt-1 block text-xs font-normal text-muted-foreground">
|
||||
{summarizeDevices(
|
||||
hardware ?? [],
|
||||
@@ -401,27 +416,24 @@ export function ModelsField(props: FieldProps) {
|
||||
<CollapsibleContent>
|
||||
<CardContent className="space-y-6 p-4 pt-0">
|
||||
<div className="space-y-1">
|
||||
<Label>{t("detectionModels.scene.label")}</Label>
|
||||
<Select
|
||||
<Label htmlFor={`${baseId}-${index}-scene`}>
|
||||
{t("detectionModels.scene.label")}
|
||||
</Label>
|
||||
<Input
|
||||
id={`${baseId}-${index}-scene`}
|
||||
className="max-w-xs"
|
||||
value={model.scene ?? ""}
|
||||
onValueChange={(scene) => updateModel(index, { scene })}
|
||||
placeholder={t("detectionModels.scene.placeholder")}
|
||||
onChange={(event) =>
|
||||
updateModel(index, { scene: event.target.value.trim() })
|
||||
}
|
||||
disabled={disabled || readonly}
|
||||
>
|
||||
<SelectTrigger className="max-w-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{sceneOptions.map((scene) => (
|
||||
<SelectItem
|
||||
key={scene}
|
||||
value={scene}
|
||||
disabled={takenScenes.has(scene)}
|
||||
>
|
||||
{t(`detectionModels.scenes.${scene}`)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
/>
|
||||
{!hideError && sceneErrors?.length ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{sceneErrors.join(", ")}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("detectionModels.scene.description")}
|
||||
</p>
|
||||
@@ -438,7 +450,7 @@ export function ModelsField(props: FieldProps) {
|
||||
|
||||
<ModelSourcePicker
|
||||
path={model.path}
|
||||
plus={savedPlusForScene(model.scene)}
|
||||
plus={savedPlusForPath(model.path)}
|
||||
detector={detectorForModel(model)}
|
||||
disabled={disabled || readonly}
|
||||
onPathChange={(path) => updateModel(index, { path })}
|
||||
@@ -453,19 +465,17 @@ export function ModelsField(props: FieldProps) {
|
||||
);
|
||||
})}
|
||||
|
||||
{models.length < sceneOptions.length ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddModel}
|
||||
disabled={disabled || readonly}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("detectionModels.addModel")}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddModel}
|
||||
disabled={disabled || readonly}
|
||||
className="gap-2"
|
||||
>
|
||||
<LuPlus className="h-4 w-4" />
|
||||
{t("detectionModels.addModel")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,14 +22,23 @@ export function SelectWidget(props: WidgetProps) {
|
||||
schema,
|
||||
} = props;
|
||||
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { t, i18n } = useTranslation(["views/settings"]);
|
||||
const { enumOptions = [] } = options;
|
||||
const enumI18nPrefix = options["enumI18nPrefix"] as string | undefined;
|
||||
const enumI18nOptional = options["enumI18nOptional"] === true;
|
||||
const fieldClassName = getSizedFieldClassName(options, "sm");
|
||||
|
||||
const getLabel = (option: { value: unknown; label: string }) => {
|
||||
if (enumI18nPrefix) {
|
||||
return t(`${enumI18nPrefix}.${option.value}`);
|
||||
const key = `${enumI18nPrefix}.${option.value}`;
|
||||
|
||||
// user-defined values (such as model scenes) are shown as named rather
|
||||
// than humanized by the missing key handler
|
||||
if (enumI18nOptional && !i18n.exists(key, { ns: "views/settings" })) {
|
||||
return String(option.value);
|
||||
}
|
||||
|
||||
return t(key);
|
||||
}
|
||||
return option.label;
|
||||
};
|
||||
|
||||
@@ -490,6 +490,7 @@ export interface FrigateConfig {
|
||||
height: number | null;
|
||||
max_disappeared: number | null;
|
||||
min_initialized: number | null;
|
||||
scene: string;
|
||||
stationary: {
|
||||
interval: number | null;
|
||||
max_frames: {
|
||||
|
||||
@@ -10,6 +10,7 @@ import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { HealthProblem } from "@/types/health";
|
||||
import { getSectionConfig } from "@/utils/configUtil";
|
||||
import { activeCameras } from "@/utils/health";
|
||||
import { getSceneLabel } from "@/utils/modelUtil";
|
||||
import { resolveMessageKey } from "@/utils/runtimeOverrides";
|
||||
|
||||
function healthMessages(
|
||||
@@ -119,10 +120,7 @@ export function evaluateConfigHealth(
|
||||
section === "models" && Array.isArray(sectionData)
|
||||
? sectionData.map((model, index) => ({
|
||||
formData: model as ConfigSectionData,
|
||||
scope: t(
|
||||
`detectionModels.scenes.${(model as { scene?: string }).scene || "all"}`,
|
||||
{ ns: "views/settings" },
|
||||
),
|
||||
scope: getSceneLabel(t, (model as { scene?: string }).scene),
|
||||
idSuffix: `model${index}`,
|
||||
}))
|
||||
: [
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
import type { EmbeddingsStats, FrigateStats, GpuVendor } from "@/types/stats";
|
||||
import { EmbeddingThreshold, InferenceThreshold } from "@/types/graph";
|
||||
import { summarizeDevices } from "@/utils/detectionHardware";
|
||||
import { getSceneLabel } from "@/utils/modelUtil";
|
||||
import { isReplayCamera } from "@/utils/cameraUtil";
|
||||
import { resolveCameraName } from "@/hooks/use-camera-friendly-name";
|
||||
|
||||
@@ -142,9 +143,7 @@ export function detectionRows({
|
||||
const modelRunners = names.slice(cursor, cursor + model.devices.length);
|
||||
cursor += model.devices.length;
|
||||
|
||||
const label = t(`detectionModels.scenes.${model.scene || "all"}`, {
|
||||
ns: "views/settings",
|
||||
});
|
||||
const label = getSceneLabel(t, model.scene);
|
||||
const id = `detection:${index}`;
|
||||
const detail = probeFailed
|
||||
? t("health.hardware.probeUnavailable", {
|
||||
|
||||
@@ -1,10 +1,49 @@
|
||||
import type { TFunction } from "i18next";
|
||||
import type { HiddenFieldContext } from "@/types/configForm";
|
||||
import { DetectionModelConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
|
||||
/** The scene of the model used by cameras that don't name one. */
|
||||
export const DEFAULT_SCENE = "default";
|
||||
|
||||
/** Display name for a scene; custom scenes are shown as the user named them. */
|
||||
export function getSceneLabel(t: TFunction, scene: string | undefined): string {
|
||||
if (!scene || scene === DEFAULT_SCENE) {
|
||||
return t("detectionModels.scenes.default", { ns: "views/settings" });
|
||||
}
|
||||
|
||||
return scene;
|
||||
}
|
||||
|
||||
/**
|
||||
* The scenes a detect section can choose from: those of the configured models,
|
||||
* default first when a default model exists, plus the saved scene when no model
|
||||
* uses it, so it can still be seen and changed.
|
||||
*/
|
||||
export function getSceneChoices(
|
||||
ctx: Pick<HiddenFieldContext, "fullConfig" | "fullCameraConfig" | "level">,
|
||||
): string[] {
|
||||
const scenes = [
|
||||
...new Set(
|
||||
ctx.fullConfig.models?.map((model) => model.scene || DEFAULT_SCENE),
|
||||
),
|
||||
].sort((a, b) => Number(b === DEFAULT_SCENE) - Number(a === DEFAULT_SCENE));
|
||||
const saved =
|
||||
(ctx.level !== "global"
|
||||
? ctx.fullCameraConfig?.detect?.scene
|
||||
: undefined) ?? ctx.fullConfig.detect?.scene;
|
||||
|
||||
if (saved && !scenes.includes(saved)) {
|
||||
scenes.push(saved);
|
||||
}
|
||||
|
||||
return scenes;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model a camera runs on, matched by the camera's detect scene.
|
||||
*
|
||||
* Falls back to the model for every scene, then to the only configured model,
|
||||
* which is what the backend does when a camera does not name a scene.
|
||||
* Falls back to the default model, then to the only configured model, which
|
||||
* is what the backend does when a camera does not name a scene.
|
||||
*/
|
||||
export function getModelForCamera(
|
||||
config?: FrigateConfig,
|
||||
@@ -26,7 +65,7 @@ export function getModelForCamera(
|
||||
}
|
||||
}
|
||||
|
||||
return models.find((model) => model.scene == "all") ?? models[0];
|
||||
return models.find((model) => model.scene == DEFAULT_SCENE) ?? models[0];
|
||||
}
|
||||
|
||||
/** The model used when the question is not about a specific camera. */
|
||||
|
||||
Reference in New Issue
Block a user