mirror of
https://github.com/mofeng-git/One-KVM.git
synced 2026-01-30 09:31:53 +08:00
初步适配windows系统
This commit is contained in:
233
ustreamer-win/mjpeg_stream.py
Normal file
233
ustreamer-win/mjpeg_stream.py
Normal file
@@ -0,0 +1,233 @@
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
import json
|
||||
from collections import deque
|
||||
from typing import List, Optional, Tuple, Union, Dict, Any
|
||||
|
||||
import aiohttp
|
||||
import cv2
|
||||
import logging
|
||||
import numpy as np
|
||||
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,
|
||||
size: Optional[Tuple[int, int]] = None,
|
||||
quality: int = 50,
|
||||
fps: int = 30,
|
||||
host: str = "localhost",
|
||||
port: int = 8000,
|
||||
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))
|
||||
self.fps = fps
|
||||
self._host = host
|
||||
self._port = port
|
||||
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()
|
||||
|
||||
|
||||
# 设置日志级别为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.is_running = False
|
||||
|
||||
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()
|
||||
|
||||
# Add KVMD-compatible header information
|
||||
headers = {
|
||||
"X-UStreamer-Online": str(self._is_online).lower(),
|
||||
"X-UStreamer-Width": str(frame.shape[1]),
|
||||
"X-UStreamer-Height": str(frame.shape[0]),
|
||||
"X-UStreamer-Name": self._device_name,
|
||||
"X-Timestamp": str(int(time.time() * 1000)),
|
||||
"Cache-Control": "no-store",
|
||||
"Pragma": "no-cache",
|
||||
"Expires": "0",
|
||||
}
|
||||
|
||||
return encoded, headers
|
||||
|
||||
async def _stream_handler(self, request: web.Request) -> web.StreamResponse:
|
||||
"""Handle MJPEG stream requests"""
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
reason="OK",
|
||||
headers={"Content-Type": "multipart/x-mixed-replace;boundary=frame"}
|
||||
)
|
||||
await response.prepare(request)
|
||||
|
||||
if self.log_requests:
|
||||
print(f"Stream request received: {request.path}")
|
||||
|
||||
|
||||
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 = {
|
||||
"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],
|
||||
"height": self.size[1] if self.size else self._frame.shape[0]
|
||||
},
|
||||
"online": self._is_online,
|
||||
"desired_fps": self.fps,
|
||||
"captured_fps": 0 # You can update this with actual captured fps if needed
|
||||
},
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return web.Response(
|
||||
text=json.dumps(state),
|
||||
content_type="application/json"
|
||||
)
|
||||
|
||||
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>
|
||||
</ul>
|
||||
"""
|
||||
return web.Response(text=html, content_type="text/html")
|
||||
|
||||
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
|
||||
print(f"\nVideo stream URL: http://{self._host}:{self._port}/{self.name}")
|
||||
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")
|
||||
raise GracefulExit()
|
||||
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)
|
||||
loop.run_until_complete(runner.setup())
|
||||
site = web.TCPSite(runner, self._host, self._port)
|
||||
loop.run_until_complete(site.start())
|
||||
loop.run_forever()
|
||||
197
ustreamer-win/ustreamer-win.py
Normal file
197
ustreamer-win/ustreamer-win.py
Normal file
@@ -0,0 +1,197 @@
|
||||
import argparse
|
||||
import cv2
|
||||
import logging
|
||||
import platform
|
||||
from mjpeg_stream import MjpegStream
|
||||
|
||||
def configure_logging():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
def get_windows_cameras(logger):
|
||||
"""Retrieve available camera devices on Windows system"""
|
||||
from win32com.client import Dispatch
|
||||
devices = []
|
||||
try:
|
||||
wmi = Dispatch("WbemScripting.SWbemLocator")
|
||||
service = wmi.ConnectServer(".", "root\\cimv2")
|
||||
items = service.ExecQuery("SELECT * FROM Win32_PnPEntity WHERE (PNPClass = 'Image' OR PNPClass = 'Camera')")
|
||||
|
||||
for item in items:
|
||||
devices.append({
|
||||
'name': item.Name,
|
||||
'device_id': item.DeviceID
|
||||
})
|
||||
logger.info(f"Found camera device: {item.Name}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error enumerating camera devices: {str(e)}")
|
||||
return devices
|
||||
|
||||
def test_camera(index, logger):
|
||||
"""Test if the camera is available"""
|
||||
try:
|
||||
cap = cv2.VideoCapture(index, cv2.CAP_DSHOW if platform.system() == "Windows" else cv2.CAP_ANY)
|
||||
if cap.isOpened():
|
||||
ret, _ = cap.read()
|
||||
cap.release()
|
||||
return ret
|
||||
except Exception as e:
|
||||
logger.debug(f"Error testing camera {index}: {str(e)}")
|
||||
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
|
||||
|
||||
devices = get_windows_cameras(logger)
|
||||
for device in devices:
|
||||
if camera_name.lower() in device['name'].lower():
|
||||
# Try to find an available index
|
||||
for i in range(5): # Usually no more than 5 devices
|
||||
if test_camera(i, logger):
|
||||
logger.info(f"Found matching camera '{device['name']}' at index {i}")
|
||||
return i
|
||||
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
|
||||
return None
|
||||
|
||||
def parse_arguments():
|
||||
parser = argparse.ArgumentParser(description='MJPEG Stream Demonstration')
|
||||
device_group = parser.add_mutually_exclusive_group()
|
||||
device_group.add_argument('--device', type=int, help='Camera device index')
|
||||
device_group.add_argument('--device-name', type=str, help='Camera device name (only supported on Windows)')
|
||||
parser.add_argument('--resolution', type=str, default='640x480', help='Video resolution (e.g., 640x480)')
|
||||
parser.add_argument('--quality', type=int, default=100, help='JPEG quality (1-100)')
|
||||
parser.add_argument('--fps', type=int, default=30, help='Target FPS')
|
||||
parser.add_argument('--host', type=str, default='localhost', help='Server address')
|
||||
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:
|
||||
raise ValueError("Resolution must be in the format WIDTHxHEIGHT (e.g., 640x480).")
|
||||
|
||||
args.width = width
|
||||
args.height = height
|
||||
|
||||
return args
|
||||
|
||||
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}'")
|
||||
return
|
||||
elif args.device is not None:
|
||||
if test_camera(args.device, logger):
|
||||
device_index = args.device
|
||||
else:
|
||||
logger.warning(f"The specified device index {args.device} is not available")
|
||||
|
||||
if device_index is None:
|
||||
device_index = get_first_available_camera(logger)
|
||||
if device_index is None:
|
||||
logger.error("No available camera devices were found")
|
||||
return
|
||||
logger.info(f"Using the first available camera device (index: {device_index})")
|
||||
|
||||
# Initialize the camera
|
||||
try:
|
||||
cap = cv2.VideoCapture(device_index, cv2.CAP_DSHOW if platform.system() == "Windows" else cv2.CAP_ANY)
|
||||
|
||||
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
|
||||
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")
|
||||
cap.release()
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error initializing the camera: {str(e)}")
|
||||
if 'cap' in locals():
|
||||
cap.release()
|
||||
return
|
||||
|
||||
# Create and start the video stream
|
||||
try:
|
||||
stream = MjpegStream(
|
||||
name="stream",
|
||||
size=(int(actual_width), int(actual_height)), # Use actual resolution
|
||||
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请求日志
|
||||
)
|
||||
stream.start()
|
||||
logger.info(f"Video stream started: http://{args.host}:{args.port}/stream")
|
||||
|
||||
while True:
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
logger.error("Unable to read video frames")
|
||||
break
|
||||
|
||||
stream.set_frame(frame)
|
||||
|
||||
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)}")
|
||||
cv2.destroyAllWindows()
|
||||
logger.info("Program has exited")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user