Files
scan-calibrator/interactive_calibrator.py
T
2026-07-23 18:00:15 +08:00

379 lines
14 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()