This commit is contained in:
Ankle committed 2026-09-10 17:06:56 +08:00
1 parent 714a2e1db2
commit f7a782dcef
5 files changed
+584 -73

No files matched your search

+1
View File
@@ -3,6 +3,7 @@ __pycache__/
# Per-device calibration state and generated artifacts
/calibration.json
/history/
/result/*
!/result/.gitkeep
/scan-panorama/result/*
+42 -13
View File
@@ -14,6 +14,7 @@ scan-calibrator/
├── interactive_calibrator.py
├── calibrate.sh # 手动配置入口
├── scan_calibrator.py
├── history/ # 每次交互向导的完整或中途标定记录(运行时生成)
├── src/measure_overlap.cpp
├── result/ # 运行时生成的scan配置片段
└── scan-panorama/
@@ -41,14 +42,19 @@ cd /home/zhangw/scan-calibrator
运行`install.sh`会自动补齐。ImageMagick安装在RK3588宿主机,而不是smart-cam容器内,
因为验证拼图程序运行在宿主机。
只需要输入:
常用输入:
- `1`:确认、执行或选择野外部署;
- `2`:只在第一个问题中表示办公室测试;
- `3`:结束对话,并清理本次未完成向导生成的标定状态和临时标定截图;
- 历史记录选择处输入`1`、`2`、`3`等序号:复用对应记录,输入`0`新建,输入`q`退出;
- 左上角和右下角既可回复`1`读取摄像头,也可直接粘贴
`pan=21720 (217.20°), tilt=1268 (12.68°), zoom=4580 (45.80×)`;
- 除历史记录选择处外,`3`:结束对话并保留当前标定进度;
- `Ctrl+C`:等同于退出。
退出不会删除历史扫描目录,也不会回滚已经明确确认替换并重启的`config.json`。
每次启动都会建立一条独立历史记录。退出不会删除已记录的角点、已测得的单轴step、
已生成的配置、历史扫描目录,也不会回滚已经明确确认替换并重启的`config.json`。
临时标定截图仍会清理,避免长期占用设备磁盘。
## 完整对话示例
@@ -57,21 +63,27 @@ cd /home/zhangw/scan-calibrator
```text
您好!我们现在开始配置扫图参数!
在接下来的对话中,回复 3 会立即结束,并清理本次向导产生的标定痕迹。
标定进度会逐步保存;中途回复 3 或按 Ctrl+C 退出后,下次运行仍可选择本次记录继续。
① 当前是【野外部署阶段】(回复 1)还是【办公室测试阶段】(回复 2)?
> 2
在第②步之前发现以下历史标定记录(最新的排在前面):
1. 2026-09-10T10:20:30+08:00,安装方式=办公室正放,左上角=有,右下角=有,pan_step=有,tilt_step=无
2. 2026-09-09T16:10:00+08:00,安装方式=野外倒挂,左上角=有,右下角=无,pan_step=无,tilt_step=无
输入对应序号复用;回复 0 不复用并新建;回复 q 退出。
> 0
② 请在Web界面中设置左上角zoom,再手动把摄像头移到扫描区域的【左上角】。
到位后回复 1;回复 3 随时退出。
摄像头到位后回复 1 读取当前位置;也可以直接输入pan、tilt、zoom;回复 3 随时退出。
> 1
已读取: pan=19120 (191.20°), tilt=990 (9.90°), zoom=810 (8.10×)
③ 请手动把摄像头移到扫描区域的【右下角】。
右下角可以使用不同zoom,程序会取两个角点zoom的平均值作为最终扫描zoom。
到位后回复 1;回复 3 随时退出。
> 1
已读取: pan=13420 (134.20°), tilt=-340 (-3.40°), zoom=810 (8.10×)
右下角可以使用不同zoom,程序会从两个角点zoom的平均值向上选择最近可达的最终扫描zoom。
摄像头到位后回复 1 读取当前位置;也可以直接输入pan、tilt、zoom;回复 3 随时退出。
> pan=13420 (134.20°), tilt=-340 (-3.40°), zoom=810 (8.10×)
已采用手动输入: pan=13420 (134.20°), tilt=-340 (-3.40°), zoom=810 (8.10×)
④ 这一步用于测量当前zoom下的水平/垂直视场。
请把摄像头移到扫描区域内部、距离较远且纹理分布在整幅画面的位置。
@@ -176,8 +188,11 @@ smart-cam的HTTP接口读取PTZ并记录,等价于:
./calibrate.sh mark bottom-right
```
向导会显示两个角点的zoom,并取算术平均值作为最终扫描zoom。第四步开始测量前,
程序会自动把镜头切换到该平均zoom,因此视场标定与最终配置使用同一焦段。
向导会显示两个角点的zoom,并以算术平均值作为选择最终扫描zoom的下界。第四步开始测量前,
程序会自动把镜头切换到该平均zoom;即使摄像头当前已经显示为这个zoom,也会明确下发一次
绝对zoom命令并回读。若算术平均值不是摄像头可达档位,程序会在平均值上方查找最近的可达档位,
例如平均值`4565`只能落到`4550`或`4580`时采用`4580`。视场标定和最终生成配置都会使用
回读确认的同一个可达zoom,不会再把不可达平均值写入配置。
### ④ 自动计算pan_step和tilt_step
@@ -211,8 +226,22 @@ smart-cam的HTTP接口读取PTZ并记录,等价于:
不要把画面对准纯天空、纯墙、纯水面、玻璃、近距离物体或重复图案。水面场景应让岸线、
浮标等固定物体横向分布在画面中,不能只依赖不断变化的水纹。
测量过程中程序会锁定两个角点的平均zoom;自动对焦不影响zoom。标定失败时可以只更换第四步的位置重试,
不需要重新记录左上角和右下角。
测量过程中程序会锁定上述实际可达的扫图zoom;自动对焦不影响zoom。标定失败时可以只更换
第四步的位置重试,不需要重新记录左上角和右下角。pan或tilt任一方向已经成功时会立刻保存,
另一方向失败或退出后,下次可以只继续尚未完成的方向。
### 历史记录复用
进入第②步前,向导会读取`history/session_*.json`并按时间倒序编号,逐条显示是否已经包含:
- 左上角位置;
- 右下角位置;
- `pan_step`;
- `tilt_step`。
输入序号后,已有角点会直接跳过;只有一个角点时只补录另一个;只有一个轴的step时只补测另一个。
如果四项都齐全,向导会询问直接复用还是重新测量step。复用会建立一条新的本次记录,原历史文件
不会被覆盖。升级前已有的`calibration.json`也会自动导入历史记录一次。
### ⑤ 显示最终参数
+396 -59
View File
@@ -5,6 +5,7 @@ from __future__ import annotations
import argparse
from contextlib import redirect_stdout
from copy import deepcopy
from datetime import datetime
import io
import json
@@ -21,6 +22,7 @@ import scan_calibrator as core
ROOT = Path(__file__).resolve().parent
HISTORY_DIR = ROOT / "history"
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
@@ -31,6 +33,7 @@ RED = "\033[31m"
GREEN = "\033[32m"
YELLOW = "\033[33m"
RESET = "\033[0m"
ACTIVE_HISTORY_FILE: Path | None = None
class Cancelled(Exception):
@@ -51,6 +54,58 @@ def choose(question: str, choices: set[str]) -> str:
say(f"请输入 {' / '.join(sorted(choices | {'3'}))}。", color=YELLOW)
def valid_ptz(ptz: object) -> bool:
if not isinstance(ptz, dict):
return False
try:
pan = int(ptz["pan"])
tilt = int(ptz["tilt"])
zoom = int(ptz["zoom"])
except (KeyError, TypeError, ValueError):
return False
return 0 <= pan <= 36000 and -18000 <= tilt <= 18000 and zoom > 0
def parse_ptz_input(value: str) -> dict[str, int]:
"""Parse PTZ copied from this tool, with or without display annotations."""
result: dict[str, int] = {}
for name in ("pan", "tilt", "zoom"):
match = re.search(
rf"(?:^|[,,;;\s]){name}\s*=\s*(-?\d+)",
value,
flags=re.IGNORECASE,
)
if match:
result[name] = int(match.group(1))
if not valid_ptz(result):
raise ValueError(
"格式应为 pan=21720, tilt=1268, zoom=4580;"
"也可以直接粘贴带角度和倍数的完整输出"
)
return result
def capture_position(question: str) -> dict[str, int]:
while True:
answer = input(
f"\n{question}\n"
"摄像头到位后回复 1 读取当前位置;也可以直接输入 "
"pan=21720 (217.20°), tilt=1268 (12.68°), zoom=4580 (45.80×);"
"回复 3 随时退出。\n> "
).strip()
if answer == "3":
raise Cancelled
if answer == "1":
return camera_ptz()
try:
ptz = parse_ptz_input(answer)
except ValueError as exc:
say(f"输入无效:{exc}", color=YELLOW)
continue
core.print_ptz(ptz, "已采用手动输入")
return ptz
def confirm_position(question: str) -> None:
choose(f"{question}\n到位后回复 1;回复 3 随时退出。", {"1"})
@@ -61,6 +116,148 @@ def camera_ptz() -> dict[str, int]:
return ptz
def substantive_state(state: dict) -> dict:
"""Return reusable calibration data, excluding per-session metadata."""
return {
key: deepcopy(state[key])
for key in ("points", "steps", "scan_zoom", "scan_zoom_average", "mount")
if key in state
}
def state_fingerprint(state: dict) -> str:
return json.dumps(substantive_state(state), ensure_ascii=False, sort_keys=True)
def write_json_atomic(path: Path, value: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_name(f".{path.name}.tmp")
temporary.write_text(
json.dumps(value, ensure_ascii=False, indent=2), encoding="utf-8"
)
temporary.replace(path)
def load_history_records() -> list[tuple[Path, dict]]:
"""Load sessions and import a pre-upgrade calibration.json when needed."""
HISTORY_DIR.mkdir(parents=True, exist_ok=True)
records: list[tuple[Path, dict]] = []
fingerprints: set[str] = set()
for path in sorted(HISTORY_DIR.glob("session_*.json")):
try:
state = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError) as exc:
say(f"忽略无法读取的历史记录 {path.name}:{exc}", color=YELLOW)
continue
if not isinstance(state, dict):
continue
records.append((path, state))
fingerprints.add(state_fingerprint(state))
if core.STATE_FILE.exists():
try:
current = json.loads(core.STATE_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
current = None
if (
isinstance(current, dict)
and any(substantive_state(current).get(key) for key in ("points", "steps"))
and state_fingerprint(current) not in fingerprints
):
stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
recovered = deepcopy(current)
recovered.setdefault("_session", {})
recovered["_session"].update({
"id": f"recovered_{stamp}",
"created_at": recovered.get("updated_at")
or datetime.now().astimezone().isoformat(timespec="seconds"),
"source": "calibration.json升级前记录",
})
path = HISTORY_DIR / f"session_{stamp}_recovered.json"
write_json_atomic(path, recovered)
records.append((path, recovered))
return sorted(
records,
key=lambda item: str(
item[1].get("_session", {}).get("created_at")
or item[1].get("updated_at")
or item[0].name
),
reverse=True,
)
def state_item_exists(state: dict, section: str, name: str) -> bool:
value = state.get(section, {}).get(name)
if section == "points":
return valid_ptz(value)
return isinstance(value, dict) and bool(value)
def select_history_record(
records: list[tuple[Path, dict]],
) -> tuple[Path, dict] | None:
if not records:
say("\n未发现可复用的历史标定记录,将开始一条新记录。")
return None
say("\n在第②步之前发现以下历史标定记录(最新的排在前面):")
for index, (path, state) in enumerate(records, start=1):
session = state.get("_session", {})
when = session.get("created_at") or state.get("updated_at") or path.stem
mount = {"hanging": "野外倒挂", "desktop": "办公室正放"}.get(
state.get("mount"), "未记录"
)
say(
f"{index}. {when},安装方式={mount},"
f"左上角={'有' if state_item_exists(state, 'points', 'top-left') else '无'},"
f"右下角={'有' if state_item_exists(state, 'points', 'bottom-right') else '无'},"
f"pan_step={'有' if state_item_exists(state, 'steps', 'pan') else '无'},"
f"tilt_step={'有' if state_item_exists(state, 'steps', 'tilt') else '无'}"
)
while True:
answer = input(
"\n输入对应序号复用;回复 0 不复用并新建;回复 q 退出。"
"此处数字 3 表示第3条记录(如果存在)。\n> "
).strip().lower()
if answer in {"q", "quit", "退出"}:
raise Cancelled
if answer == "0":
return None
if answer.isdigit() and 1 <= int(answer) <= len(records):
return records[int(answer) - 1]
say(f"请输入 0、1~{len(records)} 或 q。", color=YELLOW)
def start_history_session(
mount: str, selected: tuple[Path, dict] | None
) -> dict:
global ACTIVE_HISTORY_FILE
stamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
source_path, source_state = selected if selected else (None, {})
state = substantive_state(source_state)
state.setdefault("points", {})
state.setdefault("steps", {})
state["mount"] = mount
state["_session"] = {
"id": stamp,
"created_at": datetime.now().astimezone().isoformat(timespec="seconds"),
**({"reused_from": source_path.name} if source_path else {}),
}
ACTIVE_HISTORY_FILE = HISTORY_DIR / f"session_{stamp}.json"
save_working_state(state)
return state
def save_working_state(state: dict) -> None:
session = state.setdefault("_session", {})
session["updated_at"] = datetime.now().astimezone().isoformat(timespec="seconds")
core.save_state(state)
if ACTIVE_HISTORY_FILE is not None:
write_json_atomic(ACTIVE_HISTORY_FILE, state)
def remove_session_snapshots(paths: set[str]) -> None:
for path in sorted(paths):
if not path.startswith((
@@ -492,22 +689,77 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
}
def calculate_steps(points: dict, snapshots: set[str], mount: str) -> None:
def read_zoom_after_move(origin: dict[str, int], requested_zoom: int) -> dict[str, int]:
core.move({**origin, "zoom": requested_zoom})
time.sleep(1.0)
return core.get_ptz()
def move_to_scan_zoom(points: dict) -> tuple[dict[str, int], float]:
"""Always move to the smallest reachable zoom at or above the mean."""
origin = camera_ptz()
zooms = [int(points[name]["zoom"]) for name in ("top-left", "bottom-right")]
target_zoom = round(sum(zooms) / len(zooms))
if origin["zoom"] != target_zoom:
say(
f"两个角点zoom的平均值为{target_zoom}({target_zoom/100:.2f}×);"
"正在自动切换到该zoom后再测量视场……"
)
origin = core.move({**origin, "zoom": target_zoom})
time.sleep(1.0)
if abs(int(origin["zoom"]) - target_zoom) > 10:
average_zoom = sum(zooms) / len(zooms)
minimum_zoom = math.ceil(average_zoom)
say(
f"两个角点zoom的平均值为{average_zoom:g}({average_zoom/100:.3f}×);"
"正在自动切换到该zoom。若平均值不可达,将向上取最近的可达档位……"
)
# Sending the command is intentional even when the current zoom appears
# equal: Step 4 must start from a confirmed camera-reported scan zoom.
actual = read_zoom_after_move(origin, minimum_zoom)
if int(actual["zoom"]) < minimum_zoom:
low_request = minimum_zoom
high_request = max(zooms)
if high_request <= low_request:
core.fail(
f"摄像头未能到达平均zoom:目标={target_zoom},实际={origin['zoom']}"
f"平均zoom={average_zoom:g}不可达,且没有更大的角点zoom可用于查找"
)
core.print_ptz(origin, "平均zoom到位")
high_actual = read_zoom_after_move(origin, high_request)
if int(high_actual["zoom"]) < minimum_zoom:
core.fail(
f"无法找到大于平均值的可达zoom:平均={average_zoom:g},"
f"请求上界={high_request},实际={high_actual['zoom']}"
)
# Absolute zoom response is monotonic. Find the first request that
# crosses the mean, while retaining the smallest camera-reported zoom.
best_zoom = int(high_actual["zoom"])
while low_request + 1 < high_request:
probe = (low_request + high_request) // 2
probe_actual = read_zoom_after_move(origin, probe)
observed = int(probe_actual["zoom"])
if observed >= minimum_zoom:
high_request = probe
best_zoom = min(best_zoom, observed)
else:
low_request = probe
actual = read_zoom_after_move(origin, best_zoom)
selected_zoom = int(actual["zoom"])
if selected_zoom < minimum_zoom:
core.fail(
f"摄像头回读zoom={selected_zoom},小于要求的平均值{average_zoom:g}"
)
core.print_ptz(actual, "扫图zoom到位")
if selected_zoom != average_zoom:
say(
f"平均zoom={average_zoom:g}不是可达档位;"
f"已向上采用最近可达zoom={selected_zoom}({selected_zoom/100:.2f}×)。"
)
return actual, average_zoom
def calculate_steps(
points: dict,
snapshots: set[str],
mount: str,
axes: set[str] | None = None,
) -> None:
requested_axes = set(axes or {"pan", "tilt"})
origin, average_zoom = move_to_scan_zoom(points)
top_left = points["top-left"]
bottom_right = points["bottom-right"]
# This check is only advisory and runs before signed pan direction has
@@ -533,8 +785,30 @@ def calculate_steps(points: dict, snapshots: set[str], mount: str) -> None:
color=YELLOW,
)
state = core.load_state()
pan_info = estimate_axis("pan", origin, points, snapshots)
tilt_info = estimate_axis("tilt", origin, points, snapshots)
state["scan_zoom"] = int(origin["zoom"])
state["scan_zoom_average"] = average_zoom
for axis in ("pan", "tilt"):
existing_zoom = state.get("steps", {}).get(axis, {}).get("zoom")
if axis not in requested_axes and (
existing_zoom is None or int(existing_zoom) != int(origin["zoom"])
):
say(
f"历史{axis}_step是在zoom={existing_zoom or '未知'}下测得,"
f"与本次扫图zoom={origin['zoom']}不一致,将重新标定。",
color=YELLOW,
)
requested_axes.add(axis)
save_working_state(state)
if "pan" in requested_axes:
state["steps"]["pan"] = estimate_axis("pan", origin, points, snapshots)
save_working_state(state)
if "tilt" in requested_axes:
state["steps"]["tilt"] = estimate_axis("tilt", origin, points, snapshots)
save_working_state(state)
pan_info = state["steps"]["pan"]
tilt_info = state["steps"]["tilt"]
pan_info, cross_axis_message = constrain_pan_with_tilt(
pan_info, tilt_info, configured_image_aspect_ratio()
)
@@ -543,7 +817,7 @@ def calculate_steps(points: dict, snapshots: set[str], mount: str) -> None:
))
state["steps"]["pan"] = pan_info
state["steps"]["tilt"] = tilt_info
core.save_state(state)
save_working_state(state)
def generate(mount: str) -> tuple[dict, Path, str]:
@@ -690,14 +964,54 @@ def run_panorama(scan_status: dict, mount: str, pan_direction: str) -> Path:
return output
def reusable_step_axes(state: dict, points: dict) -> set[str]:
average_zoom = sum(
int(points[name]["zoom"]) for name in ("top-left", "bottom-right")
) / 2
minimum_zoom = math.ceil(average_zoom)
scan_zoom = state.get("scan_zoom")
if scan_zoom is None:
measured_zooms: set[int] = set()
for info in state.get("steps", {}).values():
if not isinstance(info, dict) or info.get("zoom") is None:
continue
try:
measured_zooms.add(int(info["zoom"]))
except (TypeError, ValueError):
continue
if len(measured_zooms) == 1 and next(iter(measured_zooms)) >= minimum_zoom:
scan_zoom = next(iter(measured_zooms))
state["scan_zoom"] = scan_zoom
state["scan_zoom_average"] = average_zoom
try:
scan_zoom = int(scan_zoom)
except (TypeError, ValueError):
return set()
if scan_zoom < minimum_zoom:
return set()
reusable: set[str] = set()
for axis in ("pan", "tilt"):
info = state.get("steps", {}).get(axis)
if not isinstance(info, dict):
continue
try:
measured_zoom = int(info.get("zoom", -1))
except (TypeError, ValueError):
continue
if measured_zoom == scan_zoom:
reusable.add(axis)
return reusable
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 会立即结束,并清理本次向导产生的标定痕迹。")
say(
"标定进度会逐步保存;中途回复 3 或按 Ctrl+C 退出后,"
"下次运行仍可选择本次记录继续。"
)
history_records = load_history_records()
stage = choose(
"① 当前是【野外部署阶段】(回复 1)还是"
@@ -705,53 +1019,88 @@ def main() -> None:
{"1", "2"},
)
mount = "hanging" if stage == "1" else "desktop"
selected = select_history_record(history_records)
state = start_history_session(mount, selected)
if selected:
say(f"已复用历史记录 {selected[0].name};本次进度另存为一条新记录。")
confirm_position(
"② 请在Web界面中设置左上角需要的zoom,"
"再手动把摄像头移到扫描区域的【左上角】。"
)
top_left = camera_ptz()
state = core.load_state()
state["points"]["top-left"] = top_left
core.save_state(state)
if valid_ptz(state["points"].get("top-left")):
top_left = state["points"]["top-left"]
core.print_ptz(top_left, "② 已复用左上角")
else:
top_left = capture_position(
"② 请在Web界面中设置左上角需要的zoom,"
"再手动把摄像头移到扫描区域的【左上角】。"
)
state["points"]["top-left"] = top_left
state["steps"] = {}
state.pop("scan_zoom", None)
state.pop("scan_zoom_average", None)
save_working_state(state)
confirm_position(
"③ 请手动把摄像头移到扫描区域的【右下角】。\n"
"右下角可以使用不同zoom,程序会取两个角点zoom的平均值作为最终扫描zoom。"
)
bottom_right = camera_ptz()
average_zoom = round((top_left["zoom"] + bottom_right["zoom"]) / 2)
if valid_ptz(state["points"].get("bottom-right")):
bottom_right = state["points"]["bottom-right"]
core.print_ptz(bottom_right, "③ 已复用右下角")
else:
bottom_right = capture_position(
"③ 请手动把摄像头移到扫描区域的【右下角】。\n"
"右下角可以使用不同zoom;第四步会取两个角点zoom的平均值,"
"若平均值不可达则向上取最近可达档位。"
)
state["points"]["bottom-right"] = bottom_right
state["steps"] = {}
state.pop("scan_zoom", None)
state.pop("scan_zoom_average", None)
save_working_state(state)
average_zoom = (top_left["zoom"] + bottom_right["zoom"]) / 2
say(
f"两个角点zoom:左上={top_left['zoom']/100:.2f}×,"
f"右下={bottom_right['zoom']/100:.2f}×;"
f"最终取平均={average_zoom/100:.2f}×。"
f"算术平均={average_zoom/100:.3f}×。"
)
state = core.load_state()
state["points"]["bottom-right"] = bottom_right
core.save_state(state)
points = state["points"]
available_axes = reusable_step_axes(state, points)
if available_axes == {"pan", "tilt"}:
save_working_state(state)
use_steps = choose(
"④ 该历史记录已有同一扫图zoom下的pan_step和tilt_step。\n"
"直接复用回复 1;在当前画面重新测量回复 2;回复 3 退出。",
{"1", "2"},
)
if use_steps == "2":
state["steps"] = {}
state.pop("scan_zoom", None)
state.pop("scan_zoom_average", None)
save_working_state(state)
available_axes = set()
points = core.load_state()["points"]
while True:
while available_axes != {"pan", "tilt"}:
missing_axes = {"pan", "tilt"} - available_axes
confirm_position(
"④ 这一步用于测量当前zoom下的水平/垂直视场。\n"
"④ 这一步用于测量当前扫图zoom下的水平/垂直视场。\n"
"请优先把摄像头移到扫描区域内部、"
"距离较远且纹理分布在整幅画面的位置。\n"
"请避开纯天空、纯墙面、纯水面、玻璃、近距离物体和重复图案;"
"水面场景需要让岸线、浮标或其他固定物体横向分布在画面中;"
"回复1后不要再操作Web云台,程序会先切换到两个角点的平均zoom,"
"再自动移动、拍照并返回起点。"
"回复1后不要再操作Web云台。无论当前zoom是多少,程序都会先切换到"
"两个角点的平均zoom;若平均值不可达就向上取最近可达档位,"
f"然后测量{'+'.join(sorted(missing_axes))}并返回起点。"
)
say("\n正在计算,请不要操作摄像头或Web控制页面……")
try:
calculate_steps(points, snapshots, mount)
break
calculate_steps(points, snapshots, mount, missing_axes)
state = core.load_state()
available_axes = reusable_step_axes(state, points)
except SystemExit as exc:
say(f"\n本次视场标定没有通过:{exc}", color=RED)
choose(
"请根据上方逐项失败原因调整位置后重试第四步,两个角点无需重录。\n"
"重新尝试回复 1;结束对话回复 3。",
"已成功完成的单轴结果已经保存。重新尝试回复 1;结束对话回复 3。",
{"1"},
)
state = core.load_state()
available_axes = reusable_step_axes(state, points)
scan, generated, detail = generate(mount)
say("\n⑤ 本次得到的扫图参数:", color=GREEN)
say(json.dumps(scan, ensure_ascii=False, indent=2))
@@ -792,27 +1141,15 @@ def main() -> None:
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)
say("\n已结束对话;当前标定进度和已生成结果均已保留,后续可以复用。", color=YELLOW)
except (KeyboardInterrupt, EOFError):
say("\n对话被中断,正在清理本次向导痕迹……", color=YELLOW)
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__":
+13 -1
View File
@@ -525,7 +525,19 @@ def command_generate(args: argparse.Namespace) -> None:
f"相位响应={phase_response:.2f});"
f"请在纹理丰富且尽量远离摄像头的位置重新执行calibrate {axis}"
)
zoom = round((int(top_left["zoom"]) + int(bottom_right["zoom"])) / 2)
arithmetic_zoom = (int(top_left["zoom"]) + int(bottom_right["zoom"])) / 2
# The interactive wizard records the exact camera-reported zoom used for
# FOV measurement. Prefer it because many PTZ cameras expose quantized zoom
# positions and cannot reach the arithmetic mean exactly.
try:
zoom = int(state["scan_zoom"])
except (KeyError, TypeError, ValueError):
zoom = round(arithmetic_zoom)
if "scan_zoom" in state and zoom < math.ceil(arithmetic_zoom):
fail(
f"保存的scan_zoom={zoom}小于两个角点平均值{arithmetic_zoom:g};"
"请重新执行交互向导第四步"
)
pan_start, pan_end, pan_span, visual_pan_direction = scan_pan_range(
int(top_left["pan"]),
int(bottom_right["pan"]),
+132
View File
@@ -1,11 +1,16 @@
from __future__ import annotations
import argparse
from contextlib import redirect_stderr
import io
import json
from pathlib import Path
import subprocess
import tempfile
import unittest
from unittest import mock
import interactive_calibrator as wizard
import scan_calibrator as core
@@ -107,5 +112,132 @@ class ApiRecoveryTests(unittest.TestCase):
self.assertEqual(move_mock.call_count, 2)
class InteractiveWizardTests(unittest.TestCase):
def test_manual_ptz_accepts_annotated_display_format(self):
self.assertEqual(
wizard.parse_ptz_input(
"pan=21720 (217.20°), tilt=1268 (12.68°), "
"zoom=4580 (45.80×)"
),
{"pan": 21720, "tilt": 1268, "zoom": 4580},
)
def test_manual_ptz_rejects_incomplete_input(self):
with self.assertRaisesRegex(ValueError, "pan=21720"):
wizard.parse_ptz_input("pan=21720, tilt=1268")
@mock.patch("interactive_calibrator.core.print_ptz")
@mock.patch("interactive_calibrator.camera_ptz")
@mock.patch("interactive_calibrator.read_zoom_after_move")
def test_unreachable_average_uses_nearest_higher_zoom(
self, move_mock, camera_mock, _print_mock
):
origin = {"pan": 23052, "tilt": 1298, "zoom": 4510}
camera_mock.return_value = origin
def quantized(_origin, requested):
return {
**origin,
"zoom": 4550 if requested <= 4565 else 4580,
}
move_mock.side_effect = quantized
actual, average = wizard.move_to_scan_zoom({
"top-left": {**origin, "zoom": 4550},
"bottom-right": {**origin, "zoom": 4580},
})
self.assertEqual(average, 4565)
self.assertEqual(actual["zoom"], 4580)
self.assertEqual(move_mock.call_args_list[0].args[1], 4565)
self.assertEqual(move_mock.call_args_list[-1].args[1], 4580)
@mock.patch("interactive_calibrator.core.print_ptz")
@mock.patch("interactive_calibrator.camera_ptz")
@mock.patch("interactive_calibrator.read_zoom_after_move")
def test_step_four_always_sends_zoom_even_when_already_equal(
self, move_mock, camera_mock, _print_mock
):
origin = {"pan": 100, "tilt": 200, "zoom": 4580}
camera_mock.return_value = origin
move_mock.return_value = origin
actual, _average = wizard.move_to_scan_zoom({
"top-left": origin,
"bottom-right": origin,
})
self.assertEqual(actual["zoom"], 4580)
move_mock.assert_called_once_with(origin, 4580)
def test_partial_session_is_saved_to_history(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
history = root / "history"
state_file = root / "calibration.json"
with (
mock.patch.object(wizard, "HISTORY_DIR", history),
mock.patch.object(wizard, "ACTIVE_HISTORY_FILE", None),
mock.patch.object(core, "STATE_FILE", state_file),
):
state = wizard.start_history_session("hanging", None)
state["points"]["top-left"] = {
"pan": 21720,
"tilt": 1268,
"zoom": 4580,
}
wizard.save_working_state(state)
records = wizard.load_history_records()
self.assertEqual(len(records), 1)
self.assertTrue(
wizard.state_item_exists(records[0][1], "points", "top-left")
)
self.assertFalse(
wizard.state_item_exists(records[0][1], "points", "bottom-right")
)
class GenerateZoomTests(unittest.TestCase):
def test_generate_uses_camera_reachable_scan_zoom(self):
state = {
"points": {
"top-left": {"pan": 1000, "tilt": 200, "zoom": 4550},
"bottom-right": {"pan": 900, "tilt": 100, "zoom": 4580},
},
"steps": {
"pan": {
"value": 50,
"overlap": 0.04,
"target_overlap": 0.04,
"overlap_method": "axis_shift_v1_retargeted",
"inliers": 50,
"visual_direction": "decreasing",
},
"tilt": {
"value": 50,
"overlap": 0.04,
"target_overlap": 0.04,
"overlap_method": "axis_shift_v1_retargeted",
"inliers": 50,
},
},
"scan_zoom": 4580,
}
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
with (
mock.patch.object(core, "ROOT", root),
mock.patch.object(core, "load_state", return_value=state),
):
with mock.patch("sys.stdout", new=io.StringIO()):
core.command_generate(argparse.Namespace(mount="desktop"))
result = next((root / "result").glob("scan_config_*.json"))
generated = json.loads(result.read_text(encoding="utf-8"))
self.assertEqual(generated["scan"]["zoom"], 4580)
if __name__ == "__main__":
unittest.main()