From f12fef855d87fab078bdb4df97b69717ac275f3b Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sun, 10 May 2026 01:38:48 +0700 Subject: [PATCH] fix(mapillary): port viguessr random-point + per-city delta strategy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces adaptive shrink/grow bbox loop with viguessr's proven approach: random point inside city bbox → fixed-size sub-bbox of side 2*delta → re-roll position on empty/error. Small fixed bbox keeps Mapillary query cost under their 'reduce data' cap; dart-throw eventually lands on a pano-rich window. - src/lib/game.js: add per-city mapillaryDelta (HN=0.003, others=0.005) and export cityDeltas alongside existing cityBboxes - src/lib/mapillary.js: rewrite fetchMapillaryImages(bbox, delta) with 10-attempt position-only retry; revert to thumb_original_url; limit=3 - src/app/api/new-game/route.js: pass cityDeltas[cityCode] to the fetcher Source: https://github.com/luuvanduc1999/viguessr (assets/js/game.js) --- ...se-01-per-city-delta-random-point-fetch.md | 176 ++++++++++++++++++ .../plan.md | 94 ++++++++++ src/app/api/new-game/route.js | 19 +- src/lib/game.js | 11 ++ src/lib/mapillary.js | 128 ++++++------- 5 files changed, 346 insertions(+), 82 deletions(-) create mode 100644 plans/260510-0140-port-viguessr-mapillary/phase-01-per-city-delta-random-point-fetch.md create mode 100644 plans/260510-0140-port-viguessr-mapillary/plan.md diff --git a/plans/260510-0140-port-viguessr-mapillary/phase-01-per-city-delta-random-point-fetch.md b/plans/260510-0140-port-viguessr-mapillary/phase-01-per-city-delta-random-point-fetch.md new file mode 100644 index 0000000..5fc275f --- /dev/null +++ b/plans/260510-0140-port-viguessr-mapillary/phase-01-per-city-delta-random-point-fetch.md @@ -0,0 +1,176 @@ +--- +phase: 1 +title: "Per-city Delta + Random-Point Fetch" +status: pending +priority: P1 +effort: "30m" +dependencies: [] +--- + +# Phase 1: Per-city Delta + Random-Point Fetch + +## Overview + +Replace the adaptive bbox loop in `src/lib/mapillary.js` with viguessr's random-point dart-throw using a per-city fixed `delta`. Add `mapillaryDelta` to each `CITIES` entry. Update `new-game` route to pass delta and use `thumb_original_url`. + +## Related Code Files + +- Modify: `src/lib/game.js` — add `mapillaryDelta` field on each `CITIES` entry; export `cityDeltas` +- Rewrite: `src/lib/mapillary.js` — random-point + fixed-delta + retry-position +- Modify: `src/app/api/new-game/route.js` — import `cityDeltas`, pass to fetch, switch back to `thumb_original_url` + +## Implementation Steps + +### 1. `src/lib/game.js` + +Inside each `CITIES.` object, add a numeric field `mapillaryDelta`: + +```js +HN: { ..., mapillaryDelta: 0.003 }, +TPHCM: { ..., mapillaryDelta: 0.005 }, +DL: { ..., mapillaryDelta: 0.005 }, +DH: { ..., mapillaryDelta: 0.005 }, +DN: { ..., mapillaryDelta: 0.005 }, // disabled but populated +``` + +Below the existing `cityBboxes` export, add: + +```js +export const cityDeltas = Object.fromEntries( + Object.values(CITIES).map(city => [city.code, city.mapillaryDelta]) +); +``` + +### 2. `src/lib/mapillary.js` (full rewrite) + +```js +// Mapillary API utilities +// +// Strategy ported from viguessr (https://github.com/luuvanduc1999/viguessr): +// pick a random point inside the city bbox, query a small fixed-size sub-bbox +// (side = 2*delta) centered on that point, and re-roll the point on empty +// results or transient 5xx. The fixed small size keeps Mapillary query cost +// below their cap; the dart-throw eventually lands on a pano-rich window. + +const MAX_RETRIES = 10; + +export async function fetchMapillaryImages(bbox, delta) { + const accessToken = process.env.MAPILLARY_ACCESS_TOKEN; + if (!accessToken) { + throw new Error('MAPILLARY_ACCESS_TOKEN environment variable is not set'); + } + + const [minLng, minLat, maxLng, maxLat] = bbox; + let lastError = null; + + for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { + const lat = Math.random() * (maxLat - minLat) + minLat; + const lng = Math.random() * (maxLng - minLng) + minLng; + const queryBbox = [ + (lng - delta).toFixed(4), + (lat - delta).toFixed(4), + (lng + delta).toFixed(4), + (lat + delta).toFixed(4), + ].join(','); + const apiUrl = `https://graph.mapillary.com/images?access_token=${accessToken}&fields=id,thumb_original_url,geometry,is_pano&limit=3&bbox=${queryBbox}&is_pano=true`; + + try { + const response = await fetch(apiUrl, { + headers: { Accept: 'application/json' }, + next: { revalidate: 0 }, + }); + + if (response.status === 401) { + throw new Error('Mapillary authentication failed'); + } + + if (!response.ok) { + const body = await response.text().catch(() => ''); + lastError = `${response.status}: ${body.slice(0, 200)}`; + console.error(`Attempt ${attempt}/${MAX_RETRIES}: Mapillary ${response.status}; re-rolling`); + continue; + } + + const data = await response.json(); + const images = data.data || []; + const panos = images.filter(img => img.is_pano); + + if (panos.length > 0) { + console.log(`Found ${panos.length} panos on attempt ${attempt}`); + return { success: true, data: panos }; + } + + console.log(`Attempt ${attempt}/${MAX_RETRIES}: no panos; re-rolling`); + } catch (error) { + if (error.message === 'Mapillary authentication failed') { + throw error; + } + lastError = error.message; + console.error(`Attempt ${attempt}/${MAX_RETRIES} threw: ${error.message}; re-rolling`); + } + } + + return { + success: false, + error: lastError + ? `No panos after ${MAX_RETRIES} attempts (last: ${lastError})` + : 'No panos found', + }; +} +``` + +Removed: +- `MAX_BBOX_AREA` / `MIN_BBOX_AREA` / `GROW_FACTOR` / `SHRINK_FACTOR` +- `getRandomSubBbox` helper (replaced inline) +- Adaptive shrink/grow loop + +### 3. `src/app/api/new-game/route.js` + +Update imports + call: + +```js +// Before: +import { cityNames, cityBboxes } from '../../../lib/game.js'; +const bbox = cityBboxes[cityCode]; +const imageResult = await fetchMapillaryImages(bbox); +const imageUrl = selectedImage.thumb_2048_url; + +// After: +import { cityNames, cityBboxes, cityDeltas } from '../../../lib/game.js'; +const bbox = cityBboxes[cityCode]; +const delta = cityDeltas[cityCode]; +const imageResult = await fetchMapillaryImages(bbox, delta); +const imageUrl = selectedImage.thumb_original_url; +``` + +Add a guard for missing delta: + +```js +if (!delta) { + return NextResponse.json({ + success: false, + error: `No Mapillary delta configured for city: ${cityCode}`, + }, { status: 400 }); +} +``` + +## Success Criteria + +- [ ] `npm run build` passes +- [ ] HN: 3 sample requests → ≥2 success +- [ ] TPHCM: 3 sample requests → ≥2 success +- [ ] DL: 3 sample requests → ≥2 success +- [ ] DH: 3 sample requests → ≥2 success +- [ ] No 'reduce data' 500s in production logs after deploy +- [ ] `grep "MAX_BBOX_AREA\|GROW_FACTOR\|SHRINK_FACTOR" src/` empty + +## Risk Assessment + +- **Risk**: viguessr's HN delta=0.003 (666m) might still occasionally hit 'reduce data' if Mapillary tightens further. **Mitigation**: 10 retries; if persistent, drop HN delta to 0.002. +- **Risk**: Per-city delta requires a value for each enabled city; missing entry crashes. **Mitigation**: explicit guard in route; default fallback to 0.005 if undefined. +- **Risk**: `thumb_original_url` may be deprecated. **Mitigation**: viguessr is shipping it in production today; if Mapillary deprecates, swap to `thumb_2048_url`. +- **Risk**: Rare cities with sparse pano coverage (DH 666m windows) might exhaust retries. **Mitigation**: keep delta=0.005 (1.1km) for non-HN cities. + +## Rollback + +Single commit; revert HEAD to restore current adaptive code. Mapillary deploys with no schema/state changes, so rollback is instant. diff --git a/plans/260510-0140-port-viguessr-mapillary/plan.md b/plans/260510-0140-port-viguessr-mapillary/plan.md new file mode 100644 index 0000000..e05f8f7 --- /dev/null +++ b/plans/260510-0140-port-viguessr-mapillary/plan.md @@ -0,0 +1,94 @@ +--- +title: "Port viguessr Mapillary strategy" +description: "Replace adaptive shrink/grow bbox loop with viguessr's proven approach: per-city fixed `delta` (half-bbox-side) + random-point dart-throw + simple retry-with-new-point. Fixes 'reduce data' 500s in HN/HCM dense areas." +status: pending +priority: P1 +effort: 30m +created: 2026-05-10 +source: https://github.com/luuvanduc1999/viguessr (assets/js/game.js, obfuscated) +blockedBy: [] +blocks: [] +--- + +# Port viguessr Mapillary strategy + +## Why + +Current `src/lib/mapillary.js` adaptive bbox sizing fails consistently in dense HN/HCM — Mapillary returns 500 'reduce data' when query bbox costs too much to scan. Adaptive grow on empty results worsens this. + +viguessr (a sibling vngeoguessr project) ships a working implementation for the same cities. Their approach: **per-city fixed `delta`**, query a small bbox centered on a random point, retry only by re-rolling the random point (never resizing the bbox). The small fixed bbox keeps Mapillary query cost under their cap; the dart-throw eventually finds pano-rich windows. + +## Source decoded (from obfuscated game.js) + +```js +boundingBoxVN = { + 'HN': [105.77, 20.96, 105.88, 21.05, delta=0.003], + 'TPHCM': [106.62, 10.71, 106.75, 10.83, delta=0.005], + 'HP': [106.65, 20.80, 106.75, 20.90, delta=0.05], + 'ND': [106.00, 20.35, 106.25, 20.50, delta=0.005], + 'DN': [108.17, 16.00, 108.25, 16.10, delta=0.005], +}; + +async function getRandomMapillaryImage() { + const lat = Math.random() * (maxLat - minLat) + minLat; + const lng = Math.random() * (maxLong - minLong) + minLong; + const bbox = [lng-delta, lat-delta, lng+delta, lat+delta].map(n => n.toFixed(4)).join(','); + const url = `https://graph.mapillary.com/images?access_token=${TOKEN}&fields=id,thumb_original_url,geometry,is_pano&limit=3&bbox=${bbox}&is_pano=true`; + const json = await (await fetch(url)).json(); + if (json.data?.length) { + const panos = json.data.filter(d => d.is_pano); + return panos.length ? panos[rand(panos.length)] : json.data[0]; + } + return getRandomMapillaryImage(); // re-roll, same delta +} +``` + +## What changes locally + +### `src/lib/game.js` (CITIES schema) + +Add `mapillaryDelta` field to each enabled `CITIES` entry: + +| Code | New `mapillaryDelta` | Source rationale | +|---|---|---| +| HN | `0.003` | viguessr value | +| TPHCM | `0.005` | viguessr value | +| DL | `0.005` | not in viguessr; use TPHCM-class default | +| DH | `0.005` | not in viguessr; use TPHCM-class default | +| DN | `0.005` (still disabled) | viguessr value, future-proof | + +Export a `cityDeltas` map alongside existing `cityBboxes`/`cityNames`/`cityCenters`. + +### `src/lib/mapillary.js` (rewrite) + +Replace adaptive loop with viguessr-style: random point inside city bbox, fixed-size sub-bbox of side `2*delta`, re-roll position on empty/error. + +Signature change: `fetchMapillaryImages(bbox, delta)`. + +### `src/app/api/new-game/route.js` + +- Import `cityDeltas` from `lib/game.js`. +- Pass `cityDeltas[cityCode]` to `fetchMapillaryImages`. +- Use `selectedImage.thumb_original_url` (revert from `thumb_2048_url`). + +## Phases + +| # | Phase | Effort | Status | +|---|---|---|---| +| 01 | [Per-city Delta + Random-Point Fetch](./phase-01-per-city-delta-random-point-fetch.md) | 30m | pending | + +## Success Criteria + +- HN, TPHCM, DL, DH all return panoramic image on first request (sample ≥ 3 attempts each). +- No `MAX_BBOX_AREA` / `MIN_BBOX_AREA` / shrink/grow logic remains in `src/lib/mapillary.js`. +- `npm run build` passes. + +## Out of scope + +- Token rotation or rate-limit handling +- Caching successful responses +- Adding more cities (DN remains disabled, future work) + +## Manual cleanup after success + +- Remove the diagnostic `console.error` of Mapillary URL/body once confirmed stable (or keep as observability). diff --git a/src/app/api/new-game/route.js b/src/app/api/new-game/route.js index fd68d94..fe9b74e 100644 --- a/src/app/api/new-game/route.js +++ b/src/app/api/new-game/route.js @@ -1,6 +1,6 @@ import { NextResponse } from 'next/server'; import { v4 as uuidv4 } from 'uuid'; -import { cityNames, cityBboxes } from '../../../lib/game.js'; +import { cityNames, cityBboxes, cityDeltas } from '../../../lib/game.js'; import { fetchMapillaryImages } from '../../../lib/mapillary.js'; import { storeGameSession } from '../../../lib/session.js'; @@ -30,17 +30,24 @@ export async function GET(request) { try { console.log('Getting images for city:', cityName); - // Get city bbox + // Get city bbox + per-city Mapillary delta (half-side of query window) const bbox = cityBboxes[cityCode]; + const delta = cityDeltas[cityCode]; if (!bbox) { return NextResponse.json({ success: false, error: `No bbox found for city: ${cityCode}` }, { status: 400 }); } + if (!delta) { + return NextResponse.json({ + success: false, + error: `No Mapillary delta configured for city: ${cityCode}` + }, { status: 400 }); + } - // Fetch images from city bbox - const imageResult = await fetchMapillaryImages(bbox); + // Fetch images from city bbox via random-point dart-throw + const imageResult = await fetchMapillaryImages(bbox, delta); if (!imageResult.success) { return NextResponse.json({ @@ -60,8 +67,8 @@ export async function GET(request) { lng: selectedImage.geometry.coordinates[0] }; - // Use the thumbnail URL for now - Mapillary requires special handling for full panoramas - const imageUrl = selectedImage.thumb_2048_url; + // Use original-resolution panorama URL for PhotoSphere viewer + const imageUrl = selectedImage.thumb_original_url; // Create or update game session const currentSessionId = sessionId || generateSessionId(); diff --git a/src/lib/game.js b/src/lib/game.js index 1cc50da..5b92dea 100644 --- a/src/lib/game.js +++ b/src/lib/game.js @@ -7,6 +7,7 @@ export const CITIES = { name: 'Ha Noi', center: [21.0285, 105.8542], bbox: [105.77, 20.96, 105.88, 21.05], + mapillaryDelta: 0.003, enabled: true }, DN: { @@ -14,6 +15,7 @@ export const CITIES = { name: 'Da Nang', center: [16.0544, 108.2022], bbox: [108.17, 16, 108.25, 16.1], + mapillaryDelta: 0.005, enabled: false }, TPHCM: { @@ -21,6 +23,7 @@ export const CITIES = { name: 'Ho Chi Minh', center: [10.8231, 106.6297], bbox: [106.62, 10.71, 106.75, 10.83], + mapillaryDelta: 0.005, enabled: true }, DL: { @@ -28,6 +31,7 @@ export const CITIES = { name: 'Da Lat', center: [11.9404, 108.4583], bbox: [108.38, 11.89, 108.50, 12.00], + mapillaryDelta: 0.005, enabled: true }, DH: { @@ -35,6 +39,7 @@ export const CITIES = { name: 'Duc Hoa (Long An)', center: [10.8888, 106.3825], bbox: [106.35, 10.85, 106.45, 10.95], + mapillaryDelta: 0.005, enabled: true } }; @@ -53,6 +58,12 @@ export const cityBboxes = Object.fromEntries( Object.values(CITIES).map(city => [city.code, city.bbox]) ); +// Per-city Mapillary query bbox half-side (degrees). Smaller for dense urban +// areas to keep Mapillary query cost under their 'reduce data' cap. +export const cityDeltas = Object.fromEntries( + Object.values(CITIES).map(city => [city.code, city.mapillaryDelta]) +); + // Cities list for UI components (only enabled cities) export const cities = Object.values(CITIES) .filter(city => city.enabled) diff --git a/src/lib/mapillary.js b/src/lib/mapillary.js index 91c448b..c98afbe 100644 --- a/src/lib/mapillary.js +++ b/src/lib/mapillary.js @@ -1,97 +1,73 @@ // Mapillary API utilities +// +// Strategy ported from viguessr (https://github.com/luuvanduc1999/viguessr): +// pick a random point inside the city bbox, query a small fixed-size sub-bbox +// (side = 2*delta) centered on that point, and re-roll the point on empty +// results or transient 5xx. The fixed small size keeps Mapillary query cost +// below their cap; the dart-throw eventually lands on a pano-rich window. -// Bounding-box area range (square degrees) used by the adaptive query loop. -// - Too big in dense urban regions → Mapillary returns 500 'reduce data'. -// - Too small in sparse regions → no panoramic images returned. -// Loop shrinks on 500 errors and grows on empty results to converge on a -// workable size per city. -const MIN_BBOX_AREA = 0.00005; // ~250m square -const MAX_BBOX_AREA = 0.003; // ~5.5km square +const MAX_RETRIES = 10; -// Pick a random sub-bbox of the requested area within the parent bbox. -function getRandomSubBbox(bbox, area) { - const [minLng, minLat, maxLng, maxLat] = bbox; - const width = maxLng - minLng; - const height = maxLat - minLat; - - const side = Math.sqrt(area) * 0.9; // 10% safety margin - const subWidth = Math.min(side, width); - const subHeight = Math.min(side, height); - - const startLng = minLng + Math.random() * Math.max(0, width - subWidth); - const startLat = minLat + Math.random() * Math.max(0, height - subHeight); - - return [startLng, startLat, startLng + subWidth, startLat + subHeight]; -} - -// Fetch panoramic images from a single bbox query. -// Returns { ok, images, retryable, error } so the loop can decide adaptive action. -async function queryMapillary(accessToken, queryBbox) { - const bboxString = queryBbox.join(','); - const apiUrl = `https://graph.mapillary.com/images?access_token=${accessToken}&fields=id,thumb_2048_url,geometry&limit=1&bbox=${bboxString}&is_pano=true`; - - const response = await fetch(apiUrl, { - headers: { 'Accept': 'application/json' }, - next: { revalidate: 0 } - }); - - if (!response.ok) { - const body = await response.text().catch(() => ''); - if (response.status === 401) { - throw new Error('Mapillary authentication failed'); - } - return { ok: false, retryable: true, error: `${response.status}: ${body.slice(0, 200)}` }; - } - - const data = await response.json(); - return { ok: true, images: data.data || [] }; -} - -const MAX_RETRIES = 15; -const GROW_FACTOR = 1.6; -const SHRINK_FACTOR = 0.5; - -export async function fetchMapillaryImages(bbox) { +export async function fetchMapillaryImages(bbox, delta) { const accessToken = process.env.MAPILLARY_ACCESS_TOKEN; if (!accessToken) { throw new Error('MAPILLARY_ACCESS_TOKEN environment variable is not set'); } - // Start at the geometric mean of the size range — a reasonable middle ground. - let area = Math.sqrt(MIN_BBOX_AREA * MAX_BBOX_AREA); + const [minLng, minLat, maxLng, maxLat] = bbox; let lastError = null; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { - const queryBbox = getRandomSubBbox(bbox, area); - let result; + const lat = Math.random() * (maxLat - minLat) + minLat; + const lng = Math.random() * (maxLng - minLng) + minLng; + const queryBbox = [ + (lng - delta).toFixed(4), + (lat - delta).toFixed(4), + (lng + delta).toFixed(4), + (lat + delta).toFixed(4), + ].join(','); + const apiUrl = `https://graph.mapillary.com/images?access_token=${accessToken}&fields=id,thumb_original_url,geometry,is_pano&limit=3&bbox=${queryBbox}&is_pano=true`; + try { - result = await queryMapillary(accessToken, queryBbox); + const response = await fetch(apiUrl, { + headers: { Accept: 'application/json' }, + next: { revalidate: 0 }, + }); + + if (response.status === 401) { + throw new Error('Mapillary authentication failed'); + } + + if (!response.ok) { + const body = await response.text().catch(() => ''); + lastError = `${response.status}: ${body.slice(0, 200)}`; + console.error(`Attempt ${attempt}/${MAX_RETRIES}: Mapillary ${response.status}; re-rolling`); + continue; + } + + const data = await response.json(); + const images = data.data || []; + const panos = images.filter(img => img.is_pano); + + if (panos.length > 0) { + console.log(`Found ${panos.length} panos on attempt ${attempt}`); + return { success: true, data: panos }; + } + + console.log(`Attempt ${attempt}/${MAX_RETRIES}: no panos; re-rolling`); } catch (error) { - // Auth failures are not transient — abort fast. - throw error; - } - - if (result.ok && result.images.length > 0) { - console.log(`Found ${result.images.length} images on attempt ${attempt} (area=${area.toFixed(5)})`); - return { success: true, data: result.images }; - } - - if (!result.ok) { - // 'reduce data' / 'unknown error' — shrink and retry. - console.error(`Attempt ${attempt}/${MAX_RETRIES} 500 (area=${area.toFixed(5)}): ${result.error}`); - lastError = result.error; - area = Math.max(MIN_BBOX_AREA, area * SHRINK_FACTOR); - } else { - // OK but empty — grow and retry to widen the search area. - console.log(`Attempt ${attempt}/${MAX_RETRIES} no panos (area=${area.toFixed(5)})`); - area = Math.min(MAX_BBOX_AREA, area * GROW_FACTOR); + if (error.message === 'Mapillary authentication failed') { + throw error; + } + lastError = error.message; + console.error(`Attempt ${attempt}/${MAX_RETRIES} threw: ${error.message}; re-rolling`); } } return { success: false, error: lastError - ? `No street view images found after ${MAX_RETRIES} attempts (last: ${lastError})` - : 'No street view images found in city bbox', + ? `No panos after ${MAX_RETRIES} attempts (last: ${lastError})` + : 'No panos found', }; }