航拍视频与公路桩号的双向定位脚本

当我们航拍一条公路时,如果里程较长,最终拼接而成的视频也是比较长的。设计工作中可能需要经常查询某个特定桩号的航拍,但多数路段缺乏足够的特征,如何快速定位就成为一个现实的问题。

一种可行的解决思路是内插:根据视频长度和起终点桩号,直接内插得到对应关系。但这种方式并不精确,一方面,无人机的飞行速度未必均匀,里程较长时误差较大;另一方面,由于禁飞区(或隧道等无法航拍的路段)的存在,视频未必连续,那么这种简单内插就更不可行了。

那么其实可以设置多个“锚点”,将视频“切分”为多个段落,然后就目标桩号所在的锚点区间进行“局部插值”。很显然,只需要“锚点”足够密,则精度是完全可控的。还可以用这种方式将禁飞区段“隔离”出去。

一、配置文件

首先编辑配置文件:KPointerSet.ini,保存为UTF-8 BOM。内容格式如下:

# 航拍视频时间与桩号双向定位脚本 - 配置文件 (v1.1)

[Parameters]
# 是否允许在锚点范围之外进行线性外推估算。
# 接受的值: true 或 false
AllowExtrapolation = false

# 当前正在处理的视频名称,会显示在脚本界面上。
# 不同的视频需要自己的ini设置,因此该变量用来作基本的识别与区分。
VideoName = XX项目XX段

# ===================================================
# 禁飞区定义(或无视频的片段对应的桩号)
# 通过定义多个 [Zone.X] 段来添加禁飞区。
# ===================================================

[Zone.1]
startStake = K10+100
endStake = K15+300
message = => 注意:桩号处于禁飞区,没有对应的视频片段。

# ===================================================
# 锚点定义
# 在下方使用 "时间 = 桩号" 的格式直接添加锚点。
# 在每个禁飞区前后桩号设置锚点。
# 脚本会自动按时间顺序进行排序,无需手动排序。
# ===================================================

[Anchors]
00:00:00 = K0+000
00:05:12 = K4+950
00:10:39 = K10+101 #这里隔出了禁飞段
00:10:50 = K15+299 #这里隔出了禁飞段
00:15:02 = K19+520
00:20:47 = K24+090

二、Powershell脚本

然后是脚本主体:KPointer.ps1。用powershell编写,无需额外安装环境。注意保存为UTF-8 BOM。

# ===== 航拍视频时间与桩号双向定位脚本v1.1 =====

# ===== INI文件解析器 =====
function Parse-IniFile {
    param([string]$filePath)
    $ini = @{}
    $currentSection = ""
    (Get-Content -Path $filePath -Raw) -split '(\r?\n)' | ForEach-Object {
        $line = $_.Trim()
        if ($line -match '^\[(.+)\]$') {
            $currentSection = $matches[1].Trim()
            if (-not $ini.ContainsKey($currentSection)) {
                $ini[$currentSection] = @{}
            }
        } elseif ($line -match '^([^#;].+?)\s*=\s*(.*)') {
            if ($currentSection) {
                $key = $matches[1].Trim()
                $value = $matches[2].Trim()
                $ini[$currentSection][$key] = $value
            }
        }
    }
    return $ini
}

# ===== 加载配置 =====
try {
    $scriptPath = $MyInvocation.MyCommand.Definition
    $scriptDir = Split-Path -Parent $scriptPath
    $iniPath = Join-Path $scriptDir "KPointerSet.ini"

    if (-not (Test-Path $iniPath)) {
        throw "配置文件 KPointerSet.ini 未在脚本目录中找到!"
    }
    $Config = Parse-IniFile -filePath $iniPath

    $AllowExtrapolation = ($Config.Parameters.AllowExtrapolation -eq 'true')
    $VideoName = $Config.Parameters.VideoName

    $NoFlyZones = @()
    foreach ($section in $Config.Keys) {
        if ($section -like 'Zone.*') {
            $NoFlyZones += [pscustomobject]$Config[$section]
        }
    }

    $Anchors = @()
    if ($Config.ContainsKey('Anchors')) {
        foreach ($entry in $Config.Anchors.GetEnumerator()) {
            $Anchors += [pscustomobject]@{ time  = $entry.Name; stake = $entry.Value }
        }
        $Anchors = $Anchors | Sort-Object { [System.TimeSpan]::Parse($_.time) }
    }
    
    if ($Anchors.Count -lt 2) {
        throw "错误:锚点数量少于2个。请检查 KPointerSet.ini 中的 [Anchors] 段落。"
    }

} catch {
    Write-Host "[初始化错误] $($_.Exception.Message)" -ForegroundColor Red
    Read-Host "按 Enter 键退出..."
    return
}

