Files
scan-calibrator/interactive_calibrator.py
T
2026-07-29 16:25:16 +08:00

632 lines
26 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 select
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"
# The grid contact sheet is the final calibration check. Horizontal seams on
# the tested PTZ camera retain more real-world overlap than the center-shift
# model predicts, while vertical seams need a small safety margin. Keep these
# axis-specific targets within the generator's accepted 0%..5% range.
TARGET_OVERLAPS = {"pan": 0.04, "tilt": 0.02}
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 [150, 250, 350, 450, 600, 800, 1100, 1500, 2100]
return [80, 120, 170, 220, 280, 350, 450, 600, 850, 1150]
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 consistent_samples(reliable: list[tuple[int, float, float, dict]]) -> list:
"""Return the largest group that estimates one mutually consistent FOV."""
if not reliable:
return []
clusters = [
[
sample for sample in reliable
if abs(sample[2] - seed[2]) / max(seed[2], 1.0) <= 0.12
]
for seed in reliable
]
return max(
clusters, key=lambda cluster: (len(cluster), -max(x[2] for x in cluster))
)
def fov_statistics(samples: list[tuple[int, float, float, dict]]) -> tuple[float, float]:
fovs = sorted(sample[2] for sample in samples)
estimated_fov = (
fovs[len(fovs) // 2] if len(fovs) % 2
else (fovs[len(fovs) // 2 - 1] + fovs[len(fovs) // 2]) / 2
)
return estimated_fov, (max(fovs) - min(fovs)) / estimated_fov
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": "匹配位移方向异常或串轴过大",
}
fields = []
if result.get("actual_axis_motion") is not None:
fields.append(
f"实际移动={result['actual_axis_motion']}/"
f"{result.get('requested_axis_motion', result.get('delta', '?'))}"
)
if result.get("good_matches") is not None:
fields.append(f"公共特征={result['good_matches']}")
if result.get("inliers") is not None:
fields.append(f"几何内点={result['inliers']}")
if result.get("axis_shift") is not None:
fields.append(f"主轴位移={float(result['axis_shift'])*100:.1f}%")
if result.get("cross_axis_shift") is not None:
fields.append(f"串轴位移={float(result['cross_axis_shift'])*100:.1f}%")
if result.get("phase_response") is not None:
fields.append(f"相位响应={float(result['phase_response']):.2f}")
suffix = f"({','.join(fields)})" if fields else ""
return f"{labels.get(reason, reason)}{suffix}"
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[int, float, float, dict]] = []
failure_reasons: dict[str, int] = {}
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 (
axis == "tilt"
and not reliable
and result.get("reason") == "ptz_motion_shortfall"
):
direction *= -1
say(
f" step={delta}: 原方向受云台限位,自动改用相反方向继续标定"
)
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"):
reason = str(result.get("reason") or "unknown")
failure_reasons[reason] = failure_reasons.get(reason, 0) + 1
say(f" step={delta}: {failure_detail(result)},继续尝试")
continue
overlap = float(result["overlap"])
inliers = int(result.get("inliers") or 0)
ratio = float(result.get("inlier_ratio") or 0)
if result.get("match_method") == "phase_correlation":
quality = f"整图相位响应={float(result.get('phase_response') or 0):.2f}"
else:
quality = f"有效特征={inliers},内点率={ratio*100:.1f}%"
say(f" step={delta}: 重叠约 {overlap*100:.1f}%,{quality}")
feature_reliable = inliers >= 50 and ratio >= 0.45
phase_reliable = (
result.get("match_method") == "phase_correlation"
and float(result.get("phase_response") or 0) >= core.PHASE_MIN_RESPONSE
)
if (feature_reliable or phase_reliable) and 0.08 <= overlap <= 0.80:
reliable.append((delta, overlap, delta / (1.0 - overlap), result))
if len(reliable) >= 4:
provisional = consistent_samples(reliable)
if len(provisional) >= 4:
_, provisional_spread = fov_statistics(provisional)
if provisional_spread <= 0.08 and min(
sample[1] for sample in provisional
) <= 0.55:
say(
f" 已获得{len(provisional)}个覆盖充分且一致的样本,"
"停止测试更大的step"
)
break
if len(reliable) < 2:
core.move(origin)
summary = "、".join(
f"{reason}={count}次"
for reason, count in sorted(failure_reasons.items())
)
core.fail(
f"{axis}方向只得到{len(reliable)}个可靠样本,至少需要2个不同移动量互相验证。"
f"失败统计:{summary or '无'}。请根据上方每个step的具体原因处理后重试"
)
# Fixed OSD text and repeated scenery can yield a convincing false
# homography. A real optical FOV must agree across different PTZ deltas.
consistent = consistent_samples(reliable)
if len(consistent) < 2:
core.move(origin)
values = ", ".join(f"{sample[2]:.0f}" for sample in reliable)
core.fail(
f"{axis}方向不同移动量反算的视场不一致({values}),疑似重复图案误匹配;"
"本次结果不写入配置,请更换标定画面"
)
estimated_fov, spread = fov_statistics(consistent)
if len(consistent) == 2:
strong_pair = all(
(
int(sample[3].get("inliers") or 0) >= 100
and float(sample[3].get("inlier_ratio") or 0) >= 0.65
)
or (
sample[3].get("match_method") == "phase_correlation"
and float(sample[3].get("phase_response") or 0) >= 0.35
)
for sample in consistent
)
allowed_spread = 0.08
if not strong_pair:
core.move(origin)
core.fail(
f"{axis}方向目前只有2个一致样本,但特征质量不足以使用双样本规则;"
"需要第3个可靠样本,请更换标定画面"
)
else:
allowed_spread = 0.15
if spread > allowed_spread:
core.move(origin)
core.fail(
f"{axis}方向视场样本离散度为{spread*100:.1f}%,"
f"超过{allowed_spread*100:.0f}%;"
"本次结果不写入配置,请更换标定画面"
)
target_overlap = TARGET_OVERLAPS[axis]
step = max(1, round(estimated_fov * (1.0 - target_overlap)))
visual_direction = None
if axis == "pan":
# Interactive pan probes always use a positive PTZ delta. The signed
# image displacement reveals the real pan direction of this exact
# camera/firmware; mount style is not a reliable substitute.
signed_shifts = [
float(sample[3].get("center_dx", 0.0))
for sample in consistent
if abs(float(sample[3].get("center_dx", 0.0))) >= 0.02
]
if len(signed_shifts) < 2:
core.move(origin)
core.fail(
"pan方向缺少至少2个可靠的带符号位移样本;"
"请更换纹理分布更均匀的位置重新执行第四步"
)
positive = sum(shift > 0 for shift in signed_shifts)
negative = sum(shift < 0 for shift in signed_shifts)
agreement = max(positive, negative) / len(signed_shifts)
if agreement < 0.75:
core.move(origin)
core.fail(
f"pan方向样本符号不一致(一致率{agreement*100:.1f}%),"
"疑似重复图案或近距离视差;本次不生成扫描弧段"
)
# center_dx < 0 means positive numeric pan advances the camera view
# toward visual right, so left-to-right is numerically increasing.
visual_direction = "increasing" if negative > positive else "decreasing"
measured_step, measured_overlap, _, detail = max(
consistent, key=lambda sample: int(sample[3].get("inliers") or 0)
)
core.move(origin)
say(
f" {axis}视场一致性通过:{len(consistent)}个样本,"
f"估算视场={estimated_fov:.0f},离散度={spread*100:.1f}%"
)
if visual_direction is not None:
say(
f" 已自动识别该摄像头:画面左→右时pan数值"
f"{'递增' if visual_direction == 'increasing' else '递减'}"
)
return {
"value": step,
"direction": direction,
"overlap": target_overlap,
"target_overlap": target_overlap,
"overlap_method": "axis_shift_v1_retargeted",
"zoom": origin["zoom"],
"measured_at": origin,
**({"visual_direction": visual_direction} if visual_direction else {}),
"good_matches": detail.get("good_matches"),
"inliers": detail.get("inliers"),
"inlier_ratio": detail.get("inlier_ratio"),
"match_method": detail.get("match_method", "orb_homography"),
"phase_response": detail.get("phase_response"),
"derived_from": {
"value": measured_step,
"overlap": measured_overlap,
"estimated_fov": round(estimated_fov, 4),
"samples": [
{
"value": sample[0],
"overlap": round(sample[1], 6),
"estimated_fov": round(sample[2], 4),
"inliers": sample[3].get("inliers"),
"inlier_ratio": sample[3].get("inlier_ratio"),
"match_method": sample[3].get("match_method", "orb_homography"),
"phase_response": sample[3].get("phase_response"),
}
for sample in consistent
],
"spread": round(spread, 6),
},
}
def calculate_steps(points: dict, snapshots: set[str], mount: 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不一致;请固定焦段后重新开始")
top_left = points["top-left"]
bottom_right = points["bottom-right"]
# This check is only advisory and runs before signed pan direction has
# been measured. Use the shorter arc solely to decide whether the chosen
# calibration scene is plausibly between the two recorded corners.
top_left_pan = int(top_left["pan"]) % 36000
bottom_right_pan = int(bottom_right["pan"]) % 36000
increasing_span = (bottom_right_pan - top_left_pan) % 36000
if increasing_span <= 18000:
pan_start, pan_span = top_left_pan, increasing_span
else:
pan_start, pan_span = bottom_right_pan, 36000 - increasing_span
pan_inside = (origin["pan"] - pan_start) % 36000 <= pan_span
tilt_low = min(int(top_left["tilt"]), int(bottom_right["tilt"]))
tilt_high = max(int(top_left["tilt"]), int(bottom_right["tilt"]))
tilt_inside = tilt_low <= origin["tilt"] <= tilt_high
if not pan_inside or not tilt_inside:
say(
"警告:当前标定位置不在刚才选择的扫描区域内。"
f"当前pan={origin['pan']}、tilt={origin['tilt']};"
f"扫描区域tilt={tilt_low}~{tilt_high}。"
"同zoom下仍可尝试,但优先选择扫描区域内部的远处纹理。",
color=YELLOW,
)
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 = ""
if process.stdout:
readable, _, _ = select.select([process.stdout], [], [], 1.0)
if readable:
line = process.stdout.readline()
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", "stopped"}:
if status.get("state") == "completed":
say(
f"\n扫描完成,共保存 {status.get('completed', status.get('total', 0))} 张图。"
f"\n保存目录:{status.get('output_dir', '')}",
color=GREEN,
)
else:
message = (
"扫描已停止。"
if status.get("state") == "stopped"
else f"扫描失败:{status.get('error') or '未知错误'}"
)
say(f"\n{message}", color=YELLOW if status.get("state") == "stopped" else RED)
return status
except KeyboardInterrupt:
say("\n收到中断,正在通知smart-cam停止本次扫描……", color=YELLOW)
try:
core.api("POST", "/api/v1/scan/stop", timeout=10)
except (SystemExit, subprocess.SubprocessError):
say("停止接口调用失败,请检查scan状态。", color=RED)
raise Cancelled
finally:
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
def run_panorama(scan_status: dict, mount: str, pan_direction: 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,
"--pan-direction", pan_direction,
],
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)
points = core.load_state()["points"]
while True:
confirm_position(
"④ 这一步用于测量当前zoom下的水平/垂直视场。\n"
"请保持zoom不变,优先把摄像头移到扫描区域内部、"
"距离较远且纹理分布在整幅画面的位置。\n"
"请避开纯天空、纯墙面、水面、玻璃、近距离物体和重复图案;"
"回复1后不要再操作Web云台,程序会自动移动、拍照并返回起点。"
)
say("\n正在计算,请不要操作摄像头或Web控制页面……")
try:
calculate_steps(points, snapshots, mount)
break
except SystemExit as exc:
say(f"\n本次视场标定没有通过:{exc}", color=RED)
choose(
"请根据上方逐项失败原因调整位置后重试第四步,两个角点无需重录。\n"
"重新尝试回复 1;结束对话回复 3。",
{"1"},
)
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":
pan_direction = str(
core.load_state()
.get("steps", {})
.get("pan", {})
.get("visual_direction", "")
)
if pan_direction not in {"increasing", "decreasing"}:
raise RuntimeError("缺少本次实测pan方向,无法可靠排列拼图左右顺序")
panorama = run_panorama(scan_status, mount, pan_direction)
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()