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参数标定、配置替换、完整试扫和验证拼图,
适合批量部署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
View File
@@ -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
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]
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
View File
@@ -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:
+119
View File
@@ -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
View File
@@ -2,7 +2,7 @@
这个程序用于不同摄像头点位的scan现场标定:
- 读取当前海康摄像头PTZ;
- 通过smart-cam统一接口读取当前海康威视或天地伟业摄像头PTZ;
- 记录扫描区域左上角和右下角;
- 在固定zoom下移动一个候选角度并抓取两张图;
- 用ORB特征和RANSAC单应矩阵定位公共特征,再按当前移动轴的画面位移计算重叠率;