This commit is contained in:
Ankle committed 2026-07-24 11:32:08 +08:00
1 parent 7e9ecfe01e
commit a773166c91
2 files changed
+135 -36

No files matched your search

+19 -10
View File
@@ -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%,
普通质量的结果必须由至少三组样本互相验证;
- 机械回差、镜头畸变和近景视差可能导致不同位置略有差异,最终必须完整试扫并拼图复核;
+116 -26
View File
@@ -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))