1278 lines
52 KiB
Python
Executable File
1278 lines
52 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Fixed-dialogue field deployment wizard for scan calibration."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
from contextlib import redirect_stdout
|
||
from copy import deepcopy
|
||
from datetime import datetime
|
||
import io
|
||
import json
|
||
import math
|
||
import os
|
||
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
|
||
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
|
||
# 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.045, "tilt": 0.02}
|
||
RED = "\033[31m"
|
||
GREEN = "\033[32m"
|
||
YELLOW = "\033[33m"
|
||
RESET = "\033[0m"
|
||
ACTIVE_HISTORY_FILE: Path | None = None
|
||
|
||
|
||
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 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"})
|
||
|
||
|
||
def camera_ptz() -> dict[str, int]:
|
||
ptz = core.get_ptz()
|
||
core.print_ptz(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:
|
||
for path in sorted(paths):
|
||
if not path.startswith((
|
||
"/smart-cam/data/scan/manual/",
|
||
"/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]:
|
||
"""Coarse probes from narrow to wide FOV.
|
||
|
||
Once any probe provides a usable FOV estimate, estimate_axis replaces the
|
||
wide tail with scene/camera-specific deltas. Dense small values are
|
||
important at high optical zoom, where a jump from 150 to 250 PTZ units can
|
||
go directly from useful overlap to no common image.
|
||
"""
|
||
if axis == "pan":
|
||
return [50, 75, 110, 150, 220, 320, 470, 700, 1050, 1500, 2100]
|
||
return [25, 40, 60, 90, 130, 190, 280, 420, 620, 850, 1150]
|
||
|
||
|
||
def fov_guided_deltas(actual_delta: int, overlap: float, axis: str) -> list[int]:
|
||
"""Choose probes that retain enough shared image for robust matching."""
|
||
shift = 1.0 - overlap
|
||
# Tiny shifts amplify PTZ quantization and image-matching error. Wait for a
|
||
# 15%..75% real image shift before replacing the generic probe sequence.
|
||
if not 0.15 <= shift <= 0.75:
|
||
return []
|
||
estimated_fov = actual_delta / shift
|
||
minimum = 20 if axis == "pan" else 12
|
||
# Fractions are image displacement/FOV, hence target overlaps are roughly
|
||
# 82%, 72%, 60%, 48% and 35%. The middle probes provide the strongest
|
||
# cross-check, while the last one confirms the wider displacement model.
|
||
return sorted({
|
||
max(minimum, round(estimated_fov * fraction))
|
||
for fraction in (0.18, 0.28, 0.40, 0.52, 0.65)
|
||
})
|
||
|
||
|
||
def safe_probe_cap(actual_delta: int, overlap: float) -> int | None:
|
||
"""Keep later probes inside the FOV already observed by the camera."""
|
||
shift = 1.0 - overlap
|
||
if actual_delta <= 0 or not 0.03 <= shift <= 0.85:
|
||
return None
|
||
estimated_fov = actual_delta / shift
|
||
return max(1, round(estimated_fov * 0.85))
|
||
|
||
|
||
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]],
|
||
maximum_spread: float = 0.15) -> list:
|
||
"""Return the largest group with a bounded end-to-end FOV spread.
|
||
|
||
The previous seed-radius clustering allowed one sample at -12% and
|
||
another at +12% into the same cluster. That cluster could therefore have
|
||
nearly 24% end-to-end spread and was immediately rejected by the later
|
||
15% check. Use sorted contiguous windows so membership and validation
|
||
apply the same definition of consistency.
|
||
"""
|
||
if not reliable:
|
||
return []
|
||
ordered = sorted(reliable, key=lambda sample: sample[2])
|
||
candidates = []
|
||
for begin in range(len(ordered)):
|
||
for end in range(begin, len(ordered)):
|
||
window = ordered[begin:end + 1]
|
||
_, spread = fov_statistics(window)
|
||
if spread <= maximum_spread:
|
||
candidates.append(window)
|
||
return max(candidates, key=lambda group: (
|
||
len(group),
|
||
-fov_statistics(group)[1],
|
||
sum(int(sample[3].get("inliers") or 0) for sample in group),
|
||
))
|
||
|
||
|
||
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 horizontal_fov_from_vertical(vertical_fov: float, aspect_ratio: float) -> float:
|
||
"""Convert a rectilinear vertical FOV to horizontal FOV in PTZ units."""
|
||
vertical_radians = math.radians(vertical_fov / 100.0)
|
||
horizontal_radians = 2.0 * math.atan(
|
||
aspect_ratio * math.tan(vertical_radians / 2.0)
|
||
)
|
||
return math.degrees(horizontal_radians) * 100.0
|
||
|
||
|
||
def constrain_pan_with_tilt(pan_info: dict, tilt_info: dict,
|
||
aspect_ratio: float) -> tuple[dict, str]:
|
||
measured_pan_fov = float(pan_info["derived_from"]["estimated_fov"])
|
||
measured_tilt_fov = float(tilt_info["derived_from"]["estimated_fov"])
|
||
optical_pan_fov = horizontal_fov_from_vertical(
|
||
measured_tilt_fov, aspect_ratio
|
||
)
|
||
agreement = measured_pan_fov / optical_pan_fov
|
||
diagnostic = {
|
||
"image_aspect_ratio": round(aspect_ratio, 6),
|
||
"measured_pan_fov": round(measured_pan_fov, 4),
|
||
"measured_tilt_fov": round(measured_tilt_fov, 4),
|
||
"tilt_predicted_pan_fov": round(optical_pan_fov, 4),
|
||
"agreement_ratio": round(agreement, 6),
|
||
}
|
||
if 0.85 <= agreement <= 1.15:
|
||
tilt_target_overlap = TARGET_OVERLAPS["tilt"]
|
||
tilt_predicted_step = max(
|
||
1, round(optical_pan_fov * (1.0 - tilt_target_overlap))
|
||
)
|
||
direct_pan_step = int(pan_info["value"])
|
||
# Neither estimate is authoritative on a PTZ dome: direct horizontal
|
||
# matching is affected by distortion, while the aspect-ratio prediction
|
||
# assumes an ideal rectilinear lens. Fuse them instead of replacing one
|
||
# with the other. The geometric mean stays between both measurements.
|
||
constrained_step = max(
|
||
1, round(math.sqrt(direct_pan_step * tilt_predicted_step))
|
||
)
|
||
effective_overlap = max(
|
||
0.0, min(core.MAX_GENERATED_OVERLAP,
|
||
1.0 - constrained_step / measured_pan_fov)
|
||
)
|
||
return {
|
||
**pan_info,
|
||
"value": constrained_step,
|
||
"overlap": effective_overlap,
|
||
"target_overlap": effective_overlap,
|
||
"overlap_method": "axis_shift_v1_pan_tilt_fused",
|
||
"cross_axis_check": {
|
||
**diagnostic,
|
||
"applied": True,
|
||
"direct_pan_step": direct_pan_step,
|
||
"tilt_predicted_pan_step": tilt_predicted_step,
|
||
"fused_pan_step": constrained_step,
|
||
},
|
||
}, (
|
||
f"pan/tilt视场比例符合普通透视模型(一致率{agreement*100:.1f}%);"
|
||
f"融合pan实测={direct_pan_step}与tilt推导={tilt_predicted_step}"
|
||
f"后pan_step={constrained_step}"
|
||
)
|
||
return {
|
||
**pan_info,
|
||
"cross_axis_check": {
|
||
**diagnostic,
|
||
"applied": False,
|
||
"reason": "non_rectilinear_or_axis_scale_mismatch",
|
||
},
|
||
}, (
|
||
f"pan/tilt视场比例仅为普通透视预测的{agreement*100:.1f}%,"
|
||
"疑似镜头畸变或不同tilt行的水平覆盖不一致;"
|
||
"不能用tilt比例替代pan实测,固定pan_step应以不缺图为优先"
|
||
)
|
||
|
||
|
||
def configured_image_aspect_ratio() -> float:
|
||
try:
|
||
raw = subprocess.run(
|
||
["docker", "exec", "smart-cam", "cat", CONFIG_PATH],
|
||
text=True, capture_output=True, check=True,
|
||
).stdout
|
||
image = json.loads(raw).get("scan", {}).get("image", {})
|
||
width, height = int(image.get("width", 1280)), int(image.get("height", 720))
|
||
if width > 0 and height > 0:
|
||
return width / height
|
||
except (OSError, ValueError, KeyError, json.JSONDecodeError,
|
||
subprocess.SubprocessError):
|
||
pass
|
||
return 16.0 / 9.0
|
||
|
||
|
||
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,摄像头会自动移动并回到起点……")
|
||
|
||
pending = candidate_deltas(axis)
|
||
tested: set[int] = set()
|
||
adaptive_seeded = False
|
||
direction_switched = False
|
||
while pending:
|
||
delta = pending.pop(0)
|
||
if delta in tested:
|
||
continue
|
||
tested.add(delta)
|
||
result = core.measure_delta(
|
||
axis, delta, direction, 1.0, baseline=baseline, origin=origin
|
||
)
|
||
snapshots.add(str(result.get("candidate_file", "")))
|
||
actual_motion = int(result.get("actual_axis_motion") or 0)
|
||
switch_threshold = 150 if axis == "pan" else 90
|
||
if (
|
||
not reliable
|
||
and not direction_switched
|
||
and result.get("reason") == "ptz_motion_shortfall"
|
||
and delta >= switch_threshold
|
||
and actual_motion < max(2, round(delta * 0.15))
|
||
):
|
||
direction *= -1
|
||
direction_switched = True
|
||
say(
|
||
f" step={delta}: {axis}在原方向基本没有移动,"
|
||
"可能接近限位;程序只切换一次反方向再试"
|
||
)
|
||
result = core.measure_delta(
|
||
axis, delta, direction, 1.0, baseline=baseline, origin=origin
|
||
)
|
||
snapshots.add(str(result.get("candidate_file", "")))
|
||
if not result.get("valid"):
|
||
reason = str(result.get("reason") or "unknown")
|
||
failure_reasons[reason] = failure_reasons.get(reason, 0) + 1
|
||
stop_expanding = (
|
||
bool(reliable)
|
||
and reason in {"not_enough_matches", "homography_failed"}
|
||
)
|
||
say(
|
||
f" step={delta}: {failure_detail(result)}"
|
||
f",{'停止扩大步长' if stop_expanding else '继续尝试'}"
|
||
)
|
||
if stop_expanding:
|
||
say(
|
||
" 移动前后已没有足够相同画面;"
|
||
"更大step只会移得更远,现在停止扩大步长。"
|
||
)
|
||
break
|
||
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}")
|
||
actual_delta = int(result.get("actual_axis_motion") or delta)
|
||
cap = safe_probe_cap(actual_delta, overlap)
|
||
if cap is not None:
|
||
pending = [value for value in pending if value <= max(delta, cap)]
|
||
if not adaptive_seeded:
|
||
guided = fov_guided_deltas(actual_delta, overlap, axis)
|
||
if guided:
|
||
# Keep only a small safety tail beyond the guided range. Very
|
||
# large fixed probes cannot match once the current optical FOV
|
||
# has been measured and only waste time.
|
||
cap = round(max(guided) * 1.15)
|
||
pending = sorted({
|
||
value for value in guided + pending
|
||
if value not in tested and value <= cap
|
||
})
|
||
adaptive_seeded = True
|
||
say(
|
||
f" 已根据首个有效样本自适应生成后续step:"
|
||
f"{', '.join(str(value) for value in pending)}"
|
||
)
|
||
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.90:
|
||
# Absolute PTZ movement may be quantized or affected by backlash on
|
||
# either vendor. Use the adapter-reported motion, not the request.
|
||
# Shortfall samples have already been rejected above.
|
||
actual_delta = int(result.get("actual_axis_motion") or delta)
|
||
reliable.append((
|
||
actual_delta, overlap,
|
||
actual_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 '无'}。\n"
|
||
"现场处理:1. 把镜头移到扫描区域中部;"
|
||
"2. 画面中保留岸线、树木、建筑等固定纹理;"
|
||
"3. 不要操作Web云台;4. 回复1重试。"
|
||
)
|
||
|
||
# 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}),疑似重复图案误匹配;"
|
||
"本次结果不写入配置。\n"
|
||
"现场处理:把镜头移到扫描区域中部,对准距离较远、"
|
||
"整幅都有不重复固定纹理的场景,然后回复1重试。"
|
||
)
|
||
|
||
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
|
||
# Two independently measured FOVs that agree within 5% are already a
|
||
# strong geometric cross-check. Do not reject such a pair merely
|
||
# because a natural scene supplied 74 rather than 100 ORB inliers.
|
||
if not strong_pair and spread > 0.05:
|
||
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 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")]
|
||
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={average_zoom:g}不可达,且没有更大的角点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
|
||
# 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["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()
|
||
)
|
||
say(f" {cross_axis_message}", color=(
|
||
GREEN if pan_info["cross_axis_check"]["applied"] else YELLOW
|
||
))
|
||
state["steps"]["pan"] = pan_info
|
||
state["steps"]["tilt"] = tilt_info
|
||
save_working_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)
|
||
|
||
unit = subprocess.run(
|
||
["systemctl", "show", "-p", "LoadState", "--value", "smart-cam.service"],
|
||
text=True, capture_output=True,
|
||
)
|
||
if unit.returncode == 0 and unit.stdout.strip() == "loaded":
|
||
restart = ["systemctl", "restart", "smart-cam.service"]
|
||
if os.geteuid() != 0:
|
||
restart = ["sudo", "-n", *restart]
|
||
result = subprocess.run(restart, text=True, capture_output=True)
|
||
if result.returncode:
|
||
detail = result.stderr.strip() or result.stdout.strip()
|
||
raise RuntimeError(
|
||
"宿主机由smart-cam.service管理容器,但无法通过systemd重启。"
|
||
"请使用root执行本向导,或为systemctl restart smart-cam.service配置sudo权限。"
|
||
f"\n{detail}"
|
||
)
|
||
else:
|
||
subprocess.run(
|
||
["docker", "restart", "smart-cam"], check=True,
|
||
stdout=subprocess.DEVNULL,
|
||
)
|
||
|
||
if not core.wait_for_scan_http_stable(timeout=180, stable_seconds=15):
|
||
raise RuntimeError(
|
||
"smart-cam重启后未能连续稳定运行15秒。请执行 "
|
||
"systemctl status smart-cam.service 和 docker logs --tail 100 smart-cam 查因。"
|
||
)
|
||
|
||
|
||
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 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 acquire_calibration_control() -> bool:
|
||
"""Pause an active patrol and wait for its in-flight PTZ command to end."""
|
||
status, camera = core.get_control_status()
|
||
host = str(camera["camera"].get("sdk_host") or "").strip()
|
||
detail = f",摄像头={host}" if host else ""
|
||
say(
|
||
f"已识别摄像头适配层:vendor={camera['vendor']}{detail};"
|
||
"PTZ位置读取和绝对移动能力正常。"
|
||
)
|
||
control = status.get("control", {})
|
||
if not isinstance(control, dict):
|
||
core.fail("scan HTTP未返回合法的control状态")
|
||
if bool(control.get("scan_active")):
|
||
core.fail("当前正在执行完整扫图,请等待扫图结束后再运行标定向导")
|
||
|
||
should_resume = bool(
|
||
control.get("patrol_active")
|
||
and not control.get("patrol_paused_by_operator")
|
||
)
|
||
if should_resume:
|
||
core.api(
|
||
"POST", "/api/v1/patrol/stop",
|
||
{"source": "scan_calibrator"}, timeout=15,
|
||
)
|
||
say("已临时暂停巡航;标定结束或正常中断时会自动恢复。", color=YELLOW)
|
||
|
||
try:
|
||
# This synchronization barrier waits behind the one PTZ command that
|
||
# was already in flight when patrol was paused. Tiandy emulated
|
||
# absolute moves can legitimately take up to 135 seconds.
|
||
say("正在等待已暂停巡航的最后一条PTZ指令结束……")
|
||
core.get_ptz(timeout=160)
|
||
except BaseException:
|
||
if should_resume:
|
||
try:
|
||
core.api(
|
||
"POST", "/api/v1/patrol/start",
|
||
{"source": "scan_calibrator"}, timeout=15,
|
||
)
|
||
except BaseException as resume_error:
|
||
core.warn(f"标定准备失败后未能自动恢复巡航:{resume_error}")
|
||
raise
|
||
return should_resume
|
||
|
||
|
||
def release_calibration_control(should_resume: bool) -> None:
|
||
if not should_resume:
|
||
return
|
||
try:
|
||
core.api(
|
||
"POST", "/api/v1/patrol/start",
|
||
{"source": "scan_calibrator"}, timeout=15,
|
||
)
|
||
say("已恢复进入向导前的巡航状态。", color=YELLOW)
|
||
except BaseException as exc:
|
||
core.warn(
|
||
f"未能自动恢复巡航:{exc};请执行 "
|
||
"docker exec smart-cam curl -sS -X POST "
|
||
"http://127.0.0.1:18080/api/v1/patrol/start"
|
||
)
|
||
|
||
|
||
def main() -> None:
|
||
snapshots: set[str] = set()
|
||
resume_patrol = False
|
||
try:
|
||
say("您好!我们现在开始配置扫图参数!", color=GREEN)
|
||
resume_patrol = acquire_calibration_control()
|
||
say(
|
||
"标定进度会逐步保存;中途回复 3 或按 Ctrl+C 退出后,"
|
||
"下次运行仍可选择本次记录继续。"
|
||
)
|
||
history_records = load_history_records()
|
||
|
||
stage = choose(
|
||
"① 当前是【野外部署阶段】(回复 1)还是"
|
||
"【办公室测试阶段】(回复 2)?",
|
||
{"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};本次进度另存为一条新记录。")
|
||
|
||
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)
|
||
|
||
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:.3f}×。"
|
||
)
|
||
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()
|
||
|
||
while available_axes != {"pan", "tilt"}:
|
||
missing_axes = {"pan", "tilt"} - available_axes
|
||
confirm_position(
|
||
"④ 这一步用于测量当前扫图zoom下的水平/垂直视场。\n"
|
||
"请优先把摄像头移到扫描区域内部、"
|
||
"距离较远且纹理分布在整幅画面的位置。\n"
|
||
"请避开纯天空、纯墙面、纯水面、玻璃、近距离物体和重复图案;"
|
||
"水面场景需要让岸线、浮标或其他固定物体横向分布在画面中;"
|
||
"回复1后不要再操作Web云台。无论当前zoom是多少,程序都会先切换到"
|
||
"两个角点的平均zoom;若平均值不可达就向上取最近可达档位,"
|
||
f"然后测量{'+'.join(sorted(missing_axes))}并返回起点。"
|
||
)
|
||
say("\n正在计算,请不要操作摄像头或Web控制页面……")
|
||
try:
|
||
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"},
|
||
)
|
||
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))
|
||
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)
|
||
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)
|
||
release_calibration_control(resume_patrol)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|