Initial commit

This commit is contained in:
tanexc
2026-06-12 23:49:11 +03:00
commit f69ffd79c2
222 changed files with 7094 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
/build
+46
View File
@@ -0,0 +1,46 @@
Pod::Spec.new do |spec|
spec.name = 'analysis'
spec.version = '1.0'
spec.homepage = ''
spec.source = { :http=> ''}
spec.authors = ''
spec.license = ''
spec.summary = ''
spec.vendored_frameworks = 'build/cocoapods/framework/inference.framework'
spec.libraries = 'c++'
spec.ios.deployment_target = '15.1'
spec.dependency 'onnxruntime-objc', '1.26.0'
if !Dir.exist?('build/cocoapods/framework/inference.framework') || Dir.empty?('build/cocoapods/framework/inference.framework')
raise "
Kotlin framework 'inference' doesn't exist yet, so a proper Xcode project can't be generated.
'pod install' should be executed after running ':generateDummyFramework' Gradle task:
./gradlew :tool:analysis:generateDummyFramework
Alternatively, proper pod installation is performed during Gradle sync in the IDE (if Podfile location is set)"
end
spec.xcconfig = {
'ENABLE_USER_SCRIPT_SANDBOXING' => 'NO',
}
spec.pod_target_xcconfig = {
'KOTLIN_PROJECT_PATH' => ':tool:analysis',
'PRODUCT_MODULE_NAME' => 'inference',
}
spec.script_phases = [
{
:name => 'Build analysis',
:execution_position => :before_compile,
:shell_path => '/bin/sh',
:script => <<-SCRIPT
if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\""
exit 0
fi
set -ev
REPO_ROOT="$PODS_TARGET_SRCROOT"
"$REPO_ROOT/../../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \
-Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \
-Pkotlin.native.cocoapods.archs="$ARCHS" \
-Pkotlin.native.cocoapods.configuration="$CONFIGURATION"
SCRIPT
}
]
end
+67
View File
@@ -0,0 +1,67 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.androidLint)
kotlin("native.cocoapods")
}
kotlin {
android {
namespace = "ru.tanexc.hemoananlysis.tool.analysis"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
minSdk = 26
}
val xcfName = "inferenceKit"
listOf(
iosArm64(),
iosSimulatorArm64()
).forEach { iosTarget ->
iosTarget.binaries.framework {
baseName = xcfName
isStatic = true
}
}
cocoapods {
version = "1.0"
ios.deploymentTarget = "15.1"
framework {
baseName = "inference"
isStatic = false
}
pod("onnxruntime-objc", version = libs.versions.onnxruntime.get())
}
sourceSets {
commonMain {
dependencies {
api(projects.domain)
implementation(libs.kotlin.stdlib)
implementation(libs.koin.core)
}
}
androidMain {
dependencies {
implementation(libs.onnxruntime.android)
implementation(libs.compose.components.resources)
}
}
iosMain {
dependencies {
}
}
}
}
+46
View File
@@ -0,0 +1,46 @@
Pod::Spec.new do |spec|
spec.name = 'inference'
spec.version = '1.0'
spec.homepage = ''
spec.source = { :http=> ''}
spec.authors = ''
spec.license = ''
spec.summary = ''
spec.vendored_frameworks = 'build/cocoapods/framework/inference.framework'
spec.libraries = 'c++'
spec.ios.deployment_target = '15.1'
spec.dependency 'onnxruntime-objc', '1.25.1'
if !Dir.exist?('build/cocoapods/framework/inference.framework') || Dir.empty?('build/cocoapods/framework/inference.framework')
raise "
Kotlin framework 'inference' doesn't exist yet, so a proper Xcode project can't be generated.
'pod install' should be executed after running ':generateDummyFramework' Gradle task:
./gradlew :tool:inference:generateDummyFramework
Alternatively, proper pod installation is performed during Gradle sync in the IDE (if Podfile location is set)"
end
spec.xcconfig = {
'ENABLE_USER_SCRIPT_SANDBOXING' => 'NO',
}
spec.pod_target_xcconfig = {
'KOTLIN_PROJECT_PATH' => ':tool:inference',
'PRODUCT_MODULE_NAME' => 'inference',
}
spec.script_phases = [
{
:name => 'Build inference',
:execution_position => :before_compile,
:shell_path => '/bin/sh',
:script => <<-SCRIPT
if [ "YES" = "$OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED" ]; then
echo "Skipping Gradle build task invocation due to OVERRIDE_KOTLIN_BUILD_IDE_SUPPORTED environment variable set to \"YES\""
exit 0
fi
set -ev
REPO_ROOT="$PODS_TARGET_SRCROOT"
"$REPO_ROOT/../../gradlew" -p "$REPO_ROOT" $KOTLIN_PROJECT_PATH:syncFramework \
-Pkotlin.native.cocoapods.platform=$PLATFORM_NAME \
-Pkotlin.native.cocoapods.archs="$ARCHS" \
-Pkotlin.native.cocoapods.configuration="$CONFIGURATION"
SCRIPT
}
]
end
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
</manifest>
@@ -0,0 +1,48 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtEnvironment
import ai.onnxruntime.OrtSession
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.MODEL_INPUT_HEIGHT
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.MODEL_INPUT_WIDTH
import java.io.InputStream
import java.nio.ByteBuffer
import java.nio.ByteOrder
import java.nio.FloatBuffer
internal object OrtRuntime {
private val environment: OrtEnvironment = OrtEnvironment.getEnvironment()
private var modelBuffer: ByteBuffer? = null
private var options: OrtSession.SessionOptions? = null
@Synchronized
fun createSession(modelStream: InputStream): OrtSession {
val bytes = modelStream.use { it.readBytes() }
modelBuffer = ByteBuffer.allocateDirect(bytes.size)
.order(ByteOrder.nativeOrder())
.apply {
put(bytes)
rewind()
}
options = OrtSession.SessionOptions().apply {
setOptimizationLevel(OrtSession.SessionOptions.OptLevel.ALL_OPT)
setMemoryPatternOptimization(true)
addCPU(true)
addConfigEntry("session.load_model_format", "ORT")
addConfigEntry("session.use_ort_model_bytes_directly", "1")
addConfigEntry("session.use_ort_model_bytes_for_initializers", "1")
}
return environment.createSession(modelBuffer!!, options)
}
fun createTensor(data: FloatBuffer): OnnxTensor = OnnxTensor.createTensor(
environment,
data,
longArrayOf(1, 3, MODEL_INPUT_WIDTH.toLong(), MODEL_INPUT_HEIGHT.toLong())
)
}
@@ -0,0 +1,61 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.inference
import ai.onnxruntime.OnnxTensor
import ai.onnxruntime.OrtSession
import ai.onnxruntime.TensorInfo
import android.content.Context
import android.util.Log
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import ru.tanexc.hemoanalysis.domain.repository.ModelRepository
import ru.tanexc.hemoanalysis.domain.usecase.GetLatestModelFileUseCase
import ru.tanexc.hemoanalysis.tool.analysis.impl.OrtRuntime
import java.nio.FloatBuffer
import kotlin.getValue
internal actual class InferenceHandler : KoinComponent {
private val getLatestModelFileUseCase: GetLatestModelFileUseCase by inject()
private val modelRepository: ModelRepository by inject()
private lateinit var session: OrtSession
init {
initSession()
}
private fun initSession() {
val isLatestModel = modelRepository.getCurrentModelInfo()?.isLatest?: false
if (!isLatestModel || !::session.isInitialized) {
getLatestModelFileUseCase()?.let { model ->
session = OrtRuntime.createSession(modelStream = model.inputStream())
}
}
}
actual operator fun invoke(tensorImage: FloatArray): InferenceOutput {
initSession()
if (!::session.isInitialized) error("Model does not exists")
val inputBuffer: FloatBuffer = FloatBuffer.wrap(tensorImage)
val tensor = OrtRuntime.createTensor(inputBuffer)
val output = session.run(mapOf(session.inputNames.first() to tensor))
return output.asInferenceOutput()
}
private fun OrtSession.Result.asInferenceOutput(): InferenceOutput {
val outputInfo = this[0].info as TensorInfo
val outputShape = outputInfo.shape
val channelCount = outputShape[1].toInt()
val candidateCount = outputShape[2].toInt()
val outputTensor = this[0] as OnnxTensor
val outputData = outputTensor.value as Array<Array<FloatArray>>
return InferenceOutput(
channelCount = channelCount,
candidateCount = candidateCount,
outputData = outputData
)
}
}
@@ -0,0 +1,8 @@
package ru.tanexc.hemoanalysis.tool.analysis.api
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.parameters.AnalysisParameters
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.results.AnalysisResult
interface AnalysisTool {
fun performAnalysis(parameters: AnalysisParameters): AnalysisResult
}
@@ -0,0 +1,8 @@
package ru.tanexc.hemoanalysis.tool.analysis.api.domain
import kotlin.time.Duration
class AnalysisException(
exception: Exception,
val duration: Duration
): RuntimeException(exception)
@@ -0,0 +1,10 @@
package ru.tanexc.hemoanalysis.tool.analysis.api.domain
data class ImageBuffer(
val pixels: IntArray,
val width: Int,
val height: Int
) {
operator fun get(x: Int, y: Int): Int =
pixels[y * width + x]
}
@@ -0,0 +1,11 @@
package ru.tanexc.hemoanalysis.tool.analysis.api.domain.parameters
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.ImageBuffer
data class AnalysisParameters(
val imageBuffer: ImageBuffer,
val confThreshold: Float,
val iouThreshold: Float,
val preprocessVariant: PreprocessVariant,
val roiVariant: RoiVariant
)
@@ -0,0 +1,5 @@
package ru.tanexc.hemoanalysis.tool.analysis.api.domain.parameters
enum class PreprocessVariant {
Default, BrightnessContrast
}
@@ -0,0 +1,5 @@
package ru.tanexc.hemoanalysis.tool.analysis.api.domain.parameters
enum class RoiVariant {
None, ThresholdMorph, ThresholdFilter
}
@@ -0,0 +1,11 @@
package ru.tanexc.hemoanalysis.tool.analysis.api.domain.results
import kotlin.time.Duration
data class AnalysisDuration(
val preprocessing: Duration = Duration.ZERO,
val inference: Duration = Duration.ZERO,
val postprocessing: Duration = Duration.ZERO,
) {
val total: Duration = preprocessing + inference + postprocessing
}
@@ -0,0 +1,15 @@
package ru.tanexc.hemoanalysis.tool.analysis.api.domain.results
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.AnalysisException
sealed interface AnalysisResult {
data class Success(
val detection: List<Detection>,
val duration: AnalysisDuration
) : AnalysisResult
data class Error(
val exception: AnalysisException
) : AnalysisResult
}
@@ -0,0 +1,26 @@
package ru.tanexc.hemoanalysis.tool.analysis.api.domain.results
enum class CellClass {
Lymphocyte,
Metamyelocyte,
Monocyte,
Myelocyte,
Normoblasts,
RodNeutrophil,
SegmentonuclearNeutrophil,
Blast,
Promyelocyte,
Eosinophil,
Basophil,
Magakariocyte,
BadCells,
PlasmaCell,
Artifacts,
GumprichtShadows,
Methoses,
NormoblastsOxyfilny,
Macrophages,
NormoblastsErythroblasts,
NormoblastsBasophilic,
NormoblastsPolychromatophilic
}
@@ -0,0 +1,12 @@
package ru.tanexc.hemoanalysis.tool.analysis.api.domain.results
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.results.CellClass
data class Detection(
val cellClass: CellClass,
val confidence: Float,
val centerX: Float,
val centerY: Float,
val width: Float,
val height: Float,
)
@@ -0,0 +1,69 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.parameters.AnalysisParameters
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.AnalysisException
import ru.tanexc.hemoanalysis.tool.analysis.impl.inference.InferenceHandler
import ru.tanexc.hemoanalysis.tool.analysis.impl.postprocess.PostprocessHandler
import ru.tanexc.hemoanalysis.tool.analysis.impl.preprocess.PreprocessHandler
import ru.tanexc.hemoanalysis.tool.analysis.impl.roi.RoiHandler
import ru.tanexc.hemoanalysis.tool.analysis.impl.tensor.TensorHandler
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.results.AnalysisDuration
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.results.AnalysisResult
import ru.tanexc.hemoanalysis.tool.analysis.api.AnalysisTool
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.measuresDuration
internal class AnalysisToolImpl(
private val preprocessHandler: PreprocessHandler,
private val roiHandler: RoiHandler,
private val tensorHandler: TensorHandler,
private val inferenceHandler: InferenceHandler,
private val postprocessHandler: PostprocessHandler,
): AnalysisTool {
override fun performAnalysis(parameters: AnalysisParameters): AnalysisResult = try {
val sourceImageBuffer = parameters.imageBuffer
val (preprocessOutput, preprocessDuration) = measuresDuration {
preprocessHandler(
imageBuffer = sourceImageBuffer,
preprocessVariant = parameters.preprocessVariant
)
}
val (roiOutput, roiDuration) = measuresDuration {
roiHandler(
imageBuffer = preprocessOutput.imageBuffer,
roiVariant =parameters.roiVariant
)
}
val (tensorOutput, tensorDuration) = measuresDuration {
tensorHandler(imageBuffer = roiOutput.imageBuffer)
}
val (inferenceOutput, inferenceDuration) = measuresDuration {
inferenceHandler(tensorImage = tensorOutput)
}
val (postprocessOutput, postprocessDuration) = measuresDuration {
postprocessHandler(
inferenceOutput = inferenceOutput,
letterboxMeta = preprocessOutput.meta,
confThreshold = parameters.confThreshold,
iouThreshold = parameters.iouThreshold
)
}
val duration = AnalysisDuration(
preprocessing = preprocessDuration + roiDuration + tensorDuration,
inference = inferenceDuration,
postprocessing = postprocessDuration
)
return AnalysisResult.Success(
detection = postprocessOutput.detections,
duration = duration
)
} catch (e: AnalysisException) {
return AnalysisResult.Error(e)
}
}
@@ -0,0 +1,4 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.core
internal const val MODEL_INPUT_WIDTH = 640
internal const val MODEL_INPUT_HEIGHT = 640
@@ -0,0 +1,120 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.core
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.ImageBuffer
internal val BACKGROUND_COLOR = rgb(200, 200, 200)
internal fun rgb(
r: Int,
g: Int,
b: Int,
a: Int = 255
): Int =
((a.coerceIn(0, 255) and 0xFF) shl 24) or
((r.coerceIn(0, 255) and 0xFF) shl 16) or
((g.coerceIn(0, 255) and 0xFF) shl 8) or
(b.coerceIn(0, 255) and 0xFF)
internal fun red(pixel: Int): Int =
pixel shr 16 and 0xFF
internal fun green(pixel: Int): Int =
pixel shr 8 and 0xFF
internal fun blue(pixel: Int): Int =
pixel and 0xFF
fun gray(pixel: Int): Int {
val r = pixel ushr 16 and 0xFF
val g = pixel ushr 8 and 0xFF
val b = pixel and 0xFF
return ((r * 299 + g * 587 + b * 114) / 1000).coerceIn(0, 255)
}
internal fun open(mask: BooleanArray, width: Int, height: Int): BooleanArray =
dilate(erode(mask, width, height), width, height)
internal fun close(mask: BooleanArray, width: Int, height: Int): BooleanArray =
erode(dilate(mask, width, height), width, height)
internal fun erode(mask: BooleanArray, width: Int, height: Int): BooleanArray {
val result = BooleanArray(mask.size)
for (y in 0 until height) {
for (x in 0 until width) {
var keep = true
for (dy in -2..2) {
for (dx in -2..2) {
val nx = x + dx
val ny = y + dy
if (nx !in 0 until width || ny !in 0 until height || !mask[ny * width + nx]) {
keep = false
break
}
}
if (!keep) break
}
result[y * width + x] = keep
}
}
return result
}
internal fun dilate(mask: BooleanArray, width: Int, height: Int): BooleanArray {
val result = BooleanArray(mask.size)
for (y in 0 until height) {
for (x in 0 until width) {
var keep = false
for (dy in -2..2) {
for (dx in -2..2) {
val nx = x + dx
val ny = y + dy
if (nx in 0 until width && ny in 0 until height && mask[ny * width + nx]) {
keep = true
break
}
}
if (keep) break
}
result[y * width + x] = keep
}
}
return result
}
internal fun otsuThreshold(imageBuffer: ImageBuffer): Int {
val histogram = IntArray(256)
imageBuffer.pixels.forEach { histogram[gray(it)]++ }
val total = imageBuffer.pixels.size
var sum = 0L
for (i in histogram.indices) sum += i.toLong() * histogram[i]
var backgroundWeight = 0
var backgroundSum = 0L
var bestThreshold = 0
var bestVariance = -1.0
for (threshold in histogram.indices) {
backgroundWeight += histogram[threshold]
if (backgroundWeight == 0) continue
val foregroundWeight = total - backgroundWeight
if (foregroundWeight == 0) break
backgroundSum += threshold.toLong() * histogram[threshold]
val backgroundMean = backgroundSum.toDouble() / backgroundWeight
val foregroundMean = (sum - backgroundSum).toDouble() / foregroundWeight
val variance = backgroundWeight.toDouble() *
foregroundWeight *
(backgroundMean - foregroundMean) *
(backgroundMean - foregroundMean)
if (variance > bestVariance) {
bestVariance = variance
bestThreshold = threshold
}
}
return bestThreshold
}
@@ -0,0 +1,20 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.core
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.AnalysisException
import kotlin.time.Duration
import kotlin.time.TimeSource.Monotonic.markNow
@Throws(AnalysisException::class)
inline fun <T> measuresDuration(block: () -> T): Pair<T, Duration> {
val mark = markNow()
try {
val result = block()
return result to mark.elapsedNow()
} catch (exception: Exception) {
val elapsed = mark.elapsedNow()
throw AnalysisException(
exception = exception,
duration = elapsed,
)
}
}
@@ -0,0 +1,9 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.data
internal data class LetterboxMeta(
val gain: Float,
val padX: Float,
val padY: Float,
val left: Int,
val top: Int,
)
@@ -0,0 +1,21 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.di
import org.koin.core.module.dsl.singleOf
import org.koin.dsl.bind
import org.koin.dsl.module
import ru.tanexc.hemoanalysis.tool.analysis.impl.AnalysisToolImpl
import ru.tanexc.hemoanalysis.tool.analysis.impl.inference.InferenceHandler
import ru.tanexc.hemoanalysis.tool.analysis.impl.postprocess.PostprocessHandler
import ru.tanexc.hemoanalysis.tool.analysis.impl.preprocess.PreprocessHandler
import ru.tanexc.hemoanalysis.tool.analysis.impl.roi.RoiHandler
import ru.tanexc.hemoanalysis.tool.analysis.impl.tensor.TensorHandler
import ru.tanexc.hemoanalysis.tool.analysis.api.AnalysisTool
val analysisToolModule = module {
singleOf(::PreprocessHandler)
singleOf(::RoiHandler)
singleOf(::TensorHandler)
singleOf(::InferenceHandler)
singleOf(::PostprocessHandler)
singleOf(::AnalysisToolImpl) bind AnalysisTool::class
}
@@ -0,0 +1,6 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.inference
internal expect class InferenceHandler() {
operator fun invoke(tensorImage: FloatArray): InferenceOutput
}
@@ -0,0 +1,8 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.inference
internal data class InferenceOutput(
val channelCount: Int,
val candidateCount: Int,
val outputData: Array<Array<FloatArray>>
)
@@ -0,0 +1,157 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.postprocess
import ru.tanexc.hemoanalysis.tool.analysis.impl.inference.InferenceOutput
import ru.tanexc.hemoanalysis.tool.analysis.impl.data.LetterboxMeta
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.results.CellClass
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.results.Detection
import kotlin.collections.plusAssign
import kotlin.math.abs
internal class PostprocessHandler {
operator fun invoke(
inferenceOutput: InferenceOutput,
letterboxMeta: LetterboxMeta,
confThreshold: Float,
iouThreshold: Float
): PostprocessOutput {
val detections = process(
inferenceOutput = inferenceOutput,
letterboxMeta = letterboxMeta,
confThreshold = confThreshold,
iouThreshold = iouThreshold
)
return PostprocessOutput(
detections = detections
)
}
private fun process(
inferenceOutput: InferenceOutput,
letterboxMeta: LetterboxMeta,
confThreshold: Float,
iouThreshold: Float
): List<Detection> {
val channelCount = inferenceOutput.channelCount
val candidateCount = inferenceOutput.candidateCount
val rawPredictions = inferenceOutput.outputData[0]
val classScoresStartIndex = 4
val classCount = channelCount - classScoresStartIndex
val candidates = mutableListOf<Detection>()
for (candidateIdx in 0 until candidateCount) {
val x = rawPredictions[0][candidateIdx]
val y = rawPredictions[1][candidateIdx]
val width = rawPredictions[2][candidateIdx]
val height = rawPredictions[3][candidateIdx]
var bestClassId = -1
var bestScore = Float.NEGATIVE_INFINITY
for (classIdx in 0 until classCount) {
val score = rawPredictions[classScoresStartIndex + classIdx][candidateIdx]
if (score > bestScore) {
bestScore = score
bestClassId = classIdx
}
}
if (bestScore < confThreshold) continue
if (bestClassId !in CellClass.entries.indices) continue
candidates += Detection(
cellClass = CellClass.entries[bestClassId],
confidence = bestScore,
centerX = x,
centerY = y,
width = width,
height = height
)
}
val originalSpaceDetections = candidates.map { detection ->
detection.toOriginalSpace(meta = letterboxMeta)
}
return applyNms(
detections = originalSpaceDetections,
iouThreshold = iouThreshold
)
}
private fun applyNms(
detections: List<Detection>,
iouThreshold: Float
): List<Detection> {
return detections
.groupBy { it.cellClass }
.flatMap { (_, value) ->
val remainingItems = value
.sortedBy { it.confidence }
.toMutableList()
var index1 = 0
while (index1 < remainingItems.size) {
val currentItem = remainingItems[index1]
var index2 = index1 + 1
var isDeleted = false
while (index2 < remainingItems.size) {
val nextItem = remainingItems[index2]
if (iou(nextItem, currentItem) > iouThreshold) {
remainingItems.removeAt(index1)
isDeleted = true
break
} else {
index2++
}
}
if (!isDeleted) {
index1++
}
}
remainingItems.toList()
}
}
private fun Detection.toOriginalSpace(meta: LetterboxMeta): Detection {
return Detection(
cellClass = cellClass,
confidence = confidence,
centerX = (centerX - meta.padX) / meta.gain,
centerY = (centerY - meta.padY) / meta.gain,
width = width / meta.gain,
height = height / meta.gain
)
}
private fun iou(
a: Detection,
b: Detection
): Float {
val interH = ((a.height + b.height) / 2f - abs(a.centerY - b.centerY))
.coerceAtLeast(0f)
val interW = ((a.width + b.width) / 2f - abs(a.centerX - b.centerX))
.coerceAtLeast(0f)
val interArea = interW * interH
val areaA = a.width * a.height
val areaB = b.width * b.height
val union = (areaA + areaB - interArea)
.coerceAtLeast(0.0001f)
return interArea / union
}
}
@@ -0,0 +1,7 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.postprocess
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.results.Detection
internal data class PostprocessOutput(
val detections: List<Detection>
)
@@ -0,0 +1,28 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.preprocess
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.ImageBuffer
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.gray
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.rgb
fun brightnessContrast(imageBuffer: ImageBuffer): ImageBuffer {
val luminance = IntArray(imageBuffer.pixels.size) { gray(imageBuffer.pixels[it]) }
val min = luminance.minOrNull() ?: return imageBuffer
val max = luminance.maxOrNull() ?: return imageBuffer
if (max == min) return imageBuffer
val result = IntArray(imageBuffer.pixels.size)
for (i in imageBuffer.pixels.indices) {
val pixel = imageBuffer.pixels[i]
val oldLum = luminance[i].coerceAtLeast(1)
val newLum = ((luminance[i] - min) * 255 / (max - min)).coerceIn(0, 255)
val scale = newLum.toDouble() / oldLum
result[i] = rgb(
((pixel ushr 16 and 0xFF) * scale).toInt().coerceIn(0, 255),
((pixel ushr 8 and 0xFF) * scale).toInt().coerceIn(0, 255),
((pixel and 0xFF) * scale).toInt().coerceIn(0, 255)
)
}
return imageBuffer.copy(pixels = result)
}
@@ -0,0 +1,163 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.preprocess
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.MODEL_INPUT_HEIGHT
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.MODEL_INPUT_WIDTH
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.blue
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.green
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.red
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.rgb
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.ImageBuffer
import ru.tanexc.hemoanalysis.tool.analysis.impl.data.LetterboxMeta
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.parameters.PreprocessVariant
import kotlin.math.min
import kotlin.math.roundToInt
internal class PreprocessHandler {
operator fun invoke(
imageBuffer: ImageBuffer,
preprocessVariant: PreprocessVariant
): PreprocessOutput {
val buffer = when (preprocessVariant) {
PreprocessVariant.Default -> imageBuffer
PreprocessVariant.BrightnessContrast -> brightnessContrast(imageBuffer)
}
val letterboxed = letterbox(
image = buffer,
targetWidth = MODEL_INPUT_WIDTH,
targetHeight = MODEL_INPUT_HEIGHT
)
return letterboxed
}
private fun letterbox(
image: ImageBuffer,
targetWidth: Int,
targetHeight: Int,
fillRgb: Int = GRAY
): PreprocessOutput = with(image) {
val gain = min(
MODEL_INPUT_WIDTH.toFloat() / width.toFloat(),
MODEL_INPUT_HEIGHT.toFloat() / height.toFloat()
)
val resizedWidth = (width * gain).roundToInt().coerceAtLeast(1)
val resizedHeight = (height * gain).roundToInt().coerceAtLeast(1)
val resized = resizeBilinear(
image = image,
targetWidth = resizedWidth,
targetHeight = resizedHeight
)
val padX = (targetWidth - resizedWidth) / 2f
val padY = (targetHeight - resizedHeight) / 2f
val left = (padX - 0.1f).roundToInt()
val top = (padY - 0.1f).roundToInt()
val out = IntArray(targetWidth * targetHeight) { fillRgb }
for (y in 0 until resizedHeight) {
for (x in 0 until resizedWidth) {
val dstX = left + x
val dstY = top + y
if (dstX in 0 until targetWidth && dstY in 0 until targetHeight) {
out[dstY * targetWidth + dstX] = resized[x, y]
}
}
}
return PreprocessOutput(
imageBuffer = ImageBuffer(
width = targetWidth,
height = targetHeight,
pixels = out
),
meta = LetterboxMeta(
gain = gain,
padX = padX,
padY = padY,
left = left,
top = top
)
)
}
private fun resizeBilinear(
image: ImageBuffer,
targetWidth: Int,
targetHeight: Int
): ImageBuffer {
if (image.width == targetWidth && image.height == targetHeight) {
return image
}
val out = IntArray(targetWidth * targetHeight)
val xRatio = if (targetWidth > 1) {
(image.width - 1).toFloat() / (targetWidth - 1).toFloat()
} else {
0f
}
val yRatio = if (targetHeight > 1) {
(image.height - 1).toFloat() / (targetHeight - 1).toFloat()
} else {
0f
}
for (y in 0 until targetHeight) {
val srcY = y * yRatio
val y0 = srcY.toInt()
val y1 = min(y0 + 1, image.height - 1)
val yWeight = srcY - y0
for (x in 0 until targetWidth) {
val srcX = x * xRatio
val x0 = srcX.toInt()
val x1 = min(x0 + 1, image.width - 1)
val xWeight = srcX - x0
val p00 = image[x0, y0]
val p10 = image[x1, y0]
val p01 = image[x0, y1]
val p11 = image[x1, y1]
val r = bilinear(red(p00), red(p10), red(p01), red(p11), xWeight, yWeight)
val g = bilinear(green(p00), green(p10), green(p01), green(p11), xWeight, yWeight)
val b = bilinear(blue(p00), blue(p10), blue(p01), blue(p11), xWeight, yWeight)
out[y * targetWidth + x] = rgb(r, g, b)
}
}
return ImageBuffer(
width = targetWidth,
height = targetHeight,
pixels = out
)
}
private fun bilinear(
v00: Int,
v10: Int,
v01: Int,
v11: Int,
xWeight: Float,
yWeight: Float
): Int {
val top = v00 * (1f - xWeight) + v10 * xWeight
val bottom = v01 * (1f - xWeight) + v11 * xWeight
return (top * (1f - yWeight) + bottom * yWeight)
.roundToInt()
.coerceIn(0, 255)
}
companion object {
private val GRAY: Int = rgb(114, 114, 114)
}
}
@@ -0,0 +1,9 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.preprocess
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.ImageBuffer
import ru.tanexc.hemoanalysis.tool.analysis.impl.data.LetterboxMeta
internal data class PreprocessOutput(
val imageBuffer: ImageBuffer,
val meta: LetterboxMeta
)
@@ -0,0 +1,18 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.roi
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.ImageBuffer
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.parameters.RoiVariant
internal class RoiHandler {
operator fun invoke(
imageBuffer: ImageBuffer,
roiVariant: RoiVariant
): RoiOutput {
val image = when (roiVariant) {
RoiVariant.ThresholdMorph -> thresholdMorph(imageBuffer)
RoiVariant.ThresholdFilter -> thresholdFilter(imageBuffer)
RoiVariant.None -> imageBuffer
}
return RoiOutput(image)
}
}
@@ -0,0 +1,7 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.roi
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.ImageBuffer
internal data class RoiOutput(
val imageBuffer: ImageBuffer
)
@@ -0,0 +1,19 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.roi
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.ImageBuffer
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.BACKGROUND_COLOR
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.gray
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.otsuThreshold
internal fun thresholdFilter(imageBuffer: ImageBuffer): ImageBuffer {
val threshold = otsuThreshold(imageBuffer)
val result = IntArray(imageBuffer.pixels.size)
for (i in imageBuffer.pixels.indices) {
val pixel = imageBuffer.pixels[i]
result[i] = if (gray(pixel) <= threshold) pixel else BACKGROUND_COLOR
}
return imageBuffer.copy(pixels = result)
}
@@ -0,0 +1,28 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.roi
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.ImageBuffer
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.BACKGROUND_COLOR
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.close
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.gray
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.open
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.otsuThreshold
internal fun thresholdMorph(imageBuffer: ImageBuffer): ImageBuffer {
val threshold = otsuThreshold(imageBuffer)
val mask = BooleanArray(imageBuffer.pixels.size) {
gray(imageBuffer.pixels[it]) <= threshold
}
val morphedMask = close(
open(mask, imageBuffer.width, imageBuffer.height),
imageBuffer.width,
imageBuffer.height
)
val result = IntArray(imageBuffer.pixels.size)
for (i in imageBuffer.pixels.indices) {
val pixel = imageBuffer.pixels[i]
result[i] = if (morphedMask[i]) pixel else BACKGROUND_COLOR
}
return imageBuffer.copy(pixels = result)
}
@@ -0,0 +1,23 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl.tensor
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.blue
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.green
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.red
import ru.tanexc.hemoanalysis.tool.analysis.api.domain.ImageBuffer
internal class TensorHandler {
operator fun invoke(imageBuffer: ImageBuffer): FloatArray = with(imageBuffer) {
val channelSize = width * height
val tensor = FloatArray(3 * channelSize)
for (i in pixels.indices) {
val pixel = pixels[i]
tensor[i] = red(pixel) / 255f
tensor[channelSize + i] = green(pixel) / 255f
tensor[channelSize * 2 + i] = blue(pixel) / 255f
}
return tensor
}
}
@@ -0,0 +1,25 @@
package ru.tanexc.hemoanalysis.tool.analysis.impl
import kotlinx.cinterop.BetaInteropApi
import kotlinx.cinterop.CPointer
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.ObjCObjectVar
import kotlinx.cinterop.alloc
import kotlinx.cinterop.memScoped
import kotlinx.cinterop.ptr
import kotlinx.cinterop.value
import platform.Foundation.NSError
@OptIn(BetaInteropApi::class, ExperimentalForeignApi::class)
internal inline fun <T> handleThrowingError(
block: (CPointer<ObjCObjectVar<NSError?>>) -> T?
): T = memScoped {
val error = alloc<ObjCObjectVar<NSError?>>()
error.value = null
val result = block(error.ptr)
result ?: throw IllegalStateException(
"ONNX Runtime failed: ${error.value?.localizedDescription}"
)
}
@@ -0,0 +1,91 @@
@file:OptIn(ExperimentalForeignApi::class)
package ru.tanexc.hemoanalysis.tool.analysis.impl
import cocoapods.onnxruntime_objc.ORTEnv
import cocoapods.onnxruntime_objc.ORTGraphOptimizationLevel
import cocoapods.onnxruntime_objc.ORTLoggingLevel
import cocoapods.onnxruntime_objc.ORTSession
import cocoapods.onnxruntime_objc.ORTSessionOptions
import cocoapods.onnxruntime_objc.ORTTensorElementDataType
import cocoapods.onnxruntime_objc.ORTValue
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.addressOf
import kotlinx.cinterop.convert
import kotlinx.cinterop.usePinned
import platform.Foundation.NSMutableData
import platform.Foundation.NSNumber
import platform.Foundation.dataWithLength
import platform.Foundation.numberWithLongLong
import platform.posix.memcpy
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.MODEL_INPUT_HEIGHT
import ru.tanexc.hemoanalysis.tool.analysis.impl.core.MODEL_INPUT_WIDTH
internal object OrtRuntime {
private val environment: ORTEnv = handleThrowingError { error ->
ORTEnv(
loggingLevel = ORTLoggingLevel.ORTLoggingLevelWarning,
error = error
)
}
private val options: ORTSessionOptions = handleThrowingError { error ->
ORTSessionOptions(error = error).apply {
setGraphOptimizationLevel(
ORTGraphOptimizationLevel.ORTGraphOptimizationLevelAll,
error = error
)
setGraphOptimizationLevel(
ORTGraphOptimizationLevel.ORTGraphOptimizationLevelAll,
error = error
)
addConfigEntryWithKey(
"session.load_model_format",
value = "ORT",
error = error
)
}
}
fun createSession(modelPath: String): ORTSession =
handleThrowingError { error ->
ORTSession(
env = environment,
modelPath = modelPath,
sessionOptions = options,
error = error
)
}
fun createTensor(tensorData: FloatArray): ORTValue =
handleThrowingError { error ->
ORTValue(
tensorData = tensorData.toNSMutableData(),
elementType = ORTTensorElementDataType.ORTTensorElementDataTypeFloat,
shape = listOf(
NSNumber.numberWithLongLong(1),
NSNumber.numberWithLongLong(3),
NSNumber.numberWithLongLong(MODEL_INPUT_WIDTH.toLong()),
NSNumber.numberWithLongLong(MODEL_INPUT_HEIGHT.toLong())
),
error = error
)
}
private fun FloatArray.toNSMutableData(): NSMutableData {
val byteSize = size * Float.SIZE_BYTES
val data = NSMutableData.dataWithLength(byteSize.convert())
?: error("Failed to allocate input tensor data: $byteSize bytes")
usePinned { pinned ->
memcpy(
data.mutableBytes,
pinned.addressOf(0),
byteSize.convert()
)
}
return data
}
}
@@ -0,0 +1,98 @@
@file:OptIn(ExperimentalForeignApi::class)
package ru.tanexc.hemoanalysis.tool.analysis.impl.inference
import cocoapods.onnxruntime_objc.ORTSession
import cocoapods.onnxruntime_objc.ORTValue
import kotlinx.cinterop.ExperimentalForeignApi
import kotlinx.cinterop.FloatVar
import kotlinx.cinterop.get
import kotlinx.cinterop.reinterpret
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import platform.Foundation.NSNumber
import ru.tanexc.hemoanalysis.domain.repository.ModelRepository
import ru.tanexc.hemoanalysis.domain.usecase.GetModelPathUseCase
import ru.tanexc.hemoanalysis.tool.analysis.impl.OrtRuntime
import ru.tanexc.hemoanalysis.tool.analysis.impl.handleThrowingError
internal actual class InferenceHandler : KoinComponent {
private val getModelPathUseCase: GetModelPathUseCase by inject()
private val modelRepository: ModelRepository by inject()
private lateinit var session: ORTSession
init {
initSession()
}
private fun initSession() {
val isLatestModel = modelRepository.getCurrentModelInfo()?.isLatest?: false
if (!isLatestModel || !::session.isInitialized) {
getModelPathUseCase()?.let {
session = OrtRuntime.createSession(modelPath = it)
}
}
}
actual operator fun invoke(tensorImage: FloatArray): InferenceOutput {
initSession()
if (!::session.isInitialized) error("Model does not exists")
val inputTensor = OrtRuntime.createTensor(tensorImage)
val outputName = handleThrowingError { error ->
session.outputNamesWithError(error)?.first()
}
val outputs = handleThrowingError { error ->
session.runWithInputs(
mapOf(session.inputNamesWithError(error)?.first() to inputTensor),
outputNames = setOf(outputName),
runOptions = null,
error = error
)
}
val outputValue = outputs[outputName] as ORTValue
return outputValue.asInferenceOutput()
}
private fun ORTValue.asInferenceOutput(): InferenceOutput {
val tensorInfo = handleThrowingError { error ->
tensorTypeAndShapeInfoWithError(error)
}
val outputShape = tensorInfo.shape.map { number ->
(number as NSNumber).longLongValue.toInt()
}
val channelCount = outputShape[1]
val candidateCount = outputShape[2]
val tensorData = handleThrowingError { error ->
tensorDataWithError(error)
}
val rawFloats = tensorData.bytes!!.reinterpret<FloatVar>()
var index = 0
val outputData = Array(1) {
Array(channelCount) {
FloatArray(candidateCount)
}
}
for (channel in 0 until channelCount) {
for (candidate in 0 until candidateCount) {
outputData[0][channel][candidate] = rawFloats[index++]
}
}
return InferenceOutput(
channelCount = channelCount,
candidateCount = candidateCount,
outputData = outputData
)
}
}