Wi‐fi 기반 위치 추적 시스템과 INS(관성 항법 장치) - YangJJune/U-Compass GitHub Wiki
val context = LocalContext.current
val fusedLocationClient: FusedLocationProviderClient = remember {
LocationServices.getFusedLocationProviderClient(context)
}
var currentLocation by remember { mutableStateOf<Location?>(null) }
// 위치 업데이트 콜백
val locationCallback = remember {
object : LocationCallback() {
override fun onLocationResult(locationResult: LocationResult) {
for (location in locationResult.locations) {
currentLocation = location
break
}
}
}
}
LaunchedEffect(key1 = locationPermissionState.status.isGranted) {
try {
fusedLocationClient.lastLocation
.addOnSuccessListener { loc ->
if (loc != null) {
currentLocation = loc
Log.d("LocationPermission", "Last location received: $loc")
} else {
Log.d("LocationPermission", "Last location is null")
}
}
.addOnFailureListener { e ->
Log.e("LocationPermission", "Failed to get location: ${e.message}", e)
}
// 지속적인 위치 업데이트 요청
val locationRequest = LocationRequest.Builder(1000)
.setPriority(Priority.PRIORITY_HIGH_ACCURACY)
.build()
fusedLocationClient.requestLocationUpdates(
locationRequest,
locationCallback,
context.mainLooper
)
Log.d("LocationPermission", "Location updates requested")
} catch (e: SecurityException) {
Log.e("LocationPermission", "Security exception: ${e.message}", e)
errorMessage = "권한 오류: ${e.message}"
e.printStackTrace()
} catch (e: Exception) {
Log.e("LocationPermission", "Unexpected error: ${e.message}", e)
errorMessage = "예상치 못한 오류: ${e.message}"
e.printStackTrace()
}
}
}
- 현재 위치가 신뢰할 만한 위치로 추정되긴 함
- 하지만 실내 기준 계속 위도 경도가 바뀜
- (휴대폰은 가만히 있는 상태)
- 축척을 10m 로 낮추면 위치 마커가 계속해서 변동됨. (GPS 위도경도가 계속 바뀜)
- GPS 만으로는 실내에서 위치를 신뢰하기 어려움.
- 미리 수집해둔 Wi-Fi 신호 패턴 데이터베이스와, 실시간 스캔 데이터를 비교해서 사용자의 대략적인 위치를 추정하는 기법.
- 기준 위치에서 RSSI(신호 세기) 데이터를 수집해 해당 데이터를 저장.
- 사용자가 요청을 할 때, 주기적으로 주변 AP 의 RSSI 를 스캔하고, DB 의 신호 벡터와 비교해 가장 유사도가 높은 위치를 현재 위치로 결정함.
- [Wifi FingerPrint 데이터 수집 및 관리 문제](https://koreascience.kr/article/JAKO201320360167437.pdf)
- FingerPrint 데이터베이스를 구축하려면 1 미터제곱 당 4~6개의 지점에서 AP 신호를 측정해야 함. 500 미터제곱의 경우에는 최소 2,000 회의 측정 필요 + 그만큼의 시간 또한 추가로 소요됨.(500 미터제곱 규모 공간에서 약 72시간 소요)
- 데이터베이스에 존재하는 Wifi 의 스펙이 변경되거나 건물 구조가 변경되었을 때, 데이터베이스 상 이 변경사항에 관련된 모든 데이터를 수정해야 합니다. 단순 가구 이동, 벽 설치 시 기존의 데이터베이스로는 심한 오차가 생겨 기대되는 위치를 제공할 수 없습니다.
- 정확도 문제
- [지역별 AP 밀도 편차가 존재함](https://hoonstudio.tistory.com/3)(ex) 강남 → 0.32 , 신안 → 0.002), 따라서 지역간 위치 정확도의 편차가 존재
- 전파를 기반으로 측정하기 때문에 금속 구조물이 많은 공간에서 신호 반사로 인해서 오차가 발생할 수 있음. 금속 구조물이 많을 수록 오차가 누적되는 Drift 문제가 발생해 위치 좌표의 오차가 점점 커질 수 있음.
- Wi-Fi RTT (Round-Trip-Time) API 가 제공하는 Wi-Fi 위치 기능을 사용하여 주변의 RTT 지원 Wi-Fi 에 액세스해서 해당 기기와의 거리를 측정 가능하다.
- 이 때 기기(핸드폰)에서 3개 이상의 AP(Wi-Fi Access Point) 에 접근해 거리를 측정하는 경우에, 다변측정(MLAT) 알고리즘을 사용해 거리 측정치를 기준으로 기기의 위치를 특정할 수 있다. 이 때 오차는 1~2 미터로 계산된다. by [[Android Developer Wifi RTT](https://developer.android.com/develop/connectivity/wifi/wifi-rtt?hl=ko&_gl=112z2spc_up*MQ)]
- WiFi-RTT 와 관련한 FTM(Fine-Time-Measurement) 기능은 IEEE 802.11-2016 표준에서 지정했다.
- 이를 통해 패킷이 두 기기를 왕복하는 시간을 측정하고 그 시간을 활용해 거리를 계산함.
- Android 10 이상부터 기기가 Wi-Fi AP 정보를 미리 저장할 필요 없이 AP 를 쿼리해서 위치 정보(위도, 경도, 고도 등)를 직접 요청할 수 있다.
-
scanResults()
을 통해 얻은List<ScanResult>
에서 Wi-Fi RTT 를 지원하는 Wi-Fi 를 찾아내서 이 Wi-Fi 들로 작업을 해야 한다.val scanResults : List<ScanResult> = wifiManager.scanResults val rttCapableAps = scanResults.filter { it.is80211mcResponder }
-
Android 9(28 API) 이상에서부터 실행 가능, IEEE 802.11az 기반 측정은 Android 15(35 API) 부터 사용 가능
-
Android 13(33 API) 이상을 타겟팅하는 경우 NEARBY_WIFI_DEVICES 권한이 있어야 함.
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
<uses-permission android:name="android.permission.CHANGE_WIFI_STATE" />
<uses-permission
android:name="android.permission.NEARBY_WIFI_DEVICES"
android:usesPermissionFlags="neverForLocation" />
- 기기가 Wi-Fi RTT 를 지원하는 지 확인
Log.d("TAG", context.packageManager.hasSystemFeature(PackageManager.FEATURE_WIFI_RTT).toString)
- 기기가 Wi-Fi RTT 를 사용할 수 있는 지 확인
Log.d("TAG", wifiRttManager.isAvailable)
-
BroadcastReceiver
를 이용해 사용 가능 여부를 계속 추적하기
val filter = IntentFilter(WifiRttManager.ACTION_WIFI_RTT_STATE_CHANGED)
val myReceiver = object: BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
if (wifiRttManager.isAvailable) {
…
} else {
…
}
}
}
context.registerReceiver(myReceiver, filter)
- 권한 요청
val requiredPermissions = remember {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
arrayOf(
Manifest.permission.ACCESS_COARSE_LOCATION
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.NEARBY_WIFI_DEVICES
)
} else {
arrayOf(
Manifest.permission.ACCESS_FINE_LOCATION,
Manifest.permission.ACCESS_COARSE_LOCATION
)
}
}
- Wi-Fi AP 에 요청할 수 있는 최대 개수 구하기
val maxRttDevices = RangingRequest.getMaxPeers()
- 주변에 접근할 수 있는 Wi-Fi 스캔하기 후 RTT 기능이 있는 Wi-Fi 추출하기
val scanResults : List<ScanResult> = wifiManager.scanResults
val rttCapableAps = scanResults.filter { it.is80211mcResponder }
- Wifi RTT 기술을 지원하는 Wifi AP 3대를 찾아서, 나와 그 AP들 사이의 거리를 측정함.
- AP 3개 ↔ 내 위치 사이의 거리를 사용해 삼변측위 기술로 내 현재 위치를 특정한다.
- Wi-Fi RTT 를 지원하는 wifi 가 많이 없음.
// 사용 가능한 AP 스캔
val success = wifiManager.startScan()
if (!success) {
Log.w("PositionLog", "WiFi 스캔 시작 실패, 이전 결과를 사용합니다")
}
val scanResults = wifiManager.scanResults
Log.d("PositionLog", "스캔된 AP 개수: ${scanResults.size}")
// 모든 AP 정보 로깅
Log.d("PositionLog", "==== 스캔된 모든 AP 정보 ====")
scanResults.forEachIndexed { index, result ->
Log.d("PositionLog", "AP[$index]: SSID='${result.SSID}', BSSID=${result.BSSID}, " +
"RSSI=${result.level}dBm, 주파수=${result.frequency}MHz, " +
"RTT지원=${result.is80211mcResponder}, 채널폭=${result.channelWidth}")
}
// 802.11mc 지원 AP 필터링
val rttCapableAps = scanResults
.filter { result ->
result.is80211mcResponder
}
Log.d("PositionLog", "==== RTT 지원 AP 정보 ====")
Log.d("PositionLog", "RTT 지원 AP 개수: ${rttCapableAps.size}")
아래 사진은 건국대학교 경영관에서 측정한 주변 AP 정보들이다.
하지만 조사한 상태로는, 주변 39개의 AP 중 RTT 를 지원하는 Wifi 가 단 한 개도 없음을 알 수 있다.
- GPS 위치와 스마트폰 내장 센서를 결합해서, 실내에서도 비교적 정확한 위치 추적을 하기 위해 고안된 시스템
- 실내에 들어가면, 마지막 GPS 좌표를 바탕으로 기준점을 정하고 기기의 방향과 이동거리를 조합해 실내 위치를 특정합니다.
-
권한 관리
- 위치 권한 :
ACCESS_FINE_LOCATION
,ACCESS_COARSE_LOACATION
- 위치 권한 :
-
GPS 기반 위치 추적
val locationCallback = remember { object : LocationCallback() { override fun onLocationResult(locationResult: LocationResult) { for (location in locationResult.locations) { if (initialLocation == null) { initialLocation = location currentLatitude = location.latitude currentLongitude = location.longitude } else if (!insActive) { // INS가 활성화되지 않은 경우에만 GPS 위치 업데이트 currentLatitude = location.latitude currentLongitude = location.longitude } break } } }
-
센서 선언 :
*TYPE_ACCELEROMETER
,TYPE_MAGNETIC_FIELD
*val sensorManager = remember { context.getSystemService(Context.SENSOR_SERVICE) as SensorManager } val accelerometer = remember { sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER) } val magnetometer = remember { sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD) }
-
센서 등록
sensorManager.registerListener( sensorListener, accelerometer, SensorManager.SENSOR_DELAY_NORMAL ) sensorManager.registerListener( sensorListener, magnetometer, SensorManager.SENSOR_DELAY_NORMAL )
-
실내에서 이동하는 경우와 실외에서 이동하는 경우를 분리해야 한다.
- 실외에서 이동하는 경우 : GPS 가 정상 작동되므로 GPS 를 사용함.
- 실내에서 이동하는 경우 : 마지막으로 얻은 GPS 좌표를 기반으로, 센서기능을 조합해 기기가 움직이는 방향과 거리만큼 위도 경도 좌표를 업데이트한다.
-
GPS 위치 요청 콜백 등록
val fusedLocationClient = remember { LocationServices.getFusedLocationProviderClient(context) } val locationRequest = LocationRequest.Builder(1000) // 1초마다 업데이트 .setPriority(Priority.PRIORITY_HIGH_ACCURACY) .build() fusedLocationClient.requestLocationUpdates( locationRequest, locationCallback, Looper.getMainLooper() ) fusedLocationClient.lastLocation.addOnSuccessListener { location -> if (location != null) { initialLocation = location currentLatitude = location.latitude currentLongitude = location.longitude logMessages = addLogMessage(logMessages) { "초기 위치 설정: 위도 ${location.latitude}, 경도 ${location.longitude}" } } }
- Location Callback 이 성공적으로 호출되었으면 current 좌표가 정상적으로 업데이트 됨 (실외 GPS 기반 기준)
-
센서 기반 걸음 수 측정
override fun onSensorChanged(event: SensorEvent) {
when (event.sensor.type) {
Sensor.TYPE_STEP_COUNTER -> {
val steps = event.values[0].toLong()
stepCount++ // 세션 내 걸음 수만 증가
logMessages = addLogMessage(logMessages) { "걸음 감지 (카운터): $stepCount" }
// 위치 업데이트
updatePositionWithINS(
currentAzimuth,
stepLength,
currentLatitude,
currentLongitude
) { lat, lng ->
currentLatitude = lat
currentLongitude = lng
}
}
}
}
-
기기 방위각 계산
override fun onSensorChanged(event: SensorEvent) { when (event.sensor.type) { Sensor.TYPE_MAGNETIC_FIELD -> { magnetValues = event.values.clone() // 방위각 계산 calculateOrientation(accelValues, magnetValues) { azimuth -> currentAzimuth = azimuth } } } } private fun calculateOrientation( accelValues: FloatArray, magnetValues: FloatArray, onResult: (Float) -> Unit ) { val rotationMatrix = FloatArray(9) val orientationAngles = FloatArray(3) val success = SensorManager.getRotationMatrix( rotationMatrix, null, accelValues, magnetValues ) if (success) { SensorManager.getOrientation(rotationMatrix, orientationAngles) // orientationAngles[0]는 방위각(라디안) val azimuthRad = orientationAngles[0] var azimuthDegrees = Math.toDegrees(azimuthRad.toDouble()).toFloat() // 음수 값을 0~360도로 변환 if (azimuthDegrees < 0) { azimuthDegrees += 360f } onResult(azimuthDegrees) } }
-
현재 위치 좌표 업데이트
override fun onSensorChanged(event: SensorEvent) { when (event.sensor.type) { Sensor.TYPE_ACCELEROMETER -> { accelValues = event.values.clone() detectStep(accelValues, lastUpdateTime) { isStep -> if (isStep && insActive) { stepCount++ updatePositionWithINS( currentAzimuth, stepLength, currentLatitude, currentLongitude ) { lat, lng -> currentLatitude = lat currentLongitude = lng } } lastUpdateTime = System.currentTimeMillis() } } } } private fun updatePositionWithINS( azimuth: Float, stepLength: Float, currentLat: Double, currentLng: Double, onUpdate: (Double, Double) -> Unit ) { // 방위각을 라디안으로 변환 val azimuthRad = Math.toRadians(azimuth.toDouble()) // 북쪽 및 동쪽 방향으로의 이동량 (미터) val northMeter = stepLength * cos(azimuthRad) val eastMeter = stepLength * sin(azimuthRad) // 지구 반경 (미터) val earthRadius = 6378137.0 // 위도 변화량 (라디안) val latRad = Math.toRadians(currentLat) val latChange = northMeter / earthRadius // 경도 변화량 (라디안) val lngChange = eastMeter / (earthRadius * cos(latRad)) // 라디안에서 도(degree)로 변환 val newLat = currentLat + Math.toDegrees(latChange) val newLng = currentLng + Math.toDegrees(lngChange) onUpdate(newLat, newLng) }
최초의 GPS 좌표를 기반으로 기기의 방향 과 걸음 에 기반해 위치 좌표를 업데이트함.
- 방향 문제: 사용자가 앞으로 걷고 있더라도, 기기가 정확히 앞 방향을 바라보지 않고, 옆이나 뒤를 바라보고 있다면 실제 걸음 방향은 옆이나 뒤를 향해 가고 있다고 인식하게 됨.
- GPS 를 끄고 있고, 의존하는 것은 기기의 각센서와 자기장 센서 이므로 이에 대한 해결책은 사용자가 핸드폰을 똑바로 앞으로 향하도록 기대하는 것 으로 추정..
- 걸음 문제: 사용자의 걸음 수만 인식할 수 있음. -> 실제 걸음 수에 따른 이동거리(걸음 수 * 보폭) 은 사용자마다 다를 것임.
- 의존하는 것은 내장된 사용자의 걸음 수 센서이므로 사용자마다 적용하는 보폭 거리를 적용할 수 있도록 한다면 사용자 간 보폭 차이를 줄일 수 있음.
- 오차 누적(Drift) 문제 :
- 실내에서는 GPS 좌표가 적합한 위치인지, INS 로 업데이트된 위치좌표가 적합한 위치인지 파악 불가능
- 사용자가 실내에서는 INS 시스템을 쓰도록하고, 외부에서는 GPS 를 쓰도록 사용자에게 맡겨야할 듯함.