2026-04-06 00:00:00
AC模拟器跑完的结果或者录像一直都有,但是缺少具体分析,也没找到类似的分析工具,不如自己写一个,刚好利用Cursor来完全做一个项目,我不写一行代码,仅仅做分析和指导方向,看看是否AI能实现我的全部要求,也能观察转成AI写代码时,我们的输入到底要做到什么程度,这个东西才足够好用或者能够工程化。
第一个需求其实是比较简单的,分析acreplay的文件格式,然后将其转变成ideal_line.ai的格式。
做之前第一步是询问AI,是否能实现将录像轨迹转成AI行车线,AI表示可以,并且给了几个方案,这里我审过以后,确认了基础实现的技术线路。

如果要我去找AC相关mod的制作信息并且了解清楚行车线和地图相关数据关系,还是比较耗时的,这里AI直接快速解决了问题。
核心结论就是只要非内置的行车线,就可以自行替代,而现在mod级别都不会内置行车线,恰恰方便了行车线的替换逻辑
当然也问了一下是否能根据车型、参数设置、赛道等等直接生成最优行车线,这里由于数据不全,所以AI回答也比较模糊,实际上AC有类似的Mod,但是那种行车线还是有延迟,而且不是很准。
接着就是先做一个最小的MVP,给出最核心的需求,先看一下是否能够实现。
根据回放1.388.acreplay文件转成zhuhai\data\ideal_line.ai格式,并替代
实际上给出这些命令,Cursor就已经完成了核心转换逻辑,实际测试确实替代了老的行车线,但是行车线还存在一些重复的部分并且刹车和油门的提示是错误的,但是轨迹基本都是正确的。
基于上面的逻辑,让AI补充提取逻辑
结合记录中的刹车和油门信号,补充到行车线中使用红色或者绿色提示
到这一步,AI直接理解了,并且刹车和油门提示正确了,但是还是有问题,acreplay中飞行圈有时候不一定是第一圈,存在半圈或者开场圈的一点点路径,AI把这部分内容也弄进去,导致一部分轨迹是错误的
根据记录的计时点开始和结束位置,提取轨迹路径
给完这个以后,AI自动理解了计时开始应该从0,结束的时间应该比较长,到这里提取出来的轨迹就是相对完美的了,实际生成的ideal_line.ai已经是我要的轨迹线了
给出更多acreplay文件进行测试,AI自动发现了飞行圈选择的问题,他自己增加了参数选择第n圈,但是实际上我们需要的是最快圈速的那一圈,这一步应该自动选择,而不是还要用户输入
自动识别acreplay中圈速最快的一圈作为提取的轨迹
到这里ACReplay2AILine就完全正常工作了
#Requires -Version 5.1
<#
.SYNOPSIS
用 acrp 解析录像,将轨迹写入任意赛道的 data\ideal_line.ai(版本 7)。
.DESCRIPTION
简易用法(acrp.exe 与脚本同目录为默认):
powershell -ExecutionPolicy Bypass -File .\BuildIdealLineFromReplay.ps1 `
-Replay "C:\path\lap.acreplay" -TrackFolder "C:\...\content\tracks\zhuhai"
多车手录像请指定 -DriverName。已导出 JSON 时可省略 -Replay,改用 -JsonPath。
-TrackFolder: 赛道根目录(其下应有 data\ideal_line.ai,除非用 -IdealLinePath 覆盖)。
-AcRpPath: 默认 = 脚本目录\acrp.exe
路径可为绝对路径(如 C:\...\x.acreplay)、相对当前目录、或 ~ 开头(用户主目录);首尾引号会自动去掉。
轨迹与赛道:ideal_line 只按录像里的世界坐标 x/y/z 重采样,与「目标赛道文件夹」无自动校验,
请自行保证录像对应该赛道。计时线模式:在起点 currentLap 等于 -Lap 的若干区间中,直接取弧长最长的一段作为一圈(出场短段自然被排除)。
-Lap 对应录像 JSON 里的 currentLap 整型(通常第 1 圈=0,第 2 圈=1 …),不限于 0/1;第 N 圈飞行一般传 N-1。
不确定时用 -ShowLapHints 列出每个计时区间起点的 currentLap。
#>
[CmdletBinding()]
param(
[string]$Replay,
[string]$TrackFolder,
[string]$AcRpPath,
[string]$DriverName,
[string]$JsonPath,
[string]$CsvPath,
[string]$IdealLinePath,
[int]$Lap = 0,
[bool]$UseTimingLine = $true,
[double]$MinSegmentMeters = 50.0,
[double]$DedupePlanarMin = 0.05,
[switch]$WhatIf,
[switch]$KeepTempJson,
[switch]$ShowLapHints
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Resolve-FsPath([string]$Path) {
if ($null -eq $Path -or [string]::IsNullOrWhiteSpace($Path)) { return $Path }
$p = $Path.Trim()
while ($p.Length -ge 2 -and $p.StartsWith('"') -and $p.EndsWith('"')) {
$p = $p.Substring(1, $p.Length - 2).Trim()
}
if ($p.StartsWith('~')) {
$rest = $p.Substring(1).TrimStart('\', '/')
$p = if ($rest) { Join-Path $HOME $rest } else { $HOME }
}
return [IO.Path]::GetFullPath($p)
}
function Show-Usage {
Write-Host @"
用法:
BuildIdealLineFromReplay.ps1 -Replay <录像.acreplay> -TrackFolder <赛道文件夹> [选项]
必填(二选一):
-Replay Assetto Corsa 录像路径
-TrackFolder 赛道根目录(内含 data\ideal_line.ai)
或已手动用 acrp 导出:
-JsonPath / -CsvPath 与 -IdealLinePath(或 -TrackFolder)
常用选项:
-DriverName 多车时指定车手名(传给 acrp --driver-name)
-AcRpPath 默认: 脚本所在目录\acrp.exe
-IdealLinePath 默认: <TrackFolder>\data\ideal_line.ai
-Lap 与录像 currentLap 一致(第 2 圈多为 1,第 3 圈多为 2,依此类推)
-ShowLapHints 只打印计时线分段与每段起点 currentLap,不写 ideal_line(仅需 JSON)
-MinSegmentMeters 计时线模式下,若「该 Lap 最长区间」弧长仍小于此值(m)则放弃切段(防数据损坏),默认 50
-UseTimingLine:`$false 关闭计时线截取
-WhatIf 只预览不写文件
示例:
powershell -ExecutionPolicy Bypass -File .\BuildIdealLineFromReplay.ps1 `
-Replay ".\my.acreplay" -TrackFolder "..\zhuhai"
"@
}
$scriptDir = $null
if ($PSCommandPath) {
$scriptDir = Split-Path -LiteralPath $PSCommandPath
} elseif ($PSScriptRoot) {
$scriptDir = $PSScriptRoot
} else {
try {
$exePath = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName
if ($exePath -and (Test-Path -LiteralPath $exePath)) {
$scriptDir = Split-Path -LiteralPath $exePath
}
} catch { }
if (-not $scriptDir) {
$a0 = [Environment]::GetCommandLineArgs()[0]
if ($a0 -and (Test-Path -LiteralPath $a0)) {
$scriptDir = Split-Path -LiteralPath $a0
} else {
$scriptDir = (Get-Location).Path
}
}
}
if (-not $scriptDir) { throw 'Cannot resolve script directory (expected exe or .ps1 path).' }
if (-not $AcRpPath -or [string]::IsNullOrWhiteSpace($AcRpPath)) {
$AcRpPath = Join-Path $scriptDir 'acrp.exe'
} else {
$AcRpPath = Resolve-FsPath $AcRpPath
}
$useJson = $false
$tempWork = $null
if ($Replay) {
if (-not $TrackFolder) { throw "使用 -Replay 时必须同时指定 -TrackFolder(赛道根目录)。" }
if (-not (Test-Path -LiteralPath $AcRpPath)) {
throw "找不到 acrp.exe: $AcRpPath (可设置 -AcRpPath,或把 acrp.exe 放在脚本同目录)"
}
$replayFull = Resolve-FsPath $Replay
if (-not (Test-Path -LiteralPath $replayFull)) { throw "找不到录像: $replayFull" }
$trackFull = Resolve-FsPath $TrackFolder
if (-not (Test-Path -LiteralPath $trackFull -PathType Container)) {
throw "赛道目录不存在: $trackFull"
}
if (-not $IdealLinePath) {
# "指定在哪里就在哪里":默认不再强制落到 data 子目录。
$IdealLinePath = Resolve-FsPath (Join-Path $trackFull 'ideal_line.ai')
} else {
# 相对路径按当前工作目录解析,不再强制挂到 TrackFolder。
$IdealLinePath = Resolve-FsPath $IdealLinePath
}
$tempWork = Join-Path ([IO.Path]::GetTempPath()) ('ac_ideal_' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $tempWork -Force | Out-Null
$outPrefix = Join-Path $tempWork 'acrp_out'
$argList = New-Object System.Collections.Generic.List[string]
[void]$argList.Add('-o')
[void]$argList.Add($outPrefix)
if ($DriverName) {
[void]$argList.Add('--driver-name')
[void]$argList.Add($DriverName)
}
[void]$argList.Add($replayFull)
Write-Host "运行 acrp: $AcRpPath"
$proc = Start-Process -FilePath $AcRpPath -ArgumentList $argList.ToArray() -Wait -PassThru -NoNewWindow
if ($proc.ExitCode -ne 0) {
if (-not $KeepTempJson) { Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue }
throw "acrp.exe 退出码 $($proc.ExitCode)"
}
$jsonFiles = @(Get-ChildItem -LiteralPath $tempWork -Filter *.json -File | Sort-Object LastWriteTime -Descending)
if ($jsonFiles.Count -eq 0) {
if (-not $KeepTempJson) { Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue }
throw "acrp 未在临时目录生成 JSON: $tempWork"
}
if ($jsonFiles.Count -gt 1 -and -not $DriverName) {
if (-not $KeepTempJson) { Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue }
throw "生成多个 JSON(多车?),请添加 -DriverName 指定车手。文件: $($jsonFiles.Name -join ', ')"
}
$JsonPath = $jsonFiles[0].FullName
Write-Host "已解析: $JsonPath"
$useJson = $true
} elseif ($JsonPath -or $CsvPath) {
if ($JsonPath -and $CsvPath) { throw "请只指定 -JsonPath 或 -CsvPath 其中之一。" }
if ($JsonPath) {
$JsonPath = Resolve-FsPath $JsonPath
if (-not (Test-Path -LiteralPath $JsonPath)) { throw "找不到 JSON: $JsonPath" }
$useJson = $true
} else {
$CsvPath = Resolve-FsPath $CsvPath
if (-not (Test-Path -LiteralPath $CsvPath)) { throw "找不到 CSV: $CsvPath" }
}
if (-not $IdealLinePath) {
if (-not $TrackFolder) {
if ($ShowLapHints -and $JsonPath) {
$IdealLinePath = Join-Path ([IO.Path]::GetTempPath()) '_BuildIdealLine_skip.ai'
} else {
throw "使用 -JsonPath/-CsvPath 且未指定 -IdealLinePath 时,需要 -TrackFolder。"
}
} else {
# "指定在哪里就在哪里":默认不再强制落到 data 子目录。
$IdealLinePath = Resolve-FsPath (Join-Path (Resolve-FsPath $TrackFolder) 'ideal_line.ai')
}
} else {
# 相对路径按当前工作目录解析,不再依赖 TrackFolder 作为基准。
$IdealLinePath = Resolve-FsPath $IdealLinePath
}
} else {
Show-Usage
throw "请提供 -Replay 与 -TrackFolder,或提供 -JsonPath / -CsvPath。"
}
if (-not $ShowLapHints) {
if (-not (Test-Path -LiteralPath $IdealLinePath) -and $TrackFolder) {
$trackBase = Resolve-FsPath $TrackFolder
$fallbackTemplate = Join-Path $trackBase 'data\ideal_line.ai'
if (Test-Path -LiteralPath $fallbackTemplate) {
$outDir = Split-Path -Parent $IdealLinePath
if ($outDir -and -not (Test-Path -LiteralPath $outDir)) {
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
}
Copy-Item -LiteralPath $fallbackTemplate -Destination $IdealLinePath -Force
Write-Host "未找到目标 ideal_line.ai,已从模板复制: $fallbackTemplate -> $IdealLinePath"
}
}
if (-not (Test-Path -LiteralPath $IdealLinePath)) {
if ($tempWork -and -not $KeepTempJson) { Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue }
throw "找不到 ideal_line.ai: $IdealLinePath"
}
}
# --- 解析轨迹并写 ideal_line ---
function Parse-CsvLine([string]$line) {
$cells = New-Object System.Collections.Generic.List[string]
$cur = New-Object System.Text.StringBuilder
$inQ = $false
for ($i = 0; $i -lt $line.Length; $i++) {
$c = $line[$i]
if ($c -eq '"') {
$inQ = -not $inQ
} elseif (($c -eq ',') -and -not $inQ) {
[void]$cells.Add($cur.ToString())
[void]$cur.Clear()
} else {
[void]$cur.Append($c)
}
}
[void]$cells.Add($cur.ToString())
return ,$cells.ToArray()
}
function Get-SfCrossingIndices($j) {
$cross = New-Object System.Collections.Generic.List[int]
$nF = $j.currentLapTime.Count
for ($i = 1; $i -lt $nF; $i++) {
$a = [int]$j.currentLapTime[$i - 1]
$b = [int]$j.currentLapTime[$i]
$lapInc = [int]$j.currentLap[$i] - [int]$j.currentLap[$i - 1]
if (($a - $b -gt 500) -or ($lapInc -gt 0)) {
$prev = if ($cross.Count -gt 0) { $cross[$cross.Count - 1] } else { -9999 }
if (($i - $prev) -gt 2) { [void]$cross.Add($i) }
}
}
return $cross
}
function Measure-ArcJson($j, [int]$i0, [int]$i1Exclusive) {
$s = 0.0
$px = $null; $py = $null; $pz = $null
for ($i = $i0; $i -lt $i1Exclusive; $i++) {
$x = [double]$j.x[$i]; $y = [double]$j.y[$i]; $z = [double]$j.z[$i]
if ($null -ne $px) {
$dx = $x - $px; $dy = $y - $py; $dz = $z - $pz
$s += [Math]::Sqrt($dx * $dx + $dy * $dy + $dz * $dz)
}
$px = $x; $py = $y; $pz = $z
}
return $s
}
function Select-TimingSegment($j, [int]$Lap, [double]$MinSegmentMeters) {
$cross = Get-SfCrossingIndices $j
if ($cross.Count -lt 2) {
return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_crossings' }
}
$bestLen = -1.0
$bestA = -1
$bestB = -1
for ($k = 0; $k -lt $cross.Count - 1; $k++) {
$a = $cross[$k]
$b = $cross[$k + 1]
if ([int]$j.currentLap[$a] -ne $Lap) { continue }
$len = Measure-ArcJson $j $a $b
if ($len -gt $bestLen) {
$bestLen = $len
$bestA = $a
$bestB = $b
}
}
if ($bestA -lt 0) {
return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_match' }
}
if ($bestLen -lt $MinSegmentMeters) {
return @{ Start = -1; End = -1; Length = $bestLen; Mode = 'segment_too_short' }
}
return @{ Start = $bestA; End = $bestB; Length = $bestLen; Mode = 'longest_for_lap' }
}
if ($ShowLapHints) {
if (-not $useJson) { throw "-ShowLapHints 仅支持 JSON(-Replay 或 -JsonPath),不支持 CSV。" }
$jh = Get-Content -LiteralPath $JsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
if (-not $jh.currentLap -or -not $jh.currentLapTime) {
throw "JSON 缺少 currentLap 或 currentLapTime,无法分析计时线。"
}
$nH = $jh.currentLap.Count
if ($jh.currentLapTime.Count -ne $nH) { throw "currentLap 与 currentLapTime 长度不一致。" }
$xc = Get-SfCrossingIndices $jh
Write-Host "=== ShowLapHints: $JsonPath ==="
Write-Host "帧数=$nH 检测到计时线交叉索引数=$($xc.Count)"
Write-Host "(过线后该帧的 currentLap 即「已开始计时的那一圈」编号,通常从 0 递增)"
for ($ki = 0; $ki -lt $xc.Count; $ki++) {
$ix = $xc[$ki]
Write-Host (" 交叉#{0}: frame={1} currentLap={2} currentLapTime={3} ms" -f $ki, $ix, [int]$jh.currentLap[$ix], [int]$jh.currentLapTime[$ix])
}
for ($ki = 0; $ki -lt $xc.Count - 1; $ki++) {
$a = $xc[$ki]
$b = $xc[$ki + 1]
$alen = Measure-ArcJson $jh $a $b
$lapAtStart = [int]$jh.currentLap[$a]
Write-Host (" 区间 frame {0}..{1}: 起点 currentLap={2} 弧长约 {3:F1} m (同 Lap 多段时脚本取最长段)" -f $a, $b, $lapAtStart, $alen)
}
Write-Host "当前默认 -Lap=$Lap;若飞行圈是「第 3 圈」且 AC 从 0 编号,多为 -Lap 2。"
if ($tempWork -and (Test-Path -LiteralPath $tempWork) -and -not $KeepTempJson) {
Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue
}
exit 0
}
try {
$pts = New-Object System.Collections.Generic.List[object]
$hasPedals = $false
$timingMode = 'n/a'
if ($useJson) {
$j = Get-Content -LiteralPath $JsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
if (-not $j.x -or -not $j.y -or -not $j.z) { throw "JSON 缺少 x/y/z 数组(请确认为 acrp 导出)。" }
if (-not $j.currentLap) { throw "JSON 缺少 currentLap 数组。" }
$nF = $j.x.Count
if ($j.y.Count -ne $nF -or $j.z.Count -ne $nF -or $j.currentLap.Count -ne $nF) {
throw "JSON 中 x/y/z/currentLap 长度不一致。"
}
if ($j.gas -and $j.brake -and ($j.gas.Count -eq $nF) -and ($j.brake.Count -eq $nF)) {
$hasPedals = $true
}
$iStart = 0
$iEnd = $nF
$timingUsed = $false
if (-not $UseTimingLine) {
$timingMode = 'timing_disabled'
} elseif ($j.currentLapTime -and ($j.currentLapTime.Count -eq $nF)) {
$seg = Select-TimingSegment $j $Lap $MinSegmentMeters
if ($seg.Start -ge 0) {
$iStart = $seg.Start
$iEnd = $seg.End
$timingUsed = $true
$timingMode = $seg.Mode
} elseif ($seg.Mode -eq 'segment_too_short') {
$timingMode = 'segment_too_short'
Write-Warning ("计时线切段: 该 Lap 下最长区间仅 {0:F1} m,低于 -MinSegmentMeters ({1} m),已放弃切段。可调小 -MinSegmentMeters 或检查录像。" -f $seg.Length, $MinSegmentMeters)
} elseif ($seg.Mode -eq 'no_crossings') {
$timingMode = 'no_crossings'
Write-Warning "录像中未检测到计时线交叉(currentLapTime/圈数变化),已按整段 -Lap 过滤取点。"
} else {
$timingMode = 'lap_filter_pending'
}
} else {
$timingMode = 'no_currentLapTime'
Write-Warning "JSON 无 currentLapTime 或与帧数不一致,已跳过计时线切段,仅按 -Lap 过滤。"
}
for ($i = $iStart; $i -lt $iEnd; $i++) {
if (-not $timingUsed) {
if ([int]$j.currentLap[$i] -ne $Lap) { continue }
}
$g = if ($hasPedals) { [int]$j.gas[$i] } else { 0 }
$bk = if ($hasPedals) { [int]$j.brake[$i] } else { 0 }
if ($g -lt 0) { $g = 0 } elseif ($g -gt 255) { $g = 255 }
if ($bk -lt 0) { $bk = 0 } elseif ($bk -gt 255) { $bk = 255 }
[void]$pts.Add([pscustomobject]@{
X = [float][double]$j.x[$i]
Y = [float][double]$j.y[$i]
Z = [float][double]$j.z[$i]
G = $g
Bk = $bk
})
}
if ($UseTimingLine -and -not $timingUsed) {
if ($timingMode -eq 'lap_filter_pending') { $timingMode = 'no_segment_for_lap' }
Write-Warning "未找到起点 currentLap=$Lap 的计时区间(或交叉点不足),已回退为整段 Lap 过滤。可运行 -ShowLapHints 查看每段起点应对的 -Lap,或 -UseTimingLine:`$false。"
}
} else {
$timingMode = 'csv'
$hdr = Get-Content -LiteralPath $CsvPath -TotalCount 1 -Encoding UTF8
$names = Parse-CsvLine $hdr
$ixX = [array]::IndexOf($names, 'position.x')
$ixY = [array]::IndexOf($names, 'position.y')
$ixZ = [array]::IndexOf($names, 'position.z')
$ixLap = [array]::IndexOf($names, 'currentLap')
$ixGas = [array]::IndexOf($names, 'gas')
$ixBrake = [array]::IndexOf($names, 'brake')
if ($ixX -lt 0 -or $ixY -lt 0 -or $ixZ -lt 0) { throw "CSV 缺少 position.x/y/z 列,请确认由 acreplay-parser 导出。" }
if ($ixLap -lt 0) { throw "CSV 缺少 currentLap 列。" }
if ($ixGas -ge 0 -and $ixBrake -ge 0) { $hasPedals = $true }
$reader = [IO.StreamReader]::new($CsvPath, [Text.Encoding]::UTF8, $true)
try {
[void]$reader.ReadLine()
while ($null -ne ($line = $reader.ReadLine())) {
if ([string]::IsNullOrWhiteSpace($line)) { continue }
$c = Parse-CsvLine $line
if ($c.Count -le [Math]::Max($ixX, [Math]::Max($ixY, [Math]::Max($ixZ, $ixLap)))) { continue }
$lapVal = 0
[void][int]::TryParse($c[$ixLap].Trim(), [ref]$lapVal)
if ($lapVal -ne $Lap) { continue }
$x = [double]::Parse($c[$ixX].Trim(), [Globalization.CultureInfo]::InvariantCulture)
$y = [double]::Parse($c[$ixY].Trim(), [Globalization.CultureInfo]::InvariantCulture)
$z = [double]::Parse($c[$ixZ].Trim(), [Globalization.CultureInfo]::InvariantCulture)
$g = 0; $bk = 0
if ($hasPedals) {
[void][int]::TryParse($c[$ixGas].Trim(), [ref]$g)
[void][int]::TryParse($c[$ixBrake].Trim(), [ref]$bk)
}
if ($g -lt 0) { $g = 0 } elseif ($g -gt 255) { $g = 255 }
if ($bk -lt 0) { $bk = 0 } elseif ($bk -gt 255) { $bk = 255 }
[void]$pts.Add([pscustomobject]@{ X = [float]$x; Y = [float]$y; Z = [float]$z; G = $g; Bk = $bk })
}
} finally { $reader.Close() }
}
if ($DedupePlanarMin -gt 0 -and $pts.Count -gt 2) {
$dd = New-Object System.Collections.Generic.List[object]
[void]$dd.Add($pts[0])
for ($di = 1; $di -lt $pts.Count; $di++) {
$a = $dd[$dd.Count - 1]
$b = $pts[$di]
$dh = [Math]::Sqrt([double](($b.X - $a.X) * ($b.X - $a.X) + ($b.Z - $a.Z) * ($b.Z - $a.Z)))
if ($dh -ge $DedupePlanarMin) { [void]$dd.Add($b) }
}
$pts = $dd
}
if ($pts.Count -lt 200) { throw "该圈采样点过少 ($($pts.Count)),请检查 -DriverName / -Lap / -UseTimingLine。" }
$clean = New-Object System.Collections.Generic.List[object]
[void]$clean.Add($pts[0])
for ($i = 1; $i -lt $pts.Count; $i++) {
$a = $clean[$clean.Count - 1]
$b = $pts[$i]
$d = [Math]::Sqrt([double](($b.X - $a.X) * ($b.X - $a.X) + ($b.Z - $a.Z) * ($b.Z - $a.Z)))
if ($d -lt 80.0) { [void]$clean.Add($b) }
}
$pts = $clean
if ($pts.Count -lt 200) { throw "过滤跳变后点数不足 ($($pts.Count))。" }
$segLen = New-Object double[] ($pts.Count)
$cum = New-Object double[] ($pts.Count)
$cum[0] = 0.0
for ($i = 1; $i -lt $pts.Count; $i++) {
$dx = [double]$pts[$i].X - [double]$pts[$i - 1].X
$dy = [double]$pts[$i].Y - [double]$pts[$i - 1].Y
$dz = [double]$pts[$i].Z - [double]$pts[$i - 1].Z
$segLen[$i] = [Math]::Sqrt($dx * $dx + $dy * $dy + $dz * $dz)
$cum[$i] = $cum[$i - 1] + $segLen[$i]
}
$replayTotal = $cum[$pts.Count - 1]
if ($replayTotal -lt 100.0) { throw "该圈弧长异常短 ($replayTotal m),请换 -Lap 或检查录像。" }
function Get-PointAtDistance([object[]]$p, [double[]]$c, [double]$dist) {
if ($dist -le 0) { return $p[0] }
$max = $c[$p.Length - 1]
if ($dist -ge $max) { return $p[$p.Length - 1] }
$lo = 0
$hi = $p.Length - 1
while ($hi - $lo -gt 1) {
$mid = [int](($lo + $hi) / 2)
if ($c[$mid] -le $dist) { $lo = $mid } else { $hi = $mid }
}
$i = $lo
$t = if (($c[$i + 1] - $c[$i]) -gt 1e-6) { ($dist - $c[$i]) / ($c[$i + 1] - $c[$i]) } else { 0.0 }
$ax = [double]$p[$i].X; $ay = [double]$p[$i].Y; $az = [double]$p[$i].Z
$bx = [double]$p[$i + 1].X; $by = [double]$p[$i + 1].Y; $bz = [double]$p[$i + 1].Z
return [pscustomobject]@{
X = [float]($ax + $t * ($bx - $ax))
Y = [float]($ay + $t * ($by - $ay))
Z = [float]($az + $t * ($bz - $az))
}
}
function Get-Pedal01AtDistance([object[]]$p, [double[]]$c, [double]$dist, [bool]$pickGas) {
if ($dist -le 0) {
$v = if ($pickGas) { [double]$p[0].G } else { [double]$p[0].Bk }
return [float]($v / 255.0)
}
$max = $c[$p.Length - 1]
if ($dist -ge $max) {
$v = if ($pickGas) { [double]$p[$p.Length - 1].G } else { [double]$p[$p.Length - 1].Bk }
return [float]($v / 255.0)
}
$lo = 0
$hi = $p.Length - 1
while ($hi - $lo -gt 1) {
$mid = [int](($lo + $hi) / 2)
if ($c[$mid] -le $dist) { $lo = $mid } else { $hi = $mid }
}
$i = $lo
$tt = if (($c[$i + 1] - $c[$i]) -gt 1e-6) { ($dist - $c[$i]) / ($c[$i + 1] - $c[$i]) } else { 0.0 }
$va = if ($pickGas) { [double]$p[$i].G } else { [double]$p[$i].Bk }
$vb = if ($pickGas) { [double]$p[$i + 1].G } else { [double]$p[$i + 1].Bk }
return [float](($va + $tt * ($vb - $va)) / 255.0)
}
$bytes = [IO.File]::ReadAllBytes($IdealLinePath)
$ver = [BitConverter]::ToInt32($bytes, 0)
if ($ver -ne 7) { throw "ideal_line 版本为 $ver,本脚本仅按版本 7 处理。" }
$n = [BitConverter]::ToInt32($bytes, 4)
if ($n -lt 10) { throw "点数异常: $n" }
$oldLens = New-Object float[] $n
for ($i = 0; $i -lt $n; $i++) {
$o = 16 + $i * 20 + 12
$oldLens[$i] = [BitConverter]::ToSingle($bytes, $o)
}
$oldMax = [double]$oldLens[$n - 1]
if ($oldMax -lt 1.0) { throw "原线累计长度异常。" }
if ($WhatIf) {
$pedalNote = if ($hasPedals) { "写入 Gas/Brake" } else { "无油门刹车数据,不改颜色" }
Write-Host "WhatIf: $IdealLinePath | $n 点 | Lap=$Lap | timing=$timingMode | 采样 $($pts.Count) | 弧长 $replayTotal m | 原线长 $oldMax m | $pedalNote"
exit 0
}
$bak = $IdealLinePath + ".bak_" + (Get-Date -Format "yyyyMMdd_HHmmss")
Copy-Item -LiteralPath $IdealLinePath -Destination $bak -Force
Write-Host "已备份: $bak"
$newLens = New-Object float[] $n
for ($i = 0; $i -lt $n; $i++) {
$frac = [double]$oldLens[$i] / $oldMax
$d = $frac * $replayTotal
$newLens[$i] = [float]$d
$q = Get-PointAtDistance $pts $cum $d
$o = 16 + $i * 20
[Array]::Copy([BitConverter]::GetBytes($q.X), 0, $bytes, $o, 4)
[Array]::Copy([BitConverter]::GetBytes($q.Y), 0, $bytes, $o + 4, 4)
[Array]::Copy([BitConverter]::GetBytes($q.Z), 0, $bytes, $o + 8, 4)
[Array]::Copy([BitConverter]::GetBytes($newLens[$i]), 0, $bytes, $o + 12, 4)
}
$PointExtraStride = 72
$nEx = [BitConverter]::ToInt32($bytes, 16 + 20 * $n)
$extraStart = 16 + 20 * $n + 4
if ($hasPedals -and ($nEx -eq $n) -and (($bytes.Length - $extraStart) -ge ($n * $PointExtraStride))) {
$ptArr = $pts.ToArray()
for ($i = 0; $i -lt $n; $i++) {
$d = [double]$newLens[$i]
$gas01 = Get-Pedal01AtDistance $ptArr $cum $d $true
$brake01 = Get-Pedal01AtDistance $ptArr $cum $d $false
$eo = $extraStart + $i * $PointExtraStride
[Array]::Copy([BitConverter]::GetBytes($gas01), 0, $bytes, $eo + 4, 4)
[Array]::Copy([BitConverter]::GetBytes($brake01), 0, $bytes, $eo + 8, 4)
}
Write-Host "已更新 PointsExtra 的 Gas/Brake。"
} elseif ($hasPedals) {
Write-Warning "PointsExtra 与点数不匹配,已跳过颜色写入。"
}
[IO.File]::WriteAllBytes($IdealLinePath, $bytes)
Write-Host "完成: $IdealLinePath"
}
finally {
if ($tempWork -and (Test-Path -LiteralPath $tempWork) -and -not $KeepTempJson) {
Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue
}
}
到这里ACReplay2AILine基本完成,但是还是存在一些问题,他只能修改ailine,并不能凭空生成,所以需要先把原本赛道的ailine拿到,才能改,然后其中未修改的值,可能有一部分是不对的或者不匹配的。

