Streamer & OCR¶
The Streamer resource captures screenshots and performs OCR (optical character recognition) on the current screen.
Get state¶
StreamerState.streamer is None when no clients are subscribed to the
stream — kvmd shuts the streamer process down to save resources. When it
is running, streamer.source.online indicates whether a video signal is
present (host awake, HDMI plugged).
state = await kvm.streamer.get_state()
if state.streamer is None:
print("Streamer is not running (no active stream clients)")
elif not state.streamer.source.online:
print("Streamer running, but no video signal")
else:
res = state.streamer.source.resolution
print(f"Online at {res.width}x{res.height}")
if state.streamer.h264 is not None:
print(f"H.264 at {state.streamer.h264.fps} fps")
What the device supports decides which parameters exist at all: quality
only where the encoder is adjustable, h264_bitrate/h264_gop only where
H.264 is configured, resolution and limits.available_resolutions only on
capture hardware that can switch. features says which of the three the
device has, and the corresponding fields are None when it does not.
params is what was requested and applied is what the running streamer
ended up with — comparing them is the only way to see whether a change took
effect:
if state.applied.desired_fps != state.params.desired_fps:
print("The requested rate has not been applied")
Change parameters¶
await kvm.streamer.set_params(quality=70, desired_fps=25)
# Only on resolution-capable hardware
await kvm.streamer.set_params(resolution="1280x720")
Asking for a parameter the device does not have at all fails with APIError
(HTTP 400). A value outside state.limits does not: kvmd answers 200 and
then drops it. Since the change is applied asynchronously either way, reading
applied back is the only way to know what happened.
Asynchronously means about a second, and a stream restart
kvmd holds the pending values open for another second in case more
writes join them, then applies the batch and restarts the streamer —
so every parameter change costs a moment of video. Until that happens
neither params nor applied moves; both describe the streamer that is
still running.
before = (await kvm.streamer.get_state()).params.quality # 80
await kvm.streamer.set_params(quality=55)
(await kvm.streamer.get_state()).params.quality # still 80
await asyncio.sleep(4)
(await kvm.streamer.get_state()).params.quality # 55
Writing the old value back inside that window does not cancel the change. kvmd compares each incoming value against the streamer that is running, and queues only what differs — so the old value matches, is dropped, and the pending change lands a moment later anyway:
await kvm.streamer.set_params(quality=55)
await kvm.streamer.set_params(quality=80) # equals the running value: dropped
await asyncio.sleep(4)
(await kvm.streamer.get_state()).params.quality # 55, not 80
To undo a change, wait for it to take and then write the old value — by which time it differs again and is queued like any other.
Restart the streamer¶
The usual recovery for a frozen pipeline. Video drops for a moment:
Take a screenshot¶
image = await kvm.streamer.snapshot()
with open("screenshot.jpeg", "wb") as f:
f.write(image.data)
print(f"{image.width}x{image.height}, live: {image.online}")
By default snapshot() returns HTTP 503 if the video source is offline.
Pass allow_offline=True to receive a "NO LIVE VIDEO" placeholder
instead — image.online then tells the two apart, which the bytes alone
cannot:
image = await kvm.streamer.snapshot(allow_offline=True)
if not image.online:
print("That is the placeholder, not the host screen")
The flag has no effect when the streamer process is fully stopped — that
case still raises UnavailableError (HTTP 503).
Saved snapshots¶
save=True stores the frame on the device, where it shows up in
state.snapshot and outlives the streamer being stopped. load=True returns
that stored frame and works even then, which is the point of it:
await kvm.streamer.snapshot(save=True)
# Later, with no stream clients and the streamer shut down:
image = await kvm.streamer.snapshot(load=True)
load=True with nothing saved raises UnavailableError; state.snapshot.saved
says whether there is one, and how big it is.
Previews¶
kvmd can scale the frame down before sending it, which is much cheaper than fetching a full-resolution JPEG just to thumbnail it:
Omitting both bounds gives a fifth of the source size.
OCR¶
Read text from the current screen:
text = await kvm.streamer.ocr()
print(text)
# Multi-language recognition
text = await kvm.streamer.ocr(langs=["eng", "rus"])
# Read text from the placeholder when the source is offline
text = await kvm.streamer.ocr(allow_offline=True)
Cropping is what makes OCR usable in a loop — Tesseract needs 10-20 seconds for a full screen, but a fraction of that for a region:
ocr() uses a 30 s default timeout because Tesseract on the Pi is slow
(10–20 s for full-screen recognition). Override via the timeout
argument if needed.
To inspect installed OCR languages:
info = await kvm.streamer.get_ocr_info()
print(info.langs.available) # e.g. ["eng", "osd", "rus"]
print(info.langs.default) # e.g. ["eng"]
Note
OCR must be enabled in the PiKVM configuration. The quality depends on the screen resolution and font rendering.
Delete cached snapshot¶
Full example¶
import asyncio
from aiopikvm import PiKVM
async def main():
async with PiKVM("https://pikvm.local", user="admin", passwd="admin") as kvm:
state = await kvm.streamer.get_state()
if state.streamer is None or not state.streamer.source.online:
print("Video source is offline")
return
image = await kvm.streamer.snapshot()
with open("screen.jpeg", "wb") as f:
f.write(image.data)
text = await kvm.streamer.ocr()
if "login" in text.lower():
print("Login screen detected")
asyncio.run(main())