|
| 1 | +import asyncio |
| 2 | +import logging |
| 3 | +import threading |
| 4 | +from pathlib import Path |
1 | 5 | from typing import Optional, Union |
2 | 6 |
|
| 7 | +import torch |
3 | 8 | from dynamicprompts.generators import CombinatorialPromptGenerator, RandomPromptGenerator |
4 | | -from fastapi import Body |
| 9 | +from fastapi import Body, HTTPException |
5 | 10 | from fastapi.routing import APIRouter |
6 | | -from pydantic import BaseModel |
| 11 | +from pydantic import BaseModel, Field |
7 | 12 | from pyparsing import ParseException |
| 13 | +from transformers import AutoProcessor, AutoTokenizer, LlavaOnevisionForConditionalGeneration, LlavaOnevisionProcessor |
| 14 | + |
| 15 | +from invokeai.app.api.dependencies import ApiDependencies |
| 16 | +from invokeai.app.services.image_files.image_files_common import ImageFileNotFoundException |
| 17 | +from invokeai.app.services.model_records.model_records_base import UnknownModelException |
| 18 | +from invokeai.backend.llava_onevision_pipeline import LlavaOnevisionPipeline |
| 19 | +from invokeai.backend.model_manager.taxonomy import ModelType |
| 20 | +from invokeai.backend.text_llm_pipeline import DEFAULT_SYSTEM_PROMPT, TextLLMPipeline |
| 21 | +from invokeai.backend.util.devices import TorchDevice |
| 22 | + |
| 23 | +logger = logging.getLogger(__name__) |
8 | 24 |
|
9 | 25 | utilities_router = APIRouter(prefix="/v1/utilities", tags=["utilities"]) |
10 | 26 |
|
| 27 | +# The underlying model loader is not thread-safe, so we serialize load_model calls. |
| 28 | +_model_load_lock = threading.Lock() |
| 29 | + |
11 | 30 |
|
12 | 31 | class DynamicPromptsResponse(BaseModel): |
13 | 32 | prompts: list[str] |
@@ -42,3 +61,160 @@ async def parse_dynamicprompts( |
42 | 61 | prompts = [prompt] |
43 | 62 | error = str(e) |
44 | 63 | return DynamicPromptsResponse(prompts=prompts if prompts else [""], error=error) |
| 64 | + |
| 65 | + |
| 66 | +# --- Expand Prompt --- |
| 67 | + |
| 68 | + |
| 69 | +class ExpandPromptRequest(BaseModel): |
| 70 | + prompt: str |
| 71 | + model_key: str |
| 72 | + max_tokens: int = Field(default=300, ge=1, le=2048) |
| 73 | + system_prompt: str | None = None |
| 74 | + |
| 75 | + |
| 76 | +class ExpandPromptResponse(BaseModel): |
| 77 | + expanded_prompt: str |
| 78 | + error: str | None = None |
| 79 | + |
| 80 | + |
| 81 | +def _resolve_model_path(model_config_path: str) -> Path: |
| 82 | + """Resolve a model config path to an absolute path.""" |
| 83 | + model_path = Path(model_config_path) |
| 84 | + if model_path.is_absolute(): |
| 85 | + return model_path.resolve() |
| 86 | + base_models_path = ApiDependencies.invoker.services.configuration.models_path |
| 87 | + return (base_models_path / model_path).resolve() |
| 88 | + |
| 89 | + |
| 90 | +def _run_expand_prompt(prompt: str, model_key: str, max_tokens: int, system_prompt: str | None) -> str: |
| 91 | + """Run text LLM inference synchronously (called from thread).""" |
| 92 | + model_manager = ApiDependencies.invoker.services.model_manager |
| 93 | + model_config = model_manager.store.get_model(model_key) |
| 94 | + |
| 95 | + if model_config.type != ModelType.TextLLM: |
| 96 | + raise ValueError(f"Model '{model_key}' is not a TextLLM model (got {model_config.type})") |
| 97 | + |
| 98 | + with _model_load_lock: |
| 99 | + loaded_model = model_manager.load.load_model(model_config) |
| 100 | + |
| 101 | + with torch.no_grad(), loaded_model.model_on_device() as (_, model): |
| 102 | + model_abs_path = _resolve_model_path(model_config.path) |
| 103 | + tokenizer = AutoTokenizer.from_pretrained(model_abs_path, local_files_only=True) |
| 104 | + |
| 105 | + pipeline = TextLLMPipeline(model, tokenizer) |
| 106 | + model_device = next(model.parameters()).device |
| 107 | + output = pipeline.run( |
| 108 | + prompt=prompt, |
| 109 | + system_prompt=system_prompt or DEFAULT_SYSTEM_PROMPT, |
| 110 | + max_new_tokens=max_tokens, |
| 111 | + device=model_device, |
| 112 | + dtype=TorchDevice.choose_torch_dtype(), |
| 113 | + ) |
| 114 | + |
| 115 | + return output |
| 116 | + |
| 117 | + |
| 118 | +@utilities_router.post( |
| 119 | + "/expand-prompt", |
| 120 | + operation_id="expand_prompt", |
| 121 | + responses={ |
| 122 | + 200: {"model": ExpandPromptResponse}, |
| 123 | + }, |
| 124 | +) |
| 125 | +async def expand_prompt(body: ExpandPromptRequest) -> ExpandPromptResponse: |
| 126 | + """Expand a brief prompt into a detailed image generation prompt using a text LLM.""" |
| 127 | + try: |
| 128 | + expanded = await asyncio.to_thread( |
| 129 | + _run_expand_prompt, |
| 130 | + body.prompt, |
| 131 | + body.model_key, |
| 132 | + body.max_tokens, |
| 133 | + body.system_prompt, |
| 134 | + ) |
| 135 | + return ExpandPromptResponse(expanded_prompt=expanded) |
| 136 | + except UnknownModelException: |
| 137 | + raise HTTPException(status_code=404, detail=f"Model '{body.model_key}' not found") |
| 138 | + except ValueError as e: |
| 139 | + raise HTTPException(status_code=422, detail=str(e)) |
| 140 | + except Exception as e: |
| 141 | + logger.error(f"Error expanding prompt: {e}") |
| 142 | + raise HTTPException(status_code=500, detail=str(e)) |
| 143 | + |
| 144 | + |
| 145 | +# --- Image to Prompt --- |
| 146 | + |
| 147 | + |
| 148 | +class ImageToPromptRequest(BaseModel): |
| 149 | + image_name: str |
| 150 | + model_key: str |
| 151 | + instruction: str = "Describe this image in detail for use as an AI image generation prompt." |
| 152 | + |
| 153 | + |
| 154 | +class ImageToPromptResponse(BaseModel): |
| 155 | + prompt: str |
| 156 | + error: str | None = None |
| 157 | + |
| 158 | + |
| 159 | +def _run_image_to_prompt(image_name: str, model_key: str, instruction: str) -> str: |
| 160 | + """Run LLaVA OneVision inference synchronously (called from thread).""" |
| 161 | + model_manager = ApiDependencies.invoker.services.model_manager |
| 162 | + model_config = model_manager.store.get_model(model_key) |
| 163 | + |
| 164 | + if model_config.type != ModelType.LlavaOnevision: |
| 165 | + raise ValueError(f"Model '{model_key}' is not a LLaVA OneVision model (got {model_config.type})") |
| 166 | + |
| 167 | + with _model_load_lock: |
| 168 | + loaded_model = model_manager.load.load_model(model_config) |
| 169 | + |
| 170 | + # Load the image from InvokeAI's image store |
| 171 | + image = ApiDependencies.invoker.services.images.get_pil_image(image_name) |
| 172 | + image = image.convert("RGB") |
| 173 | + |
| 174 | + with torch.no_grad(), loaded_model.model_on_device() as (_, model): |
| 175 | + if not isinstance(model, LlavaOnevisionForConditionalGeneration): |
| 176 | + raise TypeError(f"Expected LlavaOnevisionForConditionalGeneration, got {type(model).__name__}") |
| 177 | + |
| 178 | + model_abs_path = _resolve_model_path(model_config.path) |
| 179 | + processor = AutoProcessor.from_pretrained(model_abs_path, local_files_only=True) |
| 180 | + if not isinstance(processor, LlavaOnevisionProcessor): |
| 181 | + raise TypeError(f"Expected LlavaOnevisionProcessor, got {type(processor).__name__}") |
| 182 | + |
| 183 | + pipeline = LlavaOnevisionPipeline(model, processor) |
| 184 | + model_device = next(model.parameters()).device |
| 185 | + output = pipeline.run( |
| 186 | + prompt=instruction, |
| 187 | + images=[image], |
| 188 | + device=model_device, |
| 189 | + dtype=TorchDevice.choose_torch_dtype(), |
| 190 | + ) |
| 191 | + |
| 192 | + return output |
| 193 | + |
| 194 | + |
| 195 | +@utilities_router.post( |
| 196 | + "/image-to-prompt", |
| 197 | + operation_id="image_to_prompt", |
| 198 | + responses={ |
| 199 | + 200: {"model": ImageToPromptResponse}, |
| 200 | + }, |
| 201 | +) |
| 202 | +async def image_to_prompt(body: ImageToPromptRequest) -> ImageToPromptResponse: |
| 203 | + """Generate a descriptive prompt from an image using a vision-language model.""" |
| 204 | + try: |
| 205 | + prompt = await asyncio.to_thread( |
| 206 | + _run_image_to_prompt, |
| 207 | + body.image_name, |
| 208 | + body.model_key, |
| 209 | + body.instruction, |
| 210 | + ) |
| 211 | + return ImageToPromptResponse(prompt=prompt) |
| 212 | + except UnknownModelException: |
| 213 | + raise HTTPException(status_code=404, detail=f"Model '{body.model_key}' not found") |
| 214 | + except ImageFileNotFoundException: |
| 215 | + raise HTTPException(status_code=404, detail=f"Image '{body.image_name}' not found") |
| 216 | + except (ValueError, TypeError) as e: |
| 217 | + raise HTTPException(status_code=422, detail=str(e)) |
| 218 | + except Exception as e: |
| 219 | + logger.error(f"Error generating prompt from image: {e}") |
| 220 | + raise HTTPException(status_code=500, detail=str(e)) |
0 commit comments