Walk detection in rides: colour it, keep it out of the ridden roads
Stage B of the ridden-roads work. GPS.detectWalk() flags contiguous low-speed runs (< walkDetectKmh, or the single-speed 30-rpm push speed, for >= walkMinRunSec) as walking inside a bike/other ride. Persisted per point (gps_points.walk, via DB.setWalkFlags in recalculateSession + recomputeAllStats). rebuild_roads maps a walk point to atype 'walk' so it colours as walking and is not treated as a rideable road. Track detail colours walk runs and shows 'пешком: N мин / X км'. Verified detectWalk on real tracks: 98% on a mostly-pushed 4km ride vs 1% on a normal 16km ride.
This commit is contained in:
parent
abdcb94f55
commit
c564c419db
7 changed files with 164 additions and 1 deletions
|
|
@ -22,6 +22,7 @@
|
|||
- **Заполнение GPS-пропусков по дороге** — на детали трека кнопка **«🧩»** прокладывает дорожный путь (BRouter, профиль shortest) через каждый GPS-дропаут (разрыв по времени + реальная хорда) и показывает **предпросмотр только достроенных пропусков** (голубым, с бело-чёрной оковкой; недостроенные — красным пунктиром) плюс панель «Достроено N (+X км), M не удалось» с кнопками **Сохранить/Отмена**. Сохранение — **обратимый патч на месте**: достроенные точки помечаются `estimated` и добавляются в трек (оригинал цел), статистика пересчитывается; повторный тап предлагает **«Убрать достройку»** (удаляет только достроенное). Достраиваются только реальные длинные потери сигнала (по умолчанию разрыв > 60с и хорда > 150м); пропуск не достраивается, если дорога заметно длиннее хорды (крюк, > 2×) или почти совпадает с прямой (мостить нечего) — чтобы не перерисовывать нормальные участки объездами.
|
||||
- **Непроезжие места (nogo)** — красные круги, которые роутинг ОБЪЕЗЖАЕТ. Ставятся кнопкой **⛔**: в планировщике — тапом по карте/линии, на карте записи — «здесь нет проезда» (текущее место). Показываются на картах планировщика, записи и детали трека; тап по кругу — убрать. Избегание работает и через домашний RouteServer, и встроенным движком офлайн (BRouter nogopoints). Web-BRouter умеет nogo сам.
|
||||
- **Объезд с возвратом на маршрут (на ходу)** — на карте записи кнопка **«↪»**: если активный маршрут ведёт через непроезжее место, строит от текущей позиции объезд обратно НА маршрут за местом затыка (отступ ~1.5 км вперёд, растёт при неудаче до конца маршрута), избегая nogo и разворота на 180° (учитывает курс). Показывается ярким навигационным оверлеем (не меняет сохранённый маршрут и не прерывает запись); долгий тап по кнопке убирает оверлей.
|
||||
- **Детект ходьбы в вело-треке** - устойчиво низкая скорость (по умолчанию < 8 км/ч дольше 60с; порог уточняется по передаче для односкоростного) в вело/другой сессии = ведёшь/идёшь пешком. Такие участки красятся отдельно (цвет ходьбы) на детали трека, в статах показывается «пешком: N мин / X км», и они НЕ попадают в «проезженные дороги» (не строятся по ним). Флаг хранится по точкам (`gps_points.walk`), пересчитывается на «Пересчитать»/«Пересчитать всё».
|
||||
- **Похвастаться (шэр-картинка)** — в детали трека кнопка **«Похвастаться»**: генерирует картинку в формате сторис (9:16) с формой трека и статами (Дистанция/Время/Средняя скорость крупно + набор/ккал/дата), **прозрачную** (PNG с альфой для наложения на своё фото) или **на фото-подложке** из настроек. Модалка предпросмотра с переключателем и кнопками **Поделиться** (системный шэр) и **Сохранить** (SAF). Подложка выбирается в Настройках.
|
||||
- **Де-джиттер трека** — сглаживание дрожащего GPS-трека методом Савицкого-Голея (настраиваемое окно/число итераций, живое превью на карте детали трека). Хранится обратимо: оригинал и сглаженный вариант лежат раздельно, калории пересчитываются по сглаженным точкам, откат к оригиналу в один тап. Точность каждого фикса (`accuracy_m`) пишется в БД: при пересчёте трека точки хуже порога «Макс. погрешность GPS» исключаются из расчёта и линии (обратимо — точки в БД остаются; поднять порог = вернуть). Работает во всех пересчётах (кнопка «Пересчитать», «Пересчитать всё», после де-джиттера); импорт/старьё без точности и dead-reckoning заполнения не трогает.
|
||||
- **POI на карте** — точки интереса (кафе, магазины, вода, туалеты, банки/ATM, остановки, укрытия, смотровые и т.д.) круглыми контрастными значками (эмодзи в кружке, читаемо на светлой и тёмной карте). Кнопка `📍` с панелью-фильтром категорий; рендер по видимой области от зума 13. Данные оффлайн (`data/pois.json`, ~100 км вокруг опорной точки, 5662 объекта из OSM/Overpass). На картах записи, деталей трека и дорог.
|
||||
|
|
|
|||
37
docs/plans/2026-09-14-ridden-roads-walk.md
Normal file
37
docs/plans/2026-09-14-ridden-roads-walk.md
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
# План: детект ходьбы (b) + предпочтение проезженных дорог (a)
|
||||
|
||||
## Context
|
||||
Маршруты из BRouter не совпадают с реальностью (роутит по несуществующим дорогам). Нужно:
|
||||
(a) при построении предпочитать уже проезженные дороги, к публичным данным (BRouter) обращаться
|
||||
только там, где проезженного нет; (b) в вело-треке детектить пешие куски по скорости, красить
|
||||
их отдельно и не считать «дорогой» (по пашне не проедешь). Решения (согласовано): обе сразу;
|
||||
детект ходьбы по скорости (универсально) + порог по передаче; окраска + исключение из дорог +
|
||||
опционально стат.
|
||||
|
||||
## Стадия B - детект ходьбы (сделано)
|
||||
- `GPS.detectWalk(points, opts)` (gps.js): sm-сглаженная скорость ниже порога дольше
|
||||
`walkMinRunSec` = ходьба. Порог = 30-rpm по передаче (односкоростной) или `walkDetectKmh`.
|
||||
- Персист `gps_points.walk` (`DB.setWalkFlags`); считается в `recalculateSession` и
|
||||
`recomputeAllStats`.
|
||||
- Rust `rebuild_roads`: walk-point -> atype 'walk' (CASE в SQL) -> в оверлее цвет ходьбы,
|
||||
не вело-дорога.
|
||||
- Деталь трека: пешие runs красятся `roadColorWalk`; стат «пешком: N мин / X км».
|
||||
|
||||
## Стадия A - роутер по проезженным дорогам (план)
|
||||
- Rust `get_ride_graph(min_speed)`: читает gps_points (исключая `walk=1`), снапит узлы на сетку
|
||||
~20м, рёбра между последовательными точками сессии (вес = расстояние), возвращает компактный
|
||||
граф JSON {nodes:[{id,lat,lon}], edges:[[a,b,dist]]}. Кэш в JS (перестраивать по запросу).
|
||||
- JS: индекс ближайшего узла (grid hash), Dijkstra между узлами у концов ноги.
|
||||
- Планировщик `showRouteEditor.routeDirty`: для дорожной ноги сначала пробовать ридден-граф
|
||||
(если оба конца в ~60м от узла и путь не длиннее ~2.5× прямой) - использовать его; иначе
|
||||
BRouter (+nogos). Ридден-путь отличать визуально (или пометкой источника).
|
||||
|
||||
## Файлы
|
||||
- `frontend/js/gps.js` (detectWalk, хуки), `frontend/js/db.js` (walk column, setWalkFlags),
|
||||
`frontend/js/main.js` (окраска, стат; Стадия A - роутер + интеграция в планировщик),
|
||||
`src-tauri/src/lib.rs` (rebuild_roads walk; Стадия A - get_ride_graph).
|
||||
|
||||
## Верификация
|
||||
- B: `detectWalk` харнесс (98% на пешем треке vs 1% на нормальном); визуал окраски на теле.
|
||||
- A: на десктопе - планировщик по знакомому месту идёт по проезженному, а не по фейковой дороге;
|
||||
где не ездил - BRouter. Затем тело (встроенный движок), пересборка APK.
|
||||
30
docs/session-logs/2026-09-14_ridden-roads-walk.md
Normal file
30
docs/session-logs/2026-09-14_ridden-roads-walk.md
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# Сессия 2026-09-14 - детект ходьбы + предпочтение проезженных дорог
|
||||
|
||||
## Контекст
|
||||
Маршруты из BRouter не совпадают с землёй. Две задачи (согласовано - обе сразу):
|
||||
(a) при построении предпочитать уже проезженные дороги, к публичным данным - только в пробелах;
|
||||
(b) в вело-треке детектить пешие куски (по скорости), красить их и не строить по ним
|
||||
(по пашне не проедешь).
|
||||
|
||||
План: `docs/plans/2026-09-14-ridden-roads-walk.md`. Детект ходьбы - по скорости (универсально),
|
||||
порог уточняется по передаче; окраска на треке + исключение из дорог + опционально стат.
|
||||
|
||||
## Сделано - Стадия B (детект ходьбы: цвет + исключение из дорог)
|
||||
- `GPS.detectWalk(points, opts)` (gps.js): contiguous runs со сглаженной скоростью ниже порога
|
||||
(`walkDetectKmh` 8 км/ч, или скорость 30-rpm по передаче для односкоростного) дольше
|
||||
`walkMinRunSec` (60с) = ходьба. Возвращает булев массив по точкам.
|
||||
- Персист: колонка `gps_points.walk`; `DB.setWalkFlags`; считается и сохраняется в
|
||||
`recalculateSession` и в `recomputeAllStats` (backfill по «Пересчитать всё»).
|
||||
- Rust `rebuild_roads`: пеший point -> atype 'walk' (в SQL CASE), значит в оверлее «дорог»
|
||||
красится цветом ходьбы и не считается вело-дорогой.
|
||||
- Деталь трека: пешие участки красятся `roadColorWalk` поверх трека; в статах «пешком: N мин / X км».
|
||||
|
||||
## Проверка B
|
||||
- Синтаксис OK. `detectWalk` харнессом на реальных треках: почти-пеший (session 1782752407585,
|
||||
4018м) - 98% точек пешие («пешком 60 мин, 3.89 км»); нормальный 16км - 1% (не пеший). Различает.
|
||||
- Визуал окраски + Rust rebuild_roads с walk - на десктопе/теле (нужна пересборка для движка;
|
||||
на десктопе rebuild_roads Rust работает).
|
||||
|
||||
## Дальше - Стадия A (роутер по проезженным)
|
||||
- Rust `get_ride_graph` (узлы на сетке ~20м, рёбра из последовательных не-walk точек сессий,
|
||||
исключая walk=1) -> JS Dijkstra; планировщик: сначала ридден-граф, потом BRouter в пробелах.
|
||||
|
|
@ -810,6 +810,18 @@ async function revertGapFill(sessionId) {
|
|||
await execute("UPDATE gps_sessions SET gap_filled=0 WHERE id=?1", [sessionId]);
|
||||
}
|
||||
|
||||
// Persist the per-point "walking" flag (walkIds = point ids in walking runs). Used by the
|
||||
// track colour, the ridden-roads overlay (rebuild_roads) and the ride-graph router.
|
||||
async function setWalkFlags(sessionId, walkIds) {
|
||||
await execute("UPDATE gps_points SET walk=0 WHERE session_id=?1", [sessionId]);
|
||||
let CHUNK = 200;
|
||||
for (let i = 0; i < walkIds.length; i += CHUNK) {
|
||||
let ch = walkIds.slice(i, i + CHUNK);
|
||||
let ph = ch.map((_, k) => "?" + (k + 1)).join(",");
|
||||
await execute(`UPDATE gps_points SET walk=1 WHERE id IN (${ph})`, ch);
|
||||
}
|
||||
}
|
||||
|
||||
async function finishGpsSession(id, endTime, distM, kcal, avgSpd, maxSpd, elevGain, elevLoss, pauses) {
|
||||
// Guard against NaN — SQLite may reject or corrupt NaN values
|
||||
const safe = v => (v != null && !isNaN(v)) ? v : null;
|
||||
|
|
@ -1280,6 +1292,7 @@ window.DB.deleteGpsSession = deleteGpsSession;
|
|||
window.DB.importGpxSession = importGpxSession;
|
||||
window.DB.saveGapFill = saveGapFill;
|
||||
window.DB.revertGapFill = revertGapFill;
|
||||
window.DB.setWalkFlags = setWalkFlags;
|
||||
|
||||
async function findNearestAltitude(lat, lon, radiusM) {
|
||||
const dLat = radiusM / 111000;
|
||||
|
|
@ -1502,6 +1515,7 @@ async function runMigrations() {
|
|||
if (!gpsCols.includes("lat_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lat_smoothed REAL");
|
||||
if (!gpsCols.includes("lon_smoothed")) await execute("ALTER TABLE gps_points ADD COLUMN lon_smoothed REAL");
|
||||
if (!gpsCols.includes("estimated")) await execute("ALTER TABLE gps_points ADD COLUMN estimated INTEGER DEFAULT 0"); // gap-fill (BRouter) points, reversible
|
||||
if (!gpsCols.includes("walk")) await execute("ALTER TABLE gps_points ADD COLUMN walk INTEGER DEFAULT 0"); // point is in a "walking" run inside a ride (excluded from ridden roads)
|
||||
|
||||
// --- Cooking methods table ---
|
||||
await execute(`CREATE TABLE IF NOT EXISTS cooking_methods (
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ GPS.config = {
|
|||
altSmoothHalf: 2, // полуокно сглаживания высоты (точек). 2 = 5pt, 5 = 11pt, 7 = 15pt
|
||||
walkMaxSpeedKmh: 12, // макс. скорость ходьбы/бега (выше = глитч GPS)
|
||||
walkMinSpeedMs: 0.3, // мин. скорость ходьбы (ниже = стоит). 0.3 м/с = 1 км/ч
|
||||
walkDetectKmh: 8, // в вело-треке устойчивая скорость ниже = ведёшь/идёшь (не едешь)
|
||||
walkMinRunSec: 60, // минимум длины пешего куска, чтобы засчитать (не притормозил у столба)
|
||||
bikeAvgSpeedKmh: 17, // средняя скорость вело (для ручных маршрутов)
|
||||
walkAvgSpeedKmh: 5, // средняя скорость ходьбы
|
||||
runAvgSpeedKmh: 10, // средняя скорость бега
|
||||
|
|
@ -50,6 +52,45 @@ try {
|
|||
}
|
||||
} catch {}
|
||||
|
||||
// Detect "walking" runs inside a ride (pushing the bike / on foot across a field): contiguous
|
||||
// runs where the smoothed speed stays below a threshold for at least walkMinRunSec. The
|
||||
// threshold is the single-speed push speed when gear data is given (30-rpm cadence), else
|
||||
// config.walkDetectKmh — universal by speed, refined by gear. Points are assumed time-ordered
|
||||
// (as read from DB). Returns a boolean array parallel to `points` (true = walking).
|
||||
GPS.detectWalk = function(points, opts) {
|
||||
opts = opts || {};
|
||||
let n = points ? points.length : 0;
|
||||
let out = new Array(n).fill(false);
|
||||
if (n < 3) return out;
|
||||
let thrMs;
|
||||
let hasGear = opts.chainringTeeth && opts.cogTeeth && opts.wheelDiameterInch;
|
||||
if (hasGear && (opts.gearCount == null || opts.gearCount <= 1)) {
|
||||
let circ = Math.PI * opts.wheelDiameterInch * 0.0254, ratio = opts.chainringTeeth / opts.cogTeeth;
|
||||
thrMs = (30 * circ * ratio) / 60;
|
||||
} else {
|
||||
thrMs = (GPS.config.walkDetectKmh ?? 8) / 3.6;
|
||||
}
|
||||
let minRun = opts.walkMinRunSec ?? (GPS.config.walkMinRunSec ?? 60);
|
||||
let v = new Array(n).fill(0);
|
||||
for (let i = 1; i < n; i++) {
|
||||
let d = GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon);
|
||||
let dt = (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000;
|
||||
let sp = points[i].speed_ms;
|
||||
v[i] = (sp != null && sp >= 0) ? sp : (dt > 0 ? d / dt : 0);
|
||||
}
|
||||
let sm = v.map((_, i) => { let s = 0, c = 0; for (let j = Math.max(0, i-2); j <= Math.min(n-1, i+2); j++) { s += v[j]; c++; } return c ? s / c : 0; });
|
||||
let i = 1;
|
||||
while (i < n) {
|
||||
if (sm[i] > 0.15 && sm[i] < thrMs) { // > 0.15 m/s excludes standing still
|
||||
let j = i, dur = 0;
|
||||
while (j < n && sm[j] > 0.15 && sm[j] < thrMs) { dur += Math.max(0, (new Date(points[j].ts) - new Date(points[j-1].ts)) / 1000); j++; }
|
||||
if (dur >= minRun) for (let k = i - 1; k < j; k++) out[k] = true;
|
||||
i = j;
|
||||
} else i++;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
// One-time migration: accuracy gate is now a HARD reject (coarse cell/wifi fixes
|
||||
// "sit at the tower"). Bump the old default 20m -> 50m so weak but real GPS survives.
|
||||
// Only touch the value if it's still the old default; respect a user-set threshold.
|
||||
|
|
@ -1733,6 +1774,16 @@ GPS.recalculateSession = async function(sessionId, opts) {
|
|||
const invoke = (q, v) => window.__TAURI__.core.invoke("plugin:sql|execute", { db: "sqlite:baldav.db", query: q, values: v });
|
||||
const sorted = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
||||
|
||||
// Persist the per-point "walking" flag (pushed bike / on foot inside a ride) for the roads
|
||||
// overlay and the ridden-graph router. Not for walk/run sessions (all on foot already).
|
||||
if (session.activity_type !== "walk" && session.activity_type !== "run") {
|
||||
try {
|
||||
const wf = GPS.detectWalk(sorted);
|
||||
const ids = sorted.filter((p, i) => wf[i] && p.id != null).map(p => p.id);
|
||||
await DB.setWalkFlags(sessionId, ids);
|
||||
} catch (e) { console.warn("[walk] persist failed", e); }
|
||||
}
|
||||
|
||||
// Manual routes: skip glitch filtering (points are user-placed, not GPS).
|
||||
// Just fix timestamps and recompute stats.
|
||||
if (session.source === "manual") {
|
||||
|
|
@ -1959,6 +2010,14 @@ GPS.recomputeAllStats = async function(onProgress) {
|
|||
});
|
||||
await DB.finishGpsSession(sid, session.end_time ?? session.start_time,
|
||||
stats.distance, stats.kcal, stats.avgSpeed, stats.maxSpeed, stats.elevGain, stats.elevLoss, bulkPauses);
|
||||
// Persist the per-point "walking" flag too (roads overlay + ridden-graph router).
|
||||
if (session.activity_type !== "walk" && session.activity_type !== "run") {
|
||||
try {
|
||||
const sw = points.slice().sort((a, b) => new Date(a.ts) - new Date(b.ts));
|
||||
const wf = GPS.detectWalk(sw);
|
||||
await DB.setWalkFlags(sid, sw.filter((p, k) => wf[k] && p.id != null).map(p => p.id));
|
||||
} catch (e) { console.warn("[walk] persist failed", sid, e); }
|
||||
}
|
||||
updated++;
|
||||
} catch (e) { console.error(`[recalc-all] ${sid} failed`, e); skipped++; }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5067,6 +5067,7 @@ async function showSessionDetail(sessionId) {
|
|||
<div class="sd-stat"><span class="sd-val">${stats.avgSpeed} км/ч</span><span class="sd-lbl">Средняя</span></div>
|
||||
<div class="sd-stat"><span class="sd-val" id="sd-max-speed">${stats.maxSpeed} км/ч</span><span class="sd-lbl">Макс.</span></div>
|
||||
</div>
|
||||
<div id="sd-walk-stat" class="prod-hint" style="text-align:center;margin-bottom:6px"></div>
|
||||
<div class="chart-wrap" id="sd-speed-wrap"><canvas id="sd-speed-chart" height="150" style="touch-action:pan-y"></canvas></div>
|
||||
</div>
|
||||
|
||||
|
|
@ -5437,6 +5438,24 @@ async function showSessionDetail(sessionId) {
|
|||
flush();
|
||||
}
|
||||
|
||||
// Colour "walking" runs inside a ride (pushed bike / on foot across a field) so they stand
|
||||
// apart from riding; also report the pushed total. Walk/run sessions are all on foot already.
|
||||
if (session.activity_type !== "walk" && session.activity_type !== "run") {
|
||||
let walk = GPS.detectWalk(points);
|
||||
let run = [], flush = () => { if (run.length >= 2) L.polyline(run, { color: GPS.config.roadColorWalk || "#8B5E3C", weight: 6, opacity: 0.95 }).addTo(map); run = []; };
|
||||
let wSec = 0, wDist = 0;
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
if (walk[i]) {
|
||||
if (!run.length && i > 0) run.push([points[i-1].lat, points[i-1].lon]);
|
||||
run.push([points[i].lat, points[i].lon]);
|
||||
if (i > 0) { wSec += Math.max(0, (new Date(points[i].ts) - new Date(points[i-1].ts)) / 1000); wDist += GPS.distance(points[i-1].lat, points[i-1].lon, points[i].lat, points[i].lon); }
|
||||
} else if (run.length) { run.push([points[i].lat, points[i].lon]); flush(); }
|
||||
}
|
||||
flush();
|
||||
let ws = document.querySelector("#sd-walk-stat");
|
||||
if (ws && wSec >= 60) ws.textContent = "пешком: " + Math.round(wSec / 60) + " мин · " + (wDist / 1000).toFixed(2) + " км";
|
||||
}
|
||||
|
||||
// Invisible but tappable hit-targets on top of track
|
||||
for (let i = 0; i < points.length; i++) {
|
||||
let p = points[i];
|
||||
|
|
|
|||
|
|
@ -264,8 +264,11 @@ fn rebuild_roads(app: tauri::AppHandle, min_speed_ms: f64) -> Result<String, Str
|
|||
let d_lat: f64 = 0.000018;
|
||||
let d_lon: f64 = 0.000027;
|
||||
|
||||
// A "walking" point inside a ride (walk=1) is classified as walk, not the session's
|
||||
// activity — so pushed-across-a-field bits don't become a rideable road.
|
||||
let mut read_stmt = db.prepare(
|
||||
"SELECT p.lat, p.lon, s.activity_type FROM gps_points p
|
||||
"SELECT p.lat, p.lon, CASE WHEN COALESCE(p.walk,0)=1 THEN 'walk' ELSE s.activity_type END
|
||||
FROM gps_points p
|
||||
JOIN gps_sessions s ON s.id = p.session_id
|
||||
WHERE p.speed_ms >= ?1
|
||||
ORDER BY p.session_id, p.seq"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue