Move SSD state models to typed Kotlin
ober
f7c53ccca0e9ab76dab9668be2e56be169dca98b
--- a/.build.yml +++ b/.build.yml @@ -5,7 +5,7 @@ packages: - make=4.4.1-r4 sources: # Build dependency: full immutable commit, mirrored in dependencies.lock.json. - - "https://git.sr.ht/~lisp/jerboa#e24a87752cc82173b8fe56d836f17d5bcd9e2006" + - "https://git.sr.ht/~lisp/jerboa#b4469164cf8a3707a4bae1272ccd645828b08695" # The second source is the build subject selected by the SourceHut submitter. - https://git.sr.ht/~lisp/jerboa-android tasks: @@ -14,6 +14,6 @@ tasks: test "$(apk info -v chez-scheme)" = chez-scheme-10.3.0-r2 test "$(apk info -v git)" = git-2.54.0-r0 test "$(apk info -v make)" = make-4.4.1-r4 - test "$(git -C ../jerboa rev-parse HEAD)" = e24a87752cc82173b8fe56d836f17d5bcd9e2006 - test "$(git -C ../jerboa rev-parse 'HEAD^{tree}')" = 0b40381c8ea6652a46503fcea38208aed61c6023 + test "$(git -C ../jerboa rev-parse HEAD)" = b4469164cf8a3707a4bae1272ccd645828b08695 + test "$(git -C ../jerboa rev-parse 'HEAD^{tree}')" = 9b0d7a8d2f2a70394b86a1ce6de43901094d10f7 JERBOA="chez --libdirs .:../jerboa/lib --script" make test --- a/dependencies.lock.json +++ b/dependencies.lock.json @@ -11,8 +11,8 @@ "generator_runtime": { "name": "jerboa", "repository": "https://git.sr.ht/~lisp/jerboa", - "commit": "e24a87752cc82173b8fe56d836f17d5bcd9e2006", - "tree": "0b40381c8ea6652a46503fcea38208aed61c6023" + "commit": "b4469164cf8a3707a4bae1272ccd645828b08695", + "tree": "9b0d7a8d2f2a70394b86a1ce6de43901094d10f7" }, "assurance_tools": { "osv_scanner": { --- a/full-kotlin.md +++ b/full-kotlin.md @@ -43,7 +43,7 @@ The vendored Jerboa tree already has the right starting point: Do not fork that model inside `jandroid.ss`. General Kotlin backend work belongs in Jerboa. Android project and Android DSL work belongs in `jerboa-android`. -Version caveat: this repository now pins Jerboa commit `e24a87752cc82173b8fe56d836f17d5bcd9e2006`, tree `0b40381c8ea6652a46503fcea38208aed61c6023`. Before editing compiler APIs, check out the exact pinned commit (or the intended replacement commit), rerun the typed parser/checker/core inventory, and record any differences. Do not assume two Jerboa snapshots expose identical IR. +Version caveat: this repository now pins Jerboa commit `b4469164cf8a3707a4bae1272ccd645828b08695`, tree `9b0d7a8d2f2a70394b86a1ce6de43901094d10f7`. Before editing compiler APIs, check out the exact pinned commit (or the intended replacement commit), rerun the typed parser/checker/core inventory, and record any differences. Do not assume two Jerboa snapshots expose identical IR. ### 2.3 `~/sfb` source requirements --- a/scripts/verify-supply-chain.sh +++ b/scripts/verify-supply-chain.sh @@ -3,8 +3,8 @@ set -eu repo=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd -P) lock="$repo/dependencies.lock.json" -jerboa_commit=e24a87752cc82173b8fe56d836f17d5bcd9e2006 # gitsafe:ignore -jerboa_tree=0b40381c8ea6652a46503fcea38208aed61c6023 # gitsafe:ignore +jerboa_commit=b4469164cf8a3707a4bae1272ccd645828b08695 # gitsafe:ignore +jerboa_tree=9b0d7a8d2f2a70394b86a1ce6de43901094d10f7 # gitsafe:ignore gradle_sha=20f1b1176237254a6fc204d8434196fa11a4cfb387567519c61556e8710aed78 jdk_macos_sha=8fa1eff40bb637a33613b2ccb8b12c70dc3661cc22cf8e784943715769a05336 jdk_linux_sha=d8afc263758141a66e0e3aafc321e783f7016696f4eaea067d340a269037d331 --- a/templates/ssd-review.ss +++ b/templates/ssd-review.ss @@ -23,6 +23,119 @@ (conf : Float32) (bbox : FloatArray))))) + (typed-kotlin-file "com/sfb/ssdreview/SsdCell.kt" + (typed-library (com sfb ssdreview) + (export make-SsdCell SsdCell? SsdCell-id + SsdCell-x SsdCell-x-set! + SsdCell-y SsdCell-y-set! + SsdCell-w SsdCell-w-set! + SsdCell-h SsdCell-h-set! + SsdCell-detector) + (type Float32) + (record SsdCell + ((id : String) + (mut x : Float32) + (mut y : Float32) + (mut w : Float32) + (mut h : Float32) + (detector : String))))) + + (typed-kotlin-file "com/sfb/ssdreview/SsdGroup.kt" + (typed-library (com sfb ssdreview) + (export make-SsdGroup SsdGroup? SsdGroup-id + SsdGroup-label SsdGroup-label-set! + SsdGroup-boxTypeId SsdGroup-boxTypeId-set! + SsdGroup-status SsdGroup-status-set! + SsdGroup-count SsdGroup-count-set! + SsdGroup-bbox SsdGroup-bbox-set! + SsdGroup-cellIds + SsdGroup-firingArc SsdGroup-firingArc-set! + SsdGroup-notes SsdGroup-notes-set!) + (type Int32) + (type FloatArray) + (record SsdGroup + ((id : String) + (mut label : String) + (mut boxTypeId : String) + (mut status : String) + (mut count : Int32) + (mut bbox : FloatArray) + (cellIds : (MutableList String)) + (mut firingArc : String) + (mut notes : String))))) + + (typed-kotlin-file "com/sfb/ssdreview/SsdSession.kt" + (typed-library (com sfb ssdreview) + (export make-SsdSession SsdSession? SsdSession-sessionId + SsdSession-sourceKey SsdSession-sourceName SsdSession-sourceUri + SsdSession-page SsdSession-pageCount SsdSession-dpi + SsdSession-imageWidth SsdSession-imageHeight + SsdSession-cells SsdSession-groups SsdSession-ocrWords + SsdSession-suppressedGroups + SsdSession-ssdArea SsdSession-ssdArea-set!) + (type Int32) + (type FloatArray) + (type SsdCell) + (type SsdGroup) + (type OcrWord) + (record SsdSession + ((sessionId : String) + (sourceKey : String) + (sourceName : String) + (sourceUri : String) + (page : Int32) + (pageCount : Int32) + (dpi : Int32) + (imageWidth : Int32) + (imageHeight : Int32) + (cells : (MutableList SsdCell)) + (groups : (MutableList SsdGroup)) + (ocrWords : (MutableList OcrWord)) + (suppressedGroups : (MutableList SsdGroup)) + (mut ssdArea : (Nullable FloatArray)))))) + + (typed-kotlin-file "com/sfb/ssdreview/NativeDetection.kt" + (typed-library (com sfb ssdreview) + (export make-NativeDetection NativeDetection? + NativeDetection-cells NativeDetection-groups) + (type SsdCell) + (type SsdGroup) + (record NativeDetection + ((cells : (List SsdCell)) + (groups : (List SsdGroup)))))) + + (typed-kotlin-file "com/sfb/ssdreview/ViewportState.kt" + (typed-library (com sfb ssdreview) + (export make-ViewportState ViewportState? + ViewportState-scale ViewportState-offsetX ViewportState-offsetY + ViewportState-rotationDegrees + ViewportState-imageWidth ViewportState-imageHeight) + (type Float32) + (type Int32) + (record ViewportState + ((scale : Float32) + (offsetX : Float32) + (offsetY : Float32) + (rotationDegrees : Int32) + (imageWidth : Int32) + (imageHeight : Int32))))) + + (typed-kotlin-file "com/sfb/ssdreview/TruthStoreCounters.kt" + (typed-library (com sfb ssdreview) + (export make-ImportBudget ImportBudget? + ImportBudget-entries ImportBudget-entries-set! + ImportBudget-expandedBytes ImportBudget-expandedBytes-set! + make-StorageStats StorageStats? + StorageStats-files StorageStats-files-set! + StorageStats-bytes StorageStats-bytes-set!) + (type Int32) + (record ImportBudget + ((mut entries : Int32) + (mut expandedBytes : Int))) + (record StorageStats + ((mut files : Int32) + (mut bytes : Int))))) + (typed-kotlin-file "com/sfb/ssdreview/Guess.kt" (typed-library (com sfb ssdreview) (export make-Guess Guess? Guess-label Guess-boxType Guess-reason) @@ -741,7 +854,7 @@ " reviewView.clearInteractionState()" " }" "" - " private fun restoreViewportAfterLayout(viewport: SsdReviewView.ViewportState?) {" + " private fun restoreViewportAfterLayout(viewport: ViewportState?) {" " reviewView.restoreViewport(viewport)" " reviewView.post { reviewView.restoreViewport(viewport) }" " reviewView.postDelayed({ reviewView.restoreViewport(viewport) }, 180)" @@ -822,9 +935,9 @@ " max(area[1], area[3])" " )" " val beforeGroups = session.groups.size" - " val outsideCount = session.groups.count { !session.groupInsideSsdArea(it) }" + " val outsideCount = session.groups.count { !ssdSessionGroupInsideSsdArea(session, it) }" " val removed = if (beforeGroups > 0 && outsideCount < beforeGroups) {" - " session.pruneGroupsOutsideSsdArea()" + " ssdSessionPruneGroupsOutsideSsdArea(session)" " } else {" " 0" " }" @@ -885,7 +998,7 @@ " val group = selectedGroup() ?: return" " group.status = \"reviewed\"" " group.notes = \"Approved on Android\"" - " session.recomputeGroup(group)" + " ssdSessionRecomputeGroup(session, group)" " truthStore.appendEvent(\"group_approved\", session)" " reviewView.performHapticFeedback(HapticFeedbackConstants.CONFIRM)" " reviewView.invalidate()" @@ -964,14 +1077,17 @@ " imageWidth = bitmap.width," " imageHeight = bitmap.height," " cells = mutableListOf()," - " groups = mutableListOf()" + " groups = mutableListOf()," + " ocrWords = mutableListOf()," + " suppressedGroups = mutableListOf()," + " ssdArea = null" " )" "" " 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)" - " session.pruneGroupsOutsideSsdArea()" + " ssdSessionPruneGroupsOutsideSsdArea(session)" " runOnUiThread {" " if (!isCurrentLoad(expectedGeneration, uri, expectedPageIndex)) return@runOnUiThread" " currentSession = session" @@ -997,7 +1113,7 @@ " detectorSource = \"local-truth\"" " session.ssdArea = jsonRect(existingTruth.optJSONArray(\"ssd_area\"))" " truthStore.applyTruth(session)" - " session.pruneGroupsOutsideSsdArea()" + " ssdSessionPruneGroupsOutsideSsdArea(session)" " } else {" " if (existingTruth != null) {" " session.ssdArea = jsonRect(existingTruth.optJSONArray(\"ssd_area\"))" @@ -1012,11 +1128,11 @@ " detectedCells = SsdDetector.detectCells(bitmap)" " detectorSource = if (forceDetect && existingTruthGroupCount > 0) \"forced-android-plus-truth\" else \"android-fallback\"" " }" - " session.cells.addAll(detectedCells.filter { session.cellInsideSsdArea(it) })" + " session.cells.addAll(detectedCells.filter { ssdSessionCellInsideSsdArea(session, it) })" " session.groups.addAll(SsdDetector.initialGroups(session.cells).take(120))" " if (existingTruth != null && existingTruthGroupCount > 0) {" " truthStore.applyTruth(session)" - " session.pruneGroupsOutsideSsdArea()" + " ssdSessionPruneGroupsOutsideSsdArea(session)" " }" " Guessing.applyInitialGuesses(this, session)" " session.ocrWords.addAll(SsdOcr.recognizeWords(bitmap))" @@ -1066,7 +1182,7 @@ " )" " merged.ssdArea = jsonRect(truth.optJSONArray(\"ssd_area\"))" " truthStore.applyTruth(merged)" - " merged.pruneGroupsOutsideSsdArea()" + " ssdSessionPruneGroupsOutsideSsdArea(merged)" " runOnUiThread {" " if (!isCurrentLoad(generation, uri, page)) return@runOnUiThread" " currentSession = merged" @@ -1200,7 +1316,7 @@ " savedStatus: String," " afterSaved: (() -> Unit)? = null" " ) {" - " val truth = session.toTruthJson()" + " val truth = ssdSessionToTruthJson(session)" " val viewport = reviewView.captureViewport()" " finishSelectInteraction()" " restoreViewportAfterLayout(viewport)" @@ -1239,7 +1355,7 @@ " createManualCells(session, rect, count.coerceIn(1, 400))" " }" " val group = SsdGroup(" - " id = session.nextGroupId()," + " id = ssdSessionNextGroupId(session)," " label = cleanLabel," " boxTypeId = resolvedType?.id ?: boxType," " status = \"reviewed\"," @@ -1277,7 +1393,7 @@ " group.cellIds.add(id)" " }" " }" - " session.recomputeGroup(group)" + " ssdSessionRecomputeGroup(session, group)" " group.status = \"reviewed\"" " group.notes = \"Appended on Android\"" " finishSelectInteraction()" @@ -1345,7 +1461,7 @@ " group.firingArc = normalizeFiringArc(firingArcText)" " group.count = countValue?.coerceAtLeast(0) ?: group.count" " group.status = \"reviewed\"" - " session.recomputeGroup(group)" + " ssdSessionRecomputeGroup(session, group)" " truthStore.appendEvent(\"group_edited\", session)" " reviewView.performHapticFeedback(HapticFeedbackConstants.CONFIRM)" " reviewView.invalidate()" @@ -1378,7 +1494,7 @@ " val y1 = rect[1] + height * row / rows" " val x2 = rect[0] + width * (column + 1) / columns" " val y2 = rect[1] + height * (row + 1) / rows" - " val cell = SsdCell(session.nextCellId(), x1, y1, x2 - x1, y2 - y1, \"android-manual\")" + " val cell = SsdCell(ssdSessionNextCellId(session), x1, y1, x2 - x1, y2 - y1, \"android-manual\")" " session.cells.add(cell)" " ids.add(cell.id)" " }" @@ -1796,12 +1912,12 @@ " edit.apply()" " }" "" - " private fun restoredViewportFor(uri: Uri, page: Int): SsdReviewView.ViewportState? {" + " private fun restoredViewportFor(uri: Uri, page: Int): ViewportState? {" " val prefs = getPreferences(MODE_PRIVATE)" " if (prefs.getString(PREF_LAST_PDF_URI, null) != uri.toString()) return null" " if (prefs.getInt(PREF_LAST_VIEWPORT_PAGE_INDEX, -1) != page) return null" " if (!prefs.contains(PREF_LAST_VIEWPORT_SCALE)) return null" - " return SsdReviewView.ViewportState(" + " return ViewportState(" " prefs.getFloat(PREF_LAST_VIEWPORT_SCALE, 1f)," " prefs.getFloat(PREF_LAST_VIEWPORT_OFFSET_X, 0f)," " prefs.getFloat(PREF_LAST_VIEWPORT_OFFSET_Y, 0f)," @@ -2022,214 +2138,173 @@ "import kotlin.math.max" "import kotlin.math.min" "" - "data class SsdCell(" - " val id: String," - " var x: Float," - " var y: Float," - " var w: Float," - " var h: Float," - " val detector: String = \"android\"" - ") {" - " val cx: Float get() = x + w / 2f" - " val cy: Float get() = y + h / 2f" - "" - " fun rect(): FloatArray = floatArrayOf(x, y, x + w, y + h)" - "" - " fun toJson(): JSONObject = JSONObject()" - " .put(\"id\", id)" - " .put(\"x\", x.toDouble())" - " .put(\"y\", y.toDouble())" - " .put(\"w\", w.toDouble())" - " .put(\"h\", h.toDouble())" - " .put(\"cx\", cx.toDouble())" - " .put(\"cy\", cy.toDouble())" - " .put(\"detector\", detector)" - " .put(\"mean_bgr\", JSONArray(listOf(0, 0, 0)))" + "fun ssdCellCx(cell: SsdCell): Float = cell.x + cell.w / 2f" + "" + "fun ssdCellCy(cell: SsdCell): Float = cell.y + cell.h / 2f" + "" + "fun ssdCellRect(cell: SsdCell): FloatArray = floatArrayOf(cell.x, cell.y, cell.x + cell.w, cell.y + cell.h)" + "" + "fun ssdCellToJson(cell: SsdCell): JSONObject = JSONObject()" + " .put(\"id\", cell.id)" + " .put(\"x\", cell.x.toDouble())" + " .put(\"y\", cell.y.toDouble())" + " .put(\"w\", cell.w.toDouble())" + " .put(\"h\", cell.h.toDouble())" + " .put(\"cx\", ssdCellCx(cell).toDouble())" + " .put(\"cy\", ssdCellCy(cell).toDouble())" + " .put(\"detector\", cell.detector)" + " .put(\"mean_bgr\", JSONArray(listOf(0, 0, 0)))" + "" + "fun ssdCellFromJson(json: JSONObject): SsdCell = SsdCell(" + " id = json.optString(\"id\")," + " x = json.optDouble(\"x\").toFloat()," + " y = json.optDouble(\"y\").toFloat()," + " w = json.optDouble(\"w\").toFloat()," + " h = json.optDouble(\"h\").toFloat()," + " detector = json.optString(\"detector\", \"truth\")" + ")" + "" + "fun ssdGroupToTruthJson(group: SsdGroup, cellsById: Map<String, SsdCell>): JSONObject {" + " val groupCells = group.cellIds.mapNotNull { cellsById[it] }" + " val ids = JSONArray()" + " group.cellIds.forEach { ids.put(it) }" + " val cells = JSONArray()" + " groupCells.forEach { cells.put(ssdCellToJson(it)) }" + " val box = JSONArray()" + " group.bbox.forEach { box.put(it.toDouble()) }" + " val json = JSONObject()" + " .put(\"source_group_id\", group.id)" + " .put(\"label\", group.label)" + " .put(\"box_type_id\", group.boxTypeId)" + " .put(\"status\", group.status)" + " .put(\"count\", if (group.count > 0) group.count else groupCells.size)" + " .put(\"bbox\", box)" + " .put(\"cell_ids\", ids)" + " .put(\"cells\", cells)" + " .put(\"notes\", group.notes)" + " if (group.firingArc.isNotBlank()) {" + " json.put(\"firing_arc\", group.firingArc)" + " }" + " return json" + "}" "" - " companion object {" - " fun fromJson(json: JSONObject): SsdCell = SsdCell(" - " id = json.optString(\"id\")," - " x = json.optDouble(\"x\").toFloat()," - " y = json.optDouble(\"y\").toFloat()," - " w = json.optDouble(\"w\").toFloat()," - " h = json.optDouble(\"h\").toFloat()," - " detector = json.optString(\"detector\", \"truth\")" - " )" - " }" + "fun ssdGroupFromTruthJson(json: JSONObject): Pair<SsdGroup, List<SsdCell>> {" + " val cells = mutableListOf<SsdCell>()" + " val ids = mutableListOf<String>()" + " val cellArray = json.optJSONArray(\"cells\") ?: JSONArray()" + " for (i in 0 until cellArray.length()) {" + " val cell = ssdCellFromJson(cellArray.getJSONObject(i))" + " cells.add(cell)" + " ids.add(cell.id)" + " }" + " val bboxArray = json.optJSONArray(\"bbox\") ?: JSONArray()" + " val bbox = FloatArray(4) { index -> bboxArray.optDouble(index, 0.0).toFloat() }" + " val group = SsdGroup(" + " id = json.optString(\"source_group_id\", json.optString(\"id\", \"g0000\"))," + " label = json.optString(\"label\")," + " boxTypeId = json.optString(\"box_type_id\")," + " status = json.optString(\"status\", \"reviewed\")," + " count = json.optInt(\"count\", cells.size)," + " bbox = bbox," + " cellIds = ids," + " firingArc = json.optString(\"firing_arc\", json.optString(\"arc\"))," + " notes = json.optString(\"notes\")" + " )" + " return group to cells" "}" "" - "data class SsdGroup(" - " val id: String," - " var label: String = \"\"," - " var boxTypeId: String = \"\"," - " var status: String = \"candidate\"," - " var count: Int = 0," - " var bbox: FloatArray = floatArrayOf(0f, 0f, 0f, 0f)," - " val cellIds: MutableList<String> = mutableListOf()," - " var firingArc: String = \"\"," - " var notes: String = \"\"" - ") {" - " fun toTruthJson(cellsById: Map<String, SsdCell>): JSONObject {" - " val groupCells = cellIds.mapNotNull { cellsById[it] }" - " val ids = JSONArray()" - " cellIds.forEach { ids.put(it) }" - " val cells = JSONArray()" - " groupCells.forEach { cells.put(it.toJson()) }" - " val box = JSONArray()" - " bbox.forEach { box.put(it.toDouble()) }" - " val json = JSONObject()" - " .put(\"source_group_id\", id)" - " .put(\"label\", label)" - " .put(\"box_type_id\", boxTypeId)" - " .put(\"status\", status)" - " .put(\"count\", if (count > 0) count else groupCells.size)" - " .put(\"bbox\", box)" - " .put(\"cell_ids\", ids)" - " .put(\"cells\", cells)" - " .put(\"notes\", notes)" - " if (firingArc.isNotBlank()) {" - " json.put(\"firing_arc\", firingArc)" - " }" - " return json" - " }" + "fun ssdSessionCellsById(session: SsdSession): Map<String, SsdCell> = session.cells.associateBy { it.id }" "" - " companion object {" - " fun fromTruthJson(json: JSONObject): Pair<SsdGroup, List<SsdCell>> {" - " val cells = mutableListOf<SsdCell>()" - " val ids = mutableListOf<String>()" - " val cellArray = json.optJSONArray(\"cells\") ?: JSONArray()" - " for (i in 0 until cellArray.length()) {" - " val cell = SsdCell.fromJson(cellArray.getJSONObject(i))" - " cells.add(cell)" - " ids.add(cell.id)" - " }" - " val bboxArray = json.optJSONArray(\"bbox\") ?: JSONArray()" - " val bbox = FloatArray(4) { index -> bboxArray.optDouble(index, 0.0).toFloat() }" - " val group = SsdGroup(" - " id = json.optString(\"source_group_id\", json.optString(\"id\", \"g0000\"))," - " label = json.optString(\"label\")," - " boxTypeId = json.optString(\"box_type_id\")," - " status = json.optString(\"status\", \"reviewed\")," - " count = json.optInt(\"count\", cells.size)," - " bbox = bbox," - " cellIds = ids," - " firingArc = json.optString(\"firing_arc\", json.optString(\"arc\"))," - " notes = json.optString(\"notes\")" - " )" - " return group to cells" - " }" + "fun ssdSessionRecomputeGroup(session: SsdSession, group: SsdGroup) {" + " val groupCells = group.cellIds.mapNotNull { id -> session.cells.firstOrNull { it.id == id } }" + " group.count = groupCells.size" + " if (groupCells.isEmpty()) {" + " return" " }" + " val x1 = groupCells.minOf { it.x }" + " 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)" "}" "" - "data class SsdSession(" - " val sessionId: String," - " val sourceKey: String," - " val sourceName: String," - " val sourceUri: String," - " val page: Int," - " val pageCount: Int," - " val dpi: Int," - " val imageWidth: Int," - " 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 ssdSessionCellInsideSsdArea(session: SsdSession, cell: SsdCell): Boolean =" + " session.ssdArea?.let { area -> rectCenterInside(ssdCellRect(cell), area) } ?: true" "" - " fun recomputeGroup(group: SsdGroup) {" - " val groupCells = group.cellIds.mapNotNull { id -> cells.firstOrNull { it.id == id } }" - " group.count = groupCells.size" - " if (groupCells.isEmpty()) {" - " return" + "fun ssdSessionGroupInsideSsdArea(session: SsdSession, group: SsdGroup): Boolean =" + " session.ssdArea?.let { area -> rectCenterInside(group.bbox, area) } ?: true" + "" + "fun ssdSessionSetSsdArea(session: SsdSession, area: FloatArray): Int {" + " session.ssdArea = floatArrayOf(" + " min(area[0], area[2])," + " min(area[1], area[3])," + " max(area[0], area[2])," + " max(area[1], area[3])" + " )" + " return ssdSessionPruneGroupsOutsideSsdArea(session)" + "}" + "" + "fun ssdSessionPruneGroupsOutsideSsdArea(session: SsdSession): Int {" + " val area = session.ssdArea ?: return 0" + " val outside = session.groups.filter { group -> !rectCenterInside(group.bbox, area) }.toList()" + " if (outside.isNotEmpty() && outside.size >= session.groups.size) return 0" + " outside.forEach { group ->" + " session.groups.remove(group)" + " group.status = \"suppressed\"" + " group.notes = if (group.notes.isBlank()) \"Outside SSD area\" else \"${group.notes}; Outside SSD area\"" + " if (session.suppressedGroups.none { it.id == group.id }) {" + " session.suppressedGroups.add(group)" " }" - " val x1 = groupCells.minOf { it.x }" - " 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 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()" - " if (outside.isNotEmpty() && outside.size >= groups.size) return 0" - " 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)" " }" + " return outside.size" + "}" "" - " fun nextGroupId(): String {" - " val maxId = (groups + suppressedGroups)" - " .mapNotNull { it.id.removePrefix(\"g\").toIntOrNull() }" - " .maxOrNull() ?: 0" - " return \"g%04d\".format(maxId + 1)" - " }" + "fun ssdSessionNextCellId(session: SsdSession): String {" + " val maxId = session.cells.mapNotNull { it.id.removePrefix(\"c\").toIntOrNull() }.maxOrNull() ?: 0" + " return \"c%04d\".format(maxId + 1)" + "}" "" - " fun toTruthJson(): JSONObject {" - " val cellsById = cellsById()" - " val groupsJson = JSONArray()" - " groups.filter { it.status == \"approved\" || it.status == \"reviewed\" }.forEach {" - " groupsJson.put(it.toTruthJson(cellsById))" - " }" - " val suppressedJson = JSONArray()" - " suppressedGroups.forEach { suppressedJson.put(it.toTruthJson(cellsById)) }" - " val payload = JSONObject()" - " .put(\"schema_version\", 1)" - " .put(\"generated_at\", System.currentTimeMillis().toString())" - " .put(\"session_id\", sessionId)" - " .put(\"source_key\", sourceKey)" - " .put(\"source_sha1\", sourceKey.substringBefore(\"-p\"))" - " .put(\"source\", JSONObject()" - " .put(\"kind\", \"pdf\")" - " .put(\"path\", sourceUri)" - " .put(\"name\", sourceName)" - " .put(\"page\", page)" - " .put(\"page_count\", pageCount)" - " .put(\"dpi\", dpi))" - " .put(\"image\", JSONObject()" - " .put(\"width\", imageWidth)" - " .put(\"height\", imageHeight))" - " .put(\"parser\", JSONObject()" - " .put(\"name\", \"SFB SSD Review Android\")" - " .put(\"version\", \"android-kotlin-mvp-1\")" - " .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 ssdSessionNextGroupId(session: SsdSession): String {" + " val maxId = (session.groups + session.suppressedGroups)" + " .mapNotNull { it.id.removePrefix(\"g\").toIntOrNull() }" + " .maxOrNull() ?: 0" + " return \"g%04d\".format(maxId + 1)" + "}" + "" + "fun ssdSessionToTruthJson(session: SsdSession): JSONObject {" + " val cellsById = ssdSessionCellsById(session)" + " val groupsJson = JSONArray()" + " session.groups.filter { it.status == \"approved\" || it.status == \"reviewed\" }.forEach {" + " groupsJson.put(ssdGroupToTruthJson(it, cellsById))" + " }" + " val suppressedJson = JSONArray()" + " session.suppressedGroups.forEach { suppressedJson.put(ssdGroupToTruthJson(it, cellsById)) }" + " val payload = JSONObject()" + " .put(\"schema_version\", 1)" + " .put(\"generated_at\", System.currentTimeMillis().toString())" + " .put(\"session_id\", session.sessionId)" + " .put(\"source_key\", session.sourceKey)" + " .put(\"source_sha1\", session.sourceKey.substringBefore(\"-p\"))" + " .put(\"source\", JSONObject()" + " .put(\"kind\", \"pdf\")" + " .put(\"path\", session.sourceUri)" + " .put(\"name\", session.sourceName)" + " .put(\"page\", session.page)" + " .put(\"page_count\", session.pageCount)" + " .put(\"dpi\", session.dpi))" + " .put(\"image\", JSONObject()" + " .put(\"width\", session.imageWidth)" + " .put(\"height\", session.imageHeight))" + " .put(\"parser\", JSONObject()" + " .put(\"name\", \"SFB SSD Review Android\")" + " .put(\"version\", \"android-kotlin-mvp-1\")" + " .put(\"cell_detection\", JSONArray(if (session.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\", session.ocrWords.size))" + " .put(\"groups\", groupsJson)" + " .put(\"suppressed_groups\", suppressedJson)" + " session.ssdArea?.let { payload.put(\"ssd_area\", floatArrayToJson(it)) }" + " return payload" "}" "" "fun bboxForCells(cells: List<SsdCell>): FloatArray {" @@ -2347,10 +2422,14 @@ " val groupCells = ids.mapNotNull { id -> cells.firstOrNull { it.id == id } }" " SsdGroup(" " id = \"g%04d\".format(index + 1)," + " label = \"\"," + " boxTypeId = \"\"," " status = \"candidate\"," " count = groupCells.size," " bbox = bboxForCells(groupCells)," - " cellIds = ids" + " cellIds = ids," + " firingArc = \"\"," + " notes = \"\"" " )" " }" " .toMutableList()" @@ -2531,7 +2610,7 @@ " }" "" " private fun iou(a: SsdCell, b: SsdCell): Float {" - " val overlap = rectOverlapArea(a.rect(), b.rect())" + " val overlap = rectOverlapArea(ssdCellRect(a), ssdCellRect(b))" " if (overlap <= 0f) return 0f" " return overlap / (a.w * a.h + b.w * b.h - overlap)" " }" @@ -2692,11 +2771,6 @@ "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\")" @@ -2785,6 +2859,7 @@ " count = json.optInt(\"count\", ids.size)," " bbox = FloatArray(4) { bboxArray.optDouble(it, 0.0).toFloat() }," " cellIds = ids," + " firingArc = json.optString(\"firing_arc\", json.optString(\"arc\"))," " notes = json.optString(\"notes\")" " ))" " }" @@ -3000,15 +3075,6 @@ "import kotlin.math.min" "" "class SsdReviewView(context: Context) : View(context) {" - " data class ViewportState(" - " val scale: Float," - " val offsetX: Float," - " val offsetY: Float," - " val rotationDegrees: Int," - " val imageWidth: Int," - " val imageHeight: Int" - " )" - "" " var bitmap: Bitmap? = null" " set(value) {" " field = value" @@ -3239,7 +3305,7 @@ " }" " val matching = if (snapSelectionToCells) {" " sess.cells.filter { cell ->" - " val overlap = rectOverlapArea(rect, cell.rect())" + " val overlap = rectOverlapArea(rect, ssdCellRect(cell))" " overlap / max(1f, cell.w * cell.h) >= 0.08f" " }" " } else {" @@ -3554,13 +3620,6 @@ " val bearerToken: String" " )" "" - " private data class ImportBudget(" - " var entries: Int = 0," - " var expandedBytes: Long = 0L" - " )" - "" - " private data class StorageStats(var files: Int = 0, var bytes: Long = 0L)" - "" " private data class StagedZipEntry(val name: String, val file: File, val modified: Long)" "" " private data class TruthIndexEntry(" @@ -3660,7 +3719,7 @@ " (candidate.groupCount == current.groupCount && candidate.truthTime > current.truthTime)" "" " fun saveSession(session: SsdSession): File =" - " saveTruthSnapshot(session.toTruthJson())" + " saveTruthSnapshot(ssdSessionToTruthJson(session))" "" " @Synchronized" " fun saveTruthSnapshot(truth: JSONObject): File {" @@ -3704,17 +3763,17 @@ " val groups = truth.optJSONArray(\"groups\") ?: JSONArray()" " val cellIds = session.cells.map { it.id }.toMutableSet()" " for (i in 0 until groups.length()) {" - " val (group, truthCells) = SsdGroup.fromTruthJson(groups.getJSONObject(i))" + " val (group, truthCells) = ssdGroupFromTruthJson(groups.getJSONObject(i))" " remapAndAddTruthCells(session, group, truthCells, cellIds)" " session.groups.add(group)" " }" " val suppressed = truth.optJSONArray(\"suppressed_groups\") ?: JSONArray()" " for (i in 0 until suppressed.length()) {" - " val (group, truthCells) = SsdGroup.fromTruthJson(suppressed.getJSONObject(i))" + " val (group, truthCells) = ssdGroupFromTruthJson(suppressed.getJSONObject(i))" " remapAndAddTruthCells(session, group, truthCells, cellIds)" " session.suppressedGroups.add(group)" " }" - " session.pruneGroupsOutsideSsdArea()" + " ssdSessionPruneGroupsOutsideSsdArea(session)" " }" "" " private class LimitedInputStream(input: InputStream, private val limit: Long) : FilterInputStream(input) {" @@ -3793,7 +3852,7 @@ " }" "" " private fun storageStats(): StorageStats {" - " val stats = StorageStats()" + " val stats = StorageStats(0, 0L)" " fun walk(file: File, depth: Int) {" " if (depth > 32) throw IllegalStateException(\"Storage tree too deep\")" " if (Files.isSymbolicLink(file.toPath())) throw IllegalStateException(\"Storage symlink rejected\")" @@ -4186,7 +4245,7 @@ " fun importZip(input: InputStream): Int {" " val stage = Files.createTempDirectory(context.cacheDir.toPath(), \"ssd-import-\").toFile()" " val staged = mutableListOf<StagedZipEntry>()" - " val budget = ImportBudget()" + " val budget = ImportBudget(0, 0L)" " var count = 0" " try {" " val compressed = LimitedInputStream(input, MAX_REMOTE_ZIP_BYTES)" @@ -4420,7 +4479,7 @@ "" " private fun importFromTree(tree: DocumentFile): Int {" " var count = 0" - " val budget = ImportBudget()" + " val budget = ImportBudget(0, 0L)" " listOf(\"ground_truth\", \"learned\", \"events\", \"reviews\").forEach { name ->" " val sourceDir = tree.findFile(name)" " val localDir = File(root, name)"