WebcamTexture.kt
src/jvmMain/kotlin/afield/graphics/texture/video/WebcamTexture.kt · 603 lines
Exposes a webcam as an RGBA Vulkan texture, captured through OpenCV.
The first frame is uploaded on open so WebcamTexture.imageView is valid immediately; later frames are grabbed on a background thread and uploaded from the render thread.
package afield.graphics.texture.video
import afield.graphics.vulkan.device.vkLayout
import afield.graphics.vulkan.Renderer
import afield.graphics.vulkan.Target
import afield.graphics.vulkan.buffers.VulkanBuffer
import afield.graphics.vulkan.device.CommandBuffer
import afield.graphics.vulkan.device.Fence
import afield.document.context.fromContext
import afield.graphics.texture.HasImageView
import afield.graphics.texture.Image
import afield.graphics.texture.ImageView
import afield.app.diagnostics.logging.lprintln
import afield.app.server.Browsable
import afield.app.server.HasBrowsable
import org.bytedeco.javacv.Frame
import org.bytedeco.javacv.OpenCVFrameConverter
import org.bytedeco.javacv.OpenCVFrameGrabber
import org.bytedeco.opencv.global.opencv_core.CV_8UC4
import org.bytedeco.opencv.global.opencv_imgproc.COLOR_BGR2RGBA
import org.bytedeco.opencv.global.opencv_imgproc.COLOR_BGRA2RGBA
import org.bytedeco.opencv.global.opencv_imgproc.COLOR_GRAY2RGBA
import org.bytedeco.opencv.global.opencv_imgproc.cvtColor
import org.bytedeco.opencv.opencv_core.Mat
import org.lwjgl.system.MemoryStack
import org.lwjgl.system.MemoryUtil
import org.lwjgl.vulkan.VK10
import org.lwjgl.vulkan.VkBufferImageCopy
import org.lwjgl.vulkan.VkExtent3D
import org.lwjgl.vulkan.VkImageMemoryBarrier
import org.lwjgl.vulkan.VkImageSubresourceLayers
import org.lwjgl.vulkan.VkImageSubresourceRange
import org.lwjgl.vulkan.VkOffset3D
class WebcamTexture
Webcam-backed RGBA texture for Vulkan sampling.
The camera is opened on first use, the first frame is uploaded immediately so imageView is valid, and later frames are grabbed on a background thread and uploaded from the render thread.
class WebcamTexture(
private val renderer: Renderer = fromContext(),
private val deviceIndex: Int = 0,
private val requestedWidth: Int? = null,
private val requestedHeight: Int? = null,
private val requestedFps: Double? = null,
private val startupTimeoutMs: Long = 5_000L
) : HasImageView, Target, HasBrowsable, AutoCloseable {
private val stateLock = Any()
private val converter = OpenCVFrameConverter.ToMat()
private var grabber: OpenCVFrameGrabber? = null
private var captureThread: Thread? = null
private var texture: WebcamRgbaTexture? = null
private var sharedFrameBuffer: java.nio.ByteBuffer? = null
private var rgbaScratch: Mat? = null
private var running = false
private var frameBytes = 0
private var cameraWidth = 0
private var cameraHeight = 0
private var latestFrameIdInternal = 0L
private var uploadedFrameId = 0L
private var capturedFrames = 0L
private var uploadedFrames = 0L
private var droppedFrames = 0L
private var warnedAboutResize = false
private var lastError: Throwable? = null
val width: Int
Width in pixels of the frames the camera is actually delivering, which need not be the size this texture asked for. 0 until the camera opens, and 0 again after cleanup; stop leaves it, since the last frame stays in the texture.
val width: Int
get() = synchronized(stateLock) { cameraWidth }
val height: Int
Height in pixels of the delivered frames, on width's terms. A camera that changes resolution part way through is not followed: those frames are dropped, one warning is logged, and the texture keeps the size it opened at.
val height: Int
get() = synchronized(stateLock) { cameraHeight }
val active: Boolean
True while the capture thread is grabbing frames: set when the camera opens, cleared by stop and cleanup, and cleared as well if the capture loop throws. A false value after frames had been arriving means capture has stopped, and latestFrameId still names the last frame that got through.
val active: Boolean
get() = synchronized(stateLock) { running }
val latestFrameId: Long
Monotonic id of the most recently captured frame, or 0 before the first.
Lets a CPU-side consumer skip work it has already done, the same way update skips a redundant GPU upload.
val latestFrameId: Long
get() = synchronized(stateLock) { latestFrameIdInternal }
fun withLatestFrame(action: (rgba: java.nio.ByteBuffer, width: Int, height: Int, frameId: Long) -> Unit): Boolean =
Read the newest frame on the CPU, without a GPU round trip.
The capture thread already has every frame in host memory as tightly packed RGBA8 on its way to the staging buffer, so anything wanting pixels — a depth or segmentation model, say — should take them from here rather than reading the texture back off the GPU.
action receives the shared capture buffer, its dimensions and the frame id. It runs while the
capture lock is held, so it must not block: copy what it needs and return. Anything slower
than a memcpy stalls capture and shows up as dropped frames.
@return false if no frame has been captured yet, in which case action is not called
fun withLatestFrame(action: (rgba: java.nio.ByteBuffer, width: Int, height: Int, frameId: Long) -> Unit): Boolean =
synchronized(stateLock) {
val buffer = sharedFrameBuffer ?: return false
if (latestFrameIdInternal == 0L) return false
action(buffer, cameraWidth, cameraHeight, latestFrameIdInternal)
true
}
override val imageView: ImageView
override val imageView: ImageView
get() = ensureInitialized().imageView
override fun render(command: CommandBuffer, index: Int)
override fun render(command: CommandBuffer, index: Int) {
update(command)
}
override fun close()
override fun close() {
cleanup()
}
fun start(): WebcamTexture
fun start(): WebcamTexture {
ensureInitialized()
return this
}
fun stop()
fun stop() {
val threadToJoin: Thread?
val grabberToRelease: OpenCVFrameGrabber?
synchronized(stateLock) {
running = false
threadToJoin = captureThread
captureThread = null
grabberToRelease = grabber
grabber = null
}
if (threadToJoin != null && threadToJoin !== Thread.currentThread()) {
threadToJoin.interrupt()
}
runCatching { grabberToRelease?.stop() }
runCatching { grabberToRelease?.release() }
if (threadToJoin != null && threadToJoin !== Thread.currentThread()) {
runCatching { threadToJoin.join(1_000L) }
}
}
fun cleanup()
fun cleanup() {
stop()
synchronized(stateLock) {
texture?.cleanup()
texture = null
sharedFrameBuffer?.let { MemoryUtil.memFree(it) }
sharedFrameBuffer = null
rgbaScratch?.release()
rgbaScratch?.close()
rgbaScratch = null
frameBytes = 0
cameraWidth = 0
cameraHeight = 0
latestFrameIdInternal = 0L
uploadedFrameId = 0L
capturedFrames = 0L
uploadedFrames = 0L
droppedFrames = 0L
warnedAboutResize = false
}
}
fun update(command: CommandBuffer): Boolean
Uploads the newest captured frame, if any. Returns true when a new frame reached the texture.
fun update(command: CommandBuffer): Boolean {
ensureInitialized()
synchronized(stateLock) {
val activeTexture = texture ?: return false
val buffer = sharedFrameBuffer ?: return false
if (latestFrameIdInternal == uploadedFrameId) return false
activeTexture.upload(command, buffer, frameBytes)
uploadedFrameId = latestFrameIdInternal
uploadedFrames++
return true
}
}
override fun browsable(): Browsable?
body — 388 lines, from line 215
override fun browsable(): Browsable? = object : Browsable {
override fun heading(): String = "WebcamTexture"
override fun dir(): Map<String, Any?>? = null
override fun html(): String {
fun esc(s: String) = s.replace("&", "&").replace("<", "<").replace(">", ">")
val snapshot = synchronized(stateLock) {
mapOf(
"device" to deviceIndex.toString(),
"size" to if (cameraWidth > 0) "${cameraWidth}x${cameraHeight}" else "(uninitialized)",
"state" to if (running) "running" else "stopped",
"captured" to capturedFrames.toString(),
"uploaded" to uploadedFrames.toString(),
"dropped" to droppedFrames.toString(),
"error" to (lastError?.message ?: "")
)
}
return """
<div style="font-family:inherit;color:#ddd;padding:6px 8px;">
<div style="display:grid;grid-template-columns:120px 1fr;row-gap:4px;column-gap:10px;align-items:baseline;">
<div style="text-align:right;color:#8a8a8a;font-size:12px;">device</div><div>${snapshot["device"]}</div>
<div style="text-align:right;color:#8a8a8a;font-size:12px;">size</div><div>${snapshot["size"]}</div>
<div style="text-align:right;color:#8a8a8a;font-size:12px;">state</div><div>${snapshot["state"]}</div>
<div style="text-align:right;color:#8a8a8a;font-size:12px;">captured</div><div>${snapshot["captured"]}</div>
<div style="text-align:right;color:#8a8a8a;font-size:12px;">uploaded</div><div>${snapshot["uploaded"]}</div>
<div style="text-align:right;color:#8a8a8a;font-size:12px;">dropped</div><div>${snapshot["dropped"]}</div>
<div style="text-align:right;color:#8a8a8a;font-size:12px;">error</div><div style="white-space:pre-wrap;">${esc(snapshot["error"] ?: "")}</div>
</div>
</div>
""".trimIndent()
}
override fun version(): Long = synchronized(stateLock) { latestFrameIdInternal + (uploadedFrameId shl 32) }
}
private fun ensureInitialized(): WebcamRgbaTexture {
texture?.let { return it }
synchronized(stateLock) {
texture?.let { return it }
val newGrabber = OpenCVFrameGrabber(deviceIndex).apply {
requestedWidth?.let { imageWidth = it }
requestedHeight?.let { imageHeight = it }
requestedFps?.let { frameRate = it }
}
try {
newGrabber.start()
val firstFrame = awaitFirstFrame(newGrabber)
val firstRgba = convertFrameToRgba(firstFrame)
val width = firstRgba.cols()
val height = firstRgba.rows()
val bytes = width * height * 4
val latestBuffer = MemoryUtil.memAlloc(bytes)
copyMatToBuffer(firstRgba, latestBuffer, bytes)
val newTexture = WebcamRgbaTexture(
renderer = renderer,
width = width,
height = height,
name = "webcam_$deviceIndex"
)
newTexture.uploadNow(latestBuffer, bytes)
grabber = newGrabber
texture = newTexture
sharedFrameBuffer = latestBuffer
cameraWidth = width
cameraHeight = height
frameBytes = bytes
latestFrameIdInternal = 1L
uploadedFrameId = 1L
capturedFrames = 1L
uploadedFrames = 1L
droppedFrames = 0L
warnedAboutResize = false
lastError = null
running = true
startCaptureThreadLocked()
return newTexture
} catch (t: Throwable) {
lastError = t
runCatching { newGrabber.stop() }
runCatching { newGrabber.release() }
throw IllegalStateException("Failed to initialize webcam $deviceIndex", t)
}
}
}
private fun startCaptureThreadLocked() {
if (captureThread?.isAlive == true) return
val thread = Thread({
captureLoop()
}, "WebcamTexture-$deviceIndex").apply {
isDaemon = true
}
captureThread = thread
thread.start()
}
private fun captureLoop() {
while (true) {
val activeGrabber = synchronized(stateLock) {
if (!running) return
grabber
} ?: return
try {
val frame = activeGrabber.grab() ?: continue
val rgba = convertFrameToRgba(frame)
val width = rgba.cols()
val height = rgba.rows()
synchronized(stateLock) {
if (!running) return
if (width != cameraWidth || height != cameraHeight) {
if (!warnedAboutResize) {
warnedAboutResize = true
lprintln(
"warning,webcam",
"Ignoring webcam resize ${width}x${height}; texture remains ${cameraWidth}x${cameraHeight}"
)
}
return@synchronized
}
val latestBuffer = sharedFrameBuffer ?: return@synchronized
copyMatToBuffer(rgba, latestBuffer, frameBytes)
if (latestFrameIdInternal != uploadedFrameId) {
droppedFrames++
}
latestFrameIdInternal++
capturedFrames++
}
} catch (_: InterruptedException) {
return
} catch (t: Throwable) {
lastError = t
lprintln("error,webcam", "Capture loop failed for webcam $deviceIndex: ${t.message}")
synchronized(stateLock) {
running = false
}
return
}
}
}
private fun awaitFirstFrame(grabber: OpenCVFrameGrabber): Frame {
val deadline = System.nanoTime() + startupTimeoutMs * 1_000_000L
while (System.nanoTime() < deadline) {
val frame = grabber.grab()
if (frame != null) {
return frame
}
Thread.sleep(10L)
}
throw IllegalStateException("Timed out waiting for webcam $deviceIndex to produce a frame")
}
private fun convertFrameToRgba(frame: Frame): Mat {
val source = converter.convert(frame)
?: throw IllegalStateException("OpenCVFrameGrabber returned a frame that could not be converted to Mat")
val width = source.cols()
val height = source.rows()
val target = ensureScratch(width, height)
when (source.channels()) {
4 -> cvtColor(source, target, COLOR_BGRA2RGBA)
3 -> cvtColor(source, target, COLOR_BGR2RGBA)
1 -> cvtColor(source, target, COLOR_GRAY2RGBA)
else -> throw IllegalStateException("Unsupported webcam channel count: ${source.channels()}")
}
return target
}
private fun ensureScratch(width: Int, height: Int): Mat {
val current = rgbaScratch
if (current != null && current.cols() == width && current.rows() == height) {
return current
}
current?.release()
current?.close()
return Mat(height, width, CV_8UC4).also {
rgbaScratch = it
}
}
private fun copyMatToBuffer(mat: Mat, destination: java.nio.ByteBuffer, bytes: Int) {
val srcPointer = mat.data()
?: throw IllegalStateException("Converted webcam frame had no backing pixel buffer")
val src = MemoryUtil.memByteBuffer(srcPointer.address(), bytes)
val srcCopy = src.duplicate()
srcCopy.position(0)
srcCopy.limit(bytes)
destination.clear()
destination.put(srcCopy)
destination.flip()
}
}
private class WebcamRgbaTexture(
private val renderer: Renderer,
val width: Int,
val height: Int,
name: String
) : HasImageView {
private val image: Image
override val imageView: ImageView
private val stagingBuffer: VulkanBuffer
private var currentLayout = VK10.VK_IMAGE_LAYOUT_UNDEFINED
init {
val imageBytes = width * height * 4L
stagingBuffer = VulkanBuffer(
renderer,
imageBytes,
VK10.VK_BUFFER_USAGE_TRANSFER_SRC_BIT,
VK10.VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT or VK10.VK_MEMORY_PROPERTY_HOST_COHERENT_BIT
)
image = Image(
renderer.device,
Image.ImageData()
.width(width)
.height(height)
.format(VK10.VK_FORMAT_R8G8B8A8_UNORM)
.usage(VK10.VK_IMAGE_USAGE_TRANSFER_DST_BIT or VK10.VK_IMAGE_USAGE_SAMPLED_BIT)
.mipLevels(1)
)
imageView = ImageView(
renderer.device,
image,
ImageView.ImageViewData()
.format(VK10.VK_FORMAT_R8G8B8A8_UNORM)
.aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT)
.mipLevels(1)
)
}
fun upload(cmd: CommandBuffer, source: java.nio.ByteBuffer, bytes: Int) {
copyToStaging(source, bytes)
recordUpload(cmd)
}
fun uploadNow(source: java.nio.ByteBuffer, bytes: Int) {
val cmd = CommandBuffer(renderer.commandPool, true, true)
cmd.beginRecording()
upload(cmd, source, bytes)
cmd.endRecording()
val fence = Fence(renderer.device, true)
fence.reset()
MemoryStack.stackPush().use { stack ->
renderer.graphQueue.submit(stack.pointers(cmd.vkCommandBuffer), null, null, null, fence)
}
fence.fenceWait()
fence.cleanup()
cmd.cleanup()
}
fun cleanup() {
stagingBuffer.cleanup()
imageView.cleanup()
image.cleanup()
}
private fun copyToStaging(source: java.nio.ByteBuffer, bytes: Int) {
val src = source.duplicate()
src.position(0)
src.limit(bytes)
val mapped = stagingBuffer.map()
val dst = MemoryUtil.memByteBuffer(mapped, bytes)
dst.position(0)
dst.put(src)
stagingBuffer.unmap(0, bytes.toLong())
}
private fun recordUpload(cmd: CommandBuffer) {
MemoryStack.stackPush().use { stack ->
recordImageTransition(stack, cmd, currentLayout, VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL)
recordCopyBuffer(stack, cmd)
recordImageTransition(
stack,
cmd,
VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL,
VK10.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
)
}
currentLayout = VK10.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL
}
private fun recordCopyBuffer(stack: MemoryStack, cmd: CommandBuffer) {
val region = VkBufferImageCopy.calloc(1, stack)
.bufferOffset(0)
.bufferRowLength(0)
.bufferImageHeight(0)
.imageSubresource { it: VkImageSubresourceLayers ->
it.aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT)
.mipLevel(0)
.baseArrayLayer(0)
.layerCount(1)
}
.imageOffset { it: VkOffset3D -> it.x(0).y(0).z(0) }
.imageExtent { it: VkExtent3D ->
it.width(width).height(height).depth(1)
}
VK10.vkCmdCopyBufferToImage(
cmd.vkCommandBuffer,
stagingBuffer.buffer,
image.vkImage,
vkLayout(VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL),
region
)
}
private fun recordImageTransition(stack: MemoryStack, cmd: CommandBuffer, oldLayout: Int, newLayout: Int) {
val barrier = VkImageMemoryBarrier.calloc(1, stack)
.sType(VK10.VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER)
.oldLayout(vkLayout(oldLayout))
.newLayout(vkLayout(newLayout))
.srcQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED)
.dstQueueFamilyIndex(VK10.VK_QUEUE_FAMILY_IGNORED)
.image(image.vkImage)
.subresourceRange { it: VkImageSubresourceRange ->
it.aspectMask(VK10.VK_IMAGE_ASPECT_COLOR_BIT)
.baseMipLevel(0)
.levelCount(1)
.baseArrayLayer(0)
.layerCount(1)
}
val srcStage: Int
val srcAccessMask: Int
val dstAccessMask: Int
val dstStage: Int
when {
oldLayout == VK10.VK_IMAGE_LAYOUT_UNDEFINED && newLayout == VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL -> {
srcStage = VK10.VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT
srcAccessMask = 0
dstStage = VK10.VK_PIPELINE_STAGE_TRANSFER_BIT
dstAccessMask = VK10.VK_ACCESS_TRANSFER_WRITE_BIT
}
oldLayout == VK10.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL && newLayout == VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL -> {
srcStage = VK10.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT
srcAccessMask = VK10.VK_ACCESS_SHADER_READ_BIT
dstStage = VK10.VK_PIPELINE_STAGE_TRANSFER_BIT
dstAccessMask = VK10.VK_ACCESS_TRANSFER_WRITE_BIT
}
oldLayout == VK10.VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL && newLayout == VK10.VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL -> {
srcStage = VK10.VK_PIPELINE_STAGE_TRANSFER_BIT
srcAccessMask = VK10.VK_ACCESS_TRANSFER_WRITE_BIT
dstStage = VK10.VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT
dstAccessMask = VK10.VK_ACCESS_SHADER_READ_BIT
}
else -> throw IllegalStateException("Unsupported image layout transition: $oldLayout -> $newLayout")
}
barrier.srcAccessMask(srcAccessMask)
barrier.dstAccessMask(dstAccessMask)
VK10.vkCmdPipelineBarrier(
cmd.vkCommandBuffer,
srcStage,
dstStage,
0,
null,
null,
barrier
)
}
}