实现了上面的回放转行车线,我就思考是否可以把轨迹内容输出,并且和地图匹配到一起,这样就能单独看到自己在每个弯的刹车点、速度和开油点和对应的速度了,这样做分析完就可以模板化操作了。
实际想得还是太简单了,游戏内mod的数据给的太少了,赛道边界信息啥的都没给,而且现实中的T1-T14是人为定义的,实际上游戏内根本没有这个弯道定义,游戏内只是对赛道进行了3个segment的分段,要把现实和游戏的轨迹匹配上就有点困难了。
然后再说一个,游戏内是没有经纬度信息的,使用的xyz坐标系统,而现实T1-T14都不是,他们的经纬度信息缺少,这让匹配就更难了。
开始的几次尝试基本都失败了,回放轨迹和赛道匹配不上,比例大小都不正确,其实是缺少了赛道的宽度具体赛道边界曲线信息。
基于此放弃了赛道匹配,直接画轨迹,这个部分没问题,但是弯道匹配还是有误,T1-T14怎么都对不上,反复调整代码也不行,这个流程估计耗时两三个小时,最后放弃了。
直接使用轨迹和刹车点、开油点的逻辑,把每次操作的位置和此时时速都标识出来

然后就得到了这样一张图,我称为 Action 图(操作图),可以清晰看到每个位置大概以多少速度刹车、大概在什么位置开油。
这里比较麻烦的点是油门和刹车的判断,刹多少算刹车,持续多久算一次?同理油门,一开始沟通时没有给到这部分信息,让AI自主判断,但是结果是比较差的,出现各种奇怪情况,比如油门默认高时,不算油门上升,刹车默认高也不算刹车,上升比例要求的太多了,导致细节反馈不出来
反馈给AI以后再次生成,依然错误,甚至越改越偏。但是由于基础代码被改了,没有 commit,导致最后一错到底,无法纠正回来,只好放弃掉这部分 AI,重新梳理逻辑,再重新对话写代码。
反复调试,增加约束调节以后,总算得到了一个正确的图,并且增加用例测试,得到的结果都还行
#Requires -Version 5.1
<#
.SYNOPSIS
Generic replay lap analyzer: detect brake/throttle onsets and render trajectory markers.
If replay/corners data files are missing, they are auto-generated from the provided replay.
#>
param(
[string]$JsonPath = '',
[string]$TrackFolder = '',
[string]$CornersJson = '',
[string]$ReplayPath = '',
[string]$AcRpPath = '',
[string]$DriverName = '',
[string]$OutputPath = '',
[int]$Lap = 0,
[bool]$AutoFastestLap = $true,
[double]$MinSegmentMeters = 50.0,
[int]$ImageWidth = 1800,
[int]$ImageHeight = 1350,
[double]$InnerMarginPercent = 5.0,
[float]$FontSizeTitle = 20.0,
[float]$FontSizeMarker = 14.0,
[double]$BrakeMinSeconds = 0.3,
[double]$ThrottleMinSeconds = 0.5,
[int]$BrakePedalThreshold = 25,
[int]$GasPedalThreshold = 180,
[double]$GasReapplyMinSeconds = 0.06,
[int]$GasReapplyThreshold = 60,
[int]$GasReapplyDelta = 20,
[int]$GasReapplyBrakeMax = 20,
[bool]$AllowOverlapThrottleBetweenBrakes = $true,
[double]$SectorExpandMeters = 20.0,
[switch]$DebugEventTrace,
[string]$DebugOutputPath = '',
[switch]$HideCornerCenterLabel,
[switch]$NoVerticalFlip,
[switch]$FlipWorldZ
)
$ErrorActionPreference = 'Stop'
# PS2EXE 嵌入执行时 $PSScriptRoot / $PSCommandPath 可能为空;
# 优先使用进程主模块路径,确保在“当前目录不等于exe目录”时也能稳定定位工具目录。
$toolDir = $null
if ($PSCommandPath) {
$toolDir = Split-Path -LiteralPath $PSCommandPath
} elseif ($PSScriptRoot) {
$toolDir = $PSScriptRoot
} else {
try {
$exePath = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName
if ($exePath -and (Test-Path -LiteralPath $exePath)) {
$toolDir = Split-Path -LiteralPath $exePath
}
} catch { }
if (-not $toolDir) {
$a0 = [Environment]::GetCommandLineArgs()[0]
if ($a0 -and (Test-Path -LiteralPath $a0)) {
$toolDir = Split-Path -LiteralPath $a0
} else {
$toolDir = (Get-Location).Path
}
}
}
if (-not $toolDir) { throw 'Cannot resolve tool directory (expected exe or .ps1 path).' }
if (-not $TrackFolder) { $TrackFolder = Join-Path (Split-Path $toolDir -Parent) 'zhuhai' }
Add-Type -AssemblyName System.Drawing
$capPath = Join-Path $toolDir 'draw_trajectory_captions.json'
$cap = [pscustomobject]@{ sf = 'S/F'; titlePrefix = 'Replay Lap Analysis'; legend = 'Blue=track Orange=S/F Red=brake Green=throttle' }
if (Test-Path -LiteralPath $capPath) {
$cj = Get-Content -LiteralPath $capPath -Raw -Encoding UTF8 | ConvertFrom-Json
if ($cj.sf) { $cap.sf = [string]$cj.sf }
if ($cj.titlePrefix) { $cap.titlePrefix = [string]$cj.titlePrefix }
if ($cj.legend) { $cap.legend = [string]$cj.legend }
}
function Resolve-FsPath([string]$Path) {
if ([string]::IsNullOrWhiteSpace($Path)) { return $Path }
$p = $Path.Trim()
while ($p.Length -ge 2 -and $p.StartsWith('"') -and $p.EndsWith('"')) {
$p = $p.Substring(1, $p.Length - 2).Trim()
}
if ($p.StartsWith('~')) {
$rest = $p.Substring(1).TrimStart('\', '/')
$p = if ($rest) { Join-Path $HOME $rest } else { $HOME }
}
return [IO.Path]::GetFullPath($p)
}
function Get-FileStem([string]$pathOrName, [string]$fallback) {
if ([string]::IsNullOrWhiteSpace($pathOrName)) { return $fallback }
$nm = [IO.Path]::GetFileNameWithoutExtension($pathOrName)
if ([string]::IsNullOrWhiteSpace($nm)) { return $fallback }
return $nm
}
function Clamp-Int([int]$v, [int]$lo, [int]$hi) {
if ($v -lt $lo) { return $lo }
if ($v -gt $hi) { return $hi }
return $v
}
function Get-SfCrossingIndices($j) {
$cross = New-Object System.Collections.Generic.List[int]
if (-not $j.currentLapTime -or ($j.currentLapTime.Count -ne $j.x.Count)) { return $cross }
for ($i = 1; $i -lt $j.currentLapTime.Count; $i++) {
$a = [int]$j.currentLapTime[$i - 1]; $b = [int]$j.currentLapTime[$i]
$lapInc = [int]$j.currentLap[$i] - [int]$j.currentLap[$i - 1]
if (($a - $b -gt 500) -or ($lapInc -gt 0)) {
$prev = if ($cross.Count -gt 0) { $cross[$cross.Count - 1] } else { -9999 }
if (($i - $prev) -gt 2) { [void]$cross.Add($i) }
}
}
return $cross
}
function Measure-ArcJson($j, [int]$i0, [int]$i1Exclusive) {
$s = 0.0; $px = $null; $py = $null; $pz = $null
for ($i = $i0; $i -lt $i1Exclusive; $i++) {
$x = [double]$j.x[$i]; $y = [double]$j.y[$i]; $z = [double]$j.z[$i]
if ($null -ne $px) {
$dx = $x - $px; $dy = $y - $py; $dz = $z - $pz
$s += [Math]::Sqrt($dx * $dx + $dy * $dy + $dz * $dz)
}
$px = $x; $py = $y; $pz = $z
}
return $s
}
function Select-TimingSegment($j, [int]$LapVal, [double]$MinSeg) {
$cross = Get-SfCrossingIndices $j
if ($cross.Count -lt 2) { return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_crossings' } }
$bestLen = -1.0; $bestA = -1; $bestB = -1
for ($k = 0; $k -lt $cross.Count - 1; $k++) {
$a = $cross[$k]; $b = $cross[$k + 1]
if ([int]$j.currentLap[$a] -ne $LapVal) { continue }
$len = Measure-ArcJson $j $a $b
if ($len -gt $bestLen) { $bestLen = $len; $bestA = $a; $bestB = $b }
}
if ($bestA -lt 0) { return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_match' } }
if ($bestLen -lt $MinSeg) { return @{ Start = -1; End = -1; Length = $bestLen; Mode = 'segment_too_short' } }
return @{ Start = $bestA; End = $bestB; Length = $bestLen; Mode = 'ok' }
}
function Select-FastestTimingSegment($j, [double]$MinSeg) {
$cross = Get-SfCrossingIndices $j
if ($cross.Count -lt 2) { return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_crossings'; Lap = -1; TimeMs = -1 } }
$best = $null
for ($k = 0; $k -lt $cross.Count - 1; $k++) {
$a = $cross[$k]; $b = $cross[$k + 1]
$lapVal = [int]$j.currentLap[$a]
$len = Measure-ArcJson $j $a $b
if ($len -lt $MinSeg) { continue }
$timeMs = -1
if ($j.PSObject.Properties.Name -contains 'currentLapTime') {
$ti = [int]$j.currentLapTime[[Math]::Max($a, $b - 1)]
if ($ti -gt 0) { $timeMs = $ti }
}
if ($timeMs -le 0) {
$dt = Get-FrameDtSeconds $j
$timeMs = [int][Math]::Round(($b - $a) * $dt * 1000.0)
}
$cand = @{
Start = $a
End = $b
Length = $len
Mode = 'ok'
Lap = $lapVal
TimeMs = $timeMs
}
if ($null -eq $best -or $cand.TimeMs -lt $best.TimeMs) {
$best = $cand
}
}
if ($null -eq $best) { return @{ Start = -1; End = -1; Length = 0.0; Mode = 'no_valid_segment'; Lap = -1; TimeMs = -1 } }
return $best
}
function Get-SpeedKmh($j, [int]$fi) {
$vx = [double]$j.velocityX[$fi]; $vy = [double]$j.velocityY[$fi]; $vz = [double]$j.velocityZ[$fi]
return [Math]::Sqrt($vx * $vx + $vy * $vy + $vz * $vz) * 3.6
}
function Get-FrameDtSeconds($j) {
if ($j.PSObject.Properties.Name -contains 'recordingInterval') {
$ri = [double]$j.recordingInterval
if ($ri -gt 0 -and $ri -le 100.0) { return $ri / 1000.0 }
if ($ri -gt 100.0) { return 1.0 / $ri }
}
return (1.0 / 60.0)
}
function Get-BoundariesFromSegmentEnds([double[]]$ends) {
if ($ends.Count -ne 14) { throw 'segmentEndFraction must have 14 elements, last=1.0' }
if ([Math]::Abs($ends[13] - 1.0) -gt 0.001) { throw 'segmentEndFraction[13] must be 1.0' }
$b = New-Object double[] 15
$b[0] = 0.0
for ($i = 0; $i -lt 14; $i++) { $b[$i + 1] = $ends[$i] }
return $b
}
function Find-KRangeForArc([double[]]$sArr, [double]$lapLen, [double]$f0, [double]$f1, [int]$m) {
$s0 = [Math]::Max(0.0, $f0 * $lapLen)
$s1 = [Math]::Min($lapLen, $f1 * $lapLen)
$k0 = 0
for ($k = 0; $k -lt $m; $k++) {
if ($sArr[$k] -ge $s0) { $k0 = $k; break }
}
$k1 = $m - 1
for ($k = $m - 1; $k -ge 0; $k--) {
if ($sArr[$k] -le $s1) { $k1 = $k; break }
}
if ($k1 -lt $k0) { $k1 = $k0 }
return $k0, $k1
}
function Find-KClosestToS([double[]]$sArr, [double]$targetS, [int]$k0, [int]$k1) {
$best = $k0
$bd = [Math]::Abs($sArr[$k0] - $targetS)
for ($k = $k0; $k -le $k1; $k++) {
$d = [Math]::Abs($sArr[$k] - $targetS)
if ($d -lt $bd) { $bd = $d; $best = $k }
}
return $best
}
function Find-FirstSustainedAbove([int[]]$vals, [int]$k0, [int]$k1, [int]$thr, [int]$minFrames) {
if ($minFrames -lt 1) { $minFrames = 1 }
for ($start = $k0; $start -le $k1; $start++) {
if ($vals[$start] -lt $thr) { continue }
$ok = $true
for ($i = 0; $i -lt $minFrames; $i++) {
$kk = $start + $i
if ($kk -gt $k1) { $ok = $false; break }
if ($vals[$kk] -lt $thr) { $ok = $false; break }
}
if ($ok) { return $start }
}
return -1
}
function Find-FirstRisingSustainedAbove([int[]]$vals, [int]$k0, [int]$k1, [int]$thr, [int]$minFrames) {
if ($minFrames -lt 1) { $minFrames = 1 }
$st0 = [Math]::Max(1, $k0)
for ($start = $st0; $start -le $k1; $start++) {
# Rising edge: previous frame below threshold, current frame reaches threshold.
if ($vals[$start - 1] -ge $thr) { continue }
if ($vals[$start] -lt $thr) { continue }
$ok = $true
for ($i = 0; $i -lt $minFrames; $i++) {
$kk = $start + $i
if ($kk -gt $k1) { $ok = $false; break }
if ($vals[$kk] -lt $thr) { $ok = $false; break }
}
if ($ok) { return $start }
}
return -1
}
function Find-FirstGasReapply([int[]]$gasVals, [int[]]$brkVals, [int]$k0, [int]$k1, [int]$minGas, [int]$minDelta, [int]$brakeMax, [int]$minFrames) {
if ($minFrames -lt 1) { $minFrames = 1 }
$st0 = [Math]::Max(1, $k0)
for ($start = $st0; $start -le $k1; $start++) {
if ($brkVals[$start] -gt $brakeMax) { continue }
if ($gasVals[$start] -lt $minGas) { continue }
if (($gasVals[$start] - $gasVals[$start - 1]) -lt $minDelta) { continue }
$ok = $true
for ($i = 0; $i -lt $minFrames; $i++) {
$kk = $start + $i
if ($kk -gt $k1) { $ok = $false; break }
if ($gasVals[$kk] -lt $minGas) { $ok = $false; break }
if ($brkVals[$kk] -gt $brakeMax) { $ok = $false; break }
}
if ($ok) { return $start }
}
return -1
}
function Find-FirstGasReapplyOverlap([int[]]$gasVals, [int]$k0, [int]$k1, [int]$minGas, [int]$minDelta, [int]$minFrames) {
if ($minFrames -lt 1) { $minFrames = 1 }
$st0 = [Math]::Max(1, $k0)
for ($start = $st0; $start -le $k1; $start++) {
if ($gasVals[$start] -lt $minGas) { continue }
if (($gasVals[$start] - $gasVals[$start - 1]) -lt $minDelta) { continue }
$ok = $true
for ($i = 0; $i -lt $minFrames; $i++) {
$kk = $start + $i
if ($kk -gt $k1) { $ok = $false; break }
if ($gasVals[$kk] -lt $minGas) { $ok = $false; break }
}
if ($ok) { return $start }
}
return -1
}
function Get-ContiguousRunEnd([int[]]$vals, [int]$start, [int]$k1, [int]$thr) {
$e = $start
for ($k = $start; $k -le $k1; $k++) {
if ($vals[$k] -ge $thr) { $e = $k } else { break }
}
return $e
}
function Get-LongestRunAbove([int[]]$vals, [int]$k0, [int]$k1, [int]$thr) {
if ($k1 -lt $k0) { return 0 }
$best = 0
$cur = 0
for ($k = $k0; $k -le $k1; $k++) {
if ($vals[$k] -ge $thr) {
$cur++
if ($cur -gt $best) { $best = $cur }
} else {
$cur = 0
}
}
return $best
}
function New-CjkDrawingFont([float]$emSize, [System.Drawing.FontStyle]$style) {
$unit = [System.Drawing.GraphicsUnit]::Point
foreach ($n in @('Microsoft YaHei UI', 'Microsoft YaHei', 'SimHei', 'Segoe UI')) {
try {
$fam = New-Object System.Drawing.FontFamily $n
if ($fam.IsStyleAvailable($style)) { return [System.Drawing.Font]::new($fam, $emSize, $style, $unit) }
} catch { }
}
return [System.Drawing.Font]::new('Segoe UI', $emSize, $style, $unit)
}
function Ensure-ReplayJson([string]$TargetJsonPath, [string]$ReplayPathIn, [string]$AcRpPathIn, [string]$DriverNameIn) {
if (Test-Path -LiteralPath $TargetJsonPath) { return }
$acrp = if ([string]::IsNullOrWhiteSpace($AcRpPathIn)) { Join-Path $toolDir 'acrp.exe' } else { Resolve-FsPath $AcRpPathIn }
if (-not (Test-Path -LiteralPath $acrp)) {
throw "Replay JSON missing and acrp.exe not found: $acrp"
}
$replay = $ReplayPathIn
if ([string]::IsNullOrWhiteSpace($replay)) {
$rp = @(Get-ChildItem -LiteralPath $toolDir -Filter *.acreplay -File | Sort-Object LastWriteTime -Descending)
if ($rp.Count -lt 1) { throw "Replay JSON missing and no .acreplay found in $toolDir" }
$replay = $rp[0].FullName
} else {
$replay = Resolve-FsPath $replay
}
if (-not (Test-Path -LiteralPath $replay)) { throw "Replay file not found: $replay" }
$outDir = Split-Path -Parent $TargetJsonPath
if ($outDir -and -not (Test-Path -LiteralPath $outDir)) {
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
}
$tempWork = Join-Path ([IO.Path]::GetTempPath()) ('ac_lap_' + [guid]::NewGuid().ToString('N'))
New-Item -ItemType Directory -Path $tempWork -Force | Out-Null
$outPrefix = Join-Path $tempWork 'acrp_out'
try {
$argList = New-Object System.Collections.Generic.List[string]
[void]$argList.Add('-o')
[void]$argList.Add($outPrefix)
if (-not [string]::IsNullOrWhiteSpace($DriverNameIn)) {
[void]$argList.Add('--driver-name')
[void]$argList.Add($DriverNameIn)
}
[void]$argList.Add($replay)
Write-Host "Generating replay JSON via acrp: $replay"
$proc = Start-Process -FilePath $acrp -ArgumentList $argList.ToArray() -Wait -PassThru -NoNewWindow
if ($proc.ExitCode -ne 0) { throw "acrp.exe exit code $($proc.ExitCode)" }
$jsonFiles = @(Get-ChildItem -LiteralPath $tempWork -Filter *.json -File | Sort-Object LastWriteTime -Descending)
if ($jsonFiles.Count -lt 1) { throw "acrp generated no JSON in: $tempWork" }
if ($jsonFiles.Count -gt 1 -and [string]::IsNullOrWhiteSpace($DriverNameIn)) {
throw "acrp generated multiple JSON files; pass -DriverName to pick one."
}
Copy-Item -LiteralPath $jsonFiles[0].FullName -Destination $TargetJsonPath -Force
Write-Host "Generated: $TargetJsonPath"
} finally {
Remove-Item -LiteralPath $tempWork -Recurse -Force -ErrorAction SilentlyContinue
}
}
function Build-CornerJsonFromReplay($j, [string]$TargetPath, [int]$LapVal, [double]$MinSegMeters, [double]$DedupMinGapMeters, [bool]$AutoFastestLapVal) {
$seg = if ($AutoFastestLapVal) { Select-FastestTimingSegment $j $MinSegMeters } else { Select-TimingSegment $j $LapVal $MinSegMeters }
if ($seg.Mode -ne 'ok' -or $seg.Start -lt 0) { throw "Cannot build corners: timing segment $($seg.Mode)" }
$iStart = $seg.Start; $iEnd = $seg.End
$idx = New-Object System.Collections.Generic.List[int]
for ($i = $iStart; $i -lt $iEnd; $i++) { [void]$idx.Add($i) }
if ($idx.Count -lt 200) { throw "Cannot build corners: too few frames ($($idx.Count))" }
$m = $idx.Count
$s = New-Object double[] $m
$brk = New-Object int[] $m
for ($k = 0; $k -lt $m; $k++) {
$fi = $idx[$k]
if ($k -gt 0) {
$pi = $idx[$k - 1]
$dx = [double]$j.x[$fi] - [double]$j.x[$pi]
$dy = [double]$j.y[$fi] - [double]$j.y[$pi]
$dz = [double]$j.z[$fi] - [double]$j.z[$pi]
$s[$k] = $s[$k - 1] + [Math]::Sqrt($dx * $dx + $dy * $dy + $dz * $dz)
}
$brk[$k] = [int]$j.brake[$fi]
}
$lapLen = $s[$m - 1]
if ($lapLen -lt 100.0) { throw "Cannot build corners: lap length abnormal ($lapLen)" }
$cand = New-Object System.Collections.Generic.List[object]
for ($k = 1; $k -lt $m; $k++) {
$prev = $brk[$k - 1]; $cur = $brk[$k]
$isOnset = ($cur -ge 35 -and $prev -lt 25) -or ($cur -ge 22 -and $prev -lt 12) -or (($cur - $prev) -ge 20 -and $cur -ge 18)
if ($isOnset) {
[void]$cand.Add([pscustomobject]@{
K = $k
S = $s[$k]
Fraction = ($s[$k] / $lapLen)
Score = ($cur + [Math]::Max(0, $cur - $prev))
})
}
}
if ($cand.Count -lt 14) { throw "Cannot build corners: brake onset candidates <14 ($($cand.Count))" }
$selected = New-Object System.Collections.Generic.List[object]
foreach ($c in ($cand | Sort-Object Score -Descending)) {
if ($selected.Count -ge 14) { break }
$ok = $true
foreach ($slt in $selected) {
$d = [Math]::Abs($c.S - $slt.S)
$dc = [Math]::Min($d, $lapLen - $d)
if ($dc -lt $DedupMinGapMeters) { $ok = $false; break }
}
if ($ok) { [void]$selected.Add($c) }
}
if ($selected.Count -lt 14) {
foreach ($c in ($cand | Sort-Object Score -Descending)) {
if ($selected.Count -ge 14) { break }
$exists = $false
foreach ($slt in $selected) { if ([int]$slt.K -eq [int]$c.K) { $exists = $true; break } }
if (-not $exists) { [void]$selected.Add($c) }
}
}
if ($selected.Count -lt 14) { throw "Cannot build corners: selected <14 ($($selected.Count))" }
$bf = @($selected | Sort-Object Fraction | Select-Object -First 14 | ForEach-Object { [double]$_.Fraction })
$ends = @()
for ($i = 0; $i -lt 13; $i++) { $ends += [Math]::Round((($bf[$i] + $bf[$i + 1]) / 2.0), 6) }
$ends += 1.0
$b = @(0.0) + $ends
$center = @()
for ($i = 0; $i -lt 14; $i++) { $center += [Math]::Round((($b[$i] + $b[$i + 1]) / 2.0), 6) }
$obj = [ordered]@{
_comment = "Auto-generated by DrawZhuhaiLapCorners.ps1 from replay brake onsets."
_comment2 = "segmentEndFraction[13] fixed at 1.0; cornerCenterFraction is sector midpoint."
segmentEndFraction = $ends
cornerCenterFraction = $center
}
$outDir = Split-Path -Parent $TargetPath
if ($outDir -and -not (Test-Path -LiteralPath $outDir)) {
New-Item -ItemType Directory -Path $outDir -Force | Out-Null
}
($obj | ConvertTo-Json -Depth 6) | Set-Content -LiteralPath $TargetPath -Encoding UTF8
Write-Host "Generated: $TargetPath"
}
$ReplayPath = Resolve-FsPath $ReplayPath
$legacyJson = Join-Path $toolDir 'zhuhai_replay_out_elmagnifico.json'
$legacyCorners = Join-Path $toolDir 'zhuhai_t1_t14_apex_fractions.json'
if ([string]::IsNullOrWhiteSpace($JsonPath)) {
if (-not [string]::IsNullOrWhiteSpace($ReplayPath)) {
$rpDir = Split-Path -Parent $ReplayPath
$rpStem = Get-FileStem $ReplayPath 'replay'
$JsonPath = Join-Path $rpDir ($rpStem + '_replay.json')
} elseif (Test-Path -LiteralPath $legacyJson) {
$JsonPath = $legacyJson
} else {
throw "Please provide -ReplayPath or -JsonPath."
}
}
if ([string]::IsNullOrWhiteSpace($CornersJson)) {
if (-not [string]::IsNullOrWhiteSpace($ReplayPath)) {
$rpDir = Split-Path -Parent $ReplayPath
$rpStem = Get-FileStem $ReplayPath 'replay'
$CornersJson = Join-Path $rpDir ($rpStem + '_corners.json')
} elseif (Test-Path -LiteralPath $legacyCorners) {
$CornersJson = $legacyCorners
} else {
$jDir = Split-Path -Parent $JsonPath
$jStem = Get-FileStem $JsonPath 'replay'
$CornersJson = Join-Path $jDir ($jStem + '_corners.json')
}
}
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
if (-not [string]::IsNullOrWhiteSpace($ReplayPath)) {
$rpDir = Split-Path -Parent $ReplayPath
$rpStem = Get-FileStem $ReplayPath 'replay'
$OutputPath = Join-Path $rpDir ($rpStem + '_brake_throttle_points.png')
} else {
$jDir = Split-Path -Parent $JsonPath
$jStem = Get-FileStem $JsonPath 'replay'
$OutputPath = Join-Path $jDir ($jStem + '_brake_throttle_points.png')
}
}
if ([string]::IsNullOrWhiteSpace($DebugOutputPath)) {
$DebugOutputPath = [IO.Path]::ChangeExtension($OutputPath, '.debug.csv')
}
$JsonPath = Resolve-FsPath $JsonPath
$CornersJson = Resolve-FsPath $CornersJson
$OutputPath = Resolve-FsPath $OutputPath
$DebugOutputPath = Resolve-FsPath $DebugOutputPath
Ensure-ReplayJson $JsonPath $ReplayPath $AcRpPath $DriverName
$j = Get-Content -LiteralPath $JsonPath -Raw -Encoding UTF8 | ConvertFrom-Json
if (-not (Test-Path -LiteralPath $CornersJson)) {
Build-CornerJsonFromReplay $j $CornersJson $Lap $MinSegmentMeters 28.0 $AutoFastestLap
}
$apexObj = Get-Content -LiteralPath $CornersJson -Raw -Encoding UTF8 | ConvertFrom-Json
if (-not $apexObj.segmentEndFraction) { throw 'CornersJson needs segmentEndFraction[14] ending with 1.0' }
$se = @([double[]]@($apexObj.segmentEndFraction))
$boundaries = Get-BoundariesFromSegmentEnds $se
$cornerCenter = $null
if ($apexObj.cornerCenterFraction) {
$cornerCenter = @([double[]]@($apexObj.cornerCenterFraction))
if ($cornerCenter.Count -ne 14) { throw 'cornerCenterFraction must have 14 elements if set' }
}
$nF = $j.x.Count
if ($j.velocityX.Count -ne $nF) { throw 'JSON needs velocityX/Y/Z same length as x.' }
$dt = Get-FrameDtSeconds $j
$brkFrames = [int][math]::Ceiling($BrakeMinSeconds / $dt)
$gasFrames = [int][math]::Ceiling($ThrottleMinSeconds / $dt)
$gasReapplyFrames = [int][math]::Ceiling($GasReapplyMinSeconds / $dt)
Write-Host "Frame dt=${dt}s brake>=${BrakeMinSeconds}s -> ${brkFrames} frames throttle>=${ThrottleMinSeconds}s -> ${gasFrames} frames"
$seg = if ($AutoFastestLap) { Select-FastestTimingSegment $j $MinSegmentMeters } else { Select-TimingSegment $j $Lap $MinSegmentMeters }
$iStart = 0; $iEnd = $nF; $timingUsed = $false
if ($seg.Mode -eq 'ok' -and $seg.Start -ge 0) {
$iStart = $seg.Start; $iEnd = $seg.End; $timingUsed = $true
if ($AutoFastestLap) {
$Lap = [int]$seg.Lap
Write-Host "Timing (fastest lap): lap=$Lap time_ms=$($seg.TimeMs) frames $($seg.Start)..$($seg.End) length_m=$([math]::Round($seg.Length,1))"
} else {
Write-Host "Timing: frames $($seg.Start)..$($seg.End) length_m=$([math]::Round($seg.Length,1))"
}
} else {
Write-Warning "Timing: $($seg.Mode)"
}
$idx = New-Object System.Collections.Generic.List[int]
for ($i = $iStart; $i -lt $iEnd; $i++) {
if (-not $timingUsed) {
if ([int]$j.currentLap[$i] -ne $Lap) { continue }
}
[void]$idx.Add($i)
}
if ($idx.Count -lt 200) { throw "Too few frames: $($idx.Count)" }
$m = $idx.Count
$s = New-Object double[] $m
$sp = New-Object double[] $m
$brk = New-Object int[] $m
$gas = New-Object int[] $m
$xs = New-Object double[] $m
$zs = New-Object double[] $m
for ($k = 0; $k -lt $m; $k++) {
$fi = $idx[$k]
$xs[$k] = [double]$j.x[$fi]; $zs[$k] = [double]$j.z[$fi]
if ($k -gt 0) {
$pi = $idx[$k - 1]
$dx = [double]$j.x[$fi] - [double]$j.x[$pi]
$dy = [double]$j.y[$fi] - [double]$j.y[$pi]
$dz = [double]$j.z[$fi] - [double]$j.z[$pi]
$s[$k] = $s[$k - 1] + [Math]::Sqrt($dx * $dx + $dy * $dy + $dz * $dz)
}
$sp[$k] = Get-SpeedKmh $j $fi
$brk[$k] = [int]$j.brake[$fi]
$gas[$k] = [int]$j.gas[$fi]
}
$lapLen = $s[$m - 1]
if ($lapLen -lt 100.0) { throw "Lap length abnormal: $lapLen" }
$xmin = ($xs | Measure-Object -Minimum).Minimum
$xmax = ($xs | Measure-Object -Maximum).Maximum
$zmin = ($zs | Measure-Object -Minimum).Minimum
$zmax = ($zs | Measure-Object -Maximum).Maximum
$innerFrac = [Math]::Max(0.0, [Math]::Min(0.45, $InnerMarginPercent / 100.0))
$bmpW = $ImageWidth; $bmpH = $ImageHeight
$iw = $bmpW * (1.0 - 2.0 * $innerFrac); $ih = $bmpH * (1.0 - 2.0 * $innerFrac)
$rw = [Math]::Max(1e-9, $xmax - $xmin); $rz = [Math]::Max(1e-9, $zmax - $zmin)
$sc = [Math]::Min($iw / $rw, $ih / $rz)
$offX = $bmpW * $innerFrac + ($iw - $sc * $rw) / 2.0
$offZ = $bmpH * $innerFrac + ($ih - $sc * $rz) / 2.0
$pxi = New-Object int[] $m
$pzi = New-Object int[] $m
for ($k = 0; $k -lt $m; $k++) {
$pxd = $offX + ($xs[$k] - $xmin) * $sc
if ($FlipWorldZ.IsPresent) { $pzd = $offZ + ($zs[$k] - $zmin) * $sc }
else { $pzd = $offZ + ($zmax - $zs[$k]) * $sc }
$pxi[$k] = Clamp-Int ([int][Math]::Round($pxd)) 0 ($bmpW - 1)
$pzi[$k] = Clamp-Int ([int][Math]::Round($pzd)) 0 ($bmpH - 1)
}
if (-not $NoVerticalFlip.IsPresent) {
for ($k = 0; $k -lt $m; $k++) { $pzi[$k] = $bmpH - 1 - $pzi[$k] }
}
$bmp = New-Object System.Drawing.Bitmap $bmpW, $bmpH
$g = [System.Drawing.Graphics]::FromImage($bmp)
$g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::AntiAlias
$g.TextRenderingHint = [System.Drawing.Text.TextRenderingHint]::AntiAlias
$g.Clear([System.Drawing.Color]::White)
$fontTitle = New-CjkDrawingFont $FontSizeTitle ([System.Drawing.FontStyle]::Bold)
$fontMk = New-CjkDrawingFont $FontSizeMarker ([System.Drawing.FontStyle]::Bold)
$brushTxt = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(240, 30, 30, 30))
$penTrace = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(200, 40, 90, 200)), 3
$brushRed = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(230, 200, 40, 40))
$brushGreen = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(230, 30, 150, 50))
$brushSf = New-Object System.Drawing.SolidBrush ([System.Drawing.Color]::FromArgb(255, 200, 130, 0))
$penLeader = New-Object System.Drawing.Pen ([System.Drawing.Color]::FromArgb(160, 90, 90, 90)), 1.0
for ($k = 1; $k -lt $m; $k++) {
$g.DrawLine($penTrace, $pxi[$k - 1], $pzi[$k - 1], $pxi[$k], $pzi[$k])
}
$occupied = New-Object 'System.Collections.Generic.List[System.Drawing.RectangleF]'
function Test-RectOverlap([System.Drawing.RectangleF]$a, [System.Drawing.RectangleF]$b, [float]$pad) {
$ax1 = $a.Left - $pad; $ay1 = $a.Top - $pad; $ax2 = $a.Right + $pad; $ay2 = $a.Bottom + $pad
$bx1 = $b.Left - $pad; $by1 = $b.Top - $pad; $bx2 = $b.Right + $pad; $by2 = $b.Bottom + $pad
return -not (($ax2 -lt $bx1) -or ($ax1 -gt $bx2) -or ($ay2 -lt $by1) -or ($ay1 -gt $by2))
}
function New-LabelPlacement {
param($Graphics, $Font, [string]$Text, [int]$cx, [int]$cy, [int]$imgW, [int]$imgH, $Occupied, [float[]]$OffsetCandidates)
$sz = $Graphics.MeasureString($Text, $Font)
$w = $sz.Width + 6; $h = $sz.Height + 4
$pad = [float]4
for ($ci = 0; $ci -lt $OffsetCandidates.Length; $ci += 2) {
$tx = [float]($cx + $OffsetCandidates[$ci]); $ty = [float]($cy + $OffsetCandidates[$ci + 1])
if ($tx + $w -gt $imgW - 4) { $tx = [float]($imgW - 4 - $w) }
if ($tx -lt 4) { $tx = 4 }
if ($ty + $h -gt $imgH - 4) { $ty = [float]($imgH - 4 - $h) }
if ($ty -lt 4) { $ty = 4 }
$rc = [System.Drawing.RectangleF]::new($tx, $ty, $w, $h)
$hit = $false
foreach ($o in $Occupied) { if (Test-RectOverlap $rc $o $pad) { $hit = $true; break } }
if (-not $hit) {
[void]$Occupied.Add($rc)
return @{ Tx = $tx; Ty = $ty; W = $w; H = $h }
}
}
# Fallback: radial search around anchor to minimize collisions in dense areas.
for ($rad = 26.0; $rad -le 190.0; $rad += 12.0) {
for ($ang = 0.0; $ang -lt 360.0; $ang += 20.0) {
$rx = [Math]::Cos($ang * [Math]::PI / 180.0) * $rad
$ry = [Math]::Sin($ang * [Math]::PI / 180.0) * $rad
$tx = [float]($cx + $rx)
$ty = [float]($cy + $ry)
if ($tx + $w -gt $imgW - 4) { $tx = [float]($imgW - 4 - $w) }
if ($tx -lt 4) { $tx = 4 }
if ($ty + $h -gt $imgH - 4) { $ty = [float]($imgH - 4 - $h) }
if ($ty -lt 4) { $ty = 4 }
$rc = [System.Drawing.RectangleF]::new($tx, $ty, $w, $h)
$hit = $false
foreach ($o in $Occupied) { if (Test-RectOverlap $rc $o $pad) { $hit = $true; break } }
if (-not $hit) {
[void]$Occupied.Add($rc)
return @{ Tx = $tx; Ty = $ty; W = $w; H = $h }
}
}
}
# Last resort: place at corner to guarantee visibility.
$tx0 = [float]4; $ty0 = [float]4
$rc0 = [System.Drawing.RectangleF]::new($tx0, $ty0, $w, $h)
[void]$Occupied.Add($rc0)
return @{ Tx = $tx0; Ty = $ty0; W = $w; H = $h }
}
function Draw-StringWithLeader {
param($Graphics, $Font, $Brush, $PenL, [int]$cx, [int]$cy, [string]$Text, $Place)
$Graphics.DrawString($Text, $Font, $Brush, $Place.Tx, $Place.Ty)
$mx = $Place.Tx + $Place.W / 2.0; $my = $Place.Ty + $Place.H / 2.0
$Graphics.DrawLine($PenL, [float]$cx, [float]$cy, $mx, $my)
}
$sfOff = [float[]]@(20.0, -28.0, -120.0, -28.0, 20.0, 22.0)
$sfPl = New-LabelPlacement $g $fontMk $cap.sf $pxi[0] $pzi[0] $bmpW $bmpH $occupied $sfOff
$g.FillEllipse($brushSf, $pxi[0] - 10, $pzi[0] - 10, 20, 20)
Draw-StringWithLeader $g $fontMk $brushTxt $penLeader $pxi[0] $pzi[0] $cap.sf $sfPl
$bOff = [float[]]@(16.0, -28.0, -120.0, -28.0, 20.0, 22.0, -130.0, 24.0, 95.0, -34.0, 110.0, 12.0)
$gOff = [float[]]@(-16.0, 26.0, 90.0, 26.0, -26.0, -18.0, 110.0, -24.0, -120.0, 30.0, 24.0, 44.0)
$prevBrakeRunEnd = -1
$prevGasRunEnd = -1
$events = New-Object System.Collections.Generic.List[object]
$debugRows = New-Object System.Collections.Generic.List[object]
for ($ti = 0; $ti -lt 14; $ti++) {
$f0 = $boundaries[$ti]; $f1 = $boundaries[$ti + 1]
$sLo = [Math]::Max(0.0, ($f0 * $lapLen) - $SectorExpandMeters)
$sHi = [Math]::Min($lapLen, ($f1 * $lapLen) + $SectorExpandMeters)
$ff0 = $sLo / $lapLen
$ff1 = $sHi / $lapLen
$k0, $k1 = Find-KRangeForArc $s $lapLen $ff0 $ff1 $m
# Avoid repeated brake markers when one long brake run spans adjacent sectors.
$searchK0 = [Math]::Max($k0, $prevBrakeRunEnd + 1)
$bk = Find-FirstRisingSustainedAbove $brk $searchK0 $k1 $BrakePedalThreshold $brkFrames
$brkEnd = -1
if ($bk -ge 0) {
$brkEnd = Get-ContiguousRunEnd $brk $bk $k1 $BrakePedalThreshold
if ($brkEnd -gt $prevBrakeRunEnd) { $prevBrakeRunEnd = $brkEnd }
}
$gasFrom = $k0
if ($brkEnd -ge 0) { $gasFrom = [Math]::Min($k1, $brkEnd + 1) }
$gasSearchK0 = [Math]::Max($gasFrom, $prevGasRunEnd + 1)
$tkRise = Find-FirstRisingSustainedAbove $gas $gasSearchK0 $k1 $GasPedalThreshold $gasFrames
$tkSustain = -1
$tkReapply = -1
$tk = $tkRise
$tkSource = 'rise'
if ($tk -lt 0) {
# Fallback: if no clean rising edge exists in this window, still capture first sustained high-gas point.
$tkSustain = Find-FirstSustainedAbove $gas $gasSearchK0 $k1 $GasPedalThreshold $gasFrames
$tk = $tkSustain
$tkSource = 'sustain'
}
if ($tk -lt 0) {
# Fallback 2: capture lower-threshold throttle reapply when speed rises but full gas threshold isn't reached.
$tkReapply = Find-FirstGasReapply $gas $brk $gasSearchK0 $k1 $GasReapplyThreshold $GasReapplyDelta $GasReapplyBrakeMax $gasReapplyFrames
$tk = $tkReapply
$tkSource = 'reapply'
}
if ($tk -lt 0) { $tkSource = 'none' }
if ($tk -ge 0) {
$gasEnd = Get-ContiguousRunEnd $gas $tk $k1 $GasPedalThreshold
if ($gasEnd -gt $prevGasRunEnd) { $prevGasRunEnd = $gasEnd }
}
if ($bk -ge 0) {
[void]$events.Add([pscustomobject]@{
K = $bk
Kind = 'brake'
Sector = ($ti + 1)
Source = 'rise'
GasValue = 0
Speed = [int][math]::Round($sp[$bk], 0)
Px = $pxi[$bk]
Py = $pzi[$bk]
})
}
if ($tk -ge 0) {
[void]$events.Add([pscustomobject]@{
K = $tk
Kind = 'gas'
Sector = ($ti + 1)
Source = $tkSource
GasValue = [int]$gas[$tk]
Speed = [int][math]::Round($sp[$tk], 0)
Px = $pxi[$tk]
Py = $pzi[$tk]
})
}
if ($DebugEventTrace.IsPresent) {
$secMaxGas = ($gas[$k0..$k1] | Measure-Object -Maximum).Maximum
$secMaxBrk = ($brk[$k0..$k1] | Measure-Object -Maximum).Maximum
[void]$debugRows.Add([pscustomobject]@{
Phase = 'sector'
Sector = ('T{0}' -f ($ti + 1))
k0 = $k0
k1 = $k1
searchBrakeK0 = $searchK0
bk = $bk
brkEnd = $brkEnd
gasSearchK0 = $gasSearchK0
tkRise = $tkRise
tkSustain = $tkSustain
tkReapply = $tkReapply
tkPicked = $tk
tkSource = $tkSource
secMaxGas = $secMaxGas
secMaxBrk = $secMaxBrk
})
}
}
$markerId = 0
$orderedEvents = @($events | Sort-Object K, Kind)
# Global补漏:若两次刹车之间无油门点,则在中间区间再做一次补油搜索。
$brakeEvents = @($orderedEvents | Where-Object { $_.Kind -eq 'brake' } | Sort-Object K)
if ($brakeEvents.Count -ge 2) {
for ($bi = 0; $bi -lt $brakeEvents.Count - 1; $bi++) {
$kA = [int]$brakeEvents[$bi].K
$kB = [int]$brakeEvents[$bi + 1].K
if (($kB - $kA) -lt 3) { continue }
$hasGasBetween = $false
foreach ($ev2 in $orderedEvents) {
if ($ev2.Kind -eq 'gas' -and $ev2.K -gt $kA -and $ev2.K -lt $kB) {
$hasGasBetween = $true
break
}
}
if ($hasGasBetween) { continue }
$g0 = $kA + 1
$g1 = $kB - 1
$tkMid = Find-FirstRisingSustainedAbove $gas $g0 $g1 $GasPedalThreshold $gasFrames
if ($tkMid -lt 0) {
$tkMid = Find-FirstSustainedAbove $gas $g0 $g1 $GasPedalThreshold $gasFrames
}
if ($tkMid -lt 0) {
$tkMid = Find-FirstGasReapply $gas $brk $g0 $g1 $GasReapplyThreshold $GasReapplyDelta $GasReapplyBrakeMax $gasReapplyFrames
}
if ($tkMid -lt 0 -and $AllowOverlapThrottleBetweenBrakes) {
# Only in brake-to-brake gaps: allow overlap throttle reapply without brake-max constraint.
$tkMid = Find-FirstGasReapplyOverlap $gas $g0 $g1 $GasReapplyThreshold $GasReapplyDelta $gasReapplyFrames
if ($tkMid -lt 0) {
# If gas is already high in this gap (no rise edge), capture the first sustained high-gas sample.
$tkMid = Find-FirstSustainedAbove $gas $g0 $g1 $GasReapplyThreshold $gasReapplyFrames
}
if ($tkMid -lt 0) {
# Final fallback for brake-to-brake gap: pick max-gas point in gap to avoid missing obvious refill.
$bestK = -1
$bestG = -1
for ($kk = $g0; $kk -le $g1; $kk++) {
if ($gas[$kk] -gt $bestG) { $bestG = $gas[$kk]; $bestK = $kk }
}
if ($bestG -ge $GasReapplyThreshold) { $tkMid = $bestK }
}
}
if ($DebugEventTrace.IsPresent) {
[void]$debugRows.Add([pscustomobject]@{
Phase = 'global_gap_probe'
Sector = ('T{0}->T{1}' -f $brakeEvents[$bi].Sector, $brakeEvents[$bi + 1].Sector)
k0 = $g0
k1 = $g1
searchBrakeK0 = ''
bk = $kA
brkEnd = $kB
gasSearchK0 = $g0
tkRise = ''
tkSustain = ''
tkReapply = ''
tkPicked = $tkMid
tkSource = if ($tkMid -ge 0) { 'global_probe_hit' } else { 'global_probe_miss' }
secMaxGas = ($gas[$g0..$g1] | Measure-Object -Maximum).Maximum
secMaxBrk = ($brk[$g0..$g1] | Measure-Object -Maximum).Maximum
})
}
if ($tkMid -ge 0) {
[void]$events.Add([pscustomobject]@{
K = $tkMid
Kind = 'gas'
Sector = 0
Source = 'global_gap_fill_overlap_ok'
GasValue = [int]$gas[$tkMid]
Speed = [int][math]::Round($sp[$tkMid], 0)
Px = $pxi[$tkMid]
Py = $pzi[$tkMid]
})
if ($DebugEventTrace.IsPresent) {
[void]$debugRows.Add([pscustomobject]@{
Phase = 'global_gap_fill'
Sector = ('T{0}->T{1}' -f $brakeEvents[$bi].Sector, $brakeEvents[$bi + 1].Sector)
k0 = $g0
k1 = $g1
searchBrakeK0 = ''
bk = $brakeEvents[$bi].K
brkEnd = $brakeEvents[$bi + 1].K
gasSearchK0 = $g0
tkRise = ''
tkSustain = ''
tkReapply = ''
tkPicked = $tkMid
tkSource = 'global_gap_fill'
secMaxGas = ($gas[$g0..$g1] | Measure-Object -Maximum).Maximum
secMaxBrk = ($brk[$g0..$g1] | Measure-Object -Maximum).Maximum
})
}
}
}
$orderedEvents = @($events | Sort-Object K, Kind)
}
# Second-pass robust补漏(仅连续刹车之间):
# If a brake-to-brake gap still has no gas marker, insert one at max-gas position in that gap.
if ($AllowOverlapThrottleBetweenBrakes) {
$orderedEvents = @($events | Sort-Object K, Kind)
$brakeEvents2 = @($orderedEvents | Where-Object { $_.Kind -eq 'brake' } | Sort-Object K)
if ($brakeEvents2.Count -ge 2) {
for ($bi2 = 0; $bi2 -lt $brakeEvents2.Count - 1; $bi2++) {
$kA2 = [int]$brakeEvents2[$bi2].K
$kB2 = [int]$brakeEvents2[$bi2 + 1].K
if (($kB2 - $kA2) -lt 3) { continue }
$hasGasBetween2 = $false
foreach ($evx in $orderedEvents) {
if ($evx.Kind -eq 'gas' -and $evx.K -gt $kA2 -and $evx.K -lt $kB2) {
$hasGasBetween2 = $true
break
}
}
if ($hasGasBetween2) { continue }
$g02 = $kA2 + 1
$g12 = $kB2 - 1
$bestK2 = -1
$bestG2 = -1
for ($kk2 = $g02; $kk2 -le $g12; $kk2++) {
if ($gas[$kk2] -gt $bestG2) { $bestG2 = $gas[$kk2]; $bestK2 = $kk2 }
}
if ($bestK2 -ge 0 -and $bestG2 -ge $GasReapplyThreshold) {
[void]$events.Add([pscustomobject]@{
K = $bestK2
Kind = 'gas'
Sector = 0
Source = 'global_gap_force_max'
GasValue = [int]$gas[$bestK2]
Speed = [int][math]::Round($sp[$bestK2], 0)
Px = $pxi[$bestK2]
Py = $pzi[$bestK2]
})
}
}
$orderedEvents = @($events | Sort-Object K, Kind)
}
}
# Rule: between two consecutive brake points, keep at most one gas point.
if ($orderedEvents.Count -gt 0) {
$removeIdx = New-Object 'System.Collections.Generic.HashSet[int]'
$brakeIdx = New-Object System.Collections.Generic.List[int]
for ($i = 0; $i -lt $orderedEvents.Count; $i++) {
if ($orderedEvents[$i].Kind -eq 'brake') { [void]$brakeIdx.Add($i) }
}
for ($bi3 = 0; $bi3 -lt $brakeIdx.Count - 1; $bi3++) {
$ia = $brakeIdx[$bi3]
$ib = $brakeIdx[$bi3 + 1]
$gasCandidates = New-Object System.Collections.Generic.List[int]
for ($i = $ia + 1; $i -lt $ib; $i++) {
if ($orderedEvents[$i].Kind -eq 'gas') { [void]$gasCandidates.Add($i) }
}
if ($gasCandidates.Count -le 1) { continue }
# Keep earliest gas marker between two brake markers.
$keep = $gasCandidates | Sort-Object { [int]$orderedEvents[$_].K } | Select-Object -First 1
foreach ($gi in $gasCandidates) {
if ($gi -ne $keep) { [void]$removeIdx.Add([int]$gi) }
}
}
if ($removeIdx.Count -gt 0) {
$filtered = New-Object System.Collections.Generic.List[object]
for ($i = 0; $i -lt $orderedEvents.Count; $i++) {
if (-not $removeIdx.Contains($i)) { [void]$filtered.Add($orderedEvents[$i]) }
}
$orderedEvents = $filtered.ToArray()
}
}
foreach ($ev in $orderedEvents) {
$markerId++
$lbl = ('A{0} {1} km/h' -f $markerId, $ev.Speed)
if ($ev.Kind -eq 'brake') {
$pl = New-LabelPlacement $g $fontMk $lbl $ev.Px $ev.Py $bmpW $bmpH $occupied $bOff
$g.FillEllipse($brushRed, $ev.Px - 7, $ev.Py - 7, 14, 14)
} else {
$pl = New-LabelPlacement $g $fontMk $lbl $ev.Px $ev.Py $bmpW $bmpH $occupied $gOff
$g.FillEllipse($brushGreen, $ev.Px - 7, $ev.Py - 7, 14, 14)
}
Draw-StringWithLeader $g $fontMk $brushTxt $penLeader $ev.Px $ev.Py $lbl $pl
}
if ($DebugEventTrace.IsPresent) {
$aRows = New-Object System.Collections.Generic.List[object]
$aId = 0
foreach ($ev in $orderedEvents) {
$aId++
[void]$aRows.Add([pscustomobject]@{
Phase = 'A_sequence'
Sector = if ($ev.Sector -gt 0) { 'T' + $ev.Sector } else { '-' }
A = 'A' + $aId
Kind = $ev.Kind
Source = $ev.Source
K = $ev.K
AbsFrame = $idx[$ev.K]
ArcS_m = [Math]::Round($s[$ev.K], 3)
Speed_kmh = $ev.Speed
})
}
$gapRows = New-Object System.Collections.Generic.List[object]
$brOnly = @($aRows | Where-Object { $_.Kind -eq 'brake' })
for ($gi = 0; $gi -lt $brOnly.Count - 1; $gi++) {
$a = $brOnly[$gi]
$b = $brOnly[$gi + 1]
$ka = [int]$a.K; $kb = [int]$b.K
if (($kb - $ka) -lt 2) { continue }
$lo = $ka + 1; $hi = $kb - 1
$hasGas = ($aRows | Where-Object { $_.Kind -eq 'gas' -and [int]$_.K -gt $ka -and [int]$_.K -lt $kb } | Select-Object -First 1)
$maxGas = ($gas[$lo..$hi] | Measure-Object -Maximum).Maximum
$maxBrk = ($brk[$lo..$hi] | Measure-Object -Maximum).Maximum
$run180 = Get-LongestRunAbove $gas $lo $hi 180
$run60 = Get-LongestRunAbove $gas $lo $hi 60
$run40 = Get-LongestRunAbove $gas $lo $hi 40
[void]$gapRows.Add([pscustomobject]@{
Phase = 'brake_gap'
Sector = ($a.A + '->' + $b.A)
A = ''
Kind = ''
Source = if ($hasGas) { 'has_gas' } else { ("no_gas(run180={0},run60={1},run40={2})" -f $run180, $run60, $run40) }
K = "$lo..$hi"
AbsFrame = "$($idx[$lo])..$($idx[$hi])"
ArcS_m = [Math]::Round(($s[$lo] + $s[$hi]) / 2.0, 3)
Speed_kmh = ''
MaxGas = $maxGas
MaxBrake = $maxBrk
})
}
$all = @($debugRows + $aRows + $gapRows) | ForEach-Object {
[pscustomobject]@{
Phase = if ($_.PSObject.Properties.Name -contains 'Phase') { $_.Phase } else { '' }
Sector = if ($_.PSObject.Properties.Name -contains 'Sector') { $_.Sector } else { '' }
A = if ($_.PSObject.Properties.Name -contains 'A') { $_.A } else { '' }
Kind = if ($_.PSObject.Properties.Name -contains 'Kind') { $_.Kind } else { '' }
Source = if ($_.PSObject.Properties.Name -contains 'Source') { $_.Source } else { '' }
K = if ($_.PSObject.Properties.Name -contains 'K') { $_.K } else { '' }
AbsFrame = if ($_.PSObject.Properties.Name -contains 'AbsFrame') { $_.AbsFrame } else { '' }
ArcS_m = if ($_.PSObject.Properties.Name -contains 'ArcS_m') { $_.ArcS_m } else { '' }
Speed_kmh = if ($_.PSObject.Properties.Name -contains 'Speed_kmh') { $_.Speed_kmh } else { '' }
MaxGas = if ($_.PSObject.Properties.Name -contains 'MaxGas') { $_.MaxGas } else { '' }
MaxBrake = if ($_.PSObject.Properties.Name -contains 'MaxBrake') { $_.MaxBrake } else { '' }
k0 = if ($_.PSObject.Properties.Name -contains 'k0') { $_.k0 } else { '' }
k1 = if ($_.PSObject.Properties.Name -contains 'k1') { $_.k1 } else { '' }
searchBrakeK0 = if ($_.PSObject.Properties.Name -contains 'searchBrakeK0') { $_.searchBrakeK0 } else { '' }
bk = if ($_.PSObject.Properties.Name -contains 'bk') { $_.bk } else { '' }
brkEnd = if ($_.PSObject.Properties.Name -contains 'brkEnd') { $_.brkEnd } else { '' }
gasSearchK0 = if ($_.PSObject.Properties.Name -contains 'gasSearchK0') { $_.gasSearchK0 } else { '' }
tkRise = if ($_.PSObject.Properties.Name -contains 'tkRise') { $_.tkRise } else { '' }
tkSustain = if ($_.PSObject.Properties.Name -contains 'tkSustain') { $_.tkSustain } else { '' }
tkReapply = if ($_.PSObject.Properties.Name -contains 'tkReapply') { $_.tkReapply } else { '' }
tkPicked = if ($_.PSObject.Properties.Name -contains 'tkPicked') { $_.tkPicked } else { '' }
tkSource = if ($_.PSObject.Properties.Name -contains 'tkSource') { $_.tkSource } else { '' }
secMaxGas = if ($_.PSObject.Properties.Name -contains 'secMaxGas') { $_.secMaxGas } else { '' }
secMaxBrk = if ($_.PSObject.Properties.Name -contains 'secMaxBrk') { $_.secMaxBrk } else { '' }
}
}
$all | Export-Csv -LiteralPath $DebugOutputPath -NoTypeInformation -Encoding UTF8
$a1415 = $gapRows | Where-Object { $_.Sector -eq 'A14->A15' } | Select-Object -First 1
if ($null -ne $a1415) {
Write-Host ("Debug A14->A15: source={0} maxGas={1} maxBrake={2} gapK={3}" -f $a1415.Source, $a1415.MaxGas, $a1415.MaxBrake, $a1415.K)
}
Write-Host "Debug trace saved: $DebugOutputPath"
}
$sub = ('dt={0}ms brake>={1}s thr={2} gas>={3} expand={4}m' -f [int]($dt * 1000), $BrakeMinSeconds, $ThrottleMinSeconds, $GasPedalThreshold, $SectorExpandMeters)
$title = $cap.titlePrefix + ' Lap=' + $Lap + ' L=' + [math]::Round($lapLen, 0) + 'm ' + $sub + ' ' + (Get-Date -Format 'yyyy-MM-dd HH:mm')
$g.DrawString($title, $fontTitle, $brushTxt, 10.0, 8.0)
$leg = $cap.legend + ' | ' + $sub
$g.DrawString($leg, $fontMk, $brushTxt, 10.0, [float]($bmpH - 42))
$bmp.Save($OutputPath, [System.Drawing.Imaging.ImageFormat]::Png)
$g.Dispose(); $bmp.Dispose()
$penTrace.Dispose(); $penLeader.Dispose()
$brushRed.Dispose(); $brushGreen.Dispose(); $brushTxt.Dispose(); $brushSf.Dispose()
$fontTitle.Dispose(); $fontMk.Dispose()
Write-Host "Saved: $OutputPath"
没想到打包exe,这个简单的需求反而是最麻烦的,最难处理的。
AI生成的都是powershell的脚本,我想把它打包成一个exe,可以方便使用一些。
打包 exe 重写了三遍,第一遍打包 exe 还要调用脚本,那这个 exe 的意义何在;第二遍打包各种路径弄不对;第三遍打包增加测试方法以后,总算给出来一个能用的 exe 了。
#Requires -Version 5.1
# 将 BuildIdealLineFromReplay.ps1 / DrawZhuhaiLapCorners.ps1 打成 exe。
# 先输出到 %TEMP% 再复制到 tools,避免目标 exe 被占用时 PS2EXE 无法删除旧文件导致打包失败。
$ErrorActionPreference = 'Stop'
$here = $PSScriptRoot
Import-Module (Join-Path $here 'ps2exe-module\ps2exe.psd1') -Force
function Stop-ToolProcess([string]$exeFileName) {
$base = [IO.Path]::GetFileNameWithoutExtension($exeFileName)
Get-Process -Name $base -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
}
function Copy-ExeToTools {
param([string]$TempExe, [string]$DestExe)
Copy-Item -LiteralPath $TempExe -Destination $DestExe -Force
}
$targets = @(
@{ In = 'BuildIdealLineFromReplay.ps1'; Out = 'BuildIdealLineFromReplay.exe'; Title = 'BuildIdealLineFromReplay'; ConHost = $true },
@{ In = 'DrawZhuhaiLapCorners.ps1'; Out = 'DrawZhuhaiLapCorners.exe'; Title = 'DrawZhuhaiLapCorners'; ConHost = $false }
)
foreach ($t in $targets) {
$inPath = Join-Path $here $t.In
$outPath = Join-Path $here $t.Out
Write-Host "Building $outPath ..."
Stop-ToolProcess $t.Out
Start-Sleep -Milliseconds 400
$tmp = Join-Path $env:TEMP ('ps2exe_' + [guid]::NewGuid().ToString('N') + '_' + $t.Out)
try {
# Draw:System.Drawing 用 -STA;-conHost 会导致脚本未跑完、PNG 不落盘。
# BuildIdealLine:-conHost 便于无控制台/部分自动化场景结束等待。
if ($t.ConHost) {
Invoke-ps2exe -inputFile $inPath -outputFile $tmp -conHost -title $t.Title
} else {
Invoke-ps2exe -inputFile $inPath -outputFile $tmp -STA -noConsole:$false -title $t.Title
}
Copy-ExeToTools -TempExe $tmp -DestExe $outPath
Write-Host " -> $outPath"
} finally {
Remove-Item -LiteralPath $tmp -Force -ErrorAction SilentlyContinue
}
}
Write-Host 'Done.'
最终生成的代码如下,也一起打包了exe
https://github.com/elmagnificogi/ACRecord2AILine.git
对于AI来完成一个项目一些前提:
再完成转exe和画分析图的过程中都出现了上下文不够用的情况,我只好重新总结要求,新开一个agent再次进行,而这样再次进行的上下文大概只用了1/3就完成了所有新工作
当AI出现试错或者走向错误分支的时候,这部分上下文可能会占用很多tokens消耗,最好人工发现以后,简单总结,避免再走向错误的道路中

