A FastAPI-based backend service for Stable Diffusion image generation, inpainting, multi-model merging, and model management. Supports SD 1.5 and SDXL pipelines with asynchronous job processing, model caching, and optional LoRA adapter integration.
This is a rewrite effort of the SD-WebApp project, and has been brewing for a bit now. The idea is to decouple the UI from the actual inference backend. This repo will host the backend code.
- Text-to-Image Generation - Submit async jobs for SD 1.5 and SDXL models, receive results via job status polling
- Inpainting - Image inpainting using specialized pipelines with base image and mask (base64) inputs
- Batch Generation - Submit multiple generation jobs in a single request
- Model Comparison - Generate images from multiple checkpoints side-by-side for comparison
- Multi-Model Merging - Merge two or more models using configurable methods (add, average, slerp), with weight coefficients and multi-step recipe execution
- Asynchronous Job Queue - Non-blocking API with worker-based async pipeline execution via
asyncio.Queue - Model Caching - LRU cache (max 2 pipelines) for expensive diffusion model loading, auto-eviction via access order
- LoRA Support - Load and fuse PEFT-based LoRA adapters into the base pipeline with configurable strength
- Custom VAE - Replace the default VAE with custom models loaded from disk
- Multi-Device Auto-Detection - Automatic fallback: MPS (Apple Silicon) > CUDA (NVIDIA) > CPU with dtype auto-selection (float16 for GPU, float32 for CPU)
- Configurable Output - Optional disk persistence of generated images and metadata to
outputs/images/andoutputs/json/as PNG and JSON - System Endpoints - Health checks, system info (GPU, cache stats, queue stats), and configuration exposure
- CORS Middleware - Configurable CORS support, enabled by default with wildcard origins
All endpoints are prefixed with /api/v1.
| Method | Route | Description |
|---|---|---|
| POST | /sd15/generate |
Submit SD 1.5 text-to-image generation job |
| POST | /sd15/inpaint |
Submit SD 1.5 inpainting job |
| POST | /sd15/generate/batch |
Submit multiple SD 1.5 generation jobs |
| POST | /sd15/generate/compare |
Generate from multiple SD 1.5 models for comparison |
| POST | /sdxl/generate |
Submit SDXL text-to-image generation job |
| POST | /sdxl/inpaint |
Submit SDXL inpainting job |
| POST | /sdxl/generate/batch |
Submit multiple SDXL generation jobs |
| POST | /sdxl/generate/compare |
Generate from multiple SDXL models for comparison |
| Method | Route | Description |
|---|---|---|
| GET | /models/{model_type}/{resource_type} |
List available models (model_type: sd15/sdxl, resource_type: checkpoints/loras/vaes) |
| GET | /models/{model_type}/{resource_type}/{model_name}/metadata |
Get safetensors metadata for a specific model |
| Method | Route | Description |
|---|---|---|
| POST | /merge/merge |
Merge two models into one new checkpoint with configurable method (add, average, slerp), alpha weight, and metadata preservation |
| POST | /merge/batch |
Merge one base model against multiple target models simultaneously, saving each output into a specified subdirectory |
| POST | /merge/recipe |
Execute a multi-step merge recipe where each step takes the current result and merges it with a new target model using its own method/alpha |
| Method | Route | Description |
|---|---|---|
| GET | /jobs |
List all jobs with current status |
| GET | /jobs/{job_id} |
Get detailed status and result for a specific job |
| DELETE | /jobs/{job_id} |
Cancel a pending or running job |
| Method | Route | Description |
|---|---|---|
| GET | /system/health |
Health check endpoint |
| GET | /system/info |
System info (GPU, data type, cache stats, queue stats) |
| GET | /info/schedulers |
List available noise schedulers |
| GET | /info/app_settings |
Return current application configuration |
Additional Swagger UI docs at /docs and ReDoc at /redoc (auto-generated by FastAPI).
┌─────────────────────────────────┐
│ BasePipelineGenerator │ (ABC)
│ ───────────────────────────── │
│ - _model_path: str │
│ - _pipeline_type: PipelineType │
│ - _device: str │
│ - _dtype: torch.dtype │
│ ───────────────────────────── │
│ + loadPipeline() │
│ + loadInpaintPipeline() │
│ + pipeToDevice() │
│ + forward(prompt, params) │ # text-to-image
│ + forward_inpaint(base, mask…) │ # inpainting
│ + addLorasToPipeline() │
│ + loadCustomVAE() │
│ + getResolutions() │
└──────────┬──────────────────────┘
│ inherits
┌───────┴───────┐
│ │
┌────────┐ ┌────────┐
│ SD15 │ │ SDXL │
│Pipeline│ │Pipeline│
│ Generator│ │ Generator│
└────────┘ └────────┘
┌──────────────────────┐ ┌─────────────────────┐ ┌──────────────────┐
│ GenerationService │────>│ ModelCacheManager │────>│ BasePipelineGen │
│ ──────────────────── │ │ ─────────────────── │ │ ─────────────── │
│ + generate() │ │ + get_pipeline() │ │ - cache: dict │
│ + inpaint() │ │ + load_pipeline() │ │ + forward() │
│ + batch_generate() │ │ + clear_cache() │ │ + addLoras...() │
│ + compare_models() │ │ + get_stats() │ └──────────────────┘
└──────────┬───────────┘ └─────────────────────┘
│
│ submits
v
┌──────────────────────┐
│ JobQueue │
│ ──────────────────── │
│ + submit_job() │ # returns job_id
│ + get_job_status() │
│ + cancel_job() │
│ + list_jobs() │
│ + stop_workers() │
│ - _queue: Queue │
│ - _jobs: dict │
│ - _worker() │ # async background loop
└──────────────────────┘
┌─────────────────┐ ┌────────────────┐ ┌──────────────────┐
│ Generation │ │ InpaintRequest │ │ LoraConfig │
│ Request │ │ ────────────── │ │ ──────────────── │
│ ────────────── │ │ - image_base64 │ │ - name: str │
│ - prompt: str │ │ - mask_base64 │ │ - strength: float│
│ - model: str │ │ - image: str │ └────────┬─────────┘
│ - params: dict │ │ - mask: str │ │
│ - loras: list[] │──>│ - resolution │ │
│ - vae: str|None │ │ - negative_prompt
│ ────────────── │ └────────────────┘
│ + to_dict() │
└────────┬────────┘
│ validates
v
┌─────────────────────────────────────────┐
│ JobResponse / JobStatusResponse │
│ ───────────────────────────────────── │
│ - job_id: str (UUID4) │
│ - status: Job (QUEUED/RUNNING/...) │
│ - result: JobResult|None │
│ - image_base64: str │
│ - metadata: dict │
│ - seed: int │
└─────────────────────────────────────────┘
┌──────────────────┐ ┌─────────────────┐
│ parameters.yaml │────>│ AppConfig │
│ ─────────────── │ │ ─────────────── │
│ checkpoints: │ │ - _params: dict │
│ sd15: path │ │ ─────────────── │
│ sdxl: path │ │ + get_model_... │
│ loras: │ │ + setup_paths() │
│ vaes: │ │ + get_output_.. │
│ storage: │ │ + as_dict() │
│ api: │ └─────────────────┘
│ queue: │ ▲
│ cache: │ │ lru_cache
│ cors: │ ┌─────────────────┐
└──────────────────┘ │ get_app_config()│
└─────────────────┘
- Client sends
POST /api/v1/{sd15,sdxl}/generatewith prompt, model, and generation parameters - Router resolves model path from
AppConfig, converts LoRA configs to dicts, validates request - JobQueue generates a UUID4
job_id, creates aJobobject, enqueues it, and returns immediately withJobResponse(status="queued") - Background workers (
_workerloop) dequeue jobs and set status toRUNNING - GenerationService calls
ModelCacheManager.get_pipeline()to get or load the diffusion pipeline - Pipeline loads in a thread pool executor (blocking diffusers call)
- LoRA adapters are fused via
peftand custom VAE is swapped if provided - Pipeline's
forward()(text-to-image) orforward_inpaint()is invoked with generation params - Result is an image (base64-encoded PNG) plus metadata dict (
GenerationMetadatacontainer) - If
persist_to_backendis enabled, image is saved tooutputs/images/{uuid}.pngand metadata tooutputs/json/{uuid}.json - Job is marked
COMPLETEDand result stored for client polling
- Client sends
POST /api/v1/merge/merge(or/merge/batch,/merge/recipe) with model specs, merge method, and weight parameters - Router validates model paths from
AppConfig - JobQueue generates a
job_idand returns immediately withJobResponse(status="queued") - Background workers dequeue jobs and invoke
MergeService.merge_models() - MergeService loads model weights using
diffusersand safetensors, applies the specified merge algorithm (add, average, or slerp), and saves the merged checkpoint to the configured output directory - Recipe merges loop through each step sequentially, feeding the result of the previous step as the base for the next
- Job is marked
COMPLETEDwith the path to the newly created merged model
Client: GET /api/v1/jobs/{job_id}
→ JobQueue.get_job_status()
→ Returns JobStatusResponse { job_id, job_type, status, result, error, timestamps }
→ If status=CALLED: extract result.image_base64 → decode → save PNG
→ If status=FAILED: display result.error
ModelCacheManager.get_pipeline(model_type, model_path, pipeline_type)
1. Build cache key: "{model_type}:{model_name}:{pipeline_type}"
2. Cache HIT → update access order → return cached pipeline
3. Cache MISS → _load_pipeline() in thread pool
→ Instantiate pipeline generator → loadSDPipeline() → pipeToConfiguredDevice()
4. If at capacity (>= max_models) → evict least-recently-used
5. Insert new pipeline into cache
Uvicorn starts → api.app:app (pre-created via create_app())
Lifespan startup:
1. Load AppConfig from parameters.yaml
2. Create FastAPI app with CORS middleware
3. Mount routers (sd15, sdxl, models, merge, jobs, system, info)
4. JobQueue singleton created → start_workers() spawns N async worker tasks
5. Log "SD API Backend started"
Lifespan shutdown:
1. JobQueue.stop_workers() → sets _running=False, drains queue, cancels workers
2. Log "SD API Backend shutdown complete"
import asyncio
import httpx
import json
BASE_URL = "http://localhost:8000/api/v1"
async def generate_image():
async with httpx.AsyncClient() as client:
# Submit generation job
response = await client.post(
f"{BASE_URL}/sd15/generate",
json={
"prompt": "a beautiful sunset over mountains",
"model": "v2-1024px-pruned.safetensors",
"params": {
"width": 512,
"height": 512,
"num_inference_steps": 30,
"guidance_scale": 7.5,
"seed": 42
}
}
)
job = response.json()
job_id = job["job_id"]
print(f"Job submitted: {job_id}")
# Poll for completion
while True:
status = await client.get(f"{BASE_URL}/jobs/{job_id}")
data = status.json()
if data["status"] == "completed":
result = data["result"]
# Decode and save image
import base64
with open(f"output_{job_id}.png", "wb") as f:
f.write(base64.b64decode(result["image_base64"]))
print(f"Image saved. Seed: {result['seed']}")
break
elif data["status"] == "failed":
print(f"Job failed: {data['error']}")
break
await asyncio.sleep(1)
asyncio.run(generate_image())# Submit a generation job
curl -X POST http://localhost:8000/api/v1/sd15/generate \
-H "Content-Type: application/json" \
-d '{
"prompt": "a cyberpunk city at night",
"model": "v2-1024px-pruned.safetensors",
"params": {
"width": 512,
"height": 512,
"num_inference_steps": 30,
"guidance_scale": 7.5,
"seed": 42
}
}'
# Check job status
curl http://localhost:8000/api/v1/jobs/{job_id}
# List all jobs
curl http://localhost:8000/api/v1/jobs
# Cancel a job
curl -X DELETE http://localhost:8000/api/v1/jobs/{job_id}
# List available checkpoints
curl http://localhost:8000/api/v1/models/sd15/checkpoints
# Merge two models
curl -X POST http://localhost:8000/api/v1/merge/merge \
-H "Content-Type: application/json" \
-d '{
"model_a": "model_a.safetensors",
"model_b": "model_b.safetensors",
"alpha": 0.5,
"merge_type": "average"
}'
# Multi-step recipe merge
curl -X POST http://localhost:8000/api/v1/merge/recipe \
-H "Content-Type: application/json" \
-d '{
"base_model": "base.safetensors",
"steps": [
{"target": "style1.safetensors", "alpha": 0.4, "merge_type": "add"},
{"target": "style2.safetensors", "alpha": 0.3, "merge_type": "average"}
],
"output_path": "merged/"
}'
# Health check
curl http://localhost:8000/api/v1/system/healthresponse = await client.post(
f"{BASE_URL}/sdxl/generate",
json={
"prompt": "a portrait in anime style",
"model": "sd_xl_base_1.0.safetensors",
"params": {
"width": 1024,
"height": 1024,
"num_inference_steps": 25,
},
"loras": [
{"name": "anime_style.safetensors", "strength": 0.8}
]
}
)Copy parameters.yaml.example to parameters.yaml and adjust paths:
checkpoints:
sd15:
path: 'models/checkpoints/SD15'
sdxl:
path: 'models/checkpoints/SDXL'
loras:
sd15:
path: 'models/loras/SD15'
sdxl:
path: 'models/loras/SDXL'
vaes:
sd15:
path: 'models/vae/SD15'
sdxl:
path: 'models/vae/SDXL'
storage:
persist_to_backend: true
output_images: 'outputs/images'
output_json: 'outputs/json'
api:
host: "0.0.0.0"
port: 8000
workers: 1
job_queue:
num_workers: 1
result_ttl: 3600
model_cache:
max_models: 2
cors:
enabled: true
allow_origins: ["*"]# Install dependencies
uv sync
# Start the server
uv run uvicorn api.app:app --host 0.0.0.0 --port 8000