PTZ 最反直觉的地方:ONVIF 不告诉你「转多少度」,只说「转到 -1 到 1 之间的某个值」。 而 y 轴的负方向是「下」——这是数学坐标系,不是屏幕坐标系。
上一节讲事件,这一节讲「能动的那部分」。 PTZ 是 07.02 里唯一「发出去就立刻有物理后果」的一组——前面几节都是读或改配置, 这一节会让云台真的转起来。
一、GetNodes 告诉你能做什么
| 项 | 本例的值 | 含义 |
|---|---|---|
MaximumNumberOfPresets |
32 | 预置位数量上限 |
HomeSupported |
true |
支不支持「回原位」 |
预置位是个很实用的功能:把「看大门口」存成预置位 1,以后一句话就转过去。
它的上限是设备能力的一部分,有的设备只给 8 个,有的给 32 个(本例 32)。
做「自动巡航」时这个数就是循环次数的上限。
第二行的 HomeSupported 常被忽略:支持的话可以 GotoHomePosition 一键回原位,
不支持就只能自己记一个位置再 AbsoluteMove 回去。
后者的问题是「原来在哪」这件事设备不记得——如果你的程序也不知道,那就回不去了。
二、绝对位置和连续速度,两套空间
支持哪些坐标空间,也是 GetNodes 的一部分:
| 空间类型 | URI 尾段 | 含义 |
|---|---|---|
AbsolutePanTiltPositionSpace |
PositionGenericSpace |
转到哪 |
ContinuousPanTiltVelocitySpace |
VelocityGenericSpace |
以多快转 |
两种都能用,看你要什么:要精确定位就用绝对位置,要手感(像摇杆)就用速度。
GenericSpace 这个词的意思是「通用空间」——它的范围就是 -1 到 1,不映射到任何实际角度。
所以设备不告诉你「-1 对应多少度」,这是规范故意的:不同镜头的视野不一样,没法统一。
一个 2.8 毫米广角和一个 12 毫米长焦,同样的 -1 到 1 覆盖的角度差好几倍。
所以「转到东边 30 度」这种需求在 ONVIF 里无法直接表达,
只能靠「先转到某个已知位置、再按比例推」。
三、ContinuousMove 的参数和那个负号
连续移动的请求长这样:
<tptz:ContinuousMove>
<tptz:ProfileToken>Profile_1</tptz:ProfileToken>
<tptz:Velocity>
<tt:PanTilt x="0.5" y="-0.2"/>
<tt:Zoom x="0"/>
</tptz:Velocity>
</tptz:ContinuousMove>
| 参数 | 范围 | 含义 |
|---|---|---|
PanTilt x |
-1 ~ 1 | 左右:负是左,正是右 |
PanTilt y |
-1 ~ 1 | 上下:负是下,正是上 |
Zoom x |
-1 ~ 1 | 变倍:负是广角,正是长焦 |
第二行那个「负是下」容易记反:这是数学坐标系的习惯(y 向上为正),
不是屏幕坐标(y 向下为正)。
所以「想往下看」要发 y=-0.5,不是 +0.5。
这个反直觉的设定,是 PTZ 调试里最常见的错误来源。
它的表现是「方向反了」而不是「报错」,所以调试时很容易怀疑是设备装反了,
实际上是符号写反了。
建议第一次接设备时,四个方向各发一次,记在纸上。
四、发了不停,要自己叫停
ContinuousMove 返回成功不等于云台会自己停:它一直转到收到 Stop 或者撞到限位。
| 场景 | 会发生什么 | 怎么防 |
|---|---|---|
客户端正常发 Stop |
停住 | — |
| 客户端崩了 | 一直转,撞限位才停 | 设 Timeout |
| 网络断了 | 同上 | 设 Timeout |
「发了不停」是真实故障:客户端崩了、网络断了,云台就一直转,撞到限位才停。
所以工程做法是两条:① 移动时同时发一个「超时自动停」;② 客户端异常退出时兜底发 Stop。
第三列那个 Timeout 是 ContinuousMove 的可选参数,给了它就自动停,不给就一直转。
「不给」是最常见的错——因为不报错、调试时也看不出问题,只有在异常退出时才暴露。
这和第七章前面那些「静默失败」是一类东西,特征都是「跑得通,但出事时才发现少了一层保护」。
五、动手 Demo:云台,坐标是归一化的
Demo 先问能力(能存几个预置位、支持哪些坐标空间), 再发一次连续移动、一次停止,最后查状态。 本机没有云台,所以「动没动」验不了,只能验「报文被接受」。
# ① 能力
_a, b, _n = S.call(port, "ptz", "GetNodes")
node = ET.fromstring(b).find(".//{%s}PTZNode" % S.NS_TPTZ)
print(node.find("{%s}MaximumNumberOfPresets" % S.NS_TT).text)
for sp in node.iter("{%s}URI" % S.NS_TT):
print(" ", sp.text.split("/")[-1])
# ② 连续移动(y 负是下)
move = ("<tptz:ContinuousMove>"
"<tptz:ProfileToken>Profile_1</tptz:ProfileToken>"
"<tptz:Velocity><tt:PanTilt x=\"0.5\" y=\"-0.2\"/>"
"<tt:Zoom x=\"0\"/></tptz:Velocity>"
"</tptz:ContinuousMove>")
_a, b2, _n = S.call(port, "ptz", "ContinuousMove", move)
print("move fault:", b2.count("Fault"))
# ③ 停,再查状态
stop = ("<tptz:Stop><tptz:ProfileToken>Profile_1</tptz:ProfileToken>"
"<tptz:PanTilt>true</tptz:PanTilt>"
"<tptz:Zoom>true</tptz:Zoom></tptz:Stop>")
_a, b3, _n = S.call(port, "ptz", "Stop", stop)
print("stop fault:", b3.count("Fault"))
_a, b4, _n = S.call(port, "ptz", "GetStatus",
"<tptz:GetStatus><tptz:ProfileToken>Profile_1"
"</tptz:ProfileToken></tptz:GetStatus>")
st = ET.fromstring(b4).find(".//{%s}PTZStatus" % S.NS_TT)
pt = st.find("{%s}Position/{%s}PanTilt" % (S.NS_TT, S.NS_TT))
print(pt.get("x"), pt.get("y"))
实跑输出:
--------------------------------------------------------------------
云台,坐标是归一化的
--------------------------------------------------------------------
**PTZ 最反直觉的地方:ONVIF 不告诉你「转多少度」,只说「转到 -1 到 1 之间的某个值」。**
这一节把坐标空间、速度、状态三个东西看清楚。
**`GetNodes` 告诉你能做什么:**
项 值 含义
--------------------------------------------------------------------------------------
`MaximumNumberOfPresets` 32 **预置位数量上限**
`HomeSupported` true 支不支持「回原位」
**预置位是个很实用的功能**: **把「看大门口」存成预置位 1,以后一句话就转过去。**
**它的上限是设备能力的一部分**, **有的设备只给 8 个,有的给 32 个**(本例 32)。
**做「自动巡航」时这个数就是循环次数的上限。**
**支持哪些坐标空间:**
空间类型 URI 尾段
--------------------------------------------------------------------------
AbsolutePanTiltPositionSpace `PositionGenericSpace`
ContinuousPanTiltVelocitySpace `VelocityGenericSpace`
**`PositionSpace` 是「转到哪」,`VelocitySpace` 是「以多快转」。**
**两种都能用,看你要什么**: **要精确定位就用绝对位置,要手感(像摇杆)就用速度。**
**`GenericSpace` 这个词的意思是「通用空间」**——
**它的范围就是 -1 到 1,不映射到任何实际角度。**
**所以设备不告诉你「-1 对应多少度」**, **这是规范故意的:不同镜头的视野不一样,没法统一。**
**连续移动(`ContinuousMove`)的几个参数:**
`<tptz:ContinuousMove>`
` <tptz:ProfileToken>Profile_1</tptz:ProfileToken>`
` <tptz:Velocity><tt:PanTilt x="0.5" y="-0.2"/>`
` <tt:Zoom x="0"/></tptz:Velocity>`
`</tptz:ContinuousMove>`
参数 范围 含义
----------------------------------------------------------------------------
`PanTilt x` -1 ~ 1 **左右:负是左,正是右**
`PanTilt y` -1 ~ 1 **上下:负是下,正是上**
`Zoom x` -1 ~ 1 **变倍:负是广角,正是长焦**
**第二行那个「负是下」容易记反**:
**这是数学坐标系的习惯(y 向上为正)**, **不是屏幕坐标(y 向下为正)。**
**所以「想往下看」要发 `y=-0.5`,不是 `+0.5`。**
**这个反直觉的设定,是 PTZ 调试里最常见的错误来源。**
**连续移动不停,要自己叫停:**
**`ContinuousMove` 返回成功(是),`Stop` 返回成功(是)**
**「发了不停」是真实故障**:
**客户端崩了、网络断了,云台就一直转,撞到限位才停。**
**所以工程做法是两条**:
**① 移动时同时发一个「超时自动停」;② 客户端异常退出时兜底发 `Stop`。**
**ONVIF 的 `ContinuousMove` 有个可选的 `Timeout` 参数**,
**给了它就自动停,不给就一直转。** **不给是最常见的错。**
**状态查询(`GetStatus`)返回现在在哪:**
字段 值
--------------------------------------------------------
`PanTilt` x=0.25 y=-0.1
`Zoom` x=1.0
`UtcTime` 2024-05-01T12:00:00Z
**第三行那个时间是 UTC**——
**设备报的是绝对时刻,不是「相对某个起点」。**
**所以连续两次 `GetStatus` 的时间差就是真实间隔**,
**这一点比很多私有协议强(那些只给个计数器)。**
**不过位置还是归一化的**:
**`x=0.25` 到底指向哪,只有设备自己知道。**
**要做「转到某个预置位」,用预置位 Token,不要用坐标。**
GetStatus 返回的位置是我在模拟设备里写死的(x=0.25 y=-0.1),
它不会因为前面那次 ContinuousMove 而改变——因为模拟设备根本没有电机。
这一点必须说清,否则会误以为「移动之后状态会跟着变」。
三次调用都是「返回成功」,这只能说明「设备接受了报文」,不能说明「云台真的动了」。
真设备上要验「动没动」,只能看画面或者 GetStatus 的位置变化。
Stop 的两个布尔参数(PanTilt/Zoom)决定停哪个轴,都写 true 就是全停。
只停一个轴的场景也有(比如只停变倍、继续转),
但那样容易出「转到一半停住」的怪状态,一般不建议。
30 秒实验
- 把
y从-0.2改成0.2,想想画面上会往上还是往下。 - 把
Zoom x改成-1,看模拟设备是接受还是回 Fault。 - 给
ContinuousMove加一个Timeout参数(PT5S),看模拟设备认不认。
常见误区
- 以为坐标是角度。 它是归一化的 -1 到 1,不映射到任何实际角度,因为不同镜头视野不同。
- 以为 y 正是「下」。 y 负才是下,这是数学坐标系,不是屏幕坐标系。
ContinuousMove不带Timeout。 云台会一直转,客户端崩了就撞限位,必须设超时。
自测三问(点开对答案)
1. 为什么 ONVIF 不告诉你「-1 对应多少度」? —— 因为**不同镜头的视野不一样**,**同样 -1 到 1 覆盖的角度差好几倍**,没法统一。 2. 「往下看」应该发 `y=0.5` 还是 `y=-0.5`? —— **`y=-0.5`。** PTZ 用数学坐标系,**y 负是下**。 3. `ContinuousMove` 最容易被漏掉的参数是什么?为什么危险? —— **`Timeout`。** 不带的话云台会一直转,**客户端崩溃或断网时就撞限位**。下一节讲视频分析:规则和模块是两回事,以及「能画框」为什么不是 ONVIF 的基本能力。
- ☐ (本节完,下一节 07.02.13 视频分析)