Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Birdseye enhancements #9778

Merged
merged 5 commits into from
Feb 10, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 50 additions & 1 deletion docs/docs/configuration/birdseye.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
# Birdseye

Birdseye allows a heads-up view of your cameras to see what is going on around your property / space without having to watch all cameras that may have nothing happening. Birdseye allows specific modes that intelligently show and disappear based on what you care about.
Birdseye allows a heads-up view of your cameras to see what is going on around your property / space without having to watch all cameras that may have nothing happening. Birdseye allows specific modes that intelligently show and disappear based on what you care about.

## Birdseye Behavior

### Birdseye Modes

Expand Down Expand Up @@ -34,6 +36,29 @@ cameras:
enabled: False
```

### Birdseye Inactivity

By default birdseye shows all cameras that have had the configured activity in the last 30 seconds, this can be configured:

```yaml
birdseye:
enabled: True
inactivity_threshold: 15
```

## Birdseye Layout

### Birdseye Dimensions

The resolution and aspect ratio of birdseye can be configured. Resolution will increase the quality but does not affect the layout. Changing the aspect ratio of birdseye does affect how cameras are laid out.

```yaml
birdseye:
enabled: True
width: 1280
height: 720
```

### Sorting cameras in the Birdseye view

It is possible to override the order of cameras that are being shown in the Birdseye view.
Expand All @@ -55,3 +80,27 @@ cameras:
```

*Note*: Cameras are sorted by default using their name to ensure a constant view inside Birdseye.

### Birdseye Cameras

It is possible to limit the number of cameras shown on birdseye at one time. When this is enabled, birdseye will show the cameras with most recent activity. There is a cooldown to ensure that cameras do not switch too frequently.

For example, this can be configured to only show the most recently active camera.

```yaml
birdseye:
enabled: True
layout:
max_cameras: 1
```

### Birdseye Scaling

By default birdseye tries to fit 2 cameras in each row and then double in size until a suitable layout is found. The scaling can be configured with a value between 1.0 and 5.0 depending on use case.

```yaml
birdseye:
enabled: True
layout:
scaling_factor: 3.0
```
8 changes: 8 additions & 0 deletions docs/docs/configuration/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,14 @@ birdseye:
# motion - cameras are included if motion was detected in the last 30 seconds
# continuous - all cameras are included always
mode: objects
# Optional: Threshold for camera activity to stop showing camera (default: shown below)
inactivity_threshold: 30
# Optional: Configure the birdseye layout
layout:
# Optional: Scaling factor for the layout calculator (default: shown below)
scaling_factor: 2.0
# Optional: Maximum number of cameras to show at one time, showing the most recent (default: show all cameras)
max_cameras: 1

# Optional: ffmpeg configuration
# More information about presets at https://docs.frigate.video/configuration/ffmpeg_presets
Expand Down
13 changes: 13 additions & 0 deletions frigate/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -528,6 +528,13 @@ def get(cls, index):
return list(cls)[index]


class BirdseyeLayoutConfig(FrigateBaseModel):
scaling_factor: float = Field(
default=2.0, title="Birdseye Scaling Factor", ge=1.0, le=5.0
)
max_cameras: Optional[int] = Field(default=None, title="Max cameras")


class BirdseyeConfig(FrigateBaseModel):
enabled: bool = Field(default=True, title="Enable birdseye view.")
restream: bool = Field(default=False, title="Restream birdseye via RTSP.")
Expand All @@ -539,9 +546,15 @@ class BirdseyeConfig(FrigateBaseModel):
ge=1,
le=31,
)
inactivity_threshold: int = Field(
default=30, title="Birdseye Inactivity Threshold", gt=0
)
mode: BirdseyeModeEnum = Field(
default=BirdseyeModeEnum.objects, title="Tracking mode."
)
layout: BirdseyeLayoutConfig = Field(
default_factory=BirdseyeLayoutConfig, title="Birdseye Layout Config"
)


