Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ class YoloModelSource {
companion object {
private const val MODEL_INPUT_WIDTH = 640
private const val MODEL_INPUT_HEIGHT = 640
private const val CONFIDENCE_THRESHOLD = 0.3f
private const val CONFIDENCE_THRESHOLD = 0.2f
private const val NMS_THRESHOLD = 0.45f
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package org.appdevforall.codeonthego.computervision.domain
import android.graphics.RectF
import com.google.mlkit.vision.text.Text
import org.appdevforall.codeonthego.computervision.domain.model.DetectionResult
import org.appdevforall.codeonthego.computervision.utils.OcrTextAssembler.joinElementsWithTolerance

class DetectionMerger(
private val enrichedComponents: List<DetectionResult>,
Expand Down Expand Up @@ -39,20 +40,22 @@ class DetectionMerger(
}
}

val orphanText = fullImageTextBlocks.filter { it !in usedTextBlocks }
for (textBlock in orphanText) {
textBlock.boundingBox?.let {
finalDetections.add(
val orphanDetections = fullImageTextBlocks
.filter { it !in usedTextBlocks }
.flatMap { it.lines }
.mapNotNull { line ->
line.boundingBox?.let { box ->
DetectionResult(
boundingBox = RectF(it),
boundingBox = RectF(box),
label = "text",
score = 0.99f,
text = textBlock.text.replace("\n", " "),
text = joinElementsWithTolerance(line),
isYolo = false
)
)
}
}
}

finalDetections.addAll(orphanDetections)

return finalDetections
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ object FuzzyAttributeParser {
private val grammarValidator = UiGrammarValidator()

private const val FUZZY_VALUE_THRESHOLD = 75
private const val FUZZY_DIMENSION_THRESHOLD = 60
private val DIMENSION_CONSTANTS = listOf("wrap_content", "match_parent")
private val ID_VOCABULARY = listOf("cb", "rb", "group", "checkbox", "radio", "btn", "button", "text", "view", "img", "image", "input")

private fun fuzzyKeyThreshold(keyLength: Int): Int = when {
keyLength <= 3 -> 65
Expand Down Expand Up @@ -415,6 +418,11 @@ object FuzzyAttributeParser {
if (matchKeywords.any { it in normalized }) return "match_parent"
if (wrapKeywords.any { it in normalized }) return "wrap_content"

val fuzzyResult = FuzzySearch.extractOne(normalized, DIMENSION_CONSTANTS)
if (fuzzyResult.score >= FUZZY_DIMENSION_THRESHOLD) {
return fuzzyResult.string
}

val fixedUnit = normalized
.replace(Regex("0p$"), "dp")
.replace(Regex("op$"), "dp")
Expand Down Expand Up @@ -458,11 +466,68 @@ object FuzzyAttributeParser {
}

private fun cleanId(value: String): String {
return denoiseOcrIdentifier(value.lowercase())
val cleaned = denoiseOcrIdentifier(value.lowercase())
.replace(nonAlphanumericRegex, "_")
.replace(multipleUnderscoresRegex, "_")
.trimEnd('_')
.trimStart('_')

return normalizeKnownIdVocabulary(cleaned)
}

private fun normalizeKnownIdVocabulary(identifier: String): String {
if (identifier.isBlank()) return identifier

return identifier
.split('_')
.filter { it.isNotBlank() }
.flatMap(::normalizeIdToken)
.joinToString("_")
}

private fun normalizeIdToken(token: String): List<String> {
if (token.isBlank()) return emptyList()
if (token.all(Char::isDigit)) return listOf(token)

val wholeMatch = fuzzyMatchIdVocabulary(token)
if (wholeMatch != null) {
return listOf(wholeMatch)
}

val compositeMatch = normalizeCompositeIdToken(token)
if (compositeMatch != null) {
return compositeMatch
}

return listOf(token)
}

private fun normalizeCompositeIdToken(token: String): List<String>? {
val match = Regex("^([a-z]+?)(\\d+)?$").matchEntire(token) ?: return null
val alphaPart = match.groupValues[1]
val trailingDigits = match.groupValues[2].takeIf { it.isNotEmpty() }

val prefix = ID_VOCABULARY
.filter { alphaPart.length > it.length }
.sortedByDescending { it.length }
.firstOrNull { alphaPart.startsWith(it) }
?: return null

val remainder = alphaPart.removePrefix(prefix)
val normalizedRemainder = fuzzyMatchIdVocabulary(remainder) ?: return null

val result = mutableListOf(prefix, normalizedRemainder)
if (trailingDigits != null) result += trailingDigits
return result
}

private fun fuzzyMatchIdVocabulary(token: String): String? {
if (token.length < 3) return null
if (token in ID_VOCABULARY) return token

val result = FuzzySearch.extractOne(token, ID_VOCABULARY)
val lengthDelta = kotlin.math.abs(token.length - result.string.length)
return result.string.takeIf { result.score >= 80 && lengthDelta <= 2 }
}

private fun denoiseOcrIdentifier(value: String): String {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import android.graphics.Rect
import org.appdevforall.codeonthego.computervision.domain.model.DetectionResult
import org.appdevforall.codeonthego.computervision.domain.model.LayoutItem
import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox
import org.appdevforall.codeonthego.computervision.utils.TextCleaner.cleanTextPreservingLeadingO
import org.appdevforall.codeonthego.computervision.utils.TextCleaner.cleanTextStrippingLeadingO
import kotlin.math.abs
import kotlin.math.max
import kotlin.math.roundToInt
Expand All @@ -19,6 +21,9 @@ class LayoutGeometryProcessor {
private fun isRadioButton(box: ScaledBox): Boolean =
box.label == "radio_button_unchecked" || box.label == "radio_button_checked"

private fun isCheckbox(box: ScaledBox): Boolean =
box.label == "checkbox_unchecked" || box.label == "checkbox_checked"

private fun isLabelableWidget(box: ScaledBox): Boolean {
return box.label in setOf(
"radio_button_unchecked", "radio_button_checked",
Expand Down Expand Up @@ -134,23 +139,33 @@ class LayoutGeometryProcessor {
val rows = groupIntoRows(boxes)
val items = mutableListOf<LayoutItem>()
val verticalRadioRun = mutableListOf<ScaledBox>()
val verticalCheckboxRun = mutableListOf<ScaledBox>()

fun flushVerticalRadioRun() {
fun flushRuns() {
if (verticalRadioRun.isNotEmpty()) {
items.add(LayoutItem.RadioGroup(verticalRadioRun.toList(), "vertical"))
verticalRadioRun.clear()
}
if (verticalCheckboxRun.isNotEmpty()) {
items.add(LayoutItem.CheckboxGroup(verticalCheckboxRun.toList(), "vertical"))
verticalCheckboxRun.clear()
}
}

rows.forEach { row ->
val isRadioRow = row.all { isRadioButton(it) }
val isCheckboxRow = row.all { isCheckbox(it) }

if (!isRadioRow && verticalRadioRun.isNotEmpty()) flushRuns()
if (!isCheckboxRow && verticalCheckboxRun.isNotEmpty()) flushRuns()

when {
row.all { isRadioButton(it) } && row.size == 1 -> verticalRadioRun.add(row.first())
row.all { isRadioButton(it) } -> {
flushVerticalRadioRun()
items.add(LayoutItem.RadioGroup(row, "horizontal"))
}
isRadioRow && row.size == 1 -> verticalRadioRun.add(row.first())
isRadioRow -> items.add(LayoutItem.RadioGroup(row, "horizontal"))
isCheckboxRow && row.size == 1 -> verticalCheckboxRun.add(row.first())
isCheckboxRow -> items.add(LayoutItem.CheckboxGroup(row, "horizontal"))
else -> {
flushVerticalRadioRun()
flushRuns()
if (row.size == 1) {
items.add(LayoutItem.SimpleView(row.first()))
} else {
Expand All @@ -159,7 +174,7 @@ class LayoutGeometryProcessor {
}
}
}
flushVerticalRadioRun()
flushRuns()

return items
}
Expand All @@ -169,25 +184,31 @@ class LayoutGeometryProcessor {
val updatedWidgets = mutableMapOf<ScaledBox, ScaledBox>()

val labelableWidgets = boxes.filter { isLabelableWidget(it) }
.sortedWith(compareBy({ it.y }, { it.x }))

for (widget in labelableWidgets) {
val nearbyText = availableTexts
.asSequence()
.filter { !consumedTexts.contains(it) }
.filter { text ->
val dx = maxOf(0, widget.rect.left - text.rect.right, text.rect.left - widget.rect.right)
val dy = maxOf(0, widget.rect.top - text.rect.bottom, text.rect.top - widget.rect.bottom)
val isToTheRight = text.rect.centerX() > widget.rect.centerX()
val verticalDistance = abs(widget.centerY - text.centerY)

dx < widget.w * 2 && dy < widget.h * 2
isToTheRight && verticalDistance < maxOf(widget.h * 2.5, 40.0)
}
.minByOrNull { text ->
val dx = maxOf(0, widget.rect.left - text.rect.right, text.rect.left - widget.rect.right).toDouble()
val dy = maxOf(0, widget.rect.top - text.rect.bottom, text.rect.top - widget.rect.bottom).toDouble()
(dx * dx) + (dy * dy)
val dx = maxOf(0, text.rect.left - widget.rect.right).toDouble()
val dy = abs(widget.centerY - text.centerY).toDouble()
(dx * dx) + (dy * dy * 5)
}

if (nearbyText != null) {
updatedWidgets[widget] = widget.copy(text = nearbyText.text)
val finalText = if (widget.label.contains("radio", ignoreCase = true)) {
cleanTextStrippingLeadingO(nearbyText.text)
} else {
cleanTextPreservingLeadingO(nearbyText.text)
}
updatedWidgets[widget] = widget.copy(text = finalText)
consumedTexts.add(nearbyText)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ class RegionOcrProcessor(

private val interactiveLabels = setOf(
"button",
"checkbox_checked",
"checkbox_unchecked",
"switch_on",
"switch_off",
"text_entry_box",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ sealed interface LayoutItem {
data class SimpleView(val box: ScaledBox) : LayoutItem
data class HorizontalRow(val row: List<ScaledBox>) : LayoutItem
data class RadioGroup(val boxes: List<ScaledBox>, val orientation: String) : LayoutItem
data class CheckboxGroup(val boxes: List<ScaledBox>, val orientation: String) : LayoutItem
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,9 @@ sealed class AndroidWidget(
companion object {
fun create(box: ScaledBox, parsedAttrs: Map<String, String>): AndroidWidget {
return when (box.label) {
"text", "button", "checkbox_unchecked", "checkbox_checked",
"radio_button_unchecked", "radio_button_checked" ->
"text", "button", "radio_button_unchecked", "radio_button_checked" ->
TextBasedWidget(box, parsedAttrs, getTagFor(box.label))
"checkbox_unchecked", "checkbox_checked" -> CheckBoxWidget(box, parsedAttrs)
"switch_off", "switch_on" -> SwitchWidget(box, parsedAttrs)
"text_entry_box" -> InputWidget(box, parsedAttrs)
"image_placeholder", "icon" -> ImageWidget(box, parsedAttrs)
Expand Down Expand Up @@ -95,6 +95,28 @@ class TextBasedWidget(
}
}

class CheckBoxWidget(
box: ScaledBox, parsedAttrs: Map<String, String>
) : AndroidWidget(box, parsedAttrs) {
override val tag = "CheckBox"

override fun specificAttributes(): Map<String, String> {
val attrs = mutableMapOf<String, String>()

val rawViewText = box.text.takeIf { it.isNotEmpty() && it != box.label }
?: parsedAttrs["android:text"]
?: "CheckBox"

attrs["android:text"] = rawViewText
attrs["tools:ignore"] = "HardcodedText"

if (box.label.contains("_checked")) {
attrs["android:checked"] = parsedAttrs["android:checked"] ?: "true"
}
return attrs
}
}

class SwitchWidget(
box: ScaledBox,
parsedAttrs: Map<String, String>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,18 @@ class LayoutRenderer(
private val context: XmlContext,
private val annotations: Map<ScaledBox, String>
) {
private val checkboxGroupIdPattern = Regex("^cb_group_\\d+$")
private val radioChildGroupIdPatterns = listOf(
Regex("^rb_group_\\d+(?:_|$).*"),
Comment thread
jatezzz marked this conversation as resolved.
Regex("^radio_group_\\d+(?:_|$).*")
)

fun render(item: LayoutItem, indent: String = " ") {
when (item) {
is LayoutItem.SimpleView -> renderSimpleView(item.box, indent)
is LayoutItem.HorizontalRow -> renderHorizontalRow(item.row, indent)
is LayoutItem.RadioGroup -> renderRadioGroup(item.boxes, item.orientation, indent)
is LayoutItem.CheckboxGroup -> renderCheckboxGroup(item.boxes, item.orientation, indent)
}
context.appendLine()
}
Expand Down Expand Up @@ -62,7 +69,11 @@ class LayoutRenderer(
val parsedAttrs = FuzzyAttributeParser.parse(annotations[box], "RadioButton")

val requestedId = parsedAttrs["android:id"]?.substringAfterLast('/')
val id = context.resolveId(requestedId, box.label)
val id = if (requestedId != null && radioChildGroupIdPatterns.any { it.matches(requestedId) }) {
context.nextId(box.label)
} else {
context.resolveId(requestedId, box.label)
}

val extraAttrs = if (orientation == "horizontal" && index < boxes.lastIndex) {
val gap = maxOf(0, boxes[index + 1].x - (box.x + box.w))
Expand Down Expand Up @@ -106,4 +117,38 @@ class LayoutRenderer(
}
context.append("$indent</RadioGroup>")
}

private fun renderCheckboxGroup(boxes: List<ScaledBox>, orientation: String, indent: String) {
val groupAnnotation = boxes.firstNotNullOfOrNull { annotations[it] }
val parsedAttrs = FuzzyAttributeParser.parse(groupAnnotation, "CheckBox")

val requestedId = parsedAttrs["android:id"]?.substringAfterLast('/')
val baseId = if (requestedId != null && checkboxGroupIdPattern.matches(requestedId)) {
context.resolveId(requestedId, "cb_group")
} else {
context.nextId("cb_group", initialIndex = 1)
}

boxes.forEachIndexed { index, box ->
val suffix = ('a' + index).toString()
val childId = "${baseId}_$suffix"

val safeAttrs = parsedAttrs.toMutableMap()
safeAttrs.remove("android:id")

val extraAttrs = if (orientation == "horizontal" && index < boxes.lastIndex) {
val gap = maxOf(0, boxes[index + 1].x - (box.x + box.w))
mapOf("android:layout_marginEnd" to "${gap}dp")
} else emptyMap()

renderSimpleView(
box = box,
indent = indent,
extraAttrs = extraAttrs,
idOverride = childId,
parsedAttrsOverride = safeAttrs
)
context.appendLine()
}
}
Comment thread
jatezzz marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ class XmlContext(
) {
private val usedIds = mutableSetOf<String>()

fun nextId(label: String): String {
fun nextId(label: String, initialIndex: Int = 0): String {
val safeLabel = label.replace(Regex("[^a-zA-Z0-9_]"), "_")

var count = counters.getOrDefault(safeLabel, -1)
var count = counters.getOrDefault(safeLabel, initialIndex - 1)
var newId: String

do {
Expand Down
Loading
Loading