Untitled

mail@pastecode.io avatar
unknown
kotlin
2 years ago
9.1 kB
3
Indexable
Never
package *.feature.signature.validation

import android.graphics.Path
import android.graphics.PathMeasure
import ua.ideabank.obank.feature.signature.signatureview.TimedPoint
import kotlin.math.acos
import kotlin.math.pow
import *.common.extensions.lastEvenNumber
import *.feature.signature.signatureview.Signature
import *.feature.signature.signatureview.TimedPoint

/**
 * Signature Validation requirements:
 *  1. Signature length must be greater than [minLength] pixels.
 *  2. Signature must contain a corner/curve/intersection with angle less than 70 degree.
 */
class SignatureValidator(
    signature: Signature,
    private val maxAngle: Float,
    private val minLength: Int
) {
    private val resampledStrokes = ArrayList<ArrayList<TimedPoint>>()

    init {
        signature.strokes.forEach { stroke ->
            resampledStrokes.add(resample(stroke))
        }
    }

    /**
     * Returns true if the signature is valid.
     */
    fun isValid() = resampledStrokes.any { isStrokeValid(it) }

    /**
     * Returns true if the provided stroke is valid.
     */
    private fun isStrokeValid(strokeA: ArrayList<TimedPoint>): Boolean {
        val strokeLengthInPixels = getStrokeLengthInPixels(strokeA.size)
        if (strokeLengthInPixels < minLength) {
            // Stroke too short. Stroke isn't valid.
            return false
        }
        return resampledStrokes.any {
            if (it == strokeA) {
                isSingleStrokeValid(strokeA)
            } else intersects(
                strokeA = strokeA,
                strokeB = it
            )
        }
    }

    /**
     * Returns true if the provided stroke is valid.
     *
     * Logic:
     *  1. For each stroke point:
     *      Take the next 20-40 points (or iterate over different sizes) to form a line T.
     *      Take first point of lineT as A, mid point of lineT as B and the end point as C.
     *      Calculate acute angle ∠ABC.
     *      If this angle is less than the [maxAngle], signature is valid.
     */
    private fun isSingleStrokeValid(strokePoints: ArrayList<TimedPoint>): Boolean {
        if (strokePoints.size < minLength) {
            // Stroke too short. Stroke isn't valid.
            return false
        }
        // Iterate over different segment lengths to account for small and large strokes.
        val minSegmentSize = minLength.lastEvenNumber() / 2
        val maxSegmentSize = minLength.lastEvenNumber() * 2
        (minSegmentSize until maxSegmentSize).forEach { segmentSize: Int ->
            strokePoints.forEachIndexed { index, _ ->
                val lastIndex = index + segmentSize
                if (lastIndex > strokePoints.indexOf(strokePoints.last())) {
                    // Reached end of the stroke. Stroke isn't valid. Try larger segment.
                    return@forEach
                }
                val lineT = strokePoints.slice(index..lastIndex)
                val pointA = lineT.first()
                val pointB = lineT[segmentSize / 2]
                val pointC = lineT.last()
                val angle = calculateAngle(pointA, pointB, pointC)
                if (angle.isNaN()) return@forEachIndexed
                if (angle <= maxAngle && pointB.distance(pointC) >= minLength / 2) return true
            }
        }
        return false
    }

    /**
     * Returns true if the provided [strokeA] && [strokeB] intersect each other
     * and are valid (angle of intersection > [maxAngle]). This is fast but not very accurate.
     *
     * Logic:
     *      Take the first and last point of stroke A to form lineA
     *      Take the first and last point of stroke B to form lineB
     *      Calculate angle of intersection.
     */
    private fun intersects(
        strokeA: ArrayList<TimedPoint>,
        strokeB: ArrayList<TimedPoint>
    ): Boolean {
        if (strokeA == strokeB || strokeA.isEmpty() || strokeB.isEmpty()) return false
        val lineA = Line(strokeA.first().toPointF(), strokeA.last().toPointF())
        val lineB = Line(strokeB.first().toPointF(), strokeB.last().toPointF())
        return lineA.intersects(lineB)
    }

    /**
     * Returns true if the provided [strokeA] && [strokeB] intersect each other
     * and are valid (angle of intersection > [maxAngle]). This is accurate but slow.
     *
     * Logic:
     *  1. For each stroke point:
     *      Take the next 20-40 points to form a line A.
     *      For each stroke point in each stroke:
     *          Take the next 20-40 points to for a line B.
     *      Calculate acute angle of intersection
     *      If this angle is less than the [maxAngle], signature is valid.
     */
    private fun intersects2(
        strokeA: ArrayList<TimedPoint>,
        strokeB: ArrayList<TimedPoint>
    ): Boolean {
        if (strokeA == strokeB || strokeA.isEmpty() || strokeB.isEmpty()) return false
        fun getNextSegment(stroke: ArrayList<TimedPoint>, startIndex: Int, endIndex: Int): Line {
            val lineVector = stroke.slice(startIndex..endIndex)
            return Line(
                pointA = lineVector.first().toPointF(),
                pointB = lineVector.last().toPointF()
            )
        }

        // Iterate over different segment lengths to account for small and large strokes.
        val minSegmentSize = minLength.lastEvenNumber()
        val maxSegmentSize = minLength.lastEvenNumber() * 2
        (minSegmentSize until maxSegmentSize).forEach { segmentSize: Int ->
            strokeA.forEachIndexed { startIndexA, _ ->
                val endIndexA = startIndexA + segmentSize
                if (endIndexA > strokeA.lastIndex) {
                    // Reached end of the signature stroke. Stroke isn't valid. Try larger segment.
                    return@forEach
                }
                val lineA = getNextSegment(strokeA, startIndexA, endIndexA)
                for (startIndexB in 0..strokeB.lastIndex) {
                    val endIndexB = startIndexB + segmentSize
                    if (endIndexB > strokeB.lastIndex) {
                        // Reached end of the signature strokeB. Stroke isn't valid. Try larger segment.
                        break
                    }
                    val lineB = getNextSegment(strokeB, startIndexB, endIndexB)
                    if (lineA.intersects(lineB)) {
                        val angleOfIntersection = lineA.angleOfIntersection(lineB)
                        if (angleOfIntersection.isNaN()) continue
                        if (angleOfIntersection in 1.0..maxAngle.toDouble()) return true
                    }
                }
            }
        }
        return false
    }

    /**
     * When a signature is acquired from typical touch sensitive
     * computing devices, it is typically sampled with non-uniform rate.
     * The rate depends on the availability of computational resources at a
     * given time as well as the latency. Therefore, interpolation is used in
     * order to derive a uniformly sampled signature.
     * This helps to minimize the variation of signatures due to different
     * sampling rates.
     *
     * For the sake of simplicity, using the [android.graphics.Path] for spline
     * interpolation and [android.graphics.PathMeasure] to calculate the coordinates.
     */
    private fun resample(strokePoints: ArrayList<TimedPoint>): ArrayList<TimedPoint> {
        val path = Path()
        var lastPoint: TimedPoint
        strokePoints.forEachIndexed { index, timedPoint ->
            lastPoint = timedPoint
            if (index == 0) {
                path.moveTo(timedPoint.x, timedPoint.y)
                return@forEachIndexed
            }
            path.quadTo(
                timedPoint.x,
                timedPoint.y,
                (lastPoint.x + timedPoint.x) / 2,
                (lastPoint.y + timedPoint.y) / 2
            )
        }
        val pathMeasure = PathMeasure(path, false)
        val length = pathMeasure.length.toInt()
        var distance = 0f
        val aCoordinates = FloatArray(2)
        val resampleStroke: ArrayList<TimedPoint> = ArrayList()
        repeat(length) {
            pathMeasure.getPosTan(distance, aCoordinates, null)
            resampleStroke.add(TimedPoint(aCoordinates[0], aCoordinates[1], 0))
            distance += RESAMPLING_FREQUENCY
        }
        return resampleStroke
    }

    /**
     * Returns the stroke size in pixels (approx). Used to check stroke length.
     */
    private fun getStrokeLengthInPixels(length: Int) = length * RESAMPLING_FREQUENCY

    /**
     * Calculates angle ∠abc given points [a], [b] and [c]
     */
    private fun calculateAngle(a: TimedPoint, b: TimedPoint, c: TimedPoint): Double {
        val ab = a.distance(b)
        val bc = b.distance(c)
        val ca = c.distance(a)
        val x = (ab.pow(2) + bc.pow(2) - ca.pow(2)) / (2 * ab * bc)
        val radians = acos(x)
        return Math.toDegrees(radians)
    }

    companion object {
        private val TAG = SignatureValidator::class.java.simpleName
        private const val RESAMPLING_FREQUENCY = 20L
    }
}