一共就写这么不到 2000 行的代码,2 个需求 + 一个 CI 打包,去掉我 4 月前几天的消耗,大概 400 万 tokens,完成这个消耗了 5400 万 tokens,这里面有很多 cache,但是总体量就得有这么多,平均一行代码消耗 2 万多 tokens,还是很恐怖的。
这么一个需求消耗了接近1/3的Cursor用量,核算下来大概是5刀,30来块钱,看起来挺少的,但是总共耗时大概是7-8小时,是我全程辅助以后的结果。
如果给我7-8小时,纯工作时间,估计也能做到差不多的程度,但是消耗的脑力就很多了,我需要从头开始学习和实验。
后续如果再用AI做需求,再完善一下方法论,再给到AI应该会更快更好一些。
2026-03-27 00:00:00
2026.1.13 开始学习艺术与审美的第三阶段课程:视觉艺术方法论,接着之前的审美课二阶段内容继续学。
这个课是 25 年 10 月才开始上的,我问他的时候刚好上完,课程内容还没总结完,基本都是直播录屏,我也直接加进了直播群。
看了下这个课程大概有 230 多人,这已经是三阶段课程了,之前两阶段的人应该会更多一些。
艺术与审美课的三阶段内容如下,价格是 1680,总共是 12 节大课,大概 36 小时,课程宣传视频也确实是他刚开课的时候发的。
从目录来看,这节课涉及具体艺术落地,应该比较有用,或者说能对生活中的一些设计细节带来额外启发和思考。
目录:
这些课程内容或者研究是唯伟老师团队自己做出来的,带有一定的主观性,再加上他本身不是这个领域的人,他只是看和研究别人为什么这么做和当下的艺术界有什么联系和影响,所以只当作入门了解即可,不必当真。
这一课看似是拿家具为例子,本质上还是在论证之前他说的一些理论在这里是如何应用的。
柜子这个东西从平面到立体,一些隐形的规则,虚实,阴影带来的视觉体验,让你觉得某个柜子的设计好看或者不好看
结构即审美,就是单纯的结构设计就能让你看到美感,屏蔽了很多复杂的装饰品,类似日本的建筑师安藤忠雄,清水混凝土,他做出来的就非常好看。
在柜子这里又说到了另外一个概念,类似虚实,柜体本身的设计如果偏向简洁,那么对应的这个柜体本身兼容性就更强,他就能和更多的东西组合搭配在一起使用。但是反过来,如果柜子本身的装饰面设计的非常复杂,抢眼,那么他与其他家具或者其他装饰品要搭配在一起就比较困难,这个东西本质上还是从整体去着眼,而非从柜子本身去着眼的逻辑,整体看一面墙或者家的范围要和谐统一,一旦所有家具都是极其复杂没有虚实结合的时候,那么给人的压力就非常大,就不适合了。同样的这个柜子对于家,物理面积就有了需求,越是复杂的柜子越需要更大的空间去承载,而越小的家,越适合简单的柜子
中间说明了丹麦家具的历史地位,举例当然是椅子王,说家具离不开那帮做椅子的人。后续也很简单:家具里各种功能和用户需求的小细节是第一位的,满足了基础实用需求以后,美才会被提上日程。不过这里说得有点绝对,实际上当你有足够预算的时候,第一时间考虑的往往就是美,因为功能在这里满足不了,可能可以在其他地方满足,预算能做的事情太多了。
这里说的设计、美感上的秩序感或者说有规律的那种感觉,是东亚人的潜意识,人心里潜意识就觉得这个东西应该是有规律的,而这个规律又来自哪里,来自于古代的权威或者说几千年来建立的统治阶层对于下面人的潜意识,这个东西不是简简单单的就能抹除的,类似中国的对称感,这种潜意识的需求是很难被自我发现的。
总结来说,中低端用户是不会为美付费的,而高端用户是会为此付出溢价,推理就能得到,小工作室做中低端死路一条,无论是生产资料还是成本都比不过正规工厂,而高端才有可能盘活。
最后说了一下新时代是新能源的一代,我只能说这个说法还是有些偏颇。这个时代确实进入了下一个阶段,但到底是新能源还是其他方向,不好说。理性或者偏理性的设计一定会有,它是时代浪潮的一部分,这批人也正在成长。结合之前看到的政治课内容,这个时代已经是财富再分配时代了,老登永远是老登,只要不是特意作死,很难更换,而继承者也是一样,至于在时代浪潮里起来的人,终究是少数。
这个课的内容确实和我之前想的差不多,唯伟确实不能给做家具设计的什么东西,只能是对小白设计者或者观众给出一些浅显的内容,他是按照他的方法论,他的世界观去理解和解释其他内容的,到这里我基本看明白了,估计剩下的几课内容都是类似的方式。
包豪斯诞生的时代是刚好工业革命的时代,是机器大量替代人工刚刚开始,此时时代需求的就正好是大量工人或者普通人的需求。而包豪斯就是格罗皮乌斯提出来的一种用来解决普通人需求的产品的设计理念。包豪斯内部主要就是功能和形式之争,到底是功能优先还是艺术家的形式优先,在当时的那个年代,他们是超前的,甚至超脱当时的生产力水平,这导致他们设计的东西实际是难以落地的,虽然落地了,但是在当时是根本无法给普通人使用的,是无法惠民的。但是这部分概念却保留和影响了下面好几代的设计师,直到现在生产力的大爆发,总算是可以普遍满足包豪斯设计的初衷了。但这个背后是生产力大爆发,是人民群众的精神物质水平巨幅提高,是大家需要满足精神需求了,才逐渐和包豪斯的理念契合。而在当下,人们早就不满足于包豪斯的概念了,当你有权、有钱、有闲时,你的精神追求就成为了你的主要需求,对应的艺术概念也就开始百花齐放了,有需求就会有市场,有响应。
再说回未来,如果具身智能大爆发,可能很多人都会成为脱产者。当你不再被生存压迫时,就会开始精神空虚,对应的未来可能就是各种小众艺术崛起的时代。
之前看到一个龙头挂香竹,单纯就是把实用和结构结合在一起的东西,你看到就会觉得很好看。

