Init
commit
b0f42c9e8f
@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<module type="PYTHON_MODULE" version="4">
|
||||||
|
<component name="NewModuleRootManager">
|
||||||
|
<content url="file://$MODULE_DIR$" />
|
||||||
|
<orderEntry type="jdk" jdkName="Python 3.11" jdkType="Python SDK" />
|
||||||
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
</component>
|
||||||
|
<component name="PyDocumentationSettings">
|
||||||
|
<option name="format" value="PLAIN" />
|
||||||
|
<option name="myDocStringFormat" value="Plain" />
|
||||||
|
</component>
|
||||||
|
<component name="TestRunnerService">
|
||||||
|
<option name="PROJECT_TEST_RUNNER" value="py.test" />
|
||||||
|
</component>
|
||||||
|
</module>
|
@ -0,0 +1,32 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<profile version="1.0">
|
||||||
|
<option name="myName" value="Project Default" />
|
||||||
|
<inspection_tool class="BufCLINotInstalled" enabled="true" level="INFORMATION" enabled_by_default="true" />
|
||||||
|
<inspection_tool class="PyCompatibilityInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="ourVersions">
|
||||||
|
<value>
|
||||||
|
<list size="1">
|
||||||
|
<item index="0" class="java.lang.String" itemvalue="3.13" />
|
||||||
|
</list>
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PyPackageRequirementsInspection" enabled="true" level="WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredPackages">
|
||||||
|
<value>
|
||||||
|
<list size="2">
|
||||||
|
<item index="0" class="java.lang.String" itemvalue="grpc" />
|
||||||
|
<item index="1" class="java.lang.String" itemvalue="ts" />
|
||||||
|
</list>
|
||||||
|
</value>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
||||||
|
<option name="ignoredErrors">
|
||||||
|
<list>
|
||||||
|
<option value="N812" />
|
||||||
|
</list>
|
||||||
|
</option>
|
||||||
|
</inspection_tool>
|
||||||
|
</profile>
|
||||||
|
</component>
|
@ -0,0 +1,6 @@
|
|||||||
|
<component name="InspectionProjectProfileManager">
|
||||||
|
<settings>
|
||||||
|
<option name="USE_PROJECT_PROFILE" value="false" />
|
||||||
|
<version value="1.0" />
|
||||||
|
</settings>
|
||||||
|
</component>
|
@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Black">
|
||||||
|
<option name="sdkName" value="Python 3.11" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11" project-jdk-type="Python SDK" />
|
||||||
|
<component name="PyCharmProfessionalAdvertiser">
|
||||||
|
<option name="shown" value="true" />
|
||||||
|
</component>
|
||||||
|
</project>
|
@ -0,0 +1,8 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="ProjectModuleManager">
|
||||||
|
<modules>
|
||||||
|
<module fileurl="file://$PROJECT_DIR$/.idea/VideoCapture.iml" filepath="$PROJECT_DIR$/.idea/VideoCapture.iml" />
|
||||||
|
</modules>
|
||||||
|
</component>
|
||||||
|
</project>
|
@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="$PROJECT_DIR$/MotionDetector/motion_detection" vcs="Git" />
|
||||||
|
<mapping directory="$PROJECT_DIR$/VideoRegister" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
@ -0,0 +1,109 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AutoImportSettings">
|
||||||
|
<option name="autoReloadType" value="SELECTIVE" />
|
||||||
|
</component>
|
||||||
|
<component name="ChangeListManager">
|
||||||
|
<list default="true" id="a57321da-a595-4a34-b1ef-01654ff4f52a" name="Changes" comment="">
|
||||||
|
<change beforePath="$PROJECT_DIR$/MotionDetector/motion_detection/src/motion_detection/no_opencv/MotionDetector.py" beforeDir="false" afterPath="$PROJECT_DIR$/MotionDetector/motion_detection/src/motion_detection/no_opencv/MotionDetector.py" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/MotionDetector/motion_detection/src/motion_detection/opencv/MotionDetector.py" beforeDir="false" afterPath="$PROJECT_DIR$/MotionDetector/motion_detection/src/motion_detection/opencv/MotionDetector.py" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/VideoRegister/backend/Dockerfile" beforeDir="false" afterPath="$PROJECT_DIR$/VideoRegister/backend/Dockerfile" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/VideoRegister/backend/app/motion_detector.py" beforeDir="false" afterPath="$PROJECT_DIR$/VideoRegister/backend/app/motion_detector.py" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/VideoRegister/backend/tests/test_app.py" beforeDir="false" afterPath="$PROJECT_DIR$/VideoRegister/backend/tests/test_app.py" afterDir="false" />
|
||||||
|
<change beforePath="$PROJECT_DIR$/VideoRegister/k8s/backend-deployment.yaml" beforeDir="false" afterPath="$PROJECT_DIR$/VideoRegister/k8s/backend-deployment.yaml" afterDir="false" />
|
||||||
|
</list>
|
||||||
|
<option name="SHOW_DIALOG" value="false" />
|
||||||
|
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||||
|
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||||
|
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||||
|
</component>
|
||||||
|
<component name="Git.Settings">
|
||||||
|
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$/VideoRegister" />
|
||||||
|
</component>
|
||||||
|
<component name="MarkdownSettingsMigration">
|
||||||
|
<option name="stateVersion" value="1" />
|
||||||
|
</component>
|
||||||
|
<component name="ProjectColorInfo"><![CDATA[{
|
||||||
|
"associatedIndex": 4
|
||||||
|
}]]></component>
|
||||||
|
<component name="ProjectId" id="2mm9K2B9rpFzCJvNfP1FlqTuXgw" />
|
||||||
|
<component name="ProjectViewState">
|
||||||
|
<option name="hideEmptyMiddlePackages" value="true" />
|
||||||
|
<option name="showLibraryContents" value="true" />
|
||||||
|
</component>
|
||||||
|
<component name="PropertiesComponent"><![CDATA[{
|
||||||
|
"keyToString": {
|
||||||
|
"Python tests.pytest for test_app.test_motion_events.executor": "Run",
|
||||||
|
"Python tests.pytest for test_app.test_start_stream.executor": "Run",
|
||||||
|
"RunOnceActivity.OpenProjectViewOnStart": "true",
|
||||||
|
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||||
|
"git-widget-placeholder": "video__register__no__ai",
|
||||||
|
"last_opened_file_path": "C:/Users/snytk/VideoCapture/Simple_video_register/backend"
|
||||||
|
}
|
||||||
|
}]]></component>
|
||||||
|
<component name="RecentsManager">
|
||||||
|
<key name="CopyFile.RECENT_KEYS">
|
||||||
|
<recent name="C:\Users\snytk\VideoCapture\Simple_video_register\backend" />
|
||||||
|
</key>
|
||||||
|
</component>
|
||||||
|
<component name="RunManager" selected="Python tests.pytest for test_app.test_start_stream">
|
||||||
|
<configuration name="pytest for test_app.test_motion_events" type="tests" factoryName="py.test" temporary="true" nameIsGenerated="true">
|
||||||
|
<module name="VideoCapture" />
|
||||||
|
<option name="ENV_FILES" value="" />
|
||||||
|
<option name="INTERPRETER_OPTIONS" value="" />
|
||||||
|
<option name="PARENT_ENVS" value="true" />
|
||||||
|
<option name="SDK_HOME" value="" />
|
||||||
|
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/VideoRegister/backend/tests" />
|
||||||
|
<option name="IS_MODULE_SDK" value="true" />
|
||||||
|
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||||
|
<option name="ADD_SOURCE_ROOTS" value="true" />
|
||||||
|
<option name="_new_keywords" value="""" />
|
||||||
|
<option name="_new_parameters" value="""" />
|
||||||
|
<option name="_new_additionalArguments" value="""" />
|
||||||
|
<option name="_new_target" value=""test_app.test_motion_events"" />
|
||||||
|
<option name="_new_targetType" value=""PYTHON"" />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
<configuration name="pytest for test_app.test_start_stream" type="tests" factoryName="py.test" temporary="true" nameIsGenerated="true">
|
||||||
|
<module name="VideoCapture" />
|
||||||
|
<option name="ENV_FILES" value="" />
|
||||||
|
<option name="INTERPRETER_OPTIONS" value="" />
|
||||||
|
<option name="PARENT_ENVS" value="true" />
|
||||||
|
<option name="SDK_HOME" value="" />
|
||||||
|
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/Simple_video_register/backend/tests" />
|
||||||
|
<option name="IS_MODULE_SDK" value="true" />
|
||||||
|
<option name="ADD_CONTENT_ROOTS" value="true" />
|
||||||
|
<option name="ADD_SOURCE_ROOTS" value="true" />
|
||||||
|
<option name="_new_keywords" value="""" />
|
||||||
|
<option name="_new_parameters" value="""" />
|
||||||
|
<option name="_new_additionalArguments" value="""" />
|
||||||
|
<option name="_new_target" value=""test_app.test_start_stream"" />
|
||||||
|
<option name="_new_targetType" value=""PYTHON"" />
|
||||||
|
<method v="2" />
|
||||||
|
</configuration>
|
||||||
|
<recent_temporary>
|
||||||
|
<list>
|
||||||
|
<item itemvalue="Python tests.pytest for test_app.test_start_stream" />
|
||||||
|
<item itemvalue="Python tests.pytest for test_app.test_motion_events" />
|
||||||
|
</list>
|
||||||
|
</recent_temporary>
|
||||||
|
</component>
|
||||||
|
<component name="SharedIndexes">
|
||||||
|
<attachedChunks>
|
||||||
|
<set>
|
||||||
|
<option value="bundled-python-sdk-50da183f06c8-d3b881c8e49f-com.jetbrains.pycharm.community.sharedIndexes.bundled-PC-233.13135.95" />
|
||||||
|
</set>
|
||||||
|
</attachedChunks>
|
||||||
|
</component>
|
||||||
|
<component name="SpellCheckerSettings" RuntimeDictionaries="0" Folders="0" CustomDictionaries="0" DefaultDictionary="application-level" UseSingleDictionary="true" transferred="true" />
|
||||||
|
<component name="TaskManager">
|
||||||
|
<task active="true" id="Default" summary="Default task">
|
||||||
|
<changelist id="a57321da-a595-4a34-b1ef-01654ff4f52a" name="Changes" comment="" />
|
||||||
|
<created>1727670817034</created>
|
||||||
|
<option name="number" value="Default" />
|
||||||
|
<option name="presentableId" value="Default" />
|
||||||
|
<updated>1727670817034</updated>
|
||||||
|
</task>
|
||||||
|
<servers />
|
||||||
|
</component>
|
||||||
|
</project>
|
@ -0,0 +1 @@
|
|||||||
|
Subproject commit 2ff71b19839d5b0dd14c43d0fd55bd1a37abee38
|
@ -0,0 +1,15 @@
|
|||||||
|
# Multi-Camera Surveillance Project
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
This project enables you to connect up to 24 cameras, choose the grid size for video feeds dynamically, and manage the camera settings through a Vue.js frontend with a Flask backend.
|
||||||
|
|
||||||
|
### How to Run
|
||||||
|
1. Install Docker and Docker Compose.
|
||||||
|
2. Run `docker-compose up --build` in the project directory.
|
||||||
|
3. Access the frontend at `http://localhost:8080`.
|
||||||
|
4. Access the backend API documentation at `http://localhost:5000/swagger`.
|
||||||
|
|
||||||
|
### Features
|
||||||
|
- Add and remove cameras.
|
||||||
|
- Dynamic grid size based on the number of cameras.
|
||||||
|
- Simple motion detection placeholder (to be implemented).
|
@ -0,0 +1,10 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt ./
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["gunicorn", "-b", "0.0.0.0:5000", "app.main:app"]
|
@ -0,0 +1 @@
|
|||||||
|
# Empty file to initialize the module
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,20 @@
|
|||||||
|
from flask import Flask
|
||||||
|
from flask_restful import Api
|
||||||
|
from flask_cors import CORS
|
||||||
|
from app.routes.camera import Camera
|
||||||
|
from app.routes.detection import Detection
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
CORS(app)
|
||||||
|
api = Api(app)
|
||||||
|
|
||||||
|
# Add routes
|
||||||
|
api.add_resource(Camera, '/api/camera')
|
||||||
|
api.add_resource(Detection, '/api/detection')
|
||||||
|
|
||||||
|
@app.route('/swagger')
|
||||||
|
def swagger_ui():
|
||||||
|
return app.send_static_file('swagger.yaml')
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='0.0.0.0', port=5000)
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,25 @@
|
|||||||
|
from flask import request, jsonify
|
||||||
|
from flask_restful import Resource
|
||||||
|
|
||||||
|
class Camera(Resource):
|
||||||
|
cameras = []
|
||||||
|
|
||||||
|
def post(self):
|
||||||
|
data = request.json
|
||||||
|
rtsp_url = data.get('rtsp_url')
|
||||||
|
if len(self.cameras) < 24:
|
||||||
|
self.cameras.append({'id': len(self.cameras), 'rtsp_url': rtsp_url})
|
||||||
|
return jsonify({"message": "Camera added", "camera_id": len(self.cameras) - 1}), 200
|
||||||
|
else:
|
||||||
|
return jsonify({"message": "Maximum number of cameras reached"}), 400
|
||||||
|
|
||||||
|
def delete(self):
|
||||||
|
data = request.json
|
||||||
|
camera_id = data.get('camera_id')
|
||||||
|
if 0 <= camera_id < len(self.cameras):
|
||||||
|
self.cameras.pop(camera_id)
|
||||||
|
return jsonify({"message": "Camera removed"}), 200
|
||||||
|
return jsonify({"message": "Camera ID not found"}), 404
|
||||||
|
|
||||||
|
def get(self):
|
||||||
|
return jsonify({"cameras": self.cameras})
|
@ -0,0 +1,6 @@
|
|||||||
|
from flask_restful import Resource
|
||||||
|
|
||||||
|
class Detection(Resource):
|
||||||
|
def get(self):
|
||||||
|
# Placeholder for motion and abandoned object detection
|
||||||
|
return {"message": "Detection logic not implemented"}, 200
|
@ -0,0 +1,22 @@
|
|||||||
|
import cv2
|
||||||
|
|
||||||
|
class MotionDetector:
|
||||||
|
def __init__(self):
|
||||||
|
self.previous_frame = None
|
||||||
|
|
||||||
|
def detect_motion(self, frame):
|
||||||
|
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||||
|
gray = cv2.GaussianBlur(gray, (21, 21), 0)
|
||||||
|
|
||||||
|
if self.previous_frame is None:
|
||||||
|
self.previous_frame = gray
|
||||||
|
return False
|
||||||
|
|
||||||
|
frame_diff = cv2.absdiff(self.previous_frame, gray)
|
||||||
|
thresh = cv2.threshold(frame_diff, 25, 255, cv2.THRESH_BINARY)[1]
|
||||||
|
thresh = cv2.dilate(thresh, None, iterations=2)
|
||||||
|
|
||||||
|
contours, _ = cv2.findContours(thresh.copy(), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
self.previous_frame = gray
|
||||||
|
|
||||||
|
return len(contours) > 0
|
@ -0,0 +1,12 @@
|
|||||||
|
import cv2
|
||||||
|
|
||||||
|
class VideoStream:
|
||||||
|
def __init__(self, rtsp_url):
|
||||||
|
self.rtsp_url = rtsp_url
|
||||||
|
self.stream = cv2.VideoCapture(rtsp_url)
|
||||||
|
|
||||||
|
def get_frame(self):
|
||||||
|
ret, frame = self.stream.read()
|
||||||
|
if not ret:
|
||||||
|
return None
|
||||||
|
return frame
|
@ -0,0 +1,8 @@
|
|||||||
|
Flask
|
||||||
|
flask-restful
|
||||||
|
opencv-python-headless
|
||||||
|
numpy
|
||||||
|
flask-cors
|
||||||
|
gunicorn
|
||||||
|
sqlalchemy
|
||||||
|
pymysql
|
Binary file not shown.
@ -0,0 +1,60 @@
|
|||||||
|
import pytest
|
||||||
|
from app import app, db, VideoLog, MotionEvent, LeftItemEvent
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client():
|
||||||
|
app.config['TESTING'] = True
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
|
||||||
|
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||||
|
|
||||||
|
with app.test_client() as client:
|
||||||
|
with app.app_context():
|
||||||
|
db.create_all()
|
||||||
|
yield client
|
||||||
|
|
||||||
|
with app.app_context():
|
||||||
|
db.drop_all()
|
||||||
|
|
||||||
|
|
||||||
|
def test_start_stream(client):
|
||||||
|
response = client.post('/video/start_stream/camera1', json={'rtsp_url': 'rtsp://example.com/stream'})
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b'stream started' in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_stop_stream(client):
|
||||||
|
client.post('/video/start_stream/camera1', json={'rtsp_url': 'rtsp://example.com/stream'})
|
||||||
|
response = client.post('/video/stop_stream/camera1')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b'stream stopped' in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_video_logs(client):
|
||||||
|
log = VideoLog(camera_id='camera1', event_type='motion', video_path='path/to/video.mp4')
|
||||||
|
db.session.add(log)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
response = client.get('/video/video_logs')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b'camera1' in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_motion_events(client):
|
||||||
|
event = MotionEvent(camera_id='camera1', description='Motion detected', video_path='path/to/video.mp4')
|
||||||
|
db.session.add(event)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
response = client.get('/video/motion_events')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b'camera1' in response.data
|
||||||
|
|
||||||
|
|
||||||
|
def test_left_item_events(client):
|
||||||
|
event = LeftItemEvent(camera_id='camera1', item_description='Item left', video_path='path/to/video.mp4')
|
||||||
|
db.session.add(event)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
response = client.get('/video/left_item_events')
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert b'camera1' in response.data
|
@ -0,0 +1,11 @@
|
|||||||
|
version: '3.7'
|
||||||
|
|
||||||
|
services:
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
container_name: frontend
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile-prod
|
||||||
|
ports:
|
||||||
|
- '80:80'
|
@ -0,0 +1,17 @@
|
|||||||
|
version: '3.8'
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
volumes:
|
||||||
|
- ./backend:/app
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
volumes:
|
||||||
|
- './frontend:/src'
|
||||||
|
- '/src/node_modules'
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
@ -0,0 +1,3 @@
|
|||||||
|
node_modules
|
||||||
|
.git
|
||||||
|
.gitignore
|
@ -0,0 +1,16 @@
|
|||||||
|
# base image
|
||||||
|
FROM node:12.2.0-alpine
|
||||||
|
|
||||||
|
# set working directory
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# add `/app/node_modules/.bin` to $PATH
|
||||||
|
ENV key=/src/node_modules/.bin:$PATH
|
||||||
|
|
||||||
|
# install and cache app dependencies
|
||||||
|
COPY package.json /src/package.json
|
||||||
|
RUN npm install
|
||||||
|
RUN npm install @vue/cli@3.7.0 -g
|
||||||
|
|
||||||
|
# start app
|
||||||
|
CMD ["npm", "run", "serve"]
|
@ -0,0 +1,15 @@
|
|||||||
|
# build environment
|
||||||
|
FROM node:12.2.0-alpine
|
||||||
|
WORKDIR /src
|
||||||
|
ENV key=/src/node_modules/.bin:$PATH
|
||||||
|
COPY package.json /app/package.json
|
||||||
|
RUN npm install --silent
|
||||||
|
RUN npm install @vue/cli@3.7.0 -g
|
||||||
|
COPY . /app
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# production environment
|
||||||
|
FROM nginx:1.16.0-alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"serve": "vue-cli-service serve",
|
||||||
|
"build": "vue-cli-service build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"axios": "^0.21.1",
|
||||||
|
"core-js": "^3.6.5",
|
||||||
|
"vue": "^2.6.11"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@vue/cli-service": "^4.5.0",
|
||||||
|
"vue-template-compiler": "^2.5.17"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,83 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app">
|
||||||
|
<div class="left-panel">
|
||||||
|
<Settings :cameras="cameras" @add-camera="addCamera" @remove-camera="removeCamera" />
|
||||||
|
</div>
|
||||||
|
<div class="right-panel">
|
||||||
|
<CameraGrid :cameras="cameras" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import axios from 'axios';
|
||||||
|
import Settings from './components/Settings.vue';
|
||||||
|
import CameraGrid from './components/CameraGrid.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Settings,
|
||||||
|
CameraGrid,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
cameras: [],
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
addCamera(camera) {
|
||||||
|
this.cameras.push(camera);
|
||||||
|
},
|
||||||
|
removeCamera(index) {
|
||||||
|
this.cameras.splice(index, 1);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
axios.get('/api/camera')
|
||||||
|
.then(response => {
|
||||||
|
this.cameras = response.data.cameras;
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#app {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 20px;
|
||||||
|
height: 100vh;
|
||||||
|
background-color: #f4f4f9;
|
||||||
|
font-family: Avenir, Helvetica, Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel, .right-panel {
|
||||||
|
width: 48%;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel {
|
||||||
|
background-color: #e3f2fd; /* Синий оттенок */
|
||||||
|
}
|
||||||
|
|
||||||
|
.right-panel {
|
||||||
|
background-color: #ffebee; /* Красный оттенок */
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
#app {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.left-panel, .right-panel {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
@ -0,0 +1,40 @@
|
|||||||
|
<template>
|
||||||
|
<div class="camera-grid" :style="gridStyle">
|
||||||
|
<CameraView v-for="(camera, index) in cameras" :key="index" :rtsp-url="camera.rtsp_url" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CameraView from './CameraView.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
CameraView,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
cameras: Array,
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
gridStyle() {
|
||||||
|
const cameraCount = this.cameras.length;
|
||||||
|
const columns = Math.ceil(Math.sqrt(cameraCount));
|
||||||
|
return {
|
||||||
|
display: 'grid',
|
||||||
|
gridTemplateColumns: `repeat(${columns}, 1fr)`,
|
||||||
|
gap: '10px',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.camera-grid {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #fafafa;
|
||||||
|
border-radius: 15px;
|
||||||
|
padding: 10px;
|
||||||
|
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
</style>
|
@ -0,0 +1,31 @@
|
|||||||
|
<template>
|
||||||
|
<div class="camera-view">
|
||||||
|
<video ref="video" autoplay muted controls></video>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
rtspUrl: String,
|
||||||
|
},
|
||||||
|
mounted() {
|
||||||
|
this.startStream();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
startStream() {
|
||||||
|
const videoElement = this.$refs.video;
|
||||||
|
videoElement.src = this.rtspUrl; // For simplicity, use direct URL, handled by the backend.
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.camera-view {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background-color: #000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,71 @@
|
|||||||
|
<template>
|
||||||
|
<div class="settings">
|
||||||
|
<h2>Camera Settings</h2>
|
||||||
|
<div class="input-group">
|
||||||
|
<label for="camera-url">Camera RTSP URL:</label>
|
||||||
|
<input type="text" v-model="cameraUrl" placeholder="Enter RTSP URL">
|
||||||
|
<button @click="addCamera">Add Camera</button>
|
||||||
|
</div>
|
||||||
|
<ul>
|
||||||
|
<li v-for="(camera, index) in cameras" :key="index">
|
||||||
|
{{ camera.rtsp_url }}
|
||||||
|
<button @click="removeCamera(index)">Remove</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
props: {
|
||||||
|
cameras: Array,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
cameraUrl: '',
|
||||||
|
};
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
addCamera() {
|
||||||
|
if (this.cameraUrl) {
|
||||||
|
axios.post('/api/camera', { rtsp_url: this.cameraUrl })
|
||||||
|
.then(response => {
|
||||||
|
this.$emit('add-camera', { id: response.data.camera_id, rtsp_url: this.cameraUrl });
|
||||||
|
this.cameraUrl = '';
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
removeCamera(index) {
|
||||||
|
axios.delete('/api/camera', { data: { camera_id: index } })
|
||||||
|
.then(response => {
|
||||||
|
this.$emit('remove-camera', index);
|
||||||
|
})
|
||||||
|
.catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.settings {
|
||||||
|
padding: 10px;
|
||||||
|
background-color: #ffffff;
|
||||||
|
border-radius: 15px;
|
||||||
|
box-shadow: 0 1px 6px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.input-group {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
</style>
|
@ -0,0 +1,8 @@
|
|||||||
|
import Vue from 'vue';
|
||||||
|
import App from './App.vue';
|
||||||
|
|
||||||
|
Vue.config.productionTip = false;
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
render: h => h(App),
|
||||||
|
}).$mount('#app');
|
@ -0,0 +1,17 @@
|
|||||||
|
server {
|
||||||
|
|
||||||
|
listen 80;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
error_page 500 502 503 504 /50x.html;
|
||||||
|
|
||||||
|
location = /50x.html {
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,44 @@
|
|||||||
|
name: CI Pipeline
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v2
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v2
|
||||||
|
with:
|
||||||
|
python-version: '3.x'
|
||||||
|
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Run backend tests
|
||||||
|
run: |
|
||||||
|
cd backend
|
||||||
|
pytest
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v2
|
||||||
|
with:
|
||||||
|
node-version: '14'
|
||||||
|
|
||||||
|
- name: Install frontend dependencies
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
|
||||||
|
- name: Run frontend tests
|
||||||
|
run: |
|
||||||
|
cd frontend
|
||||||
|
npm run test
|
@ -0,0 +1,6 @@
|
|||||||
|
FROM python:3.11-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY . .
|
||||||
|
RUN pip install -r requirements.txt
|
||||||
|
EXPOSE 5000
|
||||||
|
CMD ["python", "app.py"]
|
@ -0,0 +1,45 @@
|
|||||||
|
from flask import Flask, Response, request, jsonify
|
||||||
|
from camera import Camera
|
||||||
|
from database import log_event, get_all_events
|
||||||
|
import threading
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# Хранилище камер и событий детекции
|
||||||
|
cameras = {}
|
||||||
|
motion_detected = []
|
||||||
|
|
||||||
|
@app.route('/add_camera', methods=['POST'])
|
||||||
|
def add_camera():
|
||||||
|
camera_id = request.json['id']
|
||||||
|
if camera_id not in cameras:
|
||||||
|
cameras[camera_id] = Camera(camera_id)
|
||||||
|
threading.Thread(target=cameras[camera_id].start_stream, args=(motion_detected,)).start()
|
||||||
|
return jsonify({"message": "Camera added"}), 200
|
||||||
|
return jsonify({"message": "Camera already exists"}), 400
|
||||||
|
|
||||||
|
@app.route('/remove_camera', methods=['POST'])
|
||||||
|
def remove_camera():
|
||||||
|
camera_id = request.json['id']
|
||||||
|
if camera_id in cameras:
|
||||||
|
cameras[camera_id].stop_stream()
|
||||||
|
del cameras[camera_id]
|
||||||
|
return jsonify({"message": "Camera removed"}), 200
|
||||||
|
return jsonify({"message": "Camera not found"}), 404
|
||||||
|
|
||||||
|
@app.route('/stream/<camera_id>')
|
||||||
|
def stream(camera_id):
|
||||||
|
if camera_id in cameras:
|
||||||
|
return Response(cameras[camera_id].get_frame(), mimetype='multipart/x-mixed-replace; boundary=frame')
|
||||||
|
return "Camera not found", 404
|
||||||
|
|
||||||
|
@app.route('/events', methods=['GET'])
|
||||||
|
def get_events():
|
||||||
|
return jsonify(get_all_events()), 200
|
||||||
|
|
||||||
|
@app.route('/motion', methods=['GET'])
|
||||||
|
def motion():
|
||||||
|
return jsonify(motion_detected), 200
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
app.run(host='0.0.0.0', port=5000)
|
@ -0,0 +1,36 @@
|
|||||||
|
import cv2
|
||||||
|
from motion_detection import detect_motion
|
||||||
|
from database import log_event
|
||||||
|
|
||||||
|
class Camera:
|
||||||
|
def __init__(self, camera_id, source=0):
|
||||||
|
self.camera_id = camera_id
|
||||||
|
self.capture = cv2.VideoCapture(source)
|
||||||
|
self.is_running = True
|
||||||
|
|
||||||
|
def start_stream(self):
|
||||||
|
background_frame = None
|
||||||
|
while self.is_running:
|
||||||
|
ret, frame = self.capture.read()
|
||||||
|
if not ret:
|
||||||
|
break
|
||||||
|
|
||||||
|
if background_frame is None:
|
||||||
|
background_frame = frame
|
||||||
|
|
||||||
|
moving_objects = detect_motion(frame, background_frame)
|
||||||
|
if moving_objects:
|
||||||
|
log_event(self.camera_id, moving_objects)
|
||||||
|
|
||||||
|
def get_frame(self):
|
||||||
|
while self.is_running:
|
||||||
|
ret, frame = self.capture.read()
|
||||||
|
if ret:
|
||||||
|
_, buffer = cv2.imencode('.jpg', frame)
|
||||||
|
frame = buffer.tobytes()
|
||||||
|
yield (b'--frame\r\n'
|
||||||
|
b'Content-Type: image/jpeg\r\n\r\n' + frame + b'\r\n\r\n')
|
||||||
|
|
||||||
|
def stop_stream(self):
|
||||||
|
self.is_running = False
|
||||||
|
self.capture.release()
|
@ -0,0 +1,19 @@
|
|||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
def log_event(camera_id, objects):
|
||||||
|
conn = sqlite3.connect('events.db')
|
||||||
|
c = conn.cursor()
|
||||||
|
for obj in objects:
|
||||||
|
c.execute("INSERT INTO events (camera_id, timestamp, object) VALUES (?, ?, ?)",
|
||||||
|
(camera_id, datetime.now(), str(obj)))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def get_all_events():
|
||||||
|
conn = sqlite3.connect('events.db')
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT * FROM events")
|
||||||
|
events = c.fetchall()
|
||||||
|
conn.close()
|
||||||
|
return events
|
@ -0,0 +1,15 @@
|
|||||||
|
import cv2
|
||||||
|
|
||||||
|
def detect_motion(frame, background_frame):
|
||||||
|
diff = cv2.absdiff(background_frame, frame)
|
||||||
|
gray = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY)
|
||||||
|
blur = cv2.GaussianBlur(gray, (5, 5), 0)
|
||||||
|
_, thresh = cv2.threshold(blur, 20, 255, cv2.THRESH_BINARY)
|
||||||
|
contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||||
|
|
||||||
|
moving_objects = []
|
||||||
|
for contour in contours:
|
||||||
|
if cv2.contourArea(contour) > 500:
|
||||||
|
x, y, w, h = cv2.boundingRect(contour)
|
||||||
|
moving_objects.append((x, y, w, h))
|
||||||
|
return moving_objects
|
@ -0,0 +1,2 @@
|
|||||||
|
Flask
|
||||||
|
opencv-python
|
@ -0,0 +1,30 @@
|
|||||||
|
version: '3'
|
||||||
|
services:
|
||||||
|
backend:
|
||||||
|
build: ./backend
|
||||||
|
ports:
|
||||||
|
- "5000:5000"
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
volumes:
|
||||||
|
- './frontend:/src'
|
||||||
|
- '/src/node_modules'
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
db:
|
||||||
|
image: postgres
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: user
|
||||||
|
POSTGRES_PASSWORD: password
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
prometheus:
|
||||||
|
image: prom/prometheus
|
||||||
|
ports:
|
||||||
|
- "9090:9090"
|
||||||
|
grafana:
|
||||||
|
image: grafana/grafana
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
@ -0,0 +1,28 @@
|
|||||||
|
# FROM node:14-alpine
|
||||||
|
# WORKDIR /app
|
||||||
|
# COPY . .
|
||||||
|
# RUN npm install
|
||||||
|
# RUN npm run build
|
||||||
|
# EXPOSE 8080
|
||||||
|
# CMD ["npm", "run", "serve"]
|
||||||
|
|
||||||
|
|
||||||
|
# base image
|
||||||
|
FROM node:12.2.0-alpine
|
||||||
|
|
||||||
|
# set working directory
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# add `/app/node_modules/.bin` to $PATH
|
||||||
|
ENV key=/src/node_modules/.bin:$PATH
|
||||||
|
|
||||||
|
# install and cache app dependencies
|
||||||
|
COPY package.json /src/package.json
|
||||||
|
RUN npm install
|
||||||
|
RUN npm install @vue/cli@3.7.0 -g
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
EXPOSE 80
|
||||||
|
|
||||||
|
# start app
|
||||||
|
CMD ["npm", "run", "serve"]
|
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"name": "camera-stream",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"scripts": {
|
||||||
|
"serve": "vue-cli-service serve",
|
||||||
|
"build": "vue-cli-service build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"vue": "^2.6.12"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,60 @@
|
|||||||
|
<template>
|
||||||
|
<div id="app">
|
||||||
|
<h1>All Cameras</h1>
|
||||||
|
<CameraGrid
|
||||||
|
:cameras="cameras"
|
||||||
|
:motion-detected-cameras="[]"
|
||||||
|
@add-camera="handleAddCamera"
|
||||||
|
@remove-camera="handleRemoveCamera"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<h1>Cameras with Motion Detection</h1>
|
||||||
|
<CameraGrid
|
||||||
|
:cameras="motionDetectedCameras"
|
||||||
|
:motion-detected-cameras="motionDetectedCameras"
|
||||||
|
@add-camera="handleAddCamera"
|
||||||
|
@remove-camera="handleRemoveCamera"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CameraGrid from './components/CameraGrid.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
CameraGrid
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
cameras: [],
|
||||||
|
motionDetectedCameras: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
handleAddCamera(cameraId) {
|
||||||
|
if (!this.cameras.includes(cameraId)) {
|
||||||
|
this.cameras.push(cameraId);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleRemoveCamera(cameraId) {
|
||||||
|
this.cameras = this.cameras.filter(id => id !== cameraId);
|
||||||
|
this.motionDetectedCameras = this.motionDetectedCameras.filter(id => id !== cameraId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
#app {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,48 @@
|
|||||||
|
<template>
|
||||||
|
<div class="camera-block" :class="{ 'motion': isMotionDetected }">
|
||||||
|
<img :src="`/api/stream/${cameraId}`" alt="Camera stream" />
|
||||||
|
<button class="remove-button" @click="$emit('remove')">x</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: ['cameraId', 'isMotionDetected']
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.camera-block {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-block img {
|
||||||
|
width: 100%;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-button {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 10px;
|
||||||
|
background-color: red;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.camera-block:hover .remove-button {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.motion {
|
||||||
|
border: 2px solid green;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,23 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<input v-model="cameraId" placeholder="Camera ID" />
|
||||||
|
<button @click="addCamera">Add Camera</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
cameraId: ''
|
||||||
|
}
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
addCamera() {
|
||||||
|
this.$emit('add-camera', this.cameraId);
|
||||||
|
this.cameraId = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,57 @@
|
|||||||
|
<template>
|
||||||
|
<div class="camera-grid">
|
||||||
|
<!-- Отображение всех камер -->
|
||||||
|
<CameraBlock
|
||||||
|
v-for="cameraId in cameras"
|
||||||
|
:key="cameraId"
|
||||||
|
:camera-id="cameraId"
|
||||||
|
:is-motion-detected="motionDetectedCameras.includes(cameraId)"
|
||||||
|
@remove="removeCamera(cameraId)"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Блок для добавления новой камеры -->
|
||||||
|
<div class="add-camera-block">
|
||||||
|
<button @click="addCamera">+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import CameraBlock from './CameraBlock.vue';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: { CameraBlock },
|
||||||
|
props: ['cameras', 'motionDetectedCameras'],
|
||||||
|
methods: {
|
||||||
|
addCamera() {
|
||||||
|
this.$emit('add-camera');
|
||||||
|
},
|
||||||
|
removeCamera(cameraId) {
|
||||||
|
this.$emit('remove-camera', cameraId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.camera-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-camera-block {
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
height: 200px;
|
||||||
|
border: 2px dashed #ccc;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-camera-block button {
|
||||||
|
font-size: 24px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -0,0 +1,13 @@
|
|||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<h2>Camera {{ cameraId }}</h2>
|
||||||
|
<img :src="`/api/stream/${cameraId}`" alt="Stream" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
props: ['cameraId'],
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
@ -0,0 +1,6 @@
|
|||||||
|
import Vue from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
|
||||||
|
new Vue({
|
||||||
|
render: h => h(App),
|
||||||
|
}).$mount('#app')
|
@ -0,0 +1 @@
|
|||||||
|
Subproject commit 7c0d239e48a11cb620fe18eb10e9750c181a6134
|
Loading…
Reference in New Issue