canshu
This commit is contained in:
1 parent
98ed2576c6
commit
136ba0ce89
2 files changed
+112
-10
No files matched your search
@@ -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
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in new issue
Block a user