Added ability to use unequal row and column sizes in Image Splitting by #1961

This commit is contained in:
T8RIN
2025-08-05 01:34:06 +03:00
parent f5bece16e6
commit 7dce08af63
6 changed files with 172 additions and 39 deletions

View File

@ -1742,4 +1742,5 @@
<string name="lens_correction">Lens Correction</string> <string name="lens_correction">Lens Correction</string>
<string name="target_lens_profile">Target lens profile file in JSON format</string> <string name="target_lens_profile">Target lens profile file in JSON format</string>
<string name="download_ready_lens_profiles">Download ready lens profiles</string> <string name="download_ready_lens_profiles">Download ready lens profiles</string>
<string name="part_percents">Part percents</string>
</resources> </resources>

View File

@ -39,8 +39,7 @@ internal class AndroidImageSplitter @Inject constructor(
override suspend fun split( override suspend fun split(
imageUri: String, imageUri: String,
params: SplitParams, params: SplitParams
onProgress: (Int) -> Unit
): List<String> = withContext(defaultDispatcher) { ): List<String> = withContext(defaultDispatcher) {
if (params.columnsCount <= 1 && params.rowsCount <= 1) { if (params.columnsCount <= 1 && params.rowsCount <= 1) {
return@withContext listOf(imageUri) return@withContext listOf(imageUri)
@ -56,14 +55,16 @@ internal class AndroidImageSplitter @Inject constructor(
image = image, image = image,
count = params.columnsCount, count = params.columnsCount,
imageFormat = params.imageFormat, imageFormat = params.imageFormat,
quality = params.quality quality = params.quality,
columnPercentages = params.columnPercentages,
) )
} else if (params.columnsCount <= 1) { } else if (params.columnsCount <= 1) {
splitForRows( splitForRows(
image = image, image = image,
count = params.rowsCount, count = params.rowsCount,
imageFormat = params.imageFormat, imageFormat = params.imageFormat,
quality = params.quality quality = params.quality,
rowPercentages = params.rowPercentages
) )
} else { } else {
splitBoth( splitBoth(
@ -71,7 +72,9 @@ internal class AndroidImageSplitter @Inject constructor(
rowsCount = params.rowsCount, rowsCount = params.rowsCount,
columnsCount = params.columnsCount, columnsCount = params.columnsCount,
imageFormat = params.imageFormat, imageFormat = params.imageFormat,
quality = params.quality quality = params.quality,
rowPercentages = params.rowPercentages,
columnPercentages = params.columnPercentages
) )
} }
} }
@ -81,28 +84,29 @@ internal class AndroidImageSplitter @Inject constructor(
rowsCount: Int, rowsCount: Int,
columnsCount: Int, columnsCount: Int,
imageFormat: ImageFormat, imageFormat: ImageFormat,
quality: Quality quality: Quality,
rowPercentages: List<Float> = emptyList(),
columnPercentages: List<Float> = emptyList()
): List<String> = withContext(defaultDispatcher) { ): List<String> = withContext(defaultDispatcher) {
val cellHeight = image.height / rowsCount.toFloat() val rowHeights = calculatePartSizes(image.height, rowPercentages, rowsCount)
val uris = mutableListOf<Deferred<List<String>>>() val uris = mutableListOf<Deferred<List<String>>>()
var currentY = 0
for (row in 0 until rowsCount) { for (row in 0 until rowsCount) {
val y = (row * cellHeight).toInt() val height = rowHeights[row]
val height = if (y + cellHeight.toInt() > image.height) { val rowBitmap = Bitmap.createBitmap(image, 0, currentY, image.width, height)
image.height - y
} else cellHeight.toInt()
val rowBitmap = Bitmap.createBitmap(image, 0, y, image.width, height)
val rowUris = async { val rowUris = async {
splitForColumns( splitForColumns(
image = rowBitmap, image = rowBitmap,
count = columnsCount, count = columnsCount,
imageFormat = imageFormat, imageFormat = imageFormat,
quality = quality quality = quality,
columnPercentages = columnPercentages
) )
} }
uris.add(rowUris) uris.add(rowUris)
currentY += height
} }
uris.flatMap { it.await() } uris.flatMap { it.await() }
@ -112,19 +116,16 @@ internal class AndroidImageSplitter @Inject constructor(
image: Bitmap, image: Bitmap,
count: Int, count: Int,
imageFormat: ImageFormat, imageFormat: ImageFormat,
quality: Quality quality: Quality,
rowPercentages: List<Float> = emptyList()
): List<String> = withContext(defaultDispatcher) { ): List<String> = withContext(defaultDispatcher) {
val cellHeight = image.height / count.toFloat() val rowHeights = calculatePartSizes(image.height, rowPercentages, count)
val uris = mutableListOf<String?>() val uris = mutableListOf<String?>()
var currentY = 0
for (i in 0 until count) { for (i in 0 until count) {
val y = (i * cellHeight).toInt() val height = rowHeights[i]
val height = if (y + cellHeight.toInt() > image.height) { val cell = Bitmap.createBitmap(image, 0, currentY, image.width, height)
image.height - y
} else cellHeight.toInt()
val cell = Bitmap.createBitmap(image, 0, y, image.width, height)
uris.add( uris.add(
shareProvider.cacheImage( shareProvider.cacheImage(
@ -137,6 +138,7 @@ internal class AndroidImageSplitter @Inject constructor(
) )
) )
) )
currentY += height
} }
uris.filterNotNull() uris.filterNotNull()
@ -146,19 +148,16 @@ internal class AndroidImageSplitter @Inject constructor(
image: Bitmap, image: Bitmap,
count: Int, count: Int,
imageFormat: ImageFormat, imageFormat: ImageFormat,
quality: Quality quality: Quality,
columnPercentages: List<Float> = emptyList()
): List<String> = withContext(defaultDispatcher) { ): List<String> = withContext(defaultDispatcher) {
val cellWidth = image.width / count.toFloat() val columnWidths = calculatePartSizes(image.width, columnPercentages, count)
val uris = mutableListOf<String?>() val uris = mutableListOf<String?>()
var currentX = 0
for (i in 0 until count) { for (i in 0 until count) {
val x = (i * cellWidth).toInt() val width = columnWidths[i]
val width = if (x + cellWidth.toInt() > image.width) { val cell = Bitmap.createBitmap(image, currentX, 0, width, image.height)
image.width - x
} else cellWidth.toInt()
val cell = Bitmap.createBitmap(image, x, 0, width, image.height)
uris.add( uris.add(
shareProvider.cacheImage( shareProvider.cacheImage(
@ -171,8 +170,51 @@ internal class AndroidImageSplitter @Inject constructor(
) )
) )
) )
currentX += width
} }
uris.filterNotNull() uris.filterNotNull()
} }
private fun calculatePartSizes(
totalSize: Int,
percentages: List<Float>,
count: Int
): List<Int> {
if (percentages.isEmpty()) {
val partSize = totalSize / count
return List(count) { index ->
if (index == count - 1) {
totalSize - (partSize * (count - 1))
} else {
partSize
}
}
}
val normalizedPercentages = if (percentages.size < count) {
val remainingPercentage = 100f - percentages.sum()
val remainingParts = count - percentages.size
val equalPercentage = remainingPercentage / remainingParts
percentages + List(remainingParts) { equalPercentage }
} else if (percentages.size > count) {
percentages.take(count)
} else {
percentages
}
val totalPercentage = normalizedPercentages.sum()
val normalized = normalizedPercentages.map { it / totalPercentage }
return normalized.map { percentage ->
(totalSize * percentage).toInt()
}.let { sizes ->
val calculatedTotal = sizes.sum()
if (calculatedTotal != totalSize) {
sizes.dropLast(1) + (totalSize - sizes.dropLast(1).sum())
} else {
sizes
}
}
}
} }

