From a773166c9186aff25090f72f6cccd7bbc391370f Mon Sep 17 00:00:00 2001 From: Ankle Date: Fri, 24 Jul 2026 11:32:08 +0800 Subject: [PATCH] get step --- README.md | 29 +++++--- interactive_calibrator.py | 142 +++++++++++++++++++++++++++++++------- 2 files changed, 135 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 4a462bd..7f42488 100644 --- a/README.md +++ b/README.md @@ -60,8 +60,10 @@ cd /home/zhangw/scan-calibrator > 1 已读取: pan=13420 (134.20°), tilt=-340 (-3.40°), zoom=810 (8.10×) -④ 请保持zoom不变,把摄像头移到扫描区域中央、距离较远且纹理丰富的位置。 -请避开纯天空、纯墙面、水面、玻璃和重复图案;确认后程序会自动移动、拍照并计算step。 +④ 这一步用于测量当前zoom下的水平/垂直视场。 +请保持zoom不变,优先把摄像头移到扫描区域内部、距离较远且纹理分布在整幅画面的位置。 +请避开纯天空、纯墙面、水面、玻璃、近距离物体和重复图案; +回复1后不要再操作Web云台,程序会自动移动、拍照并返回起点。 到位后回复 1;回复 3 随时退出。 > 1 @@ -81,8 +83,8 @@ cd /home/zhangw/scan-calibrator ⑤ 本次得到的扫图参数: { "pan_start": 13420, - "pan_end": 19672, - "pan_step": 1042, + "pan_end": 19870, + "pan_step": 1075, "tilt_start": -340, "tilt_end": 1511, "tilt_step": 617, @@ -90,7 +92,7 @@ cd /home/zhangw/scan-calibrator } 安装方式:desktop 范围检查:视觉左→右pan=decreasing,指定弧段=57.00° -整步扩边:pan_step=1042,向终点外扩=552(5.52°) +整步扩边:pan_step=1075,向终点外扩=750(7.50°) 整步扩边:tilt_step=617,向终点外扩=521(5.21°) 预计网格:7列 × 4行 = 28张 @@ -164,20 +166,25 @@ smart-cam的HTTP接口读取PTZ并记录,等价于: ### ④ 自动计算pan_step和tilt_step -现场人员把镜头移到扫描区域中央、距离较远且纹理丰富的位置。回复`1`后,向导会: +现场人员保持zoom不变,把镜头移到扫描区域内部、距离较远且纹理丰富的位置。 +回复`1`后,向导会: 1. 抓取基准图; 2. 沿pan方向尝试若干候选角度,每次抓图后回到起点; 3. 用ORB特征、RANSAC和单轴画面位移求水平视场; 4. 沿tilt方向重复测量; -5. 按`step = 视场 × (1 - 0.04)`计算目标重叠4%的step; -6. 要求至少50个几何内点且内点率至少45%,否则拒绝结果; -7. 删除本次自动标定产生的临时截图。 +5. 每个方向用不同移动量反算视场:两组结果只有在特征质量高且视场差不超过8%时才接受, + 否则要求至少三组一致结果; +6. 水平方向按1%目标重叠计算step,垂直方向按4%目标重叠计算step; +7. 要求至少50个几何内点且内点率至少45%,否则拒绝结果; +8. 删除本次自动标定产生的临时截图。 这一步不能省略。两个角点只能确定扫描范围,无法确定当前摄像头型号和zoom下的一幅画面 覆盖多少角度。 不要把画面对准纯天空、纯墙、水面、玻璃、近距离物体或重复图案,否则无法可靠匹配。 +这一步不能改变zoom;自动对焦不影响zoom。标定失败时可以只更换第四步的位置重试, +不需要重新记录左上角和右下角。 ### ⑤ 显示最终参数 @@ -261,5 +268,7 @@ docker exec smart-cam curl -fsS \ - 每一个摄像头点位都要重新记录角点; - 摄像头型号或zoom变化时必须重新自动计算step; - 标定期间不要同时从Web界面控制PTZ; -- 向导目标是4%重叠,为5%上限保留1%误差; +- 水平目标重叠为1%,垂直目标重叠为4%,都不超过5%上限; +- 自动标定不会使用单个样本;两组样本必须同时满足高特征质量和视场差不超过8%, + 普通质量的结果必须由至少三组样本互相验证; - 机械回差、镜头畸变和近景视差可能导致不同位置略有差异,最终必须完整试扫并拼图复核; diff --git a/interactive_calibrator.py b/interactive_calibrator.py index 625841c..2460fd3 100755 --- a/interactive_calibrator.py +++ b/interactive_calibrator.py @@ -20,7 +20,7 @@ import scan_calibrator as core ROOT = Path(__file__).resolve().parent CONFIG_PATH = "/smart-cam/config/config.json" -TARGET_OVERLAP = 0.04 +TARGET_OVERLAPS = {"pan": 0.01, "tilt": 0.04} RED = "\033[31m" GREEN = "\033[32m" YELLOW = "\033[33m" @@ -67,8 +67,8 @@ def remove_session_snapshots(paths: set[str]) -> None: def candidate_deltas(axis: str) -> list[int]: if axis == "pan": - return [250, 450, 700, 1000, 1400, 1900, 2600] - return [120, 220, 350, 500, 700, 950, 1300] + return [150, 250, 350, 450, 600, 800, 1100, 1500, 2100] + return [80, 120, 170, 220, 280, 350, 450, 600, 850, 1150] def choose_direction(axis: str, origin: dict[str, int], points: dict) -> int: @@ -85,7 +85,7 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict, direction = choose_direction(axis, origin, points) baseline = core.snapshot() snapshots.add(baseline) - reliable: list[tuple[float, int, dict]] = [] + reliable: list[tuple[int, float, float, dict]] = [] say(f"\n正在标定 {axis}_step,摄像头会自动移动并回到起点……") for delta in candidate_deltas(axis): @@ -102,28 +102,79 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict, say(f" step={delta}: 重叠约 {overlap*100:.1f}%," f"有效特征={inliers},内点率={ratio*100:.1f}%") if inliers >= 50 and ratio >= 0.45 and 0.08 <= overlap <= 0.80: - # Prefer a mid-overlap sample: enough common features without tiny motion. - reliable.append((abs(overlap - 0.40), delta, result)) - if 0.18 <= overlap <= 0.55 and reliable: - break + reliable.append((delta, overlap, delta / (1.0 - overlap), result)) - if not reliable: + if len(reliable) < 2: core.move(origin) core.fail( - f"{axis}方向没有得到可靠视场测量。请把摄像头移到远处、纹理丰富、" - "非重复图案的位置后重新运行向导" + f"{axis}方向只得到{len(reliable)}个可靠样本,至少需要2个不同移动量互相验证。" + "请把摄像头移到远处、纹理丰富、非重复图案的位置后重新运行向导" ) - _, measured_step, detail = min(reliable, key=lambda item: item[0]) - measured_overlap = float(detail["overlap"]) - estimated_fov = measured_step / (1.0 - measured_overlap) - step = max(1, round(estimated_fov * (1.0 - TARGET_OVERLAP))) + # Fixed OSD text and repeated scenery can yield a convincing false + # homography. A real optical FOV must agree across different PTZ deltas. + clusters = [ + [ + sample for sample in reliable + if abs(sample[2] - seed[2]) / max(seed[2], 1.0) <= 0.12 + ] + for seed in reliable + ] + consistent = max( + clusters, key=lambda cluster: (len(cluster), -max(x[2] for x in cluster)) + ) + if len(consistent) < 2: + core.move(origin) + values = ", ".join(f"{sample[2]:.0f}" for sample in reliable) + core.fail( + f"{axis}方向不同移动量反算的视场不一致({values}),疑似重复图案误匹配;" + "本次结果不写入配置,请更换标定画面" + ) + + fovs = sorted(sample[2] for sample in consistent) + estimated_fov = ( + fovs[len(fovs) // 2] if len(fovs) % 2 + else (fovs[len(fovs) // 2 - 1] + fovs[len(fovs) // 2]) / 2 + ) + spread = (max(fovs) - min(fovs)) / estimated_fov + 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 + for sample in consistent + ) + allowed_spread = 0.08 + if not strong_pair: + 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))) + 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}%" + ) return { "value": step, "direction": direction, - "overlap": TARGET_OVERLAP, - "target_overlap": TARGET_OVERLAP, + "overlap": target_overlap, + "target_overlap": target_overlap, "overlap_method": "axis_shift_v1_retargeted", "zoom": origin["zoom"], "measured_at": origin, @@ -134,15 +185,43 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict, "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"), + } + for sample in consistent + ], + "spread": round(spread, 6), }, } -def calculate_steps(points: dict, snapshots: set[str]) -> None: +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")] if max(zooms + [origin["zoom"]]) - min(zooms + [origin["zoom"]]) > 10: core.fail("三个位置的zoom不一致;请固定焦段后重新开始") + top_left = points["top-left"] + bottom_right = points["bottom-right"] + pan_start, _, pan_span, _ = core.scan_pan_range( + int(top_left["pan"]), int(bottom_right["pan"]), mount + ) + 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["steps"]["pan"] = estimate_axis("pan", origin, points, snapshots) state["steps"]["tilt"] = estimate_axis("tilt", origin, points, snapshots) @@ -310,15 +389,26 @@ def main() -> None: state["points"]["bottom-right"] = bottom_right core.save_state(state) - confirm_position( - "④ 请保持zoom不变,把摄像头移到扫描区域中央、" - "距离较远且纹理丰富的位置。\n" - "请避开纯天空、纯墙面、水面、玻璃和重复图案;" - "确认后程序会自动移动、拍照并计算step。" - ) - say("\n正在计算,请不要操作摄像头或Web控制页面……") points = core.load_state()["points"] - calculate_steps(points, snapshots) + while True: + confirm_position( + "④ 这一步用于测量当前zoom下的水平/垂直视场。\n" + "请保持zoom不变,优先把摄像头移到扫描区域内部、" + "距离较远且纹理分布在整幅画面的位置。\n" + "请避开纯天空、纯墙面、水面、玻璃、近距离物体和重复图案;" + "回复1后不要再操作Web云台,程序会自动移动、拍照并返回起点。" + ) + 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))