Add SSD review OCR and native vision bridge
ober
baecde6edf51a3b2998c31d3c8ed5303245c6d87
--- a/templates/ssd-review.ss +++ b/templates/ssd-review.ss @@ -256,10 +256,12 @@ "import android.app.AlertDialog" "import android.content.ContentUris" "import android.content.Intent" - "import android.content.pm.PackageManager" - "import android.graphics.Bitmap" - "import android.graphics.Matrix" - "import android.graphics.pdf.PdfRenderer" + "import android.content.pm.PackageManager" + "import android.graphics.Bitmap" + "import android.graphics.Color" + "import android.graphics.Matrix" + "import android.graphics.drawable.GradientDrawable" + "import android.graphics.pdf.PdfRenderer" "import android.net.Uri" "import android.os.Build" "import android.os.Bundle" @@ -282,9 +284,10 @@ "import android.widget.LinearLayout" "import android.widget.ScrollView" "import android.widget.TextView" - "import android.widget.Toast" - "import androidx.documentfile.provider.DocumentFile" - "import java.io.File" + "import android.widget.Toast" + "import androidx.documentfile.provider.DocumentFile" + "import org.json.JSONObject" + "import java.io.File" "import java.util.UUID" "import kotlin.concurrent.thread" "import kotlin.math.ceil" @@ -302,10 +305,11 @@ " private var pageIndex: Int = 0" " private var pageCount: Int = 0" " private var currentBitmap: Bitmap? = null" - " private var currentSession: SsdSession? = null" - " private var selectedGroupId: String? = null" - " private var appendMode: Boolean = false" - " private var reopenSsdBrowserOnResume: Boolean = false" + " private var currentSession: SsdSession? = null" + " private var selectedGroupId: String? = null" + " private var appendMode: Boolean = false" + " private var ssdAreaMode: Boolean = false" + " private var reopenSsdBrowserOnResume: Boolean = false" " @Volatile private var loadGeneration: Int = 0" " private val boxTypeDisplays: List<String> by lazy { BoxTypes.load(this).map { it.display } }" " private val firingArcDisplays = listOf(" @@ -359,11 +363,11 @@ " setPadding(8, 8, 8, 10)" " }" " root.addView(status)" - " reviewView = SsdReviewView(this).apply {" - " onStatus = { setStatus(it) }" - " onAreaSelected = { rect, ids -> post { showApproveDialog(rect, ids) } }" - " onGroupTapped = { selectGroup(it) }" - " }" + " reviewView = SsdReviewView(this).apply {" + " onStatus = { setStatus(it) }" + " onAreaSelected = { rect, ids -> handleAreaSelected(rect, ids) }" + " onGroupTapped = { selectGroup(it) }" + " }" " root.addView(reviewView, LinearLayout.LayoutParams(" " LinearLayout.LayoutParams.MATCH_PARENT," " 0," @@ -372,42 +376,41 @@ " val controls = LinearLayout(this).apply {" " orientation = LinearLayout.VERTICAL" " addView(actionRow(" - " button(\"Open SSDs\") { openPdf() }.apply { textSize = 18f }," - " button(\"Open Any\") { openAnyPdf() }," - " button(\"Detect\") { detectCurrent(forceDetect = true) }," - " button(\"Rotate\") { rotatePage() }," - " ))" - " addView(actionRow(" - " button(\"Prev Page\") { changePage(-1) }," - " button(\"Page #\") { showPageDialog() }," - " button(\"Next Page\") { changePage(1) }," - " ))" - " addView(actionRow(" - " button(\"Pan\") { setSelectMode(false) }," - " button(\"Select Area\") { setSelectMode(true) }," - " button(\"Fit\") { reviewView.fitImage() }," - " ))" - " addView(actionRow(" - " button(\"Prev Group\") { selectRelativeGroup(-1) }," - " button(\"Next Group\") { selectRelativeGroup(1) }," - " button(\"Edit\") { editSelectedGroup() }," - " ))" - " addView(actionRow(" - " button(\"Append\") { startAppendMode() }," - " button(\"Approve\") { approveSelectedGroup() }," - " button(\"Delete\") { deleteSelectedGroup() }," - " button(\"Save\") { saveTruth() }," - " ))" + " button(\"Open SSDs\", ButtonKind.PRIMARY) { openPdf() }," + " button(\"Detect\", ButtonKind.ACCENT) { detectCurrent(forceDetect = true) }," + " button(\"Set SSD Area\", ButtonKind.MODE) { startSsdAreaMode() }," + " ))" + " addView(actionRow(" + " button(\"Open Any\") { openAnyPdf() }," + " button(\"Rotate\") { rotatePage() }," + " button(\"Fit\") { reviewView.fitImage() }," + " ))" + " addView(actionRow(" + " button(\"Prev Page\") { changePage(-1) }," + " button(\"Page #\") { showPageDialog() }," + " button(\"Next Page\") { changePage(1) }," + " ))" + " addView(actionRow(" + " button(\"Pan\", ButtonKind.NEUTRAL) { setSelectMode(false) }," + " button(\"Select Area\", ButtonKind.PRIMARY) { setSelectMode(true) }," + " button(\"Add\", ButtonKind.ACCENT) { startAppendMode() }," + " ))" + " addView(actionRow(" + " button(\"Prev\", ButtonKind.NAV) { selectRelativeGroup(-1) }," + " button(\"Next\", ButtonKind.PRIMARY) { selectRelativeGroup(1) }," + " button(\"Edit\") { editSelectedGroup() }," + " ))" + " addView(actionRow(" + " button(\"Approve\", ButtonKind.ACCENT) { approveSelectedGroup() }," + " button(\"Delete\", ButtonKind.DANGER) { deleteSelectedGroup() }," + " button(\"Save\") { saveTruth() }," + " ))" " addView(actionRow(" " button(\"Pull Truth\") { pullTruthDump() }," " button(\"Push Truth\") { pushTruthDump() }," " button(\"Sync Folder\") { syncTruthFolder() }," - " ))" - " addView(actionRow(" - " button(\"Export Zip\") { exportTruthZip() }," - " button(\"Import Zip\") { importTruthZip() }," - " ))" - " }" + " ))" + " }" " root.addView(ScrollView(this).apply {" " isFillViewport = false" " addView(controls)" @@ -418,22 +421,42 @@ " return root" " }" "" - " private fun button(label: String, action: () -> Unit): Button =" - " Button(this).apply {" - " text = label" - " minHeight = 56" - " setAllCaps(false)" - " setOnClickListener { action() }" - " }" + " private enum class ButtonKind(val backgroundColor: Int, val foregroundColor: Int, val textSp: Float, val heightDp: Int) {" + " PRIMARY(Color.rgb(37, 99, 235), Color.WHITE, 18f, 56)," + " ACCENT(Color.rgb(5, 150, 105), Color.WHITE, 18f, 56)," + " MODE(Color.rgb(245, 158, 11), Color.rgb(15, 23, 42), 17f, 54)," + " NAV(Color.rgb(51, 65, 85), Color.WHITE, 17f, 52)," + " NEUTRAL(Color.rgb(226, 232, 240), Color.rgb(15, 23, 42), 15f, 48)," + " DANGER(Color.rgb(185, 28, 28), Color.WHITE, 16f, 50)" + " }" + "" + " private fun dp(value: Int): Int = (value * resources.displayMetrics.density + 0.5f).toInt()" + "" + " private fun button(label: String, kind: ButtonKind = ButtonKind.NEUTRAL, action: () -> Unit): Button =" + " Button(this).apply {" + " text = label" + " textSize = kind.textSp" + " minHeight = dp(kind.heightDp)" + " setTextColor(kind.foregroundColor)" + " setAllCaps(false)" + " setPadding(dp(10), dp(6), dp(10), dp(6))" + " background = GradientDrawable().apply {" + " cornerRadius = dp(9).toFloat()" + " setColor(kind.backgroundColor)" + " }" + " setOnClickListener { action() }" + " }" "" " private fun actionRow(vararg buttons: Button): LinearLayout =" " LinearLayout(this).apply {" " orientation = LinearLayout.HORIZONTAL" " gravity = Gravity.CENTER_VERTICAL" - " buttons.forEach { item ->" - " addView(item, LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f))" - " }" - " }" + " buttons.forEach { item ->" + " val params = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1f)" + " params.setMargins(dp(3), dp(3), dp(3), dp(3))" + " addView(item, params)" + " }" + " }" "" " private fun boxTypeInput(): AutoCompleteTextView =" " AutoCompleteTextView(this).apply {" @@ -635,22 +658,31 @@ " setSelectMode(!reviewView.selectMode)" " }" "" - " private fun setSelectMode(enabled: Boolean) {" - " if (!enabled) appendMode = false" - " reviewView.selectMode = enabled" - " setStatus(" - " when {" - " appendMode -> \"Append mode: drag boxes to add to the selected group\"" - " reviewView.selectMode -> \"Select mode: drag a loose box around SSD boxes\"" + " private fun setSelectMode(enabled: Boolean) {" + " if (!enabled) {" + " appendMode = false" + " ssdAreaMode = false" + " reviewView.snapSelectionToCells = true" + " } else if (!ssdAreaMode) {" + " reviewView.snapSelectionToCells = true" + " }" + " reviewView.selectMode = enabled" + " setStatus(" + " when {" + " ssdAreaMode -> \"SSD area mode: drag around the ship SSD; tags outside that area will be removed\"" + " appendMode -> \"Append mode: drag boxes to add to the selected group\"" + " reviewView.selectMode -> \"Select mode: drag a loose box around SSD boxes\"" " else -> \"Pan mode\"" " }" " )" " }" "" - " private fun finishSelectInteraction() {" - " appendMode = false" - " reviewView.clearInteractionState()" - " }" + " private fun finishSelectInteraction() {" + " appendMode = false" + " ssdAreaMode = false" + " reviewView.snapSelectionToCells = true" + " reviewView.clearInteractionState()" + " }" "" " private fun restoreViewportAfterLayout(viewport: SsdReviewView.ViewportState?) {" " reviewView.restoreViewport(viewport)" @@ -666,12 +698,25 @@ " reviewView.selectMode = false" " setStatus(\"Select an existing group first, then tap Append\")" " return" - " }" - " appendMode = true" - " reviewView.selectMode = true" - " reviewView.selectedGroupId = group.id" - " setStatus(\"Append mode: drag boxes to add to ${group.label.ifBlank { group.id }}\")" - " }" + " }" + " appendMode = true" + " ssdAreaMode = false" + " reviewView.snapSelectionToCells = true" + " reviewView.selectMode = true" + " reviewView.selectedGroupId = group.id" + " setStatus(\"Append mode: drag boxes to add to ${group.label.ifBlank { group.id }}\")" + " }" + "" + " private fun startSsdAreaMode() {" + " currentSession ?: return" + " appendMode = false" + " ssdAreaMode = true" + " reviewView.snapSelectionToCells = false" + " reviewView.selectMode = true" + " reviewView.selectedGroupId = selectedGroupId" + " reviewView.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)" + " setStatus(\"SSD area mode: drag around the ship SSD; tags outside that area will be removed\")" + " }" "" " private fun rotatePage() {" " reviewView.rotateClockwise()" @@ -694,15 +739,44 @@ " pageIndex = page - 1" " renderCurrentPage()" " }" - " .show()" - " }" - "" - " private fun selectGroup(groupId: String?) {" + " .show()" + " }" + "" + " private fun applySsdArea(rect: FloatArray) {" + " val session = currentSession ?: return" + " val area = floatArrayOf(" + " rect[0].coerceIn(0f, session.imageWidth.toFloat())," + " rect[1].coerceIn(0f, session.imageHeight.toFloat())," + " rect[2].coerceIn(0f, session.imageWidth.toFloat())," + " rect[3].coerceIn(0f, session.imageHeight.toFloat())" + " )" + " if ((area[2] - area[0]) < 10f || (area[3] - area[1]) < 10f) {" + " finishSelectInteraction()" + " setStatus(\"SSD area ignored: selection was too small\")" + " return" + " }" + " val removed = session.setSsdArea(area)" + " selectedGroupId = session.groups.firstOrNull { it.id == selectedGroupId }?.id" + " ?: session.groups.firstOrNull()?.id" + " reviewView.ssdArea = session.ssdArea" + " reviewView.selectedGroupId = selectedGroupId" + " truthStore.appendEvent(\"ssd_area_set\", session, JSONObject().put(\"removed_groups\", removed))" + " reviewView.performHapticFeedback(HapticFeedbackConstants.CONFIRM)" + " reviewView.invalidate()" + " saveSessionAsync(" + " session," + " \"SSD area set; removed $removed outside tags; saving...\"," + " \"SSD area saved; removed $removed outside tags\"" + " )" + " }" + "" + " private fun selectGroup(groupId: String?) {" " val session = currentSession ?: return" " val group = session.groups.firstOrNull { it.id == groupId } ?: return" - " selectedGroupId = group.id" - " reviewView.selectedGroupId = group.id" - " reviewView.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)" + " selectedGroupId = group.id" + " reviewView.selectedGroupId = group.id" + " reviewView.centerOnGroup(group)" + " reviewView.performHapticFeedback(HapticFeedbackConstants.CLOCK_TICK)" " val arc = if (group.firingArc.isNotBlank()) \", arc ${group.firingArc}\" else \"\"" " setStatus(\"${group.id}: ${group.label.ifBlank { \"unlabeled\" }} (${group.count} boxes$arc, ${group.status})\")" " }" @@ -760,10 +834,11 @@ " currentBitmap = bitmap" " currentSession = null" " selectedGroupId = null" - " reviewView.bitmap = bitmap" - " reviewView.session = null" - " reviewView.selectedGroupId = null" - " setStatus(\"Rendered $renderPdfName page ${renderPageIndex + 1}/$renderPageCount; loading truth\")" + " reviewView.bitmap = bitmap" + " reviewView.session = null" + " reviewView.selectedGroupId = null" + " reviewView.ssdArea = null" + " setStatus(\"Rendered $renderPdfName page ${renderPageIndex + 1}/$renderPageCount; loading truth\")" " detectCurrent(" " expectedGeneration = renderGeneration," " expectedPageIndex = renderPageIndex," @@ -814,16 +889,18 @@ " )" "" " val localTruth = if (forceDetect) null else truthStore.loadTruth(session)" - " val localGroupCount = localTruth?.optJSONArray(\"groups\")?.length() ?: 0" - " if (localGroupCount >= FAST_TRUTH_GROUP_THRESHOLD) {" - " truthStore.applyTruth(session)" - " runOnUiThread {" + " val localGroupCount = localTruth?.optJSONArray(\"groups\")?.length() ?: 0" + " if (localGroupCount >= FAST_TRUTH_GROUP_THRESHOLD) {" + " truthStore.applyTruth(session)" + " session.pruneGroupsOutsideSsdArea()" + " runOnUiThread {" " if (!isCurrentLoad(expectedGeneration, uri, expectedPageIndex)) return@runOnUiThread" " currentSession = session" " selectedGroupId = null" - " reviewView.session = session" - " reviewView.selectedGroupId = null" - " setStatus(\"${session.cells.size} cells, ${session.groups.size} groups, replayed cached truth\")" + " reviewView.session = session" + " reviewView.selectedGroupId = null" + " reviewView.ssdArea = session.ssdArea" + " setStatus(\"${session.cells.size} cells, ${session.groups.size} groups, replayed cached truth\")" " }" " truthStore.pullRemoteTruth(session)" " return@thread" @@ -831,31 +908,49 @@ "" " val remoteMatches = truthStore.pullRemoteTruth(session)" " val existingTruth = truthStore.loadTruth(session)" - "" - " if (existingTruth != null) {" - " if (forceDetect) {" + " var ocrGuesses = 0" + " var detectorNote = \"\"" + "" + " if (existingTruth != null) {" + " session.ssdArea = jsonRect(existingTruth.optJSONArray(\"ssd_area\"))" + " if (forceDetect) {" + " val forcedDetection = SsdVisionBridge.detect(bitmap)" + " if (forcedDetection != null) detectorNote = \", rust detector\"" + " val forcedCells = forcedDetection?.cells ?: SsdDetector.detectCells(bitmap)" + " session.cells.addAll(forcedCells.filter { session.cellInsideSsdArea(it) })" + " }" + " truthStore.applyTruth(session)" + " session.pruneGroupsOutsideSsdArea()" + " } else {" + " val nativeDetection = SsdVisionBridge.detect(bitmap)" + " if (nativeDetection != null) {" + " session.cells.addAll(nativeDetection.cells)" + " session.groups.addAll(nativeDetection.groups.take(80))" + " detectorNote = \", rust detector\"" + " } else {" " session.cells.addAll(SsdDetector.detectCells(bitmap))" + " session.groups.addAll(SsdDetector.initialGroups(session.cells).take(80))" " }" - " truthStore.applyTruth(session)" - " } else {" - " session.cells.addAll(SsdDetector.detectCells(bitmap))" - " session.groups.addAll(SsdDetector.initialGroups(session.cells).take(80))" " Guessing.applyInitialGuesses(this, session)" + " session.ocrWords.addAll(SsdOcr.recognizeWords(bitmap))" + " ocrGuesses = SsdOcr.applyOcrGuesses(this, session)" " }" " val learnedGuesses = if (existingTruth == null) truthStore.applyLearnedGuesses(session) else 0" " runOnUiThread {" " if (!isCurrentLoad(expectedGeneration, uri, expectedPageIndex)) return@runOnUiThread" " currentSession = session" " selectedGroupId = null" - " reviewView.session = session" - " reviewView.selectedGroupId = null" - " val truthNote = if (existingTruth != null) {" + " reviewView.session = session" + " reviewView.selectedGroupId = null" + " reviewView.ssdArea = session.ssdArea" + " val truthNote = if (existingTruth != null) {" " if (remoteMatches > 0) \", pulled remote truth\" else \", replayed truth\"" " } else {" " if (remoteMatches > 0) \", remote truth unavailable for this render\" else \"\"" " }" + " val ocrNote = if (ocrGuesses > 0) \", $ocrGuesses OCR guesses\" else if (session.ocrWords.isNotEmpty()) \", OCR ${session.ocrWords.size} words\" else \"\"" " val learnedNote = if (learnedGuesses > 0) \", $learnedGuesses learned guesses\" else \"\"" - " setStatus(\"${session.cells.size} cells, ${session.groups.size} groups$truthNote$learnedNote\")" + " setStatus(\"${session.cells.size} cells, ${session.groups.size} groups$detectorNote$truthNote$ocrNote$learnedNote\")" " }" " } catch (error: Exception) {" " runOnUiThread {" @@ -867,14 +962,43 @@ " }" " }" "" - " private fun isCurrentLoad(generation: Int, uri: Uri, page: Int): Boolean =" - " generation == loadGeneration && pdfUri == uri && pageIndex == page" - "" - " private fun showApproveDialog(rect: FloatArray, detectedIds: List<String>) {" - " val session = currentSession ?: return" - " if (appendMode) {" - " showAppendDialog(rect, detectedIds)" - " return" + " private fun isCurrentLoad(generation: Int, uri: Uri, page: Int): Boolean =" + " generation == loadGeneration && pdfUri == uri && pageIndex == page" + "" + " private fun handleAreaSelected(rect: FloatArray, detectedIds: List<String>) {" + " if (currentSession == null) {" + " setStatus(\"Selection ignored: truth/detection is still loading\")" + " return" + " }" + " val applyAsSsdArea = ssdAreaMode" + " val statusText = if (applyAsSsdArea) {" + " \"SSD area selected; applying boundary...\"" + " } else {" + " \"Selected ${detectedIds.size} boxes; opening editor...\"" + " }" + " setStatus(statusText)" + " reviewView.postDelayed({" + " if (currentSession == null) {" + " setStatus(\"Selection ignored: truth/detection is still loading\")" + " return@postDelayed" + " }" + " if (applyAsSsdArea) {" + " applySsdArea(rect)" + " } else {" + " showApproveDialog(rect, detectedIds)" + " }" + " }, 80)" + " }" + "" + " private fun showApproveDialog(rect: FloatArray, detectedIds: List<String>) {" + " val session = currentSession" + " if (session == null) {" + " setStatus(\"Selection ignored: truth/detection is still loading\")" + " return" + " }" + " if (appendMode) {" + " showAppendDialog(rect, detectedIds)" + " return" " }" " val layout = LinearLayout(this).apply {" " orientation = LinearLayout.VERTICAL" @@ -1207,7 +1331,7 @@ " }" " } catch (error: Exception) {" " runOnUiThread {" - " setStatus(\"Truth pull failed: ${error.message}. Use Import Zip for offline transfer.\")" + " setStatus(\"Truth pull failed: ${error.message}. Check Truth Server or use Sync Folder.\")" " }" " }" " }" @@ -1224,7 +1348,7 @@ " }" " } catch (error: Exception) {" " runOnUiThread {" - " setStatus(\"Truth push failed: ${error.message}. Use Export Zip for offline transfer.\")" + " setStatus(\"Truth push failed: ${error.message}. Check Truth Server or use Sync Folder.\")" " }" " }" " }" @@ -1608,6 +1732,12 @@ " }" "}" "" + "data class OcrWord(" + " val text: String," + " val conf: Float," + " val bbox: FloatArray" + ")" + "" "data class SsdGroup(" " val id: String," " var label: String = \"\"," @@ -1680,12 +1810,14 @@ " val pageCount: Int," " val dpi: Int," " val imageWidth: Int," - " val imageHeight: Int," - " val cells: MutableList<SsdCell> = mutableListOf()," - " val groups: MutableList<SsdGroup> = mutableListOf()," - " val suppressedGroups: MutableList<SsdGroup> = mutableListOf()" - ") {" - " fun cellsById(): Map<String, SsdCell> = cells.associateBy { it.id }" + " val imageHeight: Int," + " val cells: MutableList<SsdCell> = mutableListOf()," + " val groups: MutableList<SsdGroup> = mutableListOf()," + " val ocrWords: MutableList<OcrWord> = mutableListOf()," + " val suppressedGroups: MutableList<SsdGroup> = mutableListOf()," + " var ssdArea: FloatArray? = null" + ") {" + " fun cellsById(): Map<String, SsdCell> = cells.associateBy { it.id }" "" " fun recomputeGroup(group: SsdGroup) {" " val groupCells = group.cellIds.mapNotNull { id -> cells.firstOrNull { it.id == id } }" @@ -1697,10 +1829,40 @@ " val y1 = groupCells.minOf { it.y }" " val x2 = groupCells.maxOf { it.x + it.w }" " val y2 = groupCells.maxOf { it.y + it.h }" - " group.bbox = floatArrayOf(x1, y1, x2, y2)" - " }" - "" - " fun nextCellId(): String {" + " group.bbox = floatArrayOf(x1, y1, x2, y2)" + " }" + "" + " fun cellInsideSsdArea(cell: SsdCell): Boolean =" + " ssdArea?.let { area -> rectCenterInside(cell.rect(), area) } ?: true" + "" + " fun groupInsideSsdArea(group: SsdGroup): Boolean =" + " ssdArea?.let { area -> rectCenterInside(group.bbox, area) } ?: true" + "" + " fun setSsdArea(area: FloatArray): Int {" + " ssdArea = floatArrayOf(" + " min(area[0], area[2])," + " min(area[1], area[3])," + " max(area[0], area[2])," + " max(area[1], area[3])" + " )" + " return pruneGroupsOutsideSsdArea()" + " }" + "" + " fun pruneGroupsOutsideSsdArea(): Int {" + " val area = ssdArea ?: return 0" + " val outside = groups.filter { group -> !rectCenterInside(group.bbox, area) }.toList()" + " outside.forEach { group ->" + " groups.remove(group)" + " group.status = \"suppressed\"" + " group.notes = if (group.notes.isBlank()) \"Outside SSD area\" else \"${group.notes}; Outside SSD area\"" + " if (suppressedGroups.none { it.id == group.id }) {" + " suppressedGroups.add(group)" + " }" + " }" + " return outside.size" + " }" + "" + " fun nextCellId(): String {" " val maxId = cells.mapNotNull { it.id.removePrefix(\"c\").toIntOrNull() }.maxOrNull() ?: 0" " return \"c%04d\".format(maxId + 1)" " }" @@ -1720,8 +1882,8 @@ " }" " val suppressedJson = JSONArray()" " suppressedGroups.forEach { suppressedJson.put(it.toTruthJson(cellsById)) }" - " return JSONObject()" - " .put(\"schema_version\", 1)" + " val payload = JSONObject()" + " .put(\"schema_version\", 1)" " .put(\"generated_at\", System.currentTimeMillis().toString())" " .put(\"session_id\", sessionId)" " .put(\"source_key\", sourceKey)" @@ -1739,10 +1901,14 @@ " .put(\"parser\", JSONObject()" " .put(\"name\", \"SFB SSD Review Android\")" " .put(\"version\", \"android-kotlin-mvp-1\")" - " .put(\"cell_detection\", JSONArray(listOf(\"android-threshold-components\"))))" - " .put(\"groups\", groupsJson)" - " .put(\"suppressed_groups\", suppressedJson)" - " }" + " .put(\"cell_detection\", JSONArray(if (cells.any { it.detector.startsWith(\"rust\") }) listOf(\"jerboa-vision-rust\", \"android-threshold-components\") else listOf(\"android-threshold-components\")))" + " .put(\"ocr\", \"mlkit-text-recognition-latin-bundled\")" + " .put(\"ocr_word_count\", ocrWords.size))" + " .put(\"groups\", groupsJson)" + " .put(\"suppressed_groups\", suppressedJson)" + " ssdArea?.let { payload.put(\"ssd_area\", floatArrayToJson(it)) }" + " return payload" + " }" "}" "" "fun bboxForCells(cells: List<SsdCell>): FloatArray {" @@ -1755,12 +1921,32 @@ " )" "}" "" - "fun rectOverlapArea(a: FloatArray, b: FloatArray): Float {" - " val x = max(0f, min(a[2], b[2]) - max(a[0], b[0]))" - " val y = max(0f, min(a[3], b[3]) - max(a[1], b[1]))" - " return x * y" - "}" - )) + "fun rectOverlapArea(a: FloatArray, b: FloatArray): Float {" + " val x = max(0f, min(a[2], b[2]) - max(a[0], b[0]))" + " val y = max(0f, min(a[3], b[3]) - max(a[1], b[1]))" + " return x * y" + "}" + "" + "fun floatArrayToJson(rect: FloatArray): JSONArray = JSONArray().apply {" + " rect.forEach { put(it.toDouble()) }" + "}" + "" + "fun jsonRect(json: JSONArray?): FloatArray? {" + " if (json == null || json.length() < 4) return null" + " return floatArrayOf(" + " json.optDouble(0, 0.0).toFloat()," + " json.optDouble(1, 0.0).toFloat()," + " json.optDouble(2, 0.0).toFloat()," + " json.optDouble(3, 0.0).toFloat()" + " )" + "}" + "" + "fun rectCenterInside(rect: FloatArray, area: FloatArray): Boolean {" + " val cx = (rect[0] + rect[2]) / 2f" + " val cy = (rect[1] + rect[3]) / 2f" + " return cx >= area[0] && cx <= area[2] && cy >= area[1] && cy <= area[3]" + "}" + )) (kotlin-file-lines "com/sfb/ssdreview/SsdDetector.kt" ( "package com.sfb.ssdreview" @@ -2176,6 +2362,307 @@ " }" "}" )) + (kotlin-file-lines "com/sfb/ssdreview/SsdVisionBridge.kt" + ( + "package com.sfb.ssdreview" + "" + "import android.graphics.Bitmap" + "import org.json.JSONArray" + "import org.json.JSONObject" + "" + "object SsdVisionBridge {" + " data class NativeDetection(" + " val cells: MutableList<SsdCell>," + " val groups: MutableList<SsdGroup>" + " )" + "" + " private var loadError: Throwable? = null" + " private val loaded: Boolean = try {" + " System.loadLibrary(\"jerboa_vision\")" + " true" + " } catch (error: Throwable) {" + " loadError = error" + " false" + " }" + "" + " fun isAvailable(): Boolean = loaded" + "" + " fun loadErrorMessage(): String = loadError?.message ?: \"\"" + "" + " fun detect(bitmap: Bitmap): NativeDetection? {" + " if (!loaded || bitmap.width <= 0 || bitmap.height <= 0) return null" + " val rgba = bitmapToRgba(bitmap)" + " val jsonText = try {" + " nativeDetectRgbaJson(bitmap.width, bitmap.height, rgba)" + " } catch (error: Throwable) {" + " loadError = error" + " return null" + " }" + " val root = try { JSONObject(jsonText) } catch (_: Exception) { return null }" + " if (!root.optBoolean(\"ok\", true)) {" + " loadError = IllegalStateException(root.optString(\"error\", \"native detector failed\"))" + " return null" + " }" + " val cells = parseCells(root.optJSONArray(\"cells\"))" + " if (cells.isEmpty()) return null" + " val groups = parseGroups(root.optJSONArray(\"groups\"))" + " return NativeDetection(cells, groups)" + " }" + "" + " private fun bitmapToRgba(bitmap: Bitmap): ByteArray {" + " val width = bitmap.width" + " val height = bitmap.height" + " val pixels = IntArray(width * height)" + " bitmap.getPixels(pixels, 0, width, 0, 0, width, height)" + " val out = ByteArray(width * height * 4)" + " for (index in pixels.indices) {" + " val pixel = pixels[index]" + " val offset = index * 4" + " out[offset] = ((pixel shr 16) and 0xff).toByte()" + " out[offset + 1] = ((pixel shr 8) and 0xff).toByte()" + " out[offset + 2] = (pixel and 0xff).toByte()" + " out[offset + 3] = ((pixel ushr 24) and 0xff).toByte()" + " }" + " return out" + " }" + "" + " private fun parseCells(raw: JSONArray?): MutableList<SsdCell> {" + " val cells = mutableListOf<SsdCell>()" + " if (raw == null) return cells" + " for (index in 0 until raw.length()) {" + " val json = raw.optJSONObject(index) ?: continue" + " val id = json.optString(\"id\", \"c%04d\".format(index + 1))" + " cells.add(SsdCell(" + " id = id," + " x = json.optDouble(\"x\", 0.0).toFloat()," + " y = json.optDouble(\"y\", 0.0).toFloat()," + " w = json.optDouble(\"w\", 0.0).toFloat()," + " h = json.optDouble(\"h\", 0.0).toFloat()," + " detector = json.optString(\"detector\", \"rust-color\")" + " ))" + " }" + " return cells" + " }" + "" + " private fun parseGroups(raw: JSONArray?): MutableList<SsdGroup> {" + " val groups = mutableListOf<SsdGroup>()" + " if (raw == null) return groups" + " for (index in 0 until raw.length()) {" + " val json = raw.optJSONObject(index) ?: continue" + " val bboxArray = json.optJSONArray(\"bbox\") ?: JSONArray()" + " val idsArray = json.optJSONArray(\"cell_ids\") ?: JSONArray()" + " val ids = mutableListOf<String>()" + " for (idIndex in 0 until idsArray.length()) {" + " val id = idsArray.optString(idIndex)" + " if (id.isNotBlank()) ids.add(id)" + " }" + " groups.add(SsdGroup(" + " id = json.optString(\"id\", \"g%04d\".format(index + 1))," + " label = json.optString(\"label\")," + " boxTypeId = json.optString(\"box_type_id\")," + " status = json.optString(\"status\", \"candidate\")," + " count = json.optInt(\"count\", ids.size)," + " bbox = FloatArray(4) { bboxArray.optDouble(it, 0.0).toFloat() }," + " cellIds = ids," + " notes = json.optString(\"notes\")" + " ))" + " }" + " return groups" + " }" + "" + " private external fun nativeDetectRgbaJson(width: Int, height: Int, rgba: ByteArray): String" + "}" + )) + (kotlin-file-lines "com/sfb/ssdreview/SsdOcr.kt" + ( + "package com.sfb.ssdreview" + "" + "import android.content.Context" + "import android.graphics.Bitmap" + "import com.google.android.gms.tasks.Tasks" + "import com.google.mlkit.vision.common.InputImage" + "import com.google.mlkit.vision.text.TextRecognition" + "import com.google.mlkit.vision.text.latin.TextRecognizerOptions" + "import kotlin.math.max" + "import kotlin.math.min" + "" + "object SsdOcr {" + " private data class OcrPattern(val tokens: List<String>, val label: String, val confidence: Float)" + " private data class OcrCandidate(val label: String, val boxTypeId: String, val confidence: Float, val reason: String)" + "" + " private val patterns = listOf(" + " OcrPattern(listOf(\"PH 1\", \"PH1\", \"PHASER 1\", \"PHASER1\"), \"Phaser-1\", 0.94f)," + " OcrPattern(listOf(\"PH 2\", \"PH2\", \"PHASER 2\", \"PHASER2\"), \"Phaser-2\", 0.93f)," + " OcrPattern(listOf(\"PH 3\", \"PH3\", \"PHASER 3\", \"PHASER3\"), \"Phaser-3\", 0.93f)," + " OcrPattern(listOf(\"PH 4\", \"PH4\", \"PHASER 4\", \"PHASER4\"), \"Phaser-4\", 0.93f)," + " OcrPattern(listOf(\"DISR\", \"DIS\", \"DISRUPTOR\"), \"Disruptor\", 0.91f)," + " OcrPattern(listOf(\"PHOT\", \"PHOTON\"), \"Photon Torpedo\", 0.91f)," + " OcrPattern(listOf(\"DRONE\", \"DRN\"), \"Drone Rack\", 0.90f)," + " OcrPattern(listOf(\"PLASMA\", \"PL A\", \"PL F\", \"PL G\", \"PL S\", \"PL R\"), \"Plasma Torpedo\", 0.88f)," + " OcrPattern(listOf(\"HELLBORE\", \"HB\"), \"Hellbore\", 0.90f)," + " OcrPattern(listOf(\"FUSION\", \"FUS\"), \"Fusion Beam\", 0.90f)," + " OcrPattern(listOf(\"ESG\"), \"ESG\", 0.90f)," + " OcrPattern(listOf(\"LEFT WARP\", \"L WARP\", \"LWARP\"), \"Left Warp Engine\", 0.89f)," + " OcrPattern(listOf(\"RIGHT WARP\", \"R WARP\", \"RWARP\"), \"Right Warp Engine\", 0.89f)," + " OcrPattern(listOf(\"CENTER WARP\", \"C WARP\", \"CWARP\"), \"Center Warp Engine\", 0.88f)," + " OcrPattern(listOf(\"WARP\"), \"Warp Engine\", 0.82f)," + " OcrPattern(listOf(\"IMPULSE\", \"IMP\"), \"Impulse Engine\", 0.88f)," + " OcrPattern(listOf(\"APR\"), \"APR\", 0.88f)," + " OcrPattern(listOf(\"BAT\", \"BATT\", \"BATTERY\", \"BATTERIES\"), \"Battery\", 0.88f)," + " OcrPattern(listOf(\"LAB\", \"LABS\"), \"Lab\", 0.90f)," + " OcrPattern(listOf(\"BRIDGE\", \"BRDG\"), \"Bridge\", 0.88f)," + " OcrPattern(listOf(\"SCAN\", \"SCANNER\", \"SCANNERS\"), \"Scanner\", 0.84f)," + " OcrPattern(listOf(\"SENS\", \"SENSOR\", \"SENSORS\"), \"Sensor\", 0.84f)," + " OcrPattern(listOf(\"DAMAGE CONTROL\", \"DAM CON\", \"DAMCON\"), \"Damage Control\", 0.89f)," + " OcrPattern(listOf(\"TRAN\", \"TRANS\", \"TRANSPORTER\", \"TRANSPORTERS\"), \"Transporter\", 0.88f)," + " OcrPattern(listOf(\"TRACT\", \"TRACTOR\", \"TRACTORS\"), \"Tractor Beam\", 0.88f)," + " OcrPattern(listOf(\"PROBE\", \"PROBES\"), \"Probe Launcher\", 0.87f)," + " OcrPattern(listOf(\"CARGO\"), \"Cargo\", 0.87f)," + " OcrPattern(listOf(\"SHUTTLE\", \"SHUTTLES\", \"SHUTTLEBAY\"), \"Shuttle\", 0.84f)," + " OcrPattern(listOf(\"SECURITY\"), \"Security\", 0.85f)," + " OcrPattern(listOf(\"REPAIR\"), \"Repair\", 0.85f)," + " OcrPattern(listOf(\"PA PANEL\", \"PA PANELS\", \"POWER ABSORBER\", \"POWER ABSORBERS\"), \"PA Panel\", 0.91f)," + " OcrPattern(listOf(\"DISP DEV\", \"DISP DEVICE\", \"DISPLACEMENT DEV\", \"DISPLACEMENT DEVICE\", \"DISDEV\"), \"Displacement Device\", 0.91f)" + " )" + "" + " fun recognizeWords(bitmap: Bitmap): List<OcrWord> {" + " val recognizer = TextRecognition.getClient(TextRecognizerOptions.DEFAULT_OPTIONS)" + " return try {" + " val result = Tasks.await(recognizer.process(InputImage.fromBitmap(bitmap, 0)))" + " val words = mutableListOf<OcrWord>()" + " result.textBlocks.forEach { block ->" + " block.lines.forEach { line ->" + " line.elements.forEach { element ->" + " val box = element.boundingBox ?: line.boundingBox ?: block.boundingBox ?: return@forEach" + " val text = element.text.trim()" + " if (text.isNotBlank()) {" + " words.add(OcrWord(" + " text = text," + " conf = 80f," + " bbox = floatArrayOf(box.left.toFloat(), box.top.toFloat(), box.right.toFloat(), box.bottom.toFloat())" + " ))" + " }" + " }" + " }" + " }" + " words" + " } catch (_: Exception) {" + " emptyList()" + " } finally {" + " recognizer.close()" + " }" + " }" + "" + " fun applyOcrGuesses(context: Context, session: SsdSession): Int {" + " if (session.ocrWords.isEmpty()) return 0" + " val race = raceKey(session.sourceName + \" \" + session.sourceUri)" + " var applied = 0" + " session.groups.forEach { group ->" + " if (!shouldReplaceLabel(group)) return@forEach" + " val text = nearbyText(session.ocrWords, group.bbox, session.imageWidth, session.imageHeight)" + " val best = ocrCandidates(context, text, race).firstOrNull() ?: return@forEach" + " group.label = \"${best.label}?\"" + " if (best.boxTypeId.isNotBlank()) group.boxTypeId = best.boxTypeId" + " group.notes = appendNote(group.notes, \"OCR: ${best.label}, ${best.reason}\")" + " applied += 1" + " }" + " return applied" + " }" + "" + " private fun nearbyText(words: List<OcrWord>, bbox: FloatArray, imageWidth: Int, imageHeight: Int): String {" + " val x1 = bbox.getOrElse(0) { 0f }" + " val y1 = bbox.getOrElse(1) { 0f }" + " val x2 = bbox.getOrElse(2) { 0f }" + " val y2 = bbox.getOrElse(3) { 0f }" + " val padX = max(45f, (x2 - x1) * 1.8f)" + " val padY = max(35f, (y2 - y1) * 1.2f)" + " val rect = floatArrayOf(" + " max(0f, x1 - padX)," + " max(0f, y1 - padY)," + " min(imageWidth.toFloat(), x2 + padX)," + " min(imageHeight.toFloat(), y2 + padY)" + " )" + " return words.filter { centerInside(it.bbox, rect) }" + " .joinToString(\" \") { it.text }" + " .take(240)" + " }" + "" + " private fun centerInside(bbox: FloatArray, rect: FloatArray): Boolean {" + " if (bbox.size < 4 || rect.size < 4) return false" + " val cx = (bbox[0] + bbox[2]) / 2f" + " val cy = (bbox[1] + bbox[3]) / 2f" + " return cx >= rect[0] && cx <= rect[2] && cy >= rect[1] && cy <= rect[3]" + " }" + "" + " private fun ocrCandidates(context: Context, text: String, race: String): List<OcrCandidate> {" + " if (text.isBlank()) return emptyList()" + " val seen = mutableSetOf<String>()" + " val candidates = mutableListOf<OcrCandidate>()" + " patterns.forEach { pattern ->" + " if (!anyTokenMatch(text, pattern.tokens)) return@forEach" + " if (suppressedByRace(race, pattern.label)) return@forEach" + " val resolved = BoxTypes.resolve(context, pattern.label)" + " val label = resolved?.name ?: pattern.label" + " val key = compact(label)" + " if (seen.add(key)) {" + " candidates.add(OcrCandidate(label, resolved?.id ?: \"\", pattern.confidence, \"nearby text: ${text.take(80)}\"))" + " }" + " }" + " return candidates.sortedByDescending { it.confidence }" + " }" + "" + " private fun anyTokenMatch(text: String, tokens: List<String>): Boolean {" + " val normalized = normalizeOcr(text)" + " val compactText = compact(text)" + " return tokens.any { token ->" + " val tokenNormal = normalizeOcr(token)" + " val tokenCompact = compact(token)" + " \" $normalized \".contains(\" $tokenNormal \") || compactText.contains(tokenCompact)" + " }" + " }" + "" + " private fun shouldReplaceLabel(group: SsdGroup): Boolean {" + " val text = group.label.trim()" + " if (text.isBlank()) return true" + " if (text.endsWith(\"?\")) return true" + " if (Regex(\"\"\"^\\d+\\s+boxes$\"\"\").matches(text)) return true" + " return group.status == \"candidate\" && group.notes.startsWith(\"Guess:\")" + " }" + "" + " private fun suppressedByRace(race: String, label: String): Boolean {" + " val key = compact(label)" + " if (race == \"andromedan\" && key.contains(\"shield\")) return true" + " if (race != \"andromedan\" && (key.contains(\"papanel\") || key.contains(\"powerabsorber\") || key.contains(\"displacementdevice\"))) return true" + " return false" + " }" + "" + " private fun appendNote(existing: String, note: String): String =" + " if (existing.isBlank()) note else \"$existing; $note\"" + "" + " private fun normalizeOcr(value: String): String = value.uppercase()" + " .replace(Regex(\"[^A-Z0-9]+\"), \" \")" + " .trim()" + " .replace(Regex(\"\\\\s+\"), \" \")" + "" + " private fun compact(value: String): String = value.lowercase().filter { it.isLetterOrDigit() }" + "" + " private fun raceKey(text: String): String {" + " val lower = text.lowercase()" + " return when {" + " lower.contains(\"andromedan\") || lower.contains(\"module_c3\") || lower.contains(\"module c3\") -> \"andromedan\"" + " lower.contains(\"federation\") -> \"federation\"" + " lower.contains(\"klingon\") -> \"klingon\"" + " lower.contains(\"romulan\") -> \"romulan\"" + " lower.contains(\"gorn\") -> \"gorn\"" + " lower.contains(\"kzinti\") -> \"kzinti\"" + " lower.contains(\"lyran\") -> \"lyran\"" + " lower.contains(\"tholian\") -> \"tholian\"" + " lower.contains(\"hydran\") -> \"hydran\"" + " else -> \"\"" + " }" + " }" + "}" + )) (kotlin-file-lines "com/sfb/ssdreview/SsdReviewView.kt" ( "package com.sfb.ssdreview" @@ -2209,13 +2696,20 @@ " field = value" " fitImage()" " }" - " var session: SsdSession? = null" - " set(value) {" - " field = value" - " invalidate()" - " }" - " var selectMode: Boolean = false" - " var selectedGroupId: String? = null" + " var session: SsdSession? = null" + " set(value) {" + " field = value" + " ssdArea = value?.ssdArea" + " invalidate()" + " }" + " var selectMode: Boolean = false" + " var snapSelectionToCells: Boolean = true" + " var ssdArea: FloatArray? = null" + " set(value) {" + " field = value" + " invalidate()" + " }" + " var selectedGroupId: String? = null" " set(value) {" " field = value" " invalidate()" @@ -2233,40 +2727,41 @@ " style = Paint.Style.FILL" " textAlign = Paint.Align.CENTER" " }" - " private val labelBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {" - " color = Color.argb(220, 255, 255, 255)" - " style = Paint.Style.FILL" - " }" - " private var scale = 1f" + " private val labelBgPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {" + " color = Color.argb(220, 255, 255, 255)" + " style = Paint.Style.FILL" + " }" + " private val density = context.resources.displayMetrics.density" + " private val selectionOffsetY = 70f * density" + " private var scale = 1f" " private var offsetX = 0f" " private var offsetY = 0f" " private var rotationDegrees = 0" " private var lastX = 0f" " private var lastY = 0f" - " private var dragStart: FloatArray? = null"