ptzsim 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ptzsim/__init__.py +6 -0
- ptzsim/__main__.py +8 -0
- ptzsim/cli.py +221 -0
- ptzsim/display.py +102 -0
- ptzsim/ndi.py +285 -0
- ptzsim/onvif.py +1410 -0
- ptzsim/state.py +250 -0
- ptzsim/transports.py +223 -0
- ptzsim/tui.py +315 -0
- ptzsim/viewfinder.py +96 -0
- ptzsim/visca.py +499 -0
- ptzsim-0.2.0.dist-info/METADATA +420 -0
- ptzsim-0.2.0.dist-info/RECORD +17 -0
- ptzsim-0.2.0.dist-info/WHEEL +5 -0
- ptzsim-0.2.0.dist-info/entry_points.txt +2 -0
- ptzsim-0.2.0.dist-info/licenses/LICENSE +21 -0
- ptzsim-0.2.0.dist-info/top_level.txt +1 -0
ptzsim/__init__.py
ADDED
ptzsim/__main__.py
ADDED
ptzsim/cli.py
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Stephan Strittmatter
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Command line entry point: ``ptzsim serve`` and ``ptzsim send``."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import asyncio
|
|
10
|
+
import logging
|
|
11
|
+
import socket
|
|
12
|
+
import sys
|
|
13
|
+
from typing import List, Optional
|
|
14
|
+
|
|
15
|
+
from .display import dashboard, log_printer
|
|
16
|
+
from .transports import (SONY_HEADER, SONY_VISCA_COMMAND, SONY_VISCA_INQUIRY,
|
|
17
|
+
Simulator, serve_pty, serve_tcp, serve_udp)
|
|
18
|
+
from .onvif import OnvifCamera, serve_discovery, serve_onvif
|
|
19
|
+
from .visca import ViscaCamera, hexdump, parse_hex
|
|
20
|
+
|
|
21
|
+
DEFAULT_PORTS = {"sony": 52381, "tcp": 5678, "udp": 1259, "onvif": 8080}
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
25
|
+
parser = argparse.ArgumentParser(prog="ptzsim", description="PTZ camera simulator (ONVIF, VISCA and NDI)")
|
|
26
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
27
|
+
|
|
28
|
+
serve = sub.add_parser("serve", help="run a simulated camera")
|
|
29
|
+
serve.add_argument("--host", default="0.0.0.0", help="address to listen on (default 0.0.0.0)")
|
|
30
|
+
serve.add_argument("--sony", type=int, metavar="PORT", nargs="?", const=DEFAULT_PORTS["sony"],
|
|
31
|
+
help="Sony VISCA over IP on UDP (default port 52381)")
|
|
32
|
+
serve.add_argument("--tcp", type=int, metavar="PORT", nargs="?", const=DEFAULT_PORTS["tcp"],
|
|
33
|
+
help="raw VISCA over TCP, e.g. PTZOptics (default port 5678)")
|
|
34
|
+
serve.add_argument("--udp", type=int, metavar="PORT", nargs="?", const=DEFAULT_PORTS["udp"],
|
|
35
|
+
help="raw VISCA over UDP (default port 1259)")
|
|
36
|
+
serve.add_argument("--pty", nargs="?", const="", metavar="LINK",
|
|
37
|
+
help="emulate a serial port via a pseudo terminal; optional symlink path")
|
|
38
|
+
serve.add_argument("--onvif", type=int, metavar="PORT", nargs="?", const=DEFAULT_PORTS["onvif"],
|
|
39
|
+
help="ONVIF (SOAP over HTTP) with WS-Discovery (default port 8080)")
|
|
40
|
+
serve.add_argument("--onvif-user", metavar="USER", help="require authentication (see --onvif-auth) with this user")
|
|
41
|
+
serve.add_argument("--onvif-password", metavar="PASSWORD", default="", help="password for --onvif-user")
|
|
42
|
+
serve.add_argument("--ndi", action="store_true",
|
|
43
|
+
help="publish an NDI source with PTZ control (needs: pip install ndi-python numpy)")
|
|
44
|
+
serve.add_argument("--ndi-fps", type=int, default=25, metavar="FPS", help="NDI frame rate (default 25)")
|
|
45
|
+
serve.add_argument("--name", default="PTZ-Simulator", help="camera name (ONVIF scope/profile, NDI source name)")
|
|
46
|
+
serve.add_argument("--no-discovery", action="store_true", help="do not answer WS-Discovery probes")
|
|
47
|
+
serve.add_argument("--advertise-host", metavar="IP",
|
|
48
|
+
help="address announced in WS-Discovery (default: detected per request)")
|
|
49
|
+
serve.add_argument("--onvif-auth", choices=("both", "digest", "wsse"), default="both",
|
|
50
|
+
help="ONVIF authentication: HTTP digest and/or WS-UsernameToken (default both)")
|
|
51
|
+
serve.add_argument("--onvif-no-absolute", action="store_true",
|
|
52
|
+
help="simulate a camera without AbsoluteMove (presets only via SetPreset/GotoPreset)")
|
|
53
|
+
serve.add_argument("--rtsp-uri", metavar="URI", help="stream URI returned by ONVIF GetStreamUri")
|
|
54
|
+
serve.add_argument("--address", type=int, default=1, choices=range(1, 8),
|
|
55
|
+
help="VISCA camera address (default 1)")
|
|
56
|
+
serve.add_argument("--instant", action="store_true",
|
|
57
|
+
help="jump to target positions instead of moving at motor speed")
|
|
58
|
+
serve.add_argument("--no-tui", action="store_true", help="print one line per command instead of a dashboard")
|
|
59
|
+
serve.add_argument("--view-only", action="store_true",
|
|
60
|
+
help="show the dashboard without keyboard control")
|
|
61
|
+
serve.add_argument("-v", "--verbose", action="store_true", help="log every packet sent and received")
|
|
62
|
+
|
|
63
|
+
send = sub.add_parser("send", help="send VISCA hex packets to a camera and print the replies")
|
|
64
|
+
send.add_argument("packets", nargs="+", help='VISCA packets as hex, e.g. "81 01 06 04 FF"')
|
|
65
|
+
send.add_argument("--host", default="127.0.0.1")
|
|
66
|
+
send.add_argument("--port", type=int)
|
|
67
|
+
send.add_argument("--proto", choices=("sony", "tcp", "udp"), default="sony")
|
|
68
|
+
send.add_argument("--timeout", type=float, default=2.0, help="seconds to wait for replies")
|
|
69
|
+
return parser
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def _serve(args: argparse.Namespace) -> None:
|
|
73
|
+
camera = ViscaCamera(address=args.address, instant=args.instant)
|
|
74
|
+
sim = Simulator(camera)
|
|
75
|
+
endpoints: List[str] = []
|
|
76
|
+
discovery = None
|
|
77
|
+
|
|
78
|
+
if all(v is None for v in (args.sony, args.tcp, args.udp, args.pty, args.onvif)) and not args.ndi:
|
|
79
|
+
args.sony = DEFAULT_PORTS["sony"]
|
|
80
|
+
if args.sony is not None:
|
|
81
|
+
await serve_udp(sim, args.host, args.sony, sony=True)
|
|
82
|
+
endpoints.append(f"Sony VISCA/IP udp://{args.host}:{args.sony}")
|
|
83
|
+
if args.tcp is not None:
|
|
84
|
+
await serve_tcp(sim, args.host, args.tcp)
|
|
85
|
+
endpoints.append(f"VISCA tcp://{args.host}:{args.tcp}")
|
|
86
|
+
if args.udp is not None:
|
|
87
|
+
await serve_udp(sim, args.host, args.udp, sony=False)
|
|
88
|
+
endpoints.append(f"VISCA udp://{args.host}:{args.udp}")
|
|
89
|
+
if args.pty is not None:
|
|
90
|
+
endpoints.append(f"VISCA serial {serve_pty(sim, args.pty or None)}")
|
|
91
|
+
if args.onvif is not None:
|
|
92
|
+
onvif = OnvifCamera(camera, name=args.name, username=args.onvif_user,
|
|
93
|
+
password=args.onvif_password, rtsp_uri=args.rtsp_uri,
|
|
94
|
+
absolute=not args.onvif_no_absolute, auth=args.onvif_auth)
|
|
95
|
+
await serve_onvif(onvif, args.host, args.onvif)
|
|
96
|
+
endpoints.append(f"ONVIF http://{args.host}:{args.onvif}/onvif/device_service")
|
|
97
|
+
if not args.no_discovery:
|
|
98
|
+
discovery = await serve_discovery(onvif, args.onvif, advertise_host=args.advertise_host)
|
|
99
|
+
endpoints.append("WS-Discovery udp/3702")
|
|
100
|
+
if args.ndi:
|
|
101
|
+
from .ndi import start_ndi
|
|
102
|
+
|
|
103
|
+
source = start_ndi(camera, args.name, args.ndi_fps)
|
|
104
|
+
endpoints.append(f"NDI {source.source_name}")
|
|
105
|
+
|
|
106
|
+
title = " | ".join(endpoints)
|
|
107
|
+
logging.getLogger("ptzsim").info("listening: %s", title)
|
|
108
|
+
if args.no_tui:
|
|
109
|
+
view = log_printer(camera)
|
|
110
|
+
elif args.view_only or not _interactive_terminal():
|
|
111
|
+
view = dashboard(camera, title)
|
|
112
|
+
else:
|
|
113
|
+
from .tui import interactive
|
|
114
|
+
|
|
115
|
+
view = interactive(camera, title)
|
|
116
|
+
tasks = [asyncio.ensure_future(sim.run()), asyncio.ensure_future(view)]
|
|
117
|
+
try:
|
|
118
|
+
done, pending = await asyncio.wait(tasks, return_when=asyncio.FIRST_COMPLETED)
|
|
119
|
+
for task in pending:
|
|
120
|
+
task.cancel()
|
|
121
|
+
for task in done:
|
|
122
|
+
task.result() # re-raise errors from the simulator or the view
|
|
123
|
+
finally:
|
|
124
|
+
if discovery is not None:
|
|
125
|
+
discovery.get_protocol().bye() # WS-Discovery Bye when leaving the network
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _interactive_terminal() -> bool:
|
|
129
|
+
"""The keyboard UI needs curses (not on Windows) and a terminal on stdin and stdout."""
|
|
130
|
+
try:
|
|
131
|
+
import curses # noqa: F401
|
|
132
|
+
except ImportError:
|
|
133
|
+
return False
|
|
134
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def send_packets(packets: List[bytes], host: str, port: int, proto: str, timeout: float) -> List[bytes]:
|
|
138
|
+
"""Send packets to a camera and return all replies received (helper for manual tests)."""
|
|
139
|
+
replies: List[bytes] = []
|
|
140
|
+
if proto == "tcp":
|
|
141
|
+
with socket.create_connection((host, port), timeout=timeout) as sock:
|
|
142
|
+
for packet in packets:
|
|
143
|
+
sock.sendall(packet)
|
|
144
|
+
replies += _collect_stream(sock, timeout)
|
|
145
|
+
return replies
|
|
146
|
+
|
|
147
|
+
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
|
148
|
+
sock.settimeout(timeout)
|
|
149
|
+
for seq, packet in enumerate(packets, start=1):
|
|
150
|
+
if proto == "sony":
|
|
151
|
+
kind = SONY_VISCA_INQUIRY if len(packet) > 1 and packet[1] == 0x09 else SONY_VISCA_COMMAND
|
|
152
|
+
packet = SONY_HEADER.pack(kind, len(packet), seq) + packet
|
|
153
|
+
sock.sendto(packet, (host, port))
|
|
154
|
+
replies += _collect_datagrams(sock, proto == "sony")
|
|
155
|
+
return replies
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _collect_stream(sock: socket.socket, timeout: float) -> List[bytes]:
|
|
159
|
+
buffer = bytearray()
|
|
160
|
+
replies: List[bytes] = []
|
|
161
|
+
while True:
|
|
162
|
+
try:
|
|
163
|
+
data = sock.recv(1024)
|
|
164
|
+
except socket.timeout:
|
|
165
|
+
break
|
|
166
|
+
if not data:
|
|
167
|
+
break
|
|
168
|
+
buffer.extend(data)
|
|
169
|
+
while 0xFF in buffer:
|
|
170
|
+
end = buffer.index(0xFF)
|
|
171
|
+
replies.append(bytes(buffer[: end + 1]))
|
|
172
|
+
del buffer[: end + 1]
|
|
173
|
+
if replies and _is_final(replies[-1]):
|
|
174
|
+
break
|
|
175
|
+
return replies
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _collect_datagrams(sock: socket.socket, sony: bool) -> List[bytes]:
|
|
179
|
+
replies: List[bytes] = []
|
|
180
|
+
while True:
|
|
181
|
+
try:
|
|
182
|
+
data, _ = sock.recvfrom(1024)
|
|
183
|
+
except socket.timeout:
|
|
184
|
+
break
|
|
185
|
+
if sony:
|
|
186
|
+
data = data[SONY_HEADER.size:]
|
|
187
|
+
replies.append(data)
|
|
188
|
+
if _is_final(data):
|
|
189
|
+
break
|
|
190
|
+
return replies
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def _is_final(reply: bytes) -> bool:
|
|
194
|
+
"""Completion, inquiry answer or error ends a request; an ACK (y0 4z) does not."""
|
|
195
|
+
return len(reply) >= 2 and reply[1] & 0xF0 in (0x50, 0x60) or reply[:1] == b"\x88"
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
199
|
+
args = build_parser().parse_args(argv)
|
|
200
|
+
if args.command == "send":
|
|
201
|
+
port = args.port or DEFAULT_PORTS[args.proto]
|
|
202
|
+
packets = [parse_hex(p) for p in args.packets]
|
|
203
|
+
for reply in send_packets(packets, args.host, port, args.proto, args.timeout):
|
|
204
|
+
print(hexdump(reply))
|
|
205
|
+
return 0
|
|
206
|
+
|
|
207
|
+
level = logging.DEBUG if args.verbose else logging.INFO
|
|
208
|
+
logging.basicConfig(level=level, format="%(asctime)s %(levelname)s %(message)s",
|
|
209
|
+
stream=sys.stderr if args.no_tui else open("ptzsim.log", "a"))
|
|
210
|
+
try:
|
|
211
|
+
asyncio.run(_serve(args))
|
|
212
|
+
except KeyboardInterrupt:
|
|
213
|
+
pass
|
|
214
|
+
except (RuntimeError, OSError) as err:
|
|
215
|
+
print(f"ptzsim: {err}", file=sys.stderr)
|
|
216
|
+
return 1
|
|
217
|
+
return 0
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
if __name__ == "__main__":
|
|
221
|
+
sys.exit(main())
|
ptzsim/display.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Stephan Strittmatter
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""Terminal dashboard showing the simulated camera."""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
import sys
|
|
10
|
+
from typing import List
|
|
11
|
+
|
|
12
|
+
from . import state as st
|
|
13
|
+
from .visca import ViscaCamera
|
|
14
|
+
|
|
15
|
+
CLEAR = "\x1b[H\x1b[2J"
|
|
16
|
+
HIDE_CURSOR, SHOW_CURSOR = "\x1b[?25l", "\x1b[?25h"
|
|
17
|
+
GREEN, RED, DIM, BOLD, RESET = "\x1b[32m", "\x1b[31m", "\x1b[2m", "\x1b[1m", "\x1b[0m"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def bar(value: float, low: float, high: float, width: int = 30, center: bool = False) -> str:
|
|
21
|
+
pos = round((value - low) / (high - low) * (width - 1))
|
|
22
|
+
cells = ["─"] * width
|
|
23
|
+
if center:
|
|
24
|
+
zero = round((0 - low) / (high - low) * (width - 1))
|
|
25
|
+
cells[zero] = "┼"
|
|
26
|
+
cells[max(0, min(width - 1, pos))] = "●"
|
|
27
|
+
return "[" + "".join(cells) + "]"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def render(camera: ViscaCamera, title: str, color: bool = True, log_lines: int = 10) -> str:
|
|
31
|
+
s = camera.state
|
|
32
|
+
g, r, d, b, x = (GREEN, RED, DIM, BOLD, RESET) if color else ("",) * 5
|
|
33
|
+
pan, tilt, zoom, focus = s.position()
|
|
34
|
+
|
|
35
|
+
def motion(velocity: float, target) -> str:
|
|
36
|
+
if target is not None:
|
|
37
|
+
return f"{g}→ {round(target[0])}{x}"
|
|
38
|
+
if velocity:
|
|
39
|
+
return f"{g}{'▶' if velocity > 0 else '◀'} {abs(velocity):.0f}/s{x}"
|
|
40
|
+
return f"{d}idle{x}"
|
|
41
|
+
|
|
42
|
+
lines: List[str] = [
|
|
43
|
+
f"{b}PTZ camera simulator{x} {d}{title}{x}",
|
|
44
|
+
f"Power: {g + 'ON' if s.power else r + 'STANDBY'}{x} "
|
|
45
|
+
f"Address: {camera.address} Commands: {camera.command_count}",
|
|
46
|
+
"",
|
|
47
|
+
f"Pan {pan:6d} {s.pan_degrees:+7.1f}° "
|
|
48
|
+
f"{bar(s.pan, st.PAN_MIN, st.PAN_MAX, center=True)} {motion(s.pan_velocity, s.pan_target)}",
|
|
49
|
+
f"Tilt {tilt:6d} {s.tilt_degrees:+7.1f}° "
|
|
50
|
+
f"{bar(s.tilt, st.TILT_MIN, st.TILT_MAX, center=True)} {motion(s.tilt_velocity, s.tilt_target)}",
|
|
51
|
+
f"Zoom {zoom:#06x} {s.zoom_ratio:7.1f}x "
|
|
52
|
+
f"{bar(s.zoom, st.ZOOM_MIN, st.ZOOM_MAX)} {motion(s.zoom_velocity, s.zoom_target)}",
|
|
53
|
+
f"Focus {focus:#06x} {'AF' if s.autofocus else 'MF':>8} "
|
|
54
|
+
f"{bar(s.focus, st.FOCUS_MIN, st.FOCUS_MAX)} {motion(s.focus_velocity, s.focus_target)}",
|
|
55
|
+
f"White balance {s.white_balance:<12} R {s.red_gain:#04x} B {s.blue_gain:#04x}",
|
|
56
|
+
"",
|
|
57
|
+
]
|
|
58
|
+
if s.presets:
|
|
59
|
+
lines.append(f"{b}Presets{x}")
|
|
60
|
+
for number, p in sorted(s.presets.items()):
|
|
61
|
+
marker = "*" if number == s.last_preset else " "
|
|
62
|
+
lines.append(f" {marker}{number + 1:3d}: pan {p.pan:6d} tilt {p.tilt:6d} "
|
|
63
|
+
f"zoom {p.zoom:#06x} focus {p.focus:#06x}"
|
|
64
|
+
+ (f" {p.name}" if p.name else ""))
|
|
65
|
+
else:
|
|
66
|
+
lines.append(f"{d}No presets stored{x}")
|
|
67
|
+
lines += ["", f"{b}Last commands{x}"]
|
|
68
|
+
entries = list(camera.log)[-log_lines:]
|
|
69
|
+
if not entries:
|
|
70
|
+
lines.append(f"{d}(waiting for commands){x}")
|
|
71
|
+
for entry in entries:
|
|
72
|
+
c = "" if entry.ok else r
|
|
73
|
+
lines.append(f" {d}{entry.source:<44}{x} {c}{entry.description}{x}")
|
|
74
|
+
lines += ["", f"{d}Ctrl+C to quit{x}"]
|
|
75
|
+
return "\n".join(lines)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
async def dashboard(camera: ViscaCamera, title: str, interval: float = 0.1) -> None:
|
|
79
|
+
out = sys.stdout
|
|
80
|
+
out.write(HIDE_CURSOR)
|
|
81
|
+
try:
|
|
82
|
+
while True:
|
|
83
|
+
out.write(CLEAR + render(camera, title) + "\n")
|
|
84
|
+
out.flush()
|
|
85
|
+
await asyncio.sleep(interval)
|
|
86
|
+
finally:
|
|
87
|
+
out.write(SHOW_CURSOR)
|
|
88
|
+
out.flush()
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
async def log_printer(camera: ViscaCamera, interval: float = 0.1) -> None:
|
|
92
|
+
"""Plain line output for non-interactive use (``--no-tui``)."""
|
|
93
|
+
seen = 0
|
|
94
|
+
while True:
|
|
95
|
+
total = camera.command_count
|
|
96
|
+
new = list(camera.log)[-(total - seen):] if total > seen else []
|
|
97
|
+
seen = total
|
|
98
|
+
for entry in new:
|
|
99
|
+
pan, tilt, zoom, focus = camera.state.position()
|
|
100
|
+
print(f"{entry.source:<44} {entry.description:<36} "
|
|
101
|
+
f"pan={pan} tilt={tilt} zoom={zoom:#06x}", flush=True)
|
|
102
|
+
await asyncio.sleep(interval)
|
ptzsim/ndi.py
ADDED
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
# SPDX-FileCopyrightText: 2026 Stephan Strittmatter
|
|
2
|
+
# SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
"""NDI PTZ camera: an NDI source with PTZ support and a rendered test picture.
|
|
5
|
+
|
|
6
|
+
NDI receivers send PTZ commands as XML metadata (``<ntk_ptz_pan_tilt .../>`` and
|
|
7
|
+
friends) back to the sender. :class:`NdiPtzController` turns those into camera
|
|
8
|
+
moves; it is plain Python. :class:`NdiSource` needs the NDI runtime via the
|
|
9
|
+
``ndi-python`` package (``pip install ndi-python numpy``).
|
|
10
|
+
|
|
11
|
+
Value ranges follow the NDI SDK (Processing.NDI.Recv.ex.h):
|
|
12
|
+
pan -1 (left) .. +1 (right) pan_speed -1 (moving right) .. +1 (moving left)
|
|
13
|
+
tilt -1 (bottom) .. +1 (top) tilt_speed -1 (down) .. +1 (up)
|
|
14
|
+
zoom 0 (zoomed in) .. 1 (out) zoom_speed -1 (outwards) .. +1 (inwards)
|
|
15
|
+
focus 0 (infinity) .. 1 (close) presets 0 .. 99, recall speed 0 .. 1
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import logging
|
|
22
|
+
import socket
|
|
23
|
+
import threading
|
|
24
|
+
import xml.etree.ElementTree as ET
|
|
25
|
+
from typing import Callable, Optional
|
|
26
|
+
|
|
27
|
+
from . import state as st
|
|
28
|
+
from .onvif import tilt_from_onvif
|
|
29
|
+
from .visca import ViscaCamera
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger("ptzsim")
|
|
32
|
+
|
|
33
|
+
# Connection metadata a sender sends so receivers report PTZ support.
|
|
34
|
+
CAPABILITIES = ('<ndi_capabilities ntk_ptz="true" ntk_pan_tilt="true" ntk_zoom="true" '
|
|
35
|
+
'ntk_focus="true" ntk_autofocus="true" ntk_white_balance="true" '
|
|
36
|
+
'ntk_preset_speed="true"/>')
|
|
37
|
+
PRESET_RANGE = range(0, 100) # NDI SDK: "The preset number is in the range 0 to 99"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class NdiPtzController:
|
|
41
|
+
"""Applies NDI PTZ metadata messages to the simulated camera."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, camera: ViscaCamera):
|
|
44
|
+
self.camera = camera
|
|
45
|
+
|
|
46
|
+
@property
|
|
47
|
+
def state(self) -> st.CameraState:
|
|
48
|
+
return self.camera.state
|
|
49
|
+
|
|
50
|
+
def handle(self, xml: str) -> bool:
|
|
51
|
+
"""Handle one metadata frame. Returns False if it was not understood."""
|
|
52
|
+
try:
|
|
53
|
+
elem = ET.fromstring(xml)
|
|
54
|
+
except ET.ParseError:
|
|
55
|
+
self.camera.record("NDI", f"invalid metadata {xml[:40]!r}", ok=False)
|
|
56
|
+
return False
|
|
57
|
+
handler = getattr(self, "_" + elem.tag, None)
|
|
58
|
+
if handler is None or not elem.tag.startswith("ntk_ptz"):
|
|
59
|
+
self.camera.record("NDI", f"unsupported metadata <{elem.tag}>", ok=False)
|
|
60
|
+
return False
|
|
61
|
+
try:
|
|
62
|
+
description = handler(elem.attrib)
|
|
63
|
+
except (KeyError, ValueError) as err:
|
|
64
|
+
self.camera.record("NDI", f"<{elem.tag}> bad attribute: {err}", ok=False)
|
|
65
|
+
return False
|
|
66
|
+
if self.camera.instant:
|
|
67
|
+
self.state.finish_moves()
|
|
68
|
+
self.camera.record("NDI", description)
|
|
69
|
+
return True
|
|
70
|
+
|
|
71
|
+
@staticmethod
|
|
72
|
+
def _num(attrs, name: str, low: float, high: float) -> float:
|
|
73
|
+
return st.clamp(float(attrs[name]), low, high)
|
|
74
|
+
|
|
75
|
+
# ------------------------------------------------------------ pan/tilt
|
|
76
|
+
def _ntk_ptz_pan_tilt(self, a) -> str:
|
|
77
|
+
pan, tilt = self._num(a, "pan", -1, 1), self._num(a, "tilt", -1, 1)
|
|
78
|
+
self.state.move_pan_tilt_to(pan * st.PAN_MAX, tilt_from_onvif(tilt),
|
|
79
|
+
st.PAN_SPEED_MAX, st.TILT_SPEED_MAX)
|
|
80
|
+
return f"pan/tilt to {pan:+.2f} {tilt:+.2f}"
|
|
81
|
+
|
|
82
|
+
def _ntk_ptz_pan_tilt_speed(self, a) -> str:
|
|
83
|
+
pan, tilt = self._num(a, "pan_speed", -1, 1), self._num(a, "tilt_speed", -1, 1)
|
|
84
|
+
s = self.state
|
|
85
|
+
s.pan_target = s.tilt_target = None
|
|
86
|
+
# NDI: positive pan speed moves left, i.e. towards negative pan.
|
|
87
|
+
s.pan_velocity = -pan * st.PAN_SPEED_MAX * st.PAN_TILT_UNITS_PER_SPEED_STEP
|
|
88
|
+
s.tilt_velocity = tilt * st.TILT_SPEED_MAX * st.PAN_TILT_UNITS_PER_SPEED_STEP
|
|
89
|
+
return "pan/tilt stop" if pan == tilt == 0 else f"pan/tilt speed {pan:+.2f} {tilt:+.2f}"
|
|
90
|
+
|
|
91
|
+
# ---------------------------------------------------------------- zoom
|
|
92
|
+
def _ntk_ptz_zoom(self, a) -> str:
|
|
93
|
+
zoom = self._num(a, "zoom", 0, 1)
|
|
94
|
+
self.state.zoom_to(st.ZOOM_MIN + (1 - zoom) * (st.ZOOM_MAX - st.ZOOM_MIN))
|
|
95
|
+
return f"zoom to {zoom:.2f}"
|
|
96
|
+
|
|
97
|
+
def _ntk_ptz_zoom_speed(self, a) -> str:
|
|
98
|
+
speed = self._num(a, "zoom_speed", -1, 1)
|
|
99
|
+
self.state.zoom_target = None
|
|
100
|
+
self.state.zoom_velocity = speed * 8 * st.ZOOM_UNITS_PER_SPEED_STEP
|
|
101
|
+
return "zoom stop" if speed == 0 else f"zoom speed {speed:+.2f}"
|
|
102
|
+
|
|
103
|
+
# --------------------------------------------------------------- focus
|
|
104
|
+
def _ntk_ptz_focus(self, a) -> str:
|
|
105
|
+
if a.get("mode") == "auto":
|
|
106
|
+
self.state.autofocus = True
|
|
107
|
+
return "auto focus"
|
|
108
|
+
distance = self._num(a, "distance", 0, 1)
|
|
109
|
+
self.state.autofocus = False
|
|
110
|
+
self.state.focus_to(st.FOCUS_MIN + distance * (st.FOCUS_MAX - st.FOCUS_MIN))
|
|
111
|
+
return f"focus to {distance:.2f}"
|
|
112
|
+
|
|
113
|
+
def _ntk_ptz_focus_speed(self, a) -> str:
|
|
114
|
+
speed = self._num(a, "distance", -1, 1)
|
|
115
|
+
self.state.autofocus = False
|
|
116
|
+
self.state.focus_target = None
|
|
117
|
+
self.state.focus_velocity = speed * 8 * st.FOCUS_UNITS_PER_SPEED_STEP
|
|
118
|
+
return "focus stop" if speed == 0 else f"focus speed {speed:+.2f}"
|
|
119
|
+
|
|
120
|
+
# ------------------------------------------------------------- presets
|
|
121
|
+
def _preset(self, a) -> int:
|
|
122
|
+
number = int(a["index"])
|
|
123
|
+
if number not in PRESET_RANGE:
|
|
124
|
+
raise ValueError(f"preset {number} out of the NDI range 0..99")
|
|
125
|
+
if number >= st.PRESET_COUNT:
|
|
126
|
+
raise ValueError(f"preset {number}: the camera has {st.PRESET_COUNT} presets")
|
|
127
|
+
return number
|
|
128
|
+
|
|
129
|
+
def _ntk_ptz_store_preset(self, a) -> str:
|
|
130
|
+
number = self._preset(a)
|
|
131
|
+
self.state.save_preset(number)
|
|
132
|
+
return f"store preset {number}"
|
|
133
|
+
|
|
134
|
+
def _ntk_ptz_recall_preset(self, a) -> str:
|
|
135
|
+
number = self._preset(a)
|
|
136
|
+
speed = self._num(a, "speed", 0, 1) if "speed" in a else 1.0
|
|
137
|
+
if not self.state.recall_preset(number, speed):
|
|
138
|
+
raise ValueError(f"preset {number} not set")
|
|
139
|
+
return f"recall preset {number} (speed {speed:.2f})"
|
|
140
|
+
|
|
141
|
+
# ------------------------------------------- accepted but not simulated
|
|
142
|
+
def _ignored(self, name: str, a) -> str:
|
|
143
|
+
detail = " ".join(f"{k}={v}" for k, v in a.items())
|
|
144
|
+
return f"{name} {detail} (not simulated)".replace(" ", " ")
|
|
145
|
+
|
|
146
|
+
def _ntk_ptz_white_balance(self, a) -> str:
|
|
147
|
+
mode = a.get("mode")
|
|
148
|
+
if mode in ("auto", "indoor", "outdoor"):
|
|
149
|
+
self.state.set_white_balance(mode)
|
|
150
|
+
return f"white balance {mode}"
|
|
151
|
+
if mode == "one_shot":
|
|
152
|
+
self.state.one_push_white_balance()
|
|
153
|
+
return "white balance one shot"
|
|
154
|
+
if mode == "manual":
|
|
155
|
+
red, blue = self._num(a, "red", 0, 1), self._num(a, "blue", 0, 1)
|
|
156
|
+
self.state.set_white_balance("manual")
|
|
157
|
+
self.state.set_gains(round(red * 256), round(blue * 256)) # NDI 0.5 is neutral (0x80)
|
|
158
|
+
return f"white balance manual red {red:.2f} blue {blue:.2f}"
|
|
159
|
+
return self._ignored("white balance", a)
|
|
160
|
+
|
|
161
|
+
def _ntk_ptz_exposure(self, a) -> str:
|
|
162
|
+
return self._ignored("exposure", a)
|
|
163
|
+
|
|
164
|
+
def _ntk_ptz_iris(self, a) -> str:
|
|
165
|
+
return self._ignored("iris", a)
|
|
166
|
+
|
|
167
|
+
def _ntk_ptz_flip(self, a) -> str:
|
|
168
|
+
return self._ignored("flip", a)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------- picture
|
|
172
|
+
class SceneRenderer:
|
|
173
|
+
"""Draws what the camera "sees": a panorama grid that follows pan/tilt/zoom."""
|
|
174
|
+
|
|
175
|
+
BASE_FOV = 60.0 # horizontal field of view at 1x, degrees
|
|
176
|
+
|
|
177
|
+
def __init__(self, width: int = 640, height: int = 360):
|
|
178
|
+
import numpy as np
|
|
179
|
+
|
|
180
|
+
self.np = np
|
|
181
|
+
self.width, self.height = width, height
|
|
182
|
+
self.u = (np.arange(width, dtype=np.float32) / width - 0.5)[None, :]
|
|
183
|
+
self.v = (np.arange(height, dtype=np.float32) / height - 0.5)[:, None]
|
|
184
|
+
|
|
185
|
+
def render(self, s: st.CameraState):
|
|
186
|
+
np = self.np
|
|
187
|
+
fov = self.BASE_FOV / s.zoom_ratio
|
|
188
|
+
az = s.pan_degrees + self.u * fov
|
|
189
|
+
el = s.tilt_degrees - self.v * fov * self.height / self.width
|
|
190
|
+
checker = ((np.floor(az / 5) + np.floor(el / 5)) % 2).astype(np.float32)
|
|
191
|
+
sky = el > 0
|
|
192
|
+
frame = np.empty((self.height, self.width, 4), dtype=np.uint8)
|
|
193
|
+
# BGRX: sky blue-ish, ground green-ish, checker pattern every 5 degrees.
|
|
194
|
+
frame[..., 0] = np.where(sky, 150 + 60 * checker, 40 + 30 * checker)
|
|
195
|
+
frame[..., 1] = np.where(sky, 90 + 40 * checker, 110 + 50 * checker)
|
|
196
|
+
frame[..., 2] = np.where(sky, 40 + 20 * checker, 50 + 30 * checker)
|
|
197
|
+
frame[..., 3] = 255
|
|
198
|
+
line = fov / self.width * 1.5
|
|
199
|
+
# Fine 1-degree grid once zoomed in far enough to see it.
|
|
200
|
+
if fov / self.width < 1 / 16:
|
|
201
|
+
fine = (np.abs(((az + 0.5) % 1) - 0.5) < line / 2) | (np.abs(((el + 0.5) % 1) - 0.5) < line / 2)
|
|
202
|
+
frame[np.broadcast_to(fine, frame.shape[:2]), :3] //= 2
|
|
203
|
+
# Landmarks: red posts every 45 degrees of pan, white horizon.
|
|
204
|
+
post = np.abs(((az + 22.5) % 45) - 22.5) < line * 2
|
|
205
|
+
frame[np.broadcast_to(post, frame.shape[:2])] = (40, 40, 220, 255)
|
|
206
|
+
frame[np.broadcast_to(np.abs(el) < line, frame.shape[:2])] = (255, 255, 255, 255)
|
|
207
|
+
# Centre crosshair.
|
|
208
|
+
cx, cy = self.width // 2, self.height // 2
|
|
209
|
+
frame[cy, cx - 12:cx + 13] = (0, 255, 255, 255)
|
|
210
|
+
frame[cy - 12:cy + 13, cx] = (0, 255, 255, 255)
|
|
211
|
+
return frame
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
# ------------------------------------------------------------- NDI source
|
|
215
|
+
class NdiSource:
|
|
216
|
+
"""Publishes the camera as an NDI source and executes received PTZ commands."""
|
|
217
|
+
|
|
218
|
+
def __init__(self, camera: ViscaCamera, name: str, fps: int = 25,
|
|
219
|
+
width: int = 640, height: int = 360):
|
|
220
|
+
try:
|
|
221
|
+
import NDIlib # noqa: F401 (ndi-python)
|
|
222
|
+
except ImportError as err:
|
|
223
|
+
raise RuntimeError("NDI needs the ndi-python package: pip install ndi-python numpy") from err
|
|
224
|
+
self.camera = camera
|
|
225
|
+
self.name = name
|
|
226
|
+
self.fps = fps
|
|
227
|
+
self.renderer = SceneRenderer(width, height)
|
|
228
|
+
self.controller = NdiPtzController(camera)
|
|
229
|
+
self._stop = threading.Event()
|
|
230
|
+
self._thread: Optional[threading.Thread] = None
|
|
231
|
+
self.source_name: Optional[str] = None
|
|
232
|
+
|
|
233
|
+
def start(self, dispatch: Callable[[Callable[[], None]], None]) -> None:
|
|
234
|
+
"""Start the sender thread. PTZ commands are handed to ``dispatch`` so
|
|
235
|
+
they run on the simulator's event loop."""
|
|
236
|
+
import NDIlib as ndi
|
|
237
|
+
|
|
238
|
+
if not ndi.initialize():
|
|
239
|
+
raise RuntimeError("NDI runtime could not be initialised (CPU not supported?)")
|
|
240
|
+
settings = ndi.SendCreate(self.name, clock_video=True, clock_audio=False)
|
|
241
|
+
sender = ndi.send_create(settings)
|
|
242
|
+
if sender is None:
|
|
243
|
+
raise RuntimeError("could not create NDI sender")
|
|
244
|
+
caps = ndi.MetadataFrame()
|
|
245
|
+
caps.data = CAPABILITIES
|
|
246
|
+
ndi.send_add_connection_metadata(sender, caps)
|
|
247
|
+
try:
|
|
248
|
+
self.source_name = ndi.send_get_source_name(sender).ndi_name
|
|
249
|
+
except TypeError: # some ndi-python builds cannot convert the return value
|
|
250
|
+
self.source_name = f"{socket.gethostname().split('.')[0].upper()} ({self.name})"
|
|
251
|
+
self._thread = threading.Thread(target=self._run, args=(ndi, sender, dispatch),
|
|
252
|
+
name="ndi-sender", daemon=True)
|
|
253
|
+
self._thread.start()
|
|
254
|
+
|
|
255
|
+
def stop(self) -> None:
|
|
256
|
+
self._stop.set()
|
|
257
|
+
if self._thread is not None:
|
|
258
|
+
self._thread.join(timeout=2)
|
|
259
|
+
|
|
260
|
+
def _run(self, ndi, sender, dispatch) -> None:
|
|
261
|
+
frame = ndi.VideoFrameV2()
|
|
262
|
+
frame.FourCC = ndi.FOURCC_VIDEO_TYPE_BGRX
|
|
263
|
+
frame.frame_rate_N, frame.frame_rate_D = self.fps, 1
|
|
264
|
+
try:
|
|
265
|
+
while not self._stop.is_set():
|
|
266
|
+
frame.data = self.renderer.render(self.camera.state)
|
|
267
|
+
ndi.send_send_video_v2(sender, frame) # blocks to the frame rate
|
|
268
|
+
while True:
|
|
269
|
+
metadata = ndi.MetadataFrame()
|
|
270
|
+
ndi.send_capture(sender, metadata, 0)
|
|
271
|
+
if not metadata.data:
|
|
272
|
+
break
|
|
273
|
+
xml = metadata.data
|
|
274
|
+
ndi.send_free_metadata(sender, metadata)
|
|
275
|
+
log.debug("NDI metadata %s", xml)
|
|
276
|
+
dispatch(lambda xml=xml: self.controller.handle(xml))
|
|
277
|
+
finally:
|
|
278
|
+
ndi.send_destroy(sender)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def start_ndi(camera: ViscaCamera, name: str, fps: int = 25) -> NdiSource:
|
|
282
|
+
loop = asyncio.get_running_loop()
|
|
283
|
+
source = NdiSource(camera, name, fps)
|
|
284
|
+
source.start(loop.call_soon_threadsafe)
|
|
285
|
+
return source
|