这个东西就很好的把形式和功能结合到了一起。
后面说了一下美,独立游戏等相关问题,总的来说,美是需要巨大成本的,在成本有限时,你需要优先完成你的游戏性而不是美术风格。
美是模式识别+意义共鸣,模式识别就和代码里的一样,是你对美的语言的认同,是普通的形式美,基础美的描述语言,比如网红脸,形式美、可以被模式识别的东西是很容易被复制粘贴的,意义共鸣则是这个东西背后的意义,比如8090后对于红白机的怀旧感,战争,苦难等等各种事件,这个东西就是意义共鸣,这个东西没有相同经历或者体验,很难被复制粘贴。
这两者可以互补,当某一项过强时,他就可以约等于美,而忽略另外一边。
以发型修剪为例,解释说明为什么发型是提拉进行修剪。层次感,轻盈、蓬松、空气的感觉
理发大概有两种流派,沙宣和日式,沙宣可以理解为基础的理发技术,日式可能会上升到一点艺术的形式。很少有听说理发里怎么怎么艺术,怎么怎么大师,大家更多的感觉是理发更接近一个技术或者工匠,而不是艺术本身,再加上理发界本身科学素养不太够,那么他们在总结或者教授技术的时候,就自然的会选择贴靠一部分成熟的现代艺术理论,从而使自己说的东西更有说服度。理发选择了建筑学的包豪斯,但是实际上只能说是生搬硬套,为了靠而靠。
理发两个技术方向,线条、渐层堆积,线条就是塑造发型轮廓,渐层堆积可以认为是塑造体积,二者都类似绘画最终塑造了发型的纹理感或者是体积感。
头发的点线面那就更是直接硬套绘画的内容,有点关系,但是只能是表面关系。
植村隆博,其实就是把头发套到了空间中,把空间中的旋转、平移、拼接这么一套东西,挪到了人头上,真的有啥技术含量嘛?其实没有,如果你是个建模的,一秒钟就理解在他干啥了。唯一有点艺术感的地方就是空间关系的平衡,这个是需要参考之前雕塑、平面等方面的设计,他们在空间上或者视觉上有一种平衡感,同样的打破平衡也是一种艺术的突破。唯伟讲了半天就是吹了半天他的旋转、平移,现在看起来属实一般。
后续就是理发界的度量衡,通过一套理论总算可以量化各种发型了。这种也只能说是符合一般科学了,不再是凭感觉或者手感来控制发型修剪样式了。
再接着就是把用户进行分类,同时对发型和感官进行分类,从而更好的去满足用户需求,这样一套东西更偏向工程化,理发的工程化,这里艺术性就相对偏弱了。
准确说这一堂课大概说了一下发型逻辑的进步历史,但是也仅限于工程化就停止了,再往后如何艺术化,其实根本没有提,连入门都算不上。
穿搭的核心,还是求偶,不过这种观点过于偏激了。但是穿搭的本质除了生存以外,其他不就是为了彰显个性嘛,彰显个性干什么呢,求偶,求偶是大头,所以这里说了男性穿搭本质上,也是为了求偶,女性那就是魅男了,底层逻辑确实如此。男性对于这一方面关注不多,本质上男性掌握着社会的财富和权力,所以男性多数是走向内在修养和财富实力的,这方面有压倒性的魅力。
男性穿搭第一课不是穿搭,而是健身。意见挺好,但是太绝对了。
衣服图案可以部分抵消身材肥胖,用图案材质,削弱体积感
清爽感或者利落感的搭配,一开始就要选好或者认识清楚自己的身材比例,然后进行穿搭,男士的穿搭说的比较简单,而且也是一些比较简单粗暴的逻辑。
到女士这里就说明了穿搭的核心,利用穿搭,刻意营造视错觉,修饰自身的缺陷,从而让自己符合当下的审美,但是这样并不会改变你的本质,你该是啥样的身材,该是啥样的脸,还是啥样,等于化妆。
对于男性审美,本质上还是从古希腊时期的审美经验延续至今;而女性审美则比较复杂,与时尚、明星、健美等等都有关系。
穿搭三要素:脸型、五官轮廓判断,量感判断,身型判断
五官这个好判断,轮廓一眼就能看出来,这里的量感比较复杂,量感简单说就是可爱风和欧美风的一个衡量标准,五官越立体、越分散,就越欧美范一些,量感就更大。
同理身体的量感是和自己的肩宽比例,穿搭的核心逻辑就变成了量感匹配,只要你认识到自己是什么样的,然后搭配对应量感的服装或者修饰即可,这样就能把你从丑拉到中位线上了,拉到美,那还是得看底子。
这里的穿搭理解只能说是初级的,还是以顺应自己为主,但是实际上穿搭有很多故意做碰撞、矛盾、冲突的元素,故意使用不和谐的内容搭配在一起,这种突破性的妆造也是有的,所以能看到有些明星会穿一些不符合自身条件的衣服,就是为了彰显他能突破某些固有的思路或者典型的造型,让人审美不会疲劳,其实和艺术中的视觉经验,视觉疲劳是一模一样的,但是唯伟这里讲得太浅显了
这节课就很无语了,字体设计史直接从AI生成了,没有自己去做考究、研学了。还好之前关注过一段时间的oooooohmygosh,有一定的字体设计的基础,不然就被忽悠了。
字体设计师有能力,也懂现代设计,但是缺少对于书法的理解(这是必然的,如果书法理解很到位,一般也很少去做平面或者字体设计,过于小众了),导致设计师只能从字体中找规律,寻求和总结美感,当然了真正的书法大师自己也无法总结出来自己美的规律,各自追求的艺术性都太强了,没办法适配这种通用性的字体的。把原本写出来的字转到显示器上去显示,就免不了要西化、或者说现代化,必须要把字体几何化,这种几何化又必须统一,否则这个字体风格就会变来变去的。
或者说字体设计师很像AI,他只能基于以前有的东西去再创作、再设计,本质上更像是一种风格化,设计师很难创造出新的东西了,字体的骨架或者大致样子都被古人定死了,你再造一个新的,让别人理解,这个难度很大。其次,中文字体的单字又非常多,而字体本身的版权、推广、商用又不够发达,这就导致做这个东西很容易亏本,甚至倒贴,它的经济性太差了。
但是这里面有一个小的分支,我比较熟悉的点阵字体,这个领域是比普通的字更专业的,需要更多更抽象的能力,有时候可能就是在造字了,而不是风格化,这种极简情况下的设计逻辑是值得研究,也是有一点商业价值的。
然后这里唯伟介绍的内容也是有问题的,他看到的都是高清的字或者说不讲究分辨率的字,都是艺术字,不是给程序、编码看的,但是实际上在屏幕上显示的字用的像素点本身要少的多,这里面讲究就很多了,他完全没展开这部分内容。他说的字体基本都可以认为是拿到产品包装、海报等等上面的装饰字,本质上距离我们说的或者使用的字体是不一样的,他这里完全搞混了这两个,还在拿这二者对比,其实是不对的。
后续是讲述字体有情绪,除了字体本身,还表达了一些内容,利用字体和他的背景空间,就能创造出超过字体本身的含义表达。
好的设计是感受和情绪积累的释放,后续举例的设计案例中,就很明显可以看到这一点,基本功是必不可少的,但是真正好的设计恰巧就是你日积月累,对于生活或者什么东西的仔细观察和思考,刚好和这个设计需求契合在一起了,这个设计自然就好了,同样的硬憋一个设计就很难,那种生搬硬套感会特别重。
大小错觉、骨错觉、断裂错觉,应该还有一些其他视错觉经验可以借鉴,这部分就算作基本素养了。
包装重点在信息整理和阅读顺序,换句话说它的终极目的是为销量负责。可能它会曲里拐弯地影响营销或者其他东西,但终极目的总是销量,华与华恰巧就擅长干这个事情。
后续对于包装设计是否好的推论上就有点牵强了,你又要卖的好,又要包装设计的有艺术感?这本书就不可能,包装是给消费者看的,消费者的平均水平必须是大于等于这个产品包装设计的审美水平的,你艺术性超级高,包装是好看了,但是别人不理解你这是啥东西的时候,那就本末倒置了。但是这也要分割来说,如果一个商品是需要被普通人通过在超市、在线上这种盲选,那么这个东西大概率不是什么多高端的东西,现在信息如此丰富的情况下,推广这么强的情况下,人去盲选的概率很低了。但是反过来,当年信息不发达的时候,信息差比较高的时候,辅助盲选是很重要的。
那么包装设计的艺术美感就只能放在奢侈品或者小众性产品上了,比如手工艺品,或者一些个人开的店。个人意识比较强时才有可能出现所谓的“好包装”。在足够高的价格里,除了包装以外的宣发通常远超过包装本身的销量价值,所以此时奢侈品包装就必须能用来区分阶级,必须让人眼前一亮。
后续课程中的这些东西其实都能找到,我都见过好几次视频在介绍这种我们习以为常的东西是怎么产生、改良并最终变成今天这样的。只是唯伟纠结于 AI,陷入其中,忘记了基础搜索能力。
唯伟最后想说的是包装设计中的艺术性,他认为这才是好的,不能苟同,从历史来说,这种东西很难留在这个时代里,能留下的,只能是契合了时代本身浪潮的东西,就比如他以前讲到的例子,书法的石碑,工匠你是找不到的,但是碑文被传承了,同样的包装里的设计师很难被留名,但是如果你的作品足够出色,那么这东西一定会被留着包装历史中,但是不一定会被留着艺术史中。
陶瓷,这课开始双口相声了,但是吧,认知还是有点问题。考古和鉴定我之前也关注过一些,大概也了解了个七七八八。
这堂课前面就有点像两个半桶水的人,对着不了解的东西硬夸、硬聊,我听着都尬住了。
实际上陶瓷不像艺术,陶瓷的历史还是比较完整的,而且国内也有景德镇陶瓷大学,是有正儿八经的专业课的,陶瓷艺术设计专业,这部分过往历史直接看教科书就行了,它不仅解决艺术性的问题,还教手动技艺。
现在工业发展实际上还需要基础材料学研究配合才行。其次烧窑、窑温控制、热工艺这些也都要懂,而不能像古人一样无脑试错、全凭感觉。现在理论知识体系已经比较完备了。
宋代是中国陶瓷美学的最高峰,追求极简、自然、含蓄,其他朝代的艺术有点走偏了,宋代是最符合中国传统审美的,影响也是最大的。
大概十年前去过景德镇,也看过当地的烧窑,还是有很多很多小作坊的,以前去的时候已经取消了很多小作坊了,容易引起事故,但是实际上在当地还是家家户户一个柴窑,当地景德镇的学生,在景飘,就是和主家合作,租用柴窑,烧制自己网上接单的定制瓷器,主要是定制图案,然后烧,基本都是手工活。
这节课也是个相声,趋势?就说那么一点,不做任何分析,全凭感觉,有点不靠谱。分析结论是没有数据支撑的,趋势也只是看到了什么说什么,没看到?那就不知道了。
趋势就是一个 IP 化,没了,真的有点招笑了。IP 不 IP 从来不是乙方可以决定的,那是甲方的事情,入不了甲方的眼,怎么 IP 化?其次,IP 化需要标准化或规则化的制品,手工谈什么 IP 化,产品素质参差不齐,根本没法产品化。这里面的难度和要考量的点多得多。日本或者已经 IP 化的一部分产品,本身走的也是批量工艺而不是手工,那就注定了做手工的不太可能大规模发展起来,只能一直处于这个灰度或者存量市场里。我十年前去他们就这么做了,十年后还是这样,真的进步了吗?真的有更多需求或者发展的未来给他们吗?
这节课的部分结论和我之前说的一样,自己的IP是很难推起来的,大IP才可能在DIY或者艺术领域有所发展
这里看研报有点问题,唯伟和唯风都觉得看研报就知道这个公司在干啥了,其实不太对,这个研报本身就是美化以后的东西,谁敢让人真的揭露内部的一些有问题的地方,很多地方都被委婉或者处理过了,这个就涉及到另外一层了,万一说了一个特别差的报告,那这里就是做空了,甚至有其他意图的。大部分这种尽调或者研报都是偏向好的,做多的一面,所以这些企业的估值是一路上涨的,给市场信心,让投资人更愿意去投。
泡泡马特的前人或者模仿对象,三丽鸥,说是泡泡马特没有自己的IP,其实只是他的IP建立渠道和传统的动画、动漫、小说不一样,他没有这一块的资源,所以他从另外一个角度出发,从当代人的需求触发,给了Z世代一个精神寄托的IP,当这个东西突然起来了以后,后续这个IP自然而然就会向其他领域进军,寻求更广泛的市场。但是总体来说还是要先建立一个爆款,然后维持住这个爆款的热度,才能有后续的发展。IP消费是为了满足情感需求?你怕不是被消费主义洗脑了
感觉这几节课都有点水,就是给你介绍一下历史,国内的竞争者,但是对于他们的东西,这里的艺术性的分析,少得可怜,只有宏观分析,这玩意我还需要你来讲吗,我直接AI问一下得到的不比你还准确。
这节课唯一得到的内容大概就是知道了国内的布鲁可、铜师傅、INART,这三个公司,但是对于他们的说法和我的体感也有点出入。铜师傅的线下店,有点人逛,但是真正交易的很少很少,同样的这个铜师傅有n多工艺品或者类似名字的招牌或者店,这类的艺术品看似火爆,实则线下遇冷?
但是总体趋势还是显而易见的,你得有这个商业中的源头,IP,没有IP或者不能产生IP,那么你的持续发展的动力或者源头就有问题,但是这玩意对于普通艺术创作者有啥用?有多少人能打造出来自己的IP?
早期设计上的装饰品或者奢侈品总是繁复为主,简略的很少,能被保留下来的大概率都是过去的贵族,有钱人的东西,而这些人为了体现本身的阶级不同,这种加法性质的设计就能把生产力、权力凝结到这个器物上,从而体现他的不同。
这里或者之前的课程中唯伟和唯风都说过现在是由实转需的一个年代,这个论断还是有点早了,他们没有真的体验过VR,只是理解了这个产业里吹嘘出来的概念,不知道是什么制约了这个东西的发展。这个发展不是说你内容填充了,这个产业就有了,其实这个是背后的总体大社会或者说主流社会,主流阶级的水平上来了才有那么一点点可能。为啥是可能,因为这个产业里还有很多东西无法突破,这部分内容被基础科学制约了,电池、材料、物理,这部分基础科学无法突飞猛进,对应的产生或者生产力是无法转嫁到VR上的,即使基础科学突破了,那距离被应用到VR这种超级小领域上,也是要很久很久的。
过早的预判你是得不到正确结论的,刚好最近看过了一个大佬的记录,他16年就想着用AI来写代码了。16年,我才毕业,那会我连AI是啥我都不知道,我都没法想象AI能帮我写代码。他在当年就折腾了一下,但是实际效果就是达不到预期,这不仅仅是当年算法不够强,更是当年的算力,当年的成本比现在高的离谱。AI能写代码只是巧合,AI的底子是文本类型的,代码恰好也是,这只是他恰好的一个应用方向,但是你站在10年前你说你要做这么一个东西,少了这个大模型的基础,这事不可为,当年没有投资人相信他能成,也是正确的。
某些时候用户体验成立的根本是用户数量足够大,这种改进可以创造足够大的价值,数量过小的时候,这个体验的价值也会非常小
这节课的核心主要是介绍俞军-产品方法论,但是没想到最后的结论是反过来的,让大家学习一下反着做,这里有点新意吧。不过这些做产品的,多少都有点偏执,有点过度自信,特别是自己列公式啥的,一看就是初期产品干的事情,必须要找一点东西证明一下自己的理论是否正确。俞军实际在滴滴中更多的是建立和完善产品职能部门和一些分歧决策,实际上他本身对于业务线的接入应该是比较少的,大部分还是其他产品干的。至于视频里说的产品被拿去干这干那了,俞军要么是只当打地基的人,不理解业务,所以他不知道,要么就是他知道,但是他也无能为力,单纯进去的产品管理者,要服众还是非常困难的,特别是中间空降的。
最后落点还是回到了艺术家的小众市场,前面铺垫了这么多,最后落回了这里,我只能说这个前奏也太长了。讲了那么多瓷器、潮玩、设计发展史,实际上都是再说大众化的是什么样,而艺术家你大概率是走不了大众化的,因为如果你走大众化早就已经功成名就了,而不是还在这里听他讲课。
小众市场,本质上就是去满足那部分偏执或者观点特殊的人群,当决定了做小众市场的时候,就要放弃掉之前那些大众市场里的公约数或者说偏见。做高端,做小众,追求一种高价值的健康状态,放弃对无限增长的执念,接受市场的天花板,不做既要又要的事情。
小众市场天然就是在马斯洛需求层次理论的上三层。
艺术从业者有很强的艺术表达需求,这种东西很难被大众理解,天生就适合小众化的。当然任何小众化的东西如果你的市场是全人类或者小一点,全体男性或者什么的,算下来可能都是很大的一个范围。
以星露谷物语为例,巧了,这个项目我也非常了解,能做成这样的游戏,真的是非常难得一见的,独立游戏是在变好,但是不值得所有人去投入,他的市场规模还是太小了,他的受众就是我这种喜欢一个个精巧构思的人,但是独立游戏都有一个大问题,他不是适合所有人的,他的天花板非常有限,肉眼可见的,能做成星露谷这种行业顶级,把任何3A大作都能吊起来打的,这是奇迹级别,学习的意义真的不大。
第二个游戏,幸运猎人,巧了,我刚好玩过,而且还是demo,后续没有进我的关注和愿望单,原因很简单,demo过于粗糙,无论是动画还是机制都有点烂,玩通以后我都没打卡第二次的兴趣。刚好这里说了他是怎么做的,果然美术是外包的便宜货,不过也有可能我玩的demo刚好是他改版之前的。这个游戏实际销量应该也比较小,游戏内容偏少,至于这个作者说的,有点过于美化了,他的这些机制,所谓的节奏,就是难度曲线,在我看来烂的一坨。就这样这个游戏上线以后没有暴死,不到五百的评论,维持在了特别好评,估计是后续没人了,如果有的话这个平均应该比这个低很多。这个游戏他找了发行方,后续可能看到有点安卓手游的潜力,所以改去安卓再发行了,然后就变成了看广告收费?道具收费的游戏了
第三个游戏,偃武,有点无语,就这游戏,这流量,还点名率土之滨的改良,差的不是一点半点。

