Files
scan-calibrator/interactive_calibrator.py
T
2026-09-10 17:06:56 +08:00

1157 lines
47 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Fixed-dialogue field deployment wizard for scan calibration."""
from __future__ import annotations
import argparse
from contextlib import redirect_stdout
from copy import deepcopy
from datetime import datetime
import io
import json
import math
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(delta: int, overlap: float, axis: str) -> list[int]:
"""Choose probes that retain enough shared image for robust matching."""
shift = 1.0 - overlap
if not 0.04 <= shift <= 0.92:
return []
estimated_fov = 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 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
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", "")))
if (
axis == "tilt"
and not reliable
and result.get("reason") == "ptz_motion_shortfall"
):
direction *= -1
say(
f" step={delta}: 原方向受云台限位,自动改用相反方向继续标定"
)
result = core.measure_delta(
axis, delta, direction, 1.0, baseline=baseline, origin=origin
)
snapshots.add(str(result.get("candidate_file", "")))
if not result.get("valid"):
reason = str(result.get("reason") or "unknown")
failure_reasons[reason] = failure_reasons.get(reason, 0) + 1
say(f" step={delta}: {failure_detail(result)},继续尝试")
continue
overlap = float(result["overlap"])
inliers = int(result.get("inliers") or 0)
ratio = float(result.get("inlier_ratio") or 0)
if result.get("match_method") == "phase_correlation":
quality = f"整图相位响应={float(result.get('phase_response') or 0):.2f}"
else:
quality = f"有效特征={inliers},内点率={ratio*100:.1f}%"
say(f" step={delta}: 重叠约 {overlap*100:.1f}%,{quality}")
if not adaptive_seeded:
guided = fov_guided_deltas(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:
# Hikvision absolute PTZ movement is quantized and can also have
# backlash. At high optical zoom, using the requested delta
# instead of the SDK-reported actual movement creates a large,
# systematic FOV error. Shortfall samples have already been
# rejected above, so the actual movement is authoritative here.
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 '无'}。请根据上方每个step的具体原因处理后重试"
)
# Fixed OSD text and repeated scenery can yield a convincing false
# homography. A real optical FOV must agree across different PTZ deltas.
consistent = consistent_samples(reliable)
if len(consistent) < 2:
core.move(origin)
values = ", ".join(f"{sample[2]:.0f}" for sample in reliable)
core.fail(
f"{axis}方向不同移动量反算的视场不一致({values}),疑似重复图案误匹配;"
"本次结果不写入配置,请更换标定画面"
)
estimated_fov, spread = fov_statistics(consistent)
if len(consistent) == 2:
strong_pair = all(
(
int(sample[3].get("inliers") or 0) >= 100
and float(sample[3].get("inlier_ratio") or 0) >= 0.65
)
or (
sample[3].get("match_method") == "phase_correlation"
and float(sample[3].get("phase_response") or 0) >= 0.35
)
for sample in consistent
)
allowed_spread = 0.08
# 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)
subprocess.run(["docker", "restart", "smart-cam"], check=True,
stdout=subprocess.DEVNULL)
deadline = time.monotonic() + 90
while time.monotonic() < deadline:
try:
core.api("GET", "/api/v1/scan/status", timeout=5)
return
except (SystemExit, subprocess.SubprocessError):
time.sleep(2)
raise RuntimeError("smart-cam重启后90秒内scan HTTP服务未就绪")
def run_test_scan() -> dict:
core.api("POST", "/api/v1/scan/start", timeout=20)
say("已启动测试扫描。", color=GREEN)
say("\n扫图日志:")
since = datetime.now().astimezone().isoformat(timespec="seconds")
saved_pattern = re.compile(r"\[scan\]\s+saved\b.*\bprogress=(\d+)/(\d+)\b")
last_progress: tuple[int, int] | None = None
process = subprocess.Popen(
["docker", "logs", "-f", "--since", since, "smart-cam"],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True,
)
try:
while True:
line = ""
if process.stdout:
readable, _, _ = select.select([process.stdout], [], [], 1.0)
if readable:
line = process.stdout.readline()
match = saved_pattern.search(line)
if match:
progress = (int(match.group(1)), int(match.group(2)))
if progress != last_progress:
say(f"已保存第 {progress[0]}/{progress[1]} 张图")
last_progress = progress
status = core.api("GET", "/api/v1/scan/status", timeout=5).get("scan", {})
if status.get("state") in {"completed", "failed", "stopped"}:
if status.get("state") == "completed":
say(
f"\n扫描完成,共保存 {status.get('completed', status.get('total', 0))} 张图。"
f"\n保存目录:{status.get('output_dir', '')}",
color=GREEN,
)
else:
message = (
"扫描已停止。"
if status.get("state") == "stopped"
else f"扫描失败:{status.get('error') or '未知错误'}"
)
say(f"\n{message}", color=YELLOW if status.get("state") == "stopped" else RED)
return status
except KeyboardInterrupt:
say("\n收到中断,正在通知smart-cam停止本次扫描……", color=YELLOW)
try:
core.api("POST", "/api/v1/scan/stop", timeout=10)
except (SystemExit, subprocess.SubprocessError):
say("停止接口调用失败,请检查scan状态。", color=RED)
raise Cancelled
finally:
process.terminate()
try:
process.wait(timeout=3)
except subprocess.TimeoutExpired:
process.kill()
def run_panorama(scan_status: dict, mount: str, pan_direction: str) -> Path:
scan_dir = str(scan_status.get("output_dir") or "")
scan_id = str(scan_status.get("scan_id") or "")
# stitch_scan.py runs on the host. A scan_id resolves beneath
# /userdata/data/scan, while output_dir reported by the API is the
# container-side /smart-cam/data/scan path.
source = scan_id or scan_dir
if not source:
raise RuntimeError("扫描状态中没有output_dir或scan_id,无法确定拼图来源")
stitch = ROOT / "scan-panorama" / "stitch.sh"
result = subprocess.run(
[
str(stitch), source,
"--mount", mount,
"--pan-direction", pan_direction,
],
text=True, capture_output=True,
)
if result.returncode:
raise RuntimeError(result.stderr.strip() or result.stdout.strip() or "拼图失败")
match = re.search(r"^结果:(.+)$", result.stdout, flags=re.MULTILINE)
if not match:
raise RuntimeError(f"拼图完成但没有返回结果路径:\n{result.stdout}")
output = Path(match.group(1).strip())
if not output.is_file() or output.stat().st_size == 0:
raise RuntimeError(f"拼图结果不存在或为空:{output}")
return output
def 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:
snapshots: set[str] = set()
try:
say("您好!我们现在开始配置扫图参数!", color=GREEN)
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)
if __name__ == "__main__":
main()