Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
.git
__pycache__
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -132,3 +132,6 @@ dmypy.json

# Pyre type checker
.pyre/

# Docker volumes
docker/certs
32 changes: 32 additions & 0 deletions docker/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
FROM python:3.13-slim

WORKDIR /build
COPY . aiko_services

WORKDIR /app
ENV DEBIAN_FRONTEND=noninteractive
RUN set -eux ; \
cp -v /build/aiko_services/docker/files/* . ; \
apt-get update ; \
apt-get -y install git libgl1 libglib2.0-0 ; \
pip install uv ; \
uv pip install --system flask flask-socketio gunicorn ; \
uv pip install --system -e /build/aiko_services ; \
apt-get -y autoremove ; \
apt-get -y clean ; \
rm -rf /root/.cache \
/var/lib/apt/lists/* ; \
find /build -depth -name __pycache__ -type d | xargs rm -rf ;

# NOTE(nic): Set up and "dry-fire" the app to run the Ultralytics auto-update
# This burns an image layer, but it also serves as a build sanity-check,
# as well as trying/helping to minimize container startup overhead
RUN set -eux ; \
DEFINITION_PATHNAME=/build/aiko_services/src/aiko_services/examples/colab/pipelines/colab_pipeline_2.json \
python -B -c "from app import app; app.app_context().push()" ; \
rm -rf __pycache__ /root/.cache ;

EXPOSE 5000

ENV GUNICORN_CMD_ARGS="--worker-class=gthread --workers=1 --threads=1024 --bind=0.0.0.0:5000 --log-level=debug --access-logfile=-"
CMD ["gunicorn", "app:app"]
18 changes: 18 additions & 0 deletions docker/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Docker Image

This directory contains resources for building and managing Aiko
Services in a software container, which can provide a more frictionless
experience when standing up a development environment, as well as
better isolation/sandboxing

## Usage
```bash
cd docker
docker compose up
```

Access the web interface at https://localhost:5000

The JSON files under `src/aiko_services/examples` should be visible
inside the container, and updates to anything in that directory
should propagate into the container immediately
100 changes: 100 additions & 0 deletions docker/docker-compose.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---
services:
aiko:
build:
context: ../
dockerfile: docker/Dockerfile
depends_on:
mqtt:
condition: service_started
restart: on-failure
expose:
- "5000"
volumes:
- ../src/aiko_services/examples:/app/examples
environment:
- AIKO_MQTT_HOST=mqtt
- USER=${USER}
- DEFINITION_PATHNAME=examples/colab/pipelines/colab_pipeline_2.json
# NOTE(nic): if desired, one can forego Gunicorn and just run the Flask
# debug server by uncommenting this:
# command: [ python3, app.py ]

mqtt:
image: eclipse-mosquitto:latest
restart: unless-stopped
expose:
- "1883"
configs:
- source: mosquitto_config
target: /mosquitto/config/mosquitto.conf

# NOTE(nic): This is a simple SSL termination proxy for people that may
# want to use navigator.mediaDevices.getUserMedia() non-locally, and that
# requires HTTPS. It generates a self-signed cert on startup, and will
# automagically regenerate them if they "go bad"
nginx:
image: nginx:stable
depends_on:
cert-generator:
condition: service_completed_successfully
aiko:
condition: service_started
restart: unless-stopped
ports:
- "5000:8080"
configs:
- source: nginx_config
target: /etc/nginx/nginx.conf
volumes:
- ./certs:/etc/nginx/certs:ro
- ./files/index.html:/usr/share/nginx/html/index.html:ro

cert-generator:
image: nginx:stable
volumes:
- ./certs:/certs
entrypoint: |
sh -c "
check_cert_validity() {
openssl x509 -in /certs/cert.pem -noout -checkend 86400 || return 1
openssl rsa -in /certs/key.pem -check -noout || return 1
openssl x509 -in /certs/cert.pem -noout || return 1
echo 'Valid certs found, using...'
return 0
}
check_cert_validity 2>/dev/null && exit 0
echo 'Certificates invalid, expired, or missing. Generating...'
rm -f /certs/cert.pem /certs/key.pem
openssl req -x509 -newkey rsa:4096 -nodes -keyout /certs/key.pem -out /certs/cert.pem -days 365 -subj '/CN=localhost'
"

configs:
mosquitto_config:
content: |
listener 1883
allow_anonymous true

nginx_config:
content: |
events { worker_connections 1024; }
http {
upstream aiko { server aiko:5000; }
server {
listen 8080 ssl;
ssl_certificate /etc/nginx/certs/cert.pem;
ssl_certificate_key /etc/nginx/certs/key.pem;
location / {
root /usr/share/nginx/html;
index index.html;
}
location /socket.io/ {
proxy_pass http://aiko;
proxy_http_version 1.1;
proxy_set_header Upgrade $$http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $$host;
proxy_set_header X-Real-IP $$remote_addr;
}
}
}
99 changes: 99 additions & 0 deletions docker/files/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import logging
import os
from base64 import b64decode, b64encode
from io import BytesIO
from queue import Queue
from threading import Thread

import aiko_services as aiko
from aiko_services.elements.media import convert_image
from flask import Flask, send_from_directory
from flask_socketio import SocketIO, emit
from PIL import Image

app = Flask(__name__)
socketio = SocketIO(app, cors_allowed_origins="*")

definition_pathname = os.environ.get("DEFINITION_PATHNAME", "examples/colab/pipelines/colab_pipeline_2.json")
frame_data = None
graph_path = None
grace_time = 3 * 60 * 60
name = None
parameters = {}
stream_id = "1"

pipeline_definition = aiko.PipelineImpl.parse_pipeline_definition(definition_pathname)

app.config["FRAME_ID"] = 0
app.config["QUEUE_RESPONSE"] = Queue()
app.config["PIPELINE"] = aiko.PipelineImpl.create_pipeline(
definition_pathname,
pipeline_definition,
name,
graph_path,
stream_id,
parameters,
app.config["FRAME_ID"],
frame_data,
grace_time=grace_time,
queue_response=app.config["QUEUE_RESPONSE"],
)

thread = Thread(target=app.config["PIPELINE"].run, args=(False,))
thread.daemon = True
thread.start()


def do_create_frame(image_in_pil=None):
if image_in_pil is None:
image_in_pil = Image.new("RGB", (320, 240), "black")
image_in_numpy = convert_image(image_in_pil, "numpy")

stream_in = {"stream_id": stream_id, "frame_id": app.config["FRAME_ID"]}
frame_data_in = {"images": [image_in_numpy]}
app.config["PIPELINE"].create_frame(stream_in, frame_data_in)
app.config["FRAME_ID"] += 1

response = app.config["QUEUE_RESPONSE"].get()
stream_out, frame_data_out = response
stream_frame_ids = f"<{stream_out['stream_id']}:{stream_out['frame_id']}>"
image_out_numpy = frame_data_out["images"][0]
image_out_pil = convert_image(image_out_numpy, "pil")
app.logger.debug("Processed frame %s", stream_frame_ids)
return stream_frame_ids, image_out_pil


@socketio.on("process_frame")
def handle_frame_socket(data):
"""
WebSocket handler for frame processing
"""
data_url = data["frame"]

header, encoded = data_url.split(",", 1)
image_bytes = b64decode(encoded)
image = Image.open(BytesIO(image_bytes)).convert("RGB")

stream_frame_ids, new_image = do_create_frame(image)

buffer = BytesIO()
new_image.save(buffer, format="JPEG")
new_image_b64 = b64encode(buffer.getvalue()).decode("utf-8")
new_image_data_url = "data:image/jpeg;base64," + new_image_b64

emit("processed_frame", {"data_url": new_image_data_url})


@app.route("/")
def index():
return send_from_directory(".", "index.html")


if __name__ == "__main__":
# Flask built-in webserver
socketio.run(app, host="0.0.0.0", port=5000, debug=True, allow_unsafe_werkzeug=True)
else:
# Fixup logging if running under Gunicorn
gunicorn_logger = logging.getLogger("gunicorn.error")
app.logger.handlers = gunicorn_logger.handlers
app.logger.setLevel(gunicorn_logger.level)
122 changes: 122 additions & 0 deletions docker/files/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<!DOCTYPE html>
<html>
<head>
<title>Pipeline Video Stream</title>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
margin: 20px;
font-family: monospace;
}
.canvas-row {
display: flex;
flex-direction: row;
gap: 8px;
}
.labels-row {
display: flex;
flex-direction: row;
justify-content: space-between;
width: 100%;
font-size: 12px;
max-width: 656px;
}
canvas {
border: 1px solid #ccc;
}
button {
padding: 8px 16px;
font-family: monospace;
}
</style>
</head>
<body>
<h2>Pipeline Video Stream</h2>
<div class="labels-row">
<div>Original (320x240)</div>
<div>Processed (Pipeline)</div>
</div>
<div class="canvas-row">
<canvas id="inputCanvas" width="320" height="240"></canvas>
<canvas id="outputCanvas" width="320" height="240"></canvas>
</div>
<div>
<button id="startButton">Start Stream</button>
<button id="stopButton" disabled>Stop Stream</button>
</div>

<script src="https://cdn.socket.io/4.5.4/socket.io.min.js"></script>
<script>
const inputCanvas = document.getElementById('inputCanvas');
const outputCanvas = document.getElementById('outputCanvas');
const ctxIn = inputCanvas.getContext('2d');
const ctxOut = outputCanvas.getContext('2d');
const startButton = document.getElementById('startButton');
const stopButton = document.getElementById('stopButton');

const JPEG_QUALITY = 0.8;
const FRAME_INTERVAL_MS = 500;

let video = null;
let stream = null;
let running = false;
let socket = null;

startButton.onclick = async () => {
if (running) return;

socket = io();

video = document.createElement('video');
stream = await navigator.mediaDevices.getUserMedia({
video: { width: 320, height: 240 }
});
video.srcObject = stream;
await video.play();

running = true;
startButton.disabled = true;
stopButton.disabled = false;

socket.on('processed_frame', (data) => {
const image = new Image();
image.onload = () => {
ctxOut.clearRect(0, 0, outputCanvas.width, outputCanvas.height);
ctxOut.drawImage(image, 0, 0, outputCanvas.width, outputCanvas.height);
};
image.src = data.data_url;
});

processFrame();
};

stopButton.onclick = () => {
running = false;
if (stream) {
stream.getVideoTracks().forEach(t => t.stop());
}
if (socket) {
socket.disconnect();
}
ctxIn.clearRect(0, 0, inputCanvas.width, inputCanvas.height);
ctxOut.clearRect(0, 0, outputCanvas.width, outputCanvas.height);
startButton.disabled = false;
stopButton.disabled = true;
};

async function processFrame() {
if (!running) return;

ctxIn.drawImage(video, 0, 0, inputCanvas.width, inputCanvas.height);
const dataUrl = inputCanvas.toDataURL('image/jpeg', JPEG_QUALITY);

socket.emit('process_frame', { frame: dataUrl });

setTimeout(processFrame, FRAME_INTERVAL_MS);
}
</script>
</body>
</html>
Loading