这游戏我都搜了半天,他的宣发在哪里啊,19年上线到现在都七年了,竟然还能活着,只能说舔大哥是真牛逼。听了半天,这游戏是买量的,就是牛皮癣小广告,我吐了,买量刚好懂一点点,这里面门道可深了,买量那个广告设计那可是都是靠钱砸出来的,能让你看到的广告基本都是钱砸出来的,从N多广告设计中留存下来的,确实是有一定的参考价值,但是你要说你对标这玩意做用户需求,我就有点无语了。听唯伟尬吹这个项目我是真的有点难受,有没有可能你觉得牛逼的地方,在大厂这都是基操中的基操了。
第四个游戏,烽火与炊烟,这个我也是被画风吸引的,游戏CG,吹得很厉害,今年就要发售了,但是这个游戏的可玩性到底在哪里,目前我还没看出来,由于游戏包含要素过多,他是不是好玩,很难评断出来,目前只能说体验有点新奇,还谈不上好玩。对应这个游戏的3D版本你可以认为是燕云十六州,他要能做到燕云这样的话,也能保住名声。根据采访稿,这个团队是新人,经验不是很足的那种,虽然夫妻俩做了4年多,然后拉了五十多人又弄了2年多?但是最终品质能否达到预期,我还是先怀疑一下。
最后是吹哥的游戏观点分享,挺好的,吹哥的观点和我很相似。
总体来说这个课不值得买,过于通识向了,只是对他之前 2 个课内容的一点补充。主要问题是讲得太浅,和他适配介绍的方法论比起来差距有点大。只能说讲了几个案例和可能的方向,至于方法论,我只能说都是常识与基操。只有你不是这个领域、也从来不关注这方面事情的人,才能有所收获。但凡你是想做这个方向或者想深入探索,这些内容大概率在平常生活里就已经接触到了,能听下来的我估计只有小白了。
艺术与审美,本来以为他是可以从新手的角度介绍并引导你入门的,没想到后面的内容完全是艺术家找不到门路了,于是去探索其他方向了,然后从其他方向把方法论或者什么东西借鉴过来再引回艺术界,是唯伟自身经历的一个中期总结吧,类似在布道自己的观点和方法论,到这里基本就是他目前学习经历的一切了。
最有用的大概就是第一阶段的课了。
https://mp.weixin.qq.com/s/cFCXRR-YtlO-3kXpVM23sw
2026-03-24 00:00:00
25年换的新主机,配的AOC显示器,这个显示器设计有点问题,它的音响在屏幕后、显示器支架的左右两侧,发声位置很奇怪。特别是当显示器不靠墙的时候,声音非常空洞,要听清就得开比较大的声音,再加上我又有点聋,用起来很难受。一直想弄个音响,但是没看到适合的,再加上又有一些环境问题,也不方便弄大的、多声道的,没想到意外刷到了一个视频,发现了一个有趣的产品。
https://www.cololight.com/
https://www.indiegogo.com/zh/projects/cololight/colo-gcs-the-world-s-first-7-1-2ch-speakers-for-pc/
这个项目最初是24年 Indiegogo 的众筹项目,成功了,没想到卖得还行,最近又出了新一代,稍微改进了一点。

