OpticalFlowDemo.kt
src/jvmMain/kotlin/afield/media/vision/OpticalFlowDemo.kt · 319 lines
package afield.media.vision
import afield.document.context.*
import afield.graphics.render.*
import afield.graphics.render.Module
import afield.graphics.scene.*
import afield.graphics.shader.*
import afield.graphics.shader.fromViews
import afield.graphics.texture.TextureSampler
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.vulkan.device.Fence
import afield.graphics.vulkan.targets.RenderToTexture
import afield.math.linalg.Vector4f
import org.lwjgl.system.MemoryStack
import org.lwjgl.vulkan.VK13.*
A self-contained optical-flow demonstration that renders two warped test images, measures the motion between them, and drives a feedback image with it.
OpticalFlowDemo builds its own inputs, so it runs without a camera or video source; it is a starting point for wiring opticalFlow output into shaders.
class OpticalFlowDemo
Two generated images, their motion/cost, and a feedback image driven by that motion. backend defaults to
OpticalFlowBackend.AUTO: the NV engine where it exists, PyramidOpticalFlow elsewhere.
class OpticalFlowDemo(
val renderer: Renderer,
val width: Int = 960,
val height: Int = 540,
val name: String = "optical-flow",
backend: OpticalFlowBackend = OpticalFlowBackend.AUTO,
) : AutoCloseable {
val pair
val pair = RenderToTexture(renderer) {
this.width = this@OpticalFlowDemo.width
this.height = this@OpticalFlowDemo.height
colorAttachments = 2
useDepth = false
format = VK_FORMAT_R16G16B16A16_SFLOAT
}
private val source = Module(
parameters(renderer) {
this.name = "$name-inputs"
+pair
vertex = VERTEX
fragment = SOURCE
depthTest = false
depthWrite = false
blend = false
},
)
private val sourceMesh = quad(source, pair)
val flow: OpticalFlowOperation
val flow: OpticalFlowOperation =
opticalFlow(renderer, pair.imageViewArray[0], pair.imageViewArray[1], backend = backend)
private val feedback = List(2) {
RenderToTexture(renderer) {
this.width = this@OpticalFlowDemo.width
this.height = this@OpticalFlowDemo.height
useDepth = false
format = VK_FORMAT_R16G16B16A16_SFLOAT
}
}
val output
body — 73 lines, from line 66
val output = RenderToTexture(renderer) {
this.width = this@OpticalFlowDemo.width * 2
this.height = this@OpticalFlowDemo.height * 2
useDepth = false
format = VK_FORMAT_R16G16B16A16_SFLOAT
}
private val sampler = TextureSampler(renderer, 1, false, filter = VK_FILTER_LINEAR)
private val advectors =
List(2) { i ->
Module(
parameters(renderer) {
this.name = "$name-advection-$i"
+feedback[i]
vertex = VERTEX
fragment = ADVECT
depthTest = false
depthWrite = false
blend = false
textureResolver = { t ->
fromViews(
listOf(
when (t.name) {
"motion" -> flow.output
"source" -> pair.imageViewArray[1]
else -> feedback[1 - i]
},
),
t.name,
sampler,
)
}
},
)
}
private val advectionMeshes = advectors.mapIndexed { i, m -> quad(m, feedback[i]) }
private val displays =
List(2) { i ->
Module(
parameters(renderer) {
this.name = "$name-display-$i"
+output
vertex = VERTEX
fragment = DISPLAY
depthTest = false
depthWrite = false
blend = false
textureResolver = { t ->
fromViews(
listOf(
when (t.name) {
"motion" -> flow.output
"firstImage" -> pair.imageViewArray[0]
"secondImage" -> pair.imageViewArray[1]
else -> feedback[i]
},
),
t.name,
sampler,
)
}
},
)
}
private val displayMeshes = displays.map { m ->
val p = m.pointer()
val mesh = Mesh(renderer, p)
mesh.resize(3, 0, p)
m.meshes["quad"] = mesh
mesh.push.setMembers(m.fi.pushConstants.single().members)
mesh
}
private val command = CommandBuffer(renderer.commandPool, true, true)
private val fence = Fence(renderer.device, false)
var dx = 12f
var dy = -8f
var gain = 1f
var damping = .98f
private val hardware = flow.backend == OpticalFlowBackend.HARDWARE
val costDisplayRange = if (hardware) 100f else 25f
Cost at which the COST strip saturates. The backends' costs are on different scales (see OpticalFlowOperation): the hardware engine's raw cost, or PyramidOpticalFlow's mean window residual in grey levels.
var costThreshold = if (hardware) 80f else 8f
Advection keeps the previous image only where cost is below this; defaults are per backend, see costDisplayRange.
var deformation = false
var pairStep = 1f / 30f
Separation of the two source frames, in seconds. Advance update(time) by this amount.
var displayRange = 3f
Only affects arrows/color intensity, never the displacement delivered to another shader.
private var frame = 0
private var closed = false
private val blit = output.blitToScreen(name = name)
fun update(time: Float = 0f, reset: Boolean = false)
body — 44 lines, from line 163
fun update(time: Float = 0f, reset: Boolean = false) {
check(!closed)
sourceMesh.push["state"] = Vector4f(dx, dy, time, if (deformation) 1f else 0f)
sourceMesh.push["step"] = pairStep
submit { pair.render(command, 0) }
flow.evaluate(reset || frame == 0)
val i = frame % 2
advectionMeshes[i].push["settings"] =
Vector4f(gain, damping, costThreshold, if (reset || frame == 0) 1f else 0f)
displayMeshes[i].push["range"] = displayRange
displayMeshes[i].push["cost"] =
Vector4f(costDisplayRange, if (hardware) 4f else 10f, if (hardware) 6f else 5f, 0f)
output.modules["display"] = displays[i].pointer()
submit {
// Both feedback images need a defined initial layout even when the shader resets.
if (frame == 0) feedback[1 - i].render(command, 0)
feedback[i].render(command, 0)
output.render(command, 0)
}
frame++
}
private fun submit(record: () -> Unit) {
vkResetCommandBuffer(command.vkCommandBuffer, 0)
command.beginRecording()
record()
command.endRecording()
MemoryStack.stackPush().use {
renderer.graphQueue.submit(it.pointers(command.vkCommandBuffer), null, null, null, fence)
}
fence.fenceWait()
fence.reset()
}
private fun quad(m: Module, target: RenderToTexture): Mesh {
check(m.valid)
val p = m.pointer()
val mesh = Mesh(renderer, p)
mesh.resize(3, 0, p)
m.fi.pushConstants.singleOrNull()?.let { mesh.push.setMembers(it.members) }
m.meshes["quad"] = mesh
target.modules["quad"] = p
return mesh
}
override fun close()
override fun close() {
if (closed) return
closed = true
renderer.renderList.modules.remove(name, blit)
flow.close()
renderer.cleanup += Runnable {
source.cleanup()
advectors.forEach { it.cleanup() }
displays.forEach { it.cleanup() }
sampler.cleanup()
command.cleanup()
fence.cleanup()
(listOf(pair, output) + feedback).forEach { t ->
t.imageViewArray.forEach { it.imageView.cleanup() }
t.colorImageRefs.forEach { it?.cleanup() }
t.defaultSampler?.cleanup()
}
}
}
fun expectedDisplacement(x: Float, y: Float, time: Float): afield.math.linalg.Vector2f
Invert the known source warp: a feature at (x,y) in the first image moves to this offset. Diagnostic only; the runtime motion field comes exclusively from flow.
fun expectedDisplacement(x: Float, y: Float, time: Float): afield.math.linalg.Vector2f {
if (!deformation) return afield.math.linalg.Vector2f(dx, dy)
fun warp(a: Float, b: Float, t: Float) = afield.math.linalg.Vector2f(
a + kotlin.math.sin(b * .013f + t * 3) * 24,
b + kotlin.math.cos(a * .011f + t * 2.3f) * 20,
)
val q = warp(x, y, time)
var a = x
var b = y
val t = time + pairStep
repeat(6) {
val z = warp(a, b, t)
val rx = z.x - q.x
val ry = z.y - q.y
val j01 = kotlin.math.cos(b * .013f + t * 3) * 24 * .013f
val j10 = -kotlin.math.sin(a * .011f + t * 2.3f) * 20 * .011f
val det = 1 - j01 * j10
a -= (rx - j01 * ry) / det
b -= (ry - j10 * rx) / det
}
return afield.math.linalg.Vector2f(a + dx - x, b + dy - y)
}
companion object
declaration — 64 lines, from line 255
companion object {
private const val VERTEX =
"""#version 460
void main(){vec2 p=vec2((gl_VertexIndex<<1)&2,gl_VertexIndex&2);gl_Position=vec4(p*2-1,0,1);}
"""
private const val SOURCE =
"""#version 460
layout(push_constant) uniform P {vec4 state;float step;} p;
layout(location=0) out vec4 firstImage;layout(location=1) out vec4 secondImage;
float hash(vec2 v){return fract(sin(dot(v,vec2(127.1,311.7)))*43758.5453);}
vec3 pattern(vec2 xy,float t){
if(p.state.w>0){xy+=vec2(sin(xy.y*.013+t*3)*24,cos(xy.x*.011+t*2.3)*20);}
vec2 cell=floor(xy/32),uv=fract(xy/32)-.5;float h=hash(cell);
vec3 c=.08+.8*(.5+.5*cos(vec3(0,2,4)+h*6.28));
float shape=smoothstep(.32,.26,length(uv));float checker=mod(cell.x+cell.y,2);
return mix(vec3(.05+.15*checker),c,shape)+.04*sin(xy.x*.27)*sin(xy.y*.23);
}
void main(){vec2 xy=gl_FragCoord.xy;firstImage=vec4(pattern(xy,p.state.z),1);secondImage=vec4(pattern(xy-p.state.xy,p.state.z+p.step),1);}
"""
private const val ADVECT =
"""#version 460
layout(set=0,binding=0) uniform sampler2D motion;layout(set=0,binding=1) uniform sampler2D previous;layout(set=0,binding=2) uniform sampler2D source;
layout(push_constant) uniform P {vec4 settings;} p;layout(location=0) out vec4 color;
void main(){vec2 size=vec2(textureSize(source,0)),uv=gl_FragCoord.xy/size;vec3 v=texture(motion,uv).xyz;
float trust=float(v.z<p.settings.z && length(v.xy)<128);vec2 back=uv-v.xy/size*p.settings.x*trust;
vec3 fresh=texture(source,uv).rgb;
color=vec4(p.settings.w>.5?fresh:mix(fresh,texture(previous,back).rgb,p.settings.y*trust),1);}
"""
private const val DISPLAY =
"""#version 460
layout(set=0,binding=0) uniform sampler2D motion;layout(set=0,binding=1) uniform sampler2D firstImage;
layout(set=0,binding=2) uniform sampler2D secondImage;layout(set=0,binding=3) uniform sampler2D feedback;
layout(location=0) out vec4 color;
layout(push_constant) uniform P {float range;vec4 cost;} settings;
// COST, "0 100+" and "0 25+": 3x5 diagnostic glyphs, without an extra texture or font dependency.
// cost = (saturating cost, first glyph of the scale label, its glyph count, unused).
const uint labelGlyphs[15]=uint[](29263u,31599u,31183u,9367u,31599u,0u,29850u,31599u,31599u,1488u,31599u,0u,29671u,31183u,1488u);
float label(vec2 pixel,int start,int count,float scale) {
ivec2 p=ivec2(floor(pixel/scale));
if(p.x<0 || p.y<0 || p.y>=5 || p.x>=count*4)return 0;
int column=p.x%4;if(column==3)return 0;
return float((labelGlyphs[start+p.x/4]>>uint(p.y*3+column))&1u);
}
void main(){vec2 size=vec2(textureSize(firstImage,0)),p=gl_FragCoord.xy/size,uv=fract(p);vec3 rgb;
if(p.y<1)rgb=p.x<1?texture(firstImage,uv).rgb:texture(secondImage,uv).rgb;
else if(p.x>=1)rgb=texture(feedback,uv).rgb;
else {vec3 flow=texture(motion,uv).xyz;float angle=atan(flow.y,flow.x);rgb=(.5+.5*cos(angle+vec3(0,2.094,4.189)))*min(length(flow.xy)/settings.range,1);
vec2 cell=(fract(uv*size/40)-.5)*40;vec2 d=normalize(flow.xy+vec2(.0001));float along=dot(cell,d),across=abs(dot(cell,vec2(-d.y,d.x)));
float arrow=float((abs(along)<12 && across<.8)||(along>6 && along<13 && abs(across-(13-along))<1));
rgb=mix(rgb,vec3(1),arrow*.8*clamp(length(flow.xy)*4,0,1));if(uv.x>.85) {
rgb=vec3(clamp(flow.z/settings.cost.x,0,1));
vec2 q=vec2((uv.x-.85)*size.x,uv.y*size.y);float stripWidth=size.x*.15;
if(q.y<62) {
rgb=vec3(.025,.035,.05);
float ink=max(label(q-vec2(8,8),0,4,3),label(q-vec2(8,31),int(settings.cost.y),int(settings.cost.z),2));
rgb=mix(rgb,vec3(.92),ink);
if(q.y>=47 && q.y<55 && q.x>=8 && q.x<stripWidth-8)
rgb=vec3((q.x-8)/max(stripWidth-16,1));
}
}}
color=vec4(rgb,1);}
"""
}
}