javaandroidandroid-sensorscompass-geolocationsensor-fusion

Implementing current cardinal direction method with TYPE_ROTATION_VECTOR while standing still?


There appear to be many old examples of getting the current cardinal direction on Android devices, but an official solution provided by Google does not appear to be in their documentation.

The oldest reference Sensor.TYPE_ORIENTATION which is deprecated, more recent ones mention Sensor.TYPE_ACCELEROMETER and Sensor.TYPE_MAGNETIC_FIELD(which I have tried with little success - accuracy shifts rapidly depending on device orientation). I've been experimenting with implementations using those two like this. I've even seen some with TYPE.GRAVITY.

The most recent seem to suggest TYPE_ROTATION_VECTOR which apparently is a fused sensor(reference), but example implementations do not seem to be readily available.

I need to use these position/motion sensors, and not GPS, because the user will not be moving during the time when this measurement is needed. Also need the measurement to be stable regardless of whether phone is flat, or vertical(as if you are taking a photo)

After we pull the degree measurement somehow, converting to cardinal direction seems to be the easy part.(https://stackoverflow.com/a/25349774/1238737)

Previous solutions

  1. How to get Direction in Android (Such as North, West)
  2. https://stackoverflow.com/a/11068878/1238737

Solution

  • I was working on open source map projects such as OsmAnd, MapsWithMe and MapBox before. I think these projects are the best available android open sources in the field of map and navigation. I've checked their codes and found that MapBox approach to show compass is stable when the phone is vertical then rotating around vertical axis (y). It uses TYPE_ROTATION_VECTOR if the rotation vector sensor is available. Otherwise it uses TYPE_ORIENTATION sensor or combination of TYPE_ACCELEROMETER and TYPE_MAGNETIC_FIELD. In case of using TYPE_ACCELEROMETER and TYPE_MAGNETIC_FIELD, it is possible to reduce the oscillation in result by a low-pass filter to achieve smoother values.

    enter image description here

    Here is the compass engine of MapBox and its usage.
    .

    LocationComponentCompassEngine.java:

    import android.hardware.Sensor;
    import android.hardware.SensorEvent;
    import android.hardware.SensorEventListener;
    import android.hardware.SensorManager;
    import android.os.SystemClock;
    import android.support.annotation.NonNull;
    import android.support.annotation.Nullable;
    import android.view.Surface;
    import android.view.WindowManager;
    import timber.log.Timber;
    
    import java.util.ArrayList;
    import java.util.List;
    
    /**
     * This manager class handles compass events such as starting the tracking of device bearing, or
     * when a new compass update occurs.
     */
    public class LocationComponentCompassEngine implements SensorEventListener {
    
        // The rate sensor events will be delivered at. As the Android documentation states, this is only
        // a hint to the system and the events might actually be received faster or slower then this
        // specified rate. Since the minimum Android API levels about 9, we are able to set this value
        // ourselves rather than using one of the provided constants which deliver updates too quickly for
        // our use case. The default is set to 100ms
        private static final int SENSOR_DELAY_MICROS = 100 * 1000;
        // Filtering coefficient 0 < ALPHA < 1
        private static final float ALPHA = 0.45f;
    
        // Controls the compass update rate in milliseconds
        private static final int COMPASS_UPDATE_RATE_MS = 500;
    
        private final WindowManager windowManager;
        private final SensorManager sensorManager;
        private final List<CompassListener> compassListeners = new ArrayList<>();
    
        // Not all devices have a compassSensor
        @Nullable
        private Sensor compassSensor;
        @Nullable
        private Sensor gravitySensor;
        @Nullable
        private Sensor magneticFieldSensor;
    
        private float[] truncatedRotationVectorValue = new float[4];
        private float[] rotationMatrix = new float[9];
        private float[] rotationVectorValue;
        private float lastHeading;
        private int lastAccuracySensorStatus;
    
        private long compassUpdateNextTimestamp;
        private float[] gravityValues = new float[3];
        private float[] magneticValues = new float[3];
    
        /**
         * Construct a new instance of the this class. A internal compass listeners needed to separate it
         * from the cleared list of public listeners.
         */
        LocationComponentCompassEngine(WindowManager windowManager, SensorManager sensorManager) {
            this.windowManager = windowManager;
            this.sensorManager = sensorManager;
            compassSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ROTATION_VECTOR);
            if (compassSensor == null) {
                if (isGyroscopeAvailable()) {
                    Timber.d("Rotation vector sensor not supported on device, falling back to orientation.");
                    compassSensor = sensorManager.getDefaultSensor(Sensor.TYPE_ORIENTATION);
                } else {
                    Timber.d("Rotation vector sensor not supported on device, falling back to accelerometer and magnetic field.");
                    gravitySensor = sensorManager.getDefaultSensor(Sensor.TYPE_ACCELEROMETER);
                    magneticFieldSensor = sensorManager.getDefaultSensor(Sensor.TYPE_MAGNETIC_FIELD);
                }
            }
        }
    
        public void addCompassListener(@NonNull CompassListener compassListener) {
            if (compassListeners.isEmpty()) {
                onStart();
            }
            compassListeners.add(compassListener);
        }
    
        public void removeCompassListener(@NonNull CompassListener compassListener) {
            compassListeners.remove(compassListener);
            if (compassListeners.isEmpty()) {
                onStop();
            }
        }
    
        public int getLastAccuracySensorStatus() {
            return lastAccuracySensorStatus;
        }
    
        public float getLastHeading() {
            return lastHeading;
        }
    
        public void onStart() {
            registerSensorListeners();
        }
    
        public void onStop() {
            unregisterSensorListeners();
        }
    
        @Override
        public void onSensorChanged(SensorEvent event) {
            // check when the last time the compass was updated, return if too soon.
            long currentTime = SystemClock.elapsedRealtime();
            if (currentTime < compassUpdateNextTimestamp) {
                return;
            }
            if (lastAccuracySensorStatus == SensorManager.SENSOR_STATUS_UNRELIABLE) {
                Timber.d("Compass sensor is unreliable, device calibration is needed.");
                return;
            }
            if (event.sensor.getType() == Sensor.TYPE_ROTATION_VECTOR) {
                rotationVectorValue = getRotationVectorFromSensorEvent(event);
                updateOrientation();
    
                // Update the compassUpdateNextTimestamp
                compassUpdateNextTimestamp = currentTime + COMPASS_UPDATE_RATE_MS;
            } else if (event.sensor.getType() == Sensor.TYPE_ORIENTATION) {
                notifyCompassChangeListeners((event.values[0] + 360) % 360);
            } else if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
                gravityValues = lowPassFilter(getRotationVectorFromSensorEvent(event), gravityValues);
                updateOrientation();
            } else if (event.sensor.getType() == Sensor.TYPE_MAGNETIC_FIELD) {
                magneticValues = lowPassFilter(getRotationVectorFromSensorEvent(event), magneticValues);
                updateOrientation();
            }
        }
    
        @Override
        public void onAccuracyChanged(Sensor sensor, int accuracy) {
            if (lastAccuracySensorStatus != accuracy) {
                for (CompassListener compassListener : compassListeners) {
                    compassListener.onCompassAccuracyChange(accuracy);
                }
                lastAccuracySensorStatus = accuracy;
            }
        }
    
        private boolean isGyroscopeAvailable() {
            return sensorManager.getDefaultSensor(Sensor.TYPE_GYROSCOPE) != null;
        }
    
        @SuppressWarnings("SuspiciousNameCombination")
        private void updateOrientation() {
            if (rotationVectorValue != null) {
                SensorManager.getRotationMatrixFromVector(rotationMatrix, rotationVectorValue);
            } else {
                // Get rotation matrix given the gravity and geomagnetic matrices
                SensorManager.getRotationMatrix(rotationMatrix, null, gravityValues, magneticValues);
            }
    
            final int worldAxisForDeviceAxisX;
            final int worldAxisForDeviceAxisY;
    
            // Remap the axes as if the device screen was the instrument panel,
            // and adjust the rotation matrix for the device orientation.
            switch (windowManager.getDefaultDisplay().getRotation()) {
                case Surface.ROTATION_90:
                    worldAxisForDeviceAxisX = SensorManager.AXIS_Z;
                    worldAxisForDeviceAxisY = SensorManager.AXIS_MINUS_X;
                    break;
                case Surface.ROTATION_180:
                    worldAxisForDeviceAxisX = SensorManager.AXIS_MINUS_X;
                    worldAxisForDeviceAxisY = SensorManager.AXIS_MINUS_Z;
                    break;
                case Surface.ROTATION_270:
                    worldAxisForDeviceAxisX = SensorManager.AXIS_MINUS_Z;
                    worldAxisForDeviceAxisY = SensorManager.AXIS_X;
                    break;
                case Surface.ROTATION_0:
                default:
                    worldAxisForDeviceAxisX = SensorManager.AXIS_X;
                    worldAxisForDeviceAxisY = SensorManager.AXIS_Z;
                    break;
            }
    
            float[] adjustedRotationMatrix = new float[9];
            SensorManager.remapCoordinateSystem(rotationMatrix, worldAxisForDeviceAxisX,
                    worldAxisForDeviceAxisY, adjustedRotationMatrix);
    
            // Transform rotation matrix into azimuth/pitch/roll
            float[] orientation = new float[3];
            SensorManager.getOrientation(adjustedRotationMatrix, orientation);
    
            // The x-axis is all we care about here.
            notifyCompassChangeListeners((float) Math.toDegrees(orientation[0]));
        }
    
        private void notifyCompassChangeListeners(float heading) {
            for (CompassListener compassListener : compassListeners) {
                compassListener.onCompassChanged(heading);
            }
            lastHeading = heading;
        }
    
        private void registerSensorListeners() {
            if (isCompassSensorAvailable()) {
                // Does nothing if the sensors already registered.
                sensorManager.registerListener(this, compassSensor, SENSOR_DELAY_MICROS);
            } else {
                sensorManager.registerListener(this, gravitySensor, SENSOR_DELAY_MICROS);
                sensorManager.registerListener(this, magneticFieldSensor, SENSOR_DELAY_MICROS);
            }
        }
    
        private void unregisterSensorListeners() {
            if (isCompassSensorAvailable()) {
                sensorManager.unregisterListener(this, compassSensor);
            } else {
                sensorManager.unregisterListener(this, gravitySensor);
                sensorManager.unregisterListener(this, magneticFieldSensor);
            }
        }
    
        private boolean isCompassSensorAvailable() {
            return compassSensor != null;
        }
    
        /**
         * Helper function, that filters newValues, considering previous values
         *
         * @param newValues      array of float, that contains new data
         * @param smoothedValues array of float, that contains previous state
         * @return float filtered array of float
         */
        private float[] lowPassFilter(float[] newValues, float[] smoothedValues) {
            if (smoothedValues == null) {
                return newValues;
            }
            for (int i = 0; i < newValues.length; i++) {
                smoothedValues[i] = smoothedValues[i] + ALPHA * (newValues[i] - smoothedValues[i]);
            }
            return smoothedValues;
        }
    
        /**
         * Pulls out the rotation vector from a SensorEvent, with a maximum length
         * vector of four elements to avoid potential compatibility issues.
         *
         * @param event the sensor event
         * @return the events rotation vector, potentially truncated
         */
        @NonNull
        private float[] getRotationVectorFromSensorEvent(@NonNull SensorEvent event) {
            if (event.values.length > 4) {
                // On some Samsung devices SensorManager.getRotationMatrixFromVector
                // appears to throw an exception if rotation vector has length > 4.
                // For the purposes of this class the first 4 values of the
                // rotation vector are sufficient (see crbug.com/335298 for details).
                // Only affects Android 4.3
                System.arraycopy(event.values, 0, truncatedRotationVectorValue, 0, 4);
                return truncatedRotationVectorValue;
            } else {
                return event.values;
            }
        }
    
        public static float shortestRotation(float heading, float previousHeading) {
            double diff = previousHeading - heading;
            if (diff > 180.0f) {
                heading += 360.0f;
            } else if (diff < -180.0f) {
                heading -= 360.f;
            }
            return heading;
        }
    
    }
    

    CompassListener.java:

    /**
     * Callbacks related to the compass
     */
    public interface CompassListener {
    
        /**
         * Callback's invoked when a new compass update occurs. You can listen into the compass updates
         * using {@link LocationComponent#addCompassListener(CompassListener)} and implementing these
         * callbacks. Note that this interface is also used internally to to update the UI chevron/arrow.
         *
         * @param userHeading the new compass heading
         */
        void onCompassChanged(float userHeading);
    
        /**
         * This gets invoked when the compass accuracy status changes from one value to another. It
         * provides an integer value which is identical to the {@code SensorManager} class constants:
         * <ul>
         * <li>{@link android.hardware.SensorManager#SENSOR_STATUS_NO_CONTACT}</li>
         * <li>{@link android.hardware.SensorManager#SENSOR_STATUS_UNRELIABLE}</li>
         * <li>{@link android.hardware.SensorManager#SENSOR_STATUS_ACCURACY_LOW}</li>
         * <li>{@link android.hardware.SensorManager#SENSOR_STATUS_ACCURACY_MEDIUM}</li>
         * <li>{@link android.hardware.SensorManager#SENSOR_STATUS_ACCURACY_HIGH}</li>
         * </ul>
         *
         * @param compassStatus the new accuracy of this sensor, one of
         *                      {@code SensorManager.SENSOR_STATUS_*}
         */
        void onCompassAccuracyChange(int compassStatus);
    
    }
    

    MainActivity.java:

    import android.content.Context;
    import android.hardware.SensorManager;
    import android.os.Bundle;
    import android.support.annotation.Nullable;
    import android.support.v7.app.AppCompatActivity;
    import android.view.WindowManager;
    import android.widget.TextView;
    
    import java.util.Locale;
    
    public class MainActivity extends AppCompatActivity {
    
        private LocationComponentCompassEngine compassEngine;
        private float previousCompassBearing = -1f;
    
        @Override
        protected void onCreate(@Nullable Bundle savedInstanceState) {
            super.onCreate(savedInstanceState);
            setContentView(R.layout.activity_main);
            final TextView textView = findViewById(R.id.textView);
    
            CompassListener compassListener = new CompassListener() {
    
                @Override
                public void onCompassChanged(float targetCompassBearing) {
                    if (previousCompassBearing < 0) {
                        previousCompassBearing = targetCompassBearing;
                    }
                    float normalizedBearing =
                            LocationComponentCompassEngine.shortestRotation(targetCompassBearing, previousCompassBearing);
                    previousCompassBearing = targetCompassBearing;
    
                    String status = "NO_CONTACT";
                    switch (compassEngine.getLastAccuracySensorStatus()) {
                        case SensorManager.SENSOR_STATUS_NO_CONTACT:
                            status = "NO_CONTACT";
                            break;
                        case SensorManager.SENSOR_STATUS_UNRELIABLE:
                            status = "UNRELIABLE";
                            break;
                        case SensorManager.SENSOR_STATUS_ACCURACY_LOW:
                            status = "ACCURACY_LOW";
                            break;
                        case SensorManager.SENSOR_STATUS_ACCURACY_MEDIUM:
                            status = "ACCURACY_MEDIUM";
                            break;
                        case SensorManager.SENSOR_STATUS_ACCURACY_HIGH:
                            status = "ACCURACY_HIGH";
                            break;
                    }
    
                    textView.setText(String.format(Locale.getDefault(),
                            "CompassBearing: %f\nAccuracySensorStatus: %s", normalizedBearing, status));
                }
    
                @Override
                public void onCompassAccuracyChange(int compassStatus) {
                }
    
            };
    
            WindowManager windowManager = (WindowManager) getSystemService(Context.WINDOW_SERVICE);
            SensorManager sensorManager = (SensorManager) getSystemService(Context.SENSOR_SERVICE);
            compassEngine = new LocationComponentCompassEngine(windowManager, sensorManager);
            compassEngine.addCompassListener(compassListener);
    
            compassEngine.onStart();
        }
    
        @Override
        protected void onDestroy() {
            super.onDestroy();
            compassEngine.onStop();
        }
    
    }