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 @@ -8,39 +8,15 @@ object MarginAnnotationParser {
private const val GAP_MULTIPLIER = 1.5f
private const val HEIGHT_FRACTION = 0.8f

private val TAG_REGEX = Regex("^(?i)(B|P|D|T|C|R|SW|S)-\\d+$")
private val TAG_EXTRACT_REGEX = Regex("^(?i)([BPDTCRS8]\\s*W?)[^a-zA-Z0-9]*([\\dlIoO!]+)(?:\\s+(.+))?$")

private fun normalizeOcrDigits(raw: String): String =
raw.replace('l', '1').replace('I', '1').replace('!', '1')
.replace('o', '0').replace('O', '0')

private fun isTag(text: String): Boolean = text.matches(TAG_REGEX)

private fun extractTag(text: String): Pair<String, String?>? {
val trimmed = text.trim().trimEnd('.', ',', ';', '_', '|')
val match = TAG_EXTRACT_REGEX.find(trimmed) ?: return null

var prefix = match.groupValues[1].replace(Regex("\\s+"), "").uppercase()
if (prefix == "8") prefix = "B"
if (prefix == "8W" || prefix == "S8") prefix = "SW"

val digit = normalizeOcrDigits(match.groupValues[2])
val remaining = match.groupValues[3].takeIf { it.isNotBlank() }
val tag = "$prefix-$digit"

if (isTag(tag)) return tag to remaining
return null
}

fun parse(
detections: List<DetectionResult>,
imageWidth: Int,
leftGuidePct: Float,
rightGuidePct: Float
): Pair<List<DetectionResult>, Map<String, String>> {
val sanitizedDetections = detections.filterNot { detection ->
MetadataDetector.isMetadataDetection(detection.label, detection.text)
MetadataDetector.isMetadataLabel(detection.label) ||
(detection.isYolo && MetadataDetector.isCanvasMetadata(detection.text))
}

val leftMarginPx = imageWidth * leftGuidePct
Expand All @@ -60,7 +36,7 @@ object MarginAnnotationParser {
}

val canvasTags = canvasDetections.mapNotNull { det ->
extractTag(det.text)?.let { (tag, _) -> tag to det }
WidgetTagParser.extractTag(det.text)?.let { (tag, _) -> tag to det }
}

val canvasMidX = imageWidth * (leftGuidePct + rightGuidePct) / 2f
Expand Down Expand Up @@ -112,15 +88,13 @@ object MarginAnnotationParser {
val explicitBlocks = parsedBlocks
.filter {
it.tag != null &&
it.annotationText.isNotBlank() &&
!MetadataDetector.isCanvasMetadata(it.annotationText)
it.annotationText.isNotBlank()
}

val implicitBlocks = parsedBlocks
.filter {
it.tag == null &&
it.annotationText.length >= 5 &&
!MetadataDetector.isCanvasMetadata(it.annotationText)
it.annotationText.length >= 5
}
Comment thread
jatezzz marked this conversation as resolved.

for (block in explicitBlocks) {
Expand All @@ -136,7 +110,7 @@ object MarginAnnotationParser {
.mapValues { (_, tags) ->
tags.map { it.first }
.filter { tag -> tag !in annotationMap }
.sortedBy { tag -> extractOrdinal(tag) ?: Int.MAX_VALUE }
.sortedBy { tag -> WidgetTagParser.extractOrdinal(tag) ?: Int.MAX_VALUE }
.toMutableList()
}
.toMutableMap()
Expand Down Expand Up @@ -165,10 +139,6 @@ object MarginAnnotationParser {
return annotationMap
}

private fun extractOrdinal(tag: String): Int? {
return tag.substringAfter('-', "").toIntOrNull()
}

private fun centerX(detection: DetectionResult): Float {
return (detection.boundingBox.left + detection.boundingBox.right) / 2f
}
Expand Down Expand Up @@ -212,7 +182,7 @@ object MarginAnnotationParser {
var currentBlock = mutableListOf<DetectionResult>()

for (detection in block) {
val tagExtraction = extractTag(detection.text.trim())
val tagExtraction = WidgetTagParser.extractTag(detection.text.trim())
val isValidSplit = tagExtraction != null &&
(validPrefixes.isEmpty() || tagExtraction.first.substringBefore('-') in validPrefixes)

Expand All @@ -236,7 +206,7 @@ object MarginAnnotationParser {
.trim()
.trimStart('|', ':', ';', '.', ',', '_')

val tagExtraction = extractTag(text)
val tagExtraction = WidgetTagParser.extractTag(text)

if (tag == null && tagExtraction != null && index <= 2) {
tag = tagExtraction.first
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,6 @@ package org.appdevforall.codeonthego.computervision.domain
import org.appdevforall.codeonthego.computervision.domain.model.ScaledBox

class WidgetAnnotationMatcher {
companion object {
private val TAG_REGEX = Regex("^(?i)(B|P|D|T|C|R|SW|S)-\\d+$")
private val TAG_EXTRACT_REGEX = Regex("^(?i)(SW|S\\s*8|8\\s*W|[BPDTCRS8]\\s*W?)[^a-zA-Z0-9]*([\\dlIoO!]+)(?:\\s+(.+))?$")
}

internal fun matchAnnotationsToElements(
canvasTags: List<ScaledBox>,
uiElements: List<ScaledBox>,
Expand All @@ -17,14 +12,14 @@ class WidgetAnnotationMatcher {
val claimedWidgets = mutableSetOf<ScaledBox>()

val deduplicatedTags = canvasTags
.distinctBy { normalizeTagText(it.text) }
.distinctBy { WidgetTagParser.normalizeTagText(it.text) }

val tagsByWidgetType = annotations
.mapNotNull { (tagText, annotationText) ->
val normalizedTag = normalizeTagText(tagText)
val normalizedTag = WidgetTagParser.normalizeTagText(tagText)
val widgetType = getTagType(normalizedTag) ?: return@mapNotNull null

val matchingTagBox = deduplicatedTags.find { normalizeTagText(it.text) == normalizedTag }
val matchingTagBox = deduplicatedTags.find { WidgetTagParser.normalizeTagText(it.text) == normalizedTag }
?: return@mapNotNull null

TaggedAnnotation(
Expand All @@ -45,14 +40,14 @@ class WidgetAnnotationMatcher {

val sortedTags = taggedAnnotations.sortedWith(
compareBy(
{ extractTagOrdinal(it.normalizedTag) ?: Int.MAX_VALUE },
{ WidgetTagParser.extractOrdinal(it.normalizedTag) ?: Int.MAX_VALUE },
{ it.tagBox?.y ?: Int.MAX_VALUE },
{ it.tagBox?.x ?: Int.MAX_VALUE }
)
)

for (taggedAnnotation in sortedTags) {
val ordinal = extractTagOrdinal(taggedAnnotation.normalizedTag)
val ordinal = WidgetTagParser.extractOrdinal(taggedAnnotation.normalizedTag)
val matchedWidget = findWidgetByOrdinalOrFallback(
ordinal = ordinal,
tagBox = taggedAnnotation.tagBox,
Expand All @@ -68,22 +63,7 @@ class WidgetAnnotationMatcher {
return finalAnnotations
}

private fun normalizeOcrDigits(raw: String): String =
raw.replace('l', '1').replace('I', '1').replace('!', '1')
.replace('o', '0').replace('O', '0')

private fun normalizeTagText(text: String): String {
val trimmed = text.trim().trimEnd('.', ',', ';', ':', '_', '|')
val match = TAG_EXTRACT_REGEX.find(trimmed) ?: return trimmed.uppercase()

var prefix = match.groupValues[1].replace(Regex("\\s+"), "").uppercase()
if (prefix == "8") prefix = "B"
if (prefix == "8W" || prefix == "S8") prefix = "SW"

return "$prefix-${normalizeOcrDigits(match.groupValues[2])}"
}

internal fun isTag(text: String): Boolean = normalizeTagText(text).matches(TAG_REGEX)
internal fun isTag(text: String): Boolean = WidgetTagParser.isTag(text)

private fun getTagType(tag: String): String? {
return when {
Expand Down Expand Up @@ -111,10 +91,6 @@ class WidgetAnnotationMatcher {
else -> label
}

private fun extractTagOrdinal(tag: String): Int? {
return tag.substringAfter('-', "").toIntOrNull()
}

private fun findWidgetByOrdinalOrFallback(
ordinal: Int?,
tagBox: ScaledBox?,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package org.appdevforall.codeonthego.computervision.domain

/**
* Parses and normalizes raw OCR text into standardized Android widget tags.
* Handles common OCR misreads and formatting inconsistencies.
*/
internal object WidgetTagParser {
private val tagRegex = Regex("^(?i)(B|P|D|T|C|R|SW|S)-[A-Z0-9_]+$")
private val tagExtractRegex = Regex("^(?i)(SW|S\\s*8|8\\s*W|[BPDTCRS8]\\s*W?)[^a-zA-Z0-9]*([A-Z0-9_\\-]+)(?:\\s+(.+))?$")

/**
* Checks if the given text represents a valid, normalized widget tag.
*/
fun isTag(text: String): Boolean = normalizeTagText(text).matches(tagRegex)

/**
* Normalizes raw OCR text into a standard tag format (e.g., "Prefix-Token").
*/
fun normalizeTagText(text: String): String {
val trimmed = text.trim().trimEnd('.', ',', ';', ':', '_', '|')
val match = tagExtractRegex.find(trimmed) ?: return trimmed.uppercase()
Comment thread
jatezzz marked this conversation as resolved.

val prefix = normalizePrefix(match.groupValues[1])
val token = normalizeTagToken(match.groupValues[2].trim('-'))

return "$prefix-$token"
}

/**
* Extracts a normalized widget tag and any remaining trailing text from a raw string.
* @return A Pair containing the [normalized tag, trailing text], or null if no valid tag is found.
*/
fun extractTag(text: String): Pair<String, String?>? {
val trimmed = text.trim().trimEnd('.', ',', ';', ':', '_', '|')
val match = tagExtractRegex.find(trimmed) ?: return null

val prefix = normalizePrefix(match.groupValues[1])
val token = normalizeTagToken(match.groupValues[2].trim('-'))
val tag = "$prefix-$token"
val trailingText = match.groupValues[3].takeIf { it.isNotBlank() }

return (tag to trailingText).takeIf { isTag(tag) }
}

/**
* Normalizes the prefix using Regex to handle common OCR misreads (e.g., '8' as 'B').
*/
private fun normalizePrefix(rawPrefix: String): String {
return rawPrefix.uppercase()
.replace(Regex("\\s+"), "")
.replace(Regex("^8$"), "B")
.replace(Regex("^(8W|S8)$"), "SW")
}

/**
* Extracts the numeric or alphanumeric identifier part of the tag (the part after the hyphen).
*/
fun extractOrdinal(tag: String): Int? = tag.substringAfter('-', "").toIntOrNull()

/**
* Cleans up the token suffix. If the token consists entirely of numbers or OCR artifacts,
* it converts those artifacts back to digits.
*/
private fun normalizeTagToken(rawToken: String): String {
if (rawToken.isBlank()) return rawToken

val uppercaseToken = rawToken.uppercase().replace('-', '_')
return if (uppercaseToken.all(::isNumericLikeOcrChar)) {
normalizeOcrDigits(uppercaseToken)
} else {
uppercaseToken.replace(Regex("[^A-Z0-9_]"), "_")
}
}

/**
* Replaces characters that are commonly misread by OCR with their intended numeric values.
*/
private fun normalizeOcrDigits(raw: String): String =
raw.replace('I', '1')
.replace('L', '1')
.replace('!', '1')
.replace('O', '0')
.replace('Z', '2')
.replace('S', '5')
.replace('B', '6')

/**
* Determines whether a character is a digit or a letter frequently confused with a digit by OCR.
*/
private fun isNumericLikeOcrChar(char: Char): Boolean {
return char.isDigit() || char in setOf('O', 'I', 'L', 'Z', 'S', 'B', '!')
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package org.appdevforall.codeonthego.computervision.domain.grammar


import com.itsaky.androidide.fuzzysearch.FuzzySearch
import org.appdevforall.codeonthego.computervision.utils.extractOcrEntries

interface AttributeValidator {
fun validate(rawValue: String): String?
Expand Down Expand Up @@ -76,13 +77,12 @@ object SliderStyleValidator : AttributeValidator {
}

object EntriesValidator : AttributeValidator {

override fun validate(rawValue: String): String? {
val trimmed = rawValue.trim()
if (trimmed.startsWith("@")) return trimmed

val content = trimmed.removeSurrounding("[", "]")
val rawItems = content.split(",")
val rawItems = content.extractOcrEntries()

val isNumericArray = isEntireArrayLikelyNumeric(rawItems)

Expand All @@ -98,10 +98,6 @@ object EntriesValidator : AttributeValidator {
return cleanedItems.joinToString(",")
}

private fun isEnclosedInBrackets(text: String): Boolean {
return text.startsWith("[") && text.endsWith("]")
}

private fun isEntireArrayLikelyNumeric(items: List<String>): Boolean {
if (items.isEmpty()) return false
var hasAtLeastOneDigit = false
Expand Down
Loading
Loading