Skip to content

Commit 0633273

Browse files
ddd999ddd999
authored andcommitted
Photo/video mode
Enables local still image and video recording
1 parent c16840c commit 0633273

File tree

8 files changed

+2180
-557
lines changed

8 files changed

+2180
-557
lines changed

.gitignore

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@
99
# production
1010
/build
1111

12+
# photo/video media
13+
/media
14+
1215
# misc
1316
.DS_Store
1417
.env
@@ -28,4 +31,4 @@ settingsTest.json
2831
/flightlogs
2932
.nyc_output
3033

31-
/local_modules/long/node_modules
34+
/local_modules/long/node_modules

python/get_camera_caps.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
#!/usr/bin/env python3
2+
# -*- coding:utf-8 vi:ts=4:noexpandtab -*-
3+
4+
import subprocess
5+
import re
6+
import sys
7+
import json
8+
import os
9+
10+
def check_if_v4l2_ctl_avail():
11+
try:
12+
subprocess.run(['v4l2-ctl', '--help'], check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
13+
except subprocess.CalledProcessError:
14+
print("v4l2-ctl is not installed. Exiting.")
15+
sys.exit(1)
16+
17+
def get_subdev_paths():
18+
base_path = '/dev'
19+
return sorted([os.path.join(base_path, dev) for dev in os.listdir(base_path) if dev.startswith('v4l-subdev')])
20+
21+
def get_video_device_paths():
22+
base_path = '/dev'
23+
return sorted([os.path.join(base_path, dev) for dev in os.listdir(base_path) if dev.startswith('video')])
24+
25+
def get_mbus_codes(dev_path):
26+
command = f"v4l2-ctl -d {dev_path} --list-subdev-mbus-codes 0"
27+
result = subprocess.run(command, shell=True, text=True, capture_output=True)
28+
if result.returncode != 0:
29+
return []
30+
pattern = r"0x([0-9a-fA-F]+):\s+([A-Za-z0-9_]+)"
31+
return re.findall(pattern, result.stdout)
32+
33+
def get_resolutions(dev_path, mbus_code):
34+
command = f"v4l2-ctl -d {dev_path} --list-subdev-framesizes pad=0,code=0x{mbus_code}"
35+
result = subprocess.run(command, shell=True, text=True, capture_output=True)
36+
if result.returncode != 0:
37+
return []
38+
pattern = r"Size Range: (\d+)x(\d+)"
39+
matches = re.findall(pattern, result.stdout)
40+
return [{'width': int(w), 'height': int(h)} for w, h in matches]
41+
42+
def get_formats_and_resolutions(dev_path):
43+
command = f"v4l2-ctl -d {dev_path} --list-formats-ext"
44+
result = subprocess.run(command, shell=True, text=True, capture_output=True)
45+
if result.returncode != 0:
46+
return []
47+
48+
devices = []
49+
fmt_pattern = r"^\s*\[\d+\]: '(\w+)' \(.*?\)"
50+
size_pattern = r"\s+Size: Discrete (\d+)x(\d+)"
51+
current_fmt = None
52+
53+
for line in result.stdout.splitlines():
54+
fmt_match = re.match(fmt_pattern, line)
55+
if fmt_match:
56+
current_fmt = fmt_match.group(1)
57+
continue
58+
59+
size_match = re.match(size_pattern, line)
60+
if size_match and current_fmt:
61+
width, height = map(int, size_match.groups())
62+
devices.append({
63+
'format': current_fmt,
64+
'width': width,
65+
'height': height,
66+
'label': f"{width}x{height}_{current_fmt}",
67+
'value': f"{current_fmt}_{width}x{height}"
68+
})
69+
70+
return devices
71+
72+
def get_card_name(dev_path):
73+
command = f"v4l2-ctl -d {dev_path} --all"
74+
result = subprocess.run(command, shell=True, text=True, capture_output=True)
75+
if result.returncode != 0:
76+
return None
77+
78+
match = re.search(r"Card type\s+:\s+(.+)", result.stdout)
79+
if match:
80+
return match.group(1).strip()
81+
82+
return None
83+
84+
check_if_v4l2_ctl_avail()
85+
86+
devices = []
87+
88+
# Process CSI cameras
89+
for dev_path in get_subdev_paths():
90+
mbus_codes = get_mbus_codes(dev_path)
91+
if not mbus_codes:
92+
continue
93+
94+
device_caps = {
95+
#'device': dev_path,
96+
# Don't specify a device path for CSI, let libcamera/Picamera2 figure that out
97+
'device': None,
98+
'type': 'CSI',
99+
'card_name': get_card_name(dev_path) or "Unnamed CSI Camera",
100+
'caps': []
101+
}
102+
103+
for mbus_code, pixel_format in mbus_codes:
104+
resolutions = get_resolutions(dev_path, mbus_code)
105+
106+
for res in resolutions:
107+
fmt = pixel_format.split("MEDIA_BUS_FMT_")[1] if "MEDIA_BUS_FMT_" in pixel_format else pixel_format
108+
cap_info = {
109+
'format': fmt,
110+
'width': res['width'],
111+
'height': res['height'],
112+
'label': f"{res['width']}x{res['height']}_{fmt}",
113+
'value': f"{mbus_code}_{fmt}_{res['width']}x{res['height']}"
114+
}
115+
device_caps['caps'].append(cap_info)
116+
117+
if device_caps['caps']:
118+
devices.append(device_caps)
119+
120+
# Process UVC cameras
121+
for dev_path in get_video_device_paths():
122+
caps = get_formats_and_resolutions(dev_path)
123+
if caps:
124+
devices.append({
125+
'device': dev_path,
126+
'type': 'UVC',
127+
'card_name': get_card_name(dev_path) or "Unnamed UVC Camera",
128+
'caps': caps
129+
})
130+
131+
print(json.dumps(devices, indent=4))

python/photomode.py

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
#!/usr/bin/env python3
2+
# -*- coding:utf-8 vi:ts=4:noexpandtab
3+
4+
from picamera2 import Picamera2
5+
from picamera2.encoders import H264Encoder
6+
from libcamera import Transform
7+
8+
import cv2
9+
import argparse
10+
import time, signal, os, sys, shutil, traceback
11+
12+
GOT_SIGUSR1 = False
13+
GOT_SIGTERM = False
14+
VIDEO_ACTIVE = False
15+
pid = os.getpid()
16+
print("PID is : ", pid)
17+
18+
def handle_sigusr1(signum, stack):
19+
global GOT_SIGUSR1
20+
GOT_SIGUSR1 = True
21+
22+
def handle_sigterm(signum, stack):
23+
global GOT_SIGTERM
24+
GOT_SIGTERM = True
25+
26+
# Register the signal handler functions with the actual signals
27+
signal.signal(signal.SIGTERM, handle_sigterm)
28+
signal.signal(signal.SIGUSR1, handle_sigusr1)
29+
30+
# Parse command line arguments
31+
parser = argparse.ArgumentParser(description="Camera control server")
32+
parser.add_argument("-d", "--destination", dest="mediaPath",
33+
help="Save captured image to PATH. Default: /home/pi/Rpanion-server/media/",
34+
metavar="PATH",
35+
default="/home/pi/Rpanion-server/media/"
36+
)
37+
parser.add_argument("-m", "--mode", choices=['photo', 'video'],
38+
dest="captureMode",
39+
help="Capture mode options: photo [default], video", metavar="MODE",
40+
default='photo'
41+
)
42+
parser.add_argument("--device", dest="captureDevicePath",
43+
help="V4L2 capture device path. If not specified, defaults to the first available Picamera2-supported camera.",
44+
default=None
45+
)
46+
parser.add_argument("--width", metavar="W", type=int, dest="vidWidth",
47+
help="Image width", default=1920
48+
)
49+
parser.add_argument("--height", metavar="H", type=int, dest="vidHeight",
50+
help="Image height", default=1080
51+
)
52+
parser.add_argument("--fps", metavar="FPS", type=int, dest="vidFps",
53+
help="Video framerate", default=30
54+
)
55+
parser.add_argument("--format", dest="vidFormat",
56+
help="Video format (e.g., YUV420, MJPEG, RGB888). Default: YUV420",
57+
default="YUV420"
58+
)
59+
parser.add_argument("--rotation", metavar="DEG", type=int, choices=[0, 90, 180, 270],
60+
dest="imageRotation", help="Image rotation", default=0)
61+
parser.add_argument("-b", "--bitrate", metavar = "N",
62+
type = int, dest="vidBitrate",
63+
help="Video bitrate in bits per second. Default: 10000000",
64+
default=10000000
65+
)
66+
parser.add_argument("-f", "--min-disk-space", metavar = "N",
67+
type = int, dest="minFreeSpace",
68+
help="Minimum free disk space (in MB) required to save files. Default: 1000 MB",
69+
default=1000
70+
)
71+
args = parser.parse_args()
72+
73+
mediaPath = args.mediaPath
74+
captureMode = args.captureMode
75+
minFreeSpace = args.minFreeSpace * 10**6
76+
77+
picam2_still = None
78+
picam2_vid = None
79+
v4l2_cam = None
80+
v4l2_writer = None
81+
encoder = None
82+
use_picamera = False
83+
84+
# Create media directory if it doesn't exist
85+
try:
86+
os.makedirs(mediaPath, exist_ok=True)
87+
print(f"Media storage directory '{mediaPath}' is ready.")
88+
except Exception as e:
89+
sys.exit(f"An error occurred creating directory: {e}")
90+
91+
# Determine which backend type to use and initialize the camera
92+
if args.captureDevicePath is None:
93+
print("No camera device specified. Defaulting to Picamera2 backend.")
94+
use_picamera = True
95+
try:
96+
if not Picamera2.global_camera_info():
97+
sys.exit("Picamera2 backend selected, but no libcamera cameras found.")
98+
except Exception as e:
99+
sys.exit(f"Could not query for Picamera2 cameras: {e}")
100+
else:
101+
print(f"Device {args.captureDevicePath} specified. Using V4L2/OpenCV backend.")
102+
use_picamera = False
103+
104+
if captureMode == "photo":
105+
if use_picamera:
106+
picam2_still = Picamera2()
107+
config = picam2_still.create_still_configuration({"size":(args.vidWidth, args.vidHeight)}, transform=Transform(rotation=args.imageRotation))
108+
picam2_still.configure(config)
109+
picam2_still.start()
110+
time.sleep(2)
111+
print(f"Camera is ready in Picamera2 photo mode. Capturing {args.vidWidth}x{args.vidHeight}, {args.imageRotation}° rotation")
112+
else:
113+
v4l2_cam = cv2.VideoCapture(args.captureDevicePath, cv2.CAP_V4L2)
114+
v4l2_cam.set(cv2.CAP_PROP_FRAME_WIDTH, args.vidWidth)
115+
v4l2_cam.set(cv2.CAP_PROP_FRAME_HEIGHT, args.vidHeight)
116+
if not v4l2_cam.isOpened():
117+
sys.exit(f"V4L2 camera at {args.captureDevicePath} failed to open.")
118+
time.sleep(2)
119+
print(f"Camera is ready in V4L2 photo mode. Capturing {args.vidWidth}x{args.vidHeight}")
120+
121+
elif captureMode == "video":
122+
if use_picamera:
123+
picam2_vid = Picamera2()
124+
video_config = picam2_vid.create_video_configuration(main={"size": (args.vidWidth, args.vidHeight), "format": args.vidFormat}, controls={"FrameRate": args.vidFps}, transform=Transform(rotation=args.imageRotation))
125+
picam2_vid.configure(video_config)
126+
encoder = H264Encoder(bitrate=args.vidBitrate)
127+
picam2_vid.start()
128+
print(f"Camera is ready in Picamera2 video mode. Capturing {args.vidWidth}x{args.vidHeight} {args.vidFormat} @ {args.vidFps} fps, {args.imageRotation}° rotation")
129+
else:
130+
v4l2_cam = cv2.VideoCapture(args.captureDevicePath, cv2.CAP_V4L2)
131+
if not v4l2_cam.isOpened():
132+
sys.exit(f"V4L2 camera at {args.captureDevicePath} failed to open.")
133+
134+
v4l2_cam.set(cv2.CAP_PROP_FRAME_WIDTH, args.vidWidth)
135+
v4l2_cam.set(cv2.CAP_PROP_FRAME_HEIGHT, args.vidHeight)
136+
v4l2_cam.set(cv2.CAP_PROP_FPS, args.vidFps)
137+
138+
# Check if the actual width, height, and FPS were written correctly
139+
actual_v4l2_width = int(v4l2_cam.get(cv2.CAP_PROP_FRAME_WIDTH))
140+
actual_v4l2_height = int(v4l2_cam.get(cv2.CAP_PROP_FRAME_HEIGHT))
141+
actual_v4l2_fps = int(v4l2_cam.get(cv2.CAP_PROP_FPS))
142+
143+
print(f"Camera is ready in V4L2 video mode. Capturing {actual_v4l2_width}x{actual_v4l2_height} {args.vidFormat} @ {actual_v4l2_fps} fps")
144+
145+
def graceful_exit():
146+
global VIDEO_ACTIVE
147+
print("Gracefully exiting...")
148+
if VIDEO_ACTIVE:
149+
startstop_video()
150+
time.sleep(0.2)
151+
if use_picamera:
152+
if picam2_still: picam2_still.stop()
153+
if picam2_vid: picam2_vid.stop()
154+
elif v4l2_cam:
155+
v4l2_cam.release()
156+
sys.exit(0)
157+
158+
def startstop_video():
159+
global VIDEO_ACTIVE, v4l2_writer
160+
if use_picamera:
161+
if VIDEO_ACTIVE:
162+
picam2_vid.stop_recording()
163+
VIDEO_ACTIVE = False
164+
print("Picamera2 recording stopped.")
165+
else:
166+
filepath = os.path.join(mediaPath, time.strftime("RPN%Y%m%d_%H%M%S.h264"))
167+
picam2_vid.start_recording(encoder, filepath)
168+
VIDEO_ACTIVE = True
169+
print(f"Picamera2 recording started to {filepath}")
170+
else: # V4L2
171+
if VIDEO_ACTIVE:
172+
VIDEO_ACTIVE = False
173+
if v4l2_writer:
174+
v4l2_writer.release()
175+
v4l2_writer = None
176+
print("V4L2 recording stopped.")
177+
else:
178+
for _ in range(5): v4l2_cam.read() # Flush buffer
179+
filepath = os.path.join(mediaPath, time.strftime("RPN%Y%m%d_%H%M%S.avi"))
180+
fourcc = cv2.VideoWriter_fourcc(*'MJPG')
181+
v4l2_writer = cv2.VideoWriter(filepath, fourcc, actual_v4l2_fps, (actual_v4l2_width, actual_v4l2_height))
182+
if v4l2_writer.isOpened():
183+
VIDEO_ACTIVE = True
184+
print(f"V4L2 recording started to {filepath}")
185+
else:
186+
print(f"Error: Could not open VideoWriter for {filepath}", file=sys.stderr)
187+
188+
if __name__ == '__main__':
189+
try:
190+
while True:
191+
if GOT_SIGTERM:
192+
GOT_SIGTERM = False
193+
graceful_exit()
194+
195+
if GOT_SIGUSR1:
196+
GOT_SIGUSR1 = False
197+
198+
freeDiskSpace = shutil.disk_usage(mediaPath).free
199+
if freeDiskSpace < minFreeSpace and (captureMode == 'photo' or (captureMode == 'video' and not VIDEO_ACTIVE)):
200+
print(f"Free disk space is {(int)(freeDiskSpace / 10**6)} MB, which is less than the minimum of {(int)(minFreeSpace / 10**6)} MB. Action aborted.")
201+
else:
202+
if captureMode == 'photo':
203+
filepath = os.path.join(mediaPath, time.strftime("RPN%Y%m%d_%H%M%S.jpg"))
204+
print(f"Capturing photo to {filepath}")
205+
if use_picamera:
206+
picam2_still.capture_file(filepath)
207+
print("Photo captured.")
208+
else:
209+
for _ in range(5): v4l2_cam.read()
210+
ret, frame = v4l2_cam.read()
211+
if ret:
212+
cv2.imwrite(filepath, frame)
213+
print("Photo captured.")
214+
else:
215+
print("Failed to capture frame from V4L2 camera.", file=sys.stderr)
216+
elif captureMode == 'video':
217+
print("Toggling video recording.")
218+
startstop_video()
219+
220+
# Main loop with two states: paused and waiting for a signal, or actively recording V4L2 video
221+
if use_picamera or not VIDEO_ACTIVE:
222+
print("... Paused, waiting for signal ...", file=sys.stderr)
223+
signal.pause()
224+
else:
225+
if v4l2_cam and v4l2_writer:
226+
ret, frame = v4l2_cam.read()
227+
if ret:
228+
v4l2_writer.write(frame)
229+
else:
230+
print("V4L2 frame read failed during recording. Stopping.", file=sys.stderr)
231+
startstop_video()
232+
time.sleep(0.01)
233+
234+
except KeyboardInterrupt:
235+
graceful_exit()
236+
except Exception as e:
237+
print(f"!!! PYTHON ERROR: Unhandled exception: {e}", file=sys.stderr)
238+
traceback.print_exc(file=sys.stderr)
239+
graceful_exit()

0 commit comments

Comments
 (0)