#!/usr/bin/env python3 """Fixed-dialogue field deployment wizard for scan calibration.""" from __future__ import annotations import argparse from contextlib import redirect_stdout 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 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" 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 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 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 calculate_steps(points: dict, snapshots: set[str], mount: str) -> None: origin = camera_ptz() zooms = [int(points[name]["zoom"]) for name in ("top-left", "bottom-right")] target_zoom = round(sum(zooms) / len(zooms)) if origin["zoom"] != target_zoom: say( f"两个角点zoom的平均值为{target_zoom}({target_zoom/100:.2f}×);" "正在自动切换到该zoom后再测量视场……" ) origin = core.move({**origin, "zoom": target_zoom}) time.sleep(1.0) if abs(int(origin["zoom"]) - target_zoom) > 10: core.fail( f"摄像头未能到达平均zoom:目标={target_zoom},实际={origin['zoom']}" ) core.print_ptz(origin, "平均zoom到位") 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() pan_info = estimate_axis("pan", origin, points, snapshots) tilt_info = estimate_axis("tilt", origin, points, snapshots) 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 core.save_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 main() -> None: original_state = core.STATE_FILE.read_bytes() if core.STATE_FILE.exists() else None snapshots: set[str] = set() generated: Path | None = None success = False try: say("您好!我们现在开始配置扫图参数!", color=GREEN) say("在接下来的对话中,回复 3 会立即结束,并清理本次向导产生的标定痕迹。") stage = choose( "① 当前是【野外部署阶段】(回复 1)还是" "【办公室测试阶段】(回复 2)?", {"1", "2"}, ) mount = "hanging" if stage == "1" else "desktop" confirm_position( "② 请在Web界面中设置左上角需要的zoom," "再手动把摄像头移到扫描区域的【左上角】。" ) top_left = camera_ptz() state = core.load_state() state["points"]["top-left"] = top_left core.save_state(state) confirm_position( "③ 请手动把摄像头移到扫描区域的【右下角】。\n" "右下角可以使用不同zoom,程序会取两个角点zoom的平均值作为最终扫描zoom。" ) bottom_right = camera_ptz() average_zoom = round((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:.2f}×。" ) state = core.load_state() state["points"]["bottom-right"] = bottom_right core.save_state(state) points = core.load_state()["points"] while True: confirm_position( "④ 这一步用于测量当前zoom下的水平/垂直视场。\n" "请优先把摄像头移到扫描区域内部、" "距离较远且纹理分布在整幅画面的位置。\n" "请避开纯天空、纯墙面、纯水面、玻璃、近距离物体和重复图案;" "水面场景需要让岸线、浮标或其他固定物体横向分布在画面中;" "回复1后不要再操作Web云台,程序会先切换到两个角点的平均zoom," "再自动移动、拍照并返回起点。" ) say("\n正在计算,请不要操作摄像头或Web控制页面……") try: calculate_steps(points, snapshots, mount) break except SystemExit as exc: say(f"\n本次视场标定没有通过:{exc}", color=RED) choose( "请根据上方逐项失败原因调整位置后重试第四步,两个角点无需重录。\n" "重新尝试回复 1;结束对话回复 3。", {"1"}, ) 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) success = True 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 not success: if original_state is None: core.STATE_FILE.unlink(missing_ok=True) else: current_state = ( core.STATE_FILE.read_bytes() if core.STATE_FILE.exists() else None ) if current_state != original_state: core.STATE_FILE.write_bytes(original_state) if generated is not None: generated.unlink(missing_ok=True) if __name__ == "__main__": main()