From b91f13c72a777d3e39f84a06198de0687f173a9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filips=20=C5=A0aberts?= Date: Sun, 5 Oct 2025 15:29:47 +0300 Subject: [PATCH 1/2] Better satin borders with OpenCV --- app/build.gradle.kts | 1 + .../de/berlindroid/zepatch/MainActivity.kt | 7 ++ converter/build.gradle.kts | 1 + .../zepatch/stiches/StitchToPES.kt | 91 +++++++++++++++---- gradle/libs.versions.toml | 2 + 5 files changed, 84 insertions(+), 18 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 488c0b7..e11298a 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -69,6 +69,7 @@ dependencies { implementation(project(":patch-annotations")) implementation(project(":patch-processor")) ksp(project(":patch-processor")) + implementation(libs.opencv.android) implementation(project(":converter")) testImplementation(libs.junit) diff --git a/app/src/main/java/de/berlindroid/zepatch/MainActivity.kt b/app/src/main/java/de/berlindroid/zepatch/MainActivity.kt index b353a57..3d036a4 100644 --- a/app/src/main/java/de/berlindroid/zepatch/MainActivity.kt +++ b/app/src/main/java/de/berlindroid/zepatch/MainActivity.kt @@ -28,12 +28,19 @@ import de.berlindroid.zepatch.ui.PatchableDetail import de.berlindroid.zepatch.ui.PatchableList import de.berlindroid.zepatch.ui.theme.ZePatchTheme import kotlinx.coroutines.launch +import org.opencv.android.OpenCVLoader +import android.util.Log @ExperimentalMaterial3Api @ExperimentalMaterial3AdaptiveApi class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + if (!OpenCVLoader.initDebug()) { + Log.e("OpenCV", "Unable to load OpenCV!") + } + enableEdgeToEdge() setContent { ZePatchTheme { diff --git a/converter/build.gradle.kts b/converter/build.gradle.kts index 8817bc1..2bec3b2 100644 --- a/converter/build.gradle.kts +++ b/converter/build.gradle.kts @@ -50,4 +50,5 @@ dependencies { implementation(libs.androidx.appcompat) implementation(libs.material) implementation(libs.jts) + implementation(libs.opencv.android) } diff --git a/converter/src/main/java/de/berlindroid/zepatch/stiches/StitchToPES.kt b/converter/src/main/java/de/berlindroid/zepatch/stiches/StitchToPES.kt index 720409f..86c556a 100644 --- a/converter/src/main/java/de/berlindroid/zepatch/stiches/StitchToPES.kt +++ b/converter/src/main/java/de/berlindroid/zepatch/stiches/StitchToPES.kt @@ -14,7 +14,11 @@ import com.embroidermodder.punching.colors import org.locationtech.jts.geom.Coordinate import org.locationtech.jts.geom.Geometry import org.locationtech.jts.geom.GeometryFactory -import kotlin.collections.toMutableMap +import org.opencv.android.Utils +import org.opencv.core.Mat +import org.opencv.core.MatOfPoint +import org.opencv.core.Size +import org.opencv.imgproc.Imgproc import kotlin.io.encoding.Base64 import kotlin.math.floor import kotlin.math.sqrt @@ -118,10 +122,13 @@ object StitchToPES { }.toTypedArray() val border = if (satinBorderThickness > 0.1f && satinBorderDensity > 0.05f) { - threads.satinBorder( + bitmap.satinBorder( color = Color.BLACK, thickness = satinBorderThickness, distance = satinBorderDensity, + widthMm = mmWidth * 10, + heightMm = mmHeight * 10, + dilationRadius = 5, ) } else { listOf() @@ -132,34 +139,68 @@ object StitchToPES { ) } - private fun Array.satinBorder( + private fun Bitmap.satinBorder( color: Int, thickness: Float, - distance: Float + distance: Float, + widthMm: Float, + heightMm: Float, + dilationRadius: Int, ): List { - val factory = GeometryFactory() - val points = factory.createMultiPointFromCoords( - stitches.map { Coordinate(it.x.toDouble(), it.y.toDouble()) }.toTypedArray() + val mat = Mat() + val contours = mutableListOf() + + val scaleX = (widthMm / width).toDouble() + val scaleY = (heightMm / height).toDouble() + // Convert Bitmap to Mat and resize. + Utils.bitmapToMat(this, mat) + Imgproc.resize( + mat, + mat, + Size(0.0, 0.0), + scaleX, + scaleY, + Imgproc.INTER_NEAREST, + ) + // Convert to grayscale and apply binary threshold, + // as for finding contours the image should be a white object on a black background. + Imgproc.cvtColor(mat, mat, Imgproc.COLOR_BGR2GRAY) + val thresh = Mat() + Imgproc.threshold(mat, thresh, 0.0, 255.0, Imgproc.THRESH_BINARY) + + // MARK: morphological filters + val kernelDimension = (dilationRadius * 2 + 1).toDouble() + val kernelSize = Size(kernelDimension, kernelDimension) + val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_ELLIPSE, kernelSize) + + // Dilation to close small gaps. + Imgproc.dilate(thresh, thresh, kernel) + // Erosion shrinks back to original border positions and helps remove small noise. + Imgproc.erode(thresh, thresh, kernel) + // MARK: End morphological filters + + val hierarchy = Mat() // Not used. + Imgproc.findContours( + thresh, + contours, + hierarchy, + Imgproc.RETR_EXTERNAL, // Only outer contours + Imgproc.CHAIN_APPROX_TC89_L1 // Reduced details in output ) - val hull = points.convexHull() - val outer = hull.buffer(thickness / 2.0) - - return listOf( -// Thread( -// color = Color.WHITE, -// stitches = outer.toStitch(), -// absolute = true, -// ), + val outer = contours.asGeometries() + val threads = outer.map { Thread( color = color, - stitches = outer.toZigZagStitch( + stitches = it.toZigZagStitch( thickness * 10, distance * 10 ), absolute = true, ) - ) + } + + return threads } } @@ -316,6 +357,20 @@ private fun List.removeDoubles() = filterIndexed { index, xy -> } } +private fun List.asGeometries(): List { + val geometryFactory = GeometryFactory() + val polygons = this.mapNotNull { contour -> + val points = contour.toArray().map { Coordinate(it.x, it.y) }.toTypedArray() + + if (points.size < 3) return@mapNotNull null + + val closed = if (points.first() != points.last()) points + points.first() else points + val ring = geometryFactory.createLinearRing(closed) + geometryFactory.createPolygon(ring) + } + + return polygons.toMutableList() +} private val Float.floor: Int get() = floor(this).toInt() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 66054f2..b4043e3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -11,6 +11,7 @@ lifecycleRuntimeCompose = "2.9.3" lifecycleRuntimeKtx = "2.9.3" activityCompose = "1.10.1" composeBom = "2025.08.01" +opencvAndroid = "4.5.3.0" python = "16.1.0" robolectric = "4.15" corex = "1.7.0" @@ -24,6 +25,7 @@ coil = "3.3.0" [libraries] kotlinpoet = { group = "com.squareup", name = "kotlinpoet", version.ref = "kotlinpoet" } +opencv-android = { group = "com.quickbirdstudios", name = "opencv", version.ref = "opencvAndroid" } symbol-processing-api = { group = "com.google.devtools.ksp", name = "symbol-processing-api", version.ref = "ksp" } androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } junit = { group = "junit", name = "junit", version.ref = "junit" } From 5592d8e01ac37e7a31827972cc6fdcc70c3f6353 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Filips=20=C5=A0aberts?= Date: Sun, 5 Oct 2025 16:05:01 +0300 Subject: [PATCH 2/2] Dilation radius customization in secret settings --- .../de/berlindroid/zepatch/WizardViewModel.kt | 6 +++- .../zepatch/ui/BitmapToStitches.kt | 36 +++++++++++++++++-- .../berlindroid/zepatch/ui/WizardContent.kt | 3 +- .../zepatch/stiches/StitchToPES.kt | 3 +- 4 files changed, 42 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/de/berlindroid/zepatch/WizardViewModel.kt b/app/src/main/java/de/berlindroid/zepatch/WizardViewModel.kt index d670bcb..e3c612a 100644 --- a/app/src/main/java/de/berlindroid/zepatch/WizardViewModel.kt +++ b/app/src/main/java/de/berlindroid/zepatch/WizardViewModel.kt @@ -128,6 +128,7 @@ class WizardViewModel(application: Application) : AndroidViewModel(application) val densityY: Float = 0.4f, val borderThickness: Float = 2f, val borderDensity: Float = 0.5f, + val borderDilation: Int = 5, // satin border yes no // sizes @@ -254,7 +255,8 @@ class WizardViewModel(application: Application) : AndroidViewModel(application) densityY: Float, size: Float, borderThickness: Float, - borderDensity: Float + borderDensity: Float, + borderDilationRadius: Int, ) { _uiState.update { when (it) { @@ -264,6 +266,7 @@ class WizardViewModel(application: Application) : AndroidViewModel(application) size = size, borderThickness = borderThickness, borderDensity = borderDensity, + borderDilation = borderDilationRadius, ) else -> it.copyWithError("Cannot update embroidery in state ${it.javaClass.simpleName}.") @@ -354,6 +357,7 @@ class WizardViewModel(application: Application) : AndroidViewModel(application) mmDensityY = state.densityY, satinBorderThickness = state.borderThickness, satinBorderDensity = state.borderDensity, + satinBorderDilationRadius = state.borderDilation, ) val context = getApplication().applicationContext diff --git a/app/src/main/java/de/berlindroid/zepatch/ui/BitmapToStitches.kt b/app/src/main/java/de/berlindroid/zepatch/ui/BitmapToStitches.kt index 5209acb..d8b3bf5 100644 --- a/app/src/main/java/de/berlindroid/zepatch/ui/BitmapToStitches.kt +++ b/app/src/main/java/de/berlindroid/zepatch/ui/BitmapToStitches.kt @@ -46,6 +46,7 @@ private const val MAX_BORDER = 150f private const val MIN_SIZE = 10 private const val MAX_SIZE = 120 +private const val MIN_DILATION = 1 @Composable fun BitmapToStitches( @@ -57,6 +58,7 @@ fun BitmapToStitches( size: Float, borderThickness: Float, borderDensity: Float, + borderDilationRadius: Int, ) -> Unit, onCreateEmbroidery: () -> Unit, ) { @@ -122,7 +124,8 @@ private fun ColumnScope.SuperSecretePirateControls( densityY: Float, size: Float, borderThickness: Float, - borderDensity: Float + borderDensity: Float, + borderDilationRadius: Int, ) -> Unit ) { Card( @@ -161,6 +164,7 @@ private fun ColumnScope.SuperSecretePirateControls( state.size, state.borderThickness, state.borderDensity, + state.borderDilation, ) }, onDone = {} @@ -182,6 +186,7 @@ private fun ColumnScope.SuperSecretePirateControls( state.size, state.borderThickness, state.borderDensity, + state.borderDilation, ) }, onDone = {} @@ -203,6 +208,7 @@ private fun ColumnScope.SuperSecretePirateControls( it.toFloat(), state.borderThickness, state.borderDensity, + state.borderDilation, ) }, onDone = {} @@ -224,6 +230,7 @@ private fun ColumnScope.SuperSecretePirateControls( state.size, it, state.borderDensity, + state.borderDilation, ) }, onDone = {} @@ -245,6 +252,29 @@ private fun ColumnScope.SuperSecretePirateControls( state.size, state.borderThickness, it, + state.borderDilation, + ) + }, + onDone = {} + ) + + IntSelector( + modifier = Modifier.fillMaxWidth(), + value = "${state.borderDilation}", + unit = "mm", + label = "satin border gap bridging radius", + errorText = { "Radius of $it not in $MIN_DILATION, $MAX_SIZE." }, + acceptableNumberEntered = { + (it.toIntOrNull() ?: MIN_DILATION) in (MIN_DILATION..MAX_SIZE) + }, + onNumberChanged = { + onUpdateEmbroidery( + state.densityX, + state.densityY, + state.size, + state.borderThickness, + state.borderDensity, + it, ) }, onDone = {} @@ -265,7 +295,7 @@ private fun Pirates() { reducedBitmap = randomBitmap(100, 100).asImageBitmap(), reducedHistogram = Histogram(emptyMap()) ), - onUpdateEmbroidery = { _, _, _, _, _ -> } + onUpdateEmbroidery = { _, _, _, _, _, _ -> } ) } } @@ -284,7 +314,7 @@ private fun BitmapToStitchesPreview() { name = "MyPatch", currentlyEmbroidering = false, ), - onUpdateEmbroidery = { _, _, _, _, _ -> }, + onUpdateEmbroidery = { _, _, _, _, _, _ -> }, onCreateEmbroidery = {}, ) } diff --git a/app/src/main/java/de/berlindroid/zepatch/ui/WizardContent.kt b/app/src/main/java/de/berlindroid/zepatch/ui/WizardContent.kt index 1f5b04a..d9b0e0a 100644 --- a/app/src/main/java/de/berlindroid/zepatch/ui/WizardContent.kt +++ b/app/src/main/java/de/berlindroid/zepatch/ui/WizardContent.kt @@ -34,6 +34,7 @@ fun WizardContent( size: Float, borderThickness: Float, borderDensity: Float, + borderDilationRadius: Int, ) -> Unit, onCreateEmbroidery: () -> Unit, ) { @@ -93,7 +94,7 @@ private fun WizardContentPreview() { onBitmapUpdated = {}, onColorCountUpdated = {}, onComputeReducedBitmap = {}, - onUpdateEmbroidery = { _, _, _, _, _ -> }, + onUpdateEmbroidery = { _, _, _, _, _, _ -> }, onCreateEmbroidery = {}, ) } diff --git a/converter/src/main/java/de/berlindroid/zepatch/stiches/StitchToPES.kt b/converter/src/main/java/de/berlindroid/zepatch/stiches/StitchToPES.kt index 86c556a..8023c0c 100644 --- a/converter/src/main/java/de/berlindroid/zepatch/stiches/StitchToPES.kt +++ b/converter/src/main/java/de/berlindroid/zepatch/stiches/StitchToPES.kt @@ -102,6 +102,7 @@ object StitchToPES { mmDensityY: Float, satinBorderThickness: Float, satinBorderDensity: Float, + satinBorderDilationRadius: Int, ): Embroidery { val strandsPerColor = bitmap.getColorStrands( @@ -128,7 +129,7 @@ object StitchToPES { distance = satinBorderDensity, widthMm = mmWidth * 10, heightMm = mmHeight * 10, - dilationRadius = 5, + dilationRadius = satinBorderDilationRadius, ) } else { listOf()