# Project export: diffuji

This document was generated by HackStack to give an AI agent context about a hackathon project. Sections are labeled with their provenance; content marked as truncated was cut to keep this document small.

## Project metadata

- Hackathon: TreeHacks 2026
- Tagline: a diffusion-powered instant camera
- Devpost: https://devpost.com/software/diffuji
- GitHub: http://github.com/alexkranias/dispo
- Demo: https://diffuji.com/
- Video: https://www.youtube.com/embed/JiRth1HCIag?enablejsapi=1&hl=en_US&rel=0&start=&version=3&wmode=transparent
- Result: winner (Most Creative; [Neo] Most Likely to Become a Product)
- Team: 3 GitHub contributor(s) — Alex Kranias (19 commits), whackamadoodle3000 (2 commits), Nathan Barry (1 commits)

## Devpost submission (written by the team)

### Inspiration

The world needs a bit more silliness. Instant cams are fun, and there’s something special about memorializing a moment through physical media. But what if these moments were … part dream, part hallucination of reality? Introducing Diffuji - a diffusion-powered instant camera that turns half-real, half-dreamed moments into physical prints.

### What it does

You can take a picture and choose from a range of filters to apply to your image. However, these filters aren’t your typical color grades, but can completely reimagine the context the photo was taken in - whether turning back time to the 19th century, having the best six-pack you’ve never had, or simply becoming a duck. These filters process the captured image using image-to-image diffusion models, granting us creativity to transform the image however we wish. The picture is subsequently printed using a thermal printer onto receipt paper / stickers, which allows for inkless printing. Additionally, a teammate’s experience working at a thrift store led to another feature: the ability to take a pic of an object and use perplexity to quickly search the internet for competitive prices for it, and print a reference-backed price for it.

### How we built it

Hardware Parts Raspberry pi 2w for its low power draw, wifi capabilities, decent memory (512mb), and low cost ($15). Also with a arducam Cheap TTL Aliexpress thermal printer along with sticker thermal paper Rotary encoder for switching modes, push button for our shutter, and tactile switch for our power switch I2C OLED display for display our modes and settings 2 18650 batteries and a 3amp 5v UPS supply, enabling good battery life and regulation. We also add a 1000uF capacity to guard against current spikes from the printer. We designed a shell in fusion 360 and printed it out at the treehacks makerspace. We soldered our components together and hotglued and screwed them into our shell. Software On device We have a python script that manages inputs, displays animations and state on the screen, and prints pictures. We experimented with lots of dithering algorithms to convert images into a bitmap that looks aesthetic on the sticker. We settled on ordered bayer dithering with custom gamma / contrast adjustments. The script either processes the images on device or sends them to the cloud depending on the mode. Cloud We developed our own public API server for the camera that is hosted on Railway. This is the interface for how the camera learns what modes it has access to, server that processes images and applies diffusion to them using various APIs for the different modes, along with custom prompting to ensure alignment with the mode. The client can select which type of model and API to use for the image generation. It has access to the following: Gemini / Google Cloud We used Gemini 2.5 Flash Image as our primary provider, powering most of our filters, from reimagining scenes to be in 1846 to turning everyone into ducks or giving everyone huge muscles. OpenAI We used gpt-image-1.5 via the Images Edit API for our Studio Ghibli filter, which we found produced more faithful style transfers for this style. We also used it for our Sam mode, which merges a Sam Altman into a scene, as it was the best at composing people naturally. Modal We hosted Black Forest Labs' FLUX.1-Kontext-dev on an H100 GPU through Modal's serverless infra, giving us our own diffusion pipeline with 20-step inference at bfloat16 precision using an open-sourced model.

### Challenges we ran into

We had jamming issues where the paper would bunch up. We found that by peeling back some of the sticker, it helped We had issues prompting the model to consistently apply a style. It turns out when you give a diffusion model the image upside down they suck - must be too out of distribution. On device diffusion takes forever when you only have 512MB ram.

### Accomplishments we're proud of

We made a very sleek and functional product, which was so fun to play with. We hope everyone at the hackathon enjoyed getting custom sticker prints!

### What we learned

We learned about inference platforms for extremely low ram system Prompt engineering lol Dithering / good image processing thermal printers are cool Design Glue is permanent

### What's next

Continuing to make progress on performance for on-device diffusion. Mass produce?? more modes

## README (from the GitHub repository)

# Diffuji

A diffusion-powered instant camera that turns half-real, half-dreamed moments into physical prints.

Built at **TreeHacks 2026**.

## Inspiration

The world needs a bit more silliness. Instant cameras are fun, and there's something special about memorializing a moment through physical media. But what if these moments were part dream, part hallucination of reality?

**Diffuji** captures a photo and reimagines it through image-to-image diffusion models, then prints the result on thermal sticker paper — no ink required.

## What It Does

Take a picture and choose from a range of filters. These aren't your typical color grades — they can completely reimagine the context the photo was taken in:

