ipycam 1.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.
- ipycam/__init__.py +68 -0
- ipycam/__main__.py +327 -0
- ipycam/__version__.py +1 -0
- ipycam/camera.py +558 -0
- ipycam/config.py +161 -0
- ipycam/discovery.py +84 -0
- ipycam/http.py +632 -0
- ipycam/mjpeg.py +280 -0
- ipycam/onvif.py +416 -0
- ipycam/ptz.py +486 -0
- ipycam/rtsp.py +973 -0
- ipycam/static/css/style.css +572 -0
- ipycam/static/index.html +196 -0
- ipycam/static/js/app.js +808 -0
- ipycam/static/soap/envelope.xml +9 -0
- ipycam/static/soap/fault.xml +9 -0
- ipycam/static/soap/get_audio_decoder_configurations.xml +2 -0
- ipycam/static/soap/get_capabilities.xml +21 -0
- ipycam/static/soap/get_device_information.xml +7 -0
- ipycam/static/soap/get_profiles.xml +77 -0
- ipycam/static/soap/get_scopes.xml +5 -0
- ipycam/static/soap/get_services.xml +18 -0
- ipycam/static/soap/get_snapshot_uri.xml +8 -0
- ipycam/static/soap/get_stream_uri.xml +8 -0
- ipycam/static/soap/get_system_date_time.xml +19 -0
- ipycam/static/soap/get_users.xml +6 -0
- ipycam/static/soap/get_video_encoder_configuration.xml +16 -0
- ipycam/static/soap/get_video_source_configuration.xml +8 -0
- ipycam/static/soap/probe_match.xml +22 -0
- ipycam/static/soap/ptz_absolute_move.xml +1 -0
- ipycam/static/soap/ptz_continuous_move.xml +1 -0
- ipycam/static/soap/ptz_get_configurations.xml +30 -0
- ipycam/static/soap/ptz_get_node.xml +37 -0
- ipycam/static/soap/ptz_get_nodes.xml +37 -0
- ipycam/static/soap/ptz_get_presets.xml +3 -0
- ipycam/static/soap/ptz_get_service_capabilities.xml +4 -0
- ipycam/static/soap/ptz_get_status.xml +13 -0
- ipycam/static/soap/ptz_goto_home.xml +1 -0
- ipycam/static/soap/ptz_goto_preset.xml +1 -0
- ipycam/static/soap/ptz_relative_move.xml +1 -0
- ipycam/static/soap/ptz_set_preset.xml +3 -0
- ipycam/static/soap/ptz_stop.xml +1 -0
- ipycam/streamer.py +565 -0
- ipycam/webrtc.py +503 -0
- ipycam-1.2.0.dist-info/METADATA +305 -0
- ipycam-1.2.0.dist-info/RECORD +50 -0
- ipycam-1.2.0.dist-info/WHEEL +5 -0
- ipycam-1.2.0.dist-info/entry_points.txt +2 -0
- ipycam-1.2.0.dist-info/licenses/LICENSE +21 -0
- ipycam-1.2.0.dist-info/top_level.txt +1 -0
ipycam/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""
|
|
2
|
+
IPyCam - Pure Python Virtual IP Camera
|
|
3
|
+
|
|
4
|
+
A virtual IP camera that:
|
|
5
|
+
- Is discoverable via ONVIF (WS-Discovery)
|
|
6
|
+
- Provides RTSP streams via go2rtc
|
|
7
|
+
- Has a web interface for configuration and live preview
|
|
8
|
+
- Accepts frames from any source
|
|
9
|
+
- Supports digital PTZ (ePTZ) via ONVIF
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
from ipycam import IPCamera, CameraConfig
|
|
13
|
+
|
|
14
|
+
config = CameraConfig(name="My Camera")
|
|
15
|
+
camera = IPCamera(config)
|
|
16
|
+
camera.start()
|
|
17
|
+
|
|
18
|
+
while camera.is_running:
|
|
19
|
+
frame = get_frame() # Your frame source
|
|
20
|
+
camera.stream(frame)
|
|
21
|
+
|
|
22
|
+
camera.stop()
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from .__version__ import __version__
|
|
26
|
+
|
|
27
|
+
from .config import CameraConfig
|
|
28
|
+
from .camera import IPCamera
|
|
29
|
+
from .streamer import VideoStreamer, StreamConfig, StreamStats, HWAccel
|
|
30
|
+
from .ptz import PTZController, PTZState, PTZPreset, PTZHardwareHandler, PTZVelocity
|
|
31
|
+
from .onvif import ONVIFService
|
|
32
|
+
from .discovery import WSDiscoveryServer
|
|
33
|
+
from .http import IPCameraHTTPHandler
|
|
34
|
+
from .mjpeg import MJPEGStreamer, check_go2rtc_running, check_rtsp_port_available
|
|
35
|
+
from .webrtc import NativeWebRTCStreamer, WebRTCStats, is_webrtc_available
|
|
36
|
+
from .rtsp import NativeRTSPServer, is_native_rtsp_available
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
# Main classes
|
|
40
|
+
"IPCamera",
|
|
41
|
+
"CameraConfig",
|
|
42
|
+
# Streaming
|
|
43
|
+
"VideoStreamer",
|
|
44
|
+
"StreamConfig",
|
|
45
|
+
"StreamStats",
|
|
46
|
+
"HWAccel",
|
|
47
|
+
# MJPEG fallback
|
|
48
|
+
"MJPEGStreamer",
|
|
49
|
+
"check_go2rtc_running",
|
|
50
|
+
"check_rtsp_port_available",
|
|
51
|
+
# Native WebRTC fallback
|
|
52
|
+
"NativeWebRTCStreamer",
|
|
53
|
+
"WebRTCStats",
|
|
54
|
+
"is_webrtc_available",
|
|
55
|
+
# Native RTSP fallback
|
|
56
|
+
"NativeRTSPServer",
|
|
57
|
+
"is_native_rtsp_available",
|
|
58
|
+
# PTZ
|
|
59
|
+
"PTZController",
|
|
60
|
+
"PTZState",
|
|
61
|
+
"PTZPreset",
|
|
62
|
+
"PTZVelocity",
|
|
63
|
+
"PTZHardwareHandler",
|
|
64
|
+
# ONVIF
|
|
65
|
+
"ONVIFService",
|
|
66
|
+
"WSDiscoveryServer",
|
|
67
|
+
"IPCameraHTTPHandler",
|
|
68
|
+
]
|
ipycam/__main__.py
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
IPyCam - Run as a module
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
python -m ipycam [options]
|
|
7
|
+
|
|
8
|
+
Examples:
|
|
9
|
+
python -m ipycam
|
|
10
|
+
python -m ipycam --config camera_config.json
|
|
11
|
+
python -m ipycam --source 0
|
|
12
|
+
python -m ipycam --source rtsp://192.168.1.100/stream
|
|
13
|
+
python -m ipycam --no-timestamp
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import os
|
|
18
|
+
import platform
|
|
19
|
+
import cv2
|
|
20
|
+
from . import IPCamera, CameraConfig
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def infer_source_type(source_arg: str) -> tuple[str, str]:
|
|
24
|
+
"""
|
|
25
|
+
Infer source_type and source_info from the camera argument.
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
tuple of (source_type, source_info)
|
|
29
|
+
"""
|
|
30
|
+
# Special case: 'video' without a file path means video upload mode
|
|
31
|
+
if source_arg.lower() == 'video':
|
|
32
|
+
return ("video_file", "Waiting for upload...")
|
|
33
|
+
|
|
34
|
+
# Try to parse as integer (webcam index)
|
|
35
|
+
try:
|
|
36
|
+
index = int(source_arg)
|
|
37
|
+
return ("camera", f"Camera Index {index}")
|
|
38
|
+
except ValueError:
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
# Check if it's a URL
|
|
42
|
+
source_lower = source_arg.lower()
|
|
43
|
+
if source_lower.startswith(('rtsp://', 'rtmp://', 'http://', 'https://')):
|
|
44
|
+
return ("rtsp", source_arg)
|
|
45
|
+
|
|
46
|
+
# Check if it's a file
|
|
47
|
+
if os.path.isfile(source_arg):
|
|
48
|
+
filename = os.path.basename(source_arg)
|
|
49
|
+
return ("video_file", filename)
|
|
50
|
+
|
|
51
|
+
# Could be a file path that doesn't exist yet, or other source
|
|
52
|
+
# Check by extension
|
|
53
|
+
_, ext = os.path.splitext(source_arg)
|
|
54
|
+
video_extensions = {'.mp4', '.avi', '.mkv', '.mov', '.wmv', '.flv', '.webm', '.m4v', '.mpeg', '.mpg', '.3gp'}
|
|
55
|
+
if ext.lower() in video_extensions:
|
|
56
|
+
filename = os.path.basename(source_arg)
|
|
57
|
+
return ("video_file", filename)
|
|
58
|
+
|
|
59
|
+
# Default to custom if we can't determine
|
|
60
|
+
return ("custom", source_arg)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def main():
|
|
64
|
+
parser = argparse.ArgumentParser(
|
|
65
|
+
prog='ipycam',
|
|
66
|
+
description='IPyCam - Pure Python Virtual IP Camera',
|
|
67
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
68
|
+
epilog="""
|
|
69
|
+
Examples:
|
|
70
|
+
python -m ipycam # Use webcam with default config
|
|
71
|
+
python -m ipycam --config custom.json # Use custom config file
|
|
72
|
+
python -m ipycam --source 1 # Use second webcam
|
|
73
|
+
python -m ipycam --source video.mp4 # Stream from video file
|
|
74
|
+
python -m ipycam --source rtsp://... # Stream from RTSP source
|
|
75
|
+
python -m ipycam --no-timestamp # Disable timestamp overlay
|
|
76
|
+
python -m ipycam --width 1280 --height 720 # Override resolution
|
|
77
|
+
python -m ipycam --fps 60 # Override FPS
|
|
78
|
+
"""
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
parser.add_argument(
|
|
82
|
+
'--config', '-c',
|
|
83
|
+
type=str,
|
|
84
|
+
default='camera_config.json',
|
|
85
|
+
help='Path to config file (default: camera_config.json)'
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
'--source',
|
|
90
|
+
type=str,
|
|
91
|
+
default='0',
|
|
92
|
+
help='Camera source: device index (0), file path, or URL (default: 0)'
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
parser.add_argument(
|
|
96
|
+
'--width',
|
|
97
|
+
type=int,
|
|
98
|
+
help='Override frame width'
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
parser.add_argument(
|
|
102
|
+
'--height',
|
|
103
|
+
type=int,
|
|
104
|
+
help='Override frame height'
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
parser.add_argument(
|
|
108
|
+
'--fps',
|
|
109
|
+
type=int,
|
|
110
|
+
help='Override target FPS'
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
parser.add_argument(
|
|
114
|
+
'--no-timestamp',
|
|
115
|
+
action='store_true',
|
|
116
|
+
help='Disable timestamp overlay'
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
parser.add_argument(
|
|
120
|
+
'--timestamp-position',
|
|
121
|
+
choices=['top-left', 'top-right', 'bottom-left', 'bottom-right'],
|
|
122
|
+
help='Timestamp position'
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
parser.add_argument(
|
|
126
|
+
'--hw',
|
|
127
|
+
choices=['auto', 'nvenc', 'qsv', 'cpu'],
|
|
128
|
+
default=None,
|
|
129
|
+
help='Hardware acceleration: auto, nvenc, qsv, or cpu'
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
args = parser.parse_args()
|
|
133
|
+
|
|
134
|
+
# Load config from file, or use defaults if not found
|
|
135
|
+
config = CameraConfig.load(args.config)
|
|
136
|
+
|
|
137
|
+
# Apply command-line overrides
|
|
138
|
+
if args.width:
|
|
139
|
+
config.main_width = args.width
|
|
140
|
+
if args.height:
|
|
141
|
+
config.main_height = args.height
|
|
142
|
+
if args.fps:
|
|
143
|
+
config.main_fps = args.fps
|
|
144
|
+
if args.no_timestamp:
|
|
145
|
+
config.show_timestamp = False
|
|
146
|
+
if args.timestamp_position:
|
|
147
|
+
config.timestamp_position = args.timestamp_position
|
|
148
|
+
if args.hw:
|
|
149
|
+
config.hw_accel = args.hw
|
|
150
|
+
|
|
151
|
+
# Infer and set source type from source argument
|
|
152
|
+
source_type, source_info = infer_source_type(args.source)
|
|
153
|
+
config.source_type = source_type
|
|
154
|
+
config.source_info = source_info
|
|
155
|
+
|
|
156
|
+
print(f"Loaded config: {config.name} ({config.main_width}x{config.main_height}@{config.main_fps}fps)")
|
|
157
|
+
|
|
158
|
+
camera = IPCamera(config)
|
|
159
|
+
|
|
160
|
+
if not camera.start():
|
|
161
|
+
print("Failed to start camera")
|
|
162
|
+
return 1
|
|
163
|
+
|
|
164
|
+
print("\n" + "="*50)
|
|
165
|
+
print("IP Camera is running!")
|
|
166
|
+
print("="*50)
|
|
167
|
+
print(f"\nOpen Web UI: http://{config.local_ip}:{config.onvif_port}/")
|
|
168
|
+
print(f"Snapshot URL: http://{config.local_ip}:{config.onvif_port}/{config.snapshot_url}")
|
|
169
|
+
print("Press Ctrl+C to stop\n")
|
|
170
|
+
|
|
171
|
+
# Check if this is video upload mode (--source video without a file)
|
|
172
|
+
is_video_upload_mode = args.source.lower() == 'video'
|
|
173
|
+
|
|
174
|
+
if is_video_upload_mode:
|
|
175
|
+
# Video upload mode - no source file, wait for upload via web UI
|
|
176
|
+
print("Video upload mode - no video file specified")
|
|
177
|
+
print("Upload a video file via the web UI to start streaming\n")
|
|
178
|
+
camera.set_video_upload_mode(True)
|
|
179
|
+
|
|
180
|
+
try:
|
|
181
|
+
# Main loop that handles video switching
|
|
182
|
+
while camera.is_running:
|
|
183
|
+
video_path = camera.get_current_video_path()
|
|
184
|
+
|
|
185
|
+
if video_path and os.path.isfile(video_path):
|
|
186
|
+
# We have a video file to play
|
|
187
|
+
print(f"Opening video source: {video_path}")
|
|
188
|
+
# Use auto backend for file paths (V4L2/DSHOW are for device indices only)
|
|
189
|
+
cap = cv2.VideoCapture(video_path)
|
|
190
|
+
|
|
191
|
+
if not cap.isOpened():
|
|
192
|
+
print(f"Error: Could not open video: {video_path}")
|
|
193
|
+
camera.notify_video_error(f"Could not open video: {os.path.basename(video_path)}")
|
|
194
|
+
import time
|
|
195
|
+
time.sleep(0.5)
|
|
196
|
+
continue
|
|
197
|
+
|
|
198
|
+
# Update config with video info
|
|
199
|
+
config.source_info = os.path.basename(video_path)
|
|
200
|
+
camera.notify_video_loaded(video_path)
|
|
201
|
+
|
|
202
|
+
# Stream video frames
|
|
203
|
+
while camera.is_running and camera.get_current_video_path() == video_path:
|
|
204
|
+
ret, frame = cap.read()
|
|
205
|
+
if not ret:
|
|
206
|
+
# Loop the video
|
|
207
|
+
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
|
208
|
+
continue
|
|
209
|
+
camera.stream(frame)
|
|
210
|
+
|
|
211
|
+
cap.release()
|
|
212
|
+
print(f"Video source closed: {video_path}")
|
|
213
|
+
else:
|
|
214
|
+
# No video yet, generate a placeholder frame
|
|
215
|
+
import numpy as np
|
|
216
|
+
placeholder = np.zeros((config.main_height, config.main_width, 3), dtype=np.uint8)
|
|
217
|
+
# Dark blue background
|
|
218
|
+
placeholder[:] = (30, 20, 10)
|
|
219
|
+
# Add text
|
|
220
|
+
text = "Upload a video to start"
|
|
221
|
+
font = cv2.FONT_HERSHEY_SIMPLEX
|
|
222
|
+
font_scale = 1.5
|
|
223
|
+
thickness = 2
|
|
224
|
+
(text_w, text_h), _ = cv2.getTextSize(text, font, font_scale, thickness)
|
|
225
|
+
x = (config.main_width - text_w) // 2
|
|
226
|
+
y = (config.main_height + text_h) // 2
|
|
227
|
+
cv2.putText(placeholder, text, (x, y), font, font_scale, (100, 100, 100), thickness)
|
|
228
|
+
|
|
229
|
+
camera.stream(placeholder)
|
|
230
|
+
import time
|
|
231
|
+
time.sleep(1.0 / config.main_fps)
|
|
232
|
+
|
|
233
|
+
except KeyboardInterrupt:
|
|
234
|
+
print("\nShutting down...")
|
|
235
|
+
finally:
|
|
236
|
+
camera.stop()
|
|
237
|
+
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
# Standard mode - open camera source
|
|
241
|
+
# Try to parse as int (device index), otherwise treat as path/URL
|
|
242
|
+
try:
|
|
243
|
+
camera_source = int(args.source)
|
|
244
|
+
except ValueError:
|
|
245
|
+
camera_source = args.source
|
|
246
|
+
|
|
247
|
+
print(f"Opening camera source: {camera_source}")
|
|
248
|
+
# Use auto backend for file paths; use platform backend for device indices/URLs
|
|
249
|
+
if isinstance(camera_source, str) and os.path.isfile(camera_source):
|
|
250
|
+
cap = cv2.VideoCapture(camera_source)
|
|
251
|
+
elif platform.system().lower() == "linux":
|
|
252
|
+
cap = cv2.VideoCapture(camera_source, cv2.CAP_V4L2)
|
|
253
|
+
else:
|
|
254
|
+
cap = cv2.VideoCapture(camera_source, cv2.CAP_DSHOW)
|
|
255
|
+
|
|
256
|
+
# Set resolution if using webcam (device index)
|
|
257
|
+
if isinstance(camera_source, int):
|
|
258
|
+
# Request MJPEG + low buffer for better FPS on Pi
|
|
259
|
+
fourcc_func = getattr(cv2, "VideoWriter_fourcc", None)
|
|
260
|
+
if fourcc_func is None:
|
|
261
|
+
fourcc_func = cv2.VideoWriter.fourcc
|
|
262
|
+
cap.set(cv2.CAP_PROP_FOURCC, fourcc_func(*"MJPG"))
|
|
263
|
+
cap.set(cv2.CAP_PROP_BUFFERSIZE, 1)
|
|
264
|
+
cap.set(cv2.CAP_PROP_FRAME_WIDTH, config.main_width)
|
|
265
|
+
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, config.main_height)
|
|
266
|
+
cap.set(cv2.CAP_PROP_FPS, config.main_fps)
|
|
267
|
+
|
|
268
|
+
# Store initial video path if it's a video file
|
|
269
|
+
if source_type == "video_file" and os.path.isfile(args.source):
|
|
270
|
+
camera.set_video_upload_mode(True)
|
|
271
|
+
camera.set_current_video_path(os.path.abspath(args.source))
|
|
272
|
+
|
|
273
|
+
if not cap.isOpened():
|
|
274
|
+
print(f"Error: Could not open camera source: {camera_source}")
|
|
275
|
+
camera.stop()
|
|
276
|
+
return 1
|
|
277
|
+
|
|
278
|
+
try:
|
|
279
|
+
while camera.is_running:
|
|
280
|
+
# Check if video source changed (for video file mode)
|
|
281
|
+
if source_type == "video_file":
|
|
282
|
+
new_video = camera.get_current_video_path()
|
|
283
|
+
current_source = os.path.abspath(camera_source) if isinstance(camera_source, str) else None
|
|
284
|
+
|
|
285
|
+
if new_video and new_video != current_source:
|
|
286
|
+
# Video source changed - switch to new video
|
|
287
|
+
print(f"Switching to new video: {new_video}")
|
|
288
|
+
cap.release()
|
|
289
|
+
cap = cv2.VideoCapture(new_video)
|
|
290
|
+
|
|
291
|
+
if not cap.isOpened():
|
|
292
|
+
print(f"Error: Could not open new video: {new_video}")
|
|
293
|
+
camera.notify_video_error(f"Could not open video: {os.path.basename(new_video)}")
|
|
294
|
+
# Revert to previous video
|
|
295
|
+
if current_source and os.path.isfile(current_source):
|
|
296
|
+
cap = cv2.VideoCapture(current_source)
|
|
297
|
+
continue
|
|
298
|
+
|
|
299
|
+
camera_source = new_video
|
|
300
|
+
config.source_info = os.path.basename(new_video)
|
|
301
|
+
camera.notify_video_loaded(new_video)
|
|
302
|
+
|
|
303
|
+
ret, frame = cap.read()
|
|
304
|
+
|
|
305
|
+
if not ret:
|
|
306
|
+
# Loop video files, exit on camera failure
|
|
307
|
+
if isinstance(camera_source, str):
|
|
308
|
+
cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
|
|
309
|
+
continue
|
|
310
|
+
else:
|
|
311
|
+
print("Error: Failed to read from camera")
|
|
312
|
+
break
|
|
313
|
+
|
|
314
|
+
# Stream handles PTZ, timestamp, and frame pacing automatically
|
|
315
|
+
camera.stream(frame)
|
|
316
|
+
|
|
317
|
+
except KeyboardInterrupt:
|
|
318
|
+
print("\nShutting down...")
|
|
319
|
+
finally:
|
|
320
|
+
cap.release()
|
|
321
|
+
camera.stop()
|
|
322
|
+
|
|
323
|
+
return 0
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
if __name__ == "__main__":
|
|
327
|
+
exit(main())
|
ipycam/__version__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.2.0"
|