/** * QUIXZOOM Synthetic City Generator * * Genererar realistisk stad med korrekt spatial kontext: * - Gatlyktor längs vägar * - Skyltar vid korsningar * - Brunnar i körfält * - Träd längs trottoarer * - Elskåp nära belysning * * Detta gör att AI kan använda kontext för identitetsmatchning. * En "gatlykta mitt i en sjö" blir ett tydligt fel. */ class SyntheticCity { constructor(name, sizeKm = 10) { this.name = name; this.sizeKm = sizeKm; this.roads = []; this.intersections = []; this.objects = []; this.observations = []; this.zoomers = []; // Konstanter this.BLOCK_SIZE = 0.2; // km (200m kvarter) this.ROAD_WIDTH = 0.008; // ~8m väg this.SIDEWALK_WIDTH = 0.003; // ~3m trottoar this.KM_TO_DEG = 0.009; // ca 1km = 0.009 grader } /** * ============================================================ * GENERERA STAD * ============================================================ */ generate() { console.log(`[CITY] Generating ${this.name} (${this.sizeKm}x${this.sizeKm} km)...`); // 1. Generera vägnät (grid) this.generateRoads(); console.log(`[CITY] ${this.roads.length} roads`); // 2. Hitta korsningar this.findIntersections(); console.log(`[CITY] ${this.intersections.length} intersections`); // 3. Placera objekt längs vägar this.placeStreetLamps(); this.placeTrafficSigns(); this.placeManholes(); this.placeTrees(); this.placeUtilityBoxes(); console.log(`[CITY] ${this.objects.length} objects placed`); // 4. Generera Zoomers this.generateZoomers(100); // 5. Generera observationer this.generateObservations(); return this.getStats(); } /** * ============================================================ * VÄGNÄT * ============================================================ */ generateRoads() { const blocks = Math.floor(this.sizeKm / this.BLOCK_SIZE); // Horisontella vägar for (let i = 0; i <= blocks; i++) { const y = i * this.BLOCK_SIZE; this.roads.push({ id: `road_h_${i}`, type: 'horizontal', start: { x: 0, y }, end: { x: this.sizeKm, y }, width: this.ROAD_WIDTH, lanes: 2, hasSidewalk: true, }); } // Vertikala vägar for (let i = 0; i <= blocks; i++) { const x = i * this.BLOCK_SIZE; this.roads.push({ id: `road_v_${i}`, type: 'vertical', start: { x, y: 0 }, end: { x, y: this.sizeKm }, width: this.ROAD_WIDTH, lanes: 2, hasSidewalk: true, }); } } findIntersections() { const horizontal = this.roads.filter(r => r.type === 'horizontal'); const vertical = this.roads.filter(r => r.type === 'vertical'); for (const h of horizontal) { for (const v of vertical) { this.intersections.push({ id: `intersection_${h.id}_${v.id}`, x: v.start.x, y: h.start.y, roads: [h.id, v.id], }); } } } /** * ============================================================ * PLACERA OBJEKT (REALISTISKT) * ============================================================ */ kmToLatLng(x, y) { // Konvertera km till lat/lng (approximation) return { lat: 59.3 + y * this.KM_TO_DEG, // Centrerat runt Stockholm lng: 18.0 + x * this.KM_TO_DEG, }; } placeStreetLamps() { // Gatlyktor längs vägar, var 30e meter const spacing = 0.03; // 30m for (const road of this.roads) { const length = this.roadLength(road); const count = Math.floor(length / spacing); for (let i = 1; i < count; i++) { const t = i / count; const point = this.pointOnRoad(road, t); // Placera på trottoaren (växlar sida) const side = i % 2 === 0 ? 1 : -1; const offset = this.ROAD_WIDTH / 2 + this.SIDEWALK_WIDTH / 2; const x = point.x + (road.type === 'horizontal' ? 0 : side * offset); const y = point.y + (road.type === 'vertical' ? 0 : side * offset); this.objects.push({ id: `street_lamp_${this.objects.length}`, type: 'street_lamp', location: this.kmToLatLng(x, y), roadId: road.id, position: t, side, attributes: { height: 7 + Math.random() * 3, material: Math.random() > 0.3 ? 'steel' : 'aluminum', paint: ['grey', 'black', 'green'][Math.floor(Math.random() * 3)], light: Math.random() > 0.95 ? 'broken' : 'working', rust: Math.random() > 0.9, lean: Math.random() > 0.95 ? Math.random() * 3 : 0, }, }); } } } placeTrafficSigns() { // Skyltar vid korsningar och längs vägar for (const intersection of this.intersections) { // 80% chans för skylt vid korsning if (Math.random() > 0.2) { const signTypes = ['speed_limit', 'stop', 'yield', 'pedestrian', 'parking', 'direction']; const x = intersection.x + (Math.random() - 0.5) * 0.01; const y = intersection.y + (Math.random() - 0.5) * 0.01; this.objects.push({ id: `traffic_sign_${this.objects.length}`, type: 'traffic_sign', location: this.kmToLatLng(x, y), intersectionId: intersection.id, attributes: { signType: signTypes[Math.floor(Math.random() * signTypes.length)], height: 2 + Math.random() * 1, reflective: Math.random() > 0.1, damaged: Math.random() > 0.95, }, }); } } // Hastighetsbegränsning var 200m for (const road of this.roads) { const length = this.roadLength(road); const count = Math.floor(length / 0.2); for (let i = 1; i < count; i++) { if (Math.random() > 0.5) continue; const t = i / count; const point = this.pointOnRoad(road, t); const side = Math.random() > 0.5 ? 1 : -1; const offset = this.ROAD_WIDTH / 2 + 0.005; const x = point.x + (road.type === 'horizontal' ? 0 : side * offset); const y = point.y + (road.type === 'vertical' ? 0 : side * offset); this.objects.push({ id: `traffic_sign_${this.objects.length}`, type: 'traffic_sign', location: this.kmToLatLng(x, y), roadId: road.id, attributes: { signType: 'speed_limit', speed: [30, 50, 70][Math.floor(Math.random() * 3)], height: 2.5, reflective: true, }, }); } } } placeManholes() { // Brunnar i körfält, var 50e meter const spacing = 0.05; for (const road of this.roads) { const length = this.roadLength(road); const count = Math.floor(length / spacing); for (let i = 1; i < count; i++) { const t = i / count; const point = this.pointOnRoad(road, t); // I vägen (inte på trottoaren) const laneOffset = (Math.random() - 0.5) * this.ROAD_WIDTH * 0.6; const x = point.x + (road.type === 'horizontal' ? laneOffset : 0); const y = point.y + (road.type === 'vertical' ? laneOffset : 0); this.objects.push({ id: `manhole_${this.objects.length}`, type: 'manhole', location: this.kmToLatLng(x, y), roadId: road.id, attributes: { diameter: 0.5 + Math.random() * 0.3, material: ['cast_iron', 'concrete'][Math.floor(Math.random() * 2)], condition: Math.random() > 0.9 ? 'damaged' : 'good', }, }); } } } placeTrees() { // Träd längs trottoarer, var 10-15 meter for (const road of this.roads) { const length = this.roadLength(road); let pos = 0.01; while (pos < length) { pos += 0.01 + Math.random() * 0.005; // 10-15m const t = pos / length; const point = this.pointOnRoad(road, t); const side = Math.random() > 0.5 ? 1 : -1; const offset = this.ROAD_WIDTH / 2 + this.SIDEWALK_WIDTH + 0.002; const x = point.x + (road.type === 'horizontal' ? 0 : side * offset); const y = point.y + (road.type === 'vertical' ? 0 : side * offset); this.objects.push({ id: `tree_${this.objects.length}`, type: 'tree', location: this.kmToLatLng(x, y), roadId: road.id, attributes: { species: ['oak', 'pine', 'birch', 'maple'][Math.floor(Math.random() * 4)], height: 3 + Math.random() * 12, diameter: 0.2 + Math.random() * 0.8, health: Math.random() > 0.9 ? 'poor' : 'good', }, }); } } } placeUtilityBoxes() { // Elskåp nära gatlyktor eller vid korsningar for (let i = 0; i < this.objects.length; i++) { const obj = this.objects[i]; if (obj.type === 'street_lamp' && Math.random() > 0.7) { // 30% chans för elskåp nära gatlykta const x = (obj.location.lng - 18.0) / this.KM_TO_DEG + (Math.random() - 0.5) * 0.005; const y = (obj.location.lat - 59.3) / this.KM_TO_DEG + (Math.random() - 0.5) * 0.005; this.objects.push({ id: `utility_box_${this.objects.length}`, type: 'utility_box', location: this.kmToLatLng(x, y), roadId: obj.roadId, nearLamp: obj.id, attributes: { type: ['electric', 'telecom', 'traffic'][Math.floor(Math.random() * 3)], height: 1 + Math.random() * 0.5, condition: Math.random() > 0.85 ? 'damaged' : 'good', }, }); } } } /** * ============================================================ * GENERERA OBSERVATIONER * ============================================================ */ generateObservations() { // Generera 5 observationer per objekt (olika Zoomers, tider, väder) for (const obj of this.objects) { const numObservations = 1 + Math.floor(Math.random() * 5); for (let i = 0; i < numObservations; i++) { const zoomer = this.zoomers[Math.floor(Math.random() * this.zoomers.length)]; this.observations.push({ id: `obs_${obj.id}_${i}`, objectId: obj.id, objectType: obj.type, zoomerId: zoomer.id, location: { // GPS-variation ±1-3 meter lat: obj.location.lat + (Math.random() - 0.5) * 0.00003, lng: obj.location.lng + (Math.random() - 0.5) * 0.00003, }, gpsAccuracy: 1 + Math.random() * 2, timestamp: this.randomDate(2025, 2026), attributes: { ...obj.attributes }, // Simulera förändringar över tid weather: ['clear', 'cloudy', 'rain', 'fog'][Math.floor(Math.random() * 4)], timeOfDay: ['day', 'evening', 'night'][Math.floor(Math.random() * 3)], device: zoomer.device, angle: Math.random() * 360, quality: { blur: Math.random() * 0.2, exposure: 0.5 + Math.random() * 0.5, }, }); } } } generateZoomers(count) { for (let i = 0; i < count; i++) { this.zoomers.push({ id: `zoomer_${i.toString().padStart(3, '0')}`, device: { type: ['iPhone', 'Android'][Math.floor(Math.random() * 2)], model: ['iPhone 15', 'Samsung S24', 'Pixel 8'][Math.floor(Math.random() * 3)], }, level: ['supplementary', 'active', 'professional'][Math.floor(Math.random() * 3)], }); } } /** * ============================================================ * BENCHMARK DATASETS * ============================================================ */ generateDatasetA() { // Dataset A: Perfekt scenario // Samma gatlykta fotograferad 20 gånger const lamp = this.objects.find(o => o.type === 'street_lamp'); const observations = []; for (let i = 0; i < 20; i++) { observations.push({ id: `obs_A_${i}`, objectType: 'street_lamp', location: { lat: lamp.location.lat + (Math.random() - 0.5) * 0.00003, // ±1.5m lng: lamp.location.lng + (Math.random() - 0.5) * 0.00003, }, gpsAccuracy: 1 + Math.random() * 2, timestamp: new Date(2025, 0, 1 + i * 15), // Var 15e dag attributes: { ...lamp.attributes, // Små förändringar över tid rust: i > 10 ? true : lamp.attributes.rust, paint: i > 15 ? 'faded' : lamp.attributes.paint, }, weather: ['clear', 'cloudy', 'rain'][i % 3], timeOfDay: ['day', 'evening', 'night'][i % 3], device: { type: 'iPhone', model: `iPhone ${12 + (i % 4)}` }, angle: Math.random() * 360, }); } return { name: 'Dataset A - Perfect Scenario', description: 'Same lamp, 20 observations, varying conditions', expectedObjects: 1, expectedEvidence: 20, observations, }; } generateDatasetB() { // Dataset B: Svårt scenario // Två identiska gatlyktor 6 meter från varandra const road = this.roads[0]; const point = this.pointOnRoad(road, 0.5); const baseLoc = this.kmToLatLng(point.x, point.y); const lamp1 = { id: 'lamp_B1', type: 'street_lamp', location: { lat: baseLoc.lat, lng: baseLoc.lng - 0.00003, // 3m väster }, attributes: { height: 8.0, material: 'steel', paint: 'grey', light: 'working', }, }; const lamp2 = { id: 'lamp_B2', type: 'street_lamp', location: { lat: baseLoc.lat, lng: baseLoc.lng + 0.00003, // 3m öster (totalt 6m mellan) }, attributes: { height: 8.0, material: 'steel', paint: 'grey', light: 'working', }, }; const observations = [ { id: 'obs_B1', objectType: 'street_lamp', location: { lat: lamp1.location.lat + (Math.random() - 0.5) * 0.00001, lng: lamp1.location.lng + (Math.random() - 0.5) * 0.00001, }, gpsAccuracy: 2, attributes: lamp1.attributes, }, { id: 'obs_B2', objectType: 'street_lamp', location: { lat: lamp2.location.lat + (Math.random() - 0.5) * 0.00001, lng: lamp2.location.lng + (Math.random() - 0.5) * 0.00001, }, gpsAccuracy: 2, attributes: lamp2.attributes, }, ]; return { name: 'Dataset B - Difficult Scenario', description: 'Two identical lamps 6m apart, should NOT merge', expectedObjects: 2, expectedEvidence: 2, observations, }; } generateDatasetC() { // Dataset C: Förändring över tid const lamp = this.objects.find(o => o.type === 'street_lamp'); const observations = []; const changes = [ { month: 0, paint: 'grey', rust: false, light: 'working', lean: 0 }, { month: 3, paint: 'grey', rust: false, light: 'working', lean: 0.5 }, { month: 6, paint: 'faded', rust: true, light: 'flickering', lean: 1 }, { month: 9, paint: 'faded', rust: true, light: 'broken', lean: 2 }, { month: 12, paint: 'new_grey', rust: false, light: 'working', lean: 0 }, ]; for (const change of changes) { observations.push({ id: `obs_C_${change.month}`, objectType: 'street_lamp', location: { lat: lamp.location.lat + (Math.random() - 0.5) * 0.00002, lng: lamp.location.lng + (Math.random() - 0.5) * 0.00002, }, gpsAccuracy: 1.5, timestamp: new Date(2025, change.month, 15), attributes: { height: lamp.attributes.height, material: lamp.attributes.material, ...change, }, }); } return { name: 'Dataset C - Change Over Time', description: 'Same lamp with gradual changes, then repaired', expectedObjects: 1, expectedEvidence: 5, observations, }; } generateDatasetD() { // Dataset D: Partiellt skymt const lamp = this.objects.find(o => o.type === 'street_lamp'); return { name: 'Dataset D - Partial Occlusion', description: 'Only 30% of lamp visible', expectedObjects: 1, expectedEvidence: 1, observations: [{ id: 'obs_D1', objectType: 'street_lamp', location: { lat: lamp.location.lat + 0.00002, lng: lamp.location.lng + 0.00002, }, gpsAccuracy: 2, attributes: { ...lamp.attributes, visiblePortion: 0.3, occludedBy: 'bus', }, }], }; } generateDatasetE() { // Dataset E: Flera Zoomers const lamp = this.objects.find(o => o.type === 'street_lamp'); const observations = []; for (let i = 0; i < 5; i++) { observations.push({ id: `obs_E_${i}`, objectType: 'street_lamp', location: { lat: lamp.location.lat + (Math.random() - 0.5) * 0.00004, lng: lamp.location.lng + (Math.random() - 0.5) * 0.00004, }, gpsAccuracy: 1.5 + Math.random(), timestamp: new Date(2025, i, 1 + i * 7), attributes: lamp.attributes, zoomerId: `zoomer_${i}`, device: { type: 'iPhone', model: `iPhone ${14 + i}` }, }); } return { name: 'Dataset E - Multiple Zoomers', description: 'Five zoomers document same street over different days', expectedObjects: 1, expectedEvidence: 5, observations, }; } /** * ============================================================ * HJÄLPMETODER * ============================================================ */ roadLength(road) { if (road.type === 'horizontal') { return road.end.x - road.start.x; } else { return road.end.y - road.start.y; } } pointOnRoad(road, t) { return { x: road.start.x + (road.end.x - road.start.x) * t, y: road.start.y + (road.end.y - road.start.y) * t, }; } randomDate(startYear, endYear) { const start = new Date(startYear, 0, 1).getTime(); const end = new Date(endYear, 11, 31).getTime(); return new Date(start + Math.random() * (end - start)); } getStats() { const byType = {}; for (const obj of this.objects) { byType[obj.type] = (byType[obj.type] || 0) + 1; } return { name: this.name, size: `${this.sizeKm}x${this.sizeKm} km`, roads: this.roads.length, intersections: this.intersections.length, objects: { total: this.objects.length, byType, }, observations: this.observations.length, zoomers: this.zoomers.length, }; } } // Exportera module.exports = SyntheticCity; // Demo if (require.main === module) { const city = new SyntheticCity('TestCity', 5); const stats = city.generate(); console.log('\n=== SYNTHETIC CITY STATS ==='); console.log(JSON.stringify(stats, null, 2)); console.log('\n=== DATASET A (Perfect) ==='); const datasetA = city.generateDatasetA(); console.log(`${datasetA.name}: ${datasetA.observations.length} observations`); console.log('\n=== DATASET B (Difficult) ==='); const datasetB = city.generateDatasetB(); console.log(`${datasetB.name}: ${datasetB.observations.length} observations`); console.log('\n=== DATASET C (Change Over Time) ==='); const datasetC = city.generateDatasetC(); console.log(`${datasetC.name}: ${datasetC.observations.length} observations`); console.log('\n=== DATASET D (Partial Occlusion) ==='); const datasetD = city.generateDatasetD(); console.log(`${datasetD.name}: ${datasetD.observations.length} observations`); console.log('\n=== DATASET E (Multiple Zoomers) ==='); const datasetE = city.generateDatasetE(); console.log(`${datasetE.name}: ${datasetE.observations.length} observations`); }