目 录CONTENT

文章目录

使用Python基于Onvif控制家用摄像头云台转动

成培培
2025-10-15 / 0 评论 / 0 点赞 / 3 阅读 / 0 字

上一篇博文在浏览器上播放摄像头rtsp视频流的实现方案,我使用了开源的流媒体服务器MediamTX实现了在网页上实时播放家里的监控画面,使用的是MediamTX自带的一个测试页面,但是增加了用户密码授权后体验不太好,在微信上不能打开,而且不能控制摄像头云台上下左右转动,所以决定自己写一个页面,通过在页面上输入用户名密码播放监控画面,同时增加4个按钮,可以控制摄像头云台实时转动。

Onvif协议

ONVIF(Open Network Video Interface Forum,开放网络视频接口论坛)是一种 国际通用标准协议,用于 网络视频设备(如摄像头、NVR、VMS等)之间的互联与互通。它的主要目标是: 让不同品牌、不同型号的安防设备能够通过统一的接口标准进行通信和控制,而不需要依赖厂商的专有协议。
我的摄像头是TP-Link的某一款,是支持部分Onvif协议的,主要是视频流传输RTSP/HTTP流、PTZ(云台控制)相关功能的支持,其余的像是视频存储与回放、音频传输好像都是没有支持的,有比较完整支持的一般都是像海康威视/大华等专业领域的摄像头,所以我这摄像头能实现页面上的监控播放和云台控制也基本到头了。

onvif-zeep

操作云台我这里是用的Python的一个依赖库:onvif-zeep,可以用来与支持 ONVIF 协议的网络摄像头或录像机 进行交互,它可以让你在 Python 中轻松实现 发现设备、获取视频流、云台控制(PTZ)、事件订阅 等操作。主要就使用云台控制相关的API,直接放代码:

from onvif import ONVIFCamera
from flask import Flask, request, jsonify

# 摄像头连接信息
IP = '192.168.1.111'
PORT = 80
USERNAME = 'chengpei'
PASSWORD = 'xxxxxxxxxx'

# 物理最大角度(摄像头厂商说明的最大可旋转角度)
MAX_PAN_DEG = 90.0  # 水平 ±90°
MAX_TILT_DEG = 45.0  # 垂直 ±45°

# 创建摄像头对象
mycam = ONVIFCamera(IP, PORT, USERNAME, PASSWORD)
ptz = mycam.create_ptz_service()
media = mycam.create_media_service()
profile = media.GetProfiles()[0]
profile_token = profile.token

# 获取当前 PTZ 位置
status = ptz.GetStatus({'ProfileToken': profile_token})
current_pan = status.Position.PanTilt.x
current_tilt = status.Position.PanTilt.y

# 获取 PTZ 限制范围(防止超出)
pan_min = -1.0
pan_max = 1.0
tilt_min = -1.0
tilt_max = 1.0
if profile.PTZConfiguration.PanTiltLimits:
    pan_min = profile.PTZConfiguration.PanTiltLimits.Range.XRange.Min
    pan_max = profile.PTZConfiguration.PanTiltLimits.Range.XRange.Max
    tilt_min = profile.PTZConfiguration.PanTiltLimits.Range.YRange.Min
    tilt_max = profile.PTZConfiguration.PanTiltLimits.Range.YRange.Max


def move_camera(direction: str):
    global current_pan, current_tilt
    pan_delta = 0.0
    tilt_delta = 0.0

    # 映射角度到 [-1,1] ONVIF 坐标
    deg_to_pan = 3 / MAX_PAN_DEG
    deg_to_tilt = 5 / MAX_TILT_DEG

    direction = direction.lower()
    if direction == 'up':
        tilt_delta = deg_to_tilt
    elif direction == 'down':
        tilt_delta = -deg_to_tilt
    elif direction == 'left':
        pan_delta = -deg_to_pan
    elif direction == 'right':
        pan_delta = deg_to_pan
    else:
        print("Direction must be 'up', 'down', 'left', 'right'")
        return

    # 计算目标位置,确保不超过 PTZ 限制
    target_pan = max(min(current_pan + pan_delta, pan_max), pan_min)
    target_tilt = max(min(current_tilt + tilt_delta, tilt_max), tilt_min)

    # 执行绝对移动
    ptz.AbsoluteMove({
        'ProfileToken': profile_token,
        'Position': {
            'PanTilt': {'x': target_pan, 'y': target_tilt},
            'Zoom': None
        },
        'Speed': {
            'PanTilt': {'x': 0.1, 'y': 0.1},
            'Zoom': None
        }
    })

    # 更新当前位置
    current_pan = target_pan
    current_tilt = target_tilt

    print(f"Moved {direction}: Pan={current_pan:.3f}, Tilt={current_tilt:.3f}")

