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 @@ -23,16 +23,8 @@ import org.opencv.core.MatOfByte
import org.opencv.imgcodecs.Imgcodecs
import xyz.sevive.arcaeaoffline.core.database.entities.PlayResult
import xyz.sevive.arcaeaoffline.core.ocr.ImageHashesDatabase
import xyz.sevive.arcaeaoffline.core.ocr.device.CropBlackEdges
import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcr
import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcrResult
import xyz.sevive.arcaeaoffline.core.ocr.device.rois.DeviceRoisAutoSelector
import xyz.sevive.arcaeaoffline.core.ocr.device.rois.DeviceRoisAutoSelectorResult
import xyz.sevive.arcaeaoffline.core.ocr.device.rois.definition.DeviceRoisAutoT1
import xyz.sevive.arcaeaoffline.core.ocr.device.rois.definition.DeviceRoisAutoT2
import xyz.sevive.arcaeaoffline.core.ocr.device.rois.extractor.DeviceRoisExtractor
import xyz.sevive.arcaeaoffline.core.ocr.device.rois.masker.DeviceRoisMaskerAutoT1
import xyz.sevive.arcaeaoffline.core.ocr.device.rois.masker.DeviceRoisMaskerAutoT2
import xyz.sevive.arcaeaoffline.core.ocr.device.DeviceOcrScenario
import xyz.sevive.arcaeaoffline.core.ocr.device.toPlayResult
import xyz.sevive.arcaeaoffline.core.ocr.opencv.use
import xyz.sevive.arcaeaoffline.helpers.context.getFilename
Expand Down Expand Up @@ -63,38 +55,8 @@ object DeviceOcrHelper {

return MatOfByte(*byteArray).use { matOfBytes ->
Imgcodecs.imdecode(matOfBytes, Imgcodecs.IMREAD_COLOR).use { img ->
val roisAutoType = DeviceRoisAutoSelector.select(img)

CropBlackEdges.crop(img).use { imgCropped ->
val rois =
when (roisAutoType) {
DeviceRoisAutoSelectorResult.T1 -> {
DeviceRoisAutoT1(
imgCropped.width(),
imgCropped.height(),
)
}

else -> {
DeviceRoisAutoT2(
imgCropped.width(),
imgCropped.height(),
)
}
}
val extractor = DeviceRoisExtractor(rois, imgCropped)
val masker =
when (roisAutoType) {
DeviceRoisAutoSelectorResult.T1 -> DeviceRoisMaskerAutoT1()
else -> DeviceRoisMaskerAutoT2()
}

DeviceOcr(
extractor = extractor,
masker = masker,
ortSession = ortSession,
hashesDb = imageHashesDatabase,
).ocr()
DeviceOcrScenario.forImage(img).use { scenario ->
scenario.ocr(ortSession, imageHashesDatabase)
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions core/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,10 @@ Kotlin sources live under `src/main/kotlin/`.
```
xyz.sevive.arcaeaoffline.core/
├── ocr/
│ ├── ImageHashers, ImageHashesDatabase(+Builder), ImageHashItem
│ ├── device/ DeviceOcr, ONNX helper, ScreenshotDetect, performance benchmark
│ ├── ImageHashers, ImageHashesDatabase(+Builder), ImageHashItem, OcrScenario
│ ├── device/ DeviceOcr, DeviceOcrScenario, ONNX helper, ScreenshotDetect, performance benchmark
│ │ └── rois/ ROI definition / extractor / masker
│ ├── layouts/ DeviceLayout: bundled T1/T2 layout JSON and its screen mapping
│ └── opencv/ OpenCV helpers
└── di/CoreModule.kt
```
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,28 +25,41 @@ data class ImageHashItem(
val confidence: Double,
)

/**
* Picks the winning label across per-hash-type lookup results and returns its best entry.
*
* A label's score is the weighted average of its entries' confidences, weighting DCT 4,
* DIFFERENCE 2 and AVERAGE 1. Score ties resolve to the earliest-appearing label.
*
* @return The winning label's highest-confidence entry, with [ImageHashItem.confidence]
* replaced by the label's score rather than the entry's raw confidence. `null` on an
* empty list.
*/
fun List<ImageHashItem>.getMostConfidentItem(): ImageHashItem? {
if (isEmpty()) return null

val labelConfidenceMap =
this
.map { it.label }
.distinct()
.associateWith { 0.0 }
.toMutableMap()
data class Accumulator(
val weightedConfidenceSum: Double,
val weightSum: Double,
)

this.forEach {
val labelAccumulators = linkedMapOf<String, Accumulator>()
for ((hashType, _, label, confidence) in this) {
val weight =
when (it.hashType) {
ImageHashItemHashType.AVERAGE -> 0.3
ImageHashItemHashType.DCT -> 0.2
ImageHashItemHashType.DIFFERENCE -> 0.2
when (hashType) {
ImageHashItemHashType.AVERAGE -> 1.0
ImageHashItemHashType.DIFFERENCE -> 2.0
ImageHashItemHashType.DCT -> 4.0
}
val value = labelConfidenceMap[it.label] ?: 0.0
labelConfidenceMap[it.label] = value + it.confidence * weight
val acc = labelAccumulators[label] ?: Accumulator(0.0, 0.0)
labelAccumulators[label] =
Accumulator(acc.weightedConfidenceSum + confidence * weight, acc.weightSum + weight)
}

return this
.sortedByDescending { it.confidence }
.first { it.label == labelConfidenceMap.maxBy { mapItem -> mapItem.value }.key }
val winner =
labelAccumulators.entries.maxByOrNull { it.value.weightedConfidenceSum / it.value.weightSum }
?: return null
val fused = winner.value.weightedConfidenceSum / winner.value.weightSum

return this.filter { it.label == winner.key }.maxByOrNull { it.confidence }?.copy(confidence = fused)
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,42 @@ private fun hammingDistance(
return distance
}

/**
* Returns the [limit] items with the smallest [scoreOf] value as `(item, score)` pairs,
* ordered by score ascending.
*
* Equal scores keep their original relative order, like a stable sort truncated to [limit].
* [scoreOf] is called once per item.
*/
internal fun <T> stableTopKBy(
source: Sequence<T>,
limit: Int,
scoreOf: (T) -> Int,
): List<Pair<T, Int>> {
require(limit > 0) { "limit must be positive" }

val top = ArrayList<Pair<T, Int>>(limit)
for (item in source) {
val score = scoreOf(item)
if (top.size == limit && score >= top.last().second) continue

// insert after equal scores so earlier items keep priority
val pos = top.indexOfFirst { it.second > score }
top.add(if (pos == -1) top.size else pos, item to score)
if (top.size > limit) top.removeAt(top.size - 1)
}
return top
}

class ImageHashesDatabase(
private val conn: SQLiteConnection,
) {
companion object {
const val PROP_HASH_SIZE_KEY = "hash_size"
const val PROP_HIGH_FREQ_FACTOR_KEY = "high_freq_factor"
const val PROP_BUILT_TIMESTAMP_KEY = "built_timestamp"

private const val LOOKUP_LIMIT = 10
}

private var hashSize: Int by Delegates.notNull()
Expand Down Expand Up @@ -145,13 +174,12 @@ class ImageHashesDatabase(
hash: ByteArray,
): List<ImageHashItem> {
val hashLength = hashSize.toDouble().pow(2)
return allHashes
.filter { it.type == type && it.hashType == hashType }
.sortedBy { it.distance(hash) }
.take(10)
.map { entry ->
val distance = entry.distance(hash)

return stableTopKBy(
allHashes.asSequence().filter { it.type == type && it.hashType == hashType },
LOOKUP_LIMIT,
) { it.distance(hash) }
.map { (entry, distance) ->
ImageHashItem(
hashType = hashType,
type = type,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package xyz.sevive.arcaeaoffline.core.ocr

/**
* Marker interface for OCR scenarios.
*
* Scenario branches define their own result contracts, so no callable behavior lives here.
*/
interface OcrScenario
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import org.opencv.core.Mat
import org.opencv.core.Rect
import org.opencv.core.Scalar
import org.opencv.imgproc.Imgproc
import xyz.sevive.arcaeaoffline.core.ocr.device.CropBlackEdges.Companion.cropOrOriginal
import xyz.sevive.arcaeaoffline.core.ocr.opencv.use

class CropBlackEdges {
Expand Down Expand Up @@ -42,42 +41,47 @@ class CropBlackEdges {
var top = 0
var bottom = height

// submat views are passed directly instead of pixel-copying clones;
// submat views are passed directly instead of pixel-copying clones.
// isBlackEdge only reads the slice
for (i in 0..width) {
for (i in 0 until width) {
val isBlack = imgGray.submat(Rect(i, 0, 1, height)).use { isBlackEdge(it, blackPixelThreshold) }
if (!isBlack) break
left += 1
}

for (i in width downTo 0) {
val isBlack = imgGray.submat(Rect(i - 1, 0, 1, height)).use { isBlackEdge(it, blackPixelThreshold) }
// keeps at least a two-column crop whenever a non-black column exists
for (i in width - 1 downTo 0) {
if (i <= left + 1) break
val isBlack = imgGray.submat(Rect(i, 0, 1, height)).use { isBlackEdge(it, blackPixelThreshold) }
if (!isBlack) break
right -= 1
Comment thread
283375 marked this conversation as resolved.
Resolved
}

for (i in 0..height) {
for (i in 0 until height) {
val isBlack = imgGray.submat(Rect(0, i, width, 1)).use { isBlackEdge(it, blackPixelThreshold) }
if (!isBlack) break
top += 1
}

for (i in height downTo 0) {
val isBlack = imgGray.submat(Rect(0, i - 1, width, 1)).use { isBlackEdge(it, blackPixelThreshold) }
for (i in height - 1 downTo 0) {
if (i <= top + 1) break
val isBlack = imgGray.submat(Rect(0, i, width, 1)).use { isBlackEdge(it, blackPixelThreshold) }
if (!isBlack) break
bottom -= 1
}

assert(right > left) { "CropBlackEdges: cropped width <= 0" }
assert(bottom > top) { "CropBlackEdges: cropped height <= 0" }
require(right > left) { "CropBlackEdges: cropped width <= 0" }
require(bottom > top) { "CropBlackEdges: cropped height <= 0" }

return Rect(left, top, right - left, bottom - top)
}

/**
* This function would fail if the cropped image has a width or height below 0.
* Crops the black edges of [img].
*
* @throws IllegalArgumentException when the image is fully black.
*
* Use [cropOrOriginal] if you don't want to handle errors.
* Use [cropOrOriginal] to fall back to the original image instead of throwing.
*/
fun crop(
img: Mat,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,35 +115,14 @@ class DeviceOcr(

fun ratingClass(): ArcaeaRatingClass =
extractor.ratingClass.use { roi ->
val results =
listOf(
masker.ratingClassPst(roi),
masker.ratingClassPrs(roi),
masker.ratingClassFtr(roi),
masker.ratingClassByd(roi),
masker.ratingClassEtr(roi),
)
try {
ArcaeaRatingClass.fromInt(results.indices.maxBy { Core.countNonZero(results[it]) })
} finally {
results.forEach { it.release() }
}
val votes = masker.ratingClassVotes(roi)
ArcaeaRatingClass.fromInt(votes.indices.maxBy { votes[it] })
}

private fun clearStatus(): Int =
extractor.clearStatus.use { roi ->
val results =
listOf(
masker.clearStatusTrackLost(roi),
masker.clearStatusTrackComplete(roi),
masker.clearStatusFullRecall(roi),
masker.clearStatusPureMemory(roi),
)
try {
results.indices.maxBy { Core.countNonZero(results[it]) }
} finally {
results.forEach { it.release() }
}
val votes = masker.clearStatusVotes(roi)
votes.indices.maxBy { votes[it] }
}

private fun lookupSongId(): List<ImageHashItem> =
Expand All @@ -158,14 +137,21 @@ class DeviceOcr(
preprocessPartnerIcon(roiGray).use { hashesDb.lookupPartnerIcon(it) }
}

/** Parses the ROI's OCR text as an Int. Returns `null` when the OCR result is empty. */
private fun ocrIntOrNull(roiBgr: Mat): Int? =
roiBgr
.use { DeviceOcrOnnxHelper.ocrBgrMat(it, ortSession) }
.takeIf { it.isNotEmpty() }
?.toInt()
Comment thread
283375 marked this conversation as resolved.
Resolved

fun ocr(): DeviceOcrResult =
DeviceOcrResult(
ratingClass = ratingClass(),
pure = extractor.pure.use { DeviceOcrOnnxHelper.ocrBgrMat(it, ortSession).toInt() },
far = extractor.far.use { DeviceOcrOnnxHelper.ocrBgrMat(it, ortSession).toInt() },
lost = extractor.lost.use { DeviceOcrOnnxHelper.ocrBgrMat(it, ortSession).toInt() },
score = extractor.score.use { DeviceOcrOnnxHelper.ocrBgrMat(it, ortSession).toInt() },
maxRecall = extractor.maxRecall.use { DeviceOcrOnnxHelper.ocrBgrMat(it, ortSession).toInt() },
pure = ocrIntOrNull(extractor.pure) ?: 0,
far = ocrIntOrNull(extractor.far) ?: 0,
lost = ocrIntOrNull(extractor.lost) ?: 0,
score = ocrIntOrNull(extractor.score) ?: 0,
maxRecall = ocrIntOrNull(extractor.maxRecall),
songIdResults = lookupSongId(),
clearStatus = clearStatus(),
partnerIdResults = lookupPartnerId(),
Expand Down
Loading
Loading