RectangleFeaturesFunnel.swift 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168
  1. //
  2. // RectangleFeaturesFunnel.swift
  3. // WeScan
  4. //
  5. // Created by Boris Emorine on 2/9/18.
  6. // Copyright © 2018 WeTransfer. All rights reserved.
  7. //
  8. import Foundation
  9. import AVFoundation
  10. /// `RectangleFeaturesFunnel` is used to improve the confidence of the detected rectangles.
  11. /// Feed rectangles to a `RectangleFeaturesFunnel` instance, and it will call the completion block with a rectangle whose confidence is high enough to be displayed.
  12. final class RectangleFeaturesFunnel {
  13. /// `RectangleMatch` is a class used to assign matching scores to rectangles.
  14. private final class RectangleMatch: NSObject {
  15. /// The rectangle feature object associated to this `RectangleMatch` instance.
  16. let rectangleFeature: CIRectangleFeature
  17. /// The score to indicate how strongly the rectangle of this instance matches other recently added rectangles.
  18. /// A higher score indicates that many recently added rectangles are very close to the rectangle of this instance.
  19. var matchingScore = 0
  20. init(rectangleFeature: CIRectangleFeature) {
  21. self.rectangleFeature = rectangleFeature
  22. }
  23. override var description: String {
  24. return "Matching score: \(matchingScore) - Rectangle: \(rectangleFeature)"
  25. }
  26. /// Whether the rectangle of this instance is within the distance of the given rectangle.
  27. ///
  28. /// - Parameters:
  29. /// - rectangle: The rectangle to compare the rectangle of this instance with.
  30. /// - threshold: The distance used to determinate if the rectangles match in pixels.
  31. /// - Returns: True if both rectangles are within the given distance of each other.
  32. func matches(_ rectangle: CIRectangleFeature, withThreshold threshold: CGFloat) -> Bool {
  33. return rectangleFeature.isWithin(threshold, ofRectangleFeature: rectangle)
  34. }
  35. }
  36. /// The queue of last added rectangles. The first rectangle is oldest one, and the last rectangle is the most recently added one.
  37. private var rectangles = [RectangleMatch]()
  38. /// The maximum number of rectangles to compare newly added rectangles with. Determines the maximum size of `rectangles`. Increasing this value will impact performance.
  39. let maxNumberOfRectangles = 8
  40. /// The minimum number of rectangles needed to start making comparaisons and determining which rectangle to display. This value should always be inferior than `maxNumberOfRectangles`.
  41. /// A higher value will delay the first time a rectangle is displayed.
  42. let minNumberOfRectangles = 3
  43. /// The value in pixels used to determine if two rectangle match or not. A higher value will prevent displayed rectangles to be refreshed. On the opposite, a smaller value will make new rectangles be displayed constantly.
  44. let matchingThreshold: CGFloat = 40.0
  45. /// The minumum number of matching rectangles (within the `rectangle` queue), to be confident enough to display a rectangle.
  46. let minNumberOfMatches = 2
  47. /// Add a rectangle to the funnel, and if a new rectangle should be displayed, the completion block will be called.
  48. /// The algorithm works the following way:
  49. /// 1. Makes sure that the funnel has been fed enough rectangles
  50. /// 2. Removes old rectangles if needed
  51. /// 3. Compares all of the recently added rectangles to find out which one match each other
  52. /// 4. Within all of the recently added rectangles, finds the "best" one (@see `bestRectangle(withCurrentlyDisplayedRectangle:)`)
  53. /// 5. If the best rectangle is different than the currently displayed rectangle, informs the listener that a new rectangle should be displayed
  54. /// - Parameters:
  55. /// - rectangleFeature: The rectangle to feed to the funnel.
  56. /// - currentRectangle: The currently displayed rectangle. This is used to avoid displaying very close rectangles.
  57. /// - completion: The completion block called when a new rectangle should be displayed.
  58. func add(_ rectangleFeature: CIRectangleFeature, currentlyDisplayedRectangle currentRectangle: CIRectangleFeature?, completion: (CIRectangleFeature) -> Void) {
  59. let rectangleMatch = RectangleMatch(rectangleFeature: rectangleFeature)
  60. rectangles.append(rectangleMatch)
  61. guard rectangles.count >= minNumberOfRectangles else {
  62. return
  63. }
  64. if rectangles.count > maxNumberOfRectangles {
  65. rectangles.removeFirst()
  66. }
  67. updateRectangleMatches()
  68. guard let bestRectangle = bestRectangle(withCurrentlyDisplayedRectangle: currentRectangle) else {
  69. return
  70. }
  71. if let previousRectangle = currentRectangle,
  72. bestRectangle.rectangleFeature.isWithin(matchingThreshold, ofRectangleFeature: previousRectangle) {
  73. } else if bestRectangle.matchingScore >= minNumberOfMatches {
  74. completion(bestRectangle.rectangleFeature)
  75. }
  76. }
  77. /// Determines which rectangle is best to displayed.
  78. /// The criteria used to find the best rectangle is its matching score.
  79. /// If multiple rectangles have the same matching score, we use a tie breaker to find the best rectangle (@see breakTie(forRectangles:)).
  80. /// Parameters:
  81. /// - currentRectangle: The currently displayed rectangle. This is used to avoid displaying very close rectangles.
  82. /// Returns: The best rectangle to display given the current history.
  83. private func bestRectangle(withCurrentlyDisplayedRectangle currentRectangle: CIRectangleFeature?) -> RectangleMatch? {
  84. var bestMatch: RectangleMatch?
  85. rectangles.reversed().forEach { (rectangle) in
  86. guard let best = bestMatch else {
  87. bestMatch = rectangle
  88. return
  89. }
  90. if rectangle.matchingScore > best.matchingScore {
  91. bestMatch = rectangle
  92. return
  93. } else if rectangle.matchingScore == best.matchingScore {
  94. guard let currentRectangle = currentRectangle else {
  95. return
  96. }
  97. bestMatch = breakTie(between: best, rect2: rectangle, currentRectangle: currentRectangle)
  98. }
  99. }
  100. return bestMatch
  101. }
  102. /// Breaks a tie between two rectangles to find out which is best to display.
  103. /// The first passed rectangle is returned if no other criteria could be used to break the tie.
  104. /// If the first passed rectangle (rect1) is close to the currently displayed rectangle, we pick it.
  105. /// Otherwise if the second passed rectangle (rect2) is close to the currently displayed rectangle, we pick this one.
  106. /// Finally, if none of the passed in rectangles are close to the currently displayed rectangle, we arbitrary pick the first one.
  107. /// - Parameters:
  108. /// - rect1: The first rectangle to compare.
  109. /// - rect2: The second rectangle to compare.
  110. /// - currentRectangle: The currently displayed rectangle. This is used to avoid displaying very close rectangles.
  111. /// - Returns: The best rectangle to display between two rectangles with the same matching score.
  112. private func breakTie(between rect1: RectangleMatch, rect2: RectangleMatch, currentRectangle: CIRectangleFeature) -> RectangleMatch {
  113. if rect1.rectangleFeature.isWithin(matchingThreshold, ofRectangleFeature: currentRectangle) {
  114. return rect1
  115. } else if rect2.rectangleFeature.isWithin(matchingThreshold, ofRectangleFeature: currentRectangle) {
  116. return rect2
  117. }
  118. return rect1
  119. }
  120. /// Loops through all of the rectangles of the queue, and gives them a score depending on how many they match. @see `RectangleMatch.matchingScore`
  121. private func updateRectangleMatches() {
  122. resetMatchingScores()
  123. for (i, currentRect) in rectangles.enumerated() {
  124. for (j, rect) in rectangles.enumerated() {
  125. if j > i && currentRect.matches(rect.rectangleFeature, withThreshold: matchingThreshold) {
  126. currentRect.matchingScore += 1
  127. rect.matchingScore += 1
  128. }
  129. }
  130. }
  131. }
  132. /// Resets the matching score of all of the rectangles in the queue to 0
  133. private func resetMatchingScores() {
  134. rectangles = rectangles.map { (rectangle) -> RectangleMatch in
  135. rectangle.matchingScore = 0
  136. return rectangle
  137. }
  138. }
  139. }