# ---------------- Flask App ----------------
app = Flask(__name__)

# ---------------- HTTP 接口 ----------------
@app.route('/api/camera/move', methods=['GET'])
def api_move():
    direction = request.args.get('direction', '').lower()
    if not direction:
        return jsonify({'success': False, 'message': 'Missing direction parameter'}), 400

    move_camera(direction)
    return jsonify({'success': True})

# ---------------- 启动 Flask ----------------
if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000, debug=True)

这里是使用flask提供了一个http接口,方便后续在页面上通过调用接口控制摄像头云台转动。

页面实现

页面比较简单,就是一个基于WebRTC的视频流拉取播放,和4个按钮用户调用接口控制云台转动,代码如下:

<!doctype html>
<html lang="zh-CN">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, minimum-scale=1, user-scalable=no">
  <title>WebRTC WHEP 播放器</title>
  <style>
    body { font-family: system-ui, -apple-system, "Segoe UI", Roboto, Arial; background:#f9fafb; color:#111; padding:20px; display:flex; justify-content:center; }
    .card { background:#fff; border-radius:10px; box-shadow:0 6px 18px rgba(0,0,0,0.08); padding:16px; max-width:950px; width:100%; }
    h2 { text-align:center; color:#2563eb; margin-top:0; }

    .controls { display:flex; flex-wrap:nowrap; gap:8px; margin-bottom:12px; align-items:center; justify-content:flex-start; }
    .controls input { padding:8px 10px; border-radius:8px; border:1px solid #ddd; font-size:14px; height:36px; box-sizing:border-box; }
    .controls button { padding:8px 12px; border:none; border-radius:8px; cursor:pointer; font-weight:600; white-space:nowrap; height:36px; transition:0.2s; }
    .controls button:hover { opacity:0.9; transform:translateY(-1px); }
    .controls button:active { transform:translateY(0); }
    #playBtn { background:#10b981; color:#fff; }
    #stopBtn { background:#ef4444; color:#fff; }
    #clearBtn { background:#6b7280; color:#fff; }
    .controls button:disabled { opacity:0.5; cursor:default; }

    video { width:100%; border-radius:10px; background:#000; margin-bottom:10px; }
    pre { width:100%; font-size:13px; border:1px solid #ddd; border-radius:8px; padding:8px; background:#f3f4f6; overflow:auto; white-space:pre-wrap; }

    .controls input:focus { border-color:#2563eb; outline:none; }

    /* 手机/窄屏适配 */
    @media (max-width: 600px) {
      .controls { flex-wrap: wrap; gap:6px; }
      .group-cred { display:flex; flex-direction: column; gap:6px; width:100%; }
      .group-buttons { display:flex; gap:6px; width:100%; justify-content:flex-start; }
      .group-clear { display:flex; width:100%; }
      .controls button { width:100%; }
    }
    /* 加载转圈 */
    #loading {
      display:none;                  /* 默认隐藏 */
      border: 4px solid #f3f3f3;    /* 灰色背景圈 */
      border-top: 4px solid #2563eb; /* 蓝色旋转圈 */
      border-radius: 50%;
      width: 24px;
      height: 24px;
      animation: spin 1s linear infinite;
      margin-left:8px;
    }

    @keyframes spin {
      0% { transform: rotate(0deg); }
      100% { transform: rotate(360deg); }
    }
  </style>
</head>
<body>
<div class="card">
<!--  <h2>📺 mediamtx WebRTC 播放器</h2>-->

  <div class="controls">
    <div class="group-cred">
      <input id="username" type="text" value="" placeholder="用户名" />
      <input id="password" type="password" value="" placeholder="密码" />
    </div>
    <div class="group-buttons">
      <button id="playBtn">▶ 播放</button>
      <div id="loading"></div>
      <button id="stopBtn" disabled>⏹ 停止</button>
    </div>
    <div class="group-clear" style="display: none">
      <button id="clearBtn">🗑 清除日志</button>
    </div>
  </div>

  <video id="player" controls autoplay playsinline></video>
  <!-- 在 video 标签后添加控制按钮 -->
  <div class="controls" style="margin-top: 8px; justify-content:center; gap:6px;">
    <button id="upBtn">⬆ 上</button>
    <button id="downBtn">⬇ 下</button>
    <button id="leftBtn">⬅ 左</button>
    <button id="rightBtn">➡ 右</button>
  </div>
  <pre id="log" style="display: none"></pre>
</div>

<script>
  const logEl = document.getElementById('log');
  const playBtn = document.getElementById('playBtn');
  const stopBtn = document.getElementById('stopBtn');
  const clearBtn = document.getElementById('clearBtn');
  const videoEl = document.getElementById('player');
  const usernameInput = document.getElementById('username');
  const passwordInput = document.getElementById('password');
  const loadingEl = document.getElementById('loading');

  const base = 'https://{我的mediamtx拉流地址}/stream1/';
  let pc = null;

  function ts() { return new Date().toLocaleTimeString(); }
  function log(msg){ logEl.textContent += `[${ts()}] ${msg}\n`; logEl.scrollTop = logEl.scrollHeight; }

  // 从 URL 获取用户名密码填入输入框
  const params = new URLSearchParams(window.location.search);
  const username = params.get("username");
  const password = params.get("password");
  if (username != null) {
    document.getElementById("username").value = username;
  }
  if (password != null) {
    document.getElementById("password").value = password;
  }

  function waitIce(pcInst, timeout=5000){
    return new Promise(resolve=>{
      if(pcInst.iceGatheringState==='complete') return resolve();
      function check(){ if(pcInst.iceGatheringState==='complete'){ pcInst.removeEventListener('icegatheringstatechange',check); resolve(); } }
      pcInst.addEventListener('icegatheringstatechange',check);
      setTimeout(resolve,timeout);
    });
  }

  async function start(){
    const user = usernameInput.value.trim();
    const pass = passwordInput.value.trim();
    if(!user||!pass){ alert('请填写用户名/密码'); return; }

    loadingEl.style.display = 'inline-block'; // 显示转圈
    playBtn.disabled = true;

    const whepUrl = base.endsWith('/')? base+'whep': base+'/whep';
    log('开始播放 -> ' + whepUrl);

    pc = new RTCPeerConnection();

    pc.addEventListener('iceconnectionstatechange',()=>log('ICE状态 -> ' + pc.iceConnectionState));
    pc.addEventListener('track', evt=>{
      log(`收到track -> kind=${evt.track.kind}, id=${evt.track.id}`);
      const stream = evt.streams[0] || new MediaStream([evt.track]);
      videoEl.srcObject = stream;
    });

    try{
      pc.addTransceiver('video',{direction:'recvonly'});
      pc.addTransceiver('audio',{direction:'recvonly'});
    }catch(e){ log('添加Transceiver失败: ' + e); }

    try{
      const offer = await pc.createOffer();
      await pc.setLocalDescription(offer);
      log('本地SDP长度: ' + offer.sdp.length);

      await waitIce(pc);
      log('ICE收集完成');

      const resp = await fetch(whepUrl,{
        method:'POST',
        headers:{
          'Content-Type':'application/sdp',
          'Authorization':'Basic ' + btoa(user + ':' + pass)
        },
        body: offer.sdp
      });

      if(!resp.ok) throw new Error('HTTP ' + resp.status);
      const answerSdp = await resp.text();
      await pc.setRemoteDescription({type:'answer',sdp:answerSdp});
      log('远端SDP已设置,长度: ' + answerSdp.length);

      stopBtn.disabled = false;
    }catch(err){
      log('播放失败: ' + err);
      alert('播放失败: ' + err);
      stop();
    } finally {
      loadingEl.style.display = 'none'; // 隐藏转圈
      playBtn.disabled = false;
    }
  }

  function stop(){
    log('停止播放');
    if(pc){ pc.getSenders().forEach(s=>s.track&&s.track.stop()); pc.close(); pc=null; }
    if(videoEl.srcObject){ videoEl.srcObject.getTracks().forEach(t=>t.stop()); videoEl.srcObject=null; }
    playBtn.disabled=false; stopBtn.disabled=true;
  }

  // PTZ控制按钮事件
  async function moveCamera(direction) {
    try {
      const user = usernameInput.value.trim();
      const pass = passwordInput.value.trim();
      if(!user||!pass){ alert('请填写用户名/密码'); return; }

      const url = `/api/camera/move?direction=${direction}`;
      const resp = await fetch(url, {
        method: 'GET'
      });
      if(!resp.ok) throw new Error('HTTP ' + resp.status);
      log(`摄像头移动 -> ${direction}`);
    } catch(e) {
      log('移动失败: ' + e);
    }
  }

  document.getElementById('upBtn').addEventListener('click',()=>moveCamera('up'));
  document.getElementById('downBtn').addEventListener('click',()=>moveCamera('down'));
  document.getElementById('leftBtn').addEventListener('click',()=>moveCamera('left'));
  document.getElementById('rightBtn').addEventListener('click',()=>moveCamera('right'));

  clearBtn.addEventListener('click',()=>logEl.textContent='日志已清除');
  playBtn.addEventListener('click',start);
  stopBtn.addEventListener('click',stop);
</script>
</body>
</html>

页面代码基本就是让ChatGPT帮我写的,我自己改改就完事了,效果如下:
https://www.chengpei.top/upload/webrtc_page.jpg
页面部署到Nginx配置一下把接口/api/camera/move路由到上面的Python服务提供的接口就行了。

0

评论区