gengxin
This commit is contained in:
1 parent
714a2e1db2
commit
f7a782dcef
5 files changed
+584
-73
No files matched your search
@@ -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/*
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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"]),
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in new issue
Block a user