Compare commits

..

No commits in common. "c84bfd3ace1026d06c5c438b903ace1adb395347" and "0314bdf84cee1abb06d4d5c1cf83f5922c6c599c" have entirely different histories.

12 changed files with 72 additions and 184 deletions

View File

@ -595,13 +595,9 @@ def get_classification_dataset(name: str):
"last_training_image_count": 0, "last_training_image_count": 0,
"current_image_count": current_image_count, "current_image_count": current_image_count,
"new_images_count": current_image_count, "new_images_count": current_image_count,
"dataset_changed": current_image_count > 0,
} }
else: else:
last_training_count = metadata.get("last_training_image_count", 0) last_training_count = metadata.get("last_training_image_count", 0)
# Dataset has changed if count is different (either added or deleted images)
dataset_changed = current_image_count != last_training_count
# Only show positive count for new images (ignore deletions in the count display)
new_images_count = max(0, current_image_count - last_training_count) new_images_count = max(0, current_image_count - last_training_count)
training_metadata = { training_metadata = {
"has_trained": True, "has_trained": True,
@ -609,7 +605,6 @@ def get_classification_dataset(name: str):
"last_training_image_count": last_training_count, "last_training_image_count": last_training_count,
"current_image_count": current_image_count, "current_image_count": current_image_count,
"new_images_count": new_images_count, "new_images_count": new_images_count,
"dataset_changed": dataset_changed,
} }
return JSONResponse( return JSONResponse(
@ -953,29 +948,31 @@ async def generate_object_examples(request: Request, body: GenerateObjectExample
dependencies=[Depends(require_role(["admin"]))], dependencies=[Depends(require_role(["admin"]))],
summary="Delete a classification model", summary="Delete a classification model",
description="""Deletes a specific classification model and all its associated data. description="""Deletes a specific classification model and all its associated data.
Works even if the model is not in the config (e.g., partially created during wizard). The name must exist in the classification models. Returns a success message or an error if the name is invalid.""",
Returns a success message.""",
) )
def delete_classification_model(request: Request, name: str): def delete_classification_model(request: Request, name: str):
sanitized_name = sanitize_filename(name) 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,
)
# Delete the classification model's data directory in clips # Delete the classification model's data directory in clips
data_dir = os.path.join(CLIPS_DIR, sanitized_name) data_dir = os.path.join(CLIPS_DIR, sanitize_filename(name))
if os.path.exists(data_dir): if os.path.exists(data_dir):
try:
shutil.rmtree(data_dir) shutil.rmtree(data_dir)
logger.info(f"Deleted classification data directory for {name}")
except Exception as e:
logger.debug(f"Failed to delete data directory for {name}: {e}")
# Delete the classification model's files in model_cache # Delete the classification model's files in model_cache
model_dir = os.path.join(MODEL_CACHE_DIR, sanitized_name) model_dir = os.path.join(MODEL_CACHE_DIR, sanitize_filename(name))
if os.path.exists(model_dir): if os.path.exists(model_dir):
try:
shutil.rmtree(model_dir) shutil.rmtree(model_dir)
logger.info(f"Deleted classification model directory for {name}")
except Exception as e:
logger.debug(f"Failed to delete model directory for {name}: {e}")
return JSONResponse( return JSONResponse(
content=( content=(

View File

@ -4,6 +4,7 @@ import logging
import os import os
import sherpa_onnx import sherpa_onnx
from faster_whisper.utils import download_model
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
from frigate.const import MODEL_CACHE_DIR from frigate.const import MODEL_CACHE_DIR
@ -24,9 +25,6 @@ class AudioTranscriptionModelRunner:
if model_size == "large": if model_size == "large":
# use the Whisper download function instead of our own # use the Whisper download function instead of our own
# Import dynamically to avoid crashes on systems without AVX support
from faster_whisper.utils import download_model
logger.debug("Downloading Whisper audio transcription model") logger.debug("Downloading Whisper audio transcription model")
download_model( download_model(
size_or_id="small" if device == "cuda" else "tiny", size_or_id="small" if device == "cuda" else "tiny",

View File

@ -6,6 +6,7 @@ import threading
import time import time
from typing import Optional from typing import Optional
from faster_whisper import WhisperModel
from peewee import DoesNotExist from peewee import DoesNotExist
from frigate.comms.inter_process import InterProcessRequestor from frigate.comms.inter_process import InterProcessRequestor
@ -50,9 +51,6 @@ class AudioTranscriptionPostProcessor(PostProcessorApi):
def __build_recognizer(self) -> None: def __build_recognizer(self) -> None:
try: try:
# Import dynamically to avoid crashes on systems without AVX support
from faster_whisper import WhisperModel
self.recognizer = WhisperModel( self.recognizer = WhisperModel(
model_size_or_path="small", model_size_or_path="small",
device="cuda" device="cuda"

View File

@ -394,11 +394,7 @@ class OpenVINOModelRunner(BaseModelRunner):
self.infer_request.set_input_tensor(input_index, input_tensor) self.infer_request.set_input_tensor(input_index, input_tensor)
# Run inference # Run inference
try:
self.infer_request.infer() self.infer_request.infer()
except Exception as e:
logger.error(f"Error during OpenVINO inference: {e}")
return []
# Get all output tensors # Get all output tensors
outputs = [] outputs = []

View File

@ -16,7 +16,6 @@
"tooltip": { "tooltip": {
"trainingInProgress": "Model is currently training", "trainingInProgress": "Model is currently training",
"noNewImages": "No new images to train. Classify more images in the dataset first.", "noNewImages": "No new images to train. Classify more images in the dataset first.",
"noChanges": "No changes to the dataset since last training.",
"modelNotReady": "Model is not ready for training" "modelNotReady": "Model is not ready for training"
}, },
"toast": { "toast": {
@ -44,9 +43,7 @@
}, },
"deleteCategory": { "deleteCategory": {
"title": "Delete Class", "title": "Delete Class",
"desc": "Are you sure you want to delete the class {{name}}? This will permanently delete all associated images and require re-training the model.", "desc": "Are you sure you want to delete the class {{name}}? This will permanently delete all associated images and require re-training the model."
"minClassesTitle": "Cannot Delete Class",
"minClassesDesc": "A classification model must have at least 2 classes. Add another class before deleting this one."
}, },
"deleteModel": { "deleteModel": {
"title": "Delete Classification Model", "title": "Delete Classification Model",

View File

@ -15,7 +15,6 @@ import Step3ChooseExamples, {
} from "./wizard/Step3ChooseExamples"; } from "./wizard/Step3ChooseExamples";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { isDesktop } from "react-device-detect"; import { isDesktop } from "react-device-detect";
import axios from "axios";
const OBJECT_STEPS = [ const OBJECT_STEPS = [
"wizard.steps.nameAndDefine", "wizard.steps.nameAndDefine",
@ -121,18 +120,7 @@ export default function ClassificationModelWizardDialog({
dispatch({ type: "PREVIOUS_STEP" }); dispatch({ type: "PREVIOUS_STEP" });
}; };
const handleCancel = async () => { const handleCancel = () => {
// Clean up any generated training images if we're cancelling from Step 3
if (wizardState.step1Data && wizardState.step3Data?.examplesGenerated) {
try {
await axios.delete(
`/classification/${wizardState.step1Data.modelName}`,
);
} catch (error) {
// Silently fail - user is already cancelling
}
}
dispatch({ type: "RESET" }); dispatch({ type: "RESET" });
onClose(); onClose();
}; };

View File

@ -165,15 +165,18 @@ export default function Step3ChooseExamples({
const isLastClass = currentClassIndex === allClasses.length - 1; const isLastClass = currentClassIndex === allClasses.length - 1;
if (isLastClass) { if (isLastClass) {
// For object models, assign remaining unclassified images to "none" // Assign remaining unclassified images
// For state models, this should never happen since we require all images to be classified
if (step1Data.modelType !== "state") {
unknownImages.slice(0, 24).forEach((imageName) => { unknownImages.slice(0, 24).forEach((imageName) => {
if (!newClassifications[imageName]) { if (!newClassifications[imageName]) {
// For state models with 2 classes, assign to the last class
// For object models, assign to "none"
if (step1Data.modelType === "state" && allClasses.length === 2) {
newClassifications[imageName] = allClasses[allClasses.length - 1];
} else {
newClassifications[imageName] = "none"; newClassifications[imageName] = "none";
} }
});
} }
});
// All done, trigger training immediately // All done, trigger training immediately
setImageClassifications(newClassifications); setImageClassifications(newClassifications);
@ -313,15 +316,8 @@ export default function Step3ChooseExamples({
return images; return images;
} }
// If we're viewing a previous class (going back), show images for that class return images.filter((img) => !imageClassifications[img]);
// Otherwise show only unclassified images }, [unknownImages, imageClassifications]);
const currentClassInView = allClasses[currentClassIndex];
return images.filter((img) => {
const imgClass = imageClassifications[img];
// Show if: unclassified OR classified with current class we're viewing
return !imgClass || imgClass === currentClassInView;
});
}, [unknownImages, imageClassifications, allClasses, currentClassIndex]);
const allImagesClassified = useMemo(() => { const allImagesClassified = useMemo(() => {
return unclassifiedImages.length === 0; return unclassifiedImages.length === 0;
@ -330,26 +326,15 @@ export default function Step3ChooseExamples({
// For state models on the last class, require all images to be classified // For state models on the last class, require all images to be classified
const isLastClass = currentClassIndex === allClasses.length - 1; const isLastClass = currentClassIndex === allClasses.length - 1;
const canProceed = useMemo(() => { const canProceed = useMemo(() => {
if (step1Data.modelType === "state" && isLastClass) { if (
// Check if all 24 images will be classified after current selections are applied step1Data.modelType === "state" &&
const totalImages = unknownImages.slice(0, 24).length; isLastClass &&
!allImagesClassified
// Count images that will be classified (either already classified or currently selected) ) {
const allImages = unknownImages.slice(0, 24); return false;
const willBeClassified = allImages.filter((img) => {
return imageClassifications[img] || selectedImages.has(img);
}).length;
return willBeClassified >= totalImages;
} }
return true; return true;
}, [ }, [step1Data.modelType, isLastClass, allImagesClassified]);
step1Data.modelType,
isLastClass,
unknownImages,
imageClassifications,
selectedImages,
]);
const handleBack = useCallback(() => { const handleBack = useCallback(() => {
if (currentClassIndex > 0) { if (currentClassIndex > 0) {

View File

@ -12,13 +12,13 @@ export function ImageShadowOverlay({
<> <>
<div <div
className={cn( className={cn(
"pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent", "pointer-events-none absolute inset-x-0 top-0 z-10 h-[30%] w-full rounded-lg bg-gradient-to-b from-black/20 to-transparent md:rounded-2xl",
upperClassName, upperClassName,
)} )}
/> />
<div <div
className={cn( className={cn(
"pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent", "pointer-events-none absolute inset-x-0 bottom-0 z-10 h-[10%] w-full rounded-lg bg-gradient-to-t from-black/20 to-transparent md:rounded-2xl",
lowerClassName, lowerClassName,
)} )}
/> />

View File

@ -77,10 +77,7 @@ export default function BirdseyeLivePlayer({
)} )}
onClick={onClick} onClick={onClick}
> >
<ImageShadowOverlay <ImageShadowOverlay />
upperClassName="md:rounded-2xl"
lowerClassName="md:rounded-2xl"
/>
<div className="size-full" ref={playerRef}> <div className="size-full" ref={playerRef}>
{player} {player}
</div> </div>

View File

@ -331,10 +331,7 @@ export default function LivePlayer({
> >
{cameraEnabled && {cameraEnabled &&
((showStillWithoutActivity && !liveReady) || liveReady) && ( ((showStillWithoutActivity && !liveReady) || liveReady) && (
<ImageShadowOverlay <ImageShadowOverlay />
upperClassName="md:rounded-2xl"
lowerClassName="md:rounded-2xl"
/>
)} )}
{player} {player}
{cameraEnabled && {cameraEnabled &&

View File

@ -1,10 +1,4 @@
import React, { import React, { createContext, useContext, useState, useEffect } from "react";
createContext,
useContext,
useState,
useEffect,
useRef,
} from "react";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
@ -42,23 +36,6 @@ export function DetailStreamProvider({
() => initialSelectedObjectIds ?? [], () => initialSelectedObjectIds ?? [],
); );
// When the parent provides a new initialSelectedObjectIds (for example
// when navigating between search results) update the selection so children
// like `ObjectTrackOverlay` receive the new ids immediately. We only
// perform this update when the incoming value actually changes.
useEffect(() => {
if (
initialSelectedObjectIds &&
(initialSelectedObjectIds.length !== selectedObjectIds.length ||
initialSelectedObjectIds.some((v, i) => selectedObjectIds[i] !== v))
) {
setSelectedObjectIds(initialSelectedObjectIds);
}
// Intentionally include selectedObjectIds to compare previous value and
// avoid overwriting user interactions unless the incoming prop changed.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialSelectedObjectIds]);
const toggleObjectSelection = (id: string | undefined) => { const toggleObjectSelection = (id: string | undefined) => {
if (id === undefined) { if (id === undefined) {
setSelectedObjectIds([]); setSelectedObjectIds([]);
@ -86,33 +63,10 @@ export function DetailStreamProvider({
setAnnotationOffset(cfgOffset); setAnnotationOffset(cfgOffset);
}, [config, camera]); }, [config, camera]);
// Clear selected objects when exiting detail mode or when the camera // Clear selected objects when exiting detail mode or changing cameras
// changes for providers that are not initialized with an explicit
// `initialSelectedObjectIds` (e.g., the RecordingView). For providers
// that receive `initialSelectedObjectIds` (like SearchDetailDialog) we
// avoid clearing on camera change to prevent a race with children that
// immediately set selection when mounting.
const prevCameraRef = useRef<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
// Always clear when leaving detail mode
if (!isDetailMode) {
setSelectedObjectIds([]); setSelectedObjectIds([]);
prevCameraRef.current = camera; }, [isDetailMode, camera]);
return;
}
// If camera changed and the parent did not provide initialSelectedObjectIds,
// clear selection to preserve previous behavior.
if (
prevCameraRef.current !== undefined &&
prevCameraRef.current !== camera &&
initialSelectedObjectIds === undefined
) {
setSelectedObjectIds([]);
}
prevCameraRef.current = camera;
}, [isDetailMode, camera, initialSelectedObjectIds]);
const value: DetailStreamContextType = { const value: DetailStreamContextType = {
selectedObjectIds, selectedObjectIds,

View File

@ -126,7 +126,6 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
last_training_image_count: number; last_training_image_count: number;
current_image_count: number; current_image_count: number;
new_images_count: number; new_images_count: number;
dataset_changed: boolean;
} | null; } | null;
}>(`classification/${model.name}/dataset`); }>(`classification/${model.name}/dataset`);
@ -265,11 +264,10 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
); );
} }
// Always refresh dataset to update the categories list
refreshDataset();
if (pageToggle == "train") { if (pageToggle == "train") {
refreshTrain(); refreshTrain();
} else {
refreshDataset();
} }
} }
}) })
@ -447,7 +445,7 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
variant={modelState == "failed" ? "destructive" : "select"} variant={modelState == "failed" ? "destructive" : "select"}
disabled={ disabled={
(modelState != "complete" && modelState != "failed") || (modelState != "complete" && modelState != "failed") ||
!trainingMetadata?.dataset_changed (trainingMetadata?.new_images_count ?? 0) === 0
} }
> >
{modelState == "training" ? ( {modelState == "training" ? (
@ -468,14 +466,14 @@ export default function ModelTrainingView({ model }: ModelTrainingViewProps) {
)} )}
</Button> </Button>
</TooltipTrigger> </TooltipTrigger>
{(!trainingMetadata?.dataset_changed || {((trainingMetadata?.new_images_count ?? 0) === 0 ||
(modelState != "complete" && modelState != "failed")) && ( (modelState != "complete" && modelState != "failed")) && (
<TooltipPortal> <TooltipPortal>
<TooltipContent> <TooltipContent>
{modelState == "training" {modelState == "training"
? t("tooltip.trainingInProgress") ? t("tooltip.trainingInProgress")
: !trainingMetadata?.dataset_changed : trainingMetadata?.new_images_count === 0
? t("tooltip.noChanges") ? t("tooltip.noNewImages")
: t("tooltip.modelNotReady")} : t("tooltip.modelNotReady")}
</TooltipContent> </TooltipContent>
</TooltipPortal> </TooltipPortal>
@ -573,28 +571,13 @@ function LibrarySelector({
> >
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle> <DialogTitle>{t("deleteCategory.title")}</DialogTitle>
{Object.keys(dataset).length <= 2
? t("deleteCategory.minClassesTitle")
: t("deleteCategory.title")}
</DialogTitle>
<DialogDescription> <DialogDescription>
{Object.keys(dataset).length <= 2 {t("deleteCategory.desc", { name: confirmDelete })}
? t("deleteCategory.minClassesDesc")
: t("deleteCategory.desc", { name: confirmDelete })}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{Object.keys(dataset).length <= 2 ? (
<Button variant="outline" onClick={() => setConfirmDelete(null)}> <Button variant="outline" onClick={() => setConfirmDelete(null)}>
{t("button.ok", { ns: "common" })}
</Button>
) : (
<>
<Button
variant="outline"
onClick={() => setConfirmDelete(null)}
>
{t("button.cancel", { ns: "common" })} {t("button.cancel", { ns: "common" })}
</Button> </Button>
<Button <Button
@ -609,8 +592,6 @@ function LibrarySelector({
> >
{t("button.delete", { ns: "common" })} {t("button.delete", { ns: "common" })}
</Button> </Button>
</>
)}
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>