Skip to content

Instantly share code, notes, and snippets.

@matejsemancik
Last active June 22, 2024 18:33
Show Gist options
  • Select an option

  • Save matejsemancik/14af506aa96402479ce94bba8cfe38f0 to your computer and use it in GitHub Desktop.

Select an option

Save matejsemancik/14af506aa96402479ce94bba8cfe38f0 to your computer and use it in GitHub Desktop.
Port of @ylegall stipple portraits
import kotlinx.coroutines.DelicateCoroutinesApi
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.joinAll
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import org.openrndr.application
import org.openrndr.color.ColorHSVa
import org.openrndr.color.ColorRGBa
import org.openrndr.draw.ColorBuffer
import org.openrndr.draw.isolated
import org.openrndr.draw.loadImage
import org.openrndr.extra.color.presets.LIME_GREEN
import org.openrndr.extra.kdtree.kdTree
import org.openrndr.extra.noise.Random
import org.openrndr.extra.noise.poissonDiskSampling
import org.openrndr.extra.olive.oliveProgram
import org.openrndr.math.Vector2
import org.openrndr.math.map
import org.openrndr.math.mix
import org.openrndr.shape.Circle
import org.openrndr.shape.Rectangle
import studio.rndnr.packture.IntegralImage
import kotlin.time.measureTimedValue
/**
* Port of stipple portraits by @ylegall, compatible with latest 2024 OPENRNDR version, using orx-kdtree.
* Just static image computation, not actual animation. Grayscale contrasty images work the best.
*
* Sources:
* - https://www.reddit.com/r/generative/comments/lbodjy/morphing_stipple_portraits/
* - https://gist.github.com/ylegall/a636601e75539e4ad0c9d7ac705601c0
*/
@OptIn(DelicateCoroutinesApi::class)
fun main() = application {
configure {
width = 640
height = 640
windowResizable = false
windowAlwaysOnTop = true
}
oliveProgram {
// region properties
val backgroundColor = ColorHSVa(0.0, 0.0, 0.05).toRGBa()
val foregroundColor = ColorRGBa.LIME_GREEN
val circleRadius = 1.5
val poissonSamplingRadius = 10.0
val pointPull = 0.07
val pointLuminanceRange = 0.0..200.0
val relaxMinOverlap = 0.001
@Suppress("EmptyRange")
val pointRadiusRange = (poissonSamplingRadius * 2)..1.0
val pullIterations = 6
val relaxIterations = 4
val relaxThreads = 12
// endregion
/**
* Create a circle packing by iteratively relaxing circles.
*
* @param threads How many chunks of input data to process in parallel.
* The task will be split into multiple parallel coroutines.
*/
suspend fun relaxPoints(
points: Array<Vector2>,
radii: List<Double>,
maxIterations: Int,
threads: Int,
minOverlap: Double = 0.001,
): Int {
var iterations = 0
val positionDeltas = MutableList(points.size) { Vector2.ZERO }
val maxRadius = radii.maxOrNull() ?: error("radii is empty")
val indices = points.indices.toList()
val chunks = indices.chunked(points.indices.count() / threads)
while (iterations < maxIterations) {
val kTree = points.asList().kdTree()
chunks.map { chunk ->
GlobalScope.launch(Dispatchers.IO) {
for (i in chunk) {
val p1 = points[i]
val radius1 = radii[i]
val neighborIndices = kTree
.findAllInRadius(p1, 2 * maxRadius)
.map { points.indexOf(it) }
.filter { it != i }
var overlappingNeighbors = 0
for (j in neighborIndices) {
val p2 = points[j]
val radius2 = radii[j]
val delta = p1 - p2
val dist = delta.length
val overlap = radius1 + radius2 - dist
if (overlap > minOverlap) {
overlappingNeighbors++
positionDeltas[i] += delta.normalized * (overlap / 2)
}
}
if (overlappingNeighbors > 0) {
positionDeltas[i] = positionDeltas[i] / overlappingNeighbors.toDouble()
}
}
}
}.joinAll()
for (i in points.indices) {
points[i] += positionDeltas[i]
}
positionDeltas.fill(Vector2.ZERO)
iterations++
}
return iterations
}
suspend fun getImagePoints(
from: List<Vector2>,
image: ColorBuffer,
radius: Double,
iterations: Int,
): Array<Vector2> {
val startMillis = System.currentTimeMillis()
image.shadow.download()
val integralImage = IntegralImage.fromColorBufferShadow(image.shadow)
val newPoints = from.toTypedArray()
repeat(iterations) {
// pull the points closer to the center
for (i in newPoints.indices) {
newPoints[i] = mix(newPoints[i], image.bounds.center, pointPull)
}
val radii: List<Double> = newPoints.map { point ->
val x = point.x - radius / 2
val y = point.y - radius / 2
val result = integralImage.sum(Rectangle(x, y, radius, radius).toInt())
map(pointLuminanceRange, pointRadiusRange, result.toDouble() / (radius * radius))
}
// change the max iterations here for time/accuracy trade-off
measureTimedValue {
relaxPoints(
points = newPoints,
radii = radii,
maxIterations = relaxIterations,
threads = relaxThreads,
minOverlap = relaxMinOverlap,
)
}.also {
println("relaxed ${newPoints.count()} points in ${it.value} iterations; took ${it.duration.inWholeMilliseconds} ms")
}
}
println("frame computed in ${System.currentTimeMillis() - startMillis} ms")
return newPoints
}
val image = loadImage("data/images/monalisa.jpg")
val defaultPoints = poissonDiskSampling(
bounds = drawer.bounds,
radius = poissonSamplingRadius,
tries = 5,
initialPoints = listOf(Random.point(drawer.bounds)),
)
val imagePoints = runBlocking {
getImagePoints(
from = defaultPoints,
image = image,
radius = poissonSamplingRadius,
iterations = pullIterations,
)
}
extend {
drawer.clear(backgroundColor)
// drawer.isolated {
// image(image)
// }
drawer.isolated {
fill = foregroundColor
stroke = null
circles(imagePoints.map { Circle(it, circleRadius) })
}
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment