tiandytmd

This commit is contained in:
Ankle committed 2026-09-15 09:53:00 +08:00
1 parent f7a782dcef
commit ddea6d0578
6 files changed
+389 -34

No files matched your search

+30 -4
View File
@@ -1,7 +1,32 @@
# scan参数交互式配置向导 # scan参数交互式配置向导
该工具用于在不同摄像头点位通过固定对话完成scan参数标定、配置替换、完整试扫和验证拼图, 该工具用于在不同摄像头点位通过固定对话完成scan参数标定、配置替换、完整试扫和验证拼图,
适合批量部署RK3576和RK3588。详细的底层命令和原理见[手动配置README.md](./手动配置README.md)。 适合批量部署RK3576和RK3588,并适用于smart-cam已支持的海康威视和天地伟业PTZ摄像头。
详细的底层命令和原理见[手动配置README.md](./手动配置README.md)。
## 摄像头兼容性
标定工具不直接调用海康或天地伟业SDK,也不连接`80/8000/3000`等摄像头端口。
它只调用smart-cam在容器内提供的统一HTTP接口:
- `GET /api/v1/control/status`:检查实际厂商、驱动状态和能力;
- `GET /api/v1/ptz`:读取统一`pan/tilt/zoom`坐标;
- `POST /api/v1/ptz/absolute`:绝对移动;
- `POST /api/v1/snapshot`:抓取标定画面。
因此没有按型号写死的PTZ方向、视场角或zoom档位。程序会使用适配层实际回读的移动量,
通过实拍自动识别pan方向,并在zoom离散时向上选择最近的可达档位。pan或tilt某个方向遇到
机械/固件限位时,会换反方向探测。
`install.sh`会在不移动摄像头的前提下检查`driver_ready`、`get_position`和
`move_absolute`。只要当前型号已被当前smart-cam的对应厂商适配层正确支持,
本工具就无需增加型号分支。若驱动未就绪或型号不支持绝对PTZ,程序会在移动前明确拒绝标定,
不会盲目试转摄像头。
部分天地伟业型号的SDK不能像海康一样直接完成任意绝对坐标移动。smart-cam适配层会改用
“短方向动作 -> 回读实际位置 -> 继续逼近”,所以肉眼看起来会一小段一小段移动。
这是当前型号的控制方式,不是标定程序预先排了多个扫描点,也不能单凭这个现象判定电机故障。
标定时应等待程序自己完成到位回读,不要同时在Web端控制云台。
## 目录结构 ## 目录结构
@@ -217,9 +242,10 @@ smart-cam的HTTP接口读取PTZ并记录,等价于:
水平覆盖不一致;这种摄像头使用单一pan_step无法保证所有行同时零重叠且无缺口; 水平覆盖不一致;这种摄像头使用单一pan_step无法保证所有行同时零重叠且无缺口;
12. 根据多个水平样本的带符号位移,自动识别该型号摄像头从画面左到右时pan是递增还是递减; 12. 根据多个水平样本的带符号位移,自动识别该型号摄像头从画面左到右时pan是递增还是递减;
13. 每次标定会话只编译一次OpenCV重叠分析器,后续step直接复用; 13. 每次标定会话只编译一次OpenCV重叠分析器,后续step直接复用;
14. 如果smart-cam因RTSP采集看门狗而短暂重启,向导会等待scan HTTP恢复, 14. 标定开始前检查摄像头适配层已就绪且具备位置读取/绝对移动能力;
15. 如果smart-cam因RTSP采集看门狗而短暂重启,向导会等待scan HTTP恢复,
然后重试当前PTZ或拍照操作,不会直接丢失整个第四步; 然后重试当前PTZ或拍照操作,不会直接丢失整个第四步;
15. 删除本次自动标定产生的临时截图。 16. 删除本次自动标定产生的临时截图。
这一步不能省略。两个角点只能确定扫描范围,无法确定当前摄像头型号和zoom下的一幅画面 这一步不能省略。两个角点只能确定扫描范围,无法确定当前摄像头型号和zoom下的一幅画面
覆盖多少角度。 覆盖多少角度。
@@ -336,7 +362,7 @@ docker exec smart-cam curl -fsS -X POST \
- 每一个摄像头点位都要重新记录角点; - 每一个摄像头点位都要重新记录角点;
- 摄像头型号或zoom变化时必须重新自动计算step; - 摄像头型号或zoom变化时必须重新自动计算step;
- 不同型号的海康球机可能有相反的pan数值方向;向导会实拍识别,不能用desktop/hanging - 不同厂商和型号的球机可能有相反的pan数值方向;向导会实拍识别,不能用desktop/hanging
或“默认走短弧”代替; 或“默认走短弧”代替;
- 标定期间不要同时从Web界面控制PTZ; - 标定期间不要同时从Web界面控制PTZ;
- 向导按轴设置目标重叠:水平4.5%、垂直2%;最终以完整试扫的接缝结果为准; - 向导按轴设置目标重叠:水平4.5%、垂直2%;最终以完整试扫的接缝结果为准;
+22
View File
@@ -4,6 +4,7 @@ set -Eeuo pipefail
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
CONTAINER_NAME="${SCAN_CALIBRATOR_CONTAINER:-smart-cam}" CONTAINER_NAME="${SCAN_CALIBRATOR_CONTAINER:-smart-cam}"
SCAN_STATUS_URL="http://127.0.0.1:18080/api/v1/scan/status" SCAN_STATUS_URL="http://127.0.0.1:18080/api/v1/scan/status"
CONTROL_STATUS_URL="http://127.0.0.1:18080/api/v1/control/status"
HELPER_SOURCE="$ROOT_DIR/src/measure_overlap.cpp" HELPER_SOURCE="$ROOT_DIR/src/measure_overlap.cpp"
HELPER_CONTAINER_SOURCE="/tmp/scan-calibrator-measure.cpp" HELPER_CONTAINER_SOURCE="/tmp/scan-calibrator-measure.cpp"
HELPER_CONTAINER_BIN="/tmp/scan-calibrator-measure" HELPER_CONTAINER_BIN="/tmp/scan-calibrator-measure"
@@ -127,6 +128,27 @@ if not isinstance(payload.get("scan"), dict):
' <<<"$status" || fail "scan状态接口没有返回合法的scan JSON" ' <<<"$status" || fail "scan状态接口没有返回合法的scan JSON"
ok "scan HTTP服务正常" ok "scan HTTP服务正常"
info "检查摄像头厂商适配层和标定所需PTZ能力"
local control_status vendor
control_status="$(docker exec "$CONTAINER_NAME" curl -fsS --max-time 15 \
"$CONTROL_STATUS_URL")" ||
fail "摄像头控制状态接口不可用:$CONTROL_STATUS_URL"
vendor="$(python3 -c '
import json
import sys
payload = json.load(sys.stdin)
vendor = str(payload.get("vendor") or "unknown").strip().lower()
if payload.get("driver_ready") is not True:
detail = payload.get("driver_error") or "未返回具体原因"
raise SystemExit(f"摄像头驱动未就绪(vendor={vendor}):{detail}")
capabilities = {str(item).strip().lower() for item in payload.get("capabilities", [])}
missing = sorted({"get_position", "move_absolute"} - capabilities)
if missing:
raise SystemExit("缺少PTZ能力:" + ", ".join(missing))
print(vendor)
' <<<"$control_status")" || fail "smart-cam摄像头适配层不满足标定要求"
ok "摄像头适配层正常:vendor=$vendor,支持位置读取和绝对PTZ移动"
info "检查ImageMagick拼图命令" info "检查ImageMagick拼图命令"
identify -version >/dev/null identify -version >/dev/null
montage -version >/dev/null montage -version >/dev/null
+122 -21
View File
@@ -284,12 +284,14 @@ def candidate_deltas(axis: str) -> list[int]:
return [25, 40, 60, 90, 130, 190, 280, 420, 620, 850, 1150] return [25, 40, 60, 90, 130, 190, 280, 420, 620, 850, 1150]
def fov_guided_deltas(delta: int, overlap: float, axis: str) -> list[int]: def fov_guided_deltas(actual_delta: int, overlap: float, axis: str) -> list[int]:
"""Choose probes that retain enough shared image for robust matching.""" """Choose probes that retain enough shared image for robust matching."""
shift = 1.0 - overlap shift = 1.0 - overlap
if not 0.04 <= shift <= 0.92: # Tiny shifts amplify PTZ quantization and image-matching error. Wait for a
# 15%..75% real image shift before replacing the generic probe sequence.
if not 0.15 <= shift <= 0.75:
return [] return []
estimated_fov = delta / shift estimated_fov = actual_delta / shift
minimum = 20 if axis == "pan" else 12 minimum = 20 if axis == "pan" else 12
# Fractions are image displacement/FOV, hence target overlaps are roughly # Fractions are image displacement/FOV, hence target overlaps are roughly
# 82%, 72%, 60%, 48% and 35%. The middle probes provide the strongest # 82%, 72%, 60%, 48% and 35%. The middle probes provide the strongest
@@ -300,6 +302,15 @@ def fov_guided_deltas(delta: int, overlap: float, axis: str) -> list[int]:
}) })
def safe_probe_cap(actual_delta: int, overlap: float) -> int | None:
"""Keep later probes inside the FOV already observed by the camera."""
shift = 1.0 - overlap
if actual_delta <= 0 or not 0.03 <= shift <= 0.85:
return None
estimated_fov = actual_delta / shift
return max(1, round(estimated_fov * 0.85))
def choose_direction(axis: str, origin: dict[str, int], points: dict) -> int: def choose_direction(axis: str, origin: dict[str, int], points: dict) -> int:
if axis == "pan": if axis == "pan":
# Pan is circular. The direction changes only the sign, not the FOV estimate. # Pan is circular. The direction changes only the sign, not the FOV estimate.
@@ -437,12 +448,12 @@ def configured_image_aspect_ratio() -> float:
def failure_detail(result: dict) -> str: def failure_detail(result: dict) -> str:
reason = str(result.get("reason") or "unknown") reason = str(result.get("reason") or "unknown")
labels = { labels = {
"ptz_motion_shortfall": "云台受限,实际移动量不足", "ptz_motion_shortfall": "摄像头实际移动小于请求值,本样本自动跳过",
"not_enough_features": "两张图片可检测特征不足", "not_enough_features": "画面纹理太少,无法测量,本样本自动跳过",
"not_enough_matches": "两张图片的公共特征不足", "not_enough_matches": "移动前后相同画面太少,本样本自动跳过",
"homography_failed": "公共特征无法形成可靠几何变换", "homography_failed": "移动前后相同画面太少或纹理重复,本样本自动跳过",
"axis_motion_too_small": "匹配到的主轴画面位移过小", "axis_motion_too_small": "画面位移太小,本样本自动跳过",
"axis_motion_inconsistent": "匹配位移方向异常或串轴过大", "axis_motion_inconsistent": "画面同时出现过大横纵位移,本样本自动跳过",
} }
fields = [] fields = []
if result.get("actual_axis_motion") is not None: if result.get("actual_axis_motion") is not None:
@@ -476,6 +487,7 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
pending = candidate_deltas(axis) pending = candidate_deltas(axis)
tested: set[int] = set() tested: set[int] = set()
adaptive_seeded = False adaptive_seeded = False
direction_switched = False
while pending: while pending:
delta = pending.pop(0) delta = pending.pop(0)
if delta in tested: if delta in tested:
@@ -485,14 +497,20 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
axis, delta, direction, 1.0, baseline=baseline, origin=origin axis, delta, direction, 1.0, baseline=baseline, origin=origin
) )
snapshots.add(str(result.get("candidate_file", ""))) snapshots.add(str(result.get("candidate_file", "")))
actual_motion = int(result.get("actual_axis_motion") or 0)
switch_threshold = 150 if axis == "pan" else 90
if ( if (
axis == "tilt" not reliable
and not reliable and not direction_switched
and result.get("reason") == "ptz_motion_shortfall" and result.get("reason") == "ptz_motion_shortfall"
and delta >= switch_threshold
and actual_motion < max(2, round(delta * 0.15))
): ):
direction *= -1 direction *= -1
direction_switched = True
say( say(
f" step={delta}: 原方向受云台限位,自动改用相反方向继续标定" f" step={delta}: {axis}在原方向基本没有移动,"
"可能接近限位;程序只切换一次反方向再试"
) )
result = core.measure_delta( result = core.measure_delta(
axis, delta, direction, 1.0, baseline=baseline, origin=origin axis, delta, direction, 1.0, baseline=baseline, origin=origin
@@ -501,7 +519,20 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
if not result.get("valid"): if not result.get("valid"):
reason = str(result.get("reason") or "unknown") reason = str(result.get("reason") or "unknown")
failure_reasons[reason] = failure_reasons.get(reason, 0) + 1 failure_reasons[reason] = failure_reasons.get(reason, 0) + 1
say(f" step={delta}: {failure_detail(result)},继续尝试") stop_expanding = (
bool(reliable)
and reason in {"not_enough_matches", "homography_failed"}
)
say(
f" step={delta}: {failure_detail(result)}"
f",{'停止扩大步长' if stop_expanding else '继续尝试'}"
)
if stop_expanding:
say(
" 移动前后已没有足够相同画面;"
"更大step只会移得更远,现在停止扩大步长。"
)
break
continue continue
overlap = float(result["overlap"]) overlap = float(result["overlap"])
inliers = int(result.get("inliers") or 0) inliers = int(result.get("inliers") or 0)
@@ -511,8 +542,12 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
else: else:
quality = f"有效特征={inliers},内点率={ratio*100:.1f}%" quality = f"有效特征={inliers},内点率={ratio*100:.1f}%"
say(f" step={delta}: 重叠约 {overlap*100:.1f}%,{quality}") say(f" step={delta}: 重叠约 {overlap*100:.1f}%,{quality}")
actual_delta = int(result.get("actual_axis_motion") or delta)
cap = safe_probe_cap(actual_delta, overlap)
if cap is not None:
pending = [value for value in pending if value <= max(delta, cap)]
if not adaptive_seeded: if not adaptive_seeded:
guided = fov_guided_deltas(delta, overlap, axis) guided = fov_guided_deltas(actual_delta, overlap, axis)
if guided: if guided:
# Keep only a small safety tail beyond the guided range. Very # Keep only a small safety tail beyond the guided range. Very
# large fixed probes cannot match once the current optical FOV # large fixed probes cannot match once the current optical FOV
@@ -533,11 +568,9 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
and float(result.get("phase_response") or 0) >= core.PHASE_MIN_RESPONSE and float(result.get("phase_response") or 0) >= core.PHASE_MIN_RESPONSE
) )
if (feature_reliable or phase_reliable) and 0.08 <= overlap <= 0.90: if (feature_reliable or phase_reliable) and 0.08 <= overlap <= 0.90:
# Hikvision absolute PTZ movement is quantized and can also have # Absolute PTZ movement may be quantized or affected by backlash on
# backlash. At high optical zoom, using the requested delta # either vendor. Use the adapter-reported motion, not the request.
# instead of the SDK-reported actual movement creates a large, # Shortfall samples have already been rejected above.
# systematic FOV error. Shortfall samples have already been
# rejected above, so the actual movement is authoritative here.
actual_delta = int(result.get("actual_axis_motion") or delta) actual_delta = int(result.get("actual_axis_motion") or delta)
reliable.append(( reliable.append((
actual_delta, overlap, actual_delta, overlap,
@@ -564,7 +597,10 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
) )
core.fail( core.fail(
f"{axis}方向只得到{len(reliable)}个可靠样本,至少需要2个不同移动量互相验证。" f"{axis}方向只得到{len(reliable)}个可靠样本,至少需要2个不同移动量互相验证。"
f"失败统计:{summary or '无'}。请根据上方每个step的具体原因处理后重试" f"失败统计:{summary or '无'}。\n"
"现场处理:1. 把镜头移到扫描区域中部;"
"2. 画面中保留岸线、树木、建筑等固定纹理;"
"3. 不要操作Web云台;4. 回复1重试。"
) )
# Fixed OSD text and repeated scenery can yield a convincing false # Fixed OSD text and repeated scenery can yield a convincing false
@@ -575,7 +611,9 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
values = ", ".join(f"{sample[2]:.0f}" for sample in reliable) values = ", ".join(f"{sample[2]:.0f}" for sample in reliable)
core.fail( core.fail(
f"{axis}方向不同移动量反算的视场不一致({values}),疑似重复图案误匹配;" f"{axis}方向不同移动量反算的视场不一致({values}),疑似重复图案误匹配;"
"本次结果不写入配置,请更换标定画面" "本次结果不写入配置。\n"
"现场处理:把镜头移到扫描区域中部,对准距离较远、"
"整幅都有不重复固定纹理的场景,然后回复1重试。"
) )
estimated_fov, spread = fov_statistics(consistent) estimated_fov, spread = fov_statistics(consistent)
@@ -1003,10 +1041,72 @@ def reusable_step_axes(state: dict, points: dict) -> set[str]:
return reusable return reusable
def acquire_calibration_control() -> bool:
"""Pause an active patrol and wait for its in-flight PTZ command to end."""
status, camera = core.get_control_status()
host = str(camera["camera"].get("sdk_host") or "").strip()
detail = f",摄像头={host}" if host else ""
say(
f"已识别摄像头适配层:vendor={camera['vendor']}{detail};"
"PTZ位置读取和绝对移动能力正常。"
)
control = status.get("control", {})
if not isinstance(control, dict):
core.fail("scan HTTP未返回合法的control状态")
if bool(control.get("scan_active")):
core.fail("当前正在执行完整扫图,请等待扫图结束后再运行标定向导")
should_resume = bool(
control.get("patrol_active")
and not control.get("patrol_paused_by_operator")
)
if should_resume:
core.api(
"POST", "/api/v1/patrol/stop",
{"source": "scan_calibrator"}, timeout=15,
)
say("已临时暂停巡航;标定结束或正常中断时会自动恢复。", color=YELLOW)
try:
# This is a synchronization barrier: GET waits behind any PTZ command
# already in flight when patrol was paused, then claims manual control.
core.get_ptz()
except BaseException:
if should_resume:
try:
core.api(
"POST", "/api/v1/patrol/start",
{"source": "scan_calibrator"}, timeout=15,
)
except BaseException as resume_error:
core.warn(f"标定准备失败后未能自动恢复巡航:{resume_error}")
raise
return should_resume
def release_calibration_control(should_resume: bool) -> None:
if not should_resume:
return
try:
core.api(
"POST", "/api/v1/patrol/start",
{"source": "scan_calibrator"}, timeout=15,
)
say("已恢复进入向导前的巡航状态。", color=YELLOW)
except BaseException as exc:
core.warn(
f"未能自动恢复巡航:{exc};请执行 "
"docker exec smart-cam curl -sS -X POST "
"http://127.0.0.1:18080/api/v1/patrol/start"
)
def main() -> None: def main() -> None:
snapshots: set[str] = set() snapshots: set[str] = set()
resume_patrol = False
try: try:
say("您好!我们现在开始配置扫图参数!", color=GREEN) say("您好!我们现在开始配置扫图参数!", color=GREEN)
resume_patrol = acquire_calibration_control()
say( say(
"标定进度会逐步保存;中途回复 3 或按 Ctrl+C 退出后," "标定进度会逐步保存;中途回复 3 或按 Ctrl+C 退出后,"
"下次运行仍可选择本次记录继续。" "下次运行仍可选择本次记录继续。"
@@ -1150,6 +1250,7 @@ def main() -> None:
say(f"\n向导失败:{exc}", color=RED) say(f"\n向导失败:{exc}", color=RED)
finally: finally:
remove_session_snapshots(snapshots) remove_session_snapshots(snapshots)
release_calibration_control(resume_patrol)
if __name__ == "__main__": if __name__ == "__main__":
+95 -8
View File
@@ -8,6 +8,7 @@ from datetime import datetime
import json import json
import math import math
from pathlib import Path from pathlib import Path
import re
import subprocess import subprocess
import sys import sys
import time import time
@@ -26,6 +27,7 @@ API_RECOVERY_TIMEOUT = 90.0
API_RECOVERY_POLL_INTERVAL = 2.0 API_RECOVERY_POLL_INTERVAL = 2.0
TRANSIENT_CURL_EXIT_CODES = {7, 28, 52, 55, 56} TRANSIENT_CURL_EXIT_CODES = {7, 28, 52, 55, 56}
_HELPER_READY = False _HELPER_READY = False
REQUIRED_PTZ_CAPABILITIES = {"get_position", "move_absolute"}
def fail(message: str) -> "NoReturn": def fail(message: str) -> "NoReturn":
@@ -69,6 +71,45 @@ def transient_api_failure(result: subprocess.CompletedProcess[str]) -> bool:
)) ))
def api_error_detail(result: subprocess.CompletedProcess[str]) -> str:
"""Turn scan-service JSON errors into field-operator instructions."""
message = ""
code = ""
try:
payload = json.loads(result.stdout)
error = payload.get("error", {})
if isinstance(error, dict):
code = str(error.get("code") or "")
message = str(error.get("message") or "")
except (json.JSONDecodeError, AttributeError, TypeError):
message = result.stdout.strip()
reached = re.search(
r"move_not_reached\s+pan_error=(-?\d+)\s+tilt_error=(-?\d+)\s+"
r"zoom_error=(-?\d+)\s+target=(-?\d+),(-?\d+),(-?\d+)\s+"
r"actual=(-?\d+),(-?\d+),(-?\d+)",
message,
)
if reached:
pe, te, ze, tp, tt, tz, ap, at, az = (
int(value) for value in reached.groups()
)
return (
"摄像头在限定时间内没有完全到位。\n"
f" 目标:pan={tp} ({tp / 100:.2f}°), "
f"tilt={tt} ({tt / 100:.2f}°), zoom={tz} ({tz / 100:.2f}×)\n"
f" 实际:pan={ap} ({ap / 100:.2f}°), "
f"tilt={at} ({at / 100:.2f}°), zoom={az} ({az / 100:.2f}×)\n"
f" 未到位差值:pan={pe / 100:.2f}°, "
f"tilt={te / 100:.2f}°, zoom={ze / 100:.2f}×\n"
"现场处理:请勿再操作Web云台,把第四步标定位置放在"
"扫描区域中部后重试;程序会自动限制后续测试步长。"
)
if message:
return f"摄像头控制失败({code or 'unknown'}):{message}"
return result.stderr.strip() or f"请求退出码={result.returncode}"
def scan_http_ready() -> bool: def scan_http_ready() -> bool:
try: try:
state = subprocess.run( state = subprocess.run(
@@ -114,9 +155,7 @@ def api(method: str, path: str, body: dict | None = None, timeout: int = 60) ->
except json.JSONDecodeError: except json.JSONDecodeError:
fail(f"scan HTTP返回的不是JSON:{result.stdout}") fail(f"scan HTTP返回的不是JSON:{result.stdout}")
last_detail = result.stderr.strip() or result.stdout.strip() or ( last_detail = api_error_detail(result)
f"退出码={result.returncode}"
)
if not transient_api_failure(result) or attempt == API_MAX_ATTEMPTS: if not transient_api_failure(result) or attempt == API_MAX_ATTEMPTS:
break break
warn( warn(
@@ -131,17 +170,65 @@ def api(method: str, path: str, body: dict | None = None, timeout: int = 60) ->
# the in-container overlap helper before the next image comparison. # the in-container overlap helper before the next image comparison.
_HELPER_READY = False _HELPER_READY = False
fail(f"命令失败:{' '.join(command)}\n{last_detail}") fail(f"scan HTTP请求失败:{method} {path}\n{last_detail}")
def validate_control_status(payload: dict) -> dict:
"""Validate the vendor-neutral PTZ contract exposed by smart-cam."""
if not isinstance(payload, dict):
fail("scan HTTP控制状态不是JSON对象")
vendor = str(payload.get("vendor") or "unknown").strip().lower()
if payload.get("driver_ready") is not True:
detail = str(payload.get("driver_error") or "未返回具体原因")
fail(f"摄像头驱动未就绪(vendor={vendor}):{detail}")
capabilities = {
str(item).strip().lower()
for item in payload.get("capabilities", [])
if str(item).strip()
}
missing = sorted(REQUIRED_PTZ_CAPABILITIES - capabilities)
if missing:
fail(
f"当前smart-cam的摄像头适配层缺少标定必需能力:"
f"{', '.join(missing)}(vendor={vendor})"
)
camera = payload.get("camera")
return {
"vendor": vendor,
"capabilities": capabilities,
"camera": camera if isinstance(camera, dict) else {},
}
def get_control_status() -> tuple[dict, dict]:
payload = api("GET", "/api/v1/control/status", timeout=15)
return payload, validate_control_status(payload)
def parse_ptz_payload(payload: dict) -> dict[str, int]:
try:
ptz = payload["ptz"]
result = {key: int(ptz[key]) for key in ("pan", "tilt", "zoom")}
except (KeyError, TypeError, ValueError):
fail(f"scan HTTP未返回合法PTZ坐标:{payload}")
if not 0 <= result["pan"] <= 36000:
fail(f"scan HTTP返回的pan超出统一坐标范围:{result['pan']}")
if not -18000 <= result["tilt"] <= 18000 or result["zoom"] <= 0:
fail(f"scan HTTP返回的tilt/zoom无效:{result}")
return result
def get_ptz() -> dict[str, int]: def get_ptz() -> dict[str, int]:
payload = api("GET", "/api/v1/ptz", timeout=15) payload = api("GET", "/api/v1/ptz?source=scan_calibrator", timeout=50)
return {key: int(payload["ptz"][key]) for key in ("pan", "tilt", "zoom")} return parse_ptz_payload(payload)
def move(ptz: dict[str, int]) -> dict[str, int]: def move(ptz: dict[str, int]) -> dict[str, int]:
payload = api("POST", "/api/v1/ptz/absolute", ptz, timeout=50) payload = api(
return {key: int(payload["ptz"][key]) for key in ("pan", "tilt", "zoom")} "POST", "/api/v1/ptz/absolute",
{**ptz, "source": "scan_calibrator"}, timeout=50,
)
return parse_ptz_payload(payload)
def snapshot() -> str: def snapshot() -> str:
+119
View File
@@ -51,6 +51,29 @@ class ApiRecoveryTests(unittest.TestCase):
self.assertEqual(payload["scan"]["state"], "idle") self.assertEqual(payload["scan"]["state"], "idle")
wait_mock.assert_called_once_with() wait_mock.assert_called_once_with()
def test_move_not_reached_error_is_explained_for_operator(self):
result = completed(
22,
stdout=json.dumps({
"error": {
"code": "sdk_error",
"message": (
"ERR move_not_reached pan_error=-25 tilt_error=56 "
"zoom_error=0 target=25892,2044,4620 "
"actual=25917,1988,4620"
),
}
}),
stderr="curl: (22) The requested URL returned error: 500",
)
detail = core.api_error_detail(result)
self.assertIn("没有完全到位", detail)
self.assertIn("tilt=2044 (20.44°)", detail)
self.assertIn("实际", detail)
self.assertIn("现场处理", detail)
@mock.patch("scan_calibrator.wait_for_scan_http") @mock.patch("scan_calibrator.wait_for_scan_http")
@mock.patch("scan_calibrator.subprocess.run") @mock.patch("scan_calibrator.subprocess.run")
def test_non_transient_http_error_is_not_retried(self, run_mock, wait_mock): def test_non_transient_http_error_is_not_retried(self, run_mock, wait_mock):
@@ -111,8 +134,104 @@ class ApiRecoveryTests(unittest.TestCase):
self.assertIn("恢复起点失败", stderr.getvalue()) self.assertIn("恢复起点失败", stderr.getvalue())
self.assertEqual(move_mock.call_count, 2) self.assertEqual(move_mock.call_count, 2)
@mock.patch("scan_calibrator.api")
def test_calibration_ptz_requests_identify_their_control_source(self, api_mock):
api_mock.return_value = {"ptz": {"pan": 1, "tilt": 2, "zoom": 3}}
self.assertEqual(core.get_ptz(), {"pan": 1, "tilt": 2, "zoom": 3})
api_mock.assert_called_with(
"GET", "/api/v1/ptz?source=scan_calibrator", timeout=50
)
core.move({"pan": 4, "tilt": 5, "zoom": 6})
api_mock.assert_called_with(
"POST", "/api/v1/ptz/absolute",
{"pan": 4, "tilt": 5, "zoom": 6,
"source": "scan_calibrator"},
timeout=50,
)
def test_hikvision_control_contract_is_supported(self):
result = core.validate_control_status({
"vendor": "hikvision",
"driver_ready": True,
"capabilities": ["get_position", "move_absolute", "jog"],
"camera": {"sdk_host": "192.168.2.10", "sdk_port": 8000},
})
self.assertEqual(result["vendor"], "hikvision")
self.assertIn("move_absolute", result["capabilities"])
def test_tiandy_control_contract_is_supported(self):
result = core.validate_control_status({
"vendor": "tiandy",
"driver_ready": True,
"capabilities": ["get_position", "move_absolute", "presets"],
"camera": {"sdk_host": "192.168.100.64", "sdk_port": 3000},
})
self.assertEqual(result["vendor"], "tiandy")
self.assertIn("get_position", result["capabilities"])
def test_control_contract_rejects_unready_driver(self):
with self.assertRaisesRegex(SystemExit, "login failed"):
core.validate_control_status({
"vendor": "tiandy",
"driver_ready": False,
"driver_error": "login failed",
"capabilities": [],
})
def test_control_contract_rejects_missing_absolute_move(self):
with self.assertRaisesRegex(SystemExit, "move_absolute"):
core.validate_control_status({
"vendor": "hikvision",
"driver_ready": True,
"capabilities": ["get_position", "jog"],
})
def test_ptz_response_must_use_normalized_contract(self):
with self.assertRaisesRegex(SystemExit, "pan超出"):
core.parse_ptz_payload({
"ptz": {"pan": 40000, "tilt": 100, "zoom": 200}
})
class InteractiveWizardTests(unittest.TestCase): class InteractiveWizardTests(unittest.TestCase):
def test_guided_steps_use_actual_motion_not_requested_motion(self):
self.assertEqual(
wizard.fov_guided_deltas(62, 0.802, "tilt"),
[56, 88, 125, 163, 204],
)
def test_guided_steps_wait_for_measurable_image_shift(self):
self.assertEqual(wizard.fov_guided_deltas(33, 0.952, "tilt"), [])
def test_probe_cap_uses_camera_reported_motion(self):
self.assertEqual(wizard.safe_probe_cap(33, 0.952), 584)
self.assertEqual(wizard.safe_probe_cap(62, 0.802), 266)
@mock.patch("interactive_calibrator.core.get_ptz")
@mock.patch("interactive_calibrator.core.api")
def test_calibration_pauses_active_patrol_before_ptz_barrier(
self, api_mock, get_ptz_mock
):
status = {"control": {"scan_active": False, "patrol_active": True,
"patrol_paused_by_operator": False}}
camera = {"vendor": "tiandy", "camera": {}, "capabilities": set()}
api_mock.return_value = {"success": True}
with mock.patch(
"interactive_calibrator.core.get_control_status",
return_value=(status, camera),
):
self.assertTrue(wizard.acquire_calibration_control())
api_mock.assert_called_once_with(
"POST", "/api/v1/patrol/stop",
{"source": "scan_calibrator"}, timeout=15,
)
get_ptz_mock.assert_called_once_with()
def test_manual_ptz_accepts_annotated_display_format(self): def test_manual_ptz_accepts_annotated_display_format(self):
self.assertEqual( self.assertEqual(
wizard.parse_ptz_input( wizard.parse_ptz_input(
+1 -1
View File
@@ -2,7 +2,7 @@
这个程序用于不同摄像头点位的scan现场标定: 这个程序用于不同摄像头点位的scan现场标定:
- 读取当前海康摄像头PTZ; - 通过smart-cam统一接口读取当前海康威视或天地伟业摄像头PTZ;
- 记录扫描区域左上角和右下角; - 记录扫描区域左上角和右下角;
- 在固定zoom下移动一个候选角度并抓取两张图; - 在固定zoom下移动一个候选角度并抓取两张图;
- 用ORB特征和RANSAC单应矩阵定位公共特征,再按当前移动轴的画面位移计算重叠率; - 用ORB特征和RANSAC单应矩阵定位公共特征,再按当前移动轴的画面位移计算重叠率;