You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

128 lines
4.5 KiB
Python

import argparse
import json
import os
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
import cv2
CAMERA_LOCK = threading.Lock()
def clamp_query(query, name, default, minimum, maximum):
try:
value = int(query.get(name, [default])[0])
except (TypeError, ValueError):
value = default
return max(minimum, min(maximum, value))
def open_camera(index, width, height, fps, attempts=10):
backend = cv2.CAP_DSHOW if os.name == "nt" else cv2.CAP_ANY
for _ in range(max(1, attempts)):
cap = cv2.VideoCapture(index, backend)
cap.set(cv2.CAP_PROP_FOURCC, cv2.VideoWriter_fourcc(*"MJPG"))
cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
cap.set(cv2.CAP_PROP_FPS, fps)
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
ok, frame = cap.read()
if cap.isOpened() and ok and frame is not None:
return cap, frame
cap.release()
time.sleep(0.5)
return None, None
class Handler(BaseHTTPRequestHandler):
server_version = "HDMIUSBBridge/1.0"
def do_GET(self):
parsed = urlparse(self.path)
if parsed.path == "/health":
body = json.dumps({"ok": True, "pid": os.getpid()}).encode("utf-8")
self.send_response(200)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
return
if parsed.path != "/stream.mjpg":
self.send_error(404)
return
if not CAMERA_LOCK.acquire(blocking=False):
self.send_error(409, "camera is already in use")
return
query = parse_qs(parsed.query)
index = clamp_query(query, "index", 0, 0, 16)
width = clamp_query(query, "width", 1920, 160, 3840)
height = clamp_query(query, "height", 1080, 120, 2160)
fps = clamp_query(query, "fps", 30, 1, 120)
quality = clamp_query(query, "quality", 85, 40, 95)
cap, frame = open_camera(index, width, height, fps)
try:
if cap is None:
self.send_error(503, f"camera {index} did not return a frame")
return
self.send_response(200)
self.send_header("Content-Type", "multipart/x-mixed-replace; boundary=frame")
self.send_header("Cache-Control", "no-store")
self.end_headers()
interval = 1.0 / fps
next_frame_at = time.perf_counter()
read_failures = 0
while True:
now = time.perf_counter()
if now < next_frame_at:
time.sleep(next_frame_at - now)
elif now - next_frame_at > 3.0 * interval:
next_frame_at = now
next_frame_at += interval
encoded, jpeg = cv2.imencode(
".jpg",
frame,
[cv2.IMWRITE_JPEG_QUALITY, quality],
)
if encoded:
payload = jpeg.tobytes()
self.wfile.write(b"--frame\r\n")
self.wfile.write(b"Content-Type: image/jpeg\r\n")
self.wfile.write(f"Content-Length: {len(payload)}\r\n\r\n".encode("ascii"))
self.wfile.write(payload)
self.wfile.write(b"\r\n")
ok, next_frame = cap.read()
if ok and next_frame is not None:
frame = next_frame
read_failures = 0
else:
read_failures += 1
if read_failures >= 60:
break
except (BrokenPipeError, ConnectionAbortedError, ConnectionResetError):
pass
finally:
if cap is not None:
cap.release()
CAMERA_LOCK.release()
def log_message(self, format_text, *args):
print(f"[hdmi-bridge] {self.address_string()} {format_text % args}", flush=True)
def main():
parser = argparse.ArgumentParser(description="Windows HDMI USB to MJPEG bridge")
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8091)
args = parser.parse_args()
print(f"HDMI USB bridge listening on http://{args.host}:{args.port}", flush=True)
ThreadingHTTPServer((args.host, args.port), Handler).serve_forever()
if __name__ == "__main__":
main()