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方向尝试若干候选角度,每次抓图后回到起点; 2. 沿pan方向尝试若干候选角度,每次抓图后回到起点;
3. 用ORB特征、RANSAC和单轴画面位移求水平视场; 3. 用ORB特征、RANSAC和单轴画面位移求水平视场;
4. 沿tilt方向重复测量; 4. 沿tilt方向重复测量;
5. 每个方向会跑完预设的全部候选step,再统一筛选相互一致的视场样本;不因前几个样本 5. 每个方向统一筛选相互一致的视场样本;获得4个覆盖充分、离散度不超过8%的
离散度低而提前停止; 可靠样本后提前停止,不再测试更大且通常没有公共画面的step;
6. 两组结果只有在特征质量高且视场差不超过8%时才接受,否则要求至少三组一致结果; 6. 两组结果只有在特征质量高且视场差不超过8%时才接受,否则要求至少三组一致结果;
7. 水平方向按4%、垂直方向按2%目标重叠计算step;这是根据完整扫图接缝分别设置的轴向补偿; 7. 水平方向按4.5%、垂直方向按2%目标重叠计算step;这是根据完整扫图接缝分别设置的轴向补偿;
8. 默认使用ORB特征和RANSAC几何变换;当局部图案重复导致几何内点不足时,自动使用整幅图 8. 默认使用ORB特征和RANSAC几何变换;当局部图案重复导致几何内点不足时,自动使用整幅图
相位相关复核纯单轴位移,相位响应不得低于0.20; 相位相关复核纯单轴位移,相位响应不得低于0.20;
8. 要求至少50个几何内点且内点率至少45%,否则拒绝结果; 9. 要求至少50个几何内点且内点率至少45%,否则拒绝结果;
9. 根据多个水平样本的带符号位移,自动识别该型号摄像头从画面左到右时pan是递增还是递减; 10. 根据配置中的图像宽高比,用可靠的tilt视场按普通透视公式反算pan视场;若与pan实测相差
10. 删除本次自动标定产生的临时截图。 不超过15%,取“直接pan结果”和“tilt推导结果”的几何平均值,避免任一模型单独产生偏差;
11. 若pan实测与tilt透视预测相差超过15%,不强制套用比例,并明确提示镜头畸变或不同tilt行
水平覆盖不一致;这种摄像头使用单一pan_step无法保证所有行同时零重叠且无缺口;
12. 根据多个水平样本的带符号位移,自动识别该型号摄像头从画面左到右时pan是递增还是递减;
13. 删除本次自动标定产生的临时截图。
这一步不能省略。两个角点只能确定扫描范围,无法确定当前摄像头型号和zoom下的一幅画面 这一步不能省略。两个角点只能确定扫描范围,无法确定当前摄像头型号和zoom下的一幅画面
覆盖多少角度。 覆盖多少角度。
@@ -299,6 +303,6 @@ docker exec smart-cam curl -fsS -X POST \
- 不同型号的海康球机可能有相反的pan数值方向;向导会实拍识别,不能用desktop/hanging - 不同型号的海康球机可能有相反的pan数值方向;向导会实拍识别,不能用desktop/hanging
或“默认走短弧”代替; 或“默认走短弧”代替;
- 标定期间不要同时从Web界面控制PTZ; - 标定期间不要同时从Web界面控制PTZ;
- 向导按轴设置目标重叠:水平4%、垂直2%;最终以完整试扫的接缝结果为准; - 向导按轴设置目标重叠:水平4.5%、垂直2%;最终以完整试扫的接缝结果为准;
- 自动标定会完整测试全部候选step,再从所有可靠结果中筛选一致样本; - 自动标定会完整测试全部候选step,再从所有可靠结果中筛选一致样本;
- 机械回差、镜头畸变和近景视差可能导致不同位置略有差异,最终必须完整试扫并拼图复核; - 机械回差、镜头畸变和近景视差可能导致不同位置略有差异,最终必须完整试扫并拼图复核;
+101 -3
View File
@@ -8,6 +8,7 @@ from contextlib import redirect_stdout
from datetime import datetime from datetime import datetime
import io import io
import json import json
import math
from pathlib import Path from pathlib import Path
import re import re
import select 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 # 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 # model predicts, while vertical seams need a small safety margin. Keep these
# axis-specific targets within the generator's accepted 0%..5% range. # 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" RED = "\033[31m"
GREEN = "\033[32m" GREEN = "\033[32m"
YELLOW = "\033[33m" 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 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: def failure_detail(result: dict) -> str:
reason = str(result.get("reason") or "unknown") reason = str(result.get("reason") or "unknown")
labels = { labels = {
@@ -360,8 +450,16 @@ def calculate_steps(points: dict, snapshots: set[str], mount: str) -> None:
color=YELLOW, color=YELLOW,
) )
state = core.load_state() state = core.load_state()
state["steps"]["pan"] = estimate_axis("pan", origin, points, snapshots) pan_info = estimate_axis("pan", origin, points, snapshots)
state["steps"]["tilt"] = estimate_axis("tilt", 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) core.save_state(state)