tiandytmd
This commit is contained in:
1 parent
f7a782dcef
commit
ddea6d0578
6 files changed
+389
-34
No files matched your search
@@ -1,7 +1,32 @@
|
||||
# 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无法保证所有行同时零重叠且无缺口;
|
||||
12. 根据多个水平样本的带符号位移,自动识别该型号摄像头从画面左到右时pan是递增还是递减;
|
||||
13. 每次标定会话只编译一次OpenCV重叠分析器,后续step直接复用;
|
||||
14. 如果smart-cam因RTSP采集看门狗而短暂重启,向导会等待scan HTTP恢复,
|
||||
14. 标定开始前检查摄像头适配层已就绪且具备位置读取/绝对移动能力;
|
||||
15. 如果smart-cam因RTSP采集看门狗而短暂重启,向导会等待scan HTTP恢复,
|
||||
然后重试当前PTZ或拍照操作,不会直接丢失整个第四步;
|
||||
15. 删除本次自动标定产生的临时截图。
|
||||
16. 删除本次自动标定产生的临时截图。
|
||||
|
||||
这一步不能省略。两个角点只能确定扫描范围,无法确定当前摄像头型号和zoom下的一幅画面
|
||||
覆盖多少角度。
|
||||
@@ -336,7 +362,7 @@ docker exec smart-cam curl -fsS -X POST \
|
||||
|
||||
- 每一个摄像头点位都要重新记录角点;
|
||||
- 摄像头型号或zoom变化时必须重新自动计算step;
|
||||
- 不同型号的海康球机可能有相反的pan数值方向;向导会实拍识别,不能用desktop/hanging
|
||||
- 不同厂商和型号的球机可能有相反的pan数值方向;向导会实拍识别,不能用desktop/hanging
|
||||
或“默认走短弧”代替;
|
||||
- 标定期间不要同时从Web界面控制PTZ;
|
||||
- 向导按轴设置目标重叠:水平4.5%、垂直2%;最终以完整试扫的接缝结果为准;
|
||||
|
||||
+22
@@ -4,6 +4,7 @@ set -Eeuo pipefail
|
||||
ROOT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
CONTAINER_NAME="${SCAN_CALIBRATOR_CONTAINER:-smart-cam}"
|
||||
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_CONTAINER_SOURCE="/tmp/scan-calibrator-measure.cpp"
|
||||
HELPER_CONTAINER_BIN="/tmp/scan-calibrator-measure"
|
||||
@@ -127,6 +128,27 @@ if not isinstance(payload.get("scan"), dict):
|
||||
' <<<"$status" || fail "scan状态接口没有返回合法的scan JSON"
|
||||
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拼图命令"
|
||||
identify -version >/dev/null
|
||||
montage -version >/dev/null
|
||||
|
||||
+122
-21
@@ -284,12 +284,14 @@ def candidate_deltas(axis: str) -> list[int]:
|
||||
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."""
|
||||
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 []
|
||||
estimated_fov = delta / shift
|
||||
estimated_fov = actual_delta / shift
|
||||
minimum = 20 if axis == "pan" else 12
|
||||
# Fractions are image displacement/FOV, hence target overlaps are roughly
|
||||
# 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:
|
||||
if axis == "pan":
|
||||
# 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:
|
||||
reason = str(result.get("reason") or "unknown")
|
||||
labels = {
|
||||
"ptz_motion_shortfall": "云台受限,实际移动量不足",
|
||||
"not_enough_features": "两张图片可检测特征不足",
|
||||
"not_enough_matches": "两张图片的公共特征不足",
|
||||
"homography_failed": "公共特征无法形成可靠几何变换",
|
||||
"axis_motion_too_small": "匹配到的主轴画面位移过小",
|
||||
"axis_motion_inconsistent": "匹配位移方向异常或串轴过大",
|
||||
"ptz_motion_shortfall": "摄像头实际移动小于请求值,本样本自动跳过",
|
||||
"not_enough_features": "画面纹理太少,无法测量,本样本自动跳过",
|
||||
"not_enough_matches": "移动前后相同画面太少,本样本自动跳过",
|
||||
"homography_failed": "移动前后相同画面太少或纹理重复,本样本自动跳过",
|
||||
"axis_motion_too_small": "画面位移太小,本样本自动跳过",
|
||||
"axis_motion_inconsistent": "画面同时出现过大横纵位移,本样本自动跳过",
|
||||
}
|
||||
fields = []
|
||||
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)
|
||||
tested: set[int] = set()
|
||||
adaptive_seeded = False
|
||||
direction_switched = False
|
||||
while pending:
|
||||
delta = pending.pop(0)
|
||||
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
|
||||
)
|
||||
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 (
|
||||
axis == "tilt"
|
||||
and not reliable
|
||||
not reliable
|
||||
and not direction_switched
|
||||
and result.get("reason") == "ptz_motion_shortfall"
|
||||
and delta >= switch_threshold
|
||||
and actual_motion < max(2, round(delta * 0.15))
|
||||
):
|
||||
direction *= -1
|
||||
direction_switched = True
|
||||
say(
|
||||
f" step={delta}: 原方向受云台限位,自动改用相反方向继续标定"
|
||||
f" step={delta}: {axis}在原方向基本没有移动,"
|
||||
"可能接近限位;程序只切换一次反方向再试"
|
||||
)
|
||||
result = core.measure_delta(
|
||||
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"):
|
||||
reason = str(result.get("reason") or "unknown")
|
||||
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
|
||||
overlap = float(result["overlap"])
|
||||
inliers = int(result.get("inliers") or 0)
|
||||
@@ -511,8 +542,12 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
||||
else:
|
||||
quality = f"有效特征={inliers},内点率={ratio*100:.1f}%"
|
||||
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:
|
||||
guided = fov_guided_deltas(delta, overlap, axis)
|
||||
guided = fov_guided_deltas(actual_delta, overlap, axis)
|
||||
if guided:
|
||||
# Keep only a small safety tail beyond the guided range. Very
|
||||
# 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
|
||||
)
|
||||
if (feature_reliable or phase_reliable) and 0.08 <= overlap <= 0.90:
|
||||
# Hikvision absolute PTZ movement is quantized and can also have
|
||||
# backlash. At high optical zoom, using the requested delta
|
||||
# instead of the SDK-reported actual movement creates a large,
|
||||
# systematic FOV error. Shortfall samples have already been
|
||||
# rejected above, so the actual movement is authoritative here.
|
||||
# Absolute PTZ movement may be quantized or affected by backlash on
|
||||
# either vendor. Use the adapter-reported motion, not the request.
|
||||
# Shortfall samples have already been rejected above.
|
||||
actual_delta = int(result.get("actual_axis_motion") or delta)
|
||||
reliable.append((
|
||||
actual_delta, overlap,
|
||||
@@ -564,7 +597,10 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
||||
)
|
||||
core.fail(
|
||||
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
|
||||
@@ -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)
|
||||
core.fail(
|
||||
f"{axis}方向不同移动量反算的视场不一致({values}),疑似重复图案误匹配;"
|
||||
"本次结果不写入配置,请更换标定画面"
|
||||
"本次结果不写入配置。\n"
|
||||
"现场处理:把镜头移到扫描区域中部,对准距离较远、"
|
||||
"整幅都有不重复固定纹理的场景,然后回复1重试。"
|
||||
)
|
||||
|
||||
estimated_fov, spread = fov_statistics(consistent)
|
||||
@@ -1003,10 +1041,72 @@ def reusable_step_axes(state: dict, points: dict) -> set[str]:
|
||||
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:
|
||||
snapshots: set[str] = set()
|
||||
resume_patrol = False
|
||||
try:
|
||||
say("您好!我们现在开始配置扫图参数!", color=GREEN)
|
||||
resume_patrol = acquire_calibration_control()
|
||||
say(
|
||||
"标定进度会逐步保存;中途回复 3 或按 Ctrl+C 退出后,"
|
||||
"下次运行仍可选择本次记录继续。"
|
||||
@@ -1150,6 +1250,7 @@ def main() -> None:
|
||||
say(f"\n向导失败:{exc}", color=RED)
|
||||
finally:
|
||||
remove_session_snapshots(snapshots)
|
||||
release_calibration_control(resume_patrol)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+95
-8
@@ -8,6 +8,7 @@ from datetime import datetime
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
@@ -26,6 +27,7 @@ API_RECOVERY_TIMEOUT = 90.0
|
||||
API_RECOVERY_POLL_INTERVAL = 2.0
|
||||
TRANSIENT_CURL_EXIT_CODES = {7, 28, 52, 55, 56}
|
||||
_HELPER_READY = False
|
||||
REQUIRED_PTZ_CAPABILITIES = {"get_position", "move_absolute"}
|
||||
|
||||
|
||||
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:
|
||||
try:
|
||||
state = subprocess.run(
|
||||
@@ -114,9 +155,7 @@ def api(method: str, path: str, body: dict | None = None, timeout: int = 60) ->
|
||||
except json.JSONDecodeError:
|
||||
fail(f"scan HTTP返回的不是JSON:{result.stdout}")
|
||||
|
||||
last_detail = result.stderr.strip() or result.stdout.strip() or (
|
||||
f"退出码={result.returncode}"
|
||||
)
|
||||
last_detail = api_error_detail(result)
|
||||
if not transient_api_failure(result) or attempt == API_MAX_ATTEMPTS:
|
||||
break
|
||||
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.
|
||||
_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]:
|
||||
payload = api("GET", "/api/v1/ptz", timeout=15)
|
||||
return {key: int(payload["ptz"][key]) for key in ("pan", "tilt", "zoom")}
|
||||
payload = api("GET", "/api/v1/ptz?source=scan_calibrator", timeout=50)
|
||||
return parse_ptz_payload(payload)
|
||||
|
||||
|
||||
def move(ptz: dict[str, int]) -> dict[str, int]:
|
||||
payload = api("POST", "/api/v1/ptz/absolute", ptz, timeout=50)
|
||||
return {key: int(payload["ptz"][key]) for key in ("pan", "tilt", "zoom")}
|
||||
payload = api(
|
||||
"POST", "/api/v1/ptz/absolute",
|
||||
{**ptz, "source": "scan_calibrator"}, timeout=50,
|
||||
)
|
||||
return parse_ptz_payload(payload)
|
||||
|
||||
|
||||
def snapshot() -> str:
|
||||
|
||||
@@ -51,6 +51,29 @@ class ApiRecoveryTests(unittest.TestCase):
|
||||
self.assertEqual(payload["scan"]["state"], "idle")
|
||||
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.subprocess.run")
|
||||
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.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):
|
||||
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):
|
||||
self.assertEqual(
|
||||
wizard.parse_ptz_input(
|
||||
|
||||
+1
-1
@@ -2,7 +2,7 @@
|
||||
|
||||
这个程序用于不同摄像头点位的scan现场标定:
|
||||
|
||||
- 读取当前海康摄像头PTZ;
|
||||
- 通过smart-cam统一接口读取当前海康威视或天地伟业摄像头PTZ;
|
||||
- 记录扫描区域左上角和右下角;
|
||||
- 在固定zoom下移动一个候选角度并抓取两张图;
|
||||
- 用ORB特征和RANSAC单应矩阵定位公共特征,再按当前移动轴的画面位移计算重叠率;
|
||||
|
||||
Reference in new issue
Block a user