Files
blackbird/aibox/skills/camera.py
T
qing 9064750f33 fix(aibox/skills): Batch M 波次2 路径边界收口(S2/S3/S6/S9/S10/S11/S12/S13)
- gitclient: delete 限工作区内 .git 仓库(S2);分支名白名单防选项注入(S3)
- camera/screenshot: --filename 拒绝分隔符/绝对路径/父目录引用(S9/S10)
- facerec: person_name 白名单(兼容中文)接入 add/remove/get-info(S11)
- vision: 图片魔数白名单校验,data URL MIME 按实测类型(S12)
- sftp-transfer: listdir 条目名双向过滤防恶意服务端目录逃逸(S6)
- test-auto-appimage: exec_name 白名单防 sed 替换注入(S13)

测试 493 passed / 4 failed(既有平台环境项)/ 1 skipped,零回归
2026-08-20 13:11:07 +08:00

407 lines
14 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""摄像头拍照工具 - 获取相机设备列表、选择指定相机拍照、保存照片。"""
import os
import sys
import argparse
from pathlib import Path
import time
# 定义默认的截图保存目录
CAMERA_DIR = os.path.expanduser("~/.aibox/camera")
def ensure_camera_dir():
"""确保摄像头照片保存目录存在"""
os.makedirs(CAMERA_DIR, exist_ok=True)
return CAMERA_DIR
def validate_filename(filename):
"""S9(审计 2026-08-20):--filename 仅允许纯文件名——
拒绝路径分隔符/父目录引用/绝对路径,防止经 os.path.join 逃逸 CAMERA_DIR"""
if not filename:
return "文件名不能为空"
if filename in (".", "..") or "/" in filename or "\\" in filename or os.path.isabs(filename):
return f"非法文件名(不允许路径分隔符或父目录引用): {filename}"
return None
def list_cameras(max_test=10):
"""列出可用的摄像头设备"""
available_cameras = []
for i in range(max_test):
cap = cv2.VideoCapture(i)
if cap.isOpened():
ret, frame = cap.read()
if ret:
height, width = frame.shape[:2]
backend = cap.getBackendName()
camera_info = {
"id": i,
"resolution": f"{width}x{height}",
"backend": backend,
"status": "可用"
}
# 在Linux系统上尝试获取设备路径
if sys.platform.startswith('linux'):
dev_path = f"/dev/video{i}"
if os.path.exists(dev_path):
camera_info["device_path"] = dev_path
try:
import subprocess
result = subprocess.run(
['udevadm', 'info', '--query=property', '--name', dev_path],
capture_output=True, text=True, timeout=2
)
if result.returncode == 0:
for line in result.stdout.split('\n'):
if 'ID_MODEL=' in line:
camera_info["model"] = line.split('=')[1].strip()
elif 'ID_VENDOR=' in line:
camera_info["vendor"] = line.split('=')[1].strip()
except:
pass
available_cameras.append(camera_info)
cap.release()
else:
cap.release()
return available_cameras
def get_camera_info(camera_id):
"""获取指定摄像头的详细信息"""
cap = cv2.VideoCapture(camera_id)
if not cap.isOpened():
return None
info = {
"id": camera_id,
"properties": {}
}
# 获取各种摄像头属性
properties = [
(cv2.CAP_PROP_FRAME_WIDTH, "width"),
(cv2.CAP_PROP_FRAME_HEIGHT, "height"),
(cv2.CAP_PROP_FPS, "fps"),
(cv2.CAP_PROP_BRIGHTNESS, "brightness"),
(cv2.CAP_PROP_CONTRAST, "contrast"),
(cv2.CAP_PROP_SATURATION, "saturation"),
(cv2.CAP_PROP_HUE, "hue"),
(cv2.CAP_PROP_GAIN, "gain"),
(cv2.CAP_PROP_EXPOSURE, "exposure"),
(cv2.CAP_PROP_FOCUS, "focus"),
(cv2.CAP_PROP_AUTO_EXPOSURE, "auto_exposure"),
(cv2.CAP_PROP_AUTO_WB, "auto_white_balance"),
(cv2.CAP_PROP_WB_TEMPERATURE, "white_balance_temperature"),
]
for prop_id, prop_name in properties:
value = cap.get(prop_id)
if value >= 0:
info["properties"][prop_name] = value
ret, frame = cap.read()
if ret:
height, width = frame.shape[:2]
info["current_resolution"] = f"{width}x{height}"
info["channels"] = frame.shape[2] if len(frame.shape) > 2 else 1
cap.release()
if sys.platform.startswith('linux'):
dev_path = f"/dev/video{camera_id}"
if os.path.exists(dev_path):
info["device_path"] = dev_path
return info
def capture_photo(camera_id=0, save_path=None, resolution=None, wait_time=1, exposure=None):
"""调用指定摄像头拍照"""
cap = cv2.VideoCapture(camera_id)
if not cap.isOpened():
return None, f"无法打开摄像头 #{camera_id},请检查摄像头连接", None, None
if resolution:
try:
width, height = map(int, resolution.lower().replace('x', ' ').split())
cap.set(cv2.CAP_PROP_FRAME_WIDTH, width)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, height)
except:
pass
# 如果指定了曝光值,关闭自动曝光并设置手动曝光
if exposure is not None:
# 0.25 = 手动模式(部分后端用 1),3 = 自动模式
cap.set(cv2.CAP_PROP_AUTO_EXPOSURE, 0.25)
cap.set(cv2.CAP_PROP_EXPOSURE, exposure)
time.sleep(wait_time)
for _ in range(5):
cap.read()
ret, frame = cap.read()
cap.release()
if not ret:
return None, "拍照失败,无法获取图像帧", None, None
actual_height, actual_width = frame.shape[:2]
if save_path is None:
ensure_camera_dir()
timestamp = time.strftime("%Y%m%d_%H%M%S")
filename = f"camera_{camera_id}_{timestamp}.jpg"
save_path = os.path.join(CAMERA_DIR, filename)
else:
save_dir = os.path.dirname(save_path)
if save_dir:
os.makedirs(save_dir, exist_ok=True)
if not os.path.splitext(save_path)[1]:
save_path += '.jpg'
cv2.imwrite(save_path, frame)
return save_path, None, actual_width, actual_height
def format_camera_list(cameras):
"""格式化相机列表输出"""
if not cameras:
return "❌ 未找到可用的摄像头设备"
lines = ["📷 可用摄像头设备:"]
for cam in cameras:
model_info = ""
if "model" in cam:
model_info = f" ({cam.get('vendor', '')} {cam['model']})".strip()
dev_path = cam.get("device_path", f"Camera #{cam['id']}")
lines.append(f" [{cam['id']}] {dev_path}{model_info}")
lines.append(f" 分辨率: {cam['resolution']}, 后端: {cam['backend']}")
return "\n".join(lines)
def list_recent_photos(limit=10):
"""列出最近的摄像头照片"""
ensure_camera_dir()
if not os.path.exists(CAMERA_DIR):
return f"摄像头照片目录不存在: {CAMERA_DIR}"
photos = []
for f in os.listdir(CAMERA_DIR):
if f.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp', '.gif')):
filepath = os.path.join(CAMERA_DIR, f)
mtime = os.path.getmtime(filepath)
size = os.path.getsize(filepath)
photos.append((mtime, f, size))
photos.sort(reverse=True)
if not photos:
return f"摄像头照片目录中没有照片: {CAMERA_DIR}"
result = [f"📸 最近的摄像头照片 (共 {len(photos)} 个):"]
for i, (mtime, fname, size) in enumerate(photos[:limit], 1):
dt = time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(mtime))
if size < 1024:
size_str = f"{size}B"
elif size < 1024 * 1024:
size_str = f"{size/1024:.1f}KB"
else:
size_str = f"{size/1024/1024:.1f}MB"
result.append(f" {i}. {fname} - {dt} ({size_str})")
if len(photos) > limit:
result.append(f" ... 还有 {len(photos) - limit} 个文件")
return "\n".join(result)
def main():
parser = argparse.ArgumentParser(
description="摄像头拍照工具 - 获取相机设备列表、选择指定相机拍照、保存照片。",
epilog="""
使用示例:
# 列出所有可用摄像头
camera --action list
# 查看摄像头详细信息
camera --action info --camera-id 0
# 拍照(自动生成文件名)
camera --action capture --camera-id 0
# 拍照并指定分辨率
camera --action capture --camera-id 0 --resolution "1920x1080"
# 拍照并自定义文件名
camera --action capture --camera-id 0 --filename "my_photo"
# 拍照并等待3秒预热
camera --action capture --camera-id 0 --wait-time 3
# 手动降低曝光(数值越低越暗,-7 比 -5 更暗)
camera --action capture --camera-id 0 --exposure -7
# 调整曝光至较亮
camera --action capture --camera-id 0 --exposure -4
# 列出最近的照片
camera --action list-photos --limit 5
照片保存位置:
~/.aibox/camera/
自动命名格式: camera_{摄像头ID}_{YYYYMMDD_HHMMSS}.jpg
需要安装:
pip install opencv-python
"""
)
parser.add_argument("--action", "-a", required=True,
choices=["list", "info", "capture", "list-photos"],
help="操作类型")
# 摄像头参数
parser.add_argument("--camera-id", "-c", type=int, default=0,
help="摄像头ID(默认: 0)")
parser.add_argument("--resolution", "-r",
help="照片分辨率,格式: 宽度x高度(如 1920x1080)")
parser.add_argument("--wait-time", "-w", type=float, default=1,
help="等待摄像头预热时间(秒,默认: 1)")
parser.add_argument("--exposure", "-e", type=float,
help="曝光值(越低画面越暗。设置后自动关闭自动曝光,值范围取决于摄像头驱动,通常 -4 到 -7 为常见范围)")
parser.add_argument("--filename", "-f",
help="自定义文件名(不含路径,自动保存到默认目录)")
parser.add_argument("--limit", "-l", type=int, default=10,
help="列出照片时的数量限制(默认: 10)")
args = parser.parse_args()
# 延迟导入 cv2,确保 --help 不受依赖缺失影响
global cv2
try:
import cv2
except ImportError:
print("❌ 缺少依赖库: opencv-python。请运行: pip install opencv-python")
sys.exit(1)
# 执行操作
try:
if args.action == "list":
print("🔍 正在扫描摄像头设备...")
cameras = list_cameras(10)
print(format_camera_list(cameras))
if cameras:
print(f"\n💡 使用建议:")
print(f" 要使用摄像头 #{cameras[0]['id']} 拍照,请执行:")
print(f" camera --action capture --camera-id {cameras[0]['id']}")
print(f" 要查看摄像头详细信息,请执行:")
print(f" camera --action info --camera-id {cameras[0]['id']}")
elif args.action == "list-photos":
result = list_recent_photos(args.limit)
print(result)
elif args.action == "info":
print(f"🔍 正在获取摄像头 #{args.camera_id} 的详细信息...")
info = get_camera_info(args.camera_id)
if not info:
print(f"❌ 无法打开摄像头 #{args.camera_id},请检查设备是否存在")
sys.exit(1)
print(f"📷 摄像头 #{args.camera_id} 详细信息:")
if "device_path" in info:
print(f" 设备路径: {info['device_path']}")
print(f" 当前分辨率: {info.get('current_resolution', '未知')}")
if "channels" in info:
print(f" 色彩通道: {info['channels']}")
if info["properties"]:
print(f"\n 支持设置的属性:")
for name, value in info["properties"].items():
print(f" {name}: {value}")
print(f"\n💡 要拍照请执行:")
print(f" camera --action capture --camera-id {args.camera_id}")
elif args.action == "capture":
ensure_camera_dir()
if args.filename:
filename_error = validate_filename(args.filename)
if filename_error:
print(f"❌ {filename_error}")
sys.exit(1)
save_path = os.path.join(CAMERA_DIR, args.filename)
else:
timestamp = time.strftime("%Y%m%d_%H%M%S")
save_path = os.path.join(CAMERA_DIR, f"camera_{args.camera_id}_{timestamp}.jpg")
print(f"📸 正在使用摄像头 #{args.camera_id} 拍照...")
if args.resolution:
print(f" 请求分辨率: {args.resolution}")
print(f" 预热时间: {args.wait_time}秒")
if args.exposure is not None:
print(f" 曝光值: {args.exposure}(手动模式,自动曝光已关闭)")
print(f" 保存目录: {CAMERA_DIR}")
image_path, error, width, height = capture_photo(
camera_id=args.camera_id,
save_path=save_path,
resolution=args.resolution,
wait_time=args.wait_time,
exposure=args.exposure
)
if error:
print(f"❌ {error}")
sys.exit(1)
print(f"✅ 拍照成功!")
print(f" 📷 实际分辨率: {width}x{height}")
print(f" 💾 保存路径: {image_path}")
if os.path.exists(image_path):
file_size = os.path.getsize(image_path)
if file_size < 1024:
size_str = f"{file_size}B"
elif file_size < 1024 * 1024:
size_str = f"{file_size/1024:.1f}KB"
else:
size_str = f"{file_size/1024/1024:.1f}MB"
print(f" 📦 文件大小: {size_str}")
photos = [f for f in os.listdir(CAMERA_DIR) if f.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp'))]
print(f" 📁 该目录共有 {len(photos)} 张照片")
except Exception as e:
print(f"❌ 执行错误: {str(e)}")
sys.exit(1)
if __name__ == "__main__":
main()