사실, 안드로이드에서 센서를 이용한 구현을 이전에 해보지 못했다. 이번에 필요한 기회가 생겨서 알아보는 김에 정리를 하기로 마음먹었다. 센서라는게 종류도 많고, 기기마다 달려있는 것도 다르고, 각각 주어지는 데이터도 다르기 때문에 모든걸 다룰 수는 없을거 같고, 기본적으로 안드로이드에서 SensorManager를 통해 센서를 사용하는 방법, 그 중에서 TYPE_ROTATION_VECTOR를 이용해 나침반과 같은걸 만들어 보려한다.
센서의 분류
안드로이드 공식 문서의 센서 개요부분을 보면, 센서들을 다음 3가지로 큰 분류를 하고 있다.
- Motion Sensors : 가속계(accelerometers), 중력계(gravity sensors), 자이로스코프(gyroscopes), 로테이션 벡터(rotation vector sensors)
- Position Sensors : 오리엔테이션 센서(orientation sensor), 지자기센서(magnetometers)
- Environment Sensors : 기압계(barometers), 온도계(thermometers), 광도계(photometers)
내가쓰는 삼성 a32 스펙을 확인해보면, 가속도센서, 지문센서, 자이로 센서, 지자기 센서, 홀 센서, RGB광 센서, 가상 근접 센싱이 있다고 되어 있다. 조금 낯선 이름의 홀 센서는 자기장에 따라 전류가 변하는 센서로 NFC용도로 보이고, 광센서는 조도 자동조절, 근접센서는 통화시 화면 오프용으로 보인다.
안드로이드에서 센서 프레임워크는 위에서 나열한 HW sensor들로부터 raw 데이터를 이용하기도 하지만, SW Sensor들도 존재한다. SW Sensor라 하면, HW 센서들의 각종 정보를 조합해서 사용자가 raw 데이터보다 접근하기 쉬운 데이터를 만들어낸다. 이렇게 HW든 SW든 타입을 정의해 놨는데, 안드로이드에서 지원하는 센서타입은 문서에 다음과 같이 나와있다.
Table 1. Sensor types supported by the Android platform.
Sensor | Type | Description | Common Uses |
---|---|---|---|
TYPE_ | Hardware | Measures the acceleration force in m/s2 that is applied to a device on all three physical axes (x, y, and z), including the force of gravity. | Motion detection (shake, tilt, etc.). |
TYPE_ | Hardware | Measures the ambient room temperature in degrees Celsius (°C). See note below. | Monitoring air temperatures. |
TYPE_ | Software or Hardware | Measures the force of gravity in m/s2 that is applied to a device on all three physical axes (x, y, z). | Motion detection (shake, tilt, etc.). |
TYPE_ | Hardware | Measures a device’s rate of rotation in rad/s around each of the three physical axes (x, y, and z). | Rotation detection (spin, turn, etc.). |
TYPE_ | Hardware | Measures the ambient light level (illumination) in lx. | Controlling screen brightness. |
TYPE_ | Software or Hardware | Measures the acceleration force in m/s2 that is applied to a device on all three physical axes (x, y, and z), excluding the force of gravity. | Monitoring acceleration along a single axis. |
TYPE_ | Hardware | Measures the ambient geomagnetic field for all three physical axes (x, y, z) in μT. | Creating a compass. |
TYPE_ | Software | Measures degrees of rotation that a device makes around all three physical axes (x, y, z). As of API level 3 you can obtain the inclination matrix and rotation matrix for a device by using the gravity sensor and the geomagnetic field sensor in conjunction with the getRotationMatrix() method. | Determining device position. |
TYPE_ | Hardware | Measures the ambient air pressure in hPa or mbar. | Monitoring air pressure changes. |
TYPE_ | Hardware | Measures the proximity of an object in cm relative to the view screen of a device. This sensor is typically used to determine whether a handset is being held up to a person’s ear. | Phone position during a call. |
TYPE_ | Hardware | Measures the relative ambient humidity in percent (%). | Monitoring dewpoint, absolute, and relative humidity. |
TYPE_ | Software or Hardware | Measures the orientation of a device by providing the three elements of the device’s rotation vector. | Motion detection and rotation detection. |
TYPE_ | Hardware | Measures the temperature of the device in degrees Celsius (°C). This sensor implementation varies across devices and this sensor was replaced with the TYPE_ sensor in API Level 14 | Monitoring temperatures. |
디바이스의 센서 목록을 한 번 살펴볼까?
프로그래밍적으로 센서 목록 및 정보를 SensorManager로부터 얻어올 수 있다. getSensorList(Sensor.TYPE_ALL)을 사용하면된다. getSensorList()함수는 인자로 센서 타입을 넘겨줘야 하는데, 위에 나열한 안드로이드 센서타입을 넘겨주면, 그 타입을 지원하는 센서 목록을 돌려준다. 여기서 센서 타입에 TYPE_ALL을 넘겨주면 모든 목록을 받아오는 것이다. 다음은 Fragment에서 사용한 예제코드이다. Fragment가 뜰 때, Timber 로그로 sensor 리스트 정보들을 뿌려주고 있다.
...
private lateinit var sensorManager: SensorManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
sensorManager = requireContext().getSystemService(Context.SENSOR_SERVICE) as SensorManager
}
...
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val deviceSensors: List<Sensor> = sensorManager.getSensorList(Sensor.TYPE_ALL)
deviceSensors.forEach { sensor ->
Timber.d("name: ${sensor.name}, type: ${sensor.stringType}, power: ${sensor.power}, " +
"resolution: ${sensor.resolution}, range: ${sensor.maximumRange}, " +
"vendor: ${sensor.vendor}, version: ${sensor.version}")
}
}
}
일단, 센서의 사용은 SensorManager를 통해 가능하다. getSystemService()를 통해 SensorManager를 얻어오는게 시작이다. 센서 목록을 얻어오는 getSensorList() 메소드는 SensorManager의 멤버로 얻어온 SensorManager에서 바로 사용이 가능하다.
name: BMI160 Accelerometer, type: android.sensor.accelerometer, power: 0.18, resolution: 0.0023956299, range: 39.22661, vendor: BOSCH, version: 2062705
name: BMI160 Accelerometer Uncalibrated, type: android.sensor.accelerometer_uncalibrated, power: 0.18, resolution: 0.0023956299, range: 39.22661, vendor: BOSCH, version: 2062705
name: MMC3630KJ Magnetometer, type: android.sensor.magnetic_field, power: 0.32, resolution: 0.09765625, range: 3000.0, vendor: MEMSIC, version: 1
...
위에서 출력결과의 일부를 보여주고 있는데 보면, 센서 이름, 타입, 레졸루션, 레인지, 벤더, 버전 등등의 정보를 돌려준다.
센서 유무 확인
위의 결과에서 알 수 있지만, 안드로이드에서 정한 하나의 센서 타입에 대해 여러개의 센서가 사용될 수 있다. getDefaultSensor() 함수를 이용하면, 이 여러개의 센서중에 디폴트 센서를 돌려주게 된다. 센서 타입을 지원하는 최소 하나의 센서가 있어야 해당 센서타입이 작동하는 것이므로, getDefaultSensor()로 값을 받아와서 값이 있으면, 센서를 지원하는 것이고, 값이 없이 null이면 지원하지 않는것이 된다. 예제코드는 다음과 같다.
if(sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR) != null) {
Timber.d("Get TYPE_ROTATION_VECTOR Success.")
}else {
Timber.d("Get TYPE_ROTATION_VECTOR failed.")
}
if(sensorManager.getDefaultSensor(Sensor.TYPE_HEART_BEAT) != null) {
Timber.d("Get TYPE_HEART_BEAT Success.")
}else {
Timber.d("Get TYPE_HEART_BEAT failed.")
}
Get TYPE_ROTATION_VECTOR Success.
Get TYPE_HEART_BEAT failed.
예제를 실행한 결과를 보면, 테스트한 디바이스에서 ROTATION_VECTOR 센서는 있지만, HEART_BEAT센서는 없는걸 알 수 있다.
구글 플레이 센서 필터의 사용
앱을 만들 때, 위의 방법으로 런타임에 센서를 체크할 수도 있지만, 센서가 없는 디바이스들이 앱을 다운받지 못하게 사전에 차단할 수도 있다. 바로 구글 플레이 필터를 이용하는 방법이다. Manifest 파일에 다음과 같이 사용할 수 있다.
<uses-feature android:name="android.hardware.sensor.accelerometer"
android:required="true" />
위에선 accelerometer를 지정했으므로 가속계 센서가 있어야 구글플레이에서 다운로드 가능해진다.
센서에서 데이터 받아오기
센서에서 데이터를 받아오려면, 콜백을 등록해 사용해야 한다. 콜백은 SensorEventListener 인터페이스를 상속받아 구현한다. Fragment를 사용한 앞의 예를 본다면, 다음과 같이 된다.
class CompassFragment : Fragment(), SensorEventListener {
...
override fun onSensorChanged(event: SensorEvent?) {
....
}
override fun onAccuracyChanged(sensor: Sensor?, accuracy: Int) {
....
}
SensorEventListner는 onSensorChanged()와 onAccuracyChanged()로 이루어져있다. onSensorChanged()는 센서 업데이트 주기에 따라 데이터 값을 주게 되며, onAccuracyChanged()는 정확도의 변경시 호출된다. 정확도의 예를들면, 구글맵등을 사용시, 8자모양으로 폰을 움직여 보정을 하라고 시키는 경우가 있는데, 정확도가 떨어지는 경우에 해당한다. 8자로 폰을 움직이다보면, 알고리즘은 모르겠지만, 실제로 정확도가 올라간다. onAccuracyChanged()로 넘어오는 정확도는 SensorManager 문서에서 SENSOR_STATUS_ACCURACY_HIGH, SENSOR_STATUS_ACCURACY_MEDIUM, SENSOR_STATUS_ACCURACY_LOW, SENSOR_STATUS_NO_CONTACT, SENSOR_STATUS_UNRELIABLE 로 제공된다.
위와같이 SensorEventListener를 구현했다면, 이것을 SensorManager에 사용할 센서타입과 센서를 넘겨주어 등록해야 한다. 보통은 Activity/Fragment의 Resume시에 등록하고 Pause시에 해제한다. UI가 백그라운드로 갔을 때, 일반적으로 센서를 사용할 필요가 없고 해제를 해줘야 배터리 사용을 줄여 최적화 할 수 있다. 이런 경우가 아니라면 상황에 맞게 등록과 해제를 통해 사용가능할 것이다. 예제 코드는 다음과 같다.
rotationVectorSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR)
if( rotationVectorSensor != null) {
Timber.d("Get TYPE_ROTATION_VECTOR Success.")
}else {
Timber.d("Get TYPE_ROTATION_VECTOR failed.")
}
...
override fun onResume() {
super.onResume()
rotationVectorSensor?.also { sensor ->
sensorManager.registerListener(this, sensor, SensorManager.SENSOR_DELAY_NORMAL)
}
}
override fun onPause() {
super.onPause()
sensorManager.unregisterListener(this)
}
코드를 보면, getDefaultSensor로 TYPE_ROTATION_VECTOR 타입 센서를 얻어오고 있다. Resume단계 이전에만 해주면 되는 작업이다. onResume()에서는 해당 센서가 있는 경우, SensorManager.registerListener()를 이용하여 등록, SensorManager.unregisterListener()를 이용하여 해제해 주고 있다. registerListener()에는 SensorEventListner 인스턴스, 앞에서 SensorManager로부터 얻어온 사용할 센서, 그리고 센서 타입을 넘겨준다.
이렇게 콜백을 등록하고 나면, onSensorChanged()가 불리는데, SensorEvent 값이 넘어오게 되고, 여기에 들어있는 sensor로부터 타입을 체크하여 값을 처리하면 된다. 예제는 다음과 같다.
override fun onSensorChanged(event: SensorEvent?) {
event?.sensor?.run {
if(type == Sensor.TYPE_ROTATION_VECTOR){
...
좌표계
센서로부터 데이터를 받아오는 기본적인 방법을 살펴봤는데, 이걸 이용해서 간단한 나침반기능을 구현해보자. 그전에 먼저 알아야할 것들이 좀 있다. 첫번째로 좌표계이다. 여러 센서들이 데이터를 넘겨주는데, 좌표계는 그 데이터의 기준이 된다.
센서에서 사용하는 디바이스의 좌표계는 다음 그림과 같다.

오른손 좌표계를 사용하는데, 디바이스의 회전과는 무관하게 유지된다. 주의할 점은, 디바이스의 natural orientation이 기준이 되는데, 이게 타블렛과 같이 항상 포트레이트 모드는 아니라는 점이다. 이런 것들을 보정하기위해 getRotation()과 remapCoordinateSystem()을 이용하면 센서 좌표계를 스크린 좌표계로 변환이 가능하다.
Orientation sensor나 Rotation Vector sensor는 디바이스의 모션이나 포지션을 지구 좌표계를 기준으로 계산해서 알려준다. getRotationMatrix() 과 getOrientation() 함수들을 이용하여 지구 좌표계 기준으로 디바이스의 Azimuth(방위각, -z축 기준 회전각), Pitch( x축 기준 회전각), Roll( y축 기준 회전각) 값을 얻어올 수 있으니, 참조하기 바란다. 지구 좌표계는 지표면 상에서를 의미하며 다음 그림과 같다.

Rotation Vector
rotation vector는 Axis-angle 표현으로 방향벡터와 그 벡터의 회전값 θ 두 개로 표현된다. 이 표현방식은 강체(Rigid body)의 상태를 표시하기에 적합하다. 여기선 핸드폰을 생각하면 된다.
SensorEvent 문서를 보면, 각 센서에 대해 values가 어떤 값을 갖는지 나와있다. TYPE_ROTATION_VECTOR에 대한 값을 확인해보면,
- values[0]: x*sin(θ/2)
- values[1]: y*sin(θ/2)
- values[2]: z*sin(θ/2)
- values[3]: cos(θ/2)
- values[4]: estimated heading Accuracy (in radians) (-1 if unavailable)
와 같이 값이 들어온다고 한다. (x, y, z)가 방향벡터이고, θ 가 회전각이다. 이 Rotation vector는 바로 사용하기 어려운 형태이다. 쓰기쉽게 변환을 해줘야 하는데, 복잡한 과정을 직접 다 계산할 필요는 없고 SensorManager에서 필요한 함수들을 제공해준다.
Roll-Pitch-Azimuth
사용 편의성을 위해 위에서 받은 rotation vector를 Roll-Pitch-Azimuth 값을 같는 벡터로 변환해서 사용한다. Roll-Pitch-Azimuth는 지구 좌표계를 기준으로 폰의 회전 상태를 나타낸다. 이것은 항공기에서 Roll-Pitch-Yaw로 항공기의 현재 상태를 표시하는 것과 유사하다. 항공기의 경우 다음과 같이 Roll, Pitch, Yaw 3가지 회전상태를 이용하여 현재 상태를 나타낸다.

Roll-Pitch-Azimuth도 Roll-Pitch-Yaw와 유사하지만, Yaw가 +Z축 방향이라면, Azimuth는 -Z축 방향이다.

기준 좌표계는 앞에서 말한 지구좌표계를 참고하자. 즉, Y 방향이 북쪽이다. -Z를 오른손으로 감아보면 시계방향 회전이 되는데, 이것이 바로 방위각이며, 그래서 Yaw란 표현대신 Azimuth(방위각)란 표현을 쓰고 있다.
그러면, rotation vector로부터 Roll-Pitch-Azimuth값으로 변환해보자. getRotationMatrixFromVector() 와 getOrientation() 함수가 쓰인다. 예제는 다음과 같다.
val rotationMatrix = FloatArray(16)
SensorManager.getRotationMatrixFromVector(rotationMatrix, event.values)
val orientation = FloatArray(3)
SensorManager.getOrientation(rotationMatrix, orientation)
주의해야 할 것은 getRotationMatrixFromVector()에서 벡터 회전의 매트릭스 표현인 rotationMatrix를 받아올 때, 그 크기가 16이라는 점이다. 문서를 보면, 9~16 값이라고 되어있지만, 선형대수에 나오듯이 4X4 매트릭스, 즉 16개의 배열을 써야 완전한 표현이 가능하다.
이렇게 얻어온 rotationMatrix를 getOrientation()함수를 이용하여 orientation vector를 얻어오고 있다. getOrientation() 문서를 보면, 벡터의 각 값은 다음과 같다.
- orientation[0] : Azimuth, -Z축 중심 회전각, 값의 범위는 -π 부터 π 까지
- orientation[1] : Pitch, X축 중심 회전각, 값의 범위는 -π 부터 π 까지
- orientation[2] : Roll, Y축 중심 회전각, 값의 범위는 -π 부터 π 까지
구글 맵의 Bearing값을 자동으로
이제 원하는 값을 얻었다. 그럼 이걸 어디에 이용할까? 첫번째 용도는 구글맵과 같은 맵의 자동 회전에 쓸 수 있다. 먼저 구글맵의 카메라가 지도를 어떻게 바라보는지 알아보자. 구글맵 문서를 보면 다음과 같은 그림을 볼 수 있다.

그림을 보면, bearing이 바로 방위각, Azimuth와 일치하는걸 볼 수 있다. 다만, 앞에서 얻어온 값은 radian값이므로 degree로 변환해줘야 한다. Math.toDegrees()를 이용하면된다.
val orientationDeg = FloatArray(3)
orientation.forEachIndexed { index, element ->
orientationDeg[index] = Math.toDegrees(element.toDouble()).toFloat()
}
Azimuth가 -180( -π ) 부터 180( π )도의 범위를 가지므로 이걸 0-360도로 쉬프트 시켜줘야한다.
val bearing: Float = ((orientationDeg[0] + declination + 360f)%360)
이렇게 변경한 bearing 값을 googlemap camera에 적용한다. 코드는 다음과 같다.
val position: CameraPosition = CameraPosition.Builder()
.target(googleMap.cameraPosition.target)
.zoom(googleMap.cameraPosition.zoom)
.tilt(googleMap.cameraPosition.tilt)
.bearing(bearing)
.build()
googleMap.animateCamera(CameraUpdateFactory.newCameraPosition(position))
구글 맵 관련 작업은 생략되어 있는데, googleMap이 바로 구글맵 객체이다. 카메라의 다른 값들은 기존에 googleMap에 있는 값들을 유지하고 bearing값만 새로 넣었다. 이렇게하면, 다른 맵 앱들처럼 폰의 방향에 따라 지도를 회전 시킬 수 있다.
나침반(Compass)에 적용해보자
하다보니 자연스럽게 나침반은 따라오게 된다. 앞에서 구글맵에서 작업한 것과 동일하다. 중복이지만, 코드를 써보면 다음과 같다.
val orientationDeg = FloatArray(3)
orientation.forEachIndexed { index, element ->
orientationDeg[index] = Math.toDegrees(element.toDouble()).toFloat()
}
val azimuthCorrectFloat: Float = ((orientationDeg[0] + 360f)%360)
추가로 작업할 부분은 바로 지자기 편각 (declination)이다. 자기장 북극과 실제 지구의 북극이 다르기 때문에 생기는 그 차이각이 편각이다. 나침반을 사용한다면, 이 값을 보정해줄 필요가 있다. 문제는 편각이 위치와 시간에 따라 항상 변화한다는 것인데, 그 변화가 크진 않아서 레퍼런스 값을 사용해도 수년간 큰 문제는 없다고 한다. 어쨌든, 그 레퍼런스 값도 경도, 위도, 고도, 시간에 따라 달라지는데, 주기적으로 WMM(World Magnetic Model)이란게 발행되고 이 표를 이용하면 된다. 안드로이드에선 GeomagneticField 클래스를 통해 이를 제공하고 있다. GeomagneticField 문서를 보면, 현재는 WMM-2020이 사용되고 있다고 한다.
location 정보를 가져오는 경우는 현재 위치값을 사용하는게 가장 정확하다. 다만, 한 지역에서 크게 변하는 값이 아니므로 예제에서는 서울의 중심값을 이용했다. 예제는 다음과 같다.
object ApplicationConstants {
const val SEOUL_LATITUDE = 37.566535
const val SEOUL_LONGITUDE = 126.9779692
const val SEOULT_ALTITUDE = 38.0
}
...
val declination = GeomagneticField(ApplicationConstants.SEOUL_LATITUDE.toFloat(),
ApplicationConstants.SEOUL_LONGITUDE.toFloat(),
ApplicationConstants.SEOULT_ALTITUDE.toFloat(),
System.currentTimeMillis()).declination
...
val azimuthCorrectFloat: Float = ((orientationDeg[0] + declination + 360f)%360)
코드를 보면, GeomagneticField() 생성자에 서울의 경도, 위도, 고도를 넣었고, 시간은 시스템 현재 시간을 가져와 넣어줬다. 이렇게 생성해서 declination값을 가져왔다.
방위각의 계산은 앞서 구글맵에서 한 것과 동일하나, 최종 값을 계산할 때 이 declination을 더해줬다. 서울의 경우 서쪽으로 치우친 대략 -8도정도가 나온다.
마무리
센서의 사용과 TYPE_ROTATION_VECTOR에 필요한 정보는 충분히 적었다고 생각하지만, 그냥 생각나는거 좀 더 적어본다.
나침반을 구현하는 방법을 검색해보면 TYPE_ROTATION_VECTOR 말고도 TYPE_ACCELEROMETER 와 TYPE_MAGNETIC_FIELD 를 조합하는 방법이 나와있다. 조금 다르지만, 배경 지식은 크게 다르지 않다.
설명하는 순서상, 편각을 나침반 부분에서만 다뤘는데, 구글맵에도 적용하면 당연히 더 정확해진다. 구글맵에선 위치정보도 실시간으로 들어오니까 사용하기 더 쉬울 것이다.
코드로 구현하지 않은 부분이 onAccuracyChanged() 부분인데, 직접 해보기 바란다. 처음에 할 때 감도가 낮음이 나왔는데, 한 번 8자로 돌리는 보정을 하고나서 집에서는 계속 좋음이 뜨고 있다. 이부분은 나도 여러가지로 더 테스트를 해봐야 할거 같다. 오랫만에 길고긴 포스팅이었는데, 그래도 다뤄야할거 다 다룬거 같아서 후련함. 수고.