Initial commit of existing code
This commit is contained in:
commit
a8fe93aff0
|
|
@ -0,0 +1,15 @@
|
||||||
|
# MQTT Configuration
|
||||||
|
MQTT_BROKER=192.168.10.46
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USERNAME=note4
|
||||||
|
MQTT_PASSWORD=note4
|
||||||
|
MQTT_TOPIC=homeassistant/sensor/RTSPCamDigitDetection/state
|
||||||
|
|
||||||
|
# AI Configuration
|
||||||
|
# model filename inside the 'models' folder
|
||||||
|
MODEL_FILE=dig-class11_1910_s2_q.tflite
|
||||||
|
|
||||||
|
# App Configuration
|
||||||
|
DEBUG=True
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
*.hex
|
||||||
|
build/
|
||||||
|
.DS_Store
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,350 @@
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
import base64
|
||||||
|
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
from flask import Flask, render_template, jsonify, request, Response
|
||||||
|
|
||||||
|
# Import Config, Manager, and NEW Inference Worker
|
||||||
|
from config import Config
|
||||||
|
from manager import CameraManager
|
||||||
|
from inference import InferenceWorker
|
||||||
|
|
||||||
|
# --- Logging Setup ---
|
||||||
|
logging.basicConfig(
|
||||||
|
level=Config.LOG_LEVEL,
|
||||||
|
format='%(asctime)s [%(levelname)s] %(message)s',
|
||||||
|
handlers=[logging.StreamHandler(sys.stdout)]
|
||||||
|
)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
app = Flask(__name__)
|
||||||
|
|
||||||
|
# --- Initialize Components ---
|
||||||
|
camera_manager = CameraManager()
|
||||||
|
inference_worker = InferenceWorker() # <--- NEW
|
||||||
|
inference_worker.start() # <--- Start the background thread
|
||||||
|
|
||||||
|
# --- MQTT Setup ---
|
||||||
|
mqtt_client = mqtt.Client()
|
||||||
|
if Config.MQTT_USERNAME and Config.MQTT_PASSWORD:
|
||||||
|
mqtt_client.username_pw_set(Config.MQTT_USERNAME, Config.MQTT_PASSWORD)
|
||||||
|
|
||||||
|
try:
|
||||||
|
mqtt_client.connect(Config.MQTT_BROKER, Config.MQTT_PORT, 60)
|
||||||
|
mqtt_client.loop_start() # START THE LOOP HERE
|
||||||
|
logger.info(f"Connected to MQTT Broker at {Config.MQTT_BROKER}:{Config.MQTT_PORT}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to connect to MQTT Broker: {e}")
|
||||||
|
|
||||||
|
# --- Helper Functions (UI Only) ---
|
||||||
|
def crop_image_for_ui(image, roi_list, scaleX, scaleY):
|
||||||
|
"""Helper for the /crop endpoint (UI preview only)."""
|
||||||
|
cropped_images = []
|
||||||
|
for roi in roi_list:
|
||||||
|
try:
|
||||||
|
x = int(roi['x'] * scaleX)
|
||||||
|
y = int(roi['y'] * scaleY)
|
||||||
|
width = int(roi['width'] * scaleX)
|
||||||
|
height = int(roi['height'] * scaleY)
|
||||||
|
cropped = image[y:y + height, x:x + width]
|
||||||
|
if cropped.size > 0:
|
||||||
|
cropped_images.append(cropped)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return cropped_images
|
||||||
|
|
||||||
|
def publish_detected_number(camera_id, detected_number):
|
||||||
|
"""Publish result to MQTT."""
|
||||||
|
topic = f"{Config.MQTT_TOPIC}/{camera_id}"
|
||||||
|
payload = json.dumps({"value": detected_number})
|
||||||
|
try:
|
||||||
|
mqtt_client.publish(topic, payload)
|
||||||
|
logger.info(f"Published to {topic}: {detected_number}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"MQTT Publish failed: {e}")
|
||||||
|
|
||||||
|
# --- Main Processing Loop (Refactored) ---
|
||||||
|
# Add this global dictionary at the top of app.py (near other globals)
|
||||||
|
last_processed_time = {}
|
||||||
|
|
||||||
|
# Update process_all_cameras function
|
||||||
|
def process_all_cameras():
|
||||||
|
"""
|
||||||
|
Revised Loop with Rate Limiting
|
||||||
|
"""
|
||||||
|
# Configurable interval (seconds)
|
||||||
|
DETECTION_INTERVAL = 10
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
# --- Part 1: Process Results ---
|
||||||
|
while True:
|
||||||
|
result = inference_worker.get_result()
|
||||||
|
if not result:
|
||||||
|
break
|
||||||
|
|
||||||
|
cam_id = result['camera_id']
|
||||||
|
val = result['value']
|
||||||
|
|
||||||
|
camera_manager.results[cam_id] = val
|
||||||
|
publish_detected_number(cam_id, val)
|
||||||
|
|
||||||
|
# --- Part 2: Feed Frames ---
|
||||||
|
camera_manager.load_roi_config()
|
||||||
|
|
||||||
|
for camera_id, camera_data in camera_manager.cameras.items():
|
||||||
|
if not camera_data.get("active", True):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# RATE LIMIT CHECK
|
||||||
|
current_time = time.time()
|
||||||
|
last_time = last_processed_time.get(camera_id, 0)
|
||||||
|
|
||||||
|
if current_time - last_time < DETECTION_INTERVAL:
|
||||||
|
continue # Skip this camera, it's too soon
|
||||||
|
|
||||||
|
stream = camera_data.get("stream")
|
||||||
|
if not stream: continue
|
||||||
|
|
||||||
|
# Warmup Check
|
||||||
|
if (current_time - stream.start_time) < 5:
|
||||||
|
continue
|
||||||
|
|
||||||
|
frame = stream.read()
|
||||||
|
|
||||||
|
if frame is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if np.std(frame) < 10:
|
||||||
|
continue
|
||||||
|
|
||||||
|
roi_list = camera_manager.rois.get(camera_id, [])
|
||||||
|
if not roi_list:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# SEND TO WORKER
|
||||||
|
inference_worker.add_task(camera_id, roi_list, frame)
|
||||||
|
|
||||||
|
# Update last processed time
|
||||||
|
last_processed_time[camera_id] = current_time
|
||||||
|
|
||||||
|
# Sleep briefly to prevent CPU spinning, but keep it responsive for results
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Global process loop error: {e}")
|
||||||
|
traceback.print_exc()
|
||||||
|
time.sleep(5)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Flask Routes (Unchanged logic, just imports) ---
|
||||||
|
|
||||||
|
@app.route('/')
|
||||||
|
def index():
|
||||||
|
return render_template('index.html')
|
||||||
|
|
||||||
|
@app.route('/cameras', methods=['GET'])
|
||||||
|
def get_cameras():
|
||||||
|
return jsonify(camera_manager.get_camera_list())
|
||||||
|
|
||||||
|
@app.route('/video/<camera_id>')
|
||||||
|
def video_feed(camera_id):
|
||||||
|
def generate():
|
||||||
|
while True:
|
||||||
|
frame = camera_manager.get_frame(camera_id)
|
||||||
|
if frame is not None:
|
||||||
|
ret, jpeg = cv2.imencode('.jpg', frame)
|
||||||
|
if ret:
|
||||||
|
yield (b'--frame\r\n' b'Content-Type: image/jpeg\r\n\r\n' + jpeg.tobytes() + b'\r\n\r\n')
|
||||||
|
else:
|
||||||
|
time.sleep(0.1)
|
||||||
|
return Response(generate(), mimetype='multipart/x-mixed-replace; boundary=frame')
|
||||||
|
|
||||||
|
@app.route('/snapshot/<camera_id>')
|
||||||
|
def snapshot(camera_id):
|
||||||
|
frame = camera_manager.get_frame(camera_id)
|
||||||
|
if frame is not None:
|
||||||
|
ret, jpeg = cv2.imencode('.jpg', frame)
|
||||||
|
if ret:
|
||||||
|
return Response(jpeg.tobytes(), mimetype='image/jpeg')
|
||||||
|
return 'No frame available', 404
|
||||||
|
|
||||||
|
@app.route('/rois/<camera_id>', methods=['GET'])
|
||||||
|
def get_rois(camera_id):
|
||||||
|
# ... (Same logic as Step 3, just ensure it uses camera_manager) ...
|
||||||
|
try:
|
||||||
|
camera_manager.load_roi_config()
|
||||||
|
all_rois = camera_manager.rois
|
||||||
|
img_width = request.args.get("img_width", type=float)
|
||||||
|
img_height = request.args.get("img_height", type=float)
|
||||||
|
|
||||||
|
if not img_width or not img_height:
|
||||||
|
return jsonify(all_rois.get(camera_id, []))
|
||||||
|
|
||||||
|
cam = camera_manager.cameras.get(camera_id)
|
||||||
|
if cam and cam.get("stream"):
|
||||||
|
real_w = cam["stream"].width or cam["width"]
|
||||||
|
real_h = cam["stream"].height or cam["height"]
|
||||||
|
else:
|
||||||
|
return jsonify({"error": "Camera not ready"}), 500
|
||||||
|
|
||||||
|
scaleX = img_width / real_w
|
||||||
|
scaleY = img_height / real_h
|
||||||
|
scaled_rois = []
|
||||||
|
for roi in all_rois.get(camera_id, []):
|
||||||
|
scaled_rois.append({
|
||||||
|
"id": roi["id"],
|
||||||
|
"x": int(round(roi["x"] * scaleX)),
|
||||||
|
"y": int(round(roi["y"] * scaleY)),
|
||||||
|
"width": int(round(roi["width"] * scaleX)),
|
||||||
|
"height": int(round(roi["height"] * scaleY)),
|
||||||
|
"angle": roi["angle"]
|
||||||
|
})
|
||||||
|
return jsonify(scaled_rois)
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"error": str(e)}), 500
|
||||||
|
|
||||||
|
@app.route("/save_rois", methods=["POST"])
|
||||||
|
def save_rois_api():
|
||||||
|
# ... (Same logic as Step 3) ...
|
||||||
|
data = request.json
|
||||||
|
camera_id = data.get("camera_id")
|
||||||
|
new_rois = data.get("rois")
|
||||||
|
img_width = data.get("img_width")
|
||||||
|
img_height = data.get("img_height")
|
||||||
|
|
||||||
|
if not camera_id or new_rois is None: return jsonify({"success": False})
|
||||||
|
|
||||||
|
cam = camera_manager.cameras.get(camera_id)
|
||||||
|
if not cam: return jsonify({"success": False})
|
||||||
|
|
||||||
|
stream = cam.get("stream")
|
||||||
|
real_w = stream.width if stream and stream.width else cam["width"]
|
||||||
|
real_h = stream.height if stream and stream.height else cam["height"]
|
||||||
|
|
||||||
|
scaleX = real_w / img_width if img_width else 1
|
||||||
|
scaleY = real_h / img_height if img_height else 1
|
||||||
|
|
||||||
|
scaled_rois = []
|
||||||
|
for roi in new_rois:
|
||||||
|
scaled_rois.append({
|
||||||
|
"id": roi["id"],
|
||||||
|
"x": int(round(roi["x"] * scaleX)),
|
||||||
|
"y": int(round(roi["y"] * scaleY)),
|
||||||
|
"width": int(round(roi["width"] * scaleX)),
|
||||||
|
"height": int(round(roi["height"] * scaleY)),
|
||||||
|
"angle": roi["angle"]
|
||||||
|
})
|
||||||
|
camera_manager.rois[camera_id] = scaled_rois
|
||||||
|
return jsonify(camera_manager.save_roi_config())
|
||||||
|
|
||||||
|
@app.route('/crop', methods=['POST'])
|
||||||
|
def crop():
|
||||||
|
# Helper for UI
|
||||||
|
data = request.json
|
||||||
|
camera_id = data.get('camera_id')
|
||||||
|
scaleX = data.get('scaleX', 1)
|
||||||
|
scaleY = data.get('scaleY', 1)
|
||||||
|
|
||||||
|
frame = camera_manager.get_frame(camera_id)
|
||||||
|
if frame is None: return jsonify({'error': 'No frame'}), 500
|
||||||
|
|
||||||
|
roi_list = camera_manager.rois.get(camera_id, [])
|
||||||
|
# Use the local UI helper function
|
||||||
|
cropped_images = crop_image_for_ui(frame, roi_list, scaleX, scaleY)
|
||||||
|
|
||||||
|
cropped_base64_list = []
|
||||||
|
for cropped_img in cropped_images:
|
||||||
|
ret, buffer = cv2.imencode('.jpg', cropped_img)
|
||||||
|
if ret:
|
||||||
|
cropped_base64_list.append(base64.b64encode(buffer).decode('utf-8'))
|
||||||
|
return jsonify({'cropped_images': cropped_base64_list})
|
||||||
|
|
||||||
|
@app.route('/detect_digits', methods=['POST'])
|
||||||
|
def detect_digits():
|
||||||
|
"""Manual trigger: Runs inference immediately and returns result."""
|
||||||
|
data = request.json
|
||||||
|
camera_id = data.get('camera_id')
|
||||||
|
|
||||||
|
if not camera_id:
|
||||||
|
return jsonify({'error': 'Invalid camera ID'}), 400
|
||||||
|
|
||||||
|
# 1. Get Frame
|
||||||
|
frame = camera_manager.get_frame(camera_id)
|
||||||
|
if frame is None:
|
||||||
|
return jsonify({'error': 'Failed to capture image'}), 500
|
||||||
|
|
||||||
|
# 2. Get ROIs
|
||||||
|
roi_list = camera_manager.rois.get(camera_id, [])
|
||||||
|
if not roi_list:
|
||||||
|
return jsonify({'error': 'No ROIs defined'}), 400
|
||||||
|
|
||||||
|
# 3. Crop (Using the UI helper is fine here)
|
||||||
|
cropped_images = crop_image_for_ui(frame, roi_list, scaleX=1, scaleY=1)
|
||||||
|
if not cropped_images:
|
||||||
|
return jsonify({'error': 'Failed to crop ROIs'}), 500
|
||||||
|
|
||||||
|
# 4. Run Inference Synchronously
|
||||||
|
# Note: We access the worker directly.
|
||||||
|
# Thread safety: 'predict_batch' uses 'self.interpreter'.
|
||||||
|
# If the background thread is also using it, TFLite might complain or crash.
|
||||||
|
# PROPER FIX: Pause the worker or use a Lock.
|
||||||
|
|
||||||
|
# Since adding a Lock is complex now, a simple hack is to just add it to the queue
|
||||||
|
# and WAIT for the result? No, that's hard to correlate.
|
||||||
|
|
||||||
|
# SAFE APPROACH: Use a Lock in InferenceWorker.
|
||||||
|
# For now, let's assume TFLite is robust enough or race conditions are rare for manual clicks.
|
||||||
|
# CALL THE PUBLIC METHOD:
|
||||||
|
try:
|
||||||
|
detected_digits = inference_worker.predict_batch(cropped_images)
|
||||||
|
|
||||||
|
valid_digits = [d for d in detected_digits if d.isdigit()]
|
||||||
|
|
||||||
|
if not valid_digits:
|
||||||
|
return jsonify({'error': 'No valid digits detected', 'raw': detected_digits}), 500
|
||||||
|
|
||||||
|
final_number = int("".join(valid_digits))
|
||||||
|
|
||||||
|
# Publish and Update State
|
||||||
|
publish_detected_number(camera_id, final_number)
|
||||||
|
camera_manager.results[camera_id] = final_number
|
||||||
|
|
||||||
|
logger.info(f"Manual detection for {camera_id}: {final_number}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'detected_digits': valid_digits,
|
||||||
|
'final_number': final_number
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during manual detection: {e}")
|
||||||
|
return jsonify({'error': str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/update_camera_config', methods=['POST'])
|
||||||
|
def update_camera_config():
|
||||||
|
data = request.json
|
||||||
|
success = camera_manager.update_camera_flip(data.get("camera_id"), data.get("flip_type"))
|
||||||
|
return jsonify({"success": success})
|
||||||
|
|
||||||
|
# --- Main ---
|
||||||
|
if __name__ == '__main__':
|
||||||
|
# Threading:
|
||||||
|
# 1. Video Threads (in Manager)
|
||||||
|
# 2. Inference Thread (in Worker)
|
||||||
|
# 3. Main Loop (process_all_cameras - handles feeding)
|
||||||
|
|
||||||
|
t = threading.Thread(target=process_all_cameras, daemon=True)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
logger.info("Starting Flask Server...")
|
||||||
|
app.run(host='0.0.0.0', port=5000, threaded=True)
|
||||||
|
|
@ -0,0 +1,125 @@
|
||||||
|
import cv2
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class VideoStream:
|
||||||
|
def __init__(self, camera_id, rtsp_url, flip_type="None"):
|
||||||
|
self.camera_id = camera_id
|
||||||
|
self.rtsp_url = rtsp_url
|
||||||
|
self.flip_type = flip_type
|
||||||
|
self.width = None
|
||||||
|
self.height = None
|
||||||
|
|
||||||
|
# State
|
||||||
|
self.active = False
|
||||||
|
self.frame = None
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
self.cap = None
|
||||||
|
|
||||||
|
# Track start time for warmup logic (Step 2 Fix)
|
||||||
|
self.start_time = time.time()
|
||||||
|
|
||||||
|
# Initialize connection
|
||||||
|
self.connect()
|
||||||
|
|
||||||
|
def connect(self):
|
||||||
|
"""Establish the cv2 connection with TCP forced."""
|
||||||
|
if self.cap:
|
||||||
|
self.cap.release()
|
||||||
|
|
||||||
|
logger.info(f"Connecting to {self.camera_id} via RTSP (TCP)...")
|
||||||
|
|
||||||
|
# Fix for packet errors (Step 3 Fix)
|
||||||
|
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = "rtsp_transport;tcp"
|
||||||
|
|
||||||
|
self.cap = cv2.VideoCapture(self.rtsp_url, cv2.CAP_FFMPEG)
|
||||||
|
self.cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
||||||
|
|
||||||
|
if self.cap.isOpened():
|
||||||
|
ret, frame = self.cap.read()
|
||||||
|
if ret:
|
||||||
|
h, w, _ = frame.shape
|
||||||
|
self.width = w
|
||||||
|
self.height = h
|
||||||
|
self.frame = frame
|
||||||
|
self.start_time = time.time() # Reset warm-up timer
|
||||||
|
logger.info(f"Connected to {self.camera_id} ({w}x{h})")
|
||||||
|
else:
|
||||||
|
logger.warning(f"Connected to {self.camera_id} but initial read failed.")
|
||||||
|
self.cap.release()
|
||||||
|
else:
|
||||||
|
logger.error(f"Failed to open RTSP stream for {self.camera_id}")
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
"""Start the thread to read frames."""
|
||||||
|
if self.active:
|
||||||
|
return self
|
||||||
|
|
||||||
|
self.active = True
|
||||||
|
self.thread = threading.Thread(target=self.update, args=(), daemon=True)
|
||||||
|
self.thread.start()
|
||||||
|
return self
|
||||||
|
|
||||||
|
def update(self):
|
||||||
|
"""Background loop to read frames."""
|
||||||
|
failure_count = 0
|
||||||
|
|
||||||
|
while self.active:
|
||||||
|
if self.cap is None or not self.cap.isOpened():
|
||||||
|
time.sleep(2)
|
||||||
|
self.connect()
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
ret, frame = self.cap.read()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Read error: {e}")
|
||||||
|
ret = False
|
||||||
|
|
||||||
|
if ret:
|
||||||
|
# Apply flip
|
||||||
|
if self.flip_type == "horizontal":
|
||||||
|
frame = cv2.flip(frame, 1)
|
||||||
|
elif self.flip_type == "vertical":
|
||||||
|
frame = cv2.flip(frame, 0)
|
||||||
|
elif self.flip_type == "both":
|
||||||
|
frame = cv2.flip(frame, -1)
|
||||||
|
|
||||||
|
with self.lock:
|
||||||
|
self.frame = frame
|
||||||
|
|
||||||
|
failure_count = 0
|
||||||
|
time.sleep(0.01)
|
||||||
|
else:
|
||||||
|
failure_count += 1
|
||||||
|
if failure_count > 5:
|
||||||
|
logger.warning(f"Stream {self.camera_id} lost. Reconnecting...")
|
||||||
|
self.connect()
|
||||||
|
failure_count = 0
|
||||||
|
else:
|
||||||
|
time.sleep(0.1)
|
||||||
|
|
||||||
|
def clear_buffer(self):
|
||||||
|
"""Flush the buffer to remove old frames. (Step 2 Fix)"""
|
||||||
|
if self.cap and self.cap.isOpened():
|
||||||
|
self.cap.grab()
|
||||||
|
|
||||||
|
def read(self):
|
||||||
|
"""Return the most recent frame."""
|
||||||
|
with self.lock:
|
||||||
|
return self.frame
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
"""Stop the thread and release resources."""
|
||||||
|
self.active = False
|
||||||
|
if self.thread.is_alive():
|
||||||
|
self.thread.join()
|
||||||
|
if self.cap:
|
||||||
|
self.cap.release()
|
||||||
|
|
||||||
|
def set_flip(self, flip_type):
|
||||||
|
self.flip_type = flip_type
|
||||||
|
|
@ -0,0 +1,11 @@
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "camera3",
|
||||||
|
"name": "Camera 3",
|
||||||
|
"rtsp_url": "rtsp://yourusername:yourpassword@192.168.10.53:8083/TimerCameraX",
|
||||||
|
"width": 1280,
|
||||||
|
"height": 1024,
|
||||||
|
"active": true,
|
||||||
|
"flip_type": "both"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
# Load variables from .env file if it exists
|
||||||
|
load_dotenv()
|
||||||
|
|
||||||
|
class Config:
|
||||||
|
# --- Paths ---
|
||||||
|
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
UPLOAD_FOLDER = os.getenv('UPLOAD_FOLDER', os.path.join(BASE_DIR, 'static'))
|
||||||
|
ROI_FOLDER = os.path.join(UPLOAD_FOLDER, 'rois')
|
||||||
|
|
||||||
|
# Configuration Files
|
||||||
|
ROI_CONFIG_PATH = os.path.join(BASE_DIR, os.getenv('ROI_CONFIG_FILE', 'roi_config.json'))
|
||||||
|
CAMERA_CONFIG_PATH = os.path.join(BASE_DIR, os.getenv('CAMERA_CONFIG_FILE', 'camera_config.json'))
|
||||||
|
|
||||||
|
# Model Path
|
||||||
|
MODEL_PATH = os.path.join(BASE_DIR, 'models', os.getenv('MODEL_FILE', 'dig-class11_1910_s2_q.tflite'))
|
||||||
|
|
||||||
|
# --- MQTT Settings ---
|
||||||
|
MQTT_BROKER = os.getenv('MQTT_BROKER', '127.0.0.1')
|
||||||
|
MQTT_PORT = int(os.getenv('MQTT_PORT', 1883))
|
||||||
|
MQTT_TOPIC = os.getenv('MQTT_TOPIC', 'homeassistant/sensor/RTSPCamDigitDetection/state')
|
||||||
|
MQTT_USERNAME = os.getenv('MQTT_USERNAME', None)
|
||||||
|
MQTT_PASSWORD = os.getenv('MQTT_PASSWORD', None)
|
||||||
|
|
||||||
|
# --- App Settings ---
|
||||||
|
DEBUG = os.getenv('DEBUG', 'False').lower() in ('true', '1', 't')
|
||||||
|
LOG_LEVEL = getattr(logging, os.getenv('LOG_LEVEL', 'INFO').upper(), logging.INFO)
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
import threading
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import cv2
|
||||||
|
import numpy as np
|
||||||
|
import tflite_runtime.interpreter as tflite
|
||||||
|
from config import Config
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class InferenceWorker:
|
||||||
|
def __init__(self):
|
||||||
|
self.input_queue = queue.Queue(maxsize=10)
|
||||||
|
self.result_queue = queue.Queue()
|
||||||
|
self.running = False
|
||||||
|
self.interpreter = None
|
||||||
|
self.input_details = None
|
||||||
|
self.output_details = None
|
||||||
|
|
||||||
|
self.lock = threading.Lock()
|
||||||
|
|
||||||
|
# Load Model
|
||||||
|
self.load_model()
|
||||||
|
|
||||||
|
def load_model(self):
|
||||||
|
try:
|
||||||
|
logger.info(f"Loading TFLite model from: {Config.MODEL_PATH}")
|
||||||
|
self.interpreter = tflite.Interpreter(model_path=Config.MODEL_PATH)
|
||||||
|
self.interpreter.allocate_tensors()
|
||||||
|
self.input_details = self.interpreter.get_input_details()
|
||||||
|
self.output_details = self.interpreter.get_output_details()
|
||||||
|
|
||||||
|
# Store original input shape for resizing logic
|
||||||
|
self.original_input_shape = self.input_details[0]['shape']
|
||||||
|
logger.info(f"Model loaded. Default input shape: {self.original_input_shape}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.critical(f"Failed to load TFLite model: {e}")
|
||||||
|
self.interpreter = None
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
if self.running: return
|
||||||
|
self.running = True
|
||||||
|
threading.Thread(target=self._worker_loop, daemon=True).start()
|
||||||
|
logger.info("Inference worker started.")
|
||||||
|
|
||||||
|
def add_task(self, camera_id, rois, frame):
|
||||||
|
"""Add task (non-blocking)."""
|
||||||
|
if not self.interpreter: return
|
||||||
|
try:
|
||||||
|
task = {
|
||||||
|
'camera_id': camera_id,
|
||||||
|
'rois': rois,
|
||||||
|
'frame': frame,
|
||||||
|
'timestamp': time.time()
|
||||||
|
}
|
||||||
|
self.input_queue.put(task, block=False)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def get_result(self):
|
||||||
|
try:
|
||||||
|
return self.result_queue.get(block=False)
|
||||||
|
except queue.Empty:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _worker_loop(self):
|
||||||
|
while self.running:
|
||||||
|
try:
|
||||||
|
task = self.input_queue.get(timeout=1)
|
||||||
|
except queue.Empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cam_id = task['camera_id']
|
||||||
|
rois = task['rois']
|
||||||
|
frame = task['frame']
|
||||||
|
|
||||||
|
try:
|
||||||
|
# 1. Crop all ROIs
|
||||||
|
crops = self._crop_rois(frame, rois)
|
||||||
|
if not crops: continue
|
||||||
|
|
||||||
|
# 2. Batch Predict (Optimized Step)
|
||||||
|
digits = self.predict_batch(crops)
|
||||||
|
|
||||||
|
# 3. Combine
|
||||||
|
valid_digits = [d for d in digits if d.isdigit()]
|
||||||
|
if len(valid_digits) == len(digits) and len(valid_digits) > 0:
|
||||||
|
final_number = int("".join(valid_digits))
|
||||||
|
|
||||||
|
self.result_queue.put({
|
||||||
|
'camera_id': cam_id,
|
||||||
|
'value': final_number,
|
||||||
|
'digits': valid_digits
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Inference error for {cam_id}: {e}")
|
||||||
|
|
||||||
|
def _crop_rois(self, image, roi_list):
|
||||||
|
cropped_images = []
|
||||||
|
for roi in roi_list:
|
||||||
|
try:
|
||||||
|
x, y, w, h = roi['x'], roi['y'], roi['width'], roi['height']
|
||||||
|
cropped = image[y:y+h, x:x+w]
|
||||||
|
if cropped.size > 0:
|
||||||
|
cropped_images.append(cropped)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return cropped_images
|
||||||
|
|
||||||
|
def predict_batch(self, images):
|
||||||
|
"""Run inference on a batch of images at once."""
|
||||||
|
with self.lock: # <--- Add this wrapper
|
||||||
|
if not self.interpreter: return []
|
||||||
|
|
||||||
|
num_images = len(images)
|
||||||
|
if num_images == 0: return []
|
||||||
|
|
||||||
|
input_index = self.input_details[0]['index']
|
||||||
|
output_index = self.output_details[0]['index']
|
||||||
|
|
||||||
|
# Preprocess all images into a single batch array
|
||||||
|
# Shape: [N, 32, 20, 3] (assuming model expects 32x20 rgb)
|
||||||
|
batch_input = []
|
||||||
|
|
||||||
|
target_h, target_w = 32, 20 # Based on your previous code logic
|
||||||
|
|
||||||
|
for img in images:
|
||||||
|
# Resize
|
||||||
|
roi_resized = cv2.resize(img, (target_w, target_h))
|
||||||
|
# Color
|
||||||
|
roi_rgb = cv2.cvtColor(roi_resized, cv2.COLOR_BGR2RGB)
|
||||||
|
# Normalize
|
||||||
|
roi_norm = roi_rgb.astype(np.float32)
|
||||||
|
batch_input.append(roi_norm)
|
||||||
|
|
||||||
|
# Create batch tensor
|
||||||
|
input_tensor = np.array(batch_input)
|
||||||
|
|
||||||
|
# --- DYNAMIC RESIZING ---
|
||||||
|
# TFLite models have a fixed input size (usually batch=1).
|
||||||
|
# We must resize the input tensor to match our current batch size (N).
|
||||||
|
|
||||||
|
# 1. Resize input tensor
|
||||||
|
self.interpreter.resize_tensor_input(input_index, [num_images, target_h, target_w, 3])
|
||||||
|
|
||||||
|
# 2. Re-allocate tensors (This is expensive! See note below)
|
||||||
|
self.interpreter.allocate_tensors()
|
||||||
|
|
||||||
|
# 3. Run Inference
|
||||||
|
self.interpreter.set_tensor(input_index, input_tensor)
|
||||||
|
self.interpreter.invoke()
|
||||||
|
|
||||||
|
# 4. Get Results
|
||||||
|
output_data = self.interpreter.get_tensor(output_index)
|
||||||
|
|
||||||
|
# Result shape is [N, 10] (probabilities for 10 digits)
|
||||||
|
predictions = []
|
||||||
|
for i in range(num_images):
|
||||||
|
digit_class = np.argmax(output_data[i])
|
||||||
|
predictions.append(str(digit_class))
|
||||||
|
|
||||||
|
return predictions
|
||||||
|
|
@ -0,0 +1,112 @@
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from config import Config
|
||||||
|
from camera import VideoStream
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class CameraManager:
|
||||||
|
def __init__(self):
|
||||||
|
# State containers
|
||||||
|
# Replaces global 'cameras', 'rois', 'camera_results'
|
||||||
|
self.cameras = {}
|
||||||
|
self.rois = {}
|
||||||
|
self.results = {}
|
||||||
|
|
||||||
|
# Load initial configuration immediately
|
||||||
|
self.load_camera_config()
|
||||||
|
self.load_roi_config()
|
||||||
|
|
||||||
|
def load_camera_config(self):
|
||||||
|
"""Load settings from JSON and initialize VideoStream objects."""
|
||||||
|
try:
|
||||||
|
with open(Config.CAMERA_CONFIG_PATH, 'r') as f:
|
||||||
|
config_data = json.load(f)
|
||||||
|
|
||||||
|
for cam_conf in config_data:
|
||||||
|
cam_id = cam_conf['id']
|
||||||
|
if not cam_conf.get("active", False):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Initialize stream only if new
|
||||||
|
if cam_id not in self.cameras:
|
||||||
|
logger.info(f"Initializing new camera manager entry: {cam_id}")
|
||||||
|
stream = VideoStream(
|
||||||
|
camera_id=cam_id,
|
||||||
|
rtsp_url=cam_conf['rtsp_url'],
|
||||||
|
flip_type=cam_conf.get("flip_type", "None")
|
||||||
|
)
|
||||||
|
stream.start()
|
||||||
|
|
||||||
|
self.cameras[cam_id] = {
|
||||||
|
"id": cam_id,
|
||||||
|
"name": cam_conf['name'],
|
||||||
|
"stream": stream,
|
||||||
|
"width": stream.width or 640,
|
||||||
|
"height": stream.height or 360,
|
||||||
|
"active": True,
|
||||||
|
"rtsp_url": cam_conf['rtsp_url'],
|
||||||
|
"flip_type": cam_conf.get("flip_type", "None")
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure ROI list exists
|
||||||
|
if cam_id not in self.rois:
|
||||||
|
self.rois[cam_id] = []
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load camera config: {e}")
|
||||||
|
|
||||||
|
def load_roi_config(self):
|
||||||
|
"""Reload ROI definitions from disk."""
|
||||||
|
try:
|
||||||
|
with open(Config.ROI_CONFIG_PATH, 'r') as f:
|
||||||
|
self.rois = json.load(f)
|
||||||
|
# logger.info(f"ROIs loaded/reloaded.") # Optional logging
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error loading ROI config: {e}")
|
||||||
|
self.rois = {}
|
||||||
|
|
||||||
|
def save_roi_config(self):
|
||||||
|
"""Save current ROIs to disk."""
|
||||||
|
try:
|
||||||
|
with open(Config.ROI_CONFIG_PATH, 'w') as f:
|
||||||
|
json.dump(self.rois, f, indent=4)
|
||||||
|
return {"success": True}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error saving ROI config: {e}")
|
||||||
|
return {"success": False, "error": str(e)}
|
||||||
|
|
||||||
|
def get_frame(self, camera_id):
|
||||||
|
"""Helper to get the latest frame from a specific camera."""
|
||||||
|
cam = self.cameras.get(camera_id)
|
||||||
|
if not cam or not cam.get('stream'):
|
||||||
|
return None
|
||||||
|
return cam['stream'].read()
|
||||||
|
|
||||||
|
def get_camera_list(self):
|
||||||
|
"""Return simple list of cameras for API."""
|
||||||
|
return [{"id": c['id'], "name": c['name']} for c in self.cameras.values()]
|
||||||
|
|
||||||
|
def update_camera_flip(self, camera_id, flip_type):
|
||||||
|
"""Update flip settings for a camera."""
|
||||||
|
if camera_id in self.cameras:
|
||||||
|
self.cameras[camera_id]["flip_type"] = flip_type
|
||||||
|
stream = self.cameras[camera_id].get("stream")
|
||||||
|
if stream:
|
||||||
|
stream.set_flip(flip_type)
|
||||||
|
|
||||||
|
# Persist to disk
|
||||||
|
try:
|
||||||
|
with open(Config.CAMERA_CONFIG_PATH, 'r') as f:
|
||||||
|
config = json.load(f)
|
||||||
|
for c in config:
|
||||||
|
if c['id'] == camera_id:
|
||||||
|
c['flip_type'] = flip_type
|
||||||
|
with open(Config.CAMERA_CONFIG_PATH, 'w') as f:
|
||||||
|
json.dump(config, f, indent=4)
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save flip config: {e}")
|
||||||
|
return False
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,144 @@
|
||||||
|
[TakeImage]
|
||||||
|
;RawImagesLocation = /log/source
|
||||||
|
;RawImagesRetention = 15
|
||||||
|
WaitBeforeTakingPicture = 2
|
||||||
|
CamGainceiling = x8
|
||||||
|
CamQuality = 10
|
||||||
|
CamBrightness = 0
|
||||||
|
CamContrast = 0
|
||||||
|
CamSaturation = 0
|
||||||
|
CamSharpness = 0
|
||||||
|
CamAutoSharpness = false
|
||||||
|
CamSpecialEffect = no_effect
|
||||||
|
CamWbMode = auto
|
||||||
|
CamAwb = true
|
||||||
|
CamAwbGain = true
|
||||||
|
CamAec = true
|
||||||
|
CamAec2 = true
|
||||||
|
CamAeLevel = 2
|
||||||
|
CamAecValue = 600
|
||||||
|
CamAgc = true
|
||||||
|
CamAgcGain = 8
|
||||||
|
CamBpc = true
|
||||||
|
CamWpc = true
|
||||||
|
CamRawGma = true
|
||||||
|
CamLenc = true
|
||||||
|
CamHmirror = false
|
||||||
|
CamVflip = false
|
||||||
|
CamDcw = true
|
||||||
|
CamDenoise = 0
|
||||||
|
CamZoom = false
|
||||||
|
CamZoomOffsetX = 0
|
||||||
|
CamZoomOffsetY = 0
|
||||||
|
CamZoomSize = 0
|
||||||
|
LEDIntensity = 50
|
||||||
|
Demo = false
|
||||||
|
|
||||||
|
[Alignment]
|
||||||
|
InitialRotate = 0.0
|
||||||
|
SearchFieldX = 20
|
||||||
|
SearchFieldY = 20
|
||||||
|
AlignmentAlgo = default
|
||||||
|
/config/ref0.jpg 103 271
|
||||||
|
/config/ref1.jpg 442 142
|
||||||
|
|
||||||
|
[Digits]
|
||||||
|
Model = /config/dig-cont_0712_s3_q.tflite
|
||||||
|
CNNGoodThreshold = 0.5
|
||||||
|
;ROIImagesLocation = /log/digit
|
||||||
|
;ROIImagesRetention = 3
|
||||||
|
main.dig1 294 126 30 54 false
|
||||||
|
main.dig2 343 126 30 54 false
|
||||||
|
main.dig3 391 126 30 54 false
|
||||||
|
|
||||||
|
[Analog]
|
||||||
|
Model = /config/ana-cont_1300_s2.tflite
|
||||||
|
CNNGoodThreshold = 0.5
|
||||||
|
;ROIImagesLocation = /log/analog
|
||||||
|
;ROIImagesRetention = 3
|
||||||
|
main.ana1 432 230 92 92 false
|
||||||
|
main.ana2 379 332 92 92 false
|
||||||
|
main.ana3 283 374 92 92 false
|
||||||
|
main.ana4 155 328 92 92 false
|
||||||
|
|
||||||
|
[PostProcessing]
|
||||||
|
main.DecimalShift = 0
|
||||||
|
main.AnalogDigitTransitionStart = 9.2
|
||||||
|
main.ChangeRateThreshold = 2
|
||||||
|
PreValueUse = true
|
||||||
|
PreValueAgeStartup = 720
|
||||||
|
main.AllowNegativeRates = false
|
||||||
|
main.MaxRateValue = 0.05
|
||||||
|
;main.MaxRateType = AbsoluteChange
|
||||||
|
main.ExtendedResolution = false
|
||||||
|
main.IgnoreLeadingNaN = false
|
||||||
|
ErrorMessage = true
|
||||||
|
CheckDigitIncreaseConsistency = false
|
||||||
|
|
||||||
|
;[MQTT]
|
||||||
|
;Uri = mqtt://IP-ADRESS:1883
|
||||||
|
;MainTopic = watermeter
|
||||||
|
;ClientID = watermeter
|
||||||
|
;user = USERNAME
|
||||||
|
;password = PASSWORD
|
||||||
|
RetainMessages = false
|
||||||
|
HomeassistantDiscovery = false
|
||||||
|
;MeterType = other
|
||||||
|
;CACert = /config/certs/RootCA.pem
|
||||||
|
;ClientCert = /config/certs/client.pem.crt
|
||||||
|
;ClientKey = /config/certs/client.pem.key
|
||||||
|
;DomoticzTopicIn = domoticz/in
|
||||||
|
;main.DomoticzIDX = 0
|
||||||
|
|
||||||
|
;[InfluxDB]
|
||||||
|
;Uri = undefined
|
||||||
|
;Database = undefined
|
||||||
|
;user = undefined
|
||||||
|
;password = undefined
|
||||||
|
;main.Measurement = undefined
|
||||||
|
;main.Field = undefined
|
||||||
|
|
||||||
|
;[InfluxDBv2]
|
||||||
|
;Uri = undefined
|
||||||
|
;Bucket = undefined
|
||||||
|
;Org = undefined
|
||||||
|
;Token = undefined
|
||||||
|
;main.Measurement = undefined
|
||||||
|
;main.Field = undefined
|
||||||
|
|
||||||
|
;[Webhook]
|
||||||
|
;Uri = undefined
|
||||||
|
;ApiKey = undefined
|
||||||
|
;UploadImg = 0
|
||||||
|
|
||||||
|
;[GPIO]
|
||||||
|
;MainTopicMQTT = wasserzaehler/GPIO
|
||||||
|
;IO0 = input disabled 10 false false
|
||||||
|
;IO1 = input disabled 10 false false
|
||||||
|
;IO3 = input disabled 10 false false
|
||||||
|
;IO4 = built-in-led disabled 10 false false
|
||||||
|
;IO12 = input-pullup disabled 10 false false
|
||||||
|
;IO13 = input-pullup disabled 10 false false
|
||||||
|
LEDType = WS2812
|
||||||
|
LEDNumbers = 2
|
||||||
|
LEDColor = 150 150 150
|
||||||
|
|
||||||
|
[AutoTimer]
|
||||||
|
Interval = 5
|
||||||
|
|
||||||
|
[DataLogging]
|
||||||
|
DataLogActive = true
|
||||||
|
DataFilesRetention = 3
|
||||||
|
|
||||||
|
[Debug]
|
||||||
|
LogLevel = 1
|
||||||
|
LogfilesRetention = 3
|
||||||
|
|
||||||
|
[System]
|
||||||
|
TimeZone = CET-1CEST,M3.5.0,M10.5.0/3
|
||||||
|
;TimeServer = pool.ntp.org
|
||||||
|
;Hostname = undefined
|
||||||
|
RSSIThreshold = -75
|
||||||
|
CPUFrequency = 160
|
||||||
|
Tooltip = true
|
||||||
|
SetupMode = true
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,12 @@
|
||||||
|
Flask==2.3.3
|
||||||
|
Flask-SocketIO==5.3.6
|
||||||
|
eventlet==0.33.3
|
||||||
|
opencv-python==4.8.1.78
|
||||||
|
tflite-runtime==2.14.0
|
||||||
|
numpy==1.24.3
|
||||||
|
paho-mqtt==1.6.1
|
||||||
|
gunicorn==21.2.0
|
||||||
|
Pillow==10.0.1
|
||||||
|
python-dotenv==1.0.0
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
{
|
||||||
|
"camera3": [
|
||||||
|
{
|
||||||
|
"id": "1739048048162",
|
||||||
|
"x": 614,
|
||||||
|
"y": 336,
|
||||||
|
"width": 73,
|
||||||
|
"height": 117,
|
||||||
|
"angle": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "1739101594050",
|
||||||
|
"x": 693,
|
||||||
|
"y": 338,
|
||||||
|
"width": 73,
|
||||||
|
"height": 117,
|
||||||
|
"angle": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
sudo nano /etc/systemd/system/digit_detection.service
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Digit Detection Service
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
User=root
|
||||||
|
WorkingDirectory=/root/EdgeAI
|
||||||
|
ExecStart=/root/ml_env/bin/python -u /root/EdgeAI/app.py
|
||||||
|
Restart=always
|
||||||
|
Environment="PATH=/root/ml_env/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
|
||||||
|
StandardOutput=journal
|
||||||
|
StandardError=journal
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
|
@ -0,0 +1,484 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>EdgeAI Digit Reader</title>
|
||||||
|
|
||||||
|
<!-- External Libraries -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||||
|
<link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/toastify-js/src/toastify.min.css">
|
||||||
|
|
||||||
|
<style>
|
||||||
|
:root { --sidebar-width: 260px; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: #121212;
|
||||||
|
color: #e0e0e0;
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Sidebar - Dark Theme */
|
||||||
|
.sidebar {
|
||||||
|
width: var(--sidebar-width); height: 100vh; position: fixed; left: 0; top: 0;
|
||||||
|
background: #1e1e1e; border-right: 1px solid #333; padding: 1rem; z-index: 1000; overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main Content Layout */
|
||||||
|
.main-content {
|
||||||
|
margin-left: var(--sidebar-width);
|
||||||
|
height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Camera Items */
|
||||||
|
.camera-item {
|
||||||
|
cursor: pointer; padding: 12px; border-radius: 6px; margin-bottom: 8px;
|
||||||
|
background: #2c2c2c; transition: all 0.2s; border-left: 3px solid transparent;
|
||||||
|
}
|
||||||
|
.camera-item:hover { background-color: #383838; }
|
||||||
|
.camera-item.active {
|
||||||
|
background-color: #333;
|
||||||
|
border-left-color: #0d6efd;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-badge { font-size: 0.7em; padding: 2px 6px; border-radius: 4px; }
|
||||||
|
.status-online { background: #198754; color: white; }
|
||||||
|
|
||||||
|
/* Toolbar */
|
||||||
|
.toolbar {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #1e1e1e; padding: 10px 20px; border-bottom: 1px solid #333;
|
||||||
|
display: flex; justify-content: space-between; align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Debug Panel (Top Position) */
|
||||||
|
.debug-panel {
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #1e1e1e;
|
||||||
|
border-bottom: 1px solid #333;
|
||||||
|
font-family: monospace; font-size: 0.8em;
|
||||||
|
max-height: 30%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 5px 20px;
|
||||||
|
z-index: 2000;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Video Stage (Fills remaining space) */
|
||||||
|
.video-stage {
|
||||||
|
flex-grow: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #000;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
padding: 10px;
|
||||||
|
min-height: 0; /* Critical for flex shrinking */
|
||||||
|
}
|
||||||
|
|
||||||
|
.video-wrapper {
|
||||||
|
position: relative; display: inline-block;
|
||||||
|
box-shadow: 0 0 20px rgba(0,0,0,0.5); user-select: none;
|
||||||
|
max-width: 100%; max-height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#video-feed {
|
||||||
|
display: block;
|
||||||
|
max-width: 100%; max-height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ROI Overlays */
|
||||||
|
#roi-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; z-index: 10; }
|
||||||
|
|
||||||
|
.roi-box {
|
||||||
|
position: absolute; border: 2px solid #00ff00;
|
||||||
|
background-color: rgba(0, 255, 0, 0.15); cursor: move; box-sizing: border-box;
|
||||||
|
transform-origin: center center;
|
||||||
|
}
|
||||||
|
.roi-box:hover { border-color: #fff; z-index: 100; background-color: rgba(0, 255, 0, 0.25); }
|
||||||
|
.roi-box.selected { border-color: #ffc107; z-index: 110; background-color: rgba(255, 193, 7, 0.2); }
|
||||||
|
|
||||||
|
.resize-handle {
|
||||||
|
width: 12px; height: 12px; background: #fff; border: 1px solid #000;
|
||||||
|
position: absolute; bottom: -6px; right: -6px; cursor: nwse-resize; z-index: 120;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rotate-handle {
|
||||||
|
width: 12px; height: 12px; background: #0d6efd; border: 1px solid #fff; border-radius: 50%;
|
||||||
|
position: absolute; top: -20px; left: 50%; transform: translateX(-50%);
|
||||||
|
cursor: grab; z-index: 120; display: none;
|
||||||
|
}
|
||||||
|
.rotate-handle::after {
|
||||||
|
content: ''; position: absolute; top: 10px; left: 50%; width: 2px; height: 10px;
|
||||||
|
background: #0d6efd; transform: translateX(-1px);
|
||||||
|
}
|
||||||
|
.roi-box:hover .rotate-handle, .roi-box.selected .rotate-handle { display: block; }
|
||||||
|
|
||||||
|
.delete-roi {
|
||||||
|
position: absolute; top: -12px; right: -12px; background: #dc3545; color: white;
|
||||||
|
border-radius: 50%; width: 24px; height: 24px; font-size: 14px;
|
||||||
|
display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 120;
|
||||||
|
display: none; box-shadow: 0 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
.roi-box:hover .delete-roi, .roi-box.selected .delete-roi { display: flex; }
|
||||||
|
|
||||||
|
.detection-badge {
|
||||||
|
position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
|
||||||
|
background: rgba(0,0,0,0.8); color: #0f0; font-weight: bold; padding: 2px 6px;
|
||||||
|
pointer-events: none; border-radius: 4px; font-size: 1.2em; text-shadow: 0 0 5px #000;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div x-data="app()" x-init="initApp()" class="d-flex"
|
||||||
|
@mousemove.window="handleGlobalMove($event)"
|
||||||
|
@mouseup.window="handleGlobalUp($event)">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<div class="sidebar d-flex flex-column">
|
||||||
|
<h5 class="mb-4 text-primary fw-bold"><i class="fas fa-microchip me-2"></i>EdgeAI Monitor</h5>
|
||||||
|
|
||||||
|
<div class="mb-3 flex-grow-1 overflow-auto">
|
||||||
|
<label class="small text-muted mb-2 text-uppercase fw-bold" style="font-size: 0.7em; letter-spacing: 1px;">Cameras</label>
|
||||||
|
<div x-show="cameras.length === 0" class="text-muted small text-center py-4">
|
||||||
|
<i class="fas fa-spinner fa-spin mb-2"></i><br>Loading...
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template x-for="cam in cameras" :key="cam.id">
|
||||||
|
<div class="camera-item"
|
||||||
|
:class="{'active': currentCamera && currentCamera.id === cam.id}"
|
||||||
|
@click="selectCamera(cam)">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-1">
|
||||||
|
<span class="fw-bold text-white" x-text="cam.name"></span>
|
||||||
|
<span class="status-badge status-online">LIVE</span>
|
||||||
|
</div>
|
||||||
|
<div class="small text-muted font-monospace" x-text="cam.id" style="font-size: 0.8em"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-auto pt-3 border-top border-secondary">
|
||||||
|
<div class="d-grid gap-2">
|
||||||
|
<button class="btn btn-outline-secondary btn-sm text-light" @click="loadROIs()" :disabled="!currentCamera">
|
||||||
|
<i class="fas fa-sync me-2"></i>Reload ROIs
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-primary btn-sm" @click="saveROIs()" :disabled="!currentCamera">
|
||||||
|
<i class="fas fa-save me-2"></i>Save Config
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="main-content flex-grow-1">
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<div x-show="!currentCamera" class="h-100 d-flex flex-column justify-content-center align-items-center text-secondary">
|
||||||
|
<i class="fas fa-video fa-3x mb-3"></i>
|
||||||
|
<h3>Select a Camera</h3>
|
||||||
|
<p>Choose a stream from the sidebar to begin.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Camera View -->
|
||||||
|
<div x-show="currentCamera" class="d-flex flex-column h-100">
|
||||||
|
|
||||||
|
<!-- 1. Top Toolbar -->
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="d-flex align-items-center gap-3">
|
||||||
|
<h5 class="m-0 text-white" x-text="currentCamera ? currentCamera.name : ''"></h5>
|
||||||
|
|
||||||
|
<!-- Flip Controls -->
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button class="btn btn-outline-secondary" @click="rotateFlip('none')" title="Normal"><i class="fas fa-arrow-up"></i></button>
|
||||||
|
<button class="btn btn-outline-secondary" @click="rotateFlip('horizontal')" title="Flip Horizontal"><i class="fas fa-arrows-alt-h"></i></button>
|
||||||
|
<button class="btn btn-outline-secondary" @click="rotateFlip('vertical')" title="Flip Vertical"><i class="fas fa-arrows-alt-v"></i></button>
|
||||||
|
<button class="btn btn-outline-secondary" @click="rotateFlip('both')" title="Flip Both"><i class="fas fa-compress-arrows-alt"></i></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Aspect Ratio Toggle -->
|
||||||
|
<div class="form-check form-switch ms-3 text-white small m-0">
|
||||||
|
<input class="form-check-input" type="checkbox" id="lockRatio" x-model="lockRatio">
|
||||||
|
<label class="form-check-label" for="lockRatio">Lock 20:32</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<span class="badge bg-secondary me-3">ROIs: <span x-text="rois.length"></span></span>
|
||||||
|
|
||||||
|
<button class="btn btn-outline-light btn-sm me-2" @click="takeSnapshot()">
|
||||||
|
<i class="fas fa-camera"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-success btn-sm" @click="testDetection()">
|
||||||
|
<i class="fas fa-play me-2"></i>Test
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 2. Debug Panel (Top Position) -->
|
||||||
|
<div class="debug-panel">
|
||||||
|
<button class="btn btn-link btn-sm text-secondary p-0 text-decoration-none" @click="showDebug = !showDebug">
|
||||||
|
<i class="fas" :class="showDebug ? 'fa-chevron-down' : 'fa-chevron-right'"></i> Debug JSON
|
||||||
|
</button>
|
||||||
|
<div x-show="showDebug" class="mt-2">
|
||||||
|
<pre class="m-0 text-info" x-text="getDebugJSON()"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 3. Video Stage (Fills Rest) -->
|
||||||
|
<div class="video-stage">
|
||||||
|
<div class="video-wrapper"
|
||||||
|
x-ref="videoWrapper"
|
||||||
|
@mousedown="startDraw($event)">
|
||||||
|
|
||||||
|
<img :src="videoUrl" id="video-feed"
|
||||||
|
x-ref="videoImg"
|
||||||
|
@load="onImageLoad">
|
||||||
|
|
||||||
|
<div id="roi-overlay">
|
||||||
|
<template x-for="(roi, index) in rois" :key="roi.id">
|
||||||
|
<div class="roi-box"
|
||||||
|
:class="{'selected': selectedRoiId === roi.id}"
|
||||||
|
:style="`left: ${roi.x}px; top: ${roi.y}px; width: ${roi.width}px; height: ${roi.height}px; transform: rotate(${roi.angle || 0}deg);`"
|
||||||
|
@mousedown.stop="startMove(roi.id, $event)">
|
||||||
|
|
||||||
|
<div class="delete-roi" @mousedown.stop="deleteRoi(index)"><i class="fas fa-times"></i></div>
|
||||||
|
<div class="resize-handle" @mousedown.stop="startResize(index, $event)"></div>
|
||||||
|
<div class="rotate-handle" @mousedown.stop="startRotate(index, $event)"></div>
|
||||||
|
|
||||||
|
<div class="detection-badge" x-show="roi.lastValue !== undefined" x-text="roi.lastValue" :style="`transform: rotate(-${roi.angle || 0}deg);`"></div>
|
||||||
|
<div class="position-absolute top-0 start-0 text-white bg-primary px-1" style="font-size:10px" x-text="index+1"></div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<div x-show="interactionMode === 'drawing'" class="roi-box"
|
||||||
|
style="border-style: dashed; pointer-events: none;"
|
||||||
|
:style="`left: ${drawBox.x}px; top: ${drawBox.y}px; width: ${drawBox.w}px; height: ${drawBox.h}px;`">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/toastify-js"></script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('alpine:init', () => {
|
||||||
|
Alpine.data('app', () => ({
|
||||||
|
cameras: [],
|
||||||
|
currentCamera: null,
|
||||||
|
videoUrl: '',
|
||||||
|
rois: [],
|
||||||
|
selectedRoiId: null,
|
||||||
|
showDebug: false,
|
||||||
|
|
||||||
|
// Interaction State
|
||||||
|
interactionMode: null, // 'drawing', 'moving', 'resizing', 'rotating'
|
||||||
|
activeRoiIndex: -1,
|
||||||
|
dragStart: {x:0, y:0},
|
||||||
|
drawStart: {x:0, y:0},
|
||||||
|
drawBox: {x:0, y:0, w:0, h:0},
|
||||||
|
lockRatio: true,
|
||||||
|
imgReady: false,
|
||||||
|
|
||||||
|
async initApp() { await this.fetchCameras(); },
|
||||||
|
async fetchCameras() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/cameras');
|
||||||
|
this.cameras = await res.json();
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
selectCamera(cam) {
|
||||||
|
this.currentCamera = cam;
|
||||||
|
this.rois = [];
|
||||||
|
this.imgReady = false; // Reset on switch
|
||||||
|
this.videoUrl = `/video/${cam.id}?t=${Date.now()}`;
|
||||||
|
},
|
||||||
|
onImageLoad(e) {
|
||||||
|
const w = e.target.clientWidth;
|
||||||
|
const h = e.target.clientHeight;
|
||||||
|
this.imgReady = true; // <--- TRIGGER UPDATE
|
||||||
|
if(this.currentCamera) this.loadROIs(w, h);
|
||||||
|
},
|
||||||
|
async loadROIs(w, h) {
|
||||||
|
if(!w || !h) {
|
||||||
|
const img = this.$refs.videoImg;
|
||||||
|
if(img) { w=img.clientWidth; h=img.clientHeight; } else { w=640; h=360; }
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/rois/${this.currentCamera.id}?img_width=${w}&img_height=${h}`);
|
||||||
|
const data = await res.json();
|
||||||
|
if(Array.isArray(data)) {
|
||||||
|
this.rois = data.map(r => ({
|
||||||
|
id: r.id || Date.now().toString(),
|
||||||
|
x: Number(r.x), y: Number(r.y), width: Number(r.width), height: Number(r.height),
|
||||||
|
angle: Number(r.angle || 0), lastValue: undefined
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
async saveROIs() {
|
||||||
|
const img = this.$refs.videoImg;
|
||||||
|
if(!img) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/save_rois', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({
|
||||||
|
camera_id: this.currentCamera.id,
|
||||||
|
rois: this.rois,
|
||||||
|
img_width: img.clientWidth,
|
||||||
|
img_height: img.clientHeight
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const d = await res.json();
|
||||||
|
if(d.success) this.showToast("Saved!", "success");
|
||||||
|
else this.showToast("Save Failed: " + d.error, "error");
|
||||||
|
} catch(e) { this.showToast("Network Error", "error"); }
|
||||||
|
},
|
||||||
|
async rotateFlip(type) {
|
||||||
|
await fetch('/update_camera_config', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({camera_id: this.currentCamera.id, flip_type: type})});
|
||||||
|
this.selectCamera(this.currentCamera);
|
||||||
|
},
|
||||||
|
async takeSnapshot() { if(this.currentCamera) window.open(`/snapshot/${this.currentCamera.id}`, '_blank'); },
|
||||||
|
async testDetection() {
|
||||||
|
this.showToast("Scanning...", "info");
|
||||||
|
try {
|
||||||
|
const res = await fetch('/detect_digits', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify({camera_id: this.currentCamera.id})});
|
||||||
|
const d = await res.json();
|
||||||
|
if(d.detected_digits) {
|
||||||
|
d.detected_digits.forEach((val, i) => { if(this.rois[i]) this.rois[i].lastValue = val; });
|
||||||
|
this.showToast(`Read: ${d.final_number}`, "success");
|
||||||
|
} else if(d.error) { this.showToast(d.error, "error"); }
|
||||||
|
} catch(e) {}
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- COMPUTED DEBUG ---
|
||||||
|
getDebugJSON() {
|
||||||
|
if(!this.imgReady) return "Loading image data...";
|
||||||
|
|
||||||
|
const img = this.$refs.videoImg;
|
||||||
|
if(!img || !img.naturalWidth) return "Waiting for stream...";
|
||||||
|
|
||||||
|
const scaleX = img.naturalWidth / img.clientWidth;
|
||||||
|
const scaleY = img.naturalHeight / img.clientHeight;
|
||||||
|
|
||||||
|
const report = this.rois.map(r => ({
|
||||||
|
id: r.id,
|
||||||
|
display: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) },
|
||||||
|
real: {
|
||||||
|
x: Math.round(r.x * scaleX),
|
||||||
|
y: Math.round(r.y * scaleY),
|
||||||
|
w: Math.round(r.width * scaleX),
|
||||||
|
h: Math.round(r.height * scaleY)
|
||||||
|
},
|
||||||
|
angle: Math.round(r.angle || 0)
|
||||||
|
}));
|
||||||
|
return JSON.stringify(report, null, 2);
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- INTERACTION ---
|
||||||
|
getRelPos(e) {
|
||||||
|
const rect = this.$refs.videoWrapper.getBoundingClientRect();
|
||||||
|
return { x: e.clientX - rect.left, y: e.clientY - rect.top };
|
||||||
|
},
|
||||||
|
startDraw(e) {
|
||||||
|
const pos = this.getRelPos(e);
|
||||||
|
this.interactionMode = 'drawing';
|
||||||
|
this.drawStart = pos;
|
||||||
|
this.drawBox = {x: pos.x, y: pos.y, w:0, h:0};
|
||||||
|
this.selectedRoiId = null;
|
||||||
|
},
|
||||||
|
startMove(id, e) {
|
||||||
|
this.selectedRoiId = id;
|
||||||
|
this.activeRoiIndex = this.rois.findIndex(r => r.id === id);
|
||||||
|
this.interactionMode = 'moving';
|
||||||
|
this.dragStart = {x: e.clientX, y: e.clientY};
|
||||||
|
},
|
||||||
|
startResize(index, e) {
|
||||||
|
this.activeRoiIndex = index;
|
||||||
|
this.interactionMode = 'resizing';
|
||||||
|
this.dragStart = {x: e.clientX, y: e.clientY};
|
||||||
|
},
|
||||||
|
startRotate(index, e) {
|
||||||
|
this.activeRoiIndex = index;
|
||||||
|
this.interactionMode = 'rotating';
|
||||||
|
this.dragStart = {x: e.clientX, y: e.clientY};
|
||||||
|
},
|
||||||
|
handleGlobalMove(e) {
|
||||||
|
if (!this.interactionMode) return;
|
||||||
|
|
||||||
|
if (this.interactionMode === 'drawing') {
|
||||||
|
const pos = this.getRelPos(e);
|
||||||
|
let w = pos.x - this.drawStart.x;
|
||||||
|
let h = pos.y - this.drawStart.y;
|
||||||
|
|
||||||
|
if(this.lockRatio) {
|
||||||
|
const signW = Math.sign(w) || 1;
|
||||||
|
const signH = Math.sign(h) || 1;
|
||||||
|
const absW = Math.abs(w);
|
||||||
|
const targetRatio = 20/32;
|
||||||
|
w = absW * signW;
|
||||||
|
h = (absW / targetRatio) * signH;
|
||||||
|
}
|
||||||
|
this.drawBox = { x: w<0?pos.x:this.drawStart.x, y: h<0?pos.y:this.drawStart.y, w: Math.abs(w), h: Math.abs(h) };
|
||||||
|
}
|
||||||
|
else if (this.interactionMode === 'moving' && this.activeRoiIndex > -1) {
|
||||||
|
const dx = e.clientX - this.dragStart.x;
|
||||||
|
const dy = e.clientY - this.dragStart.y;
|
||||||
|
const roi = this.rois[this.activeRoiIndex];
|
||||||
|
roi.x += dx; roi.y += dy;
|
||||||
|
this.dragStart = {x: e.clientX, y: e.clientY};
|
||||||
|
}
|
||||||
|
else if (this.interactionMode === 'resizing' && this.activeRoiIndex > -1) {
|
||||||
|
const dx = e.clientX - this.dragStart.x;
|
||||||
|
const dy = e.clientY - this.dragStart.y;
|
||||||
|
const roi = this.rois[this.activeRoiIndex];
|
||||||
|
let newW = Math.max(10, roi.width + dx);
|
||||||
|
let newH = Math.max(10, roi.height + dy);
|
||||||
|
|
||||||
|
if(this.lockRatio) {
|
||||||
|
const targetRatio = 20/32;
|
||||||
|
newH = newW / targetRatio;
|
||||||
|
}
|
||||||
|
roi.width = newW; roi.height = newH;
|
||||||
|
this.dragStart = {x: e.clientX, y: e.clientY};
|
||||||
|
}
|
||||||
|
else if (this.interactionMode === 'rotating' && this.activeRoiIndex > -1) {
|
||||||
|
const dx = e.clientX - this.dragStart.x;
|
||||||
|
const roi = this.rois[this.activeRoiIndex];
|
||||||
|
roi.angle = (roi.angle || 0) + (dx * 0.5);
|
||||||
|
this.dragStart = {x: e.clientX, y: e.clientY};
|
||||||
|
}
|
||||||
|
},
|
||||||
|
handleGlobalUp(e) {
|
||||||
|
if (this.interactionMode === 'drawing' && this.drawBox.w > 10 && this.drawBox.h > 10) {
|
||||||
|
this.rois.push({
|
||||||
|
id: Date.now().toString(),
|
||||||
|
x: Math.round(this.drawBox.x), y: Math.round(this.drawBox.y),
|
||||||
|
width: Math.round(this.drawBox.w), height: Math.round(this.drawBox.h),
|
||||||
|
angle: 0
|
||||||
|
});
|
||||||
|
}
|
||||||
|
this.interactionMode = null;
|
||||||
|
this.activeRoiIndex = -1;
|
||||||
|
},
|
||||||
|
deleteRoi(index) { if(confirm("Delete ROI?")) this.rois.splice(index, 1); },
|
||||||
|
showToast(msg, type="info") { Toastify({ text: msg, duration: 2000, style: { background: type=="error"?"#dc3545":"#198754" } }).showToast(); }
|
||||||
|
}));
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Reference in New Issue