commit a52d0029b4061fda81096e65966abc40f265907b Author: Ankle Date: Thu Jul 23 18:00:15 2026 +0800 first commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a11115a --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +__pycache__/ +*.py[cod] + +# Per-device calibration state and generated artifacts +/calibration.json +/result/* +!/result/.gitkeep +/scan-panorama/result/* +!/scan-panorama/result/.gitkeep + +# Temporary files +*.tmp +*.new +.*.tmp.jpg diff --git a/README.md b/README.md new file mode 100644 index 0000000..4a462bd --- /dev/null +++ b/README.md @@ -0,0 +1,265 @@ +# scan参数交互式配置向导 + +该工具用于在不同摄像头点位通过固定对话完成scan参数标定、配置替换、完整试扫和验证拼图, +适合批量部署RK3588。详细的底层命令和原理见[手动配置README.md](./手动配置README.md)。 + +## 目录结构 + +```text +scan-calibrator/ +├── README.md # 交互式向导主文档 +├── 手动配置README.md # 完整手动流程和参数原理 +├── interactive.sh # 交互式向导入口 +├── interactive_calibrator.py +├── calibrate.sh # 手动配置入口 +├── scan_calibrator.py +├── src/measure_overlap.cpp +├── result/ # 运行时生成的scan配置片段 +└── scan-panorama/ + ├── stitch.sh + ├── stitch_scan.py + ├── README.md + └── result/ # 运行时生成的验证大图 +``` + +## 启动 + +```bash +cd /home/zhangw/scan-calibrator +./interactive.sh +``` + +只需要输入: + +- `1`:确认、执行或选择野外部署; +- `2`:只在第一个问题中表示办公室测试; +- `3`:结束对话,并清理本次未完成向导生成的标定状态和临时标定截图; +- `Ctrl+C`:等同于退出。 + +退出不会删除历史扫描目录,也不会回滚已经明确确认替换并重启的`config.json`。 + +## 完整对话示例 + +下面以办公室测试、28张小图为例。`>`后面的`1`或`2`是现场人员输入;PTZ、step、 +文件名和扫描目录只是示例,实际值由当前摄像头返回。 + +```text +您好!我们现在开始配置扫图参数! +在接下来的对话中,回复 3 会立即结束,并清理本次向导产生的标定痕迹。 + +① 当前是【野外部署阶段】(回复 1)还是【办公室测试阶段】(回复 2)? +> 2 + +② 请在Web界面中固定本次扫描使用的zoom,再手动把摄像头移到扫描区域的【左上角】。 +到位后回复 1;回复 3 随时退出。 +> 1 +已读取: pan=19120 (191.20°), tilt=990 (9.90°), zoom=810 (8.10×) + +③ 请保持zoom不变,手动把摄像头移到扫描区域的【右下角】。 +到位后回复 1;回复 3 随时退出。 +> 1 +已读取: pan=13420 (134.20°), tilt=-340 (-3.40°), zoom=810 (8.10×) + +④ 请保持zoom不变,把摄像头移到扫描区域中央、距离较远且纹理丰富的位置。 +请避开纯天空、纯墙面、水面、玻璃和重复图案;确认后程序会自动移动、拍照并计算step。 +到位后回复 1;回复 3 随时退出。 +> 1 + +正在计算,请不要操作摄像头或Web控制页面…… +已读取: pan=16220 (162.20°), tilt=440 (4.40°), zoom=810 (8.10×) + +正在标定 pan_step,摄像头会自动移动并回到起点…… + step=250: 重叠约 77.0%,有效特征=286,内点率=91.7% + step=450: 重叠约 58.6%,有效特征=191,内点率=84.5% + step=700: 重叠约 35.5%,有效特征=116,内点率=73.9% + +正在标定 tilt_step,摄像头会自动移动并回到起点…… + step=120: 重叠约 81.3%,有效特征=302,内点率=92.0% + step=220: 重叠约 65.8%,有效特征=215,内点率=86.7% + step=350: 重叠约 45.6%,有效特征=143,内点率=78.1% + +⑤ 本次得到的扫图参数: +{ + "pan_start": 13420, + "pan_end": 19672, + "pan_step": 1042, + "tilt_start": -340, + "tilt_end": 1511, + "tilt_step": 617, + "zoom": 810 +} +安装方式:desktop +范围检查:视觉左→右pan=decreasing,指定弧段=57.00° +整步扩边:pan_step=1042,向终点外扩=552(5.52°) +整步扩边:tilt_step=617,向终点外扩=521(5.21°) +预计网格:7列 × 4行 = 28张 + +⑥ 是否替换 smart-cam 容器内的/smart-cam/config/config.json? +替换并重启回复 1;不替换并结束回复 3。 +> 1 +替换完成,smart-cam已重启。 + +⑦ 是否现在进行一次完整扫图测试? +开始测试回复 1;不测试并结束回复 3。 +> 1 +已启动测试扫描。 + +扫图日志: +已保存第 1/28 张图 +已保存第 2/28 张图 +…… +已保存第 27/28 张图 +已保存第 28/28 张图 + +扫描完成,共保存 28 张图。 +保存目录:/smart-cam/data/scan/2026-07-23_16-53-51 + +⑧ 本次扫描已完成,是否立即进行拼图测试? +开始拼图回复 1;不拼图并结束回复 3。 +> 1 + +拼图完成。 +大图存储位置:/home/zhangw/scan-calibrator/scan-panorama/result/2026-07-23_16-53-51_desktop_scan_grid_panorama.jpg + +配置向导已完成,对话结束。 +``` + +## 每个问题背后执行了什么 + +### ① 选择部署环境 + +- 回复`1`:使用`hanging`,表示球机在野外正常倒挂; +- 回复`2`:使用`desktop`,表示办公室桌面正放且摄像头后台旋转了180度。 + +它决定角点的pan方向和生成配置时的扫描顺序,等价于最后执行: + +```bash +./calibrate.sh generate --mount hanging +# 或 +./calibrate.sh generate --mount desktop +``` + +### ② 记录左上角 + +现场人员先在Web界面设置固定zoom,并移动到扫描区域左上角。回复`1`后,向导通过 +smart-cam的HTTP接口读取PTZ并记录,等价于: + +```bash +./calibrate.sh ptz +./calibrate.sh mark top-left +``` + +记录的是当前画面中心的`pan/tilt/zoom`。 + +### ③ 记录右下角 + +保持zoom不变,移动到右下角。回复`1`后等价于: + +```bash +./calibrate.sh ptz +./calibrate.sh mark bottom-right +``` + +向导会比较两个角点的zoom;不一致时立即停止,避免用不同视场计算网格。 + +### ④ 自动计算pan_step和tilt_step + +现场人员把镜头移到扫描区域中央、距离较远且纹理丰富的位置。回复`1`后,向导会: + +1. 抓取基准图; +2. 沿pan方向尝试若干候选角度,每次抓图后回到起点; +3. 用ORB特征、RANSAC和单轴画面位移求水平视场; +4. 沿tilt方向重复测量; +5. 按`step = 视场 × (1 - 0.04)`计算目标重叠4%的step; +6. 要求至少50个几何内点且内点率至少45%,否则拒绝结果; +7. 删除本次自动标定产生的临时截图。 + +这一步不能省略。两个角点只能确定扫描范围,无法确定当前摄像头型号和zoom下的一幅画面 +覆盖多少角度。 + +不要把画面对准纯天空、纯墙、水面、玻璃、近距离物体或重复图案,否则无法可靠匹配。 + +### ⑤ 显示最终参数 + +向导按完整step向终点扩展扫描范围,保证范围只能比手动选择的区域稍大,不能少扫。 +它会显示: + +```text +pan_start / pan_end / pan_step +tilt_start / tilt_end / tilt_step +zoom +预计行数、列数、图片总数和向外扩展角度 +``` + +同时将片段保存到: + +```text +/home/zhangw/scan-calibrator/result/ +``` + +### ⑥ 替换配置并重启 + +回复`1`后,向导会: + +1. 从容器读取`/smart-cam/config/config.json`; +2. 只更新`scan`中的七个运动参数,保留定时时间、等待时间、容差和HTTP设置; +3. 用`python3 -m json.tool`验证新JSON; +4. 原子替换配置,不另外备份原配置; +5. 执行`docker restart smart-cam`; +6. 等待scan HTTP服务重新可用。 + +回复`3`不会替换配置。 + +### ⑦ 立即完整试扫 + +回复`1`等价于: + +```bash +docker exec smart-cam curl -fsS -X POST \ + http://127.0.0.1:18080/api/v1/scan/start +``` + +向导会在后台轮询扫描状态,但不会把HTTP请求日志显示到终端。终端只显示图片保存进度: + +```text +已保存第 1/28 张图 +已保存第 2/28 张图 +…… +``` + +后台状态查询等价于: + +```bash +docker exec smart-cam curl -fsS \ + http://127.0.0.1:18080/api/v1/scan/status +``` + +扫描完成或失败后自动停止日志跟随,并显示结果目录。 + +### ⑧ 拼图测试 + +只有本次试扫成功完成后才会出现这个问题。回复`1`后,向导将本次扫描的结果目录和第①步 +选择的安装方式传给: + +```bash +/home/zhangw/scan-calibrator/scan-panorama/stitch.sh \ + <本次扫描目录> --mount +``` + +拼图完成后,终端返回大图的绝对存储路径,然后结束对话。回复`3`则不拼图并结束。 + +拼图结果统一保存在: + +```text +/home/zhangw/scan-calibrator/scan-panorama/result/ +``` + +应打开大图检查实际拼图是否存在超过5%的重叠或画面缺口。 + +## 批量部署注意事项 + +- 每一个摄像头点位都要重新记录角点; +- 摄像头型号或zoom变化时必须重新自动计算step; +- 标定期间不要同时从Web界面控制PTZ; +- 向导目标是4%重叠,为5%上限保留1%误差; +- 机械回差、镜头畸变和近景视差可能导致不同位置略有差异,最终必须完整试扫并拼图复核; diff --git a/calibrate.sh b/calibrate.sh new file mode 100755 index 0000000..457940c --- /dev/null +++ b/calibrate.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONDONTWRITEBYTECODE=1 +exec python3 "$SCRIPT_DIR/scan_calibrator.py" "$@" diff --git a/interactive.sh b/interactive.sh new file mode 100755 index 0000000..04098a0 --- /dev/null +++ b/interactive.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +export PYTHONDONTWRITEBYTECODE=1 +exec python3 "$SCRIPT_DIR/interactive_calibrator.py" diff --git a/interactive_calibrator.py b/interactive_calibrator.py new file mode 100755 index 0000000..625841c --- /dev/null +++ b/interactive_calibrator.py @@ -0,0 +1,378 @@ +#!/usr/bin/env python3 +"""Fixed-dialogue field deployment wizard for scan calibration.""" + +from __future__ import annotations + +import argparse +from contextlib import redirect_stdout +from datetime import datetime +import io +import json +from pathlib import Path +import re +import subprocess +import sys +import tempfile +import time + +import scan_calibrator as core + + +ROOT = Path(__file__).resolve().parent +CONFIG_PATH = "/smart-cam/config/config.json" +TARGET_OVERLAP = 0.04 +RED = "\033[31m" +GREEN = "\033[32m" +YELLOW = "\033[33m" +RESET = "\033[0m" + + +class Cancelled(Exception): + pass + + +def say(message: str = "", *, color: str = "") -> None: + print(f"{color}{message}{RESET if color else ''}", flush=True) + + +def choose(question: str, choices: set[str]) -> str: + while True: + answer = input(f"\n{question}\n> ").strip() + if answer == "3": + raise Cancelled + if answer in choices: + return answer + say(f"请输入 {' / '.join(sorted(choices | {'3'}))}。", color=YELLOW) + + +def confirm_position(question: str) -> None: + choose(f"{question}\n到位后回复 1;回复 3 随时退出。", {"1"}) + + +def camera_ptz() -> dict[str, int]: + ptz = core.get_ptz() + core.print_ptz(ptz, "已读取") + return ptz + + +def remove_session_snapshots(paths: set[str]) -> None: + for path in sorted(paths): + if not path.startswith("/smart-cam/data/scan/calibration/"): + continue + subprocess.run( + ["docker", "exec", "smart-cam", "rm", "-f", "--", path], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, check=False, + ) + + +def candidate_deltas(axis: str) -> list[int]: + if axis == "pan": + return [250, 450, 700, 1000, 1400, 1900, 2600] + return [120, 220, 350, 500, 700, 950, 1300] + + +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. + return 1 + low = min(int(points["top-left"]["tilt"]), int(points["bottom-right"]["tilt"])) + high = max(int(points["top-left"]["tilt"]), int(points["bottom-right"]["tilt"])) + return 1 if high - origin["tilt"] >= origin["tilt"] - low else -1 + + +def estimate_axis(axis: str, origin: dict[str, int], points: dict, + snapshots: set[str]) -> dict: + direction = choose_direction(axis, origin, points) + baseline = core.snapshot() + snapshots.add(baseline) + reliable: list[tuple[float, int, dict]] = [] + say(f"\n正在标定 {axis}_step,摄像头会自动移动并回到起点……") + + for delta in candidate_deltas(axis): + result = core.measure_delta( + axis, delta, direction, 1.0, baseline=baseline, origin=origin + ) + snapshots.add(str(result.get("candidate_file", ""))) + if not result.get("valid"): + say(f" step={delta}: 当前图片匹配不足,继续尝试") + continue + overlap = float(result["overlap"]) + inliers = int(result.get("inliers") or 0) + ratio = float(result.get("inlier_ratio") or 0) + say(f" step={delta}: 重叠约 {overlap*100:.1f}%," + f"有效特征={inliers},内点率={ratio*100:.1f}%") + if inliers >= 50 and ratio >= 0.45 and 0.08 <= overlap <= 0.80: + # Prefer a mid-overlap sample: enough common features without tiny motion. + reliable.append((abs(overlap - 0.40), delta, result)) + if 0.18 <= overlap <= 0.55 and reliable: + break + + if not reliable: + core.move(origin) + core.fail( + f"{axis}方向没有得到可靠视场测量。请把摄像头移到远处、纹理丰富、" + "非重复图案的位置后重新运行向导" + ) + + _, measured_step, detail = min(reliable, key=lambda item: item[0]) + measured_overlap = float(detail["overlap"]) + estimated_fov = measured_step / (1.0 - measured_overlap) + step = max(1, round(estimated_fov * (1.0 - TARGET_OVERLAP))) + core.move(origin) + return { + "value": step, + "direction": direction, + "overlap": TARGET_OVERLAP, + "target_overlap": TARGET_OVERLAP, + "overlap_method": "axis_shift_v1_retargeted", + "zoom": origin["zoom"], + "measured_at": origin, + "good_matches": detail.get("good_matches"), + "inliers": detail.get("inliers"), + "inlier_ratio": detail.get("inlier_ratio"), + "derived_from": { + "value": measured_step, + "overlap": measured_overlap, + "estimated_fov": round(estimated_fov, 4), + }, + } + + +def calculate_steps(points: dict, snapshots: set[str]) -> None: + origin = camera_ptz() + zooms = [int(points[name]["zoom"]) for name in ("top-left", "bottom-right")] + if max(zooms + [origin["zoom"]]) - min(zooms + [origin["zoom"]]) > 10: + core.fail("三个位置的zoom不一致;请固定焦段后重新开始") + state = core.load_state() + state["steps"]["pan"] = estimate_axis("pan", origin, points, snapshots) + state["steps"]["tilt"] = estimate_axis("tilt", origin, points, snapshots) + core.save_state(state) + + +def generate(mount: str) -> tuple[dict, Path, str]: + before = set((ROOT / "result").glob("scan_config_*.json")) + output = io.StringIO() + with redirect_stdout(output): + core.command_generate(argparse.Namespace(mount=mount)) + after = set((ROOT / "result").glob("scan_config_*.json")) + created = sorted(after - before, key=lambda path: path.stat().st_mtime) + if not created: + core.fail("没有找到本次生成的配置文件") + result_file = created[-1] + scan = json.loads(result_file.read_text(encoding="utf-8"))["scan"] + return scan, result_file, output.getvalue() + + +def deploy_config(scan: dict) -> None: + raw = subprocess.run( + ["docker", "exec", "smart-cam", "cat", CONFIG_PATH], + text=True, capture_output=True, check=True, + ).stdout + config = json.loads(raw) + if not isinstance(config.get("scan"), dict): + raise RuntimeError(f"{CONFIG_PATH}缺少scan对象") + + config["scan"].update(scan) + + with tempfile.NamedTemporaryFile( + mode="w", encoding="utf-8", suffix=".json", delete=False + ) as temporary: + json.dump(config, temporary, ensure_ascii=False, indent=2) + temporary.write("\n") + local_temp = Path(temporary.name) + try: + subprocess.run( + ["docker", "cp", str(local_temp), f"smart-cam:{CONFIG_PATH}.new"], + check=True, + ) + subprocess.run( + ["docker", "exec", "smart-cam", "python3", "-m", "json.tool", + f"{CONFIG_PATH}.new"], + stdout=subprocess.DEVNULL, check=True, + ) + subprocess.run( + ["docker", "exec", "smart-cam", "mv", f"{CONFIG_PATH}.new", CONFIG_PATH], + check=True, + ) + finally: + local_temp.unlink(missing_ok=True) + + subprocess.run(["docker", "restart", "smart-cam"], check=True, + stdout=subprocess.DEVNULL) + deadline = time.monotonic() + 90 + while time.monotonic() < deadline: + try: + core.api("GET", "/api/v1/scan/status", timeout=5) + return + except (SystemExit, subprocess.SubprocessError): + time.sleep(2) + raise RuntimeError("smart-cam重启后90秒内scan HTTP服务未就绪") + + +def run_test_scan() -> dict: + core.api("POST", "/api/v1/scan/start", timeout=20) + say("已启动测试扫描。", color=GREEN) + say("\n扫图日志:") + since = datetime.now().astimezone().isoformat(timespec="seconds") + saved_pattern = re.compile(r"\[scan\]\s+saved\b.*\bprogress=(\d+)/(\d+)\b") + last_progress: tuple[int, int] | None = None + process = subprocess.Popen( + ["docker", "logs", "-f", "--since", since, "smart-cam"], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + try: + while True: + line = process.stdout.readline() if process.stdout else "" + match = saved_pattern.search(line) + if match: + progress = (int(match.group(1)), int(match.group(2))) + if progress != last_progress: + say(f"已保存第 {progress[0]}/{progress[1]} 张图") + last_progress = progress + status = core.api("GET", "/api/v1/scan/status", timeout=5).get("scan", {}) + if status.get("state") in {"completed", "failed"}: + if status.get("state") == "completed": + say( + f"\n扫描完成,共保存 {status.get('completed', status.get('total', 0))} 张图。" + f"\n保存目录:{status.get('output_dir', '')}", + color=GREEN, + ) + else: + say(f"\n扫描失败:{status.get('error') or '未知错误'}", color=RED) + return status + if not line: + time.sleep(1) + finally: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + + +def run_panorama(scan_status: dict, mount: str) -> Path: + scan_dir = str(scan_status.get("output_dir") or "") + scan_id = str(scan_status.get("scan_id") or "") + # stitch_scan.py runs on the host. A scan_id resolves beneath + # /userdata/data/scan, while output_dir reported by the API is the + # container-side /smart-cam/data/scan path. + source = scan_id or scan_dir + if not source: + raise RuntimeError("扫描状态中没有output_dir或scan_id,无法确定拼图来源") + stitch = ROOT / "scan-panorama" / "stitch.sh" + result = subprocess.run( + [str(stitch), source, "--mount", mount], + text=True, capture_output=True, + ) + if result.returncode: + raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "拼图失败") + match = re.search(r"^结果:(.+)$", result.stdout, flags=re.MULTILINE) + if not match: + raise RuntimeError(f"拼图完成但没有返回结果路径:\n{result.stdout}") + output = Path(match.group(1).strip()) + if not output.is_file() or output.stat().st_size == 0: + raise RuntimeError(f"拼图结果不存在或为空:{output}") + return output + + +def main() -> None: + original_state = core.STATE_FILE.read_bytes() if core.STATE_FILE.exists() else None + snapshots: set[str] = set() + generated: Path | None = None + success = False + try: + say("您好!我们现在开始配置扫图参数!", color=GREEN) + say("在接下来的对话中,回复 3 会立即结束,并清理本次向导产生的标定痕迹。") + + stage = choose( + "① 当前是【野外部署阶段】(回复 1)还是" + "【办公室测试阶段】(回复 2)?", + {"1", "2"}, + ) + mount = "hanging" if stage == "1" else "desktop" + + confirm_position( + "② 请在Web界面中固定本次扫描使用的zoom," + "再手动把摄像头移到扫描区域的【左上角】。" + ) + top_left = camera_ptz() + state = core.load_state() + state["points"]["top-left"] = top_left + core.save_state(state) + + confirm_position( + "③ 请保持zoom不变,手动把摄像头移到扫描区域的【右下角】。" + ) + bottom_right = camera_ptz() + if abs(top_left["zoom"] - bottom_right["zoom"]) > 10: + core.fail("左右角的zoom不一致,请固定焦段后重新运行") + state = core.load_state() + state["points"]["bottom-right"] = bottom_right + core.save_state(state) + + confirm_position( + "④ 请保持zoom不变,把摄像头移到扫描区域中央、" + "距离较远且纹理丰富的位置。\n" + "请避开纯天空、纯墙面、水面、玻璃和重复图案;" + "确认后程序会自动移动、拍照并计算step。" + ) + say("\n正在计算,请不要操作摄像头或Web控制页面……") + points = core.load_state()["points"] + calculate_steps(points, snapshots) + scan, generated, detail = generate(mount) + say("\n⑤ 本次得到的扫图参数:", color=GREEN) + say(json.dumps(scan, ensure_ascii=False, indent=2)) + say(detail.strip()) + + install = choose( + "⑥ 是否替换 smart-cam 容器内的" + "/smart-cam/config/config.json?\n" + "替换并重启回复 1;不替换并结束回复 3。", + {"1"}, + ) + if install == "1": + deploy_config(scan) + say("替换完成,smart-cam已重启。", color=GREEN) + + test = choose( + "⑦ 是否现在进行一次完整扫图测试?\n" + "开始测试回复 1;不测试并结束回复 3。", + {"1"}, + ) + if test == "1": + scan_status = run_test_scan() + if scan_status.get("state") != "completed": + raise RuntimeError("本次扫描没有成功完成,因此不进入拼图测试") + stitch = choose( + "⑧ 本次扫描已完成,是否立即进行拼图测试?\n" + "开始拼图回复 1;不拼图并结束回复 3。", + {"1"}, + ) + if stitch == "1": + panorama = run_panorama(scan_status, mount) + say(f"\n拼图完成。\n大图存储位置:{panorama}", color=GREEN) + success = True + say("\n配置向导已完成,对话结束。", color=GREEN) + except Cancelled: + say("\n已结束对话,正在清理本次向导痕迹……", color=YELLOW) + except (KeyboardInterrupt, EOFError): + say("\n对话被中断,正在清理本次向导痕迹……", color=YELLOW) + except Exception as exc: + say(f"\n向导失败:{exc}", color=RED) + finally: + remove_session_snapshots(snapshots) + if not success: + if original_state is None: + core.STATE_FILE.unlink(missing_ok=True) + else: + current_state = ( + core.STATE_FILE.read_bytes() if core.STATE_FILE.exists() else None + ) + if current_state != original_state: + core.STATE_FILE.write_bytes(original_state) + if generated is not None: + generated.unlink(missing_ok=True) + + +if __name__ == "__main__": + main() diff --git a/result/.gitkeep b/result/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/result/.gitkeep @@ -0,0 +1 @@ + diff --git a/scan-panorama b/scan-panorama new file mode 160000 index 0000000..9c8bfcb --- /dev/null +++ b/scan-panorama @@ -0,0 +1 @@ +Subproject commit 9c8bfcb0bf47ba451584e8686ace8e44cf766c01 diff --git a/scan_calibrator.py b/scan_calibrator.py new file mode 100755 index 0000000..3ee9c8d --- /dev/null +++ b/scan_calibrator.py @@ -0,0 +1,471 @@ +#!/usr/bin/env python3 +"""Interactive scan PTZ and overlap calibration assistant for smart-cam.""" + +from __future__ import annotations + +import argparse +from datetime import datetime +import json +import math +from pathlib import Path +import subprocess +import sys +import time + + +ROOT = Path(__file__).resolve().parent +STATE_FILE = ROOT / "calibration.json" +HELPER_SOURCE = ROOT / "src/measure_overlap.cpp" +HELPER_CONTAINER_SOURCE = "/tmp/scan-calibrator-measure.cpp" +HELPER_CONTAINER_BIN = "/tmp/scan-calibrator-measure" +DEFAULT_TARGET_OVERLAP = 0.04 +MAX_GENERATED_OVERLAP = 0.05 + + +def fail(message: str) -> "NoReturn": + raise SystemExit(f"错误:{message}") + + +def run(command: list[str], *, timeout: int = 90) -> subprocess.CompletedProcess[str]: + result = subprocess.run(command, text=True, capture_output=True, timeout=timeout) + if result.returncode: + fail(f"命令失败:{' '.join(command)}\n{result.stderr.strip()}") + return result + + +def api(method: str, path: str, body: dict | None = None, timeout: int = 60) -> dict: + command = ["docker", "exec", "smart-cam", "curl", "-sS", "--fail-with-body", + "--max-time", str(timeout), "-X", method] + if body is not None: + command += ["-H", "Content-Type: application/json", "-d", json.dumps(body)] + command.append(f"http://127.0.0.1:18080{path}") + result = run(command, timeout=timeout + 10) + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + fail(f"scan HTTP返回的不是JSON:{result.stdout}") + + +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")} + + +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")} + + +def snapshot() -> str: + return str(api("POST", "/api/v1/snapshot", timeout=30)["file"]) + + +def load_state() -> dict: + if not STATE_FILE.exists(): + return {"points": {}, "steps": {}} + try: + state = json.loads(STATE_FILE.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as exc: + fail(f"无法读取{STATE_FILE}:{exc}") + state.setdefault("points", {}) + state.setdefault("steps", {}) + return state + + +def save_state(state: dict) -> None: + state["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds") + temporary = STATE_FILE.with_suffix(".json.tmp") + temporary.write_text(json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8") + temporary.replace(STATE_FILE) + + +def ensure_helper() -> None: + run(["docker", "cp", str(HELPER_SOURCE), f"smart-cam:{HELPER_CONTAINER_SOURCE}"]) + run(["docker", "exec", "smart-cam", "sh", "-lc", + f"g++ -O2 -std=c++17 {HELPER_CONTAINER_SOURCE} -o {HELPER_CONTAINER_BIN} " + "$(pkg-config --cflags --libs opencv4)"], timeout=120) + + +def measure_images(first: str, second: str) -> dict: + ensure_helper() + result = run(["docker", "exec", "smart-cam", HELPER_CONTAINER_BIN, first, second], timeout=60) + try: + return json.loads(result.stdout) + except json.JSONDecodeError: + fail(f"重叠分析器返回异常:{result.stdout} {result.stderr}") + + +def shifted(origin: dict[str, int], axis: str, delta: int, direction: int) -> dict[str, int]: + target = dict(origin) + if axis == "pan": + target["pan"] = (origin["pan"] + direction * delta) % 36000 + else: + target["tilt"] = origin["tilt"] + direction * delta + return target + + +def print_ptz(ptz: dict[str, int], prefix: str = "PTZ") -> None: + print(f"{prefix}: pan={ptz['pan']} ({ptz['pan']/100:.2f}°), " + f"tilt={ptz['tilt']} ({ptz['tilt']/100:.2f}°), " + f"zoom={ptz['zoom']} ({ptz['zoom']/100:.2f}×)") + + +def measure_delta(axis: str, delta: int, direction: int, wait_seconds: float, + baseline: str | None = None, origin: dict[str, int] | None = None) -> dict: + origin = origin or get_ptz() + baseline = baseline or snapshot() + target = shifted(origin, axis, delta, direction) + try: + actual = move(target) + time.sleep(wait_seconds) + candidate = snapshot() + result = measure_images(baseline, candidate) + if result.get("valid"): + primary_key = "center_dx" if axis == "pan" else "center_dy" + cross_key = "center_dy" if axis == "pan" else "center_dx" + primary_shift = abs(float(result.get(primary_key, 0.0))) + cross_shift = abs(float(result.get(cross_key, 0.0))) + result["polygon_overlap"] = result.get("overlap") + result["axis_shift"] = primary_shift + result["cross_axis_shift"] = cross_shift + + # Step calibration is one-dimensional. Polygon intersection mixes + # horizontal and vertical effects and is easily fooled by repeated + # wall patterns. Use displacement along the commanded axis instead. + result["overlap"] = max(0.0, min(1.0, 1.0 - primary_shift)) + + minimum_plausible_shift = delta / 18000.0 + result["minimum_plausible_shift"] = minimum_plausible_shift + if primary_shift < minimum_plausible_shift: + result["valid"] = False + result["reason"] = "axis_motion_too_small" + elif cross_shift > 0.20 or primary_shift > 1.25: + result["valid"] = False + result["reason"] = "axis_motion_inconsistent" + result.update({"axis": axis, "delta": delta, "direction": direction, + "origin": origin, "target": target, "actual": actual, + "baseline_file": baseline, "candidate_file": candidate}) + return result + finally: + move(origin) + + +def command_ptz(_: argparse.Namespace) -> None: + print_ptz(get_ptz()) + + +def command_mark(args: argparse.Namespace) -> None: + state = load_state() + ptz = get_ptz() + state["points"][args.name] = ptz + save_state(state) + print_ptz(ptz, f"已记录 {args.name}") + print(f"记录文件:{STATE_FILE}") + + +def command_show(_: argparse.Namespace) -> None: + state = load_state() + print(json.dumps(state, ensure_ascii=False, indent=2)) + + +def command_retarget(args: argparse.Namespace) -> None: + if not 0.0 <= args.target_overlap <= MAX_GENERATED_OVERLAP: + fail("当前项目要求target-overlap必须在0.00~0.05之间") + state = load_state() + for axis in ("pan", "tilt"): + try: + previous = state["steps"][axis] + old_step = int(previous["value"]) + old_overlap = float(previous["overlap"]) + except (KeyError, TypeError, ValueError) as exc: + fail(f"缺少可靠的{axis}历史标定:{exc}") + if not str(previous.get("overlap_method", "")).startswith("axis_shift_v1"): + fail(f"{axis}不是新版单轴位移标定结果,不能反算") + if int(previous.get("inliers") or 0) < 50 or not 0.0 <= old_overlap < 0.90: + fail(f"{axis}历史标定质量不足,不能安全反算") + + estimated_fov = old_step / (1.0 - old_overlap) + new_step = max(1, round(estimated_fov * (1.0 - args.target_overlap))) + state["steps"][axis] = { + **previous, + "value": new_step, + "overlap": args.target_overlap, + "target_overlap": args.target_overlap, + "overlap_method": "axis_shift_v1_retargeted", + "derived_from": { + "value": old_step, + "overlap": old_overlap, + "estimated_fov": round(estimated_fov, 4), + }, + } + print( + f"{axis}: step {old_step}(实测{old_overlap*100:.1f}%)" + f" → {new_step}(反算目标{args.target_overlap*100:.1f}%)" + ) + save_state(state) + print("反算结果已写入calibration.json;它必须通过一次完整扫图复核。") + + +def command_measure(args: argparse.Namespace) -> None: + origin = get_ptz() + print_ptz(origin, "标定起点") + result = measure_delta(args.axis, args.delta, args.direction, args.wait) + print(json.dumps(result, ensure_ascii=False, indent=2)) + if result.get("valid"): + print(f"候选step={args.delta},实测画面重叠率={result['overlap']*100:.1f}%") + if args.axis == "pan": + view = "右" if float(result.get("center_dx", 0)) < 0 else "左" + else: + view = "下" if float(result.get("center_dy", 0)) < 0 else "上" + print(f"按direction={args.direction}移动时,摄像头视野朝画面{view}侧推进。") + else: + print("当前画面纹理不足或位移过大,无法可靠匹配;请换到纹理丰富的中间位置或减小delta。") + + +def command_calibrate(args: argparse.Namespace) -> None: + if args.minimum <= 0 or args.maximum <= args.minimum: + fail("minimum/maximum范围无效") + if not 0.0 <= args.target_overlap <= MAX_GENERATED_OVERLAP: + fail("当前项目要求target-overlap必须在0.00~0.05之间") + origin = get_ptz() + baseline = snapshot() + print_ptz(origin, "自动标定起点") + print(f"基准图:{baseline}") + low, high = args.minimum, args.maximum + tested = [] + best = None + for number in range(args.iterations): + candidate = round((low + high) / 2) + result = measure_delta(args.axis, candidate, args.direction, args.wait, + baseline=baseline, origin=origin) + tested.append(result) + if result.get("valid"): + overlap = float(result["overlap"]) + error = abs(overlap - args.target_overlap) + if best is None or error < best[0]: + best = (error, candidate, overlap, result) + print(f"[{number+1}/{args.iterations}] step={candidate} overlap={overlap*100:.1f}% " + f"inliers={result.get('inliers', 0)}") + if overlap > args.target_overlap: + low = candidate + 1 + else: + high = candidate - 1 + else: + print(f"[{number+1}/{args.iterations}] step={candidate} 匹配失败,缩小步长") + high = candidate - 1 + if low > high: + break + if best is None: + fail("所有候选步长都无法可靠匹配,请换到纹理丰富的位置并缩小maximum") + + # Binary search can fall back to a reliable result that is still far from + # the requested overlap when larger candidates lose feature matches. + # Estimate the one-axis FOV from the best measurement, calculate a corrected + # step, and close the loop with up to three real captures. + for refinement in range(3): + _, current_step, current_overlap, _ = best + denominator = max(0.05, 1.0 - current_overlap) + corrected = round(current_step * (1.0 - args.target_overlap) / denominator) + corrected = max(args.minimum, min(args.maximum, corrected)) + if corrected == current_step: + break + result = measure_delta( + args.axis, corrected, args.direction, args.wait, + baseline=baseline, origin=origin + ) + if not result.get("valid"): + print(f"[闭环{refinement+1}/3] step={corrected} 匹配失败,保留当前最优结果") + break + overlap = float(result["overlap"]) + error = abs(overlap - args.target_overlap) + print( + f"[闭环{refinement+1}/3] step={corrected} " + f"overlap={overlap*100:.1f}% inliers={result.get('inliers', 0)}" + ) + if error < best[0]: + best = (error, corrected, overlap, result) + else: + break + + _, step, overlap, detail = best + if abs(overlap - args.target_overlap) > 0.10: + fail( + f"最优可靠结果的重叠率仍为{overlap*100:.1f}%," + f"与目标{args.target_overlap*100:.1f}%偏差超过10个百分点;" + "本次结果不保存,请更换非重复纹理位置或扩大minimum/maximum范围" + ) + state = load_state() + state["steps"][args.axis] = { + "value": step, "direction": args.direction, "overlap": overlap, + "target_overlap": args.target_overlap, + "overlap_method": "axis_shift_v1", + "zoom": origin["zoom"], "measured_at": origin, + "good_matches": detail.get("good_matches"), "inliers": detail.get("inliers"), + "inlier_ratio": detail.get("inlier_ratio"), + } + save_state(state) + print(f"推荐 {args.axis}_step={step}({step/100:.2f}°),实测重叠={overlap*100:.1f}%") + print("推荐值已写入 calibration.json") + + +def scan_pan_range(top_left: int, bottom_right: int, mount: str) -> tuple[int, int, int, str]: + """Encode the user-selected visual left-to-right arc for the current scanner. + + With the tested desktop setup, firmware rotates the image by 180 degrees, so + visual left-to-right is decreasing PTZ pan. The current scanner only executes + increasing pan, therefore reversing both endpoints traverses the exact same + selected arc in the opposite order. A normally hanging camera uses the native + increasing direction. + """ + top_left %= 36000 + bottom_right %= 36000 + if mount == "desktop": + span = (top_left - bottom_right) % 36000 + return bottom_right, top_left, span, "decreasing" + span = (bottom_right - top_left) % 36000 + return top_left, bottom_right, span, "increasing" + + +def point_count(start: int, end: int, step: int, *, wrap: bool = False) -> int: + distance = (end - start) % 36000 if wrap else end - start + return distance // step + 1 + (1 if distance % step else 0) + + +def expanded_grid(start: int, span: int, step: int, *, wrap: bool = False) -> tuple[int, int, int, int]: + """Expand the end outward to a whole number of steps; never shrink coverage.""" + if step <= 0: + fail("step必须大于0") + if span == 0: + return start, step, 1, 0 + intervals = max(1, math.ceil(span / step)) + expanded_span = step * intervals + extension = expanded_span - span + end = start + expanded_span + if wrap: + end %= 36000 + return end, step, intervals + 1, extension + + +def command_generate(args: argparse.Namespace) -> None: + state = load_state() + try: + top_left = state["points"]["top-left"] + bottom_right = state["points"]["bottom-right"] + pan_info = state["steps"]["pan"] + tilt_info = state["steps"]["tilt"] + except KeyError as exc: + fail(f"缺少标定项{exc};需要top-left、bottom-right、pan step和tilt step") + for axis, info in (("pan", pan_info), ("tilt", tilt_info)): + if not str(info.get("overlap_method", "")).startswith("axis_shift_v1"): + fail( + f"{axis}_step来自旧版二维重叠算法;" + f"请使用新版工具重新执行calibrate {axis}" + ) + target_overlap = float(info.get("target_overlap", 1.0)) + measured_overlap = float(info.get("overlap", 1.0)) + if target_overlap > MAX_GENERATED_OVERLAP or measured_overlap > MAX_GENERATED_OVERLAP: + fail( + f"{axis}_step的目标/实测重叠为" + f"{target_overlap*100:.1f}%/{measured_overlap*100:.1f}%,超过5%上限;" + f"请使用--target-overlap {DEFAULT_TARGET_OVERLAP:.2f}重新标定" + ) + inliers = int(info.get("inliers") or 0) + if inliers < 50: + fail( + f"{axis}_step标定只有{inliers}个几何内点,低于可靠阈值50;" + f"请在纹理丰富且尽量远离摄像头的位置重新执行calibrate {axis}" + ) + if abs(int(top_left["zoom"]) - int(bottom_right["zoom"])) > 10: + fail("左上角和右下角zoom不一致,请固定zoom后重新记录两个角点") + zoom = round((int(top_left["zoom"]) + int(bottom_right["zoom"])) / 2) + pan_start, pan_end, pan_span, visual_pan_direction = scan_pan_range( + int(top_left["pan"]), int(bottom_right["pan"]), args.mount + ) + tilt_start = min(int(top_left["tilt"]), int(bottom_right["tilt"])) + tilt_end = max(int(top_left["tilt"]), int(bottom_right["tilt"])) + nominal_pan_step = int(pan_info["value"]) + nominal_tilt_step = int(tilt_info["value"]) + pan_end, pan_step, columns, pan_extension = expanded_grid( + pan_start, pan_span, nominal_pan_step, wrap=True + ) + tilt_span = tilt_end - tilt_start + tilt_end, tilt_step, rows, tilt_extension = expanded_grid( + tilt_start, tilt_span, nominal_tilt_step + ) + config = { + "pan_start": pan_start, + "pan_end": pan_end, + "pan_step": pan_step, + # The current scan service expects an increasing numeric tilt range. + # Physical/image orientation is handled by scan-panorama --mount. + "tilt_start": tilt_start, + "tilt_end": tilt_end, + "tilt_step": tilt_step, + "zoom": zoom, + } + result_dir = ROOT / "result" + result_dir.mkdir(exist_ok=True) + output = result_dir / ( + f"scan_config_{args.mount}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.json" + ) + output.write_text(json.dumps({"scan": config}, ensure_ascii=False, indent=2), encoding="utf-8") + print(json.dumps(config, ensure_ascii=False, indent=2)) + print(f"安装方式:{args.mount}") + print(f"范围检查:视觉左→右pan={visual_pan_direction},指定弧段={pan_span/100:.2f}°") + print(f"整步扩边:pan_step={pan_step},向终点外扩={pan_extension}({pan_extension/100:.2f}°)") + print(f"整步扩边:tilt_step={tilt_step},向终点外扩={tilt_extension}({tilt_extension/100:.2f}°)") + print(f"预计网格:{columns}列 × {rows}行 = {columns * rows}张") + print( + "验证拼图:/home/zhangw/scan-calibrator/scan-panorama/" + f"stitch.sh <扫描目录> --mount {args.mount}" + ) + print(f"配置片段:{output}") + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(description="读取PTZ、记录扫描角点并按实拍重叠率标定scan步长。") + sub = root.add_subparsers(dest="command", required=True) + sub.add_parser("ptz", help="读取当前摄像头PTZ").set_defaults(func=command_ptz) + mark = sub.add_parser("mark", help="记录当前PTZ为一个标定点") + mark.add_argument("name", choices=["top-left", "bottom-right"]) + mark.set_defaults(func=command_mark) + sub.add_parser("show", help="显示已保存的角点和step").set_defaults(func=command_show) + retarget = sub.add_parser("retarget", help="用可靠历史测量反算不超过百分之五的step") + retarget.add_argument( + "--target-overlap", type=float, default=DEFAULT_TARGET_OVERLAP, + help="目标重叠率,默认0.04", + ) + retarget.set_defaults(func=command_retarget) + for name, function in (("measure", command_measure), ("calibrate", command_calibrate)): + item = sub.add_parser(name, help="测量单个候选step" if name == "measure" else "二分搜索推荐step") + item.add_argument("axis", choices=["pan", "tilt"]) + item.add_argument("--direction", type=int, choices=[-1, 1], required=True, + help="从画面左到右/上到下移动时PTZ数值方向") + item.add_argument("--wait", type=float, default=1.0, help="到位后抓图前等待秒数") + if name == "measure": + item.add_argument("--delta", type=int, required=True, help="候选step,单位0.01度") + else: + item.add_argument("--minimum", type=int, required=True) + item.add_argument("--maximum", type=int, required=True) + item.add_argument( + "--target-overlap", type=float, default=DEFAULT_TARGET_OVERLAP, + help="目标重叠率,默认0.04,为百分之五上限保留1%余量" + ) + item.add_argument("--iterations", type=int, default=6) + item.set_defaults(func=function) + generate = sub.add_parser("generate", help="根据角点和标定step生成scan配置片段") + generate.add_argument( + "--mount", choices=["desktop", "hanging"], required=True, + help="desktop=桌面正放且后台旋转180度;hanging=野外倒挂", + ) + generate.set_defaults(func=command_generate) + return root + + +def main() -> None: + args = parser().parse_args() + args.func(args) + + +if __name__ == "__main__": + main() diff --git a/src/measure_overlap.cpp b/src/measure_overlap.cpp new file mode 100644 index 0000000..6efd3b0 --- /dev/null +++ b/src/measure_overlap.cpp @@ -0,0 +1,68 @@ +#include +#include +#include +#include +#include + +int main(int argc, char** argv) { + if (argc != 3) { std::cerr << "usage: measure_overlap IMAGE_A IMAGE_B\n"; return 2; } + cv::Mat a = cv::imread(argv[1], cv::IMREAD_GRAYSCALE); + cv::Mat b = cv::imread(argv[2], cv::IMREAD_GRAYSCALE); + if (a.empty() || b.empty()) { std::cerr << "cannot read input image\n"; return 3; } + if (a.size() != b.size()) cv::resize(b, b, a.size()); + + const double scale = std::min(1.0, 960.0 / a.cols); + if (scale < 1.0) { + cv::resize(a, a, {}, scale, scale, cv::INTER_AREA); + cv::resize(b, b, {}, scale, scale, cv::INTER_AREA); + } + auto orb = cv::ORB::create(2500, 1.2f, 8, 31, 0, 2, cv::ORB::HARRIS_SCORE, 31, 8); + std::vector ka, kb; cv::Mat da, db; + orb->detectAndCompute(a, {}, ka, da); + orb->detectAndCompute(b, {}, kb, db); + if (da.empty() || db.empty()) { + std::cout << "{\"valid\":false,\"reason\":\"not_enough_features\",\"overlap\":0}\n"; + return 0; + } + std::vector> knn; + cv::BFMatcher(cv::NORM_HAMMING).knnMatch(da, db, knn, 2); + std::vector good; + for (const auto& pair : knn) + if (pair.size() == 2 && pair[0].distance < 0.75f * pair[1].distance) good.push_back(pair[0]); + if (good.size() < 8) { + std::cout << "{\"valid\":false,\"reason\":\"not_enough_matches\",\"good_matches\":" + << good.size() << ",\"overlap\":0}\n"; + return 0; + } + std::vector pa, pb; + for (const auto& m : good) { pa.push_back(ka[m.queryIdx].pt); pb.push_back(kb[m.trainIdx].pt); } + cv::Mat mask; + cv::Mat h = cv::findHomography(pa, pb, cv::RANSAC, 4.0, mask, 3000, 0.995); + const int inliers = mask.empty() ? 0 : cv::countNonZero(mask); + const double inlier_ratio = good.empty() ? 0.0 : inliers / (double)good.size(); + if (h.empty() || inliers < 50 || inlier_ratio < 0.45) { + std::cout << "{\"valid\":false,\"reason\":\"homography_failed\",\"good_matches\":" + << good.size() << ",\"inliers\":" << inliers + << ",\"inlier_ratio\":" << inlier_ratio << ",\"overlap\":0}\n"; + return 0; + } + + std::vector corners{{0,0},{(float)a.cols,0},{(float)a.cols,(float)a.rows},{0,(float)a.rows}}; + std::vector warped; + cv::perspectiveTransform(corners, warped, h); + std::vector frame{{0,0},{(float)b.cols,0},{(float)b.cols,(float)b.rows},{0,(float)b.rows}}; + std::vector intersection; + const float area = cv::intersectConvexConvex(warped, frame, intersection, true); + const double overlap = std::clamp(area / (double)(b.cols * b.rows), 0.0, 1.0); + std::vector center{{a.cols * .5f, a.rows * .5f}}, moved_center; + cv::perspectiveTransform(center, moved_center, h); + const double dx = (moved_center[0].x - center[0].x) / a.cols; + const double dy = (moved_center[0].y - center[0].y) / a.rows; + std::cout << std::fixed << std::setprecision(4) + << "{\"valid\":true,\"overlap\":" << overlap + << ",\"good_matches\":" << good.size() + << ",\"inliers\":" << inliers + << ",\"inlier_ratio\":" << inlier_ratio + << ",\"center_dx\":" << dx << ",\"center_dy\":" << dy << "}\n"; + return 0; +} diff --git a/手动配置README.md b/手动配置README.md new file mode 100644 index 0000000..7c4bff6 --- /dev/null +++ b/手动配置README.md @@ -0,0 +1,432 @@ +# scan-calibrator 扫描参数标定助手 + +这个程序用于不同摄像头点位的scan现场标定: + +- 读取当前海康摄像头PTZ; +- 记录扫描区域左上角和右下角; +- 在固定zoom下移动一个候选角度并抓取两张图; +- 用ORB特征和RANSAC单应矩阵定位公共特征,再按当前移动轴的画面位移计算重叠率; +- 自动搜索接近目标重叠率的`pan_step`和`tilt_step`; +- 输出可以复制到`config.json`的scan配置片段。 + +程序通过smart-cam已经提供的HTTP接口控制SDK,标定结束会把摄像头恢复到标定起点。 + +## 1. 最重要的原理 + +### 1.1 PTZ描述的是画面中心,不是四条边 + +```text +pan = 摄像头光轴中心的水平方向 +tilt = 摄像头光轴中心的垂直方向 +zoom = 当前光学变焦倍率 +``` + +只读取一个PTZ,无法直接知道画面左边、右边、上边、下边分别对应多少度。 +要知道画面边界,还需要镜头的水平/垂直视场角(HFOV/VFOV)。视场角取决于: + +- 摄像头传感器尺寸; +- 镜头实际焦距; +- 当前zoom; +- 镜头畸变; +- 厂商对zoom数值的定义; +- 图像裁剪和电子防抖。 + +因此不能用一个固定公式在所有摄像头上从zoom直接算step。同一个`zoom=500`,不同型号的 +真实视场角也可能不同。 + +### 1.2 step与zoom的关系 + +zoom越大,视场越窄,step必须越小;zoom越小,视场越宽,step可以越大。 + +理论关系是: + +```text +pan_step ≈ 水平视场角 × (1 - 水平目标重叠率) +tilt_step ≈ 垂直视场角 × (1 - 垂直目标重叠率) +``` + +例如水平视场角为10°,目标重叠4%,则: + +```text +pan_step ≈ 10° × 0.96 = 9.6° = 960 +``` + +本程序不依赖厂家标称视场角,而是移动摄像头实拍两张图,通过特征匹配测量实际重叠率。 + +### 1.3 推荐重叠率 + +当前项目要求上下左右重叠不超过5%。工具默认目标为4%,预留1%的测量和云台误差余量: + +- 目标4%:当前默认值; +- 实测0%~5%:符合当前项目要求; +- 超过5%:生成器会拒绝该step,要求重新标定; +- 超过35%:通常重复过多,图片数量和扫描时间明显增加; +- 5%重叠的安全余量很小,必须在扫描区域多个位置复核,发现缺口时应由现场人员明确决定是否提高。 + +### 1.4 为什么上下和左右会反 + +PTZ数值增长方向不等于最终大图方向。不同厂商、安装方向和坐标系可能出现: + +- pan增加时摄像头视野向左推进; +- pan增加时摄像头视野向右推进; +- tilt增加时向上; +- tilt增加时向下。 + +所以必须分别确认“从画面左到右”和“从画面上到下”时PTZ是增加还是减少。 +第一版验证图中天花板位于地板下面,就是把tilt数值递增错误地当成了从上到下。 + +## 2. 文件结构 + +```text +scan-calibrator/ +├── README.md +├── calibrate.sh +├── scan_calibrator.py +├── calibration.json # 运行mark/calibrate后自动生成 +├── src/ +│ └── measure_overlap.cpp +└── result/ + └── scan_config_*.json +``` + +## 3. 完整标定流程 + +进入目录: + +```bash +cd /home/zhangw/scan-calibrator +``` + +### 第一步:固定zoom和焦点 + +先通过Web界面把摄像头调到准备扫描时使用的zoom。整个标定过程、左上角、右下角和 +step测量必须使用同一个zoom。 + +注意:zoom是焦段/视场角;focus是对焦清晰度,两者不是同一个参数。 + +### 第二步:记录左上角 + +手动把摄像头移动到要扫描区域的左上角,然后查看PTZ: + +```bash +./calibrate.sh ptz +``` + +确认位置后记录: + +```bash +./calibrate.sh mark top-left +``` + +### 第三步:记录右下角 + +手动移动到扫描区域右下角,zoom保持不变: + +```bash +./calibrate.sh ptz +./calibrate.sh mark bottom-right +``` + +### 第四步:选择step标定位置 + +把摄像头移动到扫描区域中间、纹理丰富的位置。不要对着纯天空、纯白墙、水面或大面积 +重复栅栏,否则特征匹配可能不可靠。 + +### 第五步:确认pan方向 + +先用较小候选值测一次。例如从视觉上的左向右移动时,如果pan数值应增加: + +```bash +./calibrate.sh measure pan --direction 1 --delta 300 +``` + +如果pan数值应减少: + +```bash +./calibrate.sh measure pan --direction -1 --delta 300 +``` + +程序会输出重叠率,并根据图像中公共特征的移动方向说明摄像头视野向左还是向右推进。 +摄像头随后自动回到起点。 + +### 第六步:自动标定pan_step + +根据当前zoom选择一个可能覆盖真实step的范围。例如: + +单位是0.01°:`100=1°`,`2000=20°`。程序会二分测试多个step。为了兼顾拼接安全边 +和5%的重叠上限,默认使用4%: + +```bash +./calibrate.sh calibrate pan \ + --direction 1 \ + --minimum 100 \ + --maximum 2000 \ + --target-overlap 0.04 +``` + +只有几何内点不少于50且内点率不少于45%的匹配才会被接受;低质量结果不会写入配置。 +新版会在`calibration.json`记录`overlap_method=axis_shift_v1`;旧版二维面积算法产生的step +会在`generate`阶段被拒绝,必须重新标定,避免误用历史结果。 + +当目标只有4%~5%时,两张图的公共特征可能太少,自动实拍标定会主动失败,不能退回一个 +高重叠结果。若已经有同一zoom下的可靠新版测量,可以用视场角关系反算4%目标: + +```bash +./calibrate.sh retarget --target-overlap 0.04 +``` + +反算公式为: + +```text +估算视场角 = 旧step / (1 - 旧实测重叠率) +新step = 估算视场角 × (1 - 0.04) +``` + +反算结果会标记为`axis_shift_v1_retargeted`,不是“实测4%”。必须生成配置、完整扫图并在 +多个相邻位置复核;任何位置出现缺口时,说明5%要求已经低于该点位的安全余量。 + +### 第七步:自动标定tilt_step + +先确认从画面上方向下方移动时tilt是增加还是减少,然后执行: + +```bash +./calibrate.sh calibrate tilt \ + --direction -1 \ + --minimum 50 \ + --maximum 1000 \ + --target-overlap 0.04 +``` + +16:9画面的垂直视场通常小于水平视场,所以`tilt_step`通常小于`pan_step`。 + +### 第八步:生成scan配置 + +办公室测试时使用: + +```bash +./calibrate.sh show +./calibrate.sh generate --mount desktop +``` + +野外正常倒挂时使用: + +```bash +./calibrate.sh generate --mount hanging +``` + +生成结果保存在: + +```text +/home/zhangw/scan-calibrator/result/scan_config_<安装方式>_YYYYMMDD_HHMMSS.json +``` + +程序严格用先记录的`top-left`和后记录的`bottom-right`确定扫描区域。因为pan是一个 +0~360°的圆周,只给两个数值会存在两条可能的弧,所以`--mount`用于确定视觉上 +“左上角→右下角”对应的pan方向: + +- `desktop`:当前办公室实测为视觉左→右时pan递减; +- `hanging`:野外正常倒挂按原生方向,视觉左→右时pan递增。 + +当前scan只支持pan数值递增。对于`desktop`,生成器会交换两个pan端点,让scan反向遍历 +同一个指定区域;这只改变采图先后顺序,不会把小区域变成另一条大弧。程序在输出前显示 +指定弧段角度、预计列数、行数和总图片数,便于写入配置前人工核对。 + +生成器还会按照标定step做**整步扩边**。扫描范围只能比手动选择的范围大,不能更小: + +```text +区间数 = ceil(手动范围跨度 / step) +最终跨度 = 区间数 × step +向外扩展量 = 最终跨度 - 手动范围跨度 +``` + +例如垂直跨度840、标定step763时,不能直接产生`-90、673、750`这组三点,因为两个间隔 +分别是763和77。生成器保持step763不变,将终点向外扩展686,得到 +`-90、673、1436`。三个位置间距一致,完整包含原来`-90~750`的手动范围,并且多扫的 +范围小于一个step。生成时会明确打印pan和tilt分别向外扩展了多少角度。 + +例如桌面模式下,先记录的左上角为168.90°,后记录的右下角为153.00°。视觉左→右对应 +pan递减,因此用户指定的弧段是15.90°。生成器会输出 +`pan_start=15300, pan_end=16890`,让当前scan反向覆盖这同一段15.90°区域,而不是经过 +360°扫描另一段344.10°区域。 + +## 4. 如何判断标定结果是否可靠 + +每次测量会输出: + +```text +overlap 沿当前标定轴计算的重叠比例 +axis_shift 相邻画面沿当前标定轴移动的画幅比例 +cross_axis_shift 非当前标定轴上的漂移比例 +polygon_overlap 透视变换后二维多边形交集,仅作为诊断值 +good_matches 通过比例测试的特征匹配数 +inliers 符合统一几何变换的匹配数 +inlier_ratio 几何内点占比 +``` + +建议: + +- `inliers < 50`:结果不可靠,程序会拒绝; +- `inlier_ratio < 0.45`:错误匹配过多,程序会拒绝; +- 近距离物体、玻璃反射、重复栅栏和大面积纯色区域容易让单应性失效; +- step应在扫描区域内至少选择上、中、下三个纹理丰富的位置分别验证,最终取较小的安全值; +- 目标重叠率默认4%,实测必须不超过5%; +- step标定使用当前移动轴上的中心位移计算重叠率,不再直接用二维多边形交集; +- `cross_axis_shift > 0.20`会被视为运动方向不一致并拒绝,常见原因是重复图案误匹配; + +- `inliers >= 50`; +- `inlier_ratio >= 0.45`; +- 推荐step的`overlap`位于0%~5%; +- 在扫描区域另外1~2个位置用`measure`复核同一个step; +- 在至少上、中、下三个位置复核;任一位置超过5%时重新标定或增大step; + +近距离物体会产生视差,同一个step在不同位置测出的重叠可能不同。正式部署应采用多个位置中 +更保守的较小step,而不是只采用最好看的一个测量结果。 + + +手动开启扫描-->测试当前参数是否合理 +```bash +docker exec smart-cam curl -fsS -X POST http://127.0.0.1:18080/api/v1/scan/start +``` +实时查看扫图进度: +```bash +docker logs -f -t --tail 200 smart-cam 2>&1 | grep --line-buffered '\[scan\]' +``` +查看当前扫描状态: +```bash +docker exec smart-cam curl -fsS http://127.0.0.1:18080/api/v1/scan/status +``` + + +## 5. scan等待、容差和HTTP参数 + +所有角度相关参数单位均为0.01°,zoom单位为0.01×。 + +### `move_wait: 0.5` + +SDK判断云台已经到位后,额外等待0.5秒再进入对焦阶段,用于消除机械回弹和画面抖动。 + +- 需要按摄像头单独验证; +- 快速、稳定的球机通常0.3~0.8秒; +- 长焦、大跨度移动、支架晃动时可提高到1~2秒; +- 太小会产生运动模糊,太大只会增加扫描时间。 + +### `focus_wait: 1.0` + +为对焦/镜头稳定预留的等待时间。当前海康链路没有主动改变你设定的zoom,主要作为镜头稳定等待; +天地伟业worker可在这个阶段执行对应focus操作。 + +- 与摄像头型号、zoom和光照有关,需要现场验证; +- 如果固定焦点且画面稳定,可以缩短; +- 夜间或长焦对焦慢时需要加长。 + +### `capture_wait: 1.0` + +对焦阶段结束后,到真正抓取JPEG之前再等待1秒,避免抓到曝光尚未稳定或关键帧切换中的图像。 + +- 需要按码流延迟和曝光速度验证; +- 一般0.5~1.5秒; +- 夜间慢曝光可适当增加。 + +### `move_timeout: 35` + +单个PTZ目标允许的最大移动时间,单位秒。超过后本次扫描失败并记录`move_timeout`。 + +- 与球机转速、最大移动跨度和网络延迟有关; +- 通常30~45秒; +- 不能用增大timeout掩盖不可达的tilt范围。 + +### `pan_tolerance: 70` + +水平到位容差,`70=0.70°`。实际pan与目标pan误差小于该值才认为到位。 + +- 应按每台球机的回读精度配置; +- 必须明显小于`pan_step`,建议不超过step的1/4; +- 拼接要求高时建议从20~50(0.2°~0.5°)试起。 + +### `tilt_tolerance: 45` + +垂直到位容差,`45=0.45°`。规则与pan相同。 + +- 需要按球机回读抖动配置; +- 建议不超过`tilt_step/4`; +- tilt_step较小时应进一步降低。 + +### `zoom_tolerance: 400` + +zoom到位容差,`400=4.00×`。对于固定焦段扫描,这个默认值过于宽松,不适合精确拼接。 + +- 强烈建议每台摄像头单独验证; +- 海康回读稳定时可先使用10~30(0.10×~0.30×); +- 容差过大会让不同焦段的图片混入同一组,视场角不一致,后端很难拼接。 + +### `stable_checks: 2` + +连续多少次PTZ回读都在容差内,才真正认为云台稳定。天地伟业worker会使用该参数; +当前海康Python驱动尚未消费这个字段,因此它在海康配置中只是预留项。 + +- 推荐2~3次; +- 长焦或支架易抖时使用3; +- 值越大越稳,但每个点耗时更长。 + +### `stable_wait_ms: 300` + +PTZ到位检查之间的间隔,单位毫秒。`stable_checks=2`时,至少跨越约300ms再次确认。 + +- 通常200~500ms; +- 网络慢或回读抖动可增加; +- 它也影响到位轮询速度。 + +### HTTP配置 + +```json +"http": { + "host": "0.0.0.0", + "port": 18080 +} +``` + +- `host=0.0.0.0`表示监听容器所有网卡,允许同机程序及局域网映射后的Web界面调用; +- `port=18080`是scan HTTP服务端口; +- 这两个参数与摄像头光学参数无关,不需要每个摄像头单独标定; +- 同一设备只有一个scan服务时保持默认即可;端口冲突或安全策略要求时才修改。 + +## 6. 哪些参数必须每台摄像头单独配置 + +必须或强烈建议逐台/逐点位配置: + +```text +pan_start / pan_end +tilt_start / tilt_end +pan_step / tilt_step +zoom +pan_tolerance / tilt_tolerance / zoom_tolerance +move_wait / focus_wait / capture_wait(至少现场验证) +move_timeout(至少验证最大跨度) +``` + +通常可使用统一默认值: + +```text +stable_checks = 2 +stable_wait_ms = 300 +http.host = 0.0.0.0 +http.port = 18080 +``` + +即使摄像头型号相同,只要安装高度、观察距离、扫描区域或zoom不同,也应重新标定角点和step。 + +## 7. 安装姿态与拼图方向 + +本工具只负责取得PTZ角点和实测步长,不修改scan的采图顺序,也不会向scan配置输出额外的方向字段。 +球机安装姿态由`/home/zhangw/scan-calibrator/scan-panorama`在拼图时处理: + +```bash +# 办公室:球机正放在桌面,摄像头后台将画面旋转180度 +/home/zhangw/scan-calibrator/scan-panorama/stitch.sh 扫描目录 --mount desktop + +# 野外:球机正常倒挂 +/home/zhangw/scan-calibrator/scan-panorama/stitch.sh 扫描目录 --mount hanging +``` + +`desktop`只反转网格的行顺序和列顺序;不会修改meta.json,也不会旋转或镜像任何一张小图。 +`hanging`直接按meta.json原始row/col顺序拼图。