From 136ba0ce894a56a1cc7507584d92abbe78da8591 Mon Sep 17 00:00:00 2001 From: Ankle Date: Wed, 29 Jul 2026 18:28:28 +0800 Subject: [PATCH] canshu --- README.md | 18 ++++--- interactive_calibrator.py | 104 ++++++++++++++++++++++++++++++++++++-- 2 files changed, 112 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index 5c01be6..0eec6ea 100644 --- a/README.md +++ b/README.md @@ -186,15 +186,19 @@ smart-cam的HTTP接口读取PTZ并记录,等价于: 2. 沿pan方向尝试若干候选角度,每次抓图后回到起点; 3. 用ORB特征、RANSAC和单轴画面位移求水平视场; 4. 沿tilt方向重复测量; -5. 每个方向会跑完预设的全部候选step,再统一筛选相互一致的视场样本;不因前几个样本 - 离散度低而提前停止; +5. 每个方向统一筛选相互一致的视场样本;获得4个覆盖充分、离散度不超过8%的 + 可靠样本后提前停止,不再测试更大且通常没有公共画面的step; 6. 两组结果只有在特征质量高且视场差不超过8%时才接受,否则要求至少三组一致结果; -7. 水平方向按4%、垂直方向按2%目标重叠计算step;这是根据完整扫图接缝分别设置的轴向补偿; +7. 水平方向按4.5%、垂直方向按2%目标重叠计算step;这是根据完整扫图接缝分别设置的轴向补偿; 8. 默认使用ORB特征和RANSAC几何变换;当局部图案重复导致几何内点不足时,自动使用整幅图 相位相关复核纯单轴位移,相位响应不得低于0.20; -8. 要求至少50个几何内点且内点率至少45%,否则拒绝结果; -9. 根据多个水平样本的带符号位移,自动识别该型号摄像头从画面左到右时pan是递增还是递减; -10. 删除本次自动标定产生的临时截图。 +9. 要求至少50个几何内点且内点率至少45%,否则拒绝结果; +10. 根据配置中的图像宽高比,用可靠的tilt视场按普通透视公式反算pan视场;若与pan实测相差 + 不超过15%,取“直接pan结果”和“tilt推导结果”的几何平均值,避免任一模型单独产生偏差; +11. 若pan实测与tilt透视预测相差超过15%,不强制套用比例,并明确提示镜头畸变或不同tilt行 + 水平覆盖不一致;这种摄像头使用单一pan_step无法保证所有行同时零重叠且无缺口; +12. 根据多个水平样本的带符号位移,自动识别该型号摄像头从画面左到右时pan是递增还是递减; +13. 删除本次自动标定产生的临时截图。 这一步不能省略。两个角点只能确定扫描范围,无法确定当前摄像头型号和zoom下的一幅画面 覆盖多少角度。 @@ -299,6 +303,6 @@ docker exec smart-cam curl -fsS -X POST \ - 不同型号的海康球机可能有相反的pan数值方向;向导会实拍识别,不能用desktop/hanging 或“默认走短弧”代替; - 标定期间不要同时从Web界面控制PTZ; -- 向导按轴设置目标重叠:水平4%、垂直2%;最终以完整试扫的接缝结果为准; +- 向导按轴设置目标重叠:水平4.5%、垂直2%;最终以完整试扫的接缝结果为准; - 自动标定会完整测试全部候选step,再从所有可靠结果中筛选一致样本; - 机械回差、镜头畸变和近景视差可能导致不同位置略有差异,最终必须完整试扫并拼图复核; diff --git a/interactive_calibrator.py b/interactive_calibrator.py index 6521b3a..9e25ae8 100755 --- a/interactive_calibrator.py +++ b/interactive_calibrator.py @@ -8,6 +8,7 @@ from contextlib import redirect_stdout from datetime import datetime import io import json +import math from pathlib import Path import re import select @@ -25,7 +26,7 @@ CONFIG_PATH = "/smart-cam/config/config.json" # 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.04, "tilt": 0.02} +TARGET_OVERLAPS = {"pan": 0.045, "tilt": 0.02} RED = "\033[31m" GREEN = "\033[32m" YELLOW = "\033[33m" @@ -110,6 +111,95 @@ def fov_statistics(samples: list[tuple[int, float, float, dict]]) -> tuple[float 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 = { @@ -360,8 +450,16 @@ def calculate_steps(points: dict, snapshots: set[str], mount: str) -> None: color=YELLOW, ) state = core.load_state() - state["steps"]["pan"] = estimate_axis("pan", origin, points, snapshots) - state["steps"]["tilt"] = estimate_axis("tilt", origin, points, snapshots) + 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)