# uses BaseModel because some global attributes are not available at the camera level
Expand Down
84 changes: 70 additions & 14 deletions frigate/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,13 @@ def get_standard_aspect_ratio(width: int, height: int) -> tuple[int, int]:
(16, 9),
(9, 16),
(20, 10),
(16, 3), # max wide camera
(16, 6), # reolink duo 2
(32, 9), # panoramic cameras
(12, 9),
(9, 12),
(22, 15), # Amcrest, NTSC DVT
(1, 1), # fisheye
] # aspects are scaled to have common relative size
known_aspects_ratios = list(
map(lambda aspect: aspect[0] / aspect[1], known_aspects)
Expand Down Expand Up @@ -74,7 +76,13 @@ def get_canvas_shape(width: int, height: int) -> tuple[int, int]:


class Canvas:
def __init__(self, canvas_width: int, canvas_height: int) -> None:
def __init__(
self,
canvas_width: int,
canvas_height: int,
scaling_factor: int,
) -> None:
self.scaling_factor = scaling_factor
gcd = math.gcd(canvas_width, canvas_height)
self.aspect = get_standard_aspect_ratio(
(canvas_width / gcd), (canvas_height / gcd)
Expand All @@ -88,7 +96,7 @@ def get_aspect(self, coefficient: int) -> tuple[int, int]:
return (self.aspect[0] * coefficient, self.aspect[1] * coefficient)

def get_coefficient(self, camera_count: int) -> int:
return self.coefficient_cache.get(camera_count, 2)
return self.coefficient_cache.get(camera_count, self.scaling_factor)

def set_coefficient(self, camera_count: int, coefficient: int) -> None:
self.coefficient_cache[camera_count] = coefficient
Expand Down Expand Up @@ -276,9 +284,13 @@ def __init__(
self.frame_shape = (height, width)
self.yuv_shape = (height * 3 // 2, width)
self.frame = np.ndarray(self.yuv_shape, dtype=np.uint8)
self.canvas = Canvas(width, height)
self.canvas = Canvas(width, height, config.birdseye.layout.scaling_factor)
self.stop_event = stop_event
self.camera_metrics = camera_metrics
self.inactivity_threshold = config.birdseye.inactivity_threshold

if config.birdseye.layout.max_cameras:
self.last_refresh_time = 0

# initialize the frame as black and with the Frigate logo
self.blank_frame = np.zeros(self.yuv_shape, np.uint8)
Expand Down Expand Up @@ -384,16 +396,39 @@ def camera_active(self, mode, object_box_count, motion_box_count):
def update_frame(self):
"""Update to a new frame for birdseye."""

# determine how many cameras are tracking objects within the last 30 seconds
active_cameras = set(
# determine how many cameras are tracking objects within the last inactivity_threshold seconds
active_cameras: set[str] = set(
[
cam
for cam, cam_data in self.cameras.items()
if cam_data["last_active_frame"] > 0
and cam_data["current_frame"] - cam_data["last_active_frame"] < 30
and cam_data["current_frame"] - cam_data["last_active_frame"]
< self.inactivity_threshold
]
)

max_cameras = self.config.birdseye.layout.max_cameras
max_camera_refresh = False
if max_cameras:
now = datetime.datetime.now().timestamp()

if len(active_cameras) == max_cameras and now - self.last_refresh_time < 10:
# don't refresh cameras too often
active_cameras = self.active_cameras
else:
limited_active_cameras = sorted(
active_cameras,
key=lambda active_camera: (
self.cameras[active_camera]["current_frame"]
- self.cameras[active_camera]["last_active_frame"]
),
)
active_cameras = limited_active_cameras[
: self.config.birdseye.layout.max_cameras
]
max_camera_refresh = True
self.last_refresh_time = now

# if there are no active cameras
if len(active_cameras) == 0:
# if the layout is already cleared
Expand All @@ -407,7 +442,18 @@ def update_frame(self):
return True

# check if we need to reset the layout because there is a different number of cameras
reset_layout = len(self.active_cameras) - len(active_cameras) != 0
if len(self.active_cameras) - len(active_cameras) == 0:
if (
len(self.active_cameras) == 1
and self.active_cameras[0] == active_cameras[0]
):
reset_layout = True
elif max_camera_refresh:
reset_layout = True
else:
reset_layout = False
else:
reset_layout = True

# reset the layout if it needs to be different
if reset_layout:
Expand All @@ -431,17 +477,23 @@ def update_frame(self):
camera = active_cameras_to_add[0]
camera_dims = self.cameras[camera]["dimensions"].copy()
scaled_width = int(self.canvas.height * camera_dims[0] / camera_dims[1])
coefficient = (
1
if scaled_width <= self.canvas.width
else self.canvas.width / scaled_width
)

# center camera view in canvas and ensure that it fits
if scaled_width < self.canvas.width:
coefficient = 1
x_offset = int((self.canvas.width - scaled_width) / 2)
else:
coefficient = self.canvas.width / scaled_width
x_offset = int(
(self.canvas.width - (scaled_width * coefficient)) / 2
)

self.camera_layout = [
[
(
camera,
(
0,
x_offset,
0,
int(scaled_width * coefficient),
int(self.canvas.height * coefficient),
Expand Down Expand Up @@ -485,7 +537,11 @@ def update_frame(self):

return True

def calculate_layout(self, cameras_to_add: list[str], coefficient) -> tuple[any]:
def calculate_layout(
self,
cameras_to_add: list[str],
coefficient: float,
) -> tuple[any]:
"""Calculate the optimal layout for 2+ cameras."""

def map_layout(camera_layout: list[list[any]], row_height: int):
Expand Down
Loading