#!/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()