Model and media interfaces
A Reflex model interface collects inputs, runs a Python prediction function or model-service call, and displays the result. You control the surrounding layout, forms, charts, and workflow. Start with a small local example, then add the execution and storage services your model needs.
You can build an interactive machine learning demo in Reflex with a local model or a hosted inference API. Define the input controls, call your Python function from an event handler, and render the returned prediction. The same interface can grow into a multi-page application with custom styling, result review, and database-backed workflows.
A local prediction demo
This example uses one-nearest-neighbor classification on six labelled flower measurements. It finds the closest sample using Euclidean distance in petal length and width. The tiny dataset is for demonstrating a UI around inference; it is not an evaluated production classifier.
Create a blank Reflex app with the installation guide. Copy this code into the app module, followed by the registration lines below. No model download or API key is needed.
Flower classifier
Enter two petal measurements to find the closest sample.
Prediction
Enter measurements and select Predict species.
Demo model · 6 reference samples
app = rx.App()
app.add_page(model_interface, route="/")Run uv run reflex run. Inputs 1.4 and 0.2 produce setosa; 6.0 and 2.5 produce virginica. A nonnumeric or out-of-range input clears the previous prediction and shows an error. The function can be tested independently of the interface.
Use your own model
Replace predict_flower with a call to your Python inference code or model service. Load expensive resources according to their lifetime and concurrency requirements, rather than downloading a model for every input change. Keep model objects out of frontend state; store only the values the UI needs.
A slow service call needs loading, timeout, error, and duplicate-submission behavior. A CPU-heavy model needs an execution strategy that does not block the application server. See performance and execution and background events. A durable inference queue must be supplied separately when jobs must survive restarts.
Run an image input and output workflow
A media interface can pass uploaded bytes to a Python function and display its output in a custom layout. This example makes a grayscale image preview, preserves its aspect ratio, and provides a PNG download. It demonstrates image preprocessing, not a trained image classifier. Replace the processing function with your model pipeline when you want predictions or generated images.
Install Pillow in your app:
uv add pillowCopy this example into a blank app module and run it locally. File uploads run in your app; this documentation site does not accept uploads. The example accepts one still PNG or JPEG up to 2 MiB and 4 million pixels. The output is at most 512 pixels on its longest side. Processing uses a worker thread, and the form shows progress and validation errors.
import asyncio
import base64
from io import BytesIO
import reflex as rx
from PIL import Image, ImageOps, UnidentifiedImageError
MAX_IMAGE_BYTES = 2 * 1024 * 1024
IMAGE_UPLOAD_ID = "image-workflow-upload"
def prepare_image(data: bytes) -> tuple[bytes, str]:
"""Create a bounded grayscale PNG and describe the input/output dimensions."""
if len(data) > MAX_IMAGE_BYTES:
raise ValueError("Choose an image smaller than 2 MiB.")
try:
with Image.open(BytesIO(data), formats=("PNG", "JPEG")) as source:
if source.width * source.height > 4_000_000:
raise ValueError("Choose an image with at most 4 million pixels.")
if getattr(source, "n_frames", 1) != 1:
raise ValueError("Choose a still PNG or JPEG, not an animation.")
oriented = ImageOps.exif_transpose(source)
original_size = oriented.size
oriented.thumbnail((512, 512))
grayscale = ImageOps.grayscale(oriented)
# Copy pixels into a fresh image so uploaded metadata is not retained.
clean = Image.new("L", grayscale.size)
clean.paste(grayscale)
output = BytesIO()
clean.save(output, format="PNG")
summary = (
f"{original_size[0]} x {original_size[1]} input · "
f"{clean.width} x {clean.height} grayscale PNG"
)
return output.getvalue(), summary
except (UnidentifiedImageError, OSError, Image.DecompressionBombError) as error:
raise ValueError("Choose a valid PNG or JPEG image.") from error
class ImageWorkflowState(rx.State):
"""Keep the small generated preview in the current session."""
preview: str = ""
summary: str = ""
error: str = ""
processing: bool = False
_output_png: bytes = b""
@rx.event
async def process_image(self, files: list[rx.UploadFile]):
"""Validate the upload and send its generated preview to this session."""
if self.processing:
return
self.preview = ""
self.summary = ""
self.error = ""
self._output_png = b""
if len(files) != 1:
self.error = "Choose one PNG or JPEG image."
return
self.processing = True
yield
try:
data = await files[0].read(MAX_IMAGE_BYTES + 1)
png, summary = await asyncio.to_thread(prepare_image, data)
self._output_png = png
self.preview = "data:image/png;base64," + base64.b64encode(png).decode(
"ascii"
)
self.summary = summary
except ValueError as error:
self.error = str(error)
except OSError:
self.error = "The upload could not be read. Please try again."
finally:
self.processing = False
@rx.event
def download_image(self):
"""Download the output generated for the current session."""
if self._output_png:
return rx.download(
data=self._output_png,
filename="image-preview.png",
mime_type="image/png",
)
@rx.event
def clear_image(self):
"""Discard the output and clear the browser's selected file."""
self.preview = ""
self.summary = ""
self.error = ""
self._output_png = b""
return rx.clear_selected_files(IMAGE_UPLOAD_ID)
def image_workflow():
"""Render an image upload, processing action, preview, and download."""
return rx.vstack(
rx.hstack(
rx.icon("image", size=22, color=rx.color("violet", 9)),
rx.heading("Image preparation", size="5", as_="h3"),
spacing="3",
),
rx.text(
"Upload an image to create a grayscale preview for your model pipeline.",
size="2",
color=rx.color("gray", 11),
),
rx.upload(
rx.vstack(
rx.icon("upload", size=24),
rx.text(
"Drop an image here or choose a file", size="2", weight="medium"
),
rx.text(
"PNG or JPEG · up to 2 MiB", size="1", color=rx.color("gray", 11)
),
rx.foreach(
rx.selected_files(IMAGE_UPLOAD_ID),
lambda name: rx.text(name, size="2", overflow_wrap="anywhere"),
),
spacing="2",
width="100%",
),
id=IMAGE_UPLOAD_ID,
accept={"image/png": [".png"], "image/jpeg": [".jpg", ".jpeg"]},
multiple=False,
disabled=ImageWorkflowState.processing,
border=f"1px dashed {rx.color('gray', 7)}",
border_radius="8px",
padding="1.5rem",
width="100%",
),
rx.button(
"Create preview",
on_click=ImageWorkflowState.process_image(
rx.upload_files(upload_id=IMAGE_UPLOAD_ID)
),
loading=ImageWorkflowState.processing,
disabled=rx.selected_files(IMAGE_UPLOAD_ID).length() == 0,
size="3",
width="100%",
),
rx.cond(
ImageWorkflowState.error != "",
rx.text(
ImageWorkflowState.error,
role="alert",
size="2",
color=rx.color("red", 11),
),
),
rx.cond(
ImageWorkflowState.preview != "",
rx.vstack(
rx.image(
src=ImageWorkflowState.preview,
alt="Generated grayscale image preview",
max_height="18rem",
object_fit="contain",
width="100%",
),
rx.text(
ImageWorkflowState.summary,
role="status",
size="2",
color=rx.color("gray", 11),
),
rx.button(
"Download PNG",
rx.icon("download", size=16),
on_click=ImageWorkflowState.download_image,
variant="soft",
),
spacing="3",
align_items="stretch",
width="100%",
padding="1rem",
border_radius="8px",
background=rx.color("gray", 3),
),
),
rx.button(
"Clear image",
on_click=ImageWorkflowState.clear_image,
disabled=ImageWorkflowState.processing,
variant="ghost",
),
spacing="4",
align_items="stretch",
width="100%",
max_width="32rem",
padding=["1rem", "1.5rem"],
border_radius="12px",
border=f"1px solid {rx.color('gray', 5)}",
background=rx.color("gray", 1),
)ExpandCollapse
app = rx.App()
app.add_page(image_workflow, route="/")Run uv run reflex run, select an image, then choose Create preview. The generated image and Download PNG control appear together. Try another image, a malformed file, and Clear image to check the complete interaction.
The original file is not copied into the public upload directory. This small example sends a bounded preview to the current browser session and keeps download bytes in a backend-only variable. Clearing the image removes the application state; it does not erase an already downloaded copy. Configure request-size limits at your server or proxy as well: the handler's bounded read happens after the request reaches the upload endpoint.
The image function is independent of the interface. You can add crop controls, prediction labels, a review form, or a gallery without replacing the upload/event/result pattern. For larger files and long-running inference, use appropriately authorized storage and a worker service rather than sending large data URLs through state.
Run an audio input and output workflow
This audio example accepts a short WAV file, runs a Python processing function, plays the result in the browser, and offers a WAV download. It uses Reflex's upload component, a native HTML audio player, and per-session state. No external service or additional Python audio package is required.
The example performs peak normalization: it scales all samples by the same factor so the loudest sample reaches about 80% of the 16-bit range. Stereo channels keep their relative levels; silence stays silent. This is audio preprocessing, not speech recognition, noise removal, or a loudness standard. A speech model can replace the processing function while keeping the surrounding input and result workflow.
Copy the code into a blank app module. It accepts uncompressed 16-bit mono or stereo PCM WAV at 8-48 kHz, up to 10 seconds and 512 KiB. MP3, compressed WAV, and other sample widths are rejected. Python's wave module reads the WAV header; the function also checks the decoded frame count before processing.
import asyncio
import base64
import sys
import wave
from array import array
from io import BytesIO
import reflex as rx
MAX_AUDIO_BYTES = 512 * 1024
AUDIO_UPLOAD_ID = "audio-workflow-upload"
def normalize_audio(data: bytes) -> tuple[bytes, str]:
"""Normalize the peak of a short 16-bit PCM WAV without changing its timing.
Args:
data: Uploaded WAV bytes, limited to 512 KiB.
Returns:
A new WAV file and a description of its duration, channels, and gain.
Raises:
ValueError: The upload is malformed or outside the supported limits.
"""
if len(data) > MAX_AUDIO_BYTES:
raise ValueError("Choose a WAV file no larger than 512 KiB.")
try:
with wave.open(BytesIO(data), "rb") as source:
channels = source.getnchannels()
rate = source.getframerate()
frames = source.getnframes()
if source.getsampwidth() != 2 or channels not in (1, 2):
raise ValueError("Choose a 16-bit mono or stereo PCM WAV.")
if not 8000 <= rate <= 48000 or not 0 < frames <= rate * 10:
raise ValueError(
"Use 8-48 kHz audio lasting more than 0 and at most 10 seconds."
)
pcm = source.readframes(frames + 1)
if len(pcm) != frames * channels * 2:
raise ValueError("The WAV file is incomplete.")
except (wave.Error, EOFError, RuntimeError) as error:
raise ValueError("Choose a valid uncompressed PCM WAV file.") from error
samples = array("h", pcm)
if sys.byteorder != "little":
samples.byteswap()
peak = max(abs(sample) for sample in samples)
gain = 26213 / peak if peak else 1.0
normalized = array("h", (round(sample * gain) for sample in samples))
if sys.byteorder != "little":
normalized.byteswap()
output = BytesIO()
with wave.open(output, "wb") as result:
result.setnchannels(channels)
result.setsampwidth(2)
result.setframerate(rate)
result.writeframes(normalized.tobytes())
summary = f"{frames / rate:.2f} seconds · {channels} channel(s) · {gain:.2f}x gain"
return output.getvalue(), summary
class AudioWorkflowState(rx.State):
"""Keep one small processed recording in the current session."""
preview: str = ""
summary: str = ""
error: str = ""
processing: bool = False
_output_wav: bytes = b""
@rx.event
async def process_audio(self, files: list[rx.UploadFile]):
"""Validate an uploaded WAV and prepare its normalized playback.
Args:
files: Files selected by the upload component.
Yields:
An update displaying the processing state before reading the file.
"""
if self.processing:
return
self.preview = self.summary = self.error = ""
self._output_wav = b""
if len(files) != 1:
self.error = "Choose one WAV file."
return
self.processing = True
yield
try:
data = await files[0].read(MAX_AUDIO_BYTES + 1)
wav, summary = await asyncio.to_thread(normalize_audio, data)
self._output_wav = wav
self.preview = "data:audio/wav;base64," + base64.b64encode(wav).decode(
"ascii"
)
self.summary = summary
except ValueError as error:
self.error = str(error)
except OSError:
self.error = "The recording could not be read. Please try again."
finally:
self.processing = False
@rx.event
def download_audio(self):
"""Download this session's generated WAV.
Returns:
A download event when a result is available, otherwise None.
"""
if self._output_wav:
return rx.download(
data=self._output_wav, filename="normalized.wav", mime_type="audio/wav"
)
@rx.event
def clear_audio(self):
"""Discard the result and reset the file selector when idle.
Returns:
A file-selector reset event when idle, otherwise None.
"""
if self.processing:
return
self.preview = self.summary = self.error = ""
self._output_wav = b""
return rx.clear_selected_files(AUDIO_UPLOAD_ID)
def audio_workflow() -> rx.Component:
"""Render a custom audio upload and playback card.
Returns:
The upload, result player, and download controls.
"""
return rx.vstack(
rx.hstack(
rx.icon("audio-lines", size=22, color=rx.color("violet", 9)),
rx.heading("Audio preparation", size="5", as_="h3"),
spacing="3",
align="center",
),
rx.text(
"Normalize a short recording, listen to the result, and download the WAV.",
size="2",
color=rx.color("gray", 11),
),
rx.upload(
rx.vstack(
rx.icon("upload", size=24),
rx.text("Drop a WAV here or choose a file", size="2", weight="medium"),
rx.text("16-bit PCM · up to 10 seconds and 512 KiB", size="1"),
rx.foreach(
rx.selected_files(AUDIO_UPLOAD_ID),
lambda name: rx.text(name, size="2", overflow_wrap="anywhere"),
),
spacing="2",
width="100%",
),
id=AUDIO_UPLOAD_ID,
accept={"audio/wav": [".wav"]},
multiple=False,
disabled=AudioWorkflowState.processing,
border=f"1px dashed {rx.color('gray', 7)}",
border_radius="8px",
padding="1.5rem",
width="100%",
),
rx.button(
"Normalize recording",
on_click=AudioWorkflowState.process_audio(
rx.upload_files(upload_id=AUDIO_UPLOAD_ID)
),
loading=AudioWorkflowState.processing,
disabled=rx.selected_files(AUDIO_UPLOAD_ID).length() == 0,
size="3",
width="100%",
),
rx.cond(
AudioWorkflowState.error != "",
rx.text(
AudioWorkflowState.error,
role="alert",
size="2",
color=rx.color("red", 11),
),
),
rx.cond(
AudioWorkflowState.preview != "",
rx.vstack(
rx.text("Processed recording", size="2", weight="medium"),
rx.el.audio(
src=AudioWorkflowState.preview,
controls=True,
preload="metadata",
aria_label="Normalized recording",
width="100%",
),
rx.text(AudioWorkflowState.summary, role="status", size="2"),
rx.button(
"Download WAV",
on_click=AudioWorkflowState.download_audio,
variant="soft",
),
spacing="3",
align_items="stretch",
width="100%",
padding="1rem",
border_radius="8px",
background=rx.color("gray", 3),
),
),
rx.button(
"Clear recording",
on_click=AudioWorkflowState.clear_audio,
disabled=AudioWorkflowState.processing,
variant="ghost",
),
spacing="4",
align_items="stretch",
width="100%",
max_width="32rem",
padding=["1rem", "1.5rem"],
border_radius="12px",
border=f"1px solid {rx.color('gray', 5)}",
background=rx.color("gray", 1),
)ExpandCollapse
app = rx.App()
app.add_page(audio_workflow, route="/")Run uv run reflex run, select a WAV file, and choose Normalize recording. Use the player to listen when ready; playback does not start automatically. Download WAV saves the processed recording. An invalid replacement clears the previous result and shows an error; Clear recording resets the result and file selector.
The worker thread keeps the bounded sample-processing function off the event loop. The original recording is not written to the public upload directory. The small processed WAV travels to the current browser session as a data URL and stays available in a backend-only variable for download. Clearing state does not erase copies already received by the browser or downloaded. Configure server or proxy request-size limits too, since the bounded read occurs after upload handling begins. Use authorized storage and a worker service for larger or longer-running jobs.
This example receives a file; it does not record a microphone or run a speech model. For microphone capture, use a verified recording component or browser integration. For model calls, add timeouts, cancellation, and service-specific validation as described in performance and execution.
Add video playback and media capture
The file input's accepted extensions are a browser convenience, not content validation. Restrict request sizes at the server or proxy as well as validating in the handler. Do not derive a filesystem destination directly from an uploaded filename.
The upload directory is for publicly served files; it is not private per-user storage. Keep sensitive artifacts in storage with an authorization check or an appropriately scoped signed URL. Do not put a secret into an output URL that is sent to every client.
Displaying audio or video is distinct from capturing a microphone or camera stream. For a capture workflow, choose and verify the component or integration that exposes the required browser API. Use the React wrapping guide when integrating a component that is not already wrapped.
Check the complete workflow
Test valid and invalid input, oversized or malformed files, model failure, repeated submission, and access to another user's output. Check that expired artifacts no longer resolve through the intended delivery mechanism. For streaming text output, continue with AI applications and the chat tutorial.