| Mode | Description |
|------|-------------|
| `ghibli` | Studio Ghibli anime style |
| `greek` | Classical Greek marble statues |
| `duck` | Replace all people with ducks |
| `gpu` | PS2-era low-poly 3D graphics |
| `thinker` | Rodin's "The Thinker" sculpture |
| `1846` | Time-travel to 1846 |
| `1922` | Time-travel to 1922 |
| `1955` | Time-travel to 1955 |
| `1984` | Time-travel to 1984 |
| `1999` | Time-travel to 1999 |
| `business_card` | Extract info into a business card layout |
| `pricing` | Estimate retail prices of visible items |

The **pricing** mode was inspired by a teammate's experience working at a thrift store — snap a pic of an object and use Perplexity to search the internet for competitive prices, then print a reference-backed price tag.

## How We Built It

### Hardware

| Component | Purpose |
|-----------|---------|
| Raspberry Pi Zero 2W | Low power draw, WiFi, 512 MB RAM, $15 |
| Arducam camera module | Image capture |
| TTL thermal printer | Inkless printing onto sticker paper |
| Rotary encoder | Switching between modes |
| Push button | Shutter |
| Tactile switch | Power |
| I2C OLED display | Displaying modes and settings |
| 2x 18650 batteries + 3A 5V UPS | Power supply with voltage regulation |
| 1000 µF capacitor | Guards against current spikes from the printer |

The shell was designed in Fusion 360 and 3D-printed at the TreeHacks makerspace. Components were soldered together and mounted with hot glue and screws.

### Software

#### On-Device (`camera/`)

A Python script running on the Pi that:

- Manages hardware inputs (rotary encoder, shutter button, power switch)
- Displays animations and state on the OLED screen
- Captures images and either processes them locally or sends them to the cloud
- Dithers images for thermal printing using **ordered Bayer dithering** with custom gamma and contrast adjustments
- Prints the final result

#### Cloud API (`dispoapi/`)

