mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-08-02 00:52:17 +03:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7bc2ef731b | ||
|
|
a6f8e1b9a9 | ||
|
|
66bbe62ffb | ||
|
|
4b034644d2 | ||
|
|
82410f8278 | ||
|
|
2b31c53614 | ||
|
|
2c34e1ec10 | ||
|
|
91cc6747b6 | ||
|
|
7b5a1b7284 | ||
|
|
7e5d98dbab | ||
|
|
d952a97bda |
@@ -54,8 +54,8 @@ function setup_homekit_config() {
|
|||||||
local config_path="$1"
|
local config_path="$1"
|
||||||
|
|
||||||
if [[ ! -f "${config_path}" ]]; then
|
if [[ ! -f "${config_path}" ]]; then
|
||||||
echo "[INFO] Creating empty HomeKit config file..."
|
echo "[INFO] Creating empty config file for HomeKit..."
|
||||||
echo 'homekit: {}' > "${config_path}"
|
echo '{}' > "${config_path}"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# Convert YAML to JSON for jq processing
|
# Convert YAML to JSON for jq processing
|
||||||
@@ -69,15 +69,15 @@ function setup_homekit_config() {
|
|||||||
local cleaned_json="/tmp/cache/homekit_cleaned.json"
|
local cleaned_json="/tmp/cache/homekit_cleaned.json"
|
||||||
jq '
|
jq '
|
||||||
# Keep only the homekit section if it exists, otherwise empty object
|
# Keep only the homekit section if it exists, otherwise empty object
|
||||||
if has("homekit") then {homekit: .homekit} else {homekit: {}} end
|
if has("homekit") then {homekit: .homekit} else {} end
|
||||||
' "${temp_json}" > "${cleaned_json}" 2>/dev/null || {
|
' "${temp_json}" > "${cleaned_json}" 2>/dev/null || {
|
||||||
echo '{"homekit": {}}' > "${cleaned_json}"
|
echo '{}' > "${cleaned_json}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Convert back to YAML and write to the config file
|
# Convert back to YAML and write to the config file
|
||||||
yq eval -P "${cleaned_json}" > "${config_path}" 2>/dev/null || {
|
yq eval -P "${cleaned_json}" > "${config_path}" 2>/dev/null || {
|
||||||
echo "[WARNING] Failed to convert cleaned config to YAML, creating minimal config"
|
echo "[WARNING] Failed to convert cleaned config to YAML, creating minimal config"
|
||||||
echo 'homekit: {}' > "${config_path}"
|
echo '{}' > "${config_path}"
|
||||||
}
|
}
|
||||||
|
|
||||||
# Clean up temp files
|
# Clean up temp files
|
||||||
|
|||||||
@@ -23,8 +23,28 @@ sys.path.remove("/opt/frigate")
|
|||||||
yaml = YAML()
|
yaml = YAML()
|
||||||
|
|
||||||
# Check if arbitrary exec sources are allowed (defaults to False for security)
|
# Check if arbitrary exec sources are allowed (defaults to False for security)
|
||||||
ALLOW_ARBITRARY_EXEC = os.environ.get(
|
allow_arbitrary_exec = None
|
||||||
"GO2RTC_ALLOW_ARBITRARY_EXEC", "false"
|
if "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.environ:
|
||||||
|
allow_arbitrary_exec = os.environ.get("GO2RTC_ALLOW_ARBITRARY_EXEC")
|
||||||
|
elif (
|
||||||
|
os.path.isdir("/run/secrets")
|
||||||
|
and os.access("/run/secrets", os.R_OK)
|
||||||
|
and "GO2RTC_ALLOW_ARBITRARY_EXEC" in os.listdir("/run/secrets")
|
||||||
|
):
|
||||||
|
allow_arbitrary_exec = (
|
||||||
|
Path(os.path.join("/run/secrets", "GO2RTC_ALLOW_ARBITRARY_EXEC"))
|
||||||
|
.read_text()
|
||||||
|
.strip()
|
||||||
|
)
|
||||||
|
# check for the add-on options file
|
||||||
|
elif os.path.isfile("/data/options.json"):
|
||||||
|
with open("/data/options.json") as f:
|
||||||
|
raw_options = f.read()
|
||||||
|
options = json.loads(raw_options)
|
||||||
|
allow_arbitrary_exec = options.get("go2rtc_allow_arbitrary_exec")
|
||||||
|
|
||||||
|
ALLOW_ARBITRARY_EXEC = allow_arbitrary_exec is not None and str(
|
||||||
|
allow_arbitrary_exec
|
||||||
).lower() in ("true", "1", "yes")
|
).lower() in ("true", "1", "yes")
|
||||||
|
|
||||||
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
|
FRIGATE_ENV_VARS = {k: v for k, v in os.environ.items() if k.startswith("FRIGATE_")}
|
||||||
|
|||||||
@@ -41,12 +41,12 @@ If you are trying to use a single model for Frigate and HomeAssistant, it will n
|
|||||||
|
|
||||||
The following models are recommended:
|
The following models are recommended:
|
||||||
|
|
||||||
| Model | Notes |
|
| Model | Notes |
|
||||||
| ----------------- | -------------------------------------------------------------------- |
|
| ------------- | -------------------------------------------------------------------- |
|
||||||
| `qwen3-vl` | Strong visual and situational understanding, higher vram requirement |
|
| `qwen3-vl` | Strong visual and situational understanding, higher vram requirement |
|
||||||
| `Intern3.5VL` | Relatively fast with good vision comprehension |
|
| `Intern3.5VL` | Relatively fast with good vision comprehension |
|
||||||
| `gemma3` | Strong frame-to-frame understanding, slower inference times |
|
| `gemma3` | Strong frame-to-frame understanding, slower inference times |
|
||||||
| `qwen2.5-vl` | Fast but capable model with good vision comprehension |
|
| `qwen2.5-vl` | Fast but capable model with good vision comprehension |
|
||||||
|
|
||||||
:::note
|
:::note
|
||||||
|
|
||||||
@@ -61,10 +61,10 @@ genai:
|
|||||||
provider: ollama
|
provider: ollama
|
||||||
base_url: http://localhost:11434
|
base_url: http://localhost:11434
|
||||||
model: minicpm-v:8b
|
model: minicpm-v:8b
|
||||||
provider_options: # other Ollama client options can be defined
|
provider_options: # other Ollama client options can be defined
|
||||||
keep_alive: -1
|
keep_alive: -1
|
||||||
options:
|
options:
|
||||||
num_ctx: 8192 # make sure the context matches other services that are using ollama
|
num_ctx: 8192 # make sure the context matches other services that are using ollama
|
||||||
```
|
```
|
||||||
|
|
||||||
## Google Gemini
|
## Google Gemini
|
||||||
@@ -120,6 +120,23 @@ To use a different OpenAI-compatible API endpoint, set the `OPENAI_BASE_URL` env
|
|||||||
|
|
||||||
:::
|
:::
|
||||||
|
|
||||||
|
:::tip
|
||||||
|
|
||||||
|
For OpenAI-compatible servers (such as llama.cpp) that don't expose the configured context size in the API response, you can manually specify the context size in `provider_options`:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
genai:
|
||||||
|
provider: openai
|
||||||
|
base_url: http://your-llama-server
|
||||||
|
model: your-model-name
|
||||||
|
provider_options:
|
||||||
|
context_size: 8192 # Specify the configured context size
|
||||||
|
```
|
||||||
|
|
||||||
|
This ensures Frigate uses the correct context window size when generating prompts.
|
||||||
|
|
||||||
|
:::
|
||||||
|
|
||||||
## Azure OpenAI
|
## Azure OpenAI
|
||||||
|
|
||||||
Microsoft offers several vision models through Azure OpenAI. A subscription is required.
|
Microsoft offers several vision models through Azure OpenAI. A subscription is required.
|
||||||
|
|||||||
@@ -696,6 +696,9 @@ genai:
|
|||||||
# Optional additional args to pass to the GenAI Provider (default: None)
|
# Optional additional args to pass to the GenAI Provider (default: None)
|
||||||
provider_options:
|
provider_options:
|
||||||
keep_alive: -1
|
keep_alive: -1
|
||||||
|
# Optional: Options to pass during inference calls (default: {})
|
||||||
|
runtime_options:
|
||||||
|
temperature: 0.7
|
||||||
|
|
||||||
# Optional: Configuration for audio transcription
|
# Optional: Configuration for audio transcription
|
||||||
# NOTE: only the enabled option can be overridden at the camera level
|
# NOTE: only the enabled option can be overridden at the camera level
|
||||||
|
|||||||
Vendored
+8
@@ -0,0 +1,8 @@
|
|||||||
|
https://:project.pages.dev/*
|
||||||
|
X-Robots-Tag: noindex
|
||||||
|
|
||||||
|
https://:version.:project.pages.dev/*
|
||||||
|
X-Robots-Tag: noindex
|
||||||
|
|
||||||
|
https://docs-dev.frigate.video/*
|
||||||
|
X-Robots-Tag: noindex
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 28 MiB After Width: | Height: | Size: 12 MiB |
+7
-10
@@ -848,9 +848,10 @@ async def onvif_probe(
|
|||||||
try:
|
try:
|
||||||
if isinstance(uri, str) and uri.startswith("rtsp://"):
|
if isinstance(uri, str) and uri.startswith("rtsp://"):
|
||||||
if username and password and "@" not in uri:
|
if username and password and "@" not in uri:
|
||||||
# Inject URL-encoded credentials and add only the
|
# Inject raw credentials and add only the
|
||||||
# authenticated version.
|
# authenticated version. The credentials will be encoded
|
||||||
cred = f"{quote_plus(username)}:{quote_plus(password)}@"
|
# later by ffprobe_stream or the config system.
|
||||||
|
cred = f"{username}:{password}@"
|
||||||
injected = uri.replace(
|
injected = uri.replace(
|
||||||
"rtsp://", f"rtsp://{cred}", 1
|
"rtsp://", f"rtsp://{cred}", 1
|
||||||
)
|
)
|
||||||
@@ -903,12 +904,8 @@ async def onvif_probe(
|
|||||||
"/cam/realmonitor?channel=1&subtype=0",
|
"/cam/realmonitor?channel=1&subtype=0",
|
||||||
"/11",
|
"/11",
|
||||||
]
|
]
|
||||||
# Use URL-encoded credentials for pattern fallback URIs when provided
|
# Use raw credentials for pattern fallback URIs when provided
|
||||||
auth_str = (
|
auth_str = f"{username}:{password}@" if username and password else ""
|
||||||
f"{quote_plus(username)}:{quote_plus(password)}@"
|
|
||||||
if username and password
|
|
||||||
else ""
|
|
||||||
)
|
|
||||||
rtsp_port = 554
|
rtsp_port = 554
|
||||||
for path in common_paths:
|
for path in common_paths:
|
||||||
uri = f"rtsp://{auth_str}{host}:{rtsp_port}{path}"
|
uri = f"rtsp://{auth_str}{host}:{rtsp_port}{path}"
|
||||||
@@ -930,7 +927,7 @@ async def onvif_probe(
|
|||||||
and uri.startswith("rtsp://")
|
and uri.startswith("rtsp://")
|
||||||
and "@" not in uri
|
and "@" not in uri
|
||||||
):
|
):
|
||||||
cred = f"{quote_plus(username)}:{quote_plus(password)}@"
|
cred = f"{username}:{password}@"
|
||||||
cred_uri = uri.replace("rtsp://", f"rtsp://{cred}", 1)
|
cred_uri = uri.replace("rtsp://", f"rtsp://{cred}", 1)
|
||||||
if cred_uri not in to_test:
|
if cred_uri not in to_test:
|
||||||
to_test.append(cred_uri)
|
to_test.append(cred_uri)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ class ReviewQueryParams(BaseModel):
|
|||||||
cameras: str = "all"
|
cameras: str = "all"
|
||||||
labels: str = "all"
|
labels: str = "all"
|
||||||
zones: str = "all"
|
zones: str = "all"
|
||||||
reviewed: int = 0
|
reviewed: Union[int, SkipJsonSchema[None]] = None
|
||||||
limit: Union[int, SkipJsonSchema[None]] = None
|
limit: Union[int, SkipJsonSchema[None]] = None
|
||||||
severity: Union[SeverityEnum, SkipJsonSchema[None]] = None
|
severity: Union[SeverityEnum, SkipJsonSchema[None]] = None
|
||||||
before: Union[float, SkipJsonSchema[None]] = None
|
before: Union[float, SkipJsonSchema[None]] = None
|
||||||
|
|||||||
@@ -144,6 +144,8 @@ async def review(
|
|||||||
(UserReviewStatus.has_been_reviewed == False)
|
(UserReviewStatus.has_been_reviewed == False)
|
||||||
| (UserReviewStatus.has_been_reviewed.is_null())
|
| (UserReviewStatus.has_been_reviewed.is_null())
|
||||||
)
|
)
|
||||||
|
elif reviewed == 1:
|
||||||
|
review_query = review_query.where(UserReviewStatus.has_been_reviewed == True)
|
||||||
|
|
||||||
# Apply ordering and limit
|
# Apply ordering and limit
|
||||||
review_query = (
|
review_query = (
|
||||||
|
|||||||
@@ -26,3 +26,6 @@ class GenAIConfig(FrigateBaseModel):
|
|||||||
provider_options: dict[str, Any] = Field(
|
provider_options: dict[str, Any] = Field(
|
||||||
default={}, title="GenAI Provider extra options."
|
default={}, title="GenAI Provider extra options."
|
||||||
)
|
)
|
||||||
|
runtime_options: dict[str, Any] = Field(
|
||||||
|
default={}, title="Options to pass during inference calls."
|
||||||
|
)
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ class OpenAIClient(GenAIClient):
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
|
**self.genai_config.runtime_options,
|
||||||
)
|
)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Azure OpenAI returned an error: %s", str(e))
|
logger.warning("Azure OpenAI returned an error: %s", str(e))
|
||||||
|
|||||||
@@ -35,10 +35,14 @@ class GeminiClient(GenAIClient):
|
|||||||
for img in images
|
for img in images
|
||||||
] + [prompt]
|
] + [prompt]
|
||||||
try:
|
try:
|
||||||
|
# Merge runtime_options into generation_config if provided
|
||||||
|
generation_config_dict = {"candidate_count": 1}
|
||||||
|
generation_config_dict.update(self.genai_config.runtime_options)
|
||||||
|
|
||||||
response = self.provider.generate_content(
|
response = self.provider.generate_content(
|
||||||
data,
|
data,
|
||||||
generation_config=genai.types.GenerationConfig(
|
generation_config=genai.types.GenerationConfig(
|
||||||
candidate_count=1,
|
**generation_config_dict
|
||||||
),
|
),
|
||||||
request_options=genai.types.RequestOptions(
|
request_options=genai.types.RequestOptions(
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
|
|||||||
@@ -58,11 +58,15 @@ class OllamaClient(GenAIClient):
|
|||||||
)
|
)
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
|
ollama_options = {
|
||||||
|
**self.provider_options,
|
||||||
|
**self.genai_config.runtime_options,
|
||||||
|
}
|
||||||
result = self.provider.generate(
|
result = self.provider.generate(
|
||||||
self.genai_config.model,
|
self.genai_config.model,
|
||||||
prompt,
|
prompt,
|
||||||
images=images if images else None,
|
images=images if images else None,
|
||||||
**self.provider_options,
|
**ollama_options,
|
||||||
)
|
)
|
||||||
logger.debug(
|
logger.debug(
|
||||||
f"Ollama tokens used: eval_count={result.get('eval_count')}, prompt_eval_count={result.get('prompt_eval_count')}"
|
f"Ollama tokens used: eval_count={result.get('eval_count')}, prompt_eval_count={result.get('prompt_eval_count')}"
|
||||||
|
|||||||
+19
-3
@@ -22,9 +22,14 @@ class OpenAIClient(GenAIClient):
|
|||||||
|
|
||||||
def _init_provider(self):
|
def _init_provider(self):
|
||||||
"""Initialize the client."""
|
"""Initialize the client."""
|
||||||
return OpenAI(
|
# Extract context_size from provider_options as it's not a valid OpenAI client parameter
|
||||||
api_key=self.genai_config.api_key, **self.genai_config.provider_options
|
# It will be used in get_context_size() instead
|
||||||
)
|
provider_opts = {
|
||||||
|
k: v
|
||||||
|
for k, v in self.genai_config.provider_options.items()
|
||||||
|
if k != "context_size"
|
||||||
|
}
|
||||||
|
return OpenAI(api_key=self.genai_config.api_key, **provider_opts)
|
||||||
|
|
||||||
def _send(self, prompt: str, images: list[bytes]) -> Optional[str]:
|
def _send(self, prompt: str, images: list[bytes]) -> Optional[str]:
|
||||||
"""Submit a request to OpenAI."""
|
"""Submit a request to OpenAI."""
|
||||||
@@ -56,6 +61,7 @@ class OpenAIClient(GenAIClient):
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
timeout=self.timeout,
|
timeout=self.timeout,
|
||||||
|
**self.genai_config.runtime_options,
|
||||||
)
|
)
|
||||||
if (
|
if (
|
||||||
result is not None
|
result is not None
|
||||||
@@ -73,6 +79,16 @@ class OpenAIClient(GenAIClient):
|
|||||||
if self.context_size is not None:
|
if self.context_size is not None:
|
||||||
return self.context_size
|
return self.context_size
|
||||||
|
|
||||||
|
# First check provider_options for manually specified context size
|
||||||
|
# This is necessary for llama.cpp and other OpenAI-compatible servers
|
||||||
|
# that don't expose the configured runtime context size in the API response
|
||||||
|
if "context_size" in self.genai_config.provider_options:
|
||||||
|
self.context_size = self.genai_config.provider_options["context_size"]
|
||||||
|
logger.debug(
|
||||||
|
f"Using context size {self.context_size} from provider_options for model {self.genai_config.model}"
|
||||||
|
)
|
||||||
|
return self.context_size
|
||||||
|
|
||||||
try:
|
try:
|
||||||
models = self.provider.models.list()
|
models = self.provider.models.list()
|
||||||
for model in models.data:
|
for model in models.data:
|
||||||
|
|||||||
@@ -171,8 +171,8 @@ class BaseTestHttp(unittest.TestCase):
|
|||||||
def insert_mock_event(
|
def insert_mock_event(
|
||||||
self,
|
self,
|
||||||
id: str,
|
id: str,
|
||||||
start_time: float = datetime.datetime.now().timestamp(),
|
start_time: float | None = None,
|
||||||
end_time: float = datetime.datetime.now().timestamp() + 20,
|
end_time: float | None = None,
|
||||||
has_clip: bool = True,
|
has_clip: bool = True,
|
||||||
top_score: int = 100,
|
top_score: int = 100,
|
||||||
score: int = 0,
|
score: int = 0,
|
||||||
@@ -180,6 +180,11 @@ class BaseTestHttp(unittest.TestCase):
|
|||||||
camera: str = "front_door",
|
camera: str = "front_door",
|
||||||
) -> Event:
|
) -> Event:
|
||||||
"""Inserts a basic event model with a given id."""
|
"""Inserts a basic event model with a given id."""
|
||||||
|
if start_time is None:
|
||||||
|
start_time = datetime.datetime.now().timestamp()
|
||||||
|
if end_time is None:
|
||||||
|
end_time = start_time + 20
|
||||||
|
|
||||||
return Event.insert(
|
return Event.insert(
|
||||||
id=id,
|
id=id,
|
||||||
label="Mock",
|
label="Mock",
|
||||||
@@ -229,11 +234,16 @@ class BaseTestHttp(unittest.TestCase):
|
|||||||
def insert_mock_recording(
|
def insert_mock_recording(
|
||||||
self,
|
self,
|
||||||
id: str,
|
id: str,
|
||||||
start_time: float = datetime.datetime.now().timestamp(),
|
start_time: float | None = None,
|
||||||
end_time: float = datetime.datetime.now().timestamp() + 20,
|
end_time: float | None = None,
|
||||||
motion: int = 0,
|
motion: int = 0,
|
||||||
) -> Event:
|
) -> Event:
|
||||||
"""Inserts a recording model with a given id."""
|
"""Inserts a recording model with a given id."""
|
||||||
|
if start_time is None:
|
||||||
|
start_time = datetime.datetime.now().timestamp()
|
||||||
|
if end_time is None:
|
||||||
|
end_time = start_time + 20
|
||||||
|
|
||||||
return Recordings.insert(
|
return Recordings.insert(
|
||||||
id=id,
|
id=id,
|
||||||
path=id,
|
path=id,
|
||||||
|
|||||||
@@ -96,16 +96,17 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
assert len(events) == 0
|
assert len(events) == 0
|
||||||
|
|
||||||
def test_get_event_list_limit(self):
|
def test_get_event_list_limit(self):
|
||||||
|
now = datetime.now().timestamp()
|
||||||
id = "123456.random"
|
id = "123456.random"
|
||||||
id2 = "54321.random"
|
id2 = "54321.random"
|
||||||
|
|
||||||
with AuthTestClient(self.app) as client:
|
with AuthTestClient(self.app) as client:
|
||||||
super().insert_mock_event(id)
|
super().insert_mock_event(id, start_time=now + 1)
|
||||||
events = client.get("/events").json()
|
events = client.get("/events").json()
|
||||||
assert len(events) == 1
|
assert len(events) == 1
|
||||||
assert events[0]["id"] == id
|
assert events[0]["id"] == id
|
||||||
|
|
||||||
super().insert_mock_event(id2)
|
super().insert_mock_event(id2, start_time=now)
|
||||||
events = client.get("/events").json()
|
events = client.get("/events").json()
|
||||||
assert len(events) == 2
|
assert len(events) == 2
|
||||||
|
|
||||||
@@ -144,7 +145,7 @@ class TestHttpApp(BaseTestHttp):
|
|||||||
assert events[0]["id"] == id2
|
assert events[0]["id"] == id2
|
||||||
assert events[1]["id"] == id
|
assert events[1]["id"] == id
|
||||||
|
|
||||||
events = client.get("/events", params={"sort": "score_des"}).json()
|
events = client.get("/events", params={"sort": "score_desc"}).json()
|
||||||
assert len(events) == 2
|
assert len(events) == 2
|
||||||
assert events[0]["id"] == id
|
assert events[0]["id"] == id
|
||||||
assert events[1]["id"] == id2
|
assert events[1]["id"] == id2
|
||||||
|
|||||||
@@ -196,6 +196,50 @@ class TestHttpReview(BaseTestHttp):
|
|||||||
assert len(response_json) == 1
|
assert len(response_json) == 1
|
||||||
assert response_json[0]["id"] == id
|
assert response_json[0]["id"] == id
|
||||||
|
|
||||||
|
def test_get_review_with_reviewed_filter_unreviewed(self):
|
||||||
|
"""Test that reviewed=0 returns only unreviewed items."""
|
||||||
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
id_unreviewed = "123456.unreviewed"
|
||||||
|
id_reviewed = "123456.reviewed"
|
||||||
|
super().insert_mock_review_segment(id_unreviewed, now, now + 2)
|
||||||
|
super().insert_mock_review_segment(id_reviewed, now, now + 2)
|
||||||
|
self._insert_user_review_status(id_reviewed, reviewed=True)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"reviewed": 0,
|
||||||
|
"after": now - 1,
|
||||||
|
"before": now + 3,
|
||||||
|
}
|
||||||
|
response = client.get("/review", params=params)
|
||||||
|
assert response.status_code == 200
|
||||||
|
response_json = response.json()
|
||||||
|
assert len(response_json) == 1
|
||||||
|
assert response_json[0]["id"] == id_unreviewed
|
||||||
|
|
||||||
|
def test_get_review_with_reviewed_filter_reviewed(self):
|
||||||
|
"""Test that reviewed=1 returns only reviewed items."""
|
||||||
|
now = datetime.now().timestamp()
|
||||||
|
|
||||||
|
with AuthTestClient(self.app) as client:
|
||||||
|
id_unreviewed = "123456.unreviewed"
|
||||||
|
id_reviewed = "123456.reviewed"
|
||||||
|
super().insert_mock_review_segment(id_unreviewed, now, now + 2)
|
||||||
|
super().insert_mock_review_segment(id_reviewed, now, now + 2)
|
||||||
|
self._insert_user_review_status(id_reviewed, reviewed=True)
|
||||||
|
|
||||||
|
params = {
|
||||||
|
"reviewed": 1,
|
||||||
|
"after": now - 1,
|
||||||
|
"before": now + 3,
|
||||||
|
}
|
||||||
|
response = client.get("/review", params=params)
|
||||||
|
assert response.status_code == 200
|
||||||
|
response_json = response.json()
|
||||||
|
assert len(response_json) == 1
|
||||||
|
assert response_json[0]["id"] == id_reviewed
|
||||||
|
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
################################### GET /review/summary Endpoint #################################################
|
################################### GET /review/summary Endpoint #################################################
|
||||||
####################################################################################################################
|
####################################################################################################################
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ def write_training_metadata(model_name: str, image_count: int) -> None:
|
|||||||
model_name: Name of the classification model
|
model_name: Name of the classification model
|
||||||
image_count: Number of images used in training
|
image_count: Number of images used in training
|
||||||
"""
|
"""
|
||||||
|
model_name = model_name.strip()
|
||||||
clips_model_dir = os.path.join(CLIPS_DIR, model_name)
|
clips_model_dir = os.path.join(CLIPS_DIR, model_name)
|
||||||
os.makedirs(clips_model_dir, exist_ok=True)
|
os.makedirs(clips_model_dir, exist_ok=True)
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ def read_training_metadata(model_name: str) -> dict[str, any] | None:
|
|||||||
Returns:
|
Returns:
|
||||||
Dictionary with last_training_date and last_training_image_count, or None if not found
|
Dictionary with last_training_date and last_training_image_count, or None if not found
|
||||||
"""
|
"""
|
||||||
|
model_name = model_name.strip()
|
||||||
clips_model_dir = os.path.join(CLIPS_DIR, model_name)
|
clips_model_dir = os.path.join(CLIPS_DIR, model_name)
|
||||||
metadata_path = os.path.join(clips_model_dir, TRAINING_METADATA_FILE)
|
metadata_path = os.path.join(clips_model_dir, TRAINING_METADATA_FILE)
|
||||||
|
|
||||||
@@ -95,6 +97,7 @@ def get_dataset_image_count(model_name: str) -> int:
|
|||||||
Returns:
|
Returns:
|
||||||
Total count of images across all categories
|
Total count of images across all categories
|
||||||
"""
|
"""
|
||||||
|
model_name = model_name.strip()
|
||||||
dataset_dir = os.path.join(CLIPS_DIR, model_name, "dataset")
|
dataset_dir = os.path.join(CLIPS_DIR, model_name, "dataset")
|
||||||
|
|
||||||
if not os.path.exists(dataset_dir):
|
if not os.path.exists(dataset_dir):
|
||||||
@@ -126,6 +129,7 @@ class ClassificationTrainingProcess(FrigateProcess):
|
|||||||
"TF_KERAS_MOBILENET_V2_WEIGHTS_URL",
|
"TF_KERAS_MOBILENET_V2_WEIGHTS_URL",
|
||||||
"",
|
"",
|
||||||
)
|
)
|
||||||
|
model_name = model_name.strip()
|
||||||
super().__init__(
|
super().__init__(
|
||||||
stop_event=None,
|
stop_event=None,
|
||||||
priority=PROCESS_PRIORITY_LOW,
|
priority=PROCESS_PRIORITY_LOW,
|
||||||
@@ -292,6 +296,7 @@ class ClassificationTrainingProcess(FrigateProcess):
|
|||||||
def kickoff_model_training(
|
def kickoff_model_training(
|
||||||
embeddingRequestor: EmbeddingsRequestor, model_name: str
|
embeddingRequestor: EmbeddingsRequestor, model_name: str
|
||||||
) -> None:
|
) -> None:
|
||||||
|
model_name = model_name.strip()
|
||||||
requestor = InterProcessRequestor()
|
requestor = InterProcessRequestor()
|
||||||
requestor.send_data(
|
requestor.send_data(
|
||||||
UPDATE_MODEL_STATE,
|
UPDATE_MODEL_STATE,
|
||||||
@@ -359,6 +364,7 @@ def collect_state_classification_examples(
|
|||||||
model_name: Name of the classification model
|
model_name: Name of the classification model
|
||||||
cameras: Dict mapping camera names to normalized crop coordinates [x1, y1, x2, y2] (0-1)
|
cameras: Dict mapping camera names to normalized crop coordinates [x1, y1, x2, y2] (0-1)
|
||||||
"""
|
"""
|
||||||
|
model_name = model_name.strip()
|
||||||
dataset_dir = os.path.join(CLIPS_DIR, model_name, "dataset")
|
dataset_dir = os.path.join(CLIPS_DIR, model_name, "dataset")
|
||||||
|
|
||||||
# Step 1: Get review items for the cameras
|
# Step 1: Get review items for the cameras
|
||||||
@@ -714,6 +720,7 @@ def collect_object_classification_examples(
|
|||||||
model_name: Name of the classification model
|
model_name: Name of the classification model
|
||||||
label: Object label to collect (e.g., "person", "car")
|
label: Object label to collect (e.g., "person", "car")
|
||||||
"""
|
"""
|
||||||
|
model_name = model_name.strip()
|
||||||
dataset_dir = os.path.join(CLIPS_DIR, model_name, "dataset")
|
dataset_dir = os.path.join(CLIPS_DIR, model_name, "dataset")
|
||||||
temp_dir = os.path.join(dataset_dir, "temp")
|
temp_dir = os.path.join(dataset_dir, "temp")
|
||||||
os.makedirs(temp_dir, exist_ok=True)
|
os.makedirs(temp_dir, exist_ok=True)
|
||||||
|
|||||||
@@ -540,9 +540,16 @@ def get_jetson_stats() -> Optional[dict[int, dict]]:
|
|||||||
try:
|
try:
|
||||||
results["mem"] = "-" # no discrete gpu memory
|
results["mem"] = "-" # no discrete gpu memory
|
||||||
|
|
||||||
with open("/sys/devices/gpu.0/load", "r") as f:
|
if os.path.exists("/sys/devices/gpu.0/load"):
|
||||||
gpuload = float(f.readline()) / 10
|
with open("/sys/devices/gpu.0/load", "r") as f:
|
||||||
results["gpu"] = f"{gpuload}%"
|
gpuload = float(f.readline()) / 10
|
||||||
|
results["gpu"] = f"{gpuload}%"
|
||||||
|
elif os.path.exists("/sys/devices/platform/gpu.0/load"):
|
||||||
|
with open("/sys/devices/platform/gpu.0/load", "r") as f:
|
||||||
|
gpuload = float(f.readline()) / 10
|
||||||
|
results["gpu"] = f"{gpuload}%"
|
||||||
|
else:
|
||||||
|
results["gpu"] = "-"
|
||||||
except Exception:
|
except Exception:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,9 @@
|
|||||||
},
|
},
|
||||||
"provider_options": {
|
"provider_options": {
|
||||||
"label": "GenAI Provider extra options."
|
"label": "GenAI Provider extra options."
|
||||||
|
},
|
||||||
|
"runtime_options": {
|
||||||
|
"label": "Options to pass during inference calls."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,7 +164,7 @@ export const ClassificationCard = forwardRef<
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div className="break-all smart-capitalize">
|
<div className="break-all smart-capitalize">
|
||||||
{data.name == "unknown"
|
{data.name.toLowerCase() == "unknown"
|
||||||
? t("details.unknown")
|
? t("details.unknown")
|
||||||
: data.name.toLowerCase() == "none"
|
: data.name.toLowerCase() == "none"
|
||||||
? t("details.none")
|
? t("details.none")
|
||||||
@@ -336,7 +336,7 @@ export function GroupedClassificationCard({
|
|||||||
<ContentTitle className="flex items-center gap-2 font-normal capitalize">
|
<ContentTitle className="flex items-center gap-2 font-normal capitalize">
|
||||||
{classifiedEvent?.label && classifiedEvent.label !== "none"
|
{classifiedEvent?.label && classifiedEvent.label !== "none"
|
||||||
? classifiedEvent.label
|
? classifiedEvent.label
|
||||||
: t(noClassificationLabel)}
|
: t(noClassificationLabel, { ns: i18nLibrary })}
|
||||||
{classifiedEvent?.label &&
|
{classifiedEvent?.label &&
|
||||||
classifiedEvent.label !== "none" &&
|
classifiedEvent.label !== "none" &&
|
||||||
classifiedEvent.score !== undefined && (
|
classifiedEvent.score !== undefined && (
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ export function GenAISummaryDialog({
|
|||||||
!aiAnalysis ||
|
!aiAnalysis ||
|
||||||
(!aiAnalysis.potential_threat_level && !aiAnalysis.other_concerns)
|
(!aiAnalysis.potential_threat_level && !aiAnalysis.other_concerns)
|
||||||
) {
|
) {
|
||||||
return "None";
|
return t("label.none", { ns: "common" });
|
||||||
}
|
}
|
||||||
|
|
||||||
let concerns = "";
|
let concerns = "";
|
||||||
@@ -74,7 +74,9 @@ export function GenAISummaryDialog({
|
|||||||
label = t("securityConcern", { ns: "views/events" });
|
label = t("securityConcern", { ns: "views/events" });
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
label = THREAT_LEVEL_LABELS[threatLevel as ThreatLevel] || "Unknown";
|
label =
|
||||||
|
THREAT_LEVEL_LABELS[threatLevel as ThreatLevel] ||
|
||||||
|
t("details.unknown", { ns: "views/classificationModel" });
|
||||||
}
|
}
|
||||||
concerns = `• ${label}\n`;
|
concerns = `• ${label}\n`;
|
||||||
}
|
}
|
||||||
@@ -83,7 +85,7 @@ export function GenAISummaryDialog({
|
|||||||
concerns += `• ${c}\n`;
|
concerns += `• ${c}\n`;
|
||||||
});
|
});
|
||||||
|
|
||||||
return concerns || "None";
|
return concerns || t("label.none", { ns: "common" });
|
||||||
}, [aiAnalysis, t]);
|
}, [aiAnalysis, t]);
|
||||||
|
|
||||||
// layout
|
// layout
|
||||||
|
|||||||
@@ -342,7 +342,9 @@ export default function PreviewThumbnailPlayer({
|
|||||||
default:
|
default:
|
||||||
return (
|
return (
|
||||||
THREAT_LEVEL_LABELS[threatLevel as ThreatLevel] ||
|
THREAT_LEVEL_LABELS[threatLevel as ThreatLevel] ||
|
||||||
"Unknown"
|
t("details.unknown", {
|
||||||
|
ns: "views/classificationModel",
|
||||||
|
})
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
})()}
|
})()}
|
||||||
|
|||||||
@@ -205,7 +205,7 @@ export default function Events() {
|
|||||||
cameras: reviewSearchParams["cameras"],
|
cameras: reviewSearchParams["cameras"],
|
||||||
labels: reviewSearchParams["labels"],
|
labels: reviewSearchParams["labels"],
|
||||||
zones: reviewSearchParams["zones"],
|
zones: reviewSearchParams["zones"],
|
||||||
reviewed: 1,
|
reviewed: null, // We want both reviewed and unreviewed items as we filter in the UI
|
||||||
before: reviewSearchParams["before"] || last24Hours.before,
|
before: reviewSearchParams["before"] || last24Hours.before,
|
||||||
after: reviewSearchParams["after"] || last24Hours.after,
|
after: reviewSearchParams["after"] || last24Hours.after,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ export async function detectReolinkCamera(
|
|||||||
export function maskUri(uri: string): string {
|
export function maskUri(uri: string): string {
|
||||||
try {
|
try {
|
||||||
// Handle RTSP URLs with user:pass@host format
|
// Handle RTSP URLs with user:pass@host format
|
||||||
const rtspMatch = uri.match(/rtsp:\/\/([^:]+):([^@]+)@(.+)/);
|
// Use greedy match for password to handle passwords with @
|
||||||
|
const rtspMatch = uri.match(/rtsp:\/\/([^:]+):(.+)@(.+)/);
|
||||||
if (rtspMatch) {
|
if (rtspMatch) {
|
||||||
return `rtsp://${rtspMatch[1]}:${"*".repeat(4)}@${rtspMatch[3]}`;
|
return `rtsp://${rtspMatch[1]}:${"*".repeat(4)}@${rtspMatch[3]}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -165,7 +165,7 @@ export const formatUnixTimestampToDateTime = (
|
|||||||
// Uppercase AM/PM for 12-hour formats
|
// Uppercase AM/PM for 12-hour formats
|
||||||
if (date_format.includes("a") || date_format.includes("aaa")) {
|
if (date_format.includes("a") || date_format.includes("aaa")) {
|
||||||
formatted = formatted.replace(/am|pm/gi, (match) =>
|
formatted = formatted.replace(/am|pm/gi, (match) =>
|
||||||
match.toUpperCase(),
|
i18n.t(`time.${match.toLowerCase()}`, { ns: "common" }).toUpperCase(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return formatted;
|
return formatted;
|
||||||
@@ -217,7 +217,7 @@ export const formatUnixTimestampToDateTime = (
|
|||||||
// Uppercase AM/PM in fallback
|
// Uppercase AM/PM in fallback
|
||||||
if (options.hour12) {
|
if (options.hour12) {
|
||||||
fallbackFormatted = fallbackFormatted.replace(/am|pm/gi, (match) =>
|
fallbackFormatted = fallbackFormatted.replace(/am|pm/gi, (match) =>
|
||||||
match.toUpperCase(),
|
i18n.t(`time.${match.toLowerCase()}`, { ns: "common" }).toUpperCase(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return fallbackFormatted;
|
return fallbackFormatted;
|
||||||
|
|||||||
@@ -266,7 +266,10 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
const keys = Object.keys(dataset.categories).filter((key) => key != "none");
|
const keys = Object.keys(dataset.categories).filter(
|
||||||
|
(key) => key != "none" && key.toLowerCase() != "unknown",
|
||||||
|
);
|
||||||
|
|
||||||
if (keys.length === 0) {
|
if (keys.length === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ export default function LiveDashboardView({
|
|||||||
{
|
{
|
||||||
limit: 10,
|
limit: 10,
|
||||||
severity: "alert",
|
severity: "alert",
|
||||||
|
reviewed: 0,
|
||||||
cameras: alertCameras,
|
cameras: alertCameras,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
Reference in New Issue
Block a user