diff --git a/.gitignore b/.gitignore index e04fa95..368f2da 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ __pycache__/ /media/ /videos/ /src/app.db +/src/data diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..c1639fa --- /dev/null +++ b/Dockerfile @@ -0,0 +1,54 @@ +FROM ultravideo/kvazaar + +RUN echo 'tzdata tzdata/Areas select Europe' | debconf-set-selections +RUN echo 'tzdata tzdata/Zones/Europe select Paris' | debconf-set-selections +RUN apt-get update && \ + DEBIAN_FRONTEND=noninteractive TZ=Etc/UTC apt-get -y install tzdata && \ + apt-get -y install build-essential zlib1g-dev \ + libncurses5-dev libgdbm-dev libnss3-dev sqlite3 libsqlite3-dev \ + libssl-dev libreadline-dev libffi-dev curl software-properties-common && \ + rm -rf /var/lib/apt/lists/* + +# RUN curl -fsSL https://deb.nodesource.com/setup_16.x | bash - +# RUN add-apt-repository ppa:deadsnakes/ppa +RUN mkdir /tmp/python-build && \ + cd /tmp/python-build && \ + curl -fsSL https://www.python.org/ftp/python/3.9.7/Python-3.9.7.tgz | tar zx --strip-components=1 +RUN cd /tmp/python-build && \ + ./configure --enable-loadable-sqlite-extensions --enable-optimizations && \ + make -j "$(nproc)" +RUN cd /tmp/python-build && \ + make altinstall +RUN ln -s /usr/local/bin/python3.9 /usr/bin/python3.9 +RUN python3.9 --version + +RUN apt-get update \ + && apt-get install build-essential git curl ffmpeg python3-pip \ + zlib1g-dev libfreetype6-dev libjpeg62-dev libpng-dev libmad0-dev libfaad-dev libogg-dev libvorbis-dev libtheora-dev liba52-0.7.4-dev libavcodec-dev libavformat-dev libavutil-dev libswscale-dev libavdevice-dev libxv-dev x11proto-video-dev libgl1-mesa-dev x11proto-gl-dev libxvidcore-dev libssl-dev libjack-dev libasound2-dev libpulse-dev libsdl2-dev dvb-apps mesa-utils \ + libpq-dev libsm6 libgl1 libxext6 -y + +# RUN ssh-keyscan -t rsa github.com >> ~/.ssh/known_hosts + +WORKDIR /gpac-build +RUN git clone https://github.com/gpac/gpac.git +WORKDIR /gpac-build/gpac +RUN ./configure --static-bin +RUN make +RUN make install + +WORKDIR /app + +RUN python3.9 -m venv /venv +ENV PATH=/venv/bin:$PATH + +COPY requirements.txt /app/ +RUN pip install --upgrade setuptools pip +RUN pip install scikit-build +RUN pip install --upgrade cmake +RUN python --version +RUN pip --version +RUN pip install -r requirements.txt + +COPY . . + +ENTRYPOINT [ "/app/entrypoint.sh" ] diff --git a/encode_test.py b/encode_test.py new file mode 100644 index 0000000..437607c --- /dev/null +++ b/encode_test.py @@ -0,0 +1,72 @@ +import os +from datetime import datetime, timedelta +from pathlib import Path +from subprocess import Popen, PIPE, DEVNULL, check_call +from tempfile import mkstemp + +import numpy as np + +duration = 10.0 + +ffmpeg_cmd = [ + 'ffmpeg', '-ss', '3.19912', + '-i', '/event_medias/output/output_2023-05-31T20:13:20.036643.mp4', + '-i', '/event_medias/output/output_2023-05-31T20:13:30.041467.mp4', + '-filter_complex', '[0:v][1:v]concat=n=2[outv]', + '-map', '[outv]', '-t', f'{duration}', + '-f', 'rawvideo', + '-pix_fmt', 'yuv420p', '-' +] + +ffmpeg_handle = Popen( + ffmpeg_cmd, + stdout=PIPE, + # stderr=DEVNULL +) + +resolution = "480x360" +roi_file = None +out_file = "/event_medias/kavazaar_test" +out_path = "/event_medias/kavazaar_test.mp4" + + +encode_command = [ + "kvazaar", + "--input-fps", "30", + "-i", "-", + "--input-res", resolution, + "--preset", "ultrafast", + "--qp", "27" if roi_file is not None else "27", + "-o", str(out_file), +] + +if roi_file is not None: + encode_command.extend([ + "--roi", roi_file, + ]) + +kvazaar_handle = Popen( + encode_command, + stdin=ffmpeg_handle.stdout, + stderr=PIPE, +) + +frames_encoded = 0 +total_frames = duration * 30 +for line in kvazaar_handle.stderr: + a = line.decode() + if a.startswith("POC"): + frames_encoded += 1 + + print(a, end="") + +kvazaar_handle.wait() +print(f"Kvazaar done {str(out_file)}") + +check_call( + [ + "MP4Box", + "-add", str(out_file), + "-new", str(out_path) + ] +) diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100755 index 0000000..e0bc0b2 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +flask --app src db upgrade + +if [[ "$DISABLE_READ_AND_FEED" == "1" ]]; then + rq worker & flask --app src run -h 0.0.0.0 -p 7655 +else + python read_and_feed.py "$CAMERA_NAME" "$STREAM" & rq worker & flask --app src run -h 0.0.0.0 -p 7655 +fi + + diff --git a/read_and_feed.py b/read_and_feed.py index c56d123..a3d2627 100644 --- a/read_and_feed.py +++ b/read_and_feed.py @@ -1,5 +1,8 @@ +from sys import platform import os import sys +import cv2 +import subprocess from collections import deque from datetime import datetime, timedelta from pathlib import Path @@ -10,8 +13,71 @@ load_dotenv() -def main(camera_name="output"): +def save_10_seconds_webcam(output_path, device): + # Open the webcam + cap = cv2.VideoCapture(0) # Use 0 for the default camera + + # Check if the camera is opened correctly + if not cap.isOpened(): + print("Failed to open the webcam") + return + + # Get the frames per second (FPS) of the camera + resolution = os.environ["RESOLUTION"] or "1920x1080" + width, height = [int(x) for x in resolution.split("x")] + cap.set(cv2.CAP_PROP_FRAME_WIDTH, width) + cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height) + fps = cap.get(cv2.CAP_PROP_FPS) + # print(f'fps: {fps}') + # cap.set(cv2.CAP_PROP_FPS, 30) + + # Calculate the number of frames to capture for 10 seconds + num_frames = int(fps * 10) + + # Initialize variables + frame_counter = 0 + video_frames = [] + + while cap.isOpened() and frame_counter < num_frames: + # Read a frame from the camera + ret, frame = cap.read() + + if not ret: + break + frame = cv2.flip(frame, 1) + + # Add the frame to the list + video_frames.append(frame) + + # Increment the frame counter + frame_counter += 1 + + # Release the camera + cap.release() + + # Check if enough frames were captured + if frame_counter < num_frames: + print("Not enough frames available from the webcam") + return + + # Get the width and height of the frames + height, width, _ = video_frames[0].shape + + # Create a VideoWriter object to save the frames as an MP4 file + fourcc = cv2.VideoWriter_fourcc(*"mp4v") + out = cv2.VideoWriter(output_path, fourcc, fps, (width, height)) + + # Write the frames to the MP4 file + for frame in video_frames: + out.write(frame) + + # Release the VideoWriter + out.release() + + +def main(camera_name="output", stream=""): media_dir = (Path(__file__) / ".." / "media" / camera_name).resolve() + media_dir = Path(os.environ.get("MEDIA_DIR", media_dir)) media_dir.mkdir(exist_ok=True, parents=True) for file in media_dir.glob("*"): file.unlink() @@ -29,46 +95,66 @@ def main(camera_name="output"): # '-pix_fmt', "yuv420p", # '-' # ] - a = Popen( - [ - 'ffmpeg', - '-f', 'lavfi', - '-i', f'color=c=black:size={resolution}', - '-vf', rf"drawtext=fontsize=100:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2:text=%{{n}}", - '-f', "rawvideo", - '-pix_fmt', "yuv420p", - '-' - - ], - stdout=PIPE, - stderr=DEVNULL - ) + print(f"Stream {camera_name}: {stream}") + if stream: + a = None + else: + a = Popen( + [ + "ffmpeg", + "-f", + "lavfi", + "-i", + f"color=c=black:size={resolution}", + "-vf", + rf"drawtext=fontsize=100:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2:text=%{{n}}", + "-f", + "rawvideo", + "-pix_fmt", + "yuv420p", + "-", + ], + stdout=PIPE, + stderr=DEVNULL, + ) i = 0 width, height = [int(x) for x in resolution.split("x")] segments = deque() while True: segment_start_time = datetime.utcnow() - output_file = media_dir / f'output_{segment_start_time.isoformat()}.mp4' - b = Popen( - [ - 'ffmpeg', - '-s:v', resolution, - '-f', 'rawvideo', - '-pix_fmt', 'yuv420p', - '-r', '30', - '-i', 'pipe:.yuv', - "-c:v", "libx264", - str(output_file), - "-y" - ], - stdin=PIPE, - stderr=DEVNULL - ) - for _ in range(30 * 10): - f = a.stdout.read(int(width * height * 1.5)) - b.stdin.write(f) - b.stdin.close() + output_file = media_dir / f"output_{segment_start_time.isoformat()}.mp4" + if a: + b = Popen( + [ + "ffmpeg", + "-s:v", + resolution, + "-f", + "rawvideo", + "-pix_fmt", + "yuv420p", + "-r", + "30", + "-i", + "pipe:.yuv", + "-c:v", + "libx264", + str(output_file), + "-y", + ], + stdin=PIPE, + stderr=DEVNULL, + ) + for _ in range(30 * 10): + f = a.stdout.read(int(width * height * 1.5)) + b.stdin.write(f) + b.stdin.close() + else: + save_10_seconds_webcam( + str(output_file), stream if platform != "darwin" else 0 + ) + i += 1 segments.append(output_file) if len(segments) > 60: @@ -76,10 +162,14 @@ def main(camera_name="output"): segment_to_remove.unlink() segment_end_time = datetime.utcnow() # This is not needed if the input is from actual camera - sleep(10 - (segment_end_time - segment_start_time).total_seconds()) - a.stdout.close() + # if (10 - (segment_end_time - segment_start_time).total_seconds()) > 0: + # sleep(10 - (segment_end_time - segment_start_time).total_seconds()) + + if a: + a.stdout.close() -if __name__ == '__main__': +if __name__ == "__main__": camera = sys.argv[1] if len(sys.argv) > 1 else "output" - main(camera) + stream = sys.argv[2] if len(sys.argv) > 2 else "" + main(camera, stream) diff --git a/requirements.txt b/requirements.txt index 8eb2554..e040e48 100644 --- a/requirements.txt +++ b/requirements.txt @@ -22,3 +22,4 @@ toml==0.10.2 typing_extensions==4.5.0 urllib3==1.26.14 Werkzeug==2.2.2 +opencv-python-headless \ No newline at end of file diff --git a/src/__init__.py b/src/__init__.py index 024661c..ab30970 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -1,6 +1,7 @@ import rq from flask import Flask import pathlib +import os from redis.client import Redis from flask_sqlalchemy import SQLAlchemy @@ -14,8 +15,9 @@ video_storage.mkdir() app = Flask(__name__) -app.redis = Redis.from_url("redis://") -app.config["SQLALCHEMY_DATABASE_URI"] = f'sqlite:///{(pathlib.Path(__file__) / ".." / "app.db").resolve()}' +redis_url = os.environ["REDIS_URL"] or "redis://" +app.redis = Redis.from_url(redis_url) +app.config["SQLALCHEMY_DATABASE_URI"] = f'sqlite:///{(pathlib.Path(__file__) / ".." / ".." / "data" / "app.db").resolve()}' app.config["SQLALCHEMY_TRACK_MODIFICATIONS"] = False app.task_queue = rq.Queue(connection=app.redis) db = SQLAlchemy(app) diff --git a/src/api.py b/src/api.py index 30d187d..3fa6520 100644 --- a/src/api.py +++ b/src/api.py @@ -107,8 +107,10 @@ def delete_roi_region(id_): @app.route("/encode", methods=["POST"]) def start_encoding(): + print('start encoding') data = request.get_json() roi_id = data.get("roi_id") + print(data) f = None if roi_id is not None: f = roi_storage / roi_id @@ -144,7 +146,6 @@ def start_encoding(): "src.encoder.encode", f, start_point, duration, mkstemp()[1], camera, out_path ) - print(a.get_id()) return {"id": a.get_id()} diff --git a/src/encoder.py b/src/encoder.py index 18139be..84b986f 100644 --- a/src/encoder.py +++ b/src/encoder.py @@ -7,50 +7,70 @@ import numpy as np from rq import get_current_job from dotenv import load_dotenv +import time from src import video_storage, roi_storage, models, db, app, api load_dotenv() -def ffmpeg_concat_and_pipe_partial_videos(time, duration, camera): - segments = sorted(os.listdir((Path(__file__) / ".." / ".." / "media" / camera).resolve())) +def ffmpeg_concat_and_pipe_partial_videos(event_time, duration, camera): + time.sleep(10) + segments = sorted( + os.listdir((Path(__file__) / ".." / ".." / "media" / camera).resolve()) + ) i = 0 current_segment = None segment_start = None - for i, v in enumerate(segments): - r = datetime.fromisoformat(v.split("_")[1][:-4]) - if r >= (time - timedelta(seconds=10)): - current_segment = v - segment_start = r - break - - seek = time - segment_start - inputs = ["ffmpeg", f"-ss", f"{seek.seconds}.{seek.microseconds}", "-i", f"media/{camera}/{current_segment}"] + start_time = event_time - timedelta(seconds=duration) + for i, file in enumerate(segments): + if file.endswith(".mp4"): + r = datetime.fromisoformat(file.split("_")[1][:-4]) + if r >= start_time: + break + else: + current_segment = file + segment_start = r + + seek = start_time - segment_start + inputs = [ + "ffmpeg", + f"-ss", + f"{seek.seconds}.{seek.microseconds}", + "-i", + f"media/{camera}/{current_segment}", + ] concat = [f"[0:v]"] total_time = 10 - seek.seconds - seek.microseconds / 1e7 - while total_time < duration: - i += 1 + while total_time < duration and i < len(segments): concat.append(f"[{len(concat)}:v]") inputs.extend(["-i", f"media/{camera}/{segments[i]}"]) total_time += 10 + i += 1 concat.append(f"concat=n={len(concat)}[outv]") inputs.extend( - ["-filter_complex", "".join(concat), - "-map", "[outv]", - "-t", str(duration), - "-f", "rawvideo", - "-pix_fmt", "yuv420p", - "-"] + [ + "-filter_complex", + "".join(concat), + "-map", + "[outv]", + "-t", + str(duration), + "-f", + "rawvideo", + "-pix_fmt", + "yuv420p", + "-", + ] ) return inputs def preprocess_roi(f): data = np.load(roi_storage / f, allow_pickle=True) - data *= -10 + # data *= -10 handle, name = mkstemp() file = os.fdopen(handle, "w") file.write(f"{data.shape[1]} {data.shape[0]}\n") @@ -68,26 +88,31 @@ def encode(roi_file, start_time, duration, out_file, camera, out_path): if roi_file is not None: roi_file = preprocess_roi(roi_file) - ffmpeg_handle = Popen( - ffmpeg_cmd, - stdout=PIPE, - stderr=DEVNULL - ) + print(f"ffmpeg cmd: {ffmpeg_cmd}") + ffmpeg_handle = Popen(ffmpeg_cmd, stdout=PIPE, stderr=DEVNULL) resolution = os.environ["RESOLUTION"] or "1920x1080" encode_command = [ "kvazaar", - "--input-fps", "30", - "-i", "-", - "--input-res", resolution, - "--preset", "medium", - "--qp", "37" if roi_file is not None else "27", - "-o", out_file, + "--input-fps", + "30", + "-i", + "-", + "--input-res", + resolution, + "--preset", + "ultrafast", + "--qp", + "37" if roi_file is not None else "27", + "-o", + str(out_file), ] if roi_file is not None: - encode_command.extend([ - "--roi", roi_file, - ]) - + encode_command.extend( + [ + "--roi", + roi_file, + ] + ) kvazaar_handle = Popen( encode_command, stdin=ffmpeg_handle.stdout, @@ -100,6 +125,7 @@ def encode(roi_file, start_time, duration, out_file, camera, out_path): if a.startswith("POC"): frames_encoded += 1 job.meta["progress"] = 100 * frames_encoded / total_frames + print(a, end="") job.save_meta() job.meta["progress"] = 100 @@ -107,15 +133,11 @@ def encode(roi_file, start_time, duration, out_file, camera, out_path): kvazaar_handle.wait() + print(f"Kvazaar done {str(out_file)}") + if out_path is None: out_path = (video_storage / job_get_id).with_suffix(".mp4") - check_call( - [ - "MP4Box", - "-add", out_file, - "-new", out_path - ] - ) + check_call(["MP4Box", "-add", str(out_file), "-new", str(out_path)]) with app.app_context(): r = models.Encoding(id=job_get_id, out_path=str(out_path))