diff --git a/docker/Dockerfile b/docker/Dockerfile new file mode 100644 index 000000000..9363a8fb3 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,127 @@ +FROM blakeblackshear/frigate-nginx:1.0.2 as nginx + +FROM node:14 as web + +WORKDIR /opt/frigate + +COPY web/ . + +RUN npm install && npm run build + +FROM debian:11 as wheels + +ENV DEBIAN_FRONTEND=noninteractive + +# Use a separate container to build wheels to prevent build dependencies in final image +RUN apt-get -qq update \ + && apt-get -qq install -y \ + apt-transport-https \ + gnupg \ + wget \ + && wget -O - http://archive.raspberrypi.org/debian/raspberrypi.gpg.key | apt-key add - \ + && echo "deb http://archive.raspberrypi.org/debian/ bullseye main" | tee /etc/apt/sources.list.d/raspi.list \ + && apt-get -qq update \ + && apt-get -qq install -y \ + python3 \ + python3-dev \ + wget \ + # opencv dependencies + build-essential cmake git pkg-config libgtk-3-dev \ + libavcodec-dev libavformat-dev libswscale-dev libv4l-dev \ + libxvidcore-dev libx264-dev libjpeg-dev libpng-dev libtiff-dev \ + gfortran openexr libatlas-base-dev libssl-dev\ + libtbb2 libtbb-dev libdc1394-22-dev libopenexr-dev \ + libgstreamer-plugins-base1.0-dev libgstreamer1.0-dev \ + # scipy dependencies + gcc gfortran libopenblas-dev liblapack-dev + +RUN wget -q https://bootstrap.pypa.io/get-pip.py -O get-pip.py \ + && python3 get-pip.py "pip" + +RUN pip3 install scikit-build + +# TODO: lock with requirements.txt +RUN pip3 wheel --wheel-dir=/wheels \ + opencv-python-headless \ + numpy \ + imutils \ + scipy \ + psutil \ + Flask \ + paho-mqtt \ + PyYAML \ + matplotlib \ + click \ + setproctitle \ + peewee \ + peewee_migrate \ + pydantic \ + zeroconf \ + ws4py + +# Frigate Container +FROM debian:11-slim +ARG TARGETARCH +ARG S6_OVERLAY_VERSION=3.0.0.2 + +ENV DEBIAN_FRONTEND=noninteractive +ENV FLASK_ENV=development + +COPY --from=wheels /wheels /wheels + +# Install ffmpeg +RUN apt-get -qq update \ + && apt-get -qq install --no-install-recommends -y \ + apt-transport-https \ + gnupg \ + wget \ + unzip tzdata libxml2 xz-utils \ + python3-pip \ + # add raspberry pi repo + && wget -O - http://archive.raspberrypi.org/debian/raspberrypi.gpg.key | apt-key add - \ + && echo "deb http://archive.raspberrypi.org/debian/ bullseye main" | tee /etc/apt/sources.list.d/raspi.list \ + # add coral repo + && APT_KEY_DONT_WARN_ON_DANGEROUS_USAGE=DontWarn apt-key adv --fetch-keys https://packages.cloud.google.com/apt/doc/apt-key.gpg \ + && echo "deb https://packages.cloud.google.com/apt coral-edgetpu-stable main" > /etc/apt/sources.list.d/coral-edgetpu.list \ + && echo "libedgetpu1-max libedgetpu/accepted-eula select true" | debconf-set-selections \ + && apt-get -qq update \ + && apt-get -qq install --no-install-recommends -y \ + ffmpeg \ + # coral drivers + libedgetpu1-max python3-tflite-runtime python3-pycoral \ + && pip3 install -U /wheels/*.whl \ + && rm -rf /var/lib/apt/lists/* /wheels \ + && (apt-get autoremove -y; apt-get autoclean -y) + +COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/ + +# get model and labels +COPY labelmap.txt /labelmap.txt +RUN wget -q https://github.com/google-coral/test_data/raw/release-frogfish/ssdlite_mobiledet_coco_qat_postprocess_edgetpu.tflite -O /edgetpu_model.tflite +RUN wget -q https://github.com/google-coral/test_data/raw/release-frogfish/ssdlite_mobiledet_coco_qat_postprocess.tflite -O /cpu_model.tflite + +WORKDIR /opt/frigate/ +ADD frigate frigate/ +ADD migrations migrations/ + +COPY --from=web /opt/frigate/build web/ + +COPY docker/rootfs/ / + +# s6-overlay +RUN S6_ARCH="${TARGETARCH}" \ + && if [ "${TARGETARCH}" = "amd64" ]; then S6_ARCH="amd64"; fi \ + && if [ "${TARGETARCH}" = "arm64" ]; then S6_ARCH="aarch64"; fi \ + && wget -O /tmp/s6-overlay-installer "https://github.com/just-containers/s6-overlay/releases/download/v2.2.0.3/s6-overlay-${S6_ARCH}-installer" \ + && chmod +x /tmp/s6-overlay-installer && /tmp/s6-overlay-installer / +# && wget -O - "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch-${S6_OVERLAY_VERSION}.tar.xz" \ +# | tar -C / -Jxpf - \ +# && wget -O - "https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${S6_ARCH}-${S6_OVERLAY_VERSION}.tar.xz" \ +# | tar -C / -Jxpf - + +EXPOSE 5000 +EXPOSE 1935 + +ENTRYPOINT ["/init"] + +CMD ["python3", "-u", "-m", "frigate"] diff --git a/docs/docs/contributing.md b/docs/docs/contributing.md index a818000b4..16e0ce71e 100644 --- a/docs/docs/contributing.md +++ b/docs/docs/contributing.md @@ -90,6 +90,20 @@ VSCode will start the docker compose file for you and open a terminal window con After closing VSCode, you may still have containers running. To close everything down, just run `docker-compose down -v` to cleanup all containers. +### Testing + +#### FFMPEG Hardware Acceleration + +The following commands are used inside the container to ensure hardware acceleration is working properly. + +**Raspberry Pi (64bit)** + +This should show <50% CPU in top, and ~80% CPU without `-c:v h264_v4l2m2m`. + +```shell +ffmpeg -c:v h264_v4l2m2m -re -stream_loop -1 -i https://streams.videolan.org/ffmpeg/incoming/720p60.mp4 -f rawvideo -pix_fmt yuv420p pipe: > /dev/null +``` + ## Web Interface ### Prerequisites diff --git a/docs/docs/integrations/api.md b/docs/docs/integrations/api.md index 69b6e7632..b6fc4e601 100644 --- a/docs/docs/integrations/api.md +++ b/docs/docs/integrations/api.md @@ -188,6 +188,14 @@ Returns data for a single event. Permanently deletes the event along with any clips/snapshots. +### `POST /api/events//retain` + +Sets retain to true for the event id. + +### `DELETE /api/events//retain` + +Sets retain to false for the event id (event may be deleted quickly after removing). + ### `GET /api/events//thumbnail.jpg` Returns a thumbnail for the event id optimized for notifications. Works while the event is in progress and after completion. Passing `?format=android` will convert the thumbnail to 2:1 aspect ratio. diff --git a/frigate/events.py b/frigate/events.py index e92a37f56..b90204361 100644 --- a/frigate/events.py +++ b/frigate/events.py @@ -147,6 +147,7 @@ class EventCleanup(threading.Thread): Event.camera.not_in(self.camera_keys), Event.start_time < expire_after, Event.label == l.label, + Event.retain_indefinitely == False, ) # delete the media from disk for event in expired_events: @@ -166,6 +167,7 @@ class EventCleanup(threading.Thread): Event.camera.not_in(self.camera_keys), Event.start_time < expire_after, Event.label == l.label, + Event.retain_indefinitely == False, ) update_query.execute() @@ -192,6 +194,7 @@ class EventCleanup(threading.Thread): Event.camera == name, Event.start_time < expire_after, Event.label == l.label, + Event.retain_indefinitely == False, ) # delete the grabbed clips from disk for event in expired_events: @@ -210,6 +213,7 @@ class EventCleanup(threading.Thread): Event.camera == name, Event.start_time < expire_after, Event.label == l.label, + Event.retain_indefinitely == False, ) update_query.execute() diff --git a/frigate/http.py b/frigate/http.py index 7fa174369..c8141bd2e 100644 --- a/frigate/http.py +++ b/frigate/http.py @@ -120,6 +120,40 @@ def event(id): return "Event not found", 404 +@bp.route("/events//retain", methods=("POST",)) +def set_retain(id): + try: + event = Event.get(Event.id == id) + except DoesNotExist: + return make_response( + jsonify({"success": False, "message": "Event" + id + " not found"}), 404 + ) + + event.retain_indefinitely = True + event.save() + + return make_response( + jsonify({"success": True, "message": "Event" + id + " retained"}), 200 + ) + + +@bp.route("/events//retain", methods=("DELETE",)) +def delete_retain(id): + try: + event = Event.get(Event.id == id) + except DoesNotExist: + return make_response( + jsonify({"success": False, "message": "Event" + id + " not found"}), 404 + ) + + event.retain_indefinitely = False + event.save() + + return make_response( + jsonify({"success": True, "message": "Event" + id + " un-retained"}), 200 + ) + + @bp.route("/events/", methods=("DELETE",)) def delete_event(id): try: diff --git a/frigate/models.py b/frigate/models.py index 35a397f5c..ff83dcb54 100644 --- a/frigate/models.py +++ b/frigate/models.py @@ -18,6 +18,7 @@ class Event(Model): region = JSONField() box = JSONField() area = IntegerField() + retain_indefinitely = BooleanField(default=False) class Recordings(Model): diff --git a/frigate/video.py b/frigate/video.py index 26b99176f..e38f206aa 100755 --- a/frigate/video.py +++ b/frigate/video.py @@ -623,7 +623,8 @@ def process_frames( idxs = cv2.dnn.NMSBoxes(boxes, confidences, 0.5, 0.4) for index in idxs: - obj = group[index[0]] + index = index if isinstance(index, np.int32) else index[0] + obj = group[index] if clipped(obj, frame_shape): box = obj[2] # calculate a new region that will hopefully get the entire object diff --git a/migrations/007_add_retain_indefinitely.py b/migrations/007_add_retain_indefinitely.py new file mode 100644 index 000000000..a46b72e29 --- /dev/null +++ b/migrations/007_add_retain_indefinitely.py @@ -0,0 +1,46 @@ +"""Peewee migrations -- 007_add_retain_indefinitely.py. + +Some examples (model - class or model name):: + + > Model = migrator.orm['model_name'] # Return model in current state by name + + > migrator.sql(sql) # Run custom SQL + > migrator.python(func, *args, **kwargs) # Run python code + > migrator.create_model(Model) # Create a model (could be used as decorator) + > migrator.remove_model(model, cascade=True) # Remove a model + > migrator.add_fields(model, **fields) # Add fields to a model + > migrator.change_fields(model, **fields) # Change fields + > migrator.remove_fields(model, *field_names, cascade=True) + > migrator.rename_field(model, old_field_name, new_field_name) + > migrator.rename_table(model, new_table_name) + > migrator.add_index(model, *col_names, unique=False) + > migrator.drop_index(model, *col_names) + > migrator.add_not_null(model, *field_names) + > migrator.drop_not_null(model, *field_names) + > migrator.add_default(model, field_name, default) + +""" + +import datetime as dt +import peewee as pw +from playhouse.sqlite_ext import * +from decimal import ROUND_HALF_EVEN +from frigate.models import Event + +try: + import playhouse.postgres_ext as pw_pext +except ImportError: + pass + +SQL = pw.SQL + + +def migrate(migrator, database, fake=False, **kwargs): + migrator.add_fields( + Event, + retain_indefinitely=pw.BooleanField(default=False), + ) + + +def rollback(migrator, database, fake=False, **kwargs): + migrator.remove_fields(Event, ["retain_indefinitely"]) diff --git a/web/src/api/index.jsx b/web/src/api/index.jsx index f6ef556d8..a2f7d1d75 100644 --- a/web/src/api/index.jsx +++ b/web/src/api/index.jsx @@ -117,6 +117,24 @@ export function useDelete() { return deleteEvent; } +export function useRetain() { + const { state } = useContext(Api); + + async function retainEvent(eventId, shouldRetain) { + if (!eventId) return null; + + if (shouldRetain) { + const response = await fetch(`${state.host}/api/events/${eventId}/retain`, { method: 'POST' }); + return await (response.status < 300 ? response.json() : { success: true }); + } else { + const response = await fetch(`${state.host}/api/events/${eventId}/retain`, { method: 'DELETE' }); + return await (response.status < 300 ? response.json() : { success: true }); + } + } + + return retainEvent; +} + export function useApiHost() { const { state } = useContext(Api); return state.host; diff --git a/web/src/components/Button.jsx b/web/src/components/Button.jsx index 031010dae..9cbb14a1a 100644 --- a/web/src/components/Button.jsx +++ b/web/src/components/Button.jsx @@ -17,6 +17,13 @@ const ButtonColors = { text: 'text-red-500 hover:bg-red-500 hover:bg-opacity-20 focus:bg-red-500 focus:bg-opacity-40 active:bg-red-500 active:bg-opacity-40', }, + yellow: { + contained: 'bg-yellow-500 focus:bg-yellow-400 active:bg-yellow-600 ring-yellow-300', + outlined: + 'text-yellow-500 border-2 border-yellow-500 hover:bg-yellow-500 hover:bg-opacity-20 focus:bg-yellow-500 focus:bg-opacity-40 active:bg-yellow-500 active:bg-opacity-40', + text: + 'text-yellow-500 hover:bg-yellow-500 hover:bg-opacity-20 focus:bg-yellow-500 focus:bg-opacity-40 active:bg-yellow-500 active:bg-opacity-40', + }, green: { contained: 'bg-green-500 focus:bg-green-400 active:bg-green-600 ring-green-300', outlined: diff --git a/web/src/icons/StarRecording.jsx b/web/src/icons/StarRecording.jsx new file mode 100644 index 000000000..eebf8e4aa --- /dev/null +++ b/web/src/icons/StarRecording.jsx @@ -0,0 +1,12 @@ +import { h } from 'preact'; +import { memo } from 'preact/compat'; + +export function StarRecording({ className = '' }) { + return ( + + + + ); +} + +export default memo(StarRecording); diff --git a/web/src/routes/Event.jsx b/web/src/routes/Event.jsx index 48e53e4e9..2b58bc698 100644 --- a/web/src/routes/Event.jsx +++ b/web/src/routes/Event.jsx @@ -7,16 +7,21 @@ import ArrowDown from '../icons/ArrowDropdown'; import ArrowDropup from '../icons/ArrowDropup'; import Clip from '../icons/Clip'; import Close from '../icons/Close'; +import StarRecording from '../icons/StarRecording'; import Delete from '../icons/Delete'; import Snapshot from '../icons/Snapshot'; import Dialog from '../components/Dialog'; import Heading from '../components/Heading'; import VideoPlayer from '../components/VideoPlayer'; import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table'; -import { FetchStatus, useApiHost, useEvent, useDelete } from '../api'; +import { FetchStatus, useApiHost, useEvent, useDelete, useRetain } from '../api'; -const ActionButtonGroup = ({ className, handleClickDelete, close }) => ( +const ActionButtonGroup = ({ className, isRetained, handleClickRetain, handleClickDelete, close }) => (
+ @@ -54,6 +59,8 @@ export default function Event({ eventId, close, scrollRef }) { const [showDetails, setShowDetails] = useState(false); const [shouldScroll, setShouldScroll] = useState(true); const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE); + const [isRetained, setIsRetained] = useState(false); + const setRetainEvent = useRetain(); const setDeleteEvent = useDelete(); useEffect(() => { @@ -71,6 +78,22 @@ export default function Event({ eventId, close, scrollRef }) { }; }, [data, scrollRef, eventId, shouldScroll]); + const handleClickRetain = useCallback(async () => { + let success; + try { + success = await setRetainEvent(eventId, !isRetained); + + if (success) { + setIsRetained(!isRetained); + + // Need to reload page otherwise retain button state won't stick if event is collapsed and re-opened. + window.location.reload(); + } + } catch (e) { + + } + }, [eventId, isRetained, setRetainEvent]); + const handleClickDelete = () => { setShowDialog(true); }; @@ -98,6 +121,7 @@ export default function Event({ eventId, close, scrollRef }) { return ; } + setIsRetained(data.retain_indefinitely); const startime = new Date(data.start_time * 1000); const endtime = data.end_time ? new Date(data.end_time * 1000) : null; return ( @@ -119,7 +143,7 @@ export default function Event({ eventId, close, scrollRef }) { )}
- + {showDialog ? (
- +
); diff --git a/web/src/routes/Events/components/tableHead.jsx b/web/src/routes/Events/components/tableHead.jsx index 69d60d65b..ea5afe8c4 100644 --- a/web/src/routes/Events/components/tableHead.jsx +++ b/web/src/routes/Events/components/tableHead.jsx @@ -9,6 +9,7 @@ const TableHead = () => ( Label Score Zones + Retain Date Start End diff --git a/web/src/routes/Events/components/tableRow.jsx b/web/src/routes/Events/components/tableRow.jsx index f358153b2..ddf4d4582 100644 --- a/web/src/routes/Events/components/tableRow.jsx +++ b/web/src/routes/Events/components/tableRow.jsx @@ -22,6 +22,7 @@ const EventsRow = memo( label, top_score: score, zones, + retain_indefinitely }) => { const [viewEvent, setViewEvent] = useState(null); const { searchString, removeDefaultSearchKeys } = useSearchString(limit); @@ -100,6 +101,7 @@ const EventsRow = memo( ))} + {retain_indefinitely ? 'True' : 'False'} {start.toLocaleDateString()} {start.toLocaleTimeString()} {end === null ? 'In progress' : end.toLocaleTimeString()}