Skip to content
Open
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b51fb32
fix: Skill window not closing during cooldown (bugfix)
erectorOps Sep 2, 2025
0a3fb8d
fix: Fix item order bug in DragSortAdapter and remove layout dependen…
erectorOps Sep 2, 2025
b2a8637
feat: Optionally render text along with highlights (api-utility)
erectorOps Sep 2, 2025
b0e1ea7
feat: Add Hsv data class with extension to convert to Scalar (api-uti…
erectorOps Sep 2, 2025
c2c3172
feat: Add HSV-based detection and brightness utilities to Region and …
erectorOps Sep 2, 2025
58289e2
feat: Add API method for bracketed number OCR (api-utility)
erectorOps Sep 2, 2025
4cfee0b
feat: Add function to measure the length of a gauge bar (api-utility)
erectorOps Sep 2, 2025
8d5bc90
feat: Add NP-based usage condition to SpamSkillState (data)
erectorOps Sep 2, 2025
bbdd09c
feat: Add star-count-based usage condition to SpamSkillState (data)
erectorOps Sep 2, 2025
e2d233f
feat: Replace SpamSkillTarget with AutoSkillAction to support complex…
erectorOps Sep 2, 2025
ff6b485
feat: Add priority field and repeat-per-turn option (for Hakuno) to S…
erectorOps Sep 2, 2025
295ee4c
feat: Add utility functions for Skill.Servant to get field slot and s…
erectorOps Sep 2, 2025
c5be9be
feat: Ignore skills blocked by Oberon’s Eternal Sleep when using the …
erectorOps Sep 2, 2025
26ffc56
feat: Detect skill cooldown via icon brightness, cast skills by prior…
erectorOps Sep 2, 2025
e79f767
feat: add NP charged detection and NP Condition check in SkillSpam (c…
erectorOps Sep 2, 2025
f503c79
docs: add detailed testing notes for NP gauge and skill cooldown dete…
erectorOps Sep 2, 2025
4e040fa
feat: feat: implement star count detection and add Star Condition che…
erectorOps Sep 2, 2025
e0354d3
feat: Add approximate NP value retrieval and extend NP conditions in …
erectorOps Sep 2, 2025
8d38319
feat: Update SPAM UI resources (colors, dimens, localized, extensions…
erectorOps Sep 2, 2025
c932353
feat: Add skill priority item layout for SPAM UI drag-and-drop view (ui)
erectorOps Sep 2, 2025
059bebc
feat: Separate SPAM button label from target screen header to prevent…
erectorOps Sep 2, 2025
08ef296
feat: Update Spam screen Servant skill dialogs and skill priority dra…
erectorOps Sep 2, 2025
e9261f6
fix: Return a non-empty slot instead of an empty one for actualSlot
erectorOps Sep 3, 2025
a95f88f
fix: Use use to safely release OpenCV Mat rows and columns
erectorOps Sep 3, 2025
e9cbce5
fix: Replace full-width spaces with regular ASCII spaces for consistency
erectorOps Sep 3, 2025
1108fb9
fix: Add missing space after when for correct syntax
erectorOps Sep 3, 2025
e2faecf
Add isBelowBrightness API to check region brightness directly, avoidi…
erectorOps Sep 3, 2025
72f9d2a
chore(deps): add Reorderable library via Version Catalog
erectorOps Sep 3, 2025
e4acbe0
feat: Replace RecyclerView with Reorderable LazyRow for skill priorit…
erectorOps Sep 4, 2025
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
@@ -1,6 +1,8 @@
package io.github.fate_grand_automata.imaging

import android.graphics.Bitmap
import io.github.lib_automata.Axis
import io.github.lib_automata.Hsv
import io.github.lib_automata.Match
import io.github.lib_automata.Pattern
import io.github.lib_automata.Region
Expand Down Expand Up @@ -200,6 +202,98 @@ class DroidCvPattern(
return DroidCvPattern(mask)
}

override fun getAverageBrightness(): Double {
return if (mat.channels() == 3) {
Mat().use { gray ->
Imgproc.cvtColor(mat, gray, Imgproc.COLOR_BGR2GRAY)
Core.mean(gray).`val`[0]
}
} else {
Core.mean(mat).`val`[0]
}
}

override fun getMinMaxBrightness(): Pair<Double, Double> {
return if (mat.channels() == 3) {
Mat().use { gray ->
Imgproc.cvtColor(mat, gray, Imgproc.COLOR_BGR2GRAY)
val minMax = Core.minMaxLoc(gray)
Pair(minMax.minVal, minMax.maxVal)
}
} else {
val minMax = Core.minMaxLoc(mat)
Pair(minMax.minVal, minMax.maxVal)
}
}


override fun isSaturationAndValueOver(sThresh: Double, vThresh: Double): Boolean {
Mat().use { hsv ->
Imgproc.cvtColor(mat, hsv, Imgproc.COLOR_BGR2HSV)
val mean = Core.mean(hsv)
val meanS = mean.`val`[1]
val meanV = mean.`val`[2]
return meanS >= sThresh && meanV >= vThresh
}
}
private fun Hsv.scalar() = Scalar(h, s, v)

override fun getHsvAverage(): Hsv =
Mat().use { hsv ->
Imgproc.cvtColor(mat, hsv, Imgproc.COLOR_BGR2HSV)
Core.mean(hsv).`val`.let { (h, s, v, _) ->
Hsv(h, s, v)
}
}

override fun normalizeByHsv(lower: Hsv, upper: Hsv, invert: Boolean): Pattern {
val normalized = Mat().also { resultMat ->
Mat().use { maskMat ->
Imgproc.cvtColor(mat, maskMat, Imgproc.COLOR_BGR2HSV)
Core.inRange(maskMat, lower.scalar(), upper.scalar(), maskMat)
Core.bitwise_and(mat, mat, resultMat, maskMat)
}

Imgproc.cvtColor(resultMat, resultMat, Imgproc.COLOR_BGR2GRAY)
Imgproc.threshold(
resultMat,
resultMat,
0.0,
255.0,
Imgproc.THRESH_BINARY + Imgproc.THRESH_OTSU)

if (invert) {
Core.bitwise_not(resultMat, resultMat)
}
}

return DroidCvPattern(normalized, tag = tag)
}

override fun cropWhiteRegion(
pad: Int
): Pattern = Mat().use { coords ->
Core.bitwise_not(mat, coords)

// If no non-zero pixels, return the full binary image
if (coords.empty()) {
return this
}

val rect = Imgproc.boundingRect(coords)

val xStart = maxOf(rect.x - pad, 0)
val yStart = maxOf(rect.y - pad, 0)
val xEnd = minOf(rect.x + rect.width + pad, mat.cols())
val yEnd = minOf(rect.y + rect.height + pad, mat.rows())

// Crop to ROI and return as DroidCvPattern
DroidCvPattern(
mat.submat(Rect(xStart, yStart, xEnd - xStart, yEnd - yStart)).clone(),
tag = tag
)
}

/**
* Flood fills the mat.
*/
Expand Down Expand Up @@ -259,4 +353,51 @@ class DroidCvPattern(
}
return holePoints
}

override fun countPixelsInHsvRange(
lower: Hsv,
upper: Hsv,
axis: Axis
): Int {
val gray = Mat()
val mask = Mat()
val hsvMat = Mat()
try {
Imgproc.cvtColor(mat, hsvMat, Imgproc.COLOR_BGR2HSV)
Core.inRange(hsvMat, lower.scalar(), upper.scalar(), mask)
Core.bitwise_and(mat, mat, gray, mask)

Imgproc.cvtColor(gray, gray, Imgproc.COLOR_BGR2GRAY)
Imgproc.threshold(
gray, gray,
0.0, 255.0,
Imgproc.THRESH_BINARY + Imgproc.THRESH_OTSU
)

return when (axis) {
Axis.HORIZONTAL -> {
var count = 0
for (x in 0 until gray.cols()) {
val col = gray.col(x)
if (Core.countNonZero(col) > 0) count++
col.release()
}
count
}
Axis.VERTICAL -> {
var count = 0
for (y in 0 until gray.rows()) {
val row = gray.row(y)
if (Core.countNonZero(row) > 0) count++
row.release()

Copilot AI Sep 3, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The OpenCV Mat objects created by gray.row(y) and gray.col(x) should be released in a try-finally block to ensure proper cleanup even if an exception occurs.

Copilot uses AI. Check for mistakes.
}
count
}
}
} finally {
gray.release()
mask.release()
hsvMat.release()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,37 @@ class TesseractOcrService @Inject constructor(
}
}

override fun detectNumberInBrackets(pattern: Pattern): String {
synchronized(tessApi) {
(pattern as DroidCvPattern).asBitmap().use { bmp ->
tessApi.setPageSegMode(TessBaseAPI.PageSegMode.PSM_SINGLE_WORD) // PSM 8
tessApi.setVariable(TessBaseAPI.VAR_CHAR_WHITELIST, "0123456789(){}AO")
tessApi.setImage(bmp)
val rawText = tessApi.utF8Text
tessApi.clear()

val normalized = buildString(rawText.length) {
for (c in rawText) {
append(
when (c) {
'{' -> '('
'}' -> ')'
'A' -> '4'
'O' -> '0'
else -> c
}
)
}
}

val regex = Regex("""\(\s*[\d\s]+\s*\)""")
val match = regex.find(normalized) ?: return ""
val digitsOnly = match.value.replace("""[()\s]""".toRegex(), "")
return digitsOnly
}
}
}

protected fun finalize() {
tessApi.recycle()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ private fun BattleConfigContent(
contentAlignment = Alignment.Center
) {
Text(
stringResource(R.string.p_spam_spam).uppercase(),
stringResource(R.string.spam_short).uppercase(),
style = MaterialTheme.typography.bodySmall,
modifier = Modifier
.padding(16.dp, 5.dp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,31 +13,21 @@ import io.github.fate_grand_automata.R
import io.github.fate_grand_automata.util.IItemTouchHelperAdapter
import io.github.fate_grand_automata.util.IItemTouchHelperViewHolder

class DragSortAdapter<T>(
private val items: MutableList<T>,
private val viewConfigGrabber: (T) -> ItemViewConfig
) : RecyclerView.Adapter<DragSortAdapter.ViewHolder>(), IItemTouchHelperAdapter {
class ItemViewConfig(
@ColorInt val foregroundColor: Int,
@ColorInt val backgroundColor: Int,
val text: String
)

class ViewHolder(ItemView: View) : RecyclerView.ViewHolder(ItemView),
IItemTouchHelperViewHolder {
val textView: TextView = ItemView.findViewById(R.id.drag_sort_text)

abstract class DragSortAdapterBase<T>(
protected val items: MutableList<T> = mutableListOf()
) : RecyclerView.Adapter<DragSortAdapterBase.ViewHolder>(), IItemTouchHelperAdapter {
class ViewHolder(val containerView: View) :
RecyclerView.ViewHolder(containerView), IItemTouchHelperViewHolder {
override fun onItemSelected() {}

override fun onItemClear() {}
}

lateinit var itemTouchHelper: ItemTouchHelper

@SuppressLint("ClickableViewAccessibility")
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): ViewHolder {
val view = LayoutInflater.from(parent.context)
.inflate(R.layout.drag_sort_item, parent, false)
val layout = getLayoutResId()
val view = LayoutInflater.from(parent.context).inflate(layout, parent, false)

return ViewHolder(view).also { holder ->
view.setOnTouchListener { _, event ->
Expand All @@ -49,21 +39,59 @@ class DragSortAdapter<T>(
}
}

private var prevSize = items.size

fun refreshIfDataChanged() {
if (prevSize != items.size) {
prevSize = items.size
notifyDataSetChanged()
}
}

override fun getItemCount() = items.size

override fun onItemMove(From: Int, To: Int) {
items.slide(From, To)
notifyItemMoved(From, To)
}

abstract fun getLayoutResId(): Int
abstract override fun onBindViewHolder(holder: ViewHolder, position: Int)
}

class DragSortAdapter<T>(
items: MutableList<T>,
private val viewConfigGrabber: (T) -> ItemViewConfig
) : DragSortAdapterBase<T>(items) {
class ItemViewConfig(
@ColorInt val foregroundColor: Int,
@ColorInt val backgroundColor: Int,
val text: String
)

override fun getLayoutResId() = R.layout.drag_sort_item

override fun onBindViewHolder(holder: ViewHolder, position: Int) {
val viewConfig = viewConfigGrabber(items[position])

holder.textView.text = viewConfig.text
val textView = holder.itemView.findViewById<TextView>(R.id.drag_sort_text)
textView.text = viewConfig.text
textView.setTextColor(viewConfig.foregroundColor)
holder.itemView.setBackgroundColor(viewConfig.backgroundColor)
holder.textView.setTextColor(viewConfig.foregroundColor)
}
}

override fun onItemMove(From: Int, To: Int) {
val temp = items[From]
items[From] = items[To]
items[To] = temp
private fun <T> MutableList<T>.slide(from: Int, to: Int) {
val item = this[from]

notifyItemMoved(From, To)
if (from < to) {
for (i in from until to) {
this[i] = this[i + 1]
}
} else {
for (i in from downTo to + 1) {
this[i] = this[i - 1]
}
}
this[to] = item
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ import io.github.lib_automata.HighlightColor
import io.github.lib_automata.Region
import javax.inject.Inject

data class HighlightItem(val color: HighlightColor, val text: String? = null)

@ServiceScoped
class HighlightManager @Inject constructor() {
private val tapperService by lazy {
TapperService.instance ?: throw IllegalStateException("Accessibility service not running")
}

private val regionsToHighlight = mutableMapOf<Region, HighlightColor>()
private val regionsToHighlight = mutableMapOf<Region, HighlightItem>()

private val highlightView by lazy {
HighlightView(tapperService, regionsToHighlight)
Expand Down Expand Up @@ -45,9 +47,9 @@ class HighlightManager @Inject constructor() {
accessibilityWindowManager.removeView(highlightView)
}

fun add(region: Region, color: HighlightColor) {
fun add(region: Region, color: HighlightColor, text: String?) {
highlightView.post {
regionsToHighlight[region] = color
regionsToHighlight[region] = HighlightItem(color, text)

highlightView.invalidate()
}
Expand Down
Loading