mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-05 21:30:30 +03:00
Compare commits
4
Commits
1f8d4b0dfa
...
96ff7bb8bc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
96ff7bb8bc | ||
|
|
e2ce0c82ff | ||
|
|
b5a360be39 | ||
|
|
54a7c5015e |
+83
-103
@@ -595,112 +595,92 @@ class BirdsEyeFrameManager:
|
||||
) -> Optional[list[list[Any]]]:
|
||||
"""Calculate the optimal layout for 2+ cameras."""
|
||||
|
||||
def map_layout(
|
||||
camera_layout: list[list[Any]], row_height: int
|
||||
) -> tuple[int, int, Optional[list[list[Any]]]]:
|
||||
"""Map the calculated layout."""
|
||||
candidate_layout = []
|
||||
starting_x = 0
|
||||
x = 0
|
||||
max_width = 0
|
||||
y = 0
|
||||
def find_available_x(
|
||||
current_x: int,
|
||||
width: int,
|
||||
reserved_ranges: list[tuple[int, int]],
|
||||
max_width: int,
|
||||
) -> Optional[int]:
|
||||
"""Find the first horizontal slot that does not collide with reservations."""
|
||||
x = current_x
|
||||
|
||||
for row in camera_layout:
|
||||
final_row = []
|
||||
max_width = max(max_width, x)
|
||||
x = starting_x
|
||||
for cameras in row:
|
||||
camera_dims = self.cameras[cameras[0]]["dimensions"].copy()
|
||||
camera_aspect = cameras[1]
|
||||
for reserved_start, reserved_end in sorted(reserved_ranges):
|
||||
if x >= reserved_end:
|
||||
continue
|
||||
|
||||
if camera_dims[1] > camera_dims[0]:
|
||||
scaled_height = int(row_height * 2)
|
||||
scaled_width = int(scaled_height * camera_aspect)
|
||||
starting_x = scaled_width
|
||||
else:
|
||||
scaled_height = row_height
|
||||
scaled_width = int(scaled_height * camera_aspect)
|
||||
if x + width <= reserved_start:
|
||||
return x
|
||||
|
||||
# layout is too large
|
||||
if (
|
||||
x + scaled_width > self.canvas.width
|
||||
or y + scaled_height > self.canvas.height
|
||||
):
|
||||
return x + scaled_width, y + scaled_height, None
|
||||
x = max(x, reserved_end)
|
||||
|
||||
final_row.append((cameras[0], (x, y, scaled_width, scaled_height)))
|
||||
x += scaled_width
|
||||
if x + width <= max_width:
|
||||
return x
|
||||
|
||||
y += row_height
|
||||
candidate_layout.append(final_row)
|
||||
|
||||
if max_width == 0:
|
||||
max_width = x
|
||||
|
||||
return max_width, y, candidate_layout
|
||||
|
||||
canvas_aspect_x, canvas_aspect_y = self.canvas.get_aspect(coefficient)
|
||||
camera_layout: list[list[Any]] = []
|
||||
camera_layout.append([])
|
||||
starting_x = 0
|
||||
x = starting_x
|
||||
y = 0
|
||||
y_i = 0
|
||||
max_y = 0
|
||||
for camera in cameras_to_add:
|
||||
camera_dims = self.cameras[camera]["dimensions"].copy()
|
||||
camera_aspect_x, camera_aspect_y = self.canvas.get_camera_aspect(
|
||||
camera, camera_dims[0], camera_dims[1]
|
||||
)
|
||||
|
||||
if camera_dims[1] > camera_dims[0]:
|
||||
portrait = True
|
||||
else:
|
||||
portrait = False
|
||||
|
||||
if (x + camera_aspect_x) <= canvas_aspect_x:
|
||||
# insert if camera can fit on current row
|
||||
camera_layout[y_i].append(
|
||||
(
|
||||
camera,
|
||||
camera_aspect_x / camera_aspect_y,
|
||||
)
|
||||
)
|
||||
|
||||
if portrait:
|
||||
starting_x = camera_aspect_x
|
||||
else:
|
||||
max_y = max(
|
||||
max_y,
|
||||
camera_aspect_y,
|
||||
)
|
||||
|
||||
x += camera_aspect_x
|
||||
else:
|
||||
# move on to the next row and insert
|
||||
y += max_y
|
||||
y_i += 1
|
||||
camera_layout.append([])
|
||||
x = starting_x
|
||||
|
||||
if x + camera_aspect_x > canvas_aspect_x:
|
||||
return None
|
||||
|
||||
camera_layout[y_i].append(
|
||||
(
|
||||
camera,
|
||||
camera_aspect_x / camera_aspect_y,
|
||||
)
|
||||
)
|
||||
x += camera_aspect_x
|
||||
|
||||
if y + max_y > canvas_aspect_y:
|
||||
return None
|
||||
|
||||
row_height = int(self.canvas.height / coefficient)
|
||||
total_width, total_height, standard_candidate_layout = map_layout(
|
||||
camera_layout, row_height
|
||||
)
|
||||
def map_layout(row_height: int) -> tuple[int, int, Optional[list[list[Any]]]]:
|
||||
"""Lay out cameras row by row while reserving portrait spans for the next row."""
|
||||
candidate_layout: list[list[Any]] = []
|
||||
reserved_ranges: dict[int, list[tuple[int, int]]] = {}
|
||||
current_row: list[Any] = []
|
||||
row_index = 0
|
||||
row_y = 0
|
||||
row_x = 0
|
||||
max_width = 0
|
||||
max_height = 0
|
||||
|
||||
for camera in cameras_to_add:
|
||||
camera_dims = self.cameras[camera]["dimensions"].copy()
|
||||
camera_aspect_x, camera_aspect_y = self.canvas.get_camera_aspect(
|
||||
camera, camera_dims[0], camera_dims[1]
|
||||
)
|
||||
portrait = camera_dims[1] > camera_dims[0]
|
||||
scaled_height = row_height * 2 if portrait else row_height
|
||||
scaled_width = int(scaled_height * (camera_aspect_x / camera_aspect_y))
|
||||
|
||||
while True:
|
||||
x = find_available_x(
|
||||
row_x,
|
||||
scaled_width,
|
||||
reserved_ranges.get(row_index, []),
|
||||
self.canvas.width,
|
||||
)
|
||||
|
||||
if x is not None and row_y + scaled_height <= self.canvas.height:
|
||||
current_row.append(
|
||||
(camera, (x, row_y, scaled_width, scaled_height))
|
||||
)
|
||||
row_x = x + scaled_width
|
||||
max_width = max(max_width, row_x)
|
||||
max_height = max(max_height, row_y + scaled_height)
|
||||
|
||||
if portrait:
|
||||
reserved_ranges.setdefault(row_index + 1, []).append(
|
||||
(x, row_x)
|
||||
)
|
||||
|
||||
break
|
||||
|
||||
if current_row:
|
||||
candidate_layout.append(current_row)
|
||||
current_row = []
|
||||
|
||||
row_index += 1
|
||||
row_y = row_index * row_height
|
||||
row_x = 0
|
||||
|
||||
if row_y + scaled_height > self.canvas.height:
|
||||
overflow_width = max(max_width, scaled_width)
|
||||
overflow_height = row_y + scaled_height
|
||||
return overflow_width, overflow_height, None
|
||||
|
||||
if current_row:
|
||||
candidate_layout.append(current_row)
|
||||
|
||||
return max_width, max_height, candidate_layout
|
||||
|
||||
row_height = max(1, int(self.canvas.height / coefficient))
|
||||
total_width, total_height, standard_candidate_layout = map_layout(row_height)
|
||||
|
||||
if not standard_candidate_layout:
|
||||
# if standard layout didn't work
|
||||
@@ -709,9 +689,9 @@ class BirdsEyeFrameManager:
|
||||
total_width / self.canvas.width,
|
||||
total_height / self.canvas.height,
|
||||
)
|
||||
row_height = int(row_height / scale_down_percent)
|
||||
row_height = max(1, int(row_height / scale_down_percent))
|
||||
total_width, total_height, standard_candidate_layout = map_layout(
|
||||
camera_layout, row_height
|
||||
row_height
|
||||
)
|
||||
|
||||
if not standard_candidate_layout:
|
||||
@@ -725,8 +705,8 @@ class BirdsEyeFrameManager:
|
||||
1 / (total_width / self.canvas.width),
|
||||
1 / (total_height / self.canvas.height),
|
||||
)
|
||||
row_height = int(row_height * scale_up_percent)
|
||||
_, _, scaled_layout = map_layout(camera_layout, row_height)
|
||||
row_height = max(1, int(row_height * scale_up_percent))
|
||||
_, _, scaled_layout = map_layout(row_height)
|
||||
|
||||
if scaled_layout:
|
||||
return scaled_layout
|
||||
|
||||
@@ -1,11 +1,64 @@
|
||||
"""Test camera user and password cleanup."""
|
||||
"""Tests for Birdseye canvas sizing and layout behavior."""
|
||||
|
||||
import unittest
|
||||
from multiprocessing import Event
|
||||
|
||||
from frigate.output.birdseye import get_canvas_shape
|
||||
from frigate.config import FrigateConfig
|
||||
from frigate.output.birdseye import BirdsEyeFrameManager, get_canvas_shape
|
||||
|
||||
|
||||
class TestBirdseye(unittest.TestCase):
|
||||
def _build_manager(
|
||||
self, camera_dimensions: dict[str, tuple[int, int]]
|
||||
) -> BirdsEyeFrameManager:
|
||||
config = {
|
||||
"mqtt": {"host": "mqtt"},
|
||||
"birdseye": {"width": 1280, "height": 720},
|
||||
"cameras": {},
|
||||
}
|
||||
|
||||
for order, (camera, dimensions) in enumerate(
|
||||
camera_dimensions.items(), start=1
|
||||
):
|
||||
config["cameras"][camera] = {
|
||||
"ffmpeg": {
|
||||
"inputs": [
|
||||
{
|
||||
"path": f"rtsp://10.0.0.1:554/{camera}",
|
||||
"roles": ["detect"],
|
||||
}
|
||||
]
|
||||
},
|
||||
"detect": {
|
||||
"width": dimensions[0],
|
||||
"height": dimensions[1],
|
||||
"fps": 5,
|
||||
},
|
||||
"birdseye": {"order": order},
|
||||
}
|
||||
|
||||
return BirdsEyeFrameManager(FrigateConfig(**config), Event())
|
||||
|
||||
def _assert_no_overlaps(
|
||||
self, layout: list[list[tuple[str, tuple[int, int, int, int]]]]
|
||||
):
|
||||
rectangles = [position for row in layout for _, position in row]
|
||||
|
||||
for index, rect in enumerate(rectangles):
|
||||
x1, y1, width1, height1 = rect
|
||||
for other in rectangles[index + 1 :]:
|
||||
x2, y2, width2, height2 = other
|
||||
overlap = (
|
||||
x1 < x2 + width2
|
||||
and x2 < x1 + width1
|
||||
and y1 < y2 + height2
|
||||
and y2 < y1 + height1
|
||||
)
|
||||
self.assertFalse(
|
||||
overlap,
|
||||
msg=f"Overlapping rectangles found: {rect} and {other}",
|
||||
)
|
||||
|
||||
def test_16x9(self):
|
||||
"""Test 16x9 aspect ratio works as expected for birdseye."""
|
||||
width = 1280
|
||||
@@ -45,3 +98,104 @@ class TestBirdseye(unittest.TestCase):
|
||||
canvas_width, canvas_height = get_canvas_shape(width, height)
|
||||
assert canvas_width == width # width will be the same
|
||||
assert canvas_height != height
|
||||
|
||||
def test_portrait_camera_does_not_overlap_next_row(self):
|
||||
"""Portrait cameras should reserve their real horizontal position on the next row."""
|
||||
manager = self._build_manager(
|
||||
{
|
||||
"cam_a": (1280, 720),
|
||||
"cam_p": (360, 640),
|
||||
"cam_b": (1280, 720),
|
||||
"cam_c": (640, 480),
|
||||
}
|
||||
)
|
||||
|
||||
layout = manager.calculate_layout(["cam_a", "cam_p", "cam_b", "cam_c"], 3)
|
||||
|
||||
self.assertIsNotNone(layout)
|
||||
assert layout is not None
|
||||
self._assert_no_overlaps(layout)
|
||||
|
||||
cam_c = [
|
||||
position for row in layout for camera, position in row if camera == "cam_c"
|
||||
][0]
|
||||
self.assertEqual(cam_c[0], 0)
|
||||
|
||||
def test_portrait_reservation_only_applies_to_next_row(self):
|
||||
"""Portrait reservations should not push later rows after the span ends."""
|
||||
manager = self._build_manager(
|
||||
{
|
||||
"cam_a": (1280, 720),
|
||||
"cam_p": (360, 640),
|
||||
"cam_b": (1280, 720),
|
||||
"cam_c": (1280, 720),
|
||||
"cam_d": (1280, 720),
|
||||
"cam_e": (1280, 720),
|
||||
}
|
||||
)
|
||||
|
||||
layout = manager.calculate_layout(
|
||||
["cam_a", "cam_p", "cam_b", "cam_c", "cam_d", "cam_e"],
|
||||
3,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(layout)
|
||||
assert layout is not None
|
||||
self._assert_no_overlaps(layout)
|
||||
|
||||
cam_e = [
|
||||
position for row in layout for camera, position in row if camera == "cam_e"
|
||||
][0]
|
||||
self.assertEqual(cam_e[0], 0)
|
||||
|
||||
def test_multiple_portraits_reserve_distinct_ranges(self):
|
||||
"""Multiple portrait cameras in one row should reserve separate spans below them."""
|
||||
manager = self._build_manager(
|
||||
{
|
||||
"cam_a": (640, 480),
|
||||
"cam_p1": (360, 640),
|
||||
"cam_p2": (360, 640),
|
||||
"cam_b": (640, 480),
|
||||
"cam_c": (1280, 720),
|
||||
"cam_d": (640, 480),
|
||||
}
|
||||
)
|
||||
|
||||
layout = manager.calculate_layout(
|
||||
["cam_a", "cam_p1", "cam_p2", "cam_b", "cam_c", "cam_d"],
|
||||
4,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(layout)
|
||||
assert layout is not None
|
||||
self._assert_no_overlaps(layout)
|
||||
|
||||
def test_two_landscapes_then_portrait_then_two_landscapes(self):
|
||||
"""A portrait after two landscapes should reserve only its own tail span."""
|
||||
manager = self._build_manager(
|
||||
{
|
||||
"cam_a": (1280, 720),
|
||||
"cam_b": (1280, 720),
|
||||
"cam_p": (360, 640),
|
||||
"cam_c": (1280, 720),
|
||||
"cam_d": (1280, 720),
|
||||
}
|
||||
)
|
||||
|
||||
layout = manager.calculate_layout(
|
||||
["cam_a", "cam_b", "cam_p", "cam_c", "cam_d"],
|
||||
3,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(layout)
|
||||
assert layout is not None
|
||||
self._assert_no_overlaps(layout)
|
||||
|
||||
cam_c = [
|
||||
position for row in layout for camera, position in row if camera == "cam_c"
|
||||
][0]
|
||||
cam_d = [
|
||||
position for row in layout for camera, position in row if camera == "cam_d"
|
||||
][0]
|
||||
self.assertEqual(cam_c[0], 0)
|
||||
self.assertEqual(cam_d[0], cam_c[0] + cam_c[2])
|
||||
|
||||
@@ -24,6 +24,9 @@
|
||||
},
|
||||
"state": {
|
||||
"submitted": "Submitted"
|
||||
},
|
||||
"toast": {
|
||||
"error": "Failed to submit to Frigate+. Please check your network connection and try again."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1251,30 +1251,42 @@ function ObjectDetailsTab({
|
||||
return;
|
||||
}
|
||||
|
||||
falsePositive
|
||||
? axios.put(`events/${search.id}/false_positive`)
|
||||
: axios.post(`events/${search.id}/plus`, {
|
||||
include_annotation: 1,
|
||||
});
|
||||
try {
|
||||
const resp = falsePositive
|
||||
? await axios.put(`events/${search.id}/false_positive`)
|
||||
: await axios.post(`events/${search.id}/plus`, {
|
||||
include_annotation: 1,
|
||||
});
|
||||
|
||||
setState("submitted");
|
||||
setSearch({ ...search, plus_id: "new_upload" });
|
||||
mutate(
|
||||
(key) => isEventsKey(key),
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||
mapSearchResults(currentData, (event) =>
|
||||
event.id === search.id
|
||||
? { ...event, plus_id: "new_upload" }
|
||||
: event,
|
||||
),
|
||||
{
|
||||
optimisticData: true,
|
||||
rollbackOnError: true,
|
||||
revalidate: false,
|
||||
},
|
||||
);
|
||||
if (resp.status !== 200 || !resp.data?.success) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
setState("submitted");
|
||||
setSearch({ ...search, plus_id: "new_upload" });
|
||||
mutate(
|
||||
(key) => isEventsKey(key),
|
||||
(currentData: SearchResult[][] | SearchResult[] | undefined) =>
|
||||
mapSearchResults(currentData, (event) =>
|
||||
event.id === search.id
|
||||
? { ...event, plus_id: "new_upload" }
|
||||
: event,
|
||||
),
|
||||
{
|
||||
optimisticData: true,
|
||||
rollbackOnError: true,
|
||||
revalidate: false,
|
||||
},
|
||||
);
|
||||
} catch {
|
||||
setState("reviewing");
|
||||
toast.error(
|
||||
t("explore.plus.review.toast.error", { ns: "components/dialog" }),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
}
|
||||
},
|
||||
[search, mutate, mapSearchResults, setSearch, isEventsKey],
|
||||
[search, mutate, mapSearchResults, setSearch, isEventsKey, t],
|
||||
);
|
||||
|
||||
const popoverContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -1225,11 +1225,15 @@ function LifecycleIconRow({
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
onSelect={async () => {
|
||||
const resp = await axios.post(
|
||||
`/${item.camera}/plus/${item.timestamp + annotationOffset / 1000}`,
|
||||
);
|
||||
try {
|
||||
const resp = await axios.post(
|
||||
`/${item.camera}/plus/${item.timestamp + annotationOffset / 1000}`,
|
||||
);
|
||||
|
||||
if (resp.status !== 200) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
if (resp && resp.status == 200) {
|
||||
toast.success(
|
||||
t("toast.success.submittedFrigatePlus", {
|
||||
ns: "components/player",
|
||||
@@ -1238,8 +1242,8 @@ function LifecycleIconRow({
|
||||
position: "top-center",
|
||||
},
|
||||
);
|
||||
} else {
|
||||
toast.success(
|
||||
} catch {
|
||||
toast.error(
|
||||
t("toast.error.submitFrigatePlusFailed", {
|
||||
ns: "components/player",
|
||||
}),
|
||||
|
||||
@@ -10,6 +10,7 @@ import { isDesktop, isMobile, isSafari } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation, Trans } from "react-i18next";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
@@ -48,13 +49,28 @@ export function FrigatePlusDialog({
|
||||
const onSubmitToPlus = useCallback(
|
||||
async (falsePositive: boolean) => {
|
||||
if (!upload) return;
|
||||
falsePositive
|
||||
? axios.put(`events/${upload.id}/false_positive`)
|
||||
: axios.post(`events/${upload.id}/plus`, { include_annotation: 1 });
|
||||
setState("submitted");
|
||||
onEventUploaded();
|
||||
|
||||
try {
|
||||
const resp = falsePositive
|
||||
? await axios.put(`events/${upload.id}/false_positive`)
|
||||
: await axios.post(`events/${upload.id}/plus`, {
|
||||
include_annotation: 1,
|
||||
});
|
||||
|
||||
if (resp.status !== 200 || !resp.data?.success) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
setState("submitted");
|
||||
onEventUploaded();
|
||||
} catch {
|
||||
setState("reviewing");
|
||||
toast.error(t("explore.plus.review.toast.error"), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
},
|
||||
[upload, onEventUploaded],
|
||||
[upload, onEventUploaded, t],
|
||||
);
|
||||
|
||||
const [imgRef, imgLoaded, onImgLoad] = useImageLoaded();
|
||||
|
||||
Reference in New Issue
Block a user