A FastAPI-based backend service for Stable Diffusion image generation, inpainting, multi-model merging, and model management. Supports SD 1.5, SDXL, Illustrious XL, and Flux 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, SDXL, Illustrious XL, and Flux models, receive results via job status polling
- Inpainting - Image inpainting using specialized pipelines with base image and mask (base64) inputs, including Flux's strength-based re-noising inpaint
- 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 (SD 1.5/SDXL/Illustrious XL only β see TODO.md for planned Flux merge support)
- 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 SD/SDXL/Illustrious XL, bfloat16 for Flux, float32 for CPU)
- Flux Memory Offload - Flux pipelines use
enable_model_cpu_offload()instead of a full device move to fit its larger (~12B parameter transformer + T5-XXL encoder) footprint on consumer hardware - 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 |
| POST | /illustrious/generate |
Submit Illustrious XL text-to-image generation job |
| POST | /illustrious/inpaint |
Submit Illustrious XL inpainting job |
| POST | /illustrious/generate/batch |
Submit multiple Illustrious XL generation jobs |
| POST | /illustrious/generate/compare |
Generate from multiple Illustrious XL models for comparison |
| POST | /flux/generate |
Submit Flux text-to-image generation job |
| POST | /flux/inpaint |
Submit Flux inpainting job (strength-based re-noising) |
| POST | /flux/generate/batch |
Submit multiple Flux generation jobs |
| POST | /flux/generate/compare |
Generate from multiple Flux models for comparison |
| Method | Route | Description |
|---|---|---|
| GET | /models/{model_type}/{resource_type} |
List available models (model_type: sd15/sdxl/illustrious/flux, 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 |
Merging currently supports
sd15,sdxl, andillustriousonly. Flux's transformer-based state dict isn't compatible with the current key-by-key UNet tensor merge β see TODO.md.
| 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() β
β + SCHEDULERS (overridable) β
ββββββββββββ¬βββββββββββββββββββββββ
β inherits
βββββββββ¬ββββββββ¬ββββββββ
β β β β
βββββββββββββββββββββββββββββββββββββββββββββ
β SD15 ββ SDXL ββ Illustrious ββ Flux β
βPipelineββPipelineββ Pipeline ββPipelineβ
βGeneratorββGeneratorββ Generator ββGeneratorβ
βββββββββββββββββββββββββββββββββββββββββββββ
overrides:
_init_device (bf16),
pipeToConfiguredDevice
(enable_model_cpu_offload),
forward/forward_inpaint
(true_cfg_scale, strength)
ββββββββββββββββββββββββ βββββββββββββββββββββββ ββββββββββββββββββββ
β 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
}
}'
# Submit a Flux generation job
curl -X POST http://localhost:8000/api/v1/flux/generate \
-H "Content-Type: application/json" \
-d '{
"positive_prompt": "a photo of an astronaut riding a horse on mars",
"model_checkpoint": "flux1-dev.safetensors",
"width": 1024,
"height": 1024,
"steps": 20,
"cfg_scale": 3.5,
"scheduler": "FlowMatch Euler",
"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'
illustrious:
path: 'models/checkpoints/Illustrious'
flux:
path: 'models/checkpoints/Flux'
loras:
sd15:
path: 'models/loras/SD15'
sdxl:
path: 'models/loras/SDXL'
illustrious:
path: 'models/loras/Illustrious'
flux:
path: 'models/loras/Flux'
vaes:
sd15:
path: 'models/vae/SD15'
sdxl:
path: 'models/vae/SDXL'
illustrious:
path: 'models/vae/Illustrious'
flux:
path: 'models/vae/Flux'
# Flux checkpoints are transformer-only single-file checkpoints; the shared
# text encoders (CLIP-L + T5-XXL), tokenizers, VAE, and scheduler are loaded
# once from a local diffusers-format directory and reused across checkpoints.
flux:
components_path: 'models/flux_components'
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: ["*"]Flux checkpoints only ship the transformer; the shared text encoders, tokenizers, VAE, and scheduler must be populated once under flux.components_path in standard diffusers subfolder layout (text_encoder/, tokenizer/, text_encoder_2/, tokenizer_2/, vae/, scheduler/):
# FLUX.1-schnell is ungated and license-friction-free for testing;
# FLUX.1-dev requires accepting its gated license on Hugging Face first.
huggingface-cli download black-forest-labs/FLUX.1-dev \
--exclude "transformer/*" --local-dir models/flux_componentsThen place a single-file Flux transformer checkpoint (e.g. flux1-dev.safetensors) under checkpoints.flux.path.
# Install dependencies
uv sync
# Start the server
uv run uvicorn api.app:app --host 0.0.0.0 --port 8000