# ===== 基本工具 =====
function TimeToSec([string]$timeString){ return [long]([System.TimeSpan]::Parse($timeString).TotalSeconds) }
function SecToTime([long]$totalSeconds){ return [System.TimeSpan]::FromSeconds($totalSeconds).ToString('hh\:mm\:ss') }

function StakeToMeters([string]$stake){
    $cleanInput = $stake.Trim()
    try { return [long]::Parse($cleanInput) } catch {}
    try {
        $upperStake = $cleanInput.ToUpper() -replace '\s',''
        if (-not $upperStake.StartsWith('K')) { throw }
        $upperStake = $upperStake.Substring(1)
        $parts = $upperStake.Split('+')
        $km = [long]$parts[0]
        $plus = if ($parts.Length -gt 1) { [long]$parts[1] } else { 0 }
        if ($plus -ge 1000) { throw "桩号米段应<1000:$stake" }
        return (($km * 1000) + $plus)
    } catch {
        throw "桩号格式无效:'$stake' (可接受 'K20+200' 或 '20200' 格式)"
    }
}

function MetersToStake([long]$m){
  $neg = $m -lt 0; $v = [Math]::Abs($m)
  $km = [long]([Math]::Truncate([double]$v / 1000)); $plus = $v % 1000
  "{0}{1}+{2:D3}" -f ($(if($neg){'-K'}else{'K'}),$km,$plus)
}

# ===== 构建并排序映射列表 =====
$Ks_Base = foreach($a in $Anchors){ [pscustomobject]@{ d = (StakeToMeters $a.stake); t = (TimeToSec $a.time); tstr = $a.time } }
$KsByDist = $Ks_Base | Sort-Object d, t
$KsByTime = $Ks_Base | Sort-Object t, d
$ExactTime = @{}; $Ks_Base.ForEach({ $ExactTime[$_.d] = $_.tstr })
$ExactDist = @{}; $Ks_Base.ForEach({ $ExactDist[$_.t] = (MetersToStake $_.d) })

# ===== 核心功能函数 =====
function Check-NoFlyZone([long]$d_target) {
    foreach ($zone in $NoFlyZones) {
        $d_start = StakeToMeters $zone.startStake; $d_end = StakeToMeters $zone.endStake
        $zone_min = [Math]::Min($d_start, $d_end); $zone_max = [Math]::Max($d_start, $d_end)
        if ($d_target -ge $zone_min -and $d_target -le $zone_max) {
            Write-Host $zone.message -ForegroundColor Yellow
            return $true
        }
    }
    return $false
}

function TimeAtDistance([long]$d){
  if($ExactTime.ContainsKey($d)){ return $ExactTime[$d] }
  for ($i = 1; $i -lt $KsByDist.Length; $i++) {
    $p0 = $KsByDist[$i-1]; $p1 = $KsByDist[$i]
    if ($p0.d -le $d -and $d -le $p1.d) {
      $dx = $p1.d - $p0.d; if ($dx -eq 0) { throw "Data error: Duplicate distance points." }
      $dt = $p1.t - $p0.t; $delta = $d - $p0.d
      $add = [long][Math]::Round([double]$delta * $dt / $dx); return (SecToTime ($p0.t + $add))
    }
  }
  if ($AllowExtrapolation) {
      if ($d -lt $KsByDist[0].d) { $p0 = $KsByDist[0]; $p1 = $KsByDist[1] }
      else { $p0 = $KsByDist[-2]; $p1 = $KsByDist[-1] }
      $dx = $p1.d - $p0.d; if ($dx -eq 0) { throw "Data error" }
      $dt = $p1.t - $p0.t; $delta = $d - $p0.d
      $add = [long][Math]::Round([double]$delta * $dt / $dx); return (SecToTime ($p0.t + $add))
  }
  throw "桩号 `$d` 超出定义的锚点范围。"
}

function DistanceAtTime([long]$t){
  if($ExactDist.ContainsKey($t)){ return $ExactDist[$t] }
  for ($i = 1; $i -lt $KsByTime.Length; $i++) {
    $p0 = $KsByTime[$i-1]; $p1 = $KsByTime[$i]
    if ($p0.t -le $t -and $t -le $p1.t) {
      $dt = $p1.t - $p0.t; if ($dt -eq 0) { throw "Data error: Duplicate time points." }
      $dx = $p1.d - $p0.d; $delta = $t - $p0.t
      $add = [long][Math]::Round([double]$delta * $dx / $dt); return MetersToStake($p0.d + $add)
    }
  }
  if ($AllowExtrapolation) {
      if ($t -lt $KsByTime[0].t) { $p0 = $KsByTime[0]; $p1 = $KsByTime[1] }
      else { $p0 = $KsByTime[-2]; $p1 = $KsByTime[-1] }
      $dt = $p1.t - $p0.t; if ($dt -eq 0) { throw "Data error" }
      $dx = $p1.d - $p0.d; $delta = $t - $p0.t
      $add = [long][Math]::Round([double]$delta * $dx / $dt); return MetersToStake($p0.d + $add)
  }
  throw "时间 `$t` 超出定义的锚点范围。"
}