A FastAPI server hosted on [Railway](https://railway.app) that serves as the camera's brain:

- Provides the camera with its available modes
- Routes images to AI providers for diffusion-based transformation
- Returns processed images as base64

**AI Providers:**

| Provider | Description |
|----------|-------------|
| OpenAI | GPT-image-1 via Responses API |
| Gemini | Google Gemini 2.5 Flash image generation |
| Modal | Flux Kontext on serverless H100 GPU |
| Perplexity | Sonar API for text-based search and pricing |

### Architecture

```
┌─────────────────────┐         ┌──────────────┐         ┌─────────────┐
│  Raspberry Pi Zero  │  POST   │   DispoAPI   │  route  │ AI Provider │
│  - Arducam          │ ──────> │  (Railway)   │ ──────> │ OpenAI /    │
│  - OLED display     │ <────── │              │ <────── │ Gemini /    │
│  - Rotary encoder   │  base64 │              │  image  │ Modal /     │
│  - Thermal printer  │         └──────────────┘         │ Perplexity  │
└─────────────────────┘                                  └─────────────┘
         │
         v
   ┌───────────┐
   │  Thermal  │
   │  Sticker  │
   │  Print    │
   └───────────┘
```

## Challenges We Ran Into

- **Paper jamming** — the sticker paper would bunch up inside the printer. We found that peeling back some of the sticker backing before feeding it helped.
- **Inconsistent style transfer** — prompting the diffusion model to consistently apply a style was tricky. We also discovered that feeding images upside-down produces terrible results (too far out of distribution).
- **On-device diffusion** — running diffusion on a device with only 512 MB of RAM is painfully slow, pushing us toward the cloud API approach.

## Accomplishments We're Proud Of

We built a sleek, functional product that was genuinely fun to use. We hope everyone at the hackathon enjoyed getting custom sticker prints!

## What We Learned

- Dithering algorithms and how to make images look aesthetic on thermal paper
- Inference strategies for extremely low-RAM systems
- Hardware integration with the Raspberry Pi ecosystem (I2C, TTL serial, GPIO)
- Prompting diffusion models for consistent style transfer

## What's Next

**On-device diffusion.** With emerging lightweight diffusion architectures and quantization techniques, the dream is to run the full pipeline on the Pi itself — no cloud required.

## Project Structure

```
dispo/
├── camera/             # On-device Python scripts (Pi Zero 2W)
│   └── main1.py        # Hardware control, capture, dither, print
├── dispoapi/           # Cloud API server
│   ├── main.py         # FastAPI app, endpoints, AI provider routing
│   ├── modal_app.py    # Modal deployment — Flux Kontext on H100
│   ├── prompts.py      # Mode-to-prompt mapping
│   ├── test_api.py     # Test script
│   └── pyproject.toml  # Dependencies
└── README.md
```

## Getting Started

See [`dispoapi/README.md`](dispoapi/README.md) for API setup, configuration, and usage instructions.


## Detected evidence (automated analysis)

Indexed codebase: 8 recognized source files, 85 KB.
- OpenAI (technology) — detected in the code
- Python (language) — detected in the code

## Codebase structure (from repository index)

### Files (13 of 13)

```
camera/main.py
camera/main1.py
dispoapi/.gitignore
dispoapi/.python-version
dispoapi/main.py
dispoapi/modal_app.py
dispoapi/Procfile
dispoapi/prompts.py
dispoapi/pyproject.toml
dispoapi/README.md
dispoapi/test_api.py
dispoapi/uv.lock
README.md
```

### Dependencies

- dispoapi/pyproject.toml: fastapi[standard]@>=0.129.0, google-genai@>=1.0.0, httpx@>=0.27.0, modal@>=0.73.0, openai@>=1.30.0, pillow@>=12.1.1, python-dotenv@>=1.2.1

### Recent commits (newest first)

- Update event year from 2025 to 2026 in README
- update readme
- valentines prompt
- update
- muscle
- fix issues hopefully
- save
- Merge remote-tracking branch 'origin/main'
- new sam
- Merge branch 'main' of github.com:alexkranias/dispo
- mommy
- use gemini for sam
- add sam mode
- update
- tree mode
- business card mode
- perplexity updates
- better prompt
- new business card
- business card

## Key source files (fetched from GitHub, selected and truncated for size)

### dispoapi/pyproject.toml

```
[project]
name = "dispoapi"
version = "0.1.0"
description = "Add your description here"
readme = "README.md"
requires-python = ">=3.10"
dependencies = [
    "fastapi[standard]>=0.129.0",
    "pillow>=12.1.1",
    "python-dotenv>=1.2.1",
    "openai>=1.30.0",
    "google-genai>=1.0.0",
    "httpx>=0.27.0",
    "modal>=0.73.0",
]

```

### camera/main.py

```python
#!/usr/bin/env python3
import base64
import datetime
import io
import os
import subprocess
import time

import requests
from escpos.printer import Serial
from gpiozero import Button, RotaryEncoder
from luma.core.interface.serial import i2c
from luma.core.render import canvas
from luma.oled.device import ssd1306
from PIL import Image, ImageDraw, ImageEnhance, ImageFont, ImageOps

# ========== CONFIGURATION ==========

# API Settings
API_BASE_URL = "https://web-production-95a4f.up.railway.app"

# Hardware Pins
PIN_SHUTTER = 4  # The Encoder Switch (SW)
PIN_ENC_CLK = 23  # Encoder CLK
PIN_ENC_DT = 27  # Encoder DT

# I2C Display (128x64 OLED)
I2C_PORT = 1  # GPIO 2/3 is I2C port 1 on Pi Zero 2W
I2C_ADDRESS = 0x3C  # Default I2C address for most 128x64 OLEDs
# Run 'i2cdetect -y 1' to find your display's address if 0x3C doesn't work

# Printer Settings
SERIAL_PORT = "/dev/serial0"
BAUD_RATE = 9600
PRINTER_WIDTH = 384

# Camera Settings
CAM_WIDTH = 1024
CAM_HEIGHT = 768

# Image Processing - Test 2 Settings
CONTRAST = 1.65  # Slightly increased for darker shadows
BRIGHTNESS = 0.85  # Reverted
SHARPNESS = 1.3
GAMMA = 0.7  # Lower gamma makes blacks darker (was 0.8)
DARKNESS = 220  # Increased for darker thermal printing
LINE_DELAY = 0.02

# Caption Settings - Matching test header size
FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
FONT_SIZE = 20  # Larger to match test headers

# ===================================


class ThermalCam:
    def __init__(self):
        self.current_mode_index = 0

        # Setup OLED Display first
        try:
            serial = i2c(port=I2C_PORT, address=I2C_ADDRESS)
            self.display = ssd1306(serial)
            print("✓ OLED Display connected")
        except Exception as e:
            print(f"Display Error: {e}")
            self.display = None

        self.display_status("STARTING...")

        # Fetch modes from API
        try:
            print("Fetching modes from API...")
            self.display_status("LOADING MODES...")
            resp = requests.get(f"{API_BASE_URL}/modes", timeout=5)
            resp.raise_for_status()
            data = resp.json()
            self.modes = data["modes"]
            print(f"Modes loaded: {self.modes}")
        except Exception as e:
            print(f"API unavailable ({e}), falling back to Standard mode")
            self.modes = ["Standard"]

        # Setup Printer
        try:
            self.printer = Serial(devfile=SERIAL_PORT, baudrate=BAUD_RATE)
        except Exception as e:
            print(f"Printer Error: {e}")

        # Setup GPIO
        # bounce_time=0.1 prevents "spamming" (debouncing)
        self.shutter = Button(PIN_SHUTTER, pull_up=True, bounce_time=0.1)
        self.encoder = RotaryEncoder(PIN_ENC_CLK, PIN_ENC_DT)

        # Connect Events
        self.shutter.when_pressed = self.handle_shutter_press  # Show preview
        self.shutter.when_released = self.handle_shutter_release  # Take photo
        self.encoder.when_rotated = self.handle_dial

        print(f"System Ready. Mode: {self.modes[self.current_mode_index]}")
        self.display_mode()

    def animate_progress(self, text="LOADING"):
        """Animated progress bar for all loading states"""
        if not self.display:
            return

        try:
            for i in range(0, 101, 20):
                with canvas(self.display) as draw:
                    # Title
                    w = len(text) * 6
                    x = (128 - w) // 2
                    draw.text((x, 20), text, fill="white")

                    # Progress bar
                    bar_width = 100
                    bar_x = (128 - bar_width) // 2
                    bar_y = 38

                    # Outline
                    draw.rectangle(
                        (bar_x, bar_y, bar_x + bar_width, bar_y + 10),
                        outline="white",
                        fill="black",
                    )

                    # Fill
                    fill_width = int(bar_width * i / 100)
                    if fill_width > 0:
                        draw.rectangle(
                            (bar_x + 1, bar_y + 1, bar_x + fill_width - 1, bar_y + 9),
                            fill="white",
                        )

                time.sleep(0.1)
        except:
            pass

    def display_status(self, status_text, large=False):
        """Display a status message on OLED"""
        if not self.display:
            return

        try:
            with canvas(self.display) as draw:
                if large:
                    # Large centered text for important messages
                    # Split into multiple lines if needed
                    words = status_text.split()
                    lines = []
                    current_line = ""

                    for word in words:
                        test_line = current_line + " " + word if current_line else word
                        # Rough estimate: 10 chars per line for large text
                        if len(test_line) <= 10:
                            current_line = test_line
                        else:
                            if current_line:
                                lines.append(current_line)
                            current_line = word
                    if current_line:
                        lines.append(current_line)

                    # Draw centered lines
                    y_start = 32 - (len(lines) * 8)
                    for i, line in enumerate(lines):
                        # Center each line
                        w = len(line) * 6  # Rough character width
                        x = (128 - w) // 2
                        draw.text((x, y_start + i * 16), line, fill="white")
                else:
                    # Normal centered text
                    w = len(status_text) * 6  # Rough character width
                    x = (128 - w) // 2
                    draw.text((x, 28), status_text, fill
[truncated — 15160 more characters]
```

### dispoapi/main.py

```python
"""
DispoAPI — FastAPI server for Dispo Camera image filters.

Stateless: image in -> AI transform -> image out.
"""

import base64
import io
import logging
import os
import re

import httpx
from dotenv import load_dotenv
from fastapi import FastAPI, File, Form, HTTPException, UploadFile
from fastapi.responses import JSONResponse
from google import genai
from google.genai import types as genai_types
from openai import OpenAI
from pydantic import BaseModel
from PIL import Image, ImageDraw, ImageFont

from prompts import FILTER_MODES, MODE_PROMPTS, MODE_PROVIDERS, SEARCH_MODES, VALID_MODES

load_dotenv()

log = logging.getLogger("dispoapi")

app = FastAPI(
    title="DispoAPI",
    description="Image filter API for Dispo Camera",
    version="0.1.0",
)

# ---------------------------------------------------------------------------
# Clients (initialized lazily from env vars)
# ---------------------------------------------------------------------------

_openai_client: OpenAI | None = None
_gemini_client: genai.Client | None = None


def _get_openai_client() -> OpenAI:
    global _openai_client
    if _openai_client is None:
        api_key = os.environ.get("OPENAI_API_KEY")
        if not api_key:
            raise HTTPException(status_code=500, detail="OPENAI_API_KEY not set")
        _openai_client = OpenAI(api_key=api_key)
    return _openai_client


def _get_gemini_client() -> genai.Client:
    global _gemini_client
    if _gemini_client is None:
        api_key = os.environ.get("GEMINI_API_KEY")
        if not api_key:
            raise HTTPException(status_code=500, detail="GEMINI_API_KEY not set")
        _gemini_client = genai.Client(api_key=api_key)
    return _gemini_client


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def encode_image_b64(image_bytes: bytes) -> str:
    """Encode raw image bytes to a base64 string."""
    return base64.b64encode(image_bytes).decode("utf-8")


def decode_image_b64(data: str) -> bytes:
    """Decode a base64 string back to raw image bytes."""
    return base64.b64decode(data)


MAX_DIMENSION = 480


def downscale_image(image_bytes: bytes, max_dim: int = MAX_DIMENSION) -> bytes:
    """Downscale an image so its largest side is at most *max_dim* pixels.

    Preserves aspect ratio. Returns JPEG bytes. If the image is already
    within bounds it is re-encoded without resizing.
    """
    img = Image.open(io.BytesIO(image_bytes))
    img.thumbnail((max_dim, max_dim), Image.Resampling.LANCZOS)
    # Convert to RGB so we can always save as JPEG (handles RGBA, palette, etc.)
    if img.mode not in ("RGB", "L"):
        img = img.convert("RGB")
    buf = io.BytesIO()
    img.save(buf, format="JPEG", quality=90)
    return buf.getvalue()


def render_price_image(title: str, price: str, width: int = 1280, height: int = 720) -> bytes:
    """Render a clean white image with item name and price text using Pillow."""
    img = Image.new("RGB", (width, height), "white")
    draw = ImageDraw.Draw(img)

    # Try to load a nice font, fall back to default
    try:
        font_price = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 280)
        font_title = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", 80)
    except OSError:
        try:
            font_price = ImageFont.truetype("arial.ttf", 280)
            font_title = ImageFont.truetype("arial.ttf", 80)
        except OSError:
            font_price = ImageFont.load_default(size=280)
            font_title = ImageFont.load_default(size=80)

    # Draw price centred
    price_bbox = draw.textbbox((0, 0), price, font=font_price)
    price_w = price_bbox[2] - price_bbox[0]
    price_h = price_bbox[3] - price_bbox[1]
    price_x = (width - price_w) // 2
    price_y = (height - price_h) // 2

    draw.text((price_x, price_y), price, fill="black", font=font_price)

    # Draw item title centred above the price
    if title and title != "N/A":
        title_bbox = draw.textbbox((0, 0), title, font=font_title)
        title_w = title_bbox[2] - title_bbox[0]
        # Truncate if too wide
        while title_w > width - 80 and len(title) > 10:
            title = title[: len(title) - 4] + "…"
            title_bbox = draw.textbbox((0, 0), title, font=font_title)
            title_w = title_bbox[2] - title_bbox[0]
        title_x = (width - title_w) // 2
        title_y = price_y - 90
        draw.text((title_x, title_y), title, fill="#555555", font=font_title)

    buf = io.BytesIO()
    img.save(buf, format="PNG")
    return buf.getvalue()


def get_prompt_for_mode(mode: str) -> str:
    """Look up the prompt for a given mode. Raises 400 if invalid."""
    if mode not in VALID_MODES:
        raise HTTPException(
            status_code=400,
            detail=f"Unknown mode '{mode}'. Valid modes: {sorted(VALID_MODES)}",
        )
    return MODE_PROMPTS[mode]


# ---------------------------------------------------------------------------
# AI provider calls
# ---------------------------------------------------------------------------


async def call_openai(image_bytes: bytes, prompt: str) -> bytes:
    """
    Send image + prompt to OpenAI via the Responses API with the
    image_generation tool (gpt-4o). Returns transformed image bytes.
    """
    client = _get_openai_client()
    b64_input = encode_image_b64(image_bytes)

    response = client.responses.create(
        model="gpt-4o",
        input=[
            {
                "role": "user",
                "content": [
                    {"type": "input_text", "text": prompt},
                    {
                        "type": "input_image",
                        "image_url": f"data:image/jpeg;base64,{b64_input}",
                    },
                ],
            }
        ],
        tools=[{"type": "image_generation", "size": "1024x1024", "quality": "low"}],
    )

    for output in response.output:
   
[truncated — 17160 more characters]
```

### dispoapi/modal_app.py

```python
"""
Modal app — Flux Kontext image-to-image endpoint.

Deploys a web endpoint that accepts {image_b64, prompt} and returns {image_b64}.
Matches the request/response format expected by call_modal() in main.py.

Setup:
  1. Accept the Flux Kontext license: https://huggingface.co/black-forest-labs/FLUX.1-Kontext-dev
  2. Create a Modal secret named "huggingface-secret" with your HF_TOKEN
  3. modal deploy modal_app.py

Usage:
  POST https://<your-workspace>--dispoapi-modal-inference.modal.run
  Body: {"image_b64": "<base64>", "prompt": "..."}
  Response: {"image_b64": "<base64>"}
"""

import base64
from io import BytesIO
from pathlib import Path

import modal
from pydantic import BaseModel


class InferenceRequest(BaseModel):
    image_b64: str
    prompt: str


app = modal.App("dispoapi-modal")

diffusers_commit_sha = "00f95b9755718aabb65456e791b8408526ae6e76"

image = (
    modal.Image.from_registry("nvidia/cuda:12.8.1-devel-ubuntu22.04", add_python="3.12")
    .entrypoint([])
    .apt_install("git")
    .uv_pip_install(
        "Pillow~=11.2.1",
        "accelerate~=1.8.1",
        f"git+https://github.com/huggingface/diffusers.git@{diffusers_commit_sha}",
        "huggingface-hub==0.36.0",
        "optimum-quanto==0.2.7",
        "safetensors==0.5.3",
        "sentencepiece==0.2.0",
        "torch==2.7.1",
        "transformers~=4.53.0",
        "fastapi[standard]",
        "pydantic",
        extra_options="--index-strategy unsafe-best-match",
        extra_index_url="https://download.pytorch.org/whl/cu128",
    )
)

MODEL_NAME = "black-forest-labs/FLUX.1-Kontext-dev"
MODEL_REVISION = "f9fdd1a95e0dfd7653cb0966cda2486745122695"

CACHE_DIR = Path("/cache")
cache_volume = modal.Volume.from_name("hf-hub-cache", create_if_missing=True)

image = image.env({"HF_XET_HIGH_PERFORMANCE": "1", "HF_HOME": str(CACHE_DIR)})

with image.imports():
    import torch
    from diffusers import FluxKontextPipeline
    from diffusers.utils import load_image
    from PIL import Image


@app.cls(
    image=image,
    gpu="H100",
    max_containers=1,
    volumes={CACHE_DIR: cache_volume},
    secrets=[modal.Secret.from_name("huggingface-secret")],
    scaledown_window=300,
)
class Model:
    @modal.enter()
    def load_model(self):
        self.pipe = FluxKontextPipeline.from_pretrained(
            MODEL_NAME,
            revision=MODEL_REVISION,
            torch_dtype=torch.bfloat16,
            cache_dir=CACHE_DIR,
        ).to("cuda")

    @modal.fastapi_endpoint(method="POST")
    def inference(self, body: InferenceRequest):
        image_bytes = base64.b64decode(body.image_b64)
        prompt = body.prompt

        init_image = Image.open(BytesIO(image_bytes)).convert("RGB")
        init_image.thumbnail((1024, 1024))

        result = self.pipe(
            image=init_image,
            prompt=prompt,
            guidance_scale=3.5,
            num_inference_steps=20,
            output_type="pil",
        ).images[0]

        buf = BytesIO()
        result.save(buf, format="PNG")
        result_b64 = base64.b64encode(buf.getvalue()).decode()

        return {"image_b64": result_b64}

```

### dispoapi/test_api.py

```python
"""
Test script — calls the /filter endpoint for every mode using demo.jpg.
Saves each result to data/<mode>.png.

Provider is now determined server-side per mode.

Usage:
    # Start the server first:
    #   uv run uvicorn main:app --reload --port 8000
    #
    # Then run this script (all modes):
    #   uv run python test_api.py
    #
    # Test specific modes:
    #   uv run python test_api.py ghibli duck 1984
"""

import base64
import os
import sys
import time

import httpx
from dotenv import load_dotenv

from prompts import SEARCH_MODES

load_dotenv()

SERVER = os.environ.get("TEST_SERVER", "http://localhost:8000")
IMAGE_PATH = "data/demo.jpg"
OUTPUT_DIR = "data"

# Comment out any modes you don't want to run
ALL_MODES = [
    "greek",
    "ghibli",
    "duck",
    "GPUMODE",
    "thinker",
    "business_card",
    "pricing",
    "1846",
    "1929",
    "1955",
    "1984",
    "1999",
]


def test_mode(mode: str) -> None:
    print(f"\n{'=' * 60}")
    print(f"  Mode: {mode}")
    print(f"{'=' * 60}")

    with open(IMAGE_PATH, "rb") as f:
        image_data = f.read()

    start = time.time()

    with httpx.Client(timeout=600.0) as client:
        resp = client.post(
            f"{SERVER}/filter",
            data={"mode": mode},
            files={"image": ("demo.jpg", image_data, "image/jpeg")},
        )

    elapsed = time.time() - start

    if resp.status_code != 200:
        print(f"  FAILED ({resp.status_code}): {resp.text[:300]}")
        return

    data = resp.json()
    provider = data.get("provider", "unknown")
    print(f"  Status:   {data['status']}")
    print(f"  Provider: {provider}")
    print(f"  Time:     {elapsed:.1f}s")

    if mode in SEARCH_MODES:
        # Save text response for search modes
        text = data.get("text", "")
        print(f"  Text:\n{text}")
        out_path = os.path.join(OUTPUT_DIR, f"{provider}_{mode}.txt")
        with open(out_path, "w") as f:
            f.write(text)
        print(f"  Saved:    {out_path}")

        # Also save the image if one was returned
        img_b64 = data.get("image_b64", "")
        if img_b64:
            img_bytes = base64.b64decode(img_b64)
            img_path = os.path.join(OUTPUT_DIR, f"{provider}_{mode}.png")
            with open(img_path, "wb") as f:
                f.write(img_bytes)
            print(f"  Saved:    {img_path} ({len(img_bytes) / 1024:.0f} KB)")
    else:
        # Save image for filter modes
        img_bytes = base64.b64decode(data["image_b64"])
        out_path = os.path.join(OUTPUT_DIR, f"{provider}_{mode}.png")
        with open(out_path, "wb") as f:
            f.write(img_bytes)
        print(f"  Saved:    {out_path} ({len(img_bytes) / 1024:.0f} KB)")


def main():
    if not os.path.exists(IMAGE_PATH):
        print(f"Error: {IMAGE_PATH} not found. Place a test image at {IMAGE_PATH}.")
        sys.exit(1)

    os.makedirs(OUTPUT_DIR, exist_ok=True)

    args = sys.argv[1:]

    # Check server is up
    try:
        resp = httpx.get(f"{SERVER}/health", timeout=5.0)
        resp.raise_for_status()
    except Exception:
        print(f"Error: server not reachable at {SERVER}. Start it first:")
        print(f"  uv run uvicorn main:app --reload --port 8000")
        sys.exit(1)

    modes = args if args else ALL_MODES

    print(f"  Modes: {', '.join(modes)}")

    for mode in modes:
        test_mode(mode)

    print(f"\n{'=' * 60}")
    print(f"  Done. Results in {OUTPUT_DIR}/")
    print(f"{'=' * 60}")


if __name__ == "__main__":
    main()

```

### dispoapi/prompts.py

```python
"""
Mode-to-prompt mapping for the /filter endpoint.

Modes fall into two categories:
  - filter: uses diffusion (OpenAI / Gemini / Modal) to transform the image
  - search: uses Perplexity Sonar API to analyze the image and return text
"""

# -- Filter modes (diffusion — image in, transformed image out) ------------

FILTER_PROMPTS: dict[str, str] = {
    "greek": "Make everyone waer ancient greek style robes. Make surrounding architecture look like ancient greece. Do not change the framing or composition of the image.",
    "ghibli": "Make this look like Studio Ghibli.",
    "duck": "Replace every human in the image with human-sized duck in the same pose.",
    "GPUMODE": "Replace every human in the image with a vertically oriented H100 GPU. It's like the 2001 space odyssey monolith.",
    "thinker": "Make the main subjects of the image pose in the style of the thinker. Do not change the scene, just change their pose.",
    "business_card": "Generate a professional business card from this image.",
    "1846": "Reimagine the image as if it were the year 1846. Keep the camera angle the exact same. Keep the main subjects posed the exact same.",
    "1929": "Reimagine the image as if it were the year 1929. Keep the camera angle the exact same. Keep the main subjects posed the exact same.",
    "1955": "Reimagine the image as if it were the year 1955. Keep the camera angle the exact same. Keep the main subjects posed the exact same.",
    "1984": "Reimagine the image as if it were the year 1984. Keep the camera angle the exact same. Keep the main subjects posed the exact same.",
    "1999": "Reimagine the image as if it were the year 1999. Keep the camera angle the exact same. Keep the main subjects posed the exact same.",
    "tree": "Make every human become a full body cute redwood tree mascot in the same position (you can't see their face). They should be perfectly tree shaped with a triangular green top and a brown trunk. Keep the rest of the scene completely unchanged.",
    "sam": "Add Sam Altman from the reference photo naturally into the scene.",
    "muscle": "Reimagine the main subjects of this image as insanely muscular, huge, and shredded to a superhuman level. Show them with six pack insane muscles everything. Dress them however necessary. Keep them in the exact same pose and keep everything else about the scene completely unchanged.",
    "valentines": "Identify the main subject of this image. Add an attractive romantic partner next to them or interacting with them in a natural, romantic way. The romantic partner should be attractive and complement the subject. Do NOT change the pose or appearance of the original subject at all. Keep the original subject exactly as they are.",
}

# -- Search modes (Perplexity — image in, text out) -------------------------

SEARCH_PROMPTS: dict[str, str] = {
    "pricing": (
        "Look at this image carefully. Identify the single most prominent object or product in the image. "
        "Search the web to find its current approximate retail price in USD. "
        "Be as specific as possible about the item (brand, model, size if visible). "
        "If you cannot find an exact price, give your best reasonable estimate based on similar items.\n\n"
        "You MUST always respond with ONLY one line in this exact format (no extra text):\n"
        "ITEM NAME — $PRICE\n\n"
        "Never respond with N/A. Always pick the most prominent object and always provide a dollar price, "
        "even if it is an estimate."
    ),
}

# -- Provider routing per mode -----------------------------------------------
# Every filter mode is "gemini" except "ghibli" and "sam" which use OpenAI gpt-image-1.5.
# Search modes are routed to "perplexity" in the endpoint logic.

MODE_PROVIDERS: dict[str, str] = {
    "greek": "gemini",
    "ghibli": "openai_image_edit",
    "duck": "gemini",
    "GPUMODE": "gemini",
    "thinker": "gemini",
    "business_card": "business_card",
    "1846": "gemini",
    "1929": "gemini",
    "1955": "gemini",
    "1984": "gemini",
    "1999": "gemini",
    "tree": "gemini",
    "sam": "sam",
    "muscle": "gemini",
    "valentines": "gemini",
}

# -- Combined lookups -------------------------------------------------------

MODE_PROMPTS: dict[str, str] = {**FILTER_PROMPTS, **SEARCH_PROMPTS}
VALID_MODES: set[str] = set(MODE_PROMPTS.keys())
FILTER_MODES: set[str] = set(FILTER_PROMPTS.keys())
SEARCH_MODES: set[str] = set(SEARCH_PROMPTS.keys())

```

### camera/main1.py

```python
#!/usr/bin/env python3
import time
import subprocess
import requests
import datetime
import io
import os
import base64
from gpiozero import Button, RotaryEncoder
from escpos.printer import Serial
from PIL import Image, ImageEnhance, ImageOps, ImageDraw, ImageFont
from luma.core.interface.serial import i2c
from luma.core.render import canvas
from luma.oled.device import ssd1306

# ========== CONFIGURATION ==========

# API Settings
API_BASE_URL = "https://web-production-95a4f.up.railway.app"

# Hardware Pins
PIN_SHUTTER = 4  # The Encoder Switch (SW)
PIN_ENC_CLK = 23  # Encoder CLK
PIN_ENC_DT  = 27  # Encoder DT

# I2C Display (128x64 OLED)
I2C_PORT = 1  # GPIO 2/3 is I2C port 1 on Pi Zero 2W
I2C_ADDRESS = 0x3C  # Default I2C address for most 128x64 OLEDs
# Run 'i2cdetect -y 1' to find your display's address if 0x3C doesn't work

# Printer Settings
SERIAL_PORT = '/dev/serial0'
BAUD_RATE = 9600
PRINTER_WIDTH = 384

# Camera Settings
CAM_WIDTH = 1024
CAM_HEIGHT = 768

# Image Processing - Test 2 Settings
CONTRAST = 1.65  # Slightly increased for darker shadows
BRIGHTNESS = 0.85  # Reverted
SHARPNESS = 1.3
GAMMA = 0.7  # Lower gamma makes blacks darker (was 0.8)
DARKNESS = 220  # Increased for darker thermal printing
LINE_DELAY = 0.02

# Caption Settings - Matching test header size
FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"
FONT_SIZE = 20  # Larger to match test headers

# ===================================

class ThermalCam:
    def __init__(self):
        self.current_mode_index = 0
        
        # Setup OLED Display first
        try:
            serial = i2c(port=I2C_PORT, address=I2C_ADDRESS)
            self.display = ssd1306(serial)
            print("✓ OLED Display connected")
        except Exception as e:
            print(f"Display Error: {e}")
            self.display = None
        
        self.display_status("STARTING...")
        
        # Fetch modes from API
        # "normal" is a client-side mode (no API call) available online & offline
        try:
            print("Fetching modes from API...")
            self.display_status("LOADING MODES...")
            resp = requests.get(f"{API_BASE_URL}/modes", timeout=5)
            resp.raise_for_status()
            data = resp.json()
            self.modes = ["normal"] + data["modes"]
            print(f"Modes loaded: {self.modes}")
        except Exception as e:
            print(f"API unavailable ({e}), falling back to normal mode")
            self.modes = ["normal"]
        
        # Setup Printer
        try:
            self.printer = Serial(devfile=SERIAL_PORT, baudrate=BAUD_RATE)
        except Exception as e:
            print(f"Printer Error: {e}")

        # Setup GPIO
        # bounce_time=0.1 prevents "spamming" (debouncing)
        self.shutter = Button(PIN_SHUTTER, pull_up=True, bounce_time=0.1)
        self.encoder = RotaryEncoder(PIN_ENC_CLK, PIN_ENC_DT)
        
        # Connect Events
        self.shutter.when_pressed = self.handle_shutter_press  # Show preview
        self.shutter.when_released = self.handle_shutter_release  # Take photo
        self.encoder.when_rotated = self.handle_dial
        
        print(f"System Ready. Mode: {self.modes[self.current_mode_index]}")
        self.display_mode()

    def animate_progress(self, text="LOADING"):
        """Animated progress bar for all loading states"""
        if not self.display:
            return
        
        try:
            for i in range(0, 101, 20):
                with canvas(self.display) as draw:
                    # Title
                    w = len(text) * 6
                    x = (128 - w) // 2
                    draw.text((x, 20), text, fill="white")
                    
                    # Progress bar
                    bar_width = 100
                    bar_x = (128 - bar_width) // 2
                    bar_y = 38
                    
                    # Outline
                    draw.rectangle((bar_x, bar_y, bar_x + bar_width, bar_y + 10), outline="white", fill="black")
                    
                    # Fill
                    fill_width = int(bar_width * i / 100)
                    if fill_width > 0:
                        draw.rectangle((bar_x + 1, bar_y + 1, bar_x + fill_width - 1, bar_y + 9), fill="white")
                
                time.sleep(0.1)
        except:
            pass

    def display_status(self, status_text, large=False):
        """Display a status message on OLED"""
        if not self.display:
            return
        
        try:
            with canvas(self.display) as draw:
                if large:
                    # Large centered text for important messages
                    # Split into multiple lines if needed
                    words = status_text.split()
                    lines = []
                    current_line = ""
                    
                    for word in words:
                        test_line = current_line + " " + word if current_line else word
                        # Rough estimate: 10 chars per line for large text
                        if len(test_line) <= 10:
                            current_line = test_line
                        else:
                            if current_line:
                                lines.append(current_line)
                            current_line = word
                    if current_line:
                        lines.append(current_line)
                    
                    # Draw centered lines
                    y_start = 32 - (len(lines) * 8)
                    for i, line in enumerate(lines):
                        # Center each line
                        w = len(line) * 6  # Rough character width
                        x = (128 - w) // 2
                        draw.text((x, y_start + i * 16), line, fill="white")
                else:
                    # Normal centered text
                    w = len(status_text) * 6  # Rough character width
   
[truncated — 15339 more characters]
```