tiandytmd
This commit is contained in:
1 parent
f7a782dcef
commit
ddea6d0578
6 files changed
+389
-34
No files matched your search
+122
-21
@@ -284,12 +284,14 @@ def candidate_deltas(axis: str) -> list[int]:
|
||||
return [25, 40, 60, 90, 130, 190, 280, 420, 620, 850, 1150]
|
||||
|
||||
|
||||
def fov_guided_deltas(delta: int, overlap: float, axis: str) -> list[int]:
|
||||
def fov_guided_deltas(actual_delta: int, overlap: float, axis: str) -> list[int]:
|
||||
"""Choose probes that retain enough shared image for robust matching."""
|
||||
shift = 1.0 - overlap
|
||||
if not 0.04 <= shift <= 0.92:
|
||||
# Tiny shifts amplify PTZ quantization and image-matching error. Wait for a
|
||||
# 15%..75% real image shift before replacing the generic probe sequence.
|
||||
if not 0.15 <= shift <= 0.75:
|
||||
return []
|
||||
estimated_fov = delta / shift
|
||||
estimated_fov = actual_delta / shift
|
||||
minimum = 20 if axis == "pan" else 12
|
||||
# Fractions are image displacement/FOV, hence target overlaps are roughly
|
||||
# 82%, 72%, 60%, 48% and 35%. The middle probes provide the strongest
|
||||
@@ -300,6 +302,15 @@ def fov_guided_deltas(delta: int, overlap: float, axis: str) -> list[int]:
|
||||
})
|
||||
|
||||
|
||||
def safe_probe_cap(actual_delta: int, overlap: float) -> int | None:
|
||||
"""Keep later probes inside the FOV already observed by the camera."""
|
||||
shift = 1.0 - overlap
|
||||
if actual_delta <= 0 or not 0.03 <= shift <= 0.85:
|
||||
return None
|
||||
estimated_fov = actual_delta / shift
|
||||
return max(1, round(estimated_fov * 0.85))
|
||||
|
||||
|
||||
def choose_direction(axis: str, origin: dict[str, int], points: dict) -> int:
|
||||
if axis == "pan":
|
||||
# Pan is circular. The direction changes only the sign, not the FOV estimate.
|
||||
@@ -437,12 +448,12 @@ def configured_image_aspect_ratio() -> float:
|
||||
def failure_detail(result: dict) -> str:
|
||||
reason = str(result.get("reason") or "unknown")
|
||||
labels = {
|
||||
"ptz_motion_shortfall": "云台受限,实际移动量不足",
|
||||
"not_enough_features": "两张图片可检测特征不足",
|
||||
"not_enough_matches": "两张图片的公共特征不足",
|
||||
"homography_failed": "公共特征无法形成可靠几何变换",
|
||||
"axis_motion_too_small": "匹配到的主轴画面位移过小",
|
||||
"axis_motion_inconsistent": "匹配位移方向异常或串轴过大",
|
||||
"ptz_motion_shortfall": "摄像头实际移动小于请求值,本样本自动跳过",
|
||||
"not_enough_features": "画面纹理太少,无法测量,本样本自动跳过",
|
||||
"not_enough_matches": "移动前后相同画面太少,本样本自动跳过",
|
||||
"homography_failed": "移动前后相同画面太少或纹理重复,本样本自动跳过",
|
||||
"axis_motion_too_small": "画面位移太小,本样本自动跳过",
|
||||
"axis_motion_inconsistent": "画面同时出现过大横纵位移,本样本自动跳过",
|
||||
}
|
||||
fields = []
|
||||
if result.get("actual_axis_motion") is not None:
|
||||
@@ -476,6 +487,7 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
||||
pending = candidate_deltas(axis)
|
||||
tested: set[int] = set()
|
||||
adaptive_seeded = False
|
||||
direction_switched = False
|
||||
while pending:
|
||||
delta = pending.pop(0)
|
||||
if delta in tested:
|
||||
@@ -485,14 +497,20 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
||||
axis, delta, direction, 1.0, baseline=baseline, origin=origin
|
||||
)
|
||||
snapshots.add(str(result.get("candidate_file", "")))
|
||||
actual_motion = int(result.get("actual_axis_motion") or 0)
|
||||
switch_threshold = 150 if axis == "pan" else 90
|
||||
if (
|
||||
axis == "tilt"
|
||||
and not reliable
|
||||
not reliable
|
||||
and not direction_switched
|
||||
and result.get("reason") == "ptz_motion_shortfall"
|
||||
and delta >= switch_threshold
|
||||
and actual_motion < max(2, round(delta * 0.15))
|
||||
):
|
||||
direction *= -1
|
||||
direction_switched = True
|
||||
say(
|
||||
f" step={delta}: 原方向受云台限位,自动改用相反方向继续标定"
|
||||
f" step={delta}: {axis}在原方向基本没有移动,"
|
||||
"可能接近限位;程序只切换一次反方向再试"
|
||||
)
|
||||
result = core.measure_delta(
|
||||
axis, delta, direction, 1.0, baseline=baseline, origin=origin
|
||||
@@ -501,7 +519,20 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
||||
if not result.get("valid"):
|
||||
reason = str(result.get("reason") or "unknown")
|
||||
failure_reasons[reason] = failure_reasons.get(reason, 0) + 1
|
||||
say(f" step={delta}: {failure_detail(result)},继续尝试")
|
||||
stop_expanding = (
|
||||
bool(reliable)
|
||||
and reason in {"not_enough_matches", "homography_failed"}
|
||||
)
|
||||
say(
|
||||
f" step={delta}: {failure_detail(result)}"
|
||||
f",{'停止扩大步长' if stop_expanding else '继续尝试'}"
|
||||
)
|
||||
if stop_expanding:
|
||||
say(
|
||||
" 移动前后已没有足够相同画面;"
|
||||
"更大step只会移得更远,现在停止扩大步长。"
|
||||
)
|
||||
break
|
||||
continue
|
||||
overlap = float(result["overlap"])
|
||||
inliers = int(result.get("inliers") or 0)
|
||||
@@ -511,8 +542,12 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
||||
else:
|
||||
quality = f"有效特征={inliers},内点率={ratio*100:.1f}%"
|
||||
say(f" step={delta}: 重叠约 {overlap*100:.1f}%,{quality}")
|
||||
actual_delta = int(result.get("actual_axis_motion") or delta)
|
||||
cap = safe_probe_cap(actual_delta, overlap)
|
||||
if cap is not None:
|
||||
pending = [value for value in pending if value <= max(delta, cap)]
|
||||
if not adaptive_seeded:
|
||||
guided = fov_guided_deltas(delta, overlap, axis)
|
||||
guided = fov_guided_deltas(actual_delta, overlap, axis)
|
||||
if guided:
|
||||
# Keep only a small safety tail beyond the guided range. Very
|
||||
# large fixed probes cannot match once the current optical FOV
|
||||
@@ -533,11 +568,9 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
||||
and float(result.get("phase_response") or 0) >= core.PHASE_MIN_RESPONSE
|
||||
)
|
||||
if (feature_reliable or phase_reliable) and 0.08 <= overlap <= 0.90:
|
||||
# Hikvision absolute PTZ movement is quantized and can also have
|
||||
# backlash. At high optical zoom, using the requested delta
|
||||
# instead of the SDK-reported actual movement creates a large,
|
||||
# systematic FOV error. Shortfall samples have already been
|
||||
# rejected above, so the actual movement is authoritative here.
|
||||
# Absolute PTZ movement may be quantized or affected by backlash on
|
||||
# either vendor. Use the adapter-reported motion, not the request.
|
||||
# Shortfall samples have already been rejected above.
|
||||
actual_delta = int(result.get("actual_axis_motion") or delta)
|
||||
reliable.append((
|
||||
actual_delta, overlap,
|
||||
@@ -564,7 +597,10 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
||||
)
|
||||
core.fail(
|
||||
f"{axis}方向只得到{len(reliable)}个可靠样本,至少需要2个不同移动量互相验证。"
|
||||
f"失败统计:{summary or '无'}。请根据上方每个step的具体原因处理后重试"
|
||||
f"失败统计:{summary or '无'}。\n"
|
||||
"现场处理:1. 把镜头移到扫描区域中部;"
|
||||
"2. 画面中保留岸线、树木、建筑等固定纹理;"
|
||||
"3. 不要操作Web云台;4. 回复1重试。"
|
||||
)
|
||||
|
||||
# Fixed OSD text and repeated scenery can yield a convincing false
|
||||
@@ -575,7 +611,9 @@ def estimate_axis(axis: str, origin: dict[str, int], points: dict,
|
||||
values = ", ".join(f"{sample[2]:.0f}" for sample in reliable)
|
||||
core.fail(
|
||||
f"{axis}方向不同移动量反算的视场不一致({values}),疑似重复图案误匹配;"
|
||||
"本次结果不写入配置,请更换标定画面"
|
||||
"本次结果不写入配置。\n"
|
||||
"现场处理:把镜头移到扫描区域中部,对准距离较远、"
|
||||
"整幅都有不重复固定纹理的场景,然后回复1重试。"
|
||||
)
|
||||
|
||||
estimated_fov, spread = fov_statistics(consistent)
|
||||
@@ -1003,10 +1041,72 @@ def reusable_step_axes(state: dict, points: dict) -> set[str]:
|
||||
return reusable
|
||||
|
||||
|
||||
def acquire_calibration_control() -> bool:
|
||||
"""Pause an active patrol and wait for its in-flight PTZ command to end."""
|
||||
status, camera = core.get_control_status()
|
||||
host = str(camera["camera"].get("sdk_host") or "").strip()
|
||||
detail = f",摄像头={host}" if host else ""
|
||||
say(
|
||||
f"已识别摄像头适配层:vendor={camera['vendor']}{detail};"
|
||||
"PTZ位置读取和绝对移动能力正常。"
|
||||
)
|
||||
control = status.get("control", {})
|
||||
if not isinstance(control, dict):
|
||||
core.fail("scan HTTP未返回合法的control状态")
|
||||
if bool(control.get("scan_active")):
|
||||
core.fail("当前正在执行完整扫图,请等待扫图结束后再运行标定向导")
|
||||
|
||||
should_resume = bool(
|
||||
control.get("patrol_active")
|
||||
and not control.get("patrol_paused_by_operator")
|
||||
)
|
||||
if should_resume:
|
||||
core.api(
|
||||
"POST", "/api/v1/patrol/stop",
|
||||
{"source": "scan_calibrator"}, timeout=15,
|
||||
)
|
||||
say("已临时暂停巡航;标定结束或正常中断时会自动恢复。", color=YELLOW)
|
||||
|
||||
try:
|
||||
# This is a synchronization barrier: GET waits behind any PTZ command
|
||||
# already in flight when patrol was paused, then claims manual control.
|
||||
core.get_ptz()
|
||||
except BaseException:
|
||||
if should_resume:
|
||||
try:
|
||||
core.api(
|
||||
"POST", "/api/v1/patrol/start",
|
||||
{"source": "scan_calibrator"}, timeout=15,
|
||||
)
|
||||
except BaseException as resume_error:
|
||||
core.warn(f"标定准备失败后未能自动恢复巡航:{resume_error}")
|
||||
raise
|
||||
return should_resume
|
||||
|
||||
|
||||
def release_calibration_control(should_resume: bool) -> None:
|
||||
if not should_resume:
|
||||
return
|
||||
try:
|
||||
core.api(
|
||||
"POST", "/api/v1/patrol/start",
|
||||
{"source": "scan_calibrator"}, timeout=15,
|
||||
)
|
||||
say("已恢复进入向导前的巡航状态。", color=YELLOW)
|
||||
except BaseException as exc:
|
||||
core.warn(
|
||||
f"未能自动恢复巡航:{exc};请执行 "
|
||||
"docker exec smart-cam curl -sS -X POST "
|
||||
"http://127.0.0.1:18080/api/v1/patrol/start"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
snapshots: set[str] = set()
|
||||
resume_patrol = False
|
||||
try:
|
||||
say("您好!我们现在开始配置扫图参数!", color=GREEN)
|
||||
resume_patrol = acquire_calibration_control()
|
||||
say(
|
||||
"标定进度会逐步保存;中途回复 3 或按 Ctrl+C 退出后,"
|
||||
"下次运行仍可选择本次记录继续。"
|
||||
@@ -1150,6 +1250,7 @@ def main() -> None:
|
||||
say(f"\n向导失败:{exc}", color=RED)
|
||||
finally:
|
||||
remove_session_snapshots(snapshots)
|
||||
release_calibration_control(resume_patrol)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in new issue
Block a user