This commit is contained in:
Ankle committed 2026-07-29 18:28:28 +08:00
1 parent 98ed2576c6
commit 136ba0ce89
2 files changed
+112 -10

No files matched your search

+11 -7
View File
@@ -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,再从所有可靠结果中筛选一致样本;
- 机械回差、镜头畸变和近景视差可能导致不同位置略有差异,最终必须完整试扫并拼图复核;
+101 -3
View File
@@ -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)