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 # Per-device calibration state and generated artifacts
/calibration.json /calibration.json
/history/
/result/* /result/*
!/result/.gitkeep !/result/.gitkeep
/scan-panorama/result/* /scan-panorama/result/*
+42 -13
View File
@@ -14,6 +14,7 @@ scan-calibrator/
├── interactive_calibrator.py ├── interactive_calibrator.py
├── calibrate.sh # 手动配置入口 ├── calibrate.sh # 手动配置入口
├── scan_calibrator.py ├── scan_calibrator.py
├── history/ # 每次交互向导的完整或中途标定记录(运行时生成)
├── src/measure_overlap.cpp ├── src/measure_overlap.cpp
├── result/ # 运行时生成的scan配置片段 ├── result/ # 运行时生成的scan配置片段
└── scan-panorama/ └── scan-panorama/
@@ -41,14 +42,19 @@ cd /home/zhangw/scan-calibrator
运行`install.sh`会自动补齐。ImageMagick安装在RK3588宿主机,而不是smart-cam容器内, 运行`install.sh`会自动补齐。ImageMagick安装在RK3588宿主机,而不是smart-cam容器内,
因为验证拼图程序运行在宿主机。 因为验证拼图程序运行在宿主机。
只需要输入: 常用输入:
- `1`:确认、执行或选择野外部署; - `1`:确认、执行或选择野外部署;
- `2`:只在第一个问题中表示办公室测试; - `2`:只在第一个问题中表示办公室测试;
- `3`:结束对话,并清理本次未完成向导生成的标定状态和临时标定截图; - 历史记录选择处输入`1`、`2`、`3`等序号:复用对应记录,输入`0`新建,输入`q`退出;
- 左上角和右下角既可回复`1`读取摄像头,也可直接粘贴
`pan=21720 (217.20°), tilt=1268 (12.68°), zoom=4580 (45.80×)`;
- 除历史记录选择处外,`3`:结束对话并保留当前标定进度;
- `Ctrl+C`:等同于退出。 - `Ctrl+C`:等同于退出。
退出不会删除历史扫描目录,也不会回滚已经明确确认替换并重启的`config.json`。 每次启动都会建立一条独立历史记录。退出不会删除已记录的角点、已测得的单轴step、
已生成的配置、历史扫描目录,也不会回滚已经明确确认替换并重启的`config.json`。
临时标定截图仍会清理,避免长期占用设备磁盘。
## 完整对话示例 ## 完整对话示例
@@ -57,21 +63,27 @@ cd /home/zhangw/scan-calibrator
```text ```text
您好!我们现在开始配置扫图参数! 您好!我们现在开始配置扫图参数!
在接下来的对话中,回复 3 会立即结束,并清理本次向导产生的标定痕迹。 标定进度会逐步保存;中途回复 3 或按 Ctrl+C 退出后,下次运行仍可选择本次记录继续。
① 当前是【野外部署阶段】(回复 1)还是【办公室测试阶段】(回复 2)? ① 当前是【野外部署阶段】(回复 1)还是【办公室测试阶段】(回复 2)?
> 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,再手动把摄像头移到扫描区域的【左上角】。 ② 请在Web界面中设置左上角zoom,再手动把摄像头移到扫描区域的【左上角】。
到位后回复 1;回复 3 随时退出。 摄像头到位后回复 1 读取当前位置;也可以直接输入pan、tilt、zoom;回复 3 随时退出。
> 1 > 1
已读取: pan=19120 (191.20°), tilt=990 (9.90°), zoom=810 (8.10×) 已读取: pan=19120 (191.20°), tilt=990 (9.90°), zoom=810 (8.10×)
③ 请手动把摄像头移到扫描区域的【右下角】。 ③ 请手动把摄像头移到扫描区域的【右下角】。
右下角可以使用不同zoom,程序会取两个角点zoom的平均值作为最终扫描zoom。 右下角可以使用不同zoom,程序会从两个角点zoom的平均值向上选择最近可达的最终扫描zoom。
到位后回复 1;回复 3 随时退出。 摄像头到位后回复 1 读取当前位置;也可以直接输入pan、tilt、zoom;回复 3 随时退出。
> 1 > pan=13420 (134.20°), tilt=-340 (-3.40°), zoom=810 (8.10×)
已读取: 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下的水平/垂直视场。 ④ 这一步用于测量当前zoom下的水平/垂直视场。
请把摄像头移到扫描区域内部、距离较远且纹理分布在整幅画面的位置。 请把摄像头移到扫描区域内部、距离较远且纹理分布在整幅画面的位置。
@@ -176,8 +188,11 @@ smart-cam的HTTP接口读取PTZ并记录,等价于:
./calibrate.sh mark bottom-right ./calibrate.sh mark bottom-right
``` ```
向导会显示两个角点的zoom,并取算术平均值作为最终扫描zoom。第四步开始测量前, 向导会显示两个角点的zoom,并以算术平均值作为选择最终扫描zoom的下界。第四步开始测量前,
程序会自动把镜头切换到该平均zoom,因此视场标定与最终配置使用同一焦段。 程序会自动把镜头切换到该平均zoom;即使摄像头当前已经显示为这个zoom,也会明确下发一次
绝对zoom命令并回读。若算术平均值不是摄像头可达档位,程序会在平均值上方查找最近的可达档位,
例如平均值`4565`只能落到`4550`或`4580`时采用`4580`。视场标定和最终生成配置都会使用
回读确认的同一个可达zoom,不会再把不可达平均值写入配置。
### ④ 自动计算pan_step和tilt_step ### ④ 自动计算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 import argparse
from contextlib import redirect_stdout from contextlib import redirect_stdout
from copy import deepcopy
from datetime import datetime from datetime import datetime
import io import io
import json import json
@@ -21,6 +22,7 @@ import scan_calibrator as core
ROOT = Path(__file__).resolve().parent ROOT = Path(__file__).resolve().parent
HISTORY_DIR = ROOT / "history"
CONFIG_PATH = "/smart-cam/config/config.json" CONFIG_PATH = "/smart-cam/config/config.json"
# The grid contact sheet is the final calibration check. Horizontal seams on # 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 # the tested PTZ camera retain more real-world overlap than the center-shift
@@ -31,6 +33,7 @@ RED = "\033[31m"
GREEN = "\033[32m" GREEN = "\033[32m"
YELLOW = "\033[33m" YELLOW = "\033[33m"
RESET = "\033[0m" RESET = "\033[0m"
ACTIVE_HISTORY_FILE: Path | None = None
class Cancelled(Exception): class Cancelled(Exception):
@@ -51,6 +54,58 @@ def choose(question: str, choices: set[str]) -> str:
say(f"请输入 {' / '.join(sorted(choices | {'3'}))}。", color=YELLOW) 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: def confirm_position(question: str) -> None:
choose(f"{question}\n到位后回复 1;回复 3 随时退出。", {"1"}) choose(f"{question}\n到位后回复 1;回复 3 随时退出。", {"1"})
@@ -61,6 +116,148 @@ def camera_ptz() -> dict[str, int]:
return ptz 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: def remove_session_snapshots(paths: set[str]) -> None:
for path in sorted(paths): for path in sorted(paths):
if not path.startswith(( 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() origin = camera_ptz()
zooms = [int(points[name]["zoom"]) for name in ("top-left", "bottom-right")] zooms = [int(points[name]["zoom"]) for name in ("top-left", "bottom-right")]
target_zoom = round(sum(zooms) / len(zooms)) average_zoom = sum(zooms) / len(zooms)
if origin["zoom"] != target_zoom: minimum_zoom = math.ceil(average_zoom)
say( say(
f"两个角点zoom的平均值为{target_zoom}({target_zoom/100:.2f}×);" f"两个角点zoom的平均值为{average_zoom:g}({average_zoom/100:.3f}×);"
"正在自动切换到该zoom后再测量视场……" "正在自动切换到该zoom。若平均值不可达,将向上取最近的可达档位……"
) )
origin = core.move({**origin, "zoom": target_zoom})
time.sleep(1.0) # Sending the command is intentional even when the current zoom appears
if abs(int(origin["zoom"]) - target_zoom) > 10: # 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( 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"] top_left = points["top-left"]
bottom_right = points["bottom-right"] bottom_right = points["bottom-right"]
# This check is only advisory and runs before signed pan direction has # 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, color=YELLOW,
) )
state = core.load_state() state = core.load_state()
pan_info = estimate_axis("pan", origin, points, snapshots) state["scan_zoom"] = int(origin["zoom"])
tilt_info = estimate_axis("tilt", origin, points, snapshots) 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, cross_axis_message = constrain_pan_with_tilt(
pan_info, tilt_info, configured_image_aspect_ratio() 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"]["pan"] = pan_info
state["steps"]["tilt"] = tilt_info state["steps"]["tilt"] = tilt_info
core.save_state(state) save_working_state(state)
def generate(mount: str) -> tuple[dict, Path, str]: 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 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: def main() -> None:
original_state = core.STATE_FILE.read_bytes() if core.STATE_FILE.exists() else None
snapshots: set[str] = set() snapshots: set[str] = set()
generated: Path | None = None
success = False
try: try:
say("您好!我们现在开始配置扫图参数!", color=GREEN) say("您好!我们现在开始配置扫图参数!", color=GREEN)
say("在接下来的对话中,回复 3 会立即结束,并清理本次向导产生的标定痕迹。") say(
"标定进度会逐步保存;中途回复 3 或按 Ctrl+C 退出后,"
"下次运行仍可选择本次记录继续。"
)
history_records = load_history_records()
stage = choose( stage = choose(
"① 当前是【野外部署阶段】(回复 1)还是" "① 当前是【野外部署阶段】(回复 1)还是"
@@ -705,53 +1019,88 @@ def main() -> None:
{"1", "2"}, {"1", "2"},
) )
mount = "hanging" if stage == "1" else "desktop" 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( if valid_ptz(state["points"].get("top-left")):
"② 请在Web界面中设置左上角需要的zoom," top_left = state["points"]["top-left"]
"再手动把摄像头移到扫描区域的【左上角】。" core.print_ptz(top_left, "② 已复用左上角")
) else:
top_left = camera_ptz() top_left = capture_position(
state = core.load_state() "② 请在Web界面中设置左上角需要的zoom,"
state["points"]["top-left"] = top_left "再手动把摄像头移到扫描区域的【左上角】。"
core.save_state(state) )
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( if valid_ptz(state["points"].get("bottom-right")):
"③ 请手动把摄像头移到扫描区域的【右下角】。\n" bottom_right = state["points"]["bottom-right"]
"右下角可以使用不同zoom,程序会取两个角点zoom的平均值作为最终扫描zoom。" core.print_ptz(bottom_right, "③ 已复用右下角")
) else:
bottom_right = camera_ptz() bottom_right = capture_position(
average_zoom = round((top_left["zoom"] + bottom_right["zoom"]) / 2) "③ 请手动把摄像头移到扫描区域的【右下角】。\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( say(
f"两个角点zoom:左上={top_left['zoom']/100:.2f}×," f"两个角点zoom:左上={top_left['zoom']/100:.2f}×,"
f"右下={bottom_right['zoom']/100:.2f}×;" f"右下={bottom_right['zoom']/100:.2f}×;"
f"最终取平均={average_zoom/100:.2f}×。" f"算术平均={average_zoom/100:.3f}×。"
) )
state = core.load_state() points = state["points"]
state["points"]["bottom-right"] = bottom_right available_axes = reusable_step_axes(state, points)
core.save_state(state) 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 available_axes != {"pan", "tilt"}:
while True: missing_axes = {"pan", "tilt"} - available_axes
confirm_position( confirm_position(
"④ 这一步用于测量当前zoom下的水平/垂直视场。\n" "④ 这一步用于测量当前扫图zoom下的水平/垂直视场。\n"
"请优先把摄像头移到扫描区域内部、" "请优先把摄像头移到扫描区域内部、"
"距离较远且纹理分布在整幅画面的位置。\n" "距离较远且纹理分布在整幅画面的位置。\n"
"请避开纯天空、纯墙面、纯水面、玻璃、近距离物体和重复图案;" "请避开纯天空、纯墙面、纯水面、玻璃、近距离物体和重复图案;"
"水面场景需要让岸线、浮标或其他固定物体横向分布在画面中;" "水面场景需要让岸线、浮标或其他固定物体横向分布在画面中;"
"回复1后不要再操作Web云台,程序会先切换到两个角点的平均zoom," "回复1后不要再操作Web云台。无论当前zoom是多少,程序都会先切换到"
"再自动移动、拍照并返回起点。" "两个角点的平均zoom;若平均值不可达就向上取最近可达档位,"
f"然后测量{'+'.join(sorted(missing_axes))}并返回起点。"
) )
say("\n正在计算,请不要操作摄像头或Web控制页面……") say("\n正在计算,请不要操作摄像头或Web控制页面……")
try: try:
calculate_steps(points, snapshots, mount) calculate_steps(points, snapshots, mount, missing_axes)
break state = core.load_state()
available_axes = reusable_step_axes(state, points)
except SystemExit as exc: except SystemExit as exc:
say(f"\n本次视场标定没有通过:{exc}", color=RED) say(f"\n本次视场标定没有通过:{exc}", color=RED)
choose( choose(
"请根据上方逐项失败原因调整位置后重试第四步,两个角点无需重录。\n" "请根据上方逐项失败原因调整位置后重试第四步,两个角点无需重录。\n"
"重新尝试回复 1;结束对话回复 3。", "已成功完成的单轴结果已经保存。重新尝试回复 1;结束对话回复 3。",
{"1"}, {"1"},
) )
state = core.load_state()
available_axes = reusable_step_axes(state, points)
scan, generated, detail = generate(mount) scan, generated, detail = generate(mount)
say("\n⑤ 本次得到的扫图参数:", color=GREEN) say("\n⑤ 本次得到的扫图参数:", color=GREEN)
say(json.dumps(scan, ensure_ascii=False, indent=2)) say(json.dumps(scan, ensure_ascii=False, indent=2))
@@ -792,27 +1141,15 @@ def main() -> None:
raise RuntimeError("缺少本次实测pan方向,无法可靠排列拼图左右顺序") raise RuntimeError("缺少本次实测pan方向,无法可靠排列拼图左右顺序")
panorama = run_panorama(scan_status, mount, pan_direction) panorama = run_panorama(scan_status, mount, pan_direction)
say(f"\n拼图完成。\n大图存储位置:{panorama}", color=GREEN) say(f"\n拼图完成。\n大图存储位置:{panorama}", color=GREEN)
success = True
say("\n配置向导已完成,对话结束。", color=GREEN) say("\n配置向导已完成,对话结束。", color=GREEN)
except Cancelled: except Cancelled:
say("\n已结束对话,正在清理本次向导痕迹……", color=YELLOW) say("\n已结束对话;当前标定进度和已生成结果均已保留,后续可以复用。", color=YELLOW)
except (KeyboardInterrupt, EOFError): except (KeyboardInterrupt, EOFError):
say("\n对话被中断,正在清理本次向导痕迹……", color=YELLOW) say("\n对话被中断;当前标定进度和已生成结果均已保留,后续可以复用。", color=YELLOW)
except Exception as exc: except Exception as exc:
say(f"\n向导失败:{exc}", color=RED) say(f"\n向导失败:{exc}", color=RED)
finally: finally:
remove_session_snapshots(snapshots) 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__": if __name__ == "__main__":
+13 -1
View File
@@ -525,7 +525,19 @@ def command_generate(args: argparse.Namespace) -> None:
f"相位响应={phase_response:.2f});" f"相位响应={phase_response:.2f});"
f"请在纹理丰富且尽量远离摄像头的位置重新执行calibrate {axis}" 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( pan_start, pan_end, pan_span, visual_pan_direction = scan_pan_range(
int(top_left["pan"]), int(top_left["pan"]),
int(bottom_right["pan"]), int(bottom_right["pan"]),
+132
View File
@@ -1,11 +1,16 @@
from __future__ import annotations from __future__ import annotations
import argparse
from contextlib import redirect_stderr from contextlib import redirect_stderr
import io import io
import json
from pathlib import Path
import subprocess import subprocess
import tempfile
import unittest import unittest
from unittest import mock from unittest import mock
import interactive_calibrator as wizard
import scan_calibrator as core import scan_calibrator as core
@@ -107,5 +112,132 @@ class ApiRecoveryTests(unittest.TestCase):
self.assertEqual(move_mock.call_count, 2) 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__": if __name__ == "__main__":
unittest.main() unittest.main()