mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-05-09 23:15:28 +03:00
Compare commits
5 Commits
573a5ede62
...
de593c8e3f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de593c8e3f | ||
|
|
854ef320de | ||
|
|
91ef3b2ceb | ||
|
|
6c5801ac83 | ||
|
|
d27ee166bc |
@ -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.
|
||||
|
||||
|
||||
201
docs/src/components/ShmCalculator/index.jsx
Normal file
201
docs/src/components/ShmCalculator/index.jsx
Normal 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;
|
||||
131
docs/src/components/ShmCalculator/styles.module.css
Normal file
131
docs/src/components/ShmCalculator/styles.module.css
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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]
|
||||
|
||||
|
||||
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}}"
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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]}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user