Compare commits

...

5 Commits

Author SHA1 Message Date
GuoQing Liu
de593c8e3f
docs: add shm calulator (#22103)
Some checks are pending
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
* docs: add shm calulator

* feat: update shm calculator tips && style
2026-03-24 07:40:11 -06:00
Josh Hawkins
854ef320de
Reclassification (#22603)
* add ability to reclassify images

* add ability to reclassify faces

* work around radix pointer events issue again
2026-03-24 07:18:06 -06:00
Josh Hawkins
91ef3b2ceb
Add ability to regenerate examples in classification wizard (#22604)
* add randomness to object classification

also ensure train_dir is fresh if user has regenerated examples

* frontend refresh button

* fix radix dropdown issue

* i18n
2026-03-24 07:53:37 -05:00
GuoQing Liu
6c5801ac83
fix: fix system stats i18n (#22600)
* fix: fix system stats i18n

* chore: lint
2026-03-24 06:49:05 -06:00
Nicolas Mowen
d27ee166bc
Notification Fixes (#22599)
* Fix iOS having notification token revoked

* Try to handle iOS stacked notifications

* Fix typo

* Improve updating of notification script
2026-03-24 06:30:48 -05:00
18 changed files with 829 additions and 64 deletions

View File

@ -3,6 +3,8 @@ id: installation
title: Installation
---
import ShmCalculator from '@site/src/components/ShmCalculator'
Frigate is a Docker container that can be run on any Docker host including as a [Home Assistant App](https://www.home-assistant.io/apps/). Note that the Home Assistant App is **not** the same thing as the integration. The [integration](/integrations/home-assistant) is required to integrate Frigate into Home Assistant, whether you are running Frigate as a standalone Docker container or as a Home Assistant App.
:::tip
@ -77,20 +79,7 @@ The default shm size of **128MB** is fine for setups with **2 cameras** detectin
The Frigate container also stores logs in shm, which can take up to **40MB**, so make sure to take this into account in your math as well.
You can calculate the **minimum** shm size for each camera with the following formula using the resolution specified for detect:
```console
# Template for one camera without logs, replace <width> and <height>
$ python -c 'print("{:.2f}MB".format((<width> * <height> * 1.5 * 20 + 270480) / 1048576))'
# Example for 1280x720, including logs
$ python -c 'print("{:.2f}MB".format((1280 * 720 * 1.5 * 20 + 270480) / 1048576 + 40))'
66.63MB
# Example for eight cameras detecting at 1280x720, including logs
$ python -c 'print("{:.2f}MB".format(((1280 * 720 * 1.5 * 20 + 270480) / 1048576) * 8 + 40))'
253MB
```
<ShmCalculator/>
The shm size cannot be set per container for Home Assistant Apps. However, this is probably not required since by default Home Assistant Supervisor allocates `/dev/shm` with half the size of your total memory. If your machine has 8GB of memory, chances are that Frigate will have access to up to 4GB without any additional configuration.

View File

@ -0,0 +1,201 @@
import React, { useState, useEffect } from "react";
import Admonition from "@theme/Admonition";
import styles from "./styles.module.css";
const ShmCalculator = () => {
const [width, setWidth] = useState(1280);
const [height, setHeight] = useState(720);
const [cameraCount, setCameraCount] = useState(1);
const [result, setResult] = useState("26.32MB");
const [singleCameraShm, setSingleCameraShm] = useState("26.32MB");
const [totalShm, setTotalShm] = useState("26.32MB");
const calculate = () => {
if (!width || !height || !cameraCount) {
setResult("Please enter valid values");
setSingleCameraShm("-");
setTotalShm("-");
return;
}
// Single camera base SHM calculation (excluding logs)
// Formula: (width * height * 1.5 * 20 + 270480) / 1048576
const singleCameraBase =
(width * height * 1.5 * 20 + 270480) / 1048576;
setSingleCameraShm(`${singleCameraBase.toFixed(2)}mb`);
// Total SHM calculation (multiple cameras, including logs)
const totalBase = singleCameraBase * cameraCount;
const finalResult = totalBase + 40; // Default includes logs +40mb
setTotalShm(`${(totalBase + 40).toFixed(2)}mb`);
// Format result
if (finalResult < 1) {
setResult(`${(finalResult * 1024).toFixed(2)}kb`);
} else if (finalResult >= 1024) {
setResult(`${(finalResult / 1024).toFixed(2)}gb`);
} else {
setResult(`${finalResult.toFixed(2)}mb`);
}
};
const formatWithUnit = (value) => {
const match = value.match(/^([\d.]+)(mb|kb|gb)$/i);
if (match) {
return (
<>
{match[1]}<span className={styles.unit}>{match[2]}</span>
</>
);
}
return value;
};
const applyPreset = (w, h, count) => {
setWidth(w);
setHeight(h);
setCameraCount(count);
calculate();
};
useEffect(() => {
calculate();
}, [width, height, cameraCount]);
return (
<div className={styles.shmCalculator}>
<div className={styles.card}>
<h3 className={styles.title}>SHM Calculator</h3>
<p className={styles.description}>
Calculate required shared memory (SHM) based on camera resolution and
count
</p>
<Admonition type="note">
The resolution below is the <strong>detect</strong> stream resolution,
not the <strong>record</strong> stream resolution. SHM size is
determined by the detect resolution used for object detection.{" "}
<a href="/frigate/camera_setup#choosing-a-detect-resolution">
Learn more about choosing a detect resolution.
</a>
</Admonition>
{width * height > 1280 * 720 && (
<Admonition type="warning">
Using a detect resolution higher than 720p is not recommended.
Higher resolutions do not improve object detection accuracy and will
consume significantly more resources.
</Admonition>
)}
<div className="row">
<div className="col col--6">
<div className={styles.formGroup}>
<label htmlFor="width" className={styles.label}>
Width:
</label>
<input
id="width"
type="number"
min="1"
placeholder="e.g.: 1280"
className={styles.input}
value={width}
onChange={(e) => setWidth(Number(e.target.value))}
/>
</div>
</div>
<div className="col col--6">
<div className={styles.formGroup}>
<label htmlFor="height" className={styles.label}>
Height:
</label>
<input
id="height"
type="number"
min="1"
placeholder="e.g.: 720"
className={styles.input}
value={height}
onChange={(e) => setHeight(Number(e.target.value))}
/>
</div>
</div>
</div>
<div className={styles.formGroup}>
<label htmlFor="cameraCount" className={styles.label}>
Camera Count:
</label>
<input
id="cameraCount"
type="number"
min="1"
placeholder="e.g.: 8"
className={styles.input}
value={cameraCount}
onChange={(e) => setCameraCount(Number(e.target.value))}
/>
</div>
<div className={styles.resultSection}>
<h4>Calculation Result</h4>
<div className={styles.resultValue}>
<span className={styles.resultNumber}>{formatWithUnit(result)}</span>
</div>
<div className={styles.formulaDisplay}>
<p>
<strong>Single Camera:</strong> {formatWithUnit(singleCameraShm)}
</p>
<p>
<strong>Formula:</strong> (width × height × 1.5 × 20 + 270480) ÷
1048576
</p>
{cameraCount > 1 && (
<p>
<strong>Total ({cameraCount} cameras):</strong> {formatWithUnit(totalShm)}
</p>
)}
<p>
<strong>With Logs:</strong> + 40<span className={styles.unit}>mb</span>
</p>
</div>
</div>
<div className={styles.presets}>
<h4>Common Presets</h4>
<div className={styles.presetButtons}>
<button
className="button button--outline button--primary button--sm"
onClick={() => applyPreset(640, 360, 1)}
>
640x360 × 1
</button>
<button
className="button button--outline button--primary button--sm"
onClick={() => applyPreset(1280, 720, 1)}
>
1280x720 × 1
</button>
<button
className="button button--outline button--primary button--sm"
onClick={() => applyPreset(1280, 720, 4)}
>
1280x720 × 4
</button>
<button
className="button button--outline button--primary button--sm"
onClick={() => applyPreset(1280, 720, 8)}
>
1280x720 × 8
</button>
</div>
</div>
</div>
</div>
);
};
export default ShmCalculator;

View File

@ -0,0 +1,131 @@
.shmCalculator {
margin: 2rem 0;
max-width: 600px;
}
.card {
background: var(--ifm-background-surface-color);
border: 1px solid var(--ifm-border-color);
border-radius: 12px;
padding: 2rem;
box-shadow: var(--ifm-global-shadow-lw);
}
[data-theme='light'] .card {
background: var(--ifm-color-emphasis-100);
border: 1px solid var(--ifm-color-emphasis-300);
}
.title {
margin: 0 0 0.5rem 0;
font-size: 1.5rem;
color: var(--ifm-font-color-base);
font-weight: var(--ifm-font-weight-semibold);
}
.description {
margin: 0 0 1.5rem 0;
color: var(--ifm-font-color-secondary);
font-size: 0.9rem;
}
.formGroup {
margin-bottom: 1rem;
}
.label {
display: block;
margin-bottom: 0.25rem;
color: var(--ifm-font-color-base);
font-weight: var(--ifm-font-weight-semibold);
font-size: 0.9rem;
}
.input {
width: 100%;
padding: 0.5rem 0.75rem;
border: 1px solid var(--ifm-border-color);
border-radius: 6px;
background: var(--ifm-background-color);
color: var(--ifm-font-color-base);
font-size: 0.95rem;
transition: border-color 0.2s, box-shadow 0.2s;
}
[data-theme='light'] .input {
background: #fff;
border: 1px solid #d0d7de;
}
.input:focus {
outline: none;
border-color: var(--ifm-color-primary);
box-shadow: 0 0 0 3px var(--ifm-color-primary-lightest);
}
.resultSection {
margin-top: 1rem;
padding: 1.5rem;
background: var(--ifm-background-color);
border-radius: 8px;
border: 1px solid var(--ifm-border-color);
}
[data-theme='light'] .resultSection {
background: #f6f8fa;
border: 1px solid #d0d7de;
}
.resultSection h4 {
margin: 0 0 1rem 0;
color: var(--ifm-font-color-base);
font-weight: var(--ifm-font-weight-semibold);
}
.resultValue {
text-align: center;
padding: 1rem;
background: var(--ifm-color-primary);
border-radius: 6px;
margin-bottom: 1rem;
}
.resultNumber {
font-size: 2rem;
font-weight: var(--ifm-font-weight-bold);
color: #fff;
}
.formulaDisplay {
font-size: 0.85rem;
color: var(--ifm-font-color-secondary);
line-height: 1.6;
}
.formulaDisplay p {
margin: 0.25rem 0;
}
.formulaDisplay strong {
color: var(--ifm-font-color-base);
}
.unit {
text-transform: uppercase;
}
.presets {
margin-top: 1.5rem;
}
.presets h4 {
margin: 0 0 0.75rem 0;
color: var(--ifm-font-color-base);
font-weight: var(--ifm-font-weight-semibold);
}
.presetButtons {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}

View File

@ -338,6 +338,82 @@ async def recognize_face(request: Request, file: UploadFile):
)
@router.post(
"/faces/{name}/reclassify",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Reclassify a face image to a different name",
description="""Moves a single face image from one person's folder to another.
The image is moved and renamed, and the face classifier is cleared to
incorporate the change. Returns a success message or an error if the
image or target name is invalid.""",
)
def reclassify_face_image(request: Request, name: str, body: dict = None):
if not request.app.frigate_config.face_recognition.enabled:
return JSONResponse(
status_code=400,
content={"message": "Face recognition is not enabled.", "success": False},
)
json: dict[str, Any] = body or {}
image_id = sanitize_filename(json.get("id", ""))
new_name = sanitize_filename(json.get("new_name", ""))
if not image_id or not new_name:
return JSONResponse(
content=(
{
"success": False,
"message": "Both 'id' and 'new_name' are required.",
}
),
status_code=400,
)
if new_name == name:
return JSONResponse(
content=(
{
"success": False,
"message": "New name must differ from the current name.",
}
),
status_code=400,
)
source_folder = os.path.join(FACE_DIR, sanitize_filename(name))
source_file = os.path.join(source_folder, image_id)
if not os.path.isfile(source_file):
return JSONResponse(
content=(
{
"success": False,
"message": f"Image not found: {image_id}",
}
),
status_code=404,
)
target_filename = f"{new_name}-{datetime.datetime.now().timestamp()}.webp"
target_folder = os.path.join(FACE_DIR, new_name)
os.makedirs(target_folder, exist_ok=True)
shutil.move(source_file, os.path.join(target_folder, target_filename))
# Clean up empty source folder
if os.path.exists(source_folder) and not os.listdir(source_folder):
os.rmdir(source_folder)
context: EmbeddingsContext = request.app.embeddings
context.clear_face_classifier()
return JSONResponse(
content=({"success": True, "message": "Successfully reclassified face."}),
status_code=200,
)
@router.post(
"/faces/{name}/delete",
response_model=GenericResponse,
@ -787,6 +863,101 @@ def delete_classification_dataset_images(
)
@router.post(
"/classification/{name}/dataset/{category}/reclassify",
response_model=GenericResponse,
dependencies=[Depends(require_role(["admin"]))],
summary="Reclassify a dataset image to a different category",
description="""Moves a single dataset image from one category to another.
The image is re-saved as PNG in the target category and removed from the source.""",
)
def reclassify_classification_image(
request: Request, name: str, category: str, body: dict = None
):
config: FrigateConfig = request.app.frigate_config
if name not in config.classification.custom:
return JSONResponse(
content=(
{
"success": False,
"message": f"{name} is not a known classification model.",
}
),
status_code=404,
)
json: dict[str, Any] = body or {}
image_id = sanitize_filename(json.get("id", ""))
new_category = sanitize_filename(json.get("new_category", ""))
if not image_id or not new_category:
return JSONResponse(
content=(
{
"success": False,
"message": "Both 'id' and 'new_category' are required.",
}
),
status_code=400,
)
if new_category == category:
return JSONResponse(
content=(
{
"success": False,
"message": "New category must differ from the current category.",
}
),
status_code=400,
)
sanitized_name = sanitize_filename(name)
source_folder = os.path.join(
CLIPS_DIR, sanitized_name, "dataset", sanitize_filename(category)
)
source_file = os.path.join(source_folder, image_id)
if not os.path.isfile(source_file):
return JSONResponse(
content=(
{
"success": False,
"message": f"Image not found: {image_id}",
}
),
status_code=404,
)
random_id = "".join(random.choices(string.ascii_lowercase + string.digits, k=6))
timestamp = datetime.datetime.now().timestamp()
new_name = f"{new_category}-{timestamp}-{random_id}.png"
target_folder = os.path.join(CLIPS_DIR, sanitized_name, "dataset", new_category)
os.makedirs(target_folder, exist_ok=True)
img = cv2.imread(source_file)
cv2.imwrite(os.path.join(target_folder, new_name), img)
os.unlink(source_file)
# Clean up empty source folder (unless it is "none")
if (
os.path.exists(source_folder)
and not os.listdir(source_folder)
and category.lower() != "none"
):
os.rmdir(source_folder)
# Mark dataset as changed so UI knows retraining is needed
write_training_metadata(sanitized_name, 0)
return JSONResponse(
content=({"success": True, "message": "Successfully reclassified image."}),
status_code=200,
)
@router.put(
"/classification/{name}/dataset/{old_category}/rename",
response_model=GenericResponse,

View File

@ -5,6 +5,7 @@ import json
import logging
import os
import random
import shutil
from collections import defaultdict
import cv2
@ -397,6 +398,8 @@ def collect_state_classification_examples(
# Step 5: Save to train directory for later classification
train_dir = os.path.join(CLIPS_DIR, model_name, "train")
if os.path.exists(train_dir):
shutil.rmtree(train_dir)
os.makedirs(train_dir, exist_ok=True)
saved_count = 0
@ -411,8 +414,6 @@ def collect_state_classification_examples(
except Exception as e:
logger.error(f"Failed to save image {image_path}: {e}")
import shutil
try:
shutil.rmtree(temp_dir)
except Exception as e:
@ -750,6 +751,8 @@ def collect_object_classification_examples(
# Step 5: Save to train directory for later classification
train_dir = os.path.join(CLIPS_DIR, model_name, "train")
if os.path.exists(train_dir):
shutil.rmtree(train_dir)
os.makedirs(train_dir, exist_ok=True)
saved_count = 0
@ -764,8 +767,6 @@ def collect_object_classification_examples(
except Exception as e:
logger.error(f"Failed to save image {image_path}: {e}")
import shutil
try:
shutil.rmtree(temp_dir)
except Exception as e:
@ -806,24 +807,25 @@ def _select_balanced_events(
selected = []
for group_events in grouped.values():
# Take top events by score, then randomly sample from them
sorted_events = sorted(
group_events,
key=lambda e: e.data.get("score", 0) if e.data else 0,
reverse=True,
)
sample_size = min(samples_per_group, len(sorted_events))
selected.extend(sorted_events[:sample_size])
# Consider top 3x candidates to allow randomness while preferring higher scores
candidate_pool = sorted_events[: samples_per_group * 3]
sample_size = min(samples_per_group, len(candidate_pool))
selected.extend(random.sample(candidate_pool, sample_size))
if len(selected) < target_count:
remaining = [e for e in events if e not in selected]
remaining_sorted = sorted(
remaining,
key=lambda e: e.data.get("score", 0) if e.data else 0,
reverse=True,
)
needed = target_count - len(selected)
selected.extend(remaining_sorted[:needed])
if len(remaining) > needed:
selected.extend(random.sample(remaining, needed))
else:
selected.extend(remaining)
return selected[:target_count]

View File

@ -26,6 +26,7 @@
"deletedModel_one": "Successfully deleted {{count}} model",
"deletedModel_other": "Successfully deleted {{count}} models",
"categorizedImage": "Successfully Classified Image",
"reclassifiedImage": "Successfully Reclassified Image",
"trainedModel": "Successfully trained model.",
"trainingModel": "Successfully started model training.",
"updatedModel": "Successfully updated model configuration",
@ -43,7 +44,8 @@
"trainingFailed": "Model training failed. Check Frigate logs for details.",
"trainingFailedToStart": "Failed to start model training: {{errorMessage}}",
"updateModelFailed": "Failed to update model: {{errorMessage}}",
"renameCategoryFailed": "Failed to rename class: {{errorMessage}}"
"renameCategoryFailed": "Failed to rename class: {{errorMessage}}",
"reclassifyFailed": "Failed to reclassify image: {{errorMessage}}"
}
},
"deleteCategory": {
@ -92,6 +94,8 @@
},
"categorizeImageAs": "Classify Image As:",
"categorizeImage": "Classify Image",
"reclassifyImageAs": "Reclassify Image As:",
"reclassifyImage": "Reclassify Image",
"menu": {
"objects": "Objects",
"states": "States"
@ -180,9 +184,14 @@
"classifyFailed": "Failed to classify images: {{error}}"
},
"generateSuccess": "Successfully generated sample images",
"refreshExamples": "Generate new examples",
"refreshConfirm": {
"title": "Generate New Examples?",
"description": "This will generate a new set of images and clear all selections, including any previous classes. You will need to re-select examples for all classes."
},
"missingStatesWarning": {
"title": "Missing State Examples",
"description": "It's recommended to select examples for all states for best results. You can continue without selecting all states, but the model will not be trained until all states have images. After continuing, use the Recent Classifications view to classify images for the missing states, then train the model."
"title": "Missing Class Examples",
"description": "Not all classes have examples. Try generating new examples to find the missing class, or continue and use the Recent Classifications view to add images later."
}
}
}

View File

@ -66,6 +66,8 @@
"nofaces": "No faces available",
"trainFaceAs": "Train Face as:",
"trainFace": "Train Face",
"reclassifyFaceAs": "Reclassify Face as:",
"reclassifyFace": "Reclassify Face",
"toast": {
"success": {
"uploadedImage": "Successfully uploaded image.",
@ -77,6 +79,7 @@
"deletedName_other": "{{count}} faces have been successfully deleted.",
"renamedFace": "Successfully renamed face to {{name}}",
"trainedFace": "Successfully trained face.",
"reclassifiedFace": "Successfully reclassified face.",
"updatedFaceScore": "Successfully updated face score to {{name}} ({{score}})."
},
"error": {
@ -86,6 +89,7 @@
"deleteNameFailed": "Failed to delete name: {{errorMessage}}",
"renameFaceFailed": "Failed to rename face: {{errorMessage}}",
"trainFailed": "Failed to train: {{errorMessage}}",
"reclassifyFailed": "Failed to reclassify face: {{errorMessage}}",
"updateFaceScoreFailed": "Failed to update face score: {{errorMessage}}"
}
}

View File

@ -19,8 +19,7 @@ self.addEventListener("push", function (event) {
break;
}
// @ts-expect-error we know this exists
self.registration.showNotification(data.title, {
const notificationOptions = {
body: data.message,
icon: "/images/maskable-icon.png",
image: data.image,
@ -28,7 +27,33 @@ self.addEventListener("push", function (event) {
tag: data.id,
data: { id: data.id, link: data.direct_url },
actions,
});
};
// iOS Safari does not auto-coalesce notifications by tag (WebKit bug #258922).
// On iOS 18.3+ close() works, so we manually close duplicates before showing.
// On other platforms, tag-based replacement works natively — skip the extra work.
const isIOS =
/iPad|iPhone|iPod/.test(navigator.userAgent) && !self.MSStream;
const show = () =>
// @ts-expect-error we know this exists
self.registration.showNotification(data.title, notificationOptions);
// event.waitUntil is required on iOS Safari — without it, the browser
// may consider this a "silent push" and revoke the subscription after 3 occurrences.
event.waitUntil(
isIOS
? // @ts-expect-error we know this exists
self.registration
.getNotifications({ tag: data.id })
.then((existing) => {
for (const n of existing) {
n.close();
}
})
.then(show)
: show(), // eslint-disable-line comma-dangle
);
} else {
// pass
// This push event has no data

View File

@ -11,7 +11,24 @@ import { baseUrl } from "@/api/baseUrl";
import { isMobile } from "react-device-detect";
import { cn } from "@/lib/utils";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { TooltipPortal } from "@radix-ui/react-tooltip";
import { IoIosWarning } from "react-icons/io";
import { LuRefreshCw } from "react-icons/lu";
export type Step3FormData = {
examplesGenerated: boolean;
@ -47,6 +64,7 @@ export default function Step3ChooseExamples({
const [selectedImages, setSelectedImages] = useState<Set<string>>(new Set());
const [cacheKey, setCacheKey] = useState<number>(Date.now());
const [loadedImages, setLoadedImages] = useState<Set<string>>(new Set());
const [showRefreshConfirm, setShowRefreshConfirm] = useState(false);
const handleImageLoad = useCallback((imageName: string) => {
setLoadedImages((prev) => new Set(prev).add(imageName));
@ -484,8 +502,52 @@ export default function Step3ChooseExamples({
}
}, [currentClassIndex, allClasses, imageClassifications, onBack]);
const doRefresh = useCallback(() => {
setCurrentClassIndex(0);
setSelectedImages(new Set());
setImageClassifications({});
setLoadedImages(new Set());
setShowRefreshConfirm(false);
generateExamples();
}, [generateExamples]);
const handleRefresh = useCallback(() => {
if (Object.keys(imageClassifications).length > 0) {
setShowRefreshConfirm(true);
} else {
doRefresh();
}
}, [imageClassifications, doRefresh]);
return (
<div className="flex flex-col gap-6">
<AlertDialog
open={showRefreshConfirm}
onOpenChange={setShowRefreshConfirm}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("wizard.step3.refreshConfirm.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("wizard.step3.refreshConfirm.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
onClick={doRefresh}
className="bg-destructive text-white hover:bg-destructive/90"
>
{t("button.continue", { ns: "common" })}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{isTraining ? (
<div className="flex flex-col items-center gap-6 py-12">
<ActivityIndicator className="size-12" />
@ -514,15 +576,43 @@ export default function Step3ChooseExamples({
</div>
</div>
) : hasGenerated ? (
<div className="flex flex-col gap-4">
<div className="relative flex flex-col gap-4">
<Tooltip open={showRefreshConfirm ? false : undefined}>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="icon"
className="absolute right-0 top-0 size-8"
onClick={handleRefresh}
disabled={isGenerating || isProcessing}
>
<LuRefreshCw className="size-4" />
</Button>
</TooltipTrigger>
<TooltipPortal>
<TooltipContent>
{t("wizard.step3.refreshExamples")}
</TooltipContent>
</TooltipPortal>
</Tooltip>
{showMissingStatesWarning && (
<Alert variant="destructive">
<IoIosWarning className="size-5" />
<AlertTitle>
{t("wizard.step3.missingStatesWarning.title")}
</AlertTitle>
<AlertDescription>
<AlertDescription className="flex flex-col gap-2">
{t("wizard.step3.missingStatesWarning.description")}
<Button
variant="secondary"
size="sm"
className="w-fit"
onClick={handleRefresh}
disabled={isGenerating || isProcessing}
>
<LuRefreshCw className="mr-1.5 size-3.5" />
{t("wizard.step3.refreshExamples")}
</Button>
</AlertDescription>
</Alert>
)}

View File

@ -58,7 +58,7 @@ import type { ConfigSectionData, JsonObject } from "@/types/configForm";
import { sanitizeSectionData } from "@/utils/configUtil";
import type { SectionRendererProps } from "./registry";
const NOTIFICATION_SERVICE_WORKER = "/notification-worker.js";
const NOTIFICATION_SERVICE_WORKER = "/notifications-worker.js";
import {
SettingsGroupCard,
SPLIT_ROW_CLASS_NAME,
@ -126,6 +126,8 @@ export default function NotificationsSettingsExtras({
.getRegistration(NOTIFICATION_SERVICE_WORKER)
.then((worker) => {
if (worker) {
// Trigger a check for an updated service worker script
worker.update().catch(() => {});
setRegistration(worker);
} else {
setRegistration(null);
@ -633,7 +635,9 @@ export default function NotificationsSettingsExtras({
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
navigator.serviceWorker
.register(NOTIFICATION_SERVICE_WORKER)
.register(NOTIFICATION_SERVICE_WORKER, {
updateViaCache: "none",
})
.then((workerRegistration) => {
setRegistration(workerRegistration);

View File

@ -11,7 +11,7 @@ import useSWR from "swr";
type ThresholdBarGraphProps = {
graphId: string;
name: string;
name?: string;
unit: string;
threshold: Threshold;
updateTimes: number[];
@ -25,6 +25,7 @@ export function ThresholdBarGraph({
updateTimes,
data,
}: ThresholdBarGraphProps) {
const displayName = name || data[0]?.name || "";
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
@ -186,7 +187,7 @@ export function ThresholdBarGraph({
return (
<div className="flex w-full flex-col">
<div className="flex items-center gap-1">
<div className="text-xs text-secondary-foreground">{name}</div>
<div className="text-xs text-secondary-foreground">{displayName}</div>
<div className="text-xs text-primary">
{lastValue}
{unit}

View File

@ -159,7 +159,9 @@ export default function CameraInfoDialog({
</div>
) : (
<div className="text-muted-foreground">
<div className="ml-2 mt-1">Audio:</div>
<div className="ml-2 mt-1">
{t("cameras.info.audio")}
</div>
<div className="ml-4">
{t("cameras.info.codec")}{" "}
<span className="text-primary">

View File

@ -34,7 +34,11 @@ type ClassificationSelectionDialogProps = {
classes: string[];
modelName: string;
image: string;
onRefresh: () => void;
onRefresh?: () => void;
onCategorize?: (category: string) => void;
excludeCategory?: string;
dialogLabel?: string;
tooltipLabel?: string;
children: ReactNode;
};
export default function ClassificationSelectionDialog({
@ -43,12 +47,21 @@ export default function ClassificationSelectionDialog({
modelName,
image,
onRefresh,
onCategorize,
excludeCategory,
dialogLabel,
tooltipLabel,
children,
}: ClassificationSelectionDialogProps) {
const { t } = useTranslation(["views/classificationModel"]);
const onCategorizeImage = useCallback(
(category: string) => {
if (onCategorize) {
onCategorize(category);
return;
}
axios
.post(`/classification/${modelName}/dataset/categorize`, {
category,
@ -59,7 +72,7 @@ export default function ClassificationSelectionDialog({
toast.success(t("toast.success.categorizedImage"), {
position: "top-center",
});
onRefresh();
onRefresh?.();
}
})
.catch((error) => {
@ -72,7 +85,13 @@ export default function ClassificationSelectionDialog({
});
});
},
[modelName, image, onRefresh, t],
[modelName, image, onRefresh, onCategorize, t],
);
const filteredClasses = useMemo(
() =>
excludeCategory ? classes.filter((c) => c !== excludeCategory) : classes,
[classes, excludeCategory],
);
const isChildButton = useMemo(
@ -111,6 +130,7 @@ export default function ClassificationSelectionDialog({
</SelectorTrigger>
<SelectorContent
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isMobile && (
<DrawerHeader className="sr-only">
@ -118,14 +138,16 @@ export default function ClassificationSelectionDialog({
<DrawerDescription>Details</DrawerDescription>
</DrawerHeader>
)}
<DropdownMenuLabel>{t("categorizeImageAs")}</DropdownMenuLabel>
<DropdownMenuLabel>
{dialogLabel ?? t("categorizeImageAs")}
</DropdownMenuLabel>
<div
className={cn(
"flex max-h-[40dvh] flex-col overflow-y-auto",
isMobile && "gap-2 pb-4",
)}
>
{classes
{filteredClasses
.sort((a, b) => {
if (a === "none") return 1;
if (b === "none") return -1;
@ -152,7 +174,7 @@ export default function ClassificationSelectionDialog({
</div>
</SelectorContent>
</Selector>
<TooltipContent>{t("categorizeImage")}</TooltipContent>
<TooltipContent>{tooltipLabel ?? t("categorizeImage")}</TooltipContent>
</Tooltip>
</div>
);

View File

@ -30,17 +30,29 @@ import { Button } from "../ui/button";
type FaceSelectionDialogProps = {
className?: string;
faceNames: string[];
excludeName?: string;
dialogLabel?: string;
tooltipLabel?: string;
onTrainAttempt: (name: string) => void;
children: ReactNode;
};
export default function FaceSelectionDialog({
className,
faceNames,
excludeName,
dialogLabel,
tooltipLabel,
onTrainAttempt,
children,
}: FaceSelectionDialogProps) {
const { t } = useTranslation(["views/faceLibrary"]);
const filteredNames = useMemo(
() =>
excludeName ? faceNames.filter((n) => n !== excludeName) : faceNames,
[faceNames, excludeName],
);
const isChildButton = useMemo(
() => React.isValidElement(children) && children.type === Button,
[children],
@ -79,6 +91,7 @@ export default function FaceSelectionDialog({
</SelectorTrigger>
<SelectorContent
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")}
onCloseAutoFocus={(e) => e.preventDefault()}
>
{isMobile && (
<DrawerHeader className="sr-only">
@ -86,14 +99,16 @@ export default function FaceSelectionDialog({
<DrawerDescription>Details</DrawerDescription>
</DrawerHeader>
)}
<DropdownMenuLabel>{t("trainFaceAs")}</DropdownMenuLabel>
<DropdownMenuLabel>
{dialogLabel ?? t("trainFaceAs")}
</DropdownMenuLabel>
<div
className={cn(
"flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden",
isMobile && "gap-2 pb-4",
)}
>
{faceNames.sort().map((faceName) => (
{filteredNames.sort().map((faceName) => (
<SelectorItem
key={faceName}
className="flex cursor-pointer gap-2 smart-capitalize"
@ -112,7 +127,7 @@ export default function FaceSelectionDialog({
</div>
</SelectorContent>
</Selector>
<TooltipContent>{t("trainFace")}</TooltipContent>
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
</Tooltip>
</div>
);

View File

@ -266,6 +266,34 @@ export default function FaceLibrary() {
[setPageToggle, refreshFaces, t],
);
const onReclassify = useCallback(
(image: string, newName: string) => {
axios
.post(`/faces/${pageToggle}/reclassify`, {
id: image,
new_name: newName,
})
.then((resp) => {
if (resp.status == 200) {
toast.success(t("toast.success.reclassifiedFace"), {
position: "top-center",
});
refreshFaces();
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.reclassifyFailed", { errorMessage }), {
position: "top-center",
});
});
},
[pageToggle, refreshFaces, t],
);
// keyboard
const contentRef = useRef<HTMLDivElement | null>(null);
@ -452,10 +480,12 @@ export default function FaceLibrary() {
<FaceGrid
contentRef={contentRef}
faceImages={faceImages}
faceNames={faces}
pageToggle={pageToggle}
selectedFaces={selectedFaces}
onClickFaces={onClickFaces}
onDelete={onDelete}
onReclassify={onReclassify}
/>
))
)}
@ -601,11 +631,11 @@ function LibrarySelector({
className="group flex items-center justify-between p-0"
>
<div
className="flex-grow cursor-pointer"
className="flex-grow cursor-pointer px-2 py-1.5"
onClick={() => setPageToggle(face)}
>
{face}
<span className="ml-2 px-2 py-1.5 text-muted-foreground">
<span className="ml-2 text-muted-foreground">
({faceData?.[face].length})
</span>
</div>
@ -983,18 +1013,22 @@ function FaceAttemptGroup({
type FaceGridProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
faceImages: string[];
faceNames: string[];
pageToggle: string;
selectedFaces: string[];
onClickFaces: (images: string[], ctrl: boolean) => void;
onDelete: (name: string, ids: string[]) => void;
onReclassify: (image: string, newName: string) => void;
};
function FaceGrid({
contentRef,
faceImages,
faceNames,
pageToggle,
selectedFaces,
onClickFaces,
onDelete,
onReclassify,
}: FaceGridProps) {
const { t } = useTranslation(["views/faceLibrary"]);
@ -1032,6 +1066,17 @@ function FaceGrid({
i18nLibrary="views/faceLibrary"
onClick={(data, meta) => onClickFaces([data.filename], meta)}
>
<FaceSelectionDialog
faceNames={faceNames}
excludeName={pageToggle}
dialogLabel={t("reclassifyFaceAs")}
tooltipLabel={t("reclassifyFace")}
onTrainAttempt={(newName) => onReclassify(image, newName)}
>
<BlurredIconButton>
<AddFaceIcon className="size-5" />
</BlurredIconButton>
</FaceSelectionDialog>
<Tooltip>
<TooltipTrigger>
<LuTrash2

View File

@ -342,7 +342,7 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
{config.name}
</div>
<div className="absolute bottom-2 right-2 z-40">
<DropdownMenu>
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<BlurredIconButton>
<FiMoreVertical className="size-5 text-white" />

View File

@ -304,6 +304,37 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
[pageToggle, model, refreshTrain, refreshDataset, t],
);
const onReclassify = useCallback(
(image: string, newCategory: string) => {
axios
.post(
`/classification/${model.name}/dataset/${pageToggle}/reclassify`,
{
id: image,
new_category: newCategory,
},
)
.then((resp) => {
if (resp.status == 200) {
toast.success(t("toast.success.reclassifiedImage"), {
position: "top-center",
});
refreshDataset();
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("toast.error.reclassifyFailed", { errorMessage }), {
position: "top-center",
});
});
},
[pageToggle, model, refreshDataset, t],
);
// keyboard
const contentRef = useRef<HTMLDivElement | null>(null);
@ -535,10 +566,12 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
contentRef={contentRef}
modelName={model.name}
categoryName={pageToggle}
classes={Object.keys(dataset || {})}
images={dataset?.[pageToggle] || []}
selectedImages={selectedImages}
onClickImages={onClickImages}
onDelete={onDelete}
onReclassify={onReclassify}
/>
)}
</div>
@ -776,19 +809,23 @@ type DatasetGridProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
modelName: string;
categoryName: string;
classes: string[];
images: string[];
selectedImages: string[];
onClickImages: (images: string[], ctrl: boolean) => void;
onDelete: (ids: string[]) => void;
onReclassify: (image: string, newCategory: string) => void;
};
function DatasetGrid({
contentRef,
modelName,
categoryName,
classes,
images,
selectedImages,
onClickImages,
onDelete,
onReclassify,
}: DatasetGridProps) {
const { t } = useTranslation(["views/classificationModel"]);
@ -816,10 +853,23 @@ function DatasetGrid({
i18nLibrary="views/classificationModel"
onClick={(data, _) => onClickImages([data.filename], true)}
>
<ClassificationSelectionDialog
classes={classes}
modelName={modelName}
image={image}
excludeCategory={categoryName}
dialogLabel={t("reclassifyImageAs")}
tooltipLabel={t("reclassifyImage")}
onCategorize={(newCat) => onReclassify(image, newCat)}
>
<BlurredIconButton>
<TbCategoryPlus className="size-5" />
</BlurredIconButton>
</ClassificationSelectionDialog>
<Tooltip>
<TooltipTrigger>
<LuTrash2
className="size-5 cursor-pointer text-primary-variant hover:text-danger"
className="size-5 cursor-pointer text-gray-200 hover:text-danger"
onClick={(e) => {
e.stopPropagation();
onDelete([image]);

View File

@ -548,7 +548,10 @@ export default function GeneralMetrics({
Object.entries(stats.processes).forEach(([key, procStats]) => {
if (procStats.pid.toString() in stats.cpu_usages) {
if (!(key in series)) {
series[key] = { name: key, data: [] };
series[key] = {
name: t(`general.otherProcesses.series.${key}`),
data: [],
};
}
const data = stats.cpu_usages[procStats.pid.toString()]?.cpu;
@ -563,7 +566,7 @@ export default function GeneralMetrics({
});
});
return Object.keys(series).length > 0 ? Object.values(series) : [];
}, [statsHistory]);
}, [statsHistory, t]);
const otherProcessMemSeries = useMemo(() => {
if (!statsHistory) {
@ -582,7 +585,10 @@ export default function GeneralMetrics({
Object.entries(stats.processes).forEach(([key, procStats]) => {
if (procStats.pid.toString() in stats.cpu_usages) {
if (!(key in series)) {
series[key] = { name: key, data: [] };
series[key] = {
name: t(`general.otherProcesses.series.${key}`),
data: [],
};
}
const data = stats.cpu_usages[procStats.pid.toString()]?.mem;
@ -597,7 +603,7 @@ export default function GeneralMetrics({
});
});
return Object.values(series);
}, [statsHistory]);
}, [statsHistory, t]);
return (
<>
@ -964,11 +970,10 @@ export default function GeneralMetrics({
<div className="mb-5">
{t("general.otherProcesses.processCpuUsage")}
</div>
{otherProcessCpuSeries.map((series) => (
{otherProcessCpuSeries.map((series, index) => (
<ThresholdBarGraph
key={series.name}
graphId={`${series.name}-cpu`}
name={t(`general.otherProcesses.series.${series.name}`)}
key={`other-process-cpu-${index}`}
graphId={`other-process-cpu-${index}`}
unit="%"
threshold={DetectorCpuThreshold}
updateTimes={updateTimes}
@ -984,12 +989,11 @@ export default function GeneralMetrics({
<div className="mb-5">
{t("general.otherProcesses.processMemoryUsage")}
</div>
{otherProcessMemSeries.map((series) => (
{otherProcessMemSeries.map((series, index) => (
<ThresholdBarGraph
key={series.name}
graphId={`${series.name}-mem`}
key={`other-process-mem-${index}`}
graphId={`other-process-mem-${index}`}
unit="%"
name={series.name.replaceAll("_", " ")}
threshold={DetectorMemThreshold}
updateTimes={updateTimes}
data={[series]}