# ===== 交互 =====
function ModeLoop(){
  while($true){
    Write-Host "`n=== 航拍视频时间与桩号双向定位脚本v1.1 ===" -ForegroundColor Cyan
    Write-Host "Tips:建议安装Potplayer(完美解码),按快捷键G可以读取当前视频时间,或输入要定位的时间。使用更加方便。"
    Write-Host "视频名称:"$VideoName -ForegroundColor Green
    Write-Host "`n模式1:桩号 -> 时间"; Write-Host "模式2:时间 -> 桩号"
    Write-Host "输入 'q' 退出;输入 'mode' 返回模式选择。"
    $mode = Read-Host '选择模式 [1/2]'; if($mode -eq 'q'){ break }
    if($mode -eq '1'){
      while($true){
        $s = Read-Host '桩号'; if($s -eq 'q'){ return }; if($s -eq 'mode'){ break }
        try{
            $d_target = StakeToMeters $s
            if (Check-NoFlyZone $d_target) { continue }
            $calculatedTime = TimeAtDistance $d_target
            $calculatedTime | Set-Clipboard
            Write-Host ("=> 时间: {0}  (已复制到剪贴板)" -f $calculatedTime) -ForegroundColor Green
        } catch { Write-Host "[错误] $($_.Exception.Message)" -ForegroundColor Red }
      }
    } elseif($mode -eq '2'){
      while($true){
        $tstr = Read-Host '时间 (HH:MM:SS)'; if($tstr -eq 'q'){ return }; if($tstr -eq 'mode'){ break }
        try{
            $tsec = TimeToSec $tstr
            $calculatedStake = DistanceAtTime $tsec
            $calculatedStake | Set-Clipboard
            Write-Host ("=> 桩号: {0}  (已复制到剪贴板)" -f $calculatedStake) -ForegroundColor Green
        } catch { Write-Host "[错误] $($_.Exception.Message)" -ForegroundColor Red }
      }
    }
  }
}

ModeLoop

这里有几个注意事项:

1、TimeToSec()和SecToTime()使用System.TimeSpan方法来处理时间格式与秒数的换算,以避免潜在的双精度造成的“跳进”问题。

2、MetersToStake()用Truncate()来处理桩号与米数的换算,防止四舍五入(看似简单但很容易踩坑)。

3、对锚点的读取支持自动排序,防止用户在输入锚点时没有严格遵循先后顺序。

4、StakeToMeters()考虑用户习惯,既能兼容“K20+200”这样的格式,也能兼容“20200”这样的格式。

5、采用循环设计,以便用户连续定位。给出“q”和“mode”两个关键字用来退出循环或切换模式。

6、将输出的时间点或桩号复制到剪贴板,便于用户直接粘贴到播放器中定位,或在其他专业软件中使用。可以显著提升用户体验。

三、vbs用来执行Powershell脚本

Windows下直接执行powershell有一定门槛,为了方便普通用户,用vbs脚本来执行ps1。创建RunKPointer.vbs(注意保存为ANSI格式)。

Option Explicit

Dim fso, shell, scriptDir, scriptName, ps1Path, pwsh, cmd
Set fso = CreateObject("Scripting.FileSystemObject")
Set shell = CreateObject("WScript.Shell")

' 1. 获取脚本的完整目录和文件名
scriptDir = fso.GetParentFolderName(WScript.ScriptFullName)
scriptName = "KPointer.ps1" ' PowerShell脚本的文件名
ps1Path = fso.BuildPath(scriptDir, scriptName)
pwsh = "%SystemRoot%\System32\WindowsPowerShell\v1.0\powershell.exe"

If Not fso.FileExists(ps1Path) Then
  MsgBox "未找到 " & scriptName & "(应与本VBS同目录)", 16, "KPointer"
  WScript.Quit 1
End If

cmd = pwsh & " -NoLogo -NoProfile -ExecutionPolicy Bypass -NoExit -Command ""& {Set-Location -Path '" & scriptDir & "'; & '.\" & scriptName & "' }"""

' 运行命令
' 1=正常窗口;第三个参数 False=不阻塞VBS
shell.Run cmd, 1, False

对于每一个单独的长视频文件,将上述3个文件复制到视频同目录下,设置好ini文件,即可使用。

1.1版本文件下载