mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-01-31 18:11:54 +08:00
进一步移植:能够打包 exe 运行
This commit is contained in:
@@ -3,9 +3,9 @@ import threading
|
||||
import time
|
||||
import json
|
||||
from collections import deque
|
||||
from typing import List, Optional, Tuple, Union, Dict, Any
|
||||
from typing import List, Optional, Tuple, Dict
|
||||
import uuid
|
||||
|
||||
import aiohttp
|
||||
import cv2
|
||||
import logging
|
||||
import numpy as np
|
||||
@@ -13,8 +13,7 @@ from aiohttp import MultipartWriter, web
|
||||
from aiohttp.web_runner import GracefulExit
|
||||
|
||||
class MjpegStream:
|
||||
"""MJPEG video stream class for handling video frames and providing HTTP streaming service"""
|
||||
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
@@ -26,19 +25,7 @@ class MjpegStream:
|
||||
device_name: str = "Unknown Camera",
|
||||
log_requests: bool = True
|
||||
) -> None:
|
||||
"""
|
||||
Initialize MJPEG stream
|
||||
|
||||
Args:
|
||||
name: Stream name
|
||||
size: Video size (width, height)
|
||||
quality: JPEG compression quality (1-100)
|
||||
fps: Target frame rate
|
||||
host: Server host address
|
||||
port: Server port
|
||||
device_name: Camera device name
|
||||
log_requests: Whether to log stream requests
|
||||
"""
|
||||
|
||||
self.name = name.lower().replace(" ", "_")
|
||||
self.size = size
|
||||
self.quality = max(1, min(quality, 100))
|
||||
@@ -48,53 +35,58 @@ class MjpegStream:
|
||||
self._device_name = device_name
|
||||
self.log_requests = log_requests
|
||||
|
||||
# Video frame and synchronization
|
||||
self._frame = np.zeros((320, 240, 1), dtype=np.uint8)
|
||||
self._lock = asyncio.Lock()
|
||||
self._byte_frame_window = deque(maxlen=30)
|
||||
self._bandwidth_last_modified_time = time.time()
|
||||
self._is_online = True
|
||||
self._last_frame_time = time.time()
|
||||
self._last_repeat_frame_time = time.time()
|
||||
self._last_fps_update_time = time.time()
|
||||
self._last_frame_data = None
|
||||
self.per_second_fps = 0
|
||||
self.frame_counter = 0
|
||||
|
||||
|
||||
# 设置日志级别为ERROR,以隐藏HTTP请求日志
|
||||
if not self.log_requests:
|
||||
logging.getLogger('aiohttp.access').setLevel(logging.ERROR)
|
||||
|
||||
# Server setup
|
||||
self._app = web.Application()
|
||||
self._app.router.add_route("GET", f"/{self.name}", self._stream_handler)
|
||||
self._app.router.add_route("GET", "/state", self._state_handler)
|
||||
self._app.router.add_route("GET", "/", self._index_handler)
|
||||
self._app.router.add_route("GET", "/snapshot", self._snapshot_handler)
|
||||
self._app.is_running = False
|
||||
self._clients: Dict[str, Dict] = {}
|
||||
self._clients_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def set_frame(self, frame: np.ndarray) -> None:
|
||||
"""Set the current video frame"""
|
||||
self._frame = frame
|
||||
self._last_frame_time = time.time()
|
||||
self._is_online = True
|
||||
|
||||
def get_bandwidth(self) -> float:
|
||||
"""Get current bandwidth usage (bytes/second)"""
|
||||
if time.time() - self._bandwidth_last_modified_time >= 1:
|
||||
self._byte_frame_window.clear()
|
||||
return sum(self._byte_frame_window)
|
||||
|
||||
async def _process_frame(self) -> Tuple[np.ndarray, Dict[str, str]]:
|
||||
"""Process video frame (resize and JPEG encode)"""
|
||||
frame = cv2.resize(
|
||||
self._frame, self.size or (self._frame.shape[1], self._frame.shape[0])
|
||||
)
|
||||
success, encoded = cv2.imencode(
|
||||
".jpg", frame, [cv2.IMWRITE_JPEG_QUALITY, self.quality]
|
||||
)
|
||||
|
||||
if not success:
|
||||
raise ValueError("Error encoding frame")
|
||||
|
||||
self._byte_frame_window.append(len(encoded.tobytes()))
|
||||
self._bandwidth_last_modified_time = time.time()
|
||||
|
||||
current_frame_data = encoded.tobytes()
|
||||
current_time = time.time()
|
||||
|
||||
if current_frame_data == self._last_frame_data and current_time - self._last_repeat_frame_time < 1:
|
||||
return None, {}
|
||||
else:
|
||||
self._last_frame_data = current_frame_data
|
||||
self._last_repeat_frame_time = current_time
|
||||
|
||||
# Add KVMD-compatible header information
|
||||
if current_time - self._last_fps_update_time >= 1:
|
||||
self.per_second_fps = self.frame_counter
|
||||
self.frame_counter = 0
|
||||
self._last_fps_update_time = current_time
|
||||
|
||||
self.frame_counter += 1
|
||||
headers = {
|
||||
"X-UStreamer-Online": str(self._is_online).lower(),
|
||||
"X-UStreamer-Width": str(frame.shape[1]),
|
||||
@@ -109,61 +101,69 @@ class MjpegStream:
|
||||
return encoded, headers
|
||||
|
||||
async def _stream_handler(self, request: web.Request) -> web.StreamResponse:
|
||||
"""Handle MJPEG stream requests"""
|
||||
client_id = request.query.get("client_id", uuid.uuid4().hex[:8])
|
||||
client_key = request.query.get("key", "0")
|
||||
advance_headers = request.query.get("advance_headers", "0") == "1"
|
||||
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
reason="OK",
|
||||
headers={"Content-Type": "multipart/x-mixed-replace;boundary=frame"}
|
||||
headers={
|
||||
"Content-Type": "multipart/x-mixed-replace;boundary=frame",
|
||||
"Set-Cookie": f"stream_client={client_key}/{client_id}; Path=/; Max-Age=30"
|
||||
}
|
||||
)
|
||||
await response.prepare(request)
|
||||
|
||||
if self.log_requests:
|
||||
print(f"Stream request received: {request.path}")
|
||||
async with self._clients_lock:
|
||||
if client_id not in self._clients:
|
||||
self._clients[client_id] = {
|
||||
"key": client_key,
|
||||
"advance_headers": advance_headers,
|
||||
"extra_headers": False,
|
||||
"zero_data": False,
|
||||
"fps": 0,
|
||||
}
|
||||
|
||||
try:
|
||||
while True:
|
||||
async with self._lock:
|
||||
frame, headers = await self._process_frame()
|
||||
if frame is None:
|
||||
continue
|
||||
|
||||
#Enable workaround for the Chromium/Blink bug https://issues.chromium.org/issues/41199053
|
||||
if advance_headers:
|
||||
headers.pop('Content-Length', None)
|
||||
for k in list(headers.keys()):
|
||||
if k.startswith('X-UStreamer-'):
|
||||
del headers[k]
|
||||
|
||||
with MultipartWriter("image/jpeg", boundary="frame") as mpwriter:
|
||||
part = mpwriter.append(frame.tobytes(), {"Content-Type": "image/jpeg"})
|
||||
for key, value in headers.items():
|
||||
part.headers[key] = value
|
||||
try:
|
||||
await mpwriter.write(response, close_boundary=False)
|
||||
except (ConnectionResetError, ConnectionAbortedError):
|
||||
return web.Response(status=499)
|
||||
await response.write(b"\r\n")
|
||||
self._clients[client_id]["fps"]=self.per_second_fps
|
||||
finally:
|
||||
async with self._clients_lock:
|
||||
if client_id in self._clients:
|
||||
del self._clients[client_id]
|
||||
|
||||
while True:
|
||||
await asyncio.sleep(1 / self.fps)
|
||||
|
||||
# Check if the device is online
|
||||
if time.time() - self._last_frame_time > 5:
|
||||
self._is_online = False
|
||||
|
||||
async with self._lock:
|
||||
frame, headers = await self._process_frame()
|
||||
|
||||
with MultipartWriter("image/jpeg", boundary="frame") as mpwriter:
|
||||
part = mpwriter.append(frame.tobytes(), {"Content-Type": "image/jpeg"})
|
||||
for key, value in headers.items():
|
||||
part.headers[key] = value
|
||||
try:
|
||||
await mpwriter.write(response, close_boundary=False)
|
||||
except (ConnectionResetError, ConnectionAbortedError):
|
||||
return web.Response(status=499)
|
||||
await response.write(b"\r\n")
|
||||
|
||||
async def _state_handler(self, request: web.Request) -> web.Response:
|
||||
"""Handle /state requests and return device status information"""
|
||||
state = {
|
||||
"ok": "true",
|
||||
"result": {
|
||||
"instance_id": "",
|
||||
"encoder": {
|
||||
"type": "CPU",
|
||||
"quality": self.quality
|
||||
},
|
||||
"h264": {
|
||||
"bitrate": 4875,
|
||||
"gop": 60,
|
||||
"online": self._is_online,
|
||||
"fps": self.fps
|
||||
},
|
||||
"sinks": {
|
||||
"jpeg": {
|
||||
"has_clients": False
|
||||
},
|
||||
"h264": {
|
||||
"has_clients": False
|
||||
}
|
||||
},
|
||||
"source": {
|
||||
"resolution": {
|
||||
"width": self.size[0] if self.size else self._frame.shape[1],
|
||||
@@ -171,21 +171,12 @@ class MjpegStream:
|
||||
},
|
||||
"online": self._is_online,
|
||||
"desired_fps": self.fps,
|
||||
"captured_fps": 0 # You can update this with actual captured fps if needed
|
||||
"captured_fps": self.fps
|
||||
},
|
||||
"stream": {
|
||||
"queued_fps": 2, # Placeholder value, update as needed
|
||||
"clients": 1, # Placeholder value, update as needed
|
||||
"clients_stat": {
|
||||
"70bf63a507f71e47": {
|
||||
"fps": 2, # Placeholder value, update as needed
|
||||
"extra_headers": False,
|
||||
"advance_headers": True,
|
||||
"dual_final_frames": False,
|
||||
"zero_data": False,
|
||||
"key": "tIR9TtuedKIzDYZa" # Placeholder key, update as needed
|
||||
}
|
||||
}
|
||||
"queued_fps": self.fps,
|
||||
"clients": len(self._clients),
|
||||
"clients_stat": self._clients
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -195,18 +186,30 @@ class MjpegStream:
|
||||
)
|
||||
|
||||
async def _index_handler(self, _: web.Request) -> web.Response:
|
||||
"""Handle root path requests and display available streams"""
|
||||
html = f"""
|
||||
<h2>Available Video Streams:</h2>
|
||||
<ul>
|
||||
<li><a href='http://{self._host}:{self._port}/{self.name}'>/{self.name}</a></li>
|
||||
<li><a href='http://{self._host}:{self._port}/state'>/state</a></li>
|
||||
<html>
|
||||
<head><meta charset="utf-8"><title>uStreamer-Win</title><style>body {{font-family: monospace;}}</style></head>
|
||||
<body>
|
||||
<h3>uStreamer-Win v0.01 </h3>
|
||||
<ul><hr>
|
||||
<li><a href='http://{self._host}:{self._port}/{self.name}'>/{self.name}</a>
|
||||
<br>Get a live stream. </li><hr><br>
|
||||
<li><a href='http://{self._host}:{self._port}/snapshot'>/snapshot</a>
|
||||
<br>Get a current actual image from the server.</li><hr><br>
|
||||
<li><a href='http://{self._host}:{self._port}/state'>/state</a>
|
||||
<br>Get JSON structure with the state of the server.</li><hr><br>
|
||||
</ul>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return web.Response(text=html, content_type="text/html")
|
||||
|
||||
async def _snapshot_handler(self, request: web.Request) -> web.Response:
|
||||
async with self._lock:
|
||||
frame, _ = await self._process_frame()
|
||||
return web.Response(body=frame.tobytes(), content_type="image/jpeg")
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start the stream server"""
|
||||
if not self._app.is_running:
|
||||
threading.Thread(target=self._run_server, daemon=True).start()
|
||||
self._app.is_running = True
|
||||
@@ -214,8 +217,8 @@ class MjpegStream:
|
||||
else:
|
||||
print("\nServer is already running\n")
|
||||
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop the stream server"""
|
||||
if self._app.is_running:
|
||||
self._app.is_running = False
|
||||
print("\nStopping server...\n")
|
||||
@@ -223,7 +226,6 @@ class MjpegStream:
|
||||
print("\nServer is not running\n")
|
||||
|
||||
def _run_server(self) -> None:
|
||||
"""Run the server in a new thread"""
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
runner = web.AppRunner(self._app)
|
||||
|
||||
@@ -41,7 +41,6 @@ def test_camera(index, logger):
|
||||
return False
|
||||
|
||||
def find_camera_by_name(camera_name, logger):
|
||||
"""Find device index by camera name"""
|
||||
if platform.system() != "Windows":
|
||||
logger.warning("Finding camera by name is only supported on Windows")
|
||||
return None
|
||||
@@ -57,7 +56,6 @@ def find_camera_by_name(camera_name, logger):
|
||||
return None
|
||||
|
||||
def get_first_available_camera(logger):
|
||||
"""Get the first available camera"""
|
||||
for i in range(5):
|
||||
if test_camera(i, logger):
|
||||
return i
|
||||
@@ -75,13 +73,10 @@ def parse_arguments():
|
||||
parser.add_argument('--port', type=int, default=8000, help='Server port')
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate arguments
|
||||
if args.quality < 1 or args.quality > 100:
|
||||
raise ValueError("Quality must be between 1 and 100.")
|
||||
if args.fps <= 0:
|
||||
raise ValueError("FPS must be greater than 0.")
|
||||
|
||||
# Parse resolution
|
||||
try:
|
||||
width, height = map(int, args.resolution.split('x'))
|
||||
except ValueError:
|
||||
@@ -95,14 +90,9 @@ def parse_arguments():
|
||||
def main():
|
||||
logger = configure_logging()
|
||||
args = parse_arguments()
|
||||
|
||||
# Determine which camera device to use
|
||||
device_index = None
|
||||
|
||||
if args.device_name:
|
||||
if platform.system() != "Windows":
|
||||
logger.error("Specifying camera by name is only supported on Windows")
|
||||
return
|
||||
device_index = find_camera_by_name(args.device_name, logger)
|
||||
if device_index is None:
|
||||
logger.error(f"No available camera found with a name containing '{args.device_name}'")
|
||||
@@ -122,23 +112,21 @@ def main():
|
||||
|
||||
# Initialize the camera
|
||||
try:
|
||||
cap = cv2.VideoCapture(device_index, cv2.CAP_DSHOW if platform.system() == "Windows" else cv2.CAP_ANY)
|
||||
cap = cv2.VideoCapture(device_index, cv2.CAP_DSHOW)
|
||||
|
||||
if not cap.isOpened():
|
||||
logger.error(f"Unable to open camera {device_index}")
|
||||
return
|
||||
|
||||
# Set camera parameters
|
||||
cap.set(cv2.CAP_PROP_FRAME_WIDTH, args.width)
|
||||
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, args.height)
|
||||
|
||||
# Verify camera settings
|
||||
cap.set(cv2.CAP_PROP_FRAME_COUNT, args.fps)
|
||||
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc('M','J','P','G'))
|
||||
actual_width = cap.get(cv2.CAP_PROP_FRAME_WIDTH)
|
||||
actual_height = cap.get(cv2.CAP_PROP_FRAME_HEIGHT)
|
||||
if actual_width != args.width or actual_height != args.height:
|
||||
logger.warning(f"Actual resolution ({actual_width}x{actual_height}) does not match requested resolution ({args.width}x{args.height})")
|
||||
|
||||
# Test if we can read frames
|
||||
ret, _ = cap.read()
|
||||
if not ret:
|
||||
logger.error("Unable to read video frames from the camera")
|
||||
@@ -155,13 +143,13 @@ def main():
|
||||
try:
|
||||
stream = MjpegStream(
|
||||
name="stream",
|
||||
size=(int(actual_width), int(actual_height)), # Use actual resolution
|
||||
size=(int(actual_width), int(actual_height)),
|
||||
quality=args.quality,
|
||||
fps=args.fps,
|
||||
host=args.host,
|
||||
port=args.port,
|
||||
device_name=args.device_name or f"Camera {device_index}", # Add device name
|
||||
log_requests=False # 设置为False以隐藏HTTP请求日志
|
||||
device_name=args.device_name or f"Camera {device_index}",
|
||||
log_requests=False
|
||||
)
|
||||
stream.start()
|
||||
logger.info(f"Video stream started: http://{args.host}:{args.port}/stream")
|
||||
@@ -176,20 +164,11 @@ def main():
|
||||
|
||||
except KeyboardInterrupt:
|
||||
logger.info("User interrupt")
|
||||
except Exception as e:
|
||||
logger.error(f"An error occurred: {str(e)}")
|
||||
finally:
|
||||
logger.info("Cleaning up resources...")
|
||||
try:
|
||||
stream.stop()
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping the video stream: {str(e)}")
|
||||
try:
|
||||
cap.release()
|
||||
except Exception as e:
|
||||
logger.error(f"Error releasing the camera: {str(e)}")
|
||||
stream.stop()
|
||||
cap.release()
|
||||
cv2.destroyAllWindows()
|
||||
logger.info("Program has exited")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user