初步适配windows系统

This commit is contained in:
mofeng-git
2025-02-03 12:55:28 +08:00
parent 4f5daebf93
commit ddb4d752c0
24 changed files with 567 additions and 153 deletions

View 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()

View 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()