View File

@ -21,8 +21,7 @@ interface ImageSplitter {
suspend fun split( suspend fun split(
imageUri: String, imageUri: String,
params: SplitParams, params: SplitParams
onProgress: (Int) -> Unit
): List<String> ): List<String>
} }

View File

@ -23,14 +23,18 @@ import com.t8rin.imagetoolbox.core.domain.image.model.Quality
data class SplitParams( data class SplitParams(
val rowsCount: Int, val rowsCount: Int,
val columnsCount: Int, val columnsCount: Int,
val rowPercentages: List<Float>,
val columnPercentages: List<Float>,
val imageFormat: ImageFormat, val imageFormat: ImageFormat,
val quality: Quality val quality: Quality,
) { ) {
companion object { companion object {
val Default by lazy { val Default by lazy {
SplitParams( SplitParams(
rowsCount = 2, rowsCount = 2,
columnsCount = 2, columnsCount = 2,
rowPercentages = emptyList(),
columnPercentages = emptyList(),
imageFormat = ImageFormat.Default, imageFormat = ImageFormat.Default,
quality = Quality.Base() quality = Quality.Base()
) )

View File

@ -23,19 +23,25 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.TableRows import androidx.compose.material.icons.rounded.TableRows
import androidx.compose.material.icons.rounded.ViewColumn import androidx.compose.material.icons.rounded.ViewColumn
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.smarttoolfactory.extendedcolors.util.roundToTwoDigits
import com.t8rin.imagetoolbox.core.resources.R import com.t8rin.imagetoolbox.core.resources.R
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.ImageFormatSelector import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.ImageFormatSelector
import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.QualitySelector import com.t8rin.imagetoolbox.core.ui.widget.controls.selection.QualitySelector
import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem import com.t8rin.imagetoolbox.core.ui.widget.enhanced.EnhancedSliderItem
import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults import com.t8rin.imagetoolbox.core.ui.widget.modifier.ShapeDefaults
import com.t8rin.imagetoolbox.core.ui.widget.text.RoundedTextField
import com.t8rin.imagetoolbox.image_splitting.domain.SplitParams import com.t8rin.imagetoolbox.image_splitting.domain.SplitParams
import kotlin.math.roundToInt import kotlin.math.roundToInt
@ -71,7 +77,22 @@ internal fun SplitParamsSelector(
rowsCount = it.roundToInt() rowsCount = it.roundToInt()
}, },
onValueChange = {}, onValueChange = {},
shape = ShapeDefaults.top shape = ShapeDefaults.top,
additionalContent = if (rowsCount > 1) {
{
PercentagesField(
totalSize = rowsCount,
percentageValues = value.rowPercentages,
onValueChange = {
onValueChange(
value.copy(
rowPercentages = it
)
)
}
)
}
} else null
) )
Spacer(Modifier.height(4.dp)) Spacer(Modifier.height(4.dp))
@ -102,7 +123,22 @@ internal fun SplitParamsSelector(
columnsCount = it.roundToInt() columnsCount = it.roundToInt()
}, },
onValueChange = {}, onValueChange = {},
shape = ShapeDefaults.bottom shape = ShapeDefaults.bottom,
additionalContent = if (columnsCount > 1) {
{
PercentagesField(
totalSize = columnsCount,
percentageValues = value.columnPercentages,
onValueChange = {
onValueChange(
value.copy(
columnPercentages = it
)
)
}
)
}
} else null
) )
if (value.imageFormat.canChangeCompressionValue) { if (value.imageFormat.canChangeCompressionValue) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@ -126,3 +162,53 @@ internal fun SplitParamsSelector(
} }
) )
} }
@Composable
private fun PercentagesField(
totalSize: Int,
percentageValues: List<Float>,
onValueChange: (List<Float>) -> Unit
) {
val default by remember(totalSize) {
derivedStateOf {
List(totalSize) { 1f / totalSize }.joinToString("/") {
it.roundToTwoDigits().toString()
}
}
}
var percentages by remember(default) {
mutableStateOf(
percentageValues.joinToString("/") {
it.roundToTwoDigits().toString()
}.ifEmpty { default }
)
}
LaunchedEffect(percentageValues, totalSize) {
if (percentageValues.size > totalSize) {
percentages = percentageValues.take(totalSize).joinToString("/") {
it.roundToTwoDigits().toString()
}
}
}
LaunchedEffect(percentages) {
onValueChange(
percentages.split("/").mapNotNull { it.toFloatOrNull() }
)
}
RoundedTextField(
value = percentages,
onValueChange = {
percentages = it
},
label = {
Text(text = stringResource(R.string.part_percents))
},
hint = {
Text(text = default)
},
modifier = Modifier.padding(8.dp)
)
}

View File

@ -82,8 +82,7 @@ class ImageSplitterComponent @AssistedInject internal constructor(
_uris.update { _uris.update {
imageSplitter.split( imageSplitter.split(
imageUri = uri!!.toString(), imageUri = uri!!.toString(),
params = params, params = params
onProgress = {}
).map { it.toUri() } ).map { it.toUri() }
} }
} }
@ -145,10 +144,12 @@ class ImageSplitterComponent @AssistedInject internal constructor(
fun shareBitmaps(onComplete: () -> Unit) { fun shareBitmaps(onComplete: () -> Unit) {
savingJob = componentScope.launch { savingJob = componentScope.launch {
_isSaving.value = true _isSaving.value = true
_done.value = 0
shareProvider.shareUris( shareProvider.shareUris(
uris = uris.map { it.toString() } uris = uris.map { it.toString() }
) )
onComplete() onComplete()
_isSaving.value = false
} }
} }