第一个版本它是给电竞椅或者赛车桶椅做的,后来发现人体工学椅更多,于是又出了人体工学椅的适配版本。记得很久以前有人提过类似的产品或者DIY设计,但真正产品化的还真没有,COLOGCS确实是脑洞大开的产品,很有意思。
现在已经出到2代了,安装支架更新换代了,音响本身没啥大变化。这个形态的音响目前市面上只有这一款,找不到任何竞品,其他电竞音响或者 soundbar 之类的东西也没法跟它对比。
这玩意大得超乎想象,跟34寸显示器箱子差不多。实际开箱比较简单,就是主体 + 2个天空声道 + 若干线材和收纳捆带。
安装视频,安装以后大概就是这样:


安装以后主要就是电源线和 HDMI 线。HDMI 线需要连到显卡的 HDMI 接口上,这时发现我显卡只有一个 HDMI,导致要连电视就只能走 DP 了。
要使用全景声,不需要装任何驱动,但是要安装杜比全景声的软件。
https://www.microsoft.com/zh-cn/p/dolby-access/9n0866fs04w8
安装以后只需要开启家庭影院杜比全景声,再打开上混器就行了。

接着随便打开一个杜比音效的视频,就能听到效果了,非常震撼。
COLOGCS 本身也支持蓝牙模式,玩手机也能用,同时它也有麦克风接口:左侧是蓝牙模式的麦克风接入口,右侧是 HDMI 模式下的麦克风接入口。但是这个东西有个小问题,出声和麦克风在同一侧,这会导致麦克有回音而且很难消除,所以麦克风要怎么搭配它一起使用,还得找个解决方案。

虽然是7.1声道,但是缺少从人正前面传过来的声音,这个有点遗憾。如果能再利用一下原本的显示器或者音响,做个结合,就更好了。
固定的稳固程度还是有点松垮,整体结构太大了,你晃动椅子,他也跟着晃动,好处是你躺平,音响也跟随你躺平。
不同椅子的适配性不同,导致有可能你并不处于最好的声位,这个没有调节的可能,软调节也不行。
声音结像差一些,问题其实就是上面这些因素造成的。
便捷性差了点,还是挂了一根线,椅子不能无限转圈,会导致线缠绕到下面,平常用不乱转圈其实还是挺无感的。
有时候会偶尔出现没声音的情况,但是重新切一下声音又好了,有点像休眠,比较难复现。
对比显示器音响,音响就在耳边,确实很爽,不用开很大声音就听得很清楚,还有一些小问题,不是所有游戏都支持杜比全景声,还是挑游戏的。
在车展碰到的赛车模拟装备,基本全都配了 COLOGCS。就算日后 PC 这边淘汰了,也能转给模拟器的赛车桶椅使用,也不错。
2026-03-09 00:00:00
“烟花三月下扬州”,但我去的是公历 3 月,真不算个合适的日子,如果是四五月份来,体验应该会好不少。
三月的扬州温度比较低,大概 2–10℃,也就只有短暂的中午有阳光洒在身上时会觉得暖和一点。稍微来点小风就又冷飕飕的了,我是不会说主要是因为我穿得太少了,从深圳过来没带厚衣服,确实有点扛不住江南的阴冷。
扬州整体都是青砖石瓦的矮房子,很少高楼大厦那种现代建筑,街边随处可见的亭台楼阁,确实很江南。再加上这里文人墨客众多,稍微走几步就是某某的故居、某某的纪念馆。地处江南,冬天不会下雪,树木也都是绿色的,很是诗情画意。
刚好住在文昌阁附近,走路大概就五六十米的样子。文昌阁不大,也不高,而且在路中央,正常没办法走过去,不过这条路不一般。

国庆路,但是车道让位给了假山园林。是的,你没听错,故意抹去了两三个车道留给假山园林。基本从这里走过去就到了扬州的核心景点附近,算是景点入口了。

往前再走一点,路中心还有一个亭子,这个可以走过去——四望亭。比较有名的饭店怡园就在亭子后,以早茶出名,没想到广式早茶,扬州也有。

东关街是一大片老街区,就是印象中的江南水乡的样子,青砖石瓦。街道两旁免不了被商业化,但是从我的感觉来说,比深圳或者重庆等地方的古镇还是要好一些的。商业化程度不是那么高,不是千篇一律的臭豆腐、大鱿鱼、内蒙烤串,又或是各种本地特产、奶茶。这里的街区本质上还是为本地人服务的,并不会因为是景区就提价、专宰游客。
我是淡季加工作日去的,平日里人也不少,不过采耳、洗脚店的密度那是真的高,走几步就有一家。

八戒烤猪蹄是家网红店,刚好前面也出了点舆情,我去的时候基本没人。
刚开始还没感觉扬州有多少桥、多少河流,后面就发现那是真的多啊。

逸圃,门票 15,建议不要去。地方很小,其实就是以前有钱地主家的宅子。要说考究,真的一般,传统的风水、园林、造景我认为都有点不及格。

个园,门票 45。咋说呢,还是有点小,前面的祖屋没啥看头,主要看点是园林,园林这部分相对比较大,造景比逸圃强多了,能看出一点点文人雅士的情调了。
旺季会有千秋粉黛表演,大概就是吴侬软语、弹唱之类的表演
瘦西湖算是扬州最有名的景点了。我之前一直以为瘦西湖是小一点的西湖,实际上根本没啥“完整的大湖”,反倒是很多支流。古人的“二十四桥明月夜”,说的就是这里。
瘦西湖挺大,一般都是南门进来,但是我从北门进的,北门人比较少。越往南走人越多,实际很少人全逛完,大部分人半途就走了。
还好北门人少,下车的时候,小广场有个小栅栏,大概也就15cm高,一边回消息一边走路,脑子一抽遇到栅栏跳了一下,然后就被绊倒了,摔了个狗吃屎,整个人扑到在地上,超级尴尬,还好手机啥的没摔飞,就是身上一堆擦伤


亭台楼榭

瘦西湖有很多黑天鹅、绿头鸭,游客拔草喂,它们也吃,都是野生的,在扬州过冬。

柳岸垂荫

这时刚好是梅花开放的时节,其他花基本都谢了,整个瘦西湖是比较清新、素雅的

瘦西湖的商业化也是比较克制的,也有可能是淡季,出摊比较少。至少北门进来基本没啥商业化,走到南门附近才会多一些。也不像一些景区把摊位、各种大招牌都挂在景区内,风格很突兀。
在茶馆里,坐在窗边,喝个茶,看个景,还是挺不错的,单人茶价格也不是太逆天。
大运河博物馆需要预约,周一不开门。预约要提前好几天,否则根本约不到。淡季周末提前一天都不行,得提前 2 天。我是跑过去才发现进不去,很是尴尬,运营没有照顾线下的人群,没有线下票,这点就很难受。
围绕博物馆建起了一圈私房菜馆,周边也是水路区隔,整体建设也是仿照二十四桥的概念来的,什么铁索桥,木桥,赵州桥,每座桥的设计都不一样。

宋夹城就在瘦西湖边上,可以认为是个大型体育公园,是一座小围城,免费,主要是给本地人开放散步的。边上是绕城步道,凉风习习。
对比东关街,皮市街明显就是那种重度商业化以后的游客街了。皮市街稍微往前走一点就是何园,何园类似个园,感觉千篇一律,所以这次就没去了。
这里也需要预约,只能在外面看看,没啥特殊的,就是个小宅子而已。
扬州,淮扬菜,没啥特别的,烧饼挺多的,倒是挺好吃的。吃了一家很小的淮南牛肉汤,那个饼是真的脆,很多层,一口下去疯狂掉渣。
本地有名的饭店就是大毛,不过吃的内容一般般吧,没感觉有多特色,鱼头泡油条比较少见,味道嘛,我只记得油条比较好吃了。
扬州炒饭,我感觉不如纯蛋炒饭好吃,很普通,没啥香味,也没啥怪味,说是扬州刚好是苏南苏北中间,所以比较中庸一些,不甜。
扬州古城小巷里依然有很多人还住在这里,看起来是翻新过,但是并不是那种推倒重建的翻新,旧的东西还在。不像广东这边,古建筑基本给干没了,翻新以后直接没人住,都变成商业化的门面,翻新风格还是全国统一,真的没意思。
作为古城,人文气息确实丰富,很多东西小而精致,城建规划时依然保留了老城的精气神,很是难得,下次有机会去苏州再对比一下。
2026-02-25 00:00:00
之前实践了MCP、Skills,最近又出来了OpenClaw,大火了一把,再来看一下OpenClaw又能干些啥吧
OpenClaw之前叫ClawdBot,是单独对接Claude的,但是由于瞬间爆火,对于谷歌服务大量访问,再加上Logo稍微有点侵权,直接把对应使用者的账号全封禁了,自然也就停止对谷歌系的支持,后续Meta、OpenAI都在大力招揽OpenClaw的团队,对应OpenClaw的创始人Peter,Peter后来直接投奔OpenAI了,站在了Google系的对面。
Peter在OpenClaw之前已经是一个传奇人物了,之前是做PDF相关渲染的组件的,基本上所有移动端使用的都是他的组件,很是牛逼
Peter之前写的代码是那种锱铢必较的风格,反而是到了OpenClaw这里,看了对他的采访,他已经不在意代码细节了,全都给AI去写了,自己只管功能和效果是否正确了,走向了另外一个极端,稍微有点奇怪,不太符合人物弧光。
OpenClaw就是他做一个AI助手,本质上就是希望AI可以帮自己直接完成事情,至于中间的过程也完全交给AI自己去迭代自己,而不需要人工介入,类似Manus那种,但是工作范围更广。
OpenClaw能火,其实也是建立在之前的MCP、Skills之上的,如果没有前面的软件能力开放,那么这个AI助手也巧妇难为无米之炊。
在MCP时我就预言了日后大模型供应商就会提供入口把其他软件接入进来,这么久都没有几家做出来像样的东西。一直到OpenClaw,刚好切入了这个点,同时他又符合大家对于AI助理需求的一些点:
这是AI助理最基础几点要求,OpenClaw都满足了,再往大里说:
大模型从简单的语言对话,一下子跳跃到可以帮你直接干活了,不再拘泥于你要自己写好各种Skills、Prompt或者去调用MCP再完成一个任务了,从只能程序员使用或者有类似程序员能力的人使用,使用者一下扩展了很多,类似国内的这些模型,搞点奶茶、点外卖等等
OpenClaw从IM端入手,IM刚好就是人最多的,而不是类似大模型商,非要用户下一个新的APP,用他们的程序进入到AI助手的环境中,刚好保持了用户习惯,对于用户来说没有APP迁移和学习成本。

我们都知道和大模型交流要有上下文,而且如果上下文特别多的情况下,会导致很久之前的重要信息被顶替。而如果真的使用OpenClaw作为AI助理,那无可避免的是上下文可能会特别多,这种情况下就需要额外的一些办法,把AI和你的对话或者工作内容等等关键事项,作为智能体的记忆存储下来

并不是说有了OpenClaw,就什么都可以做了,本质上还是类似Skills,需要你建立对应的工作流,然后让AI助理自动进行一些定期任务
如果是IM接入,那么对应的,它内部也可以做一个CRM系统,记住你的社交关系等内容,帮你定时提醒维护客户关系或者开会、用户反馈等事宜

类似的知识库也能直接给OpenClaw去记录甚至分享给其他人,相当于内部构建一个RAG,他甚至和上面的内容结合以后就可以分享给你的同事、朋友,让助理帮你分享对应内容(有时候你自己想不起来具体细节)

对应的到软件工程这里,也可以构建一个类似的工作流,直接让他来完成工作内容,甚至我们啥都不用干都可以,举个例子:

只要用户issues提的比较完整,剩下靠AI自己去处理,那么很多简单的问题都可以快速处理完
这里就不在云上部署了,而是直接部署在本地,OpenClaw面向国外更多一些,这里和Telegram结合进行说明
下载代码
git clone https://github.com/openclaw/openclaw.git
cd openclaw
git checkout v2026.2.24
启动Docker编译脚本(其实没必要,直接拉云端最新的就行)
./docker-setup.sh
这里是官方一键启动的脚本,他会自动创建好,并且返回给你的登录地址和token,这里时gateway token,最好保留住,以后也有可能会用到
winpty docker compose run --rm openclaw-cli onboard
这里可能会出问题,建议用winpty包一层再执行onboard

需要你同意协议或者权限,这里注意是使用方向键确认,空格键选择,选好一些基础信息就可以启动了,这里选择用千问模型

选择对接的IM,可以看到竟然有飞书,企微、微信、钉钉什么的都还没有支持(可以通过插件支持)
这里选择Telegram,然后去Telegram与@BotFather对话,创建一个新的bot,并起名
/newbot
完成以后就拿到了Telegram的bot token,输回命令行。
后续是配置Skills还有一些基础功能,默认选择就行了
修改端口在openclaw的根目录下的 .env文件里
OPENCLAW_CONFIG_DIR=/c/Users/elmag/.openclaw
OPENCLAW_WORKSPACE_DIR=/c/Users/elmag/.openclaw/workspace
OPENCLAW_GATEWAY_PORT=28789
OPENCLAW_BRIDGE_PORT=28790
OPENCLAW_GATEWAY_BIND=lan
OPENCLAW_GATEWAY_TOKEN=9a40ed6a9e0e0fe01420dd676b273953bc2f7c47412e12a23adb8eb893c35380
OPENCLAW_IMAGE=openclaw:local
OPENCLAW_EXTRA_MOUNTS=
OPENCLAW_HOME_VOLUME=
OPENCLAW_DOCKER_APT_PACKAGES=
2026-02-25 20:42:21 2026-02-25T12:42:21.921+00:00 Gateway failed to start: Error: non-loopback Control UI requires gateway.controlUi.allowedOrigins (set explicit origins), or set gateway.controlUi.dangerouslyAllowHostHeaderOriginFallback=true to use Host-header origin fallback mode
启动以后还会遇到一个情况,说白了跨域访问不允许,所以需要增加一个controlUi的配置项,指明访问域
增加访问域的配置文件一般是在这个目录下,真实的token是存储在这个json中的,前面onboard的流程中的配置项都存储在这里
C:\Users\你的用户米\.openclaw\openclaw.json

"controlUi": {
"allowedOrigins": [
"http://127.0.0.1:28789",
"http://localhost:28789"
]
}
加上这部分配置以后就能正常访问了
正常启动以后默认地址是这里,然后登录进去就可以配置他了
http://127.0.0.1:18789/
第一次登录以后会提示离线、未授权

需要输入gateway的token以后,连接

接着看到提示需要配对,这里需要先查看OpenClaw内部被请求的设备是谁
docker compose exec openclaw-gateway sh -c "node /app/dist/index.js devices list --token 你gateway的token"
正常会返回类似这样的一个东西,这里的Request 就是我们要的东西
Pending (1)
┌──────────────────────────────────────┬──────────────────────────────────┬──────────┬────────────┬──────────┬────────┐
│ Request │ Device │ Role │ IP │ Age │ Flags │
├──────────────────────────────────────┼──────────────────────────────────┼──────────┼────────────┼──────────┼────────┤
│ 9656c4a9-9ac6-4672-9315-ce47db26555c │ 3515c8425c3786148673e3e6ec5bf9f3 │ operator │ 172.18.0.1 │ just now │ │
│ │ 1fa70ec8dcc55085ce6ab6b8c536b79d │ │ │ │ │
└──────────────────────────────────────┴──────────────────────────────────┴──────────┴────────────┴──────────┴────────┘
接着允许这个请求
docker compose exec openclaw-gateway sh -c "node /app/dist/index.js devices approve <Request ID> --token 你gateway的token"
然后就能看到我们的网关UI正常匹配了,可以直接通过这个页面进行聊天

同理,此时和Telegram的机器人对话会提示要求配对

配对需要执行Cli的配对命令
docker compose run --rm openclaw-cli pairing approve Telegram NXYWNNRU
Approved Telegram sender 457350036.
正确以后会提示Approved的信息,到这里Telegram的bot就能正常工作了

刚开始初始化阶段需要给他一个名称和提供一些基础信息,然后可以看到私人助理此时已经正常运行了。
剩下你就可以把工作流或者一些要他做的事情通过对话的方式告诉他,让他执行了
简单工作流,让助手提醒我写Blog

可以看到OpenClaw在我没做任何配置的情况下,就可以理解自然语言的任务,并且根据这个任务进行几轮迭代,然后把最优的结果输出给你
剩下你就可以发挥自己的想象力了,把很多东西扔给他去做
网关页面的聊天,同样正常可以操作了


如果直接用windows git bash执行会出现无法选择yes或者no,下面的QuickStart也不能选择,这里最好用winpty包一下再执行,这样交互UI就正常了
winpty docker compose run --rm openclaw-cli onboard
启动以后提示,要么是端口被占用,要么是端口就不允许用
Error response from daemon: Ports are not available: exposing port TCP 0.0.0.0:18790 -> 127.0.0.1:0: listen tcp 0.0.0.0:18790: bind: An attempt was made to access a socket in a way forbidden by its access permissions.
端口被占用比较好解决,看哪个程序然后把他干掉就行了。
但是端口不允许用,我第一次碰到,windows下查看保留端口,就能看到刚好把18790这个附近的端口保留了
netsh int ipv4 show excludedportrange protocol=tcp
协议 tcp 端口排除范围
开始端口 结束端口
---------- --------
5357 5357
17945 18044
18045 18144
18145 18244
18245 18344
18345 18444
18445 18544
18545 18644
18645 18744
18745 18844
50000 50059 *
要么去掉这里的端口,要么在openclaw里选择manual手动配置,然后改这个端口号

当发现这个问题的时候,推荐修改.env文件内的端口号
如果使用类似Telegram的方式操作容器内部的openclaw获取设备列表会报错,这个是因为内部设备列表的路径里没加上openclaw的环境变量
$ docker compose exec openclaw-gateway openclaw devices list
OCI runtime exec failed: exec failed: unable to start container process: exec: "openclaw": executable file not found in $PATH: unknown
所以实际操作的时候直接用调用node去操作了,而不是用openclaw
gateway connect failed: Error: unauthorized: gateway token mismatch (set gateway.remote.token to match gateway.auth.token)
[openclaw] CLI failed: Error: gateway closed (1008): unauthorized: gateway token mismatch (set gateway.remote.token to match gateway.auth.token)
Gateway target: ws://127.0.0.1:18789
Source: local loopback
Config: /home/node/.openclaw/openclaw.json
Bind: lan
at Object.onClose (file:///app/dist/call-CmI9Jmad.js:444:10)
at WebSocket.<anonymous> (file:///app/dist/client-Bri_7bSd.js:2128:23)
at WebSocket.emit (node:events:519:28)
at WebSocket.emitClose (/app/node_modules/.pnpm/[email protected]/node_modules/ws/lib/websocket.js:273:10)
at Socket.socketOnClose (/app/node_modules/.pnpm/[email protected]/node_modules/ws/lib/websocket.js:1346:15)
at Socket.emit (node:events:519:28)
at TCP.<anonymous> (node:net:346:12)
出现这个错误是因为你在宿主机器上访问容器内的OpenClaw时,他认为你是个外人,不认识你,所以需要你附带操作的token才允许你交互
OpenClaw用他自己的一键Docker配置反而整的更麻烦了,特别是他的gateway竟然和OpenClaw是分离的两个东西,要单独授权,这个就有点太麻烦了。
Skills中就已经可以搭建基础工作流了,只是这个工作流触发或者执行时还是有很多地方需要人工审核或者启动,而OpenClaw相当于是一个For循环,允许这个东西自动工作了,权限更大,甚至能力也可以自动补充,而不需要Skills里面给他一个个增加这种能力,确实更进一步了。
但是在OpenClaw这么强大的同时,也要谨慎,因为他的权限过大,很容易在AI理解出错的情况下,做出来一些很奇葩的事情,比如直接给你东西删除了,覆盖了,或者公开了什么私密的东西。
同时看似OpenClaw轻而易举的就完成了某些事情,但是也要注意到实际后面的token消耗,由于我只是体验,走的本身是千问的免费token,实际这个任务量大了以后根本顶不住

这里看到一次消耗了10-15k tokens,这已经非常夸张了,如果你自己写个skill,把刚才对应的能力写成代码,估计用不了这么多
同样的由于OpenClaw可以直接接入IM工具,最好是给他一个新的账号类似TG的bot,而不是把你自己的账号给了OpenClaw。
在Windows下用Docker部署还是有点太绕了,建议还是直接部署到云服务或者内网的其他机器上去,Windows的部署体验还是比较繁琐,要解决的问题一个接一个。或者是直接原生部署,不要用Docker,这样就不存在OpenClaw和Gateway还互相不认识的情况了
目前比较划算的部署方式就是利用一些比较大的订阅服务,持续消费token,而不是买token,大工程消耗量有点惊人
看到这个助理还支持米家的大模型了,可惜米家的开放性还是差了一点,如果是用HA的话,直接可以让助理接入家庭,作为家庭管家,然后你就可以让管家自己操作对应空间内的智能家居了,其实还是挺好的,直接跳过那一堆设备联动,让管家自己来判断和操作即可
https://www.v2ex.com/t/1193787#reply19
https://www.ifanr.com/1655353?utm_source=rss&utm_medium=rss&utm_campaign=
https://www.youtube.com/watch?v=8kNv3rjQaVA
https://docs.openclaw.ai/start/getting-started