feat(T08): Bildretusche via Flux.1-Pro + Preisvergleich + Retouch UI with before/after slider
This commit is contained in:
+2
-1
@@ -9,7 +9,7 @@ from fastapi import APIRouter, FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
from app.config import settings
|
||||
from app.routers import auth, contacts, copilot, datev, files, ocr, sales, users, vehicles
|
||||
from app.routers import auth, contacts, copilot, datev, files, image_retouch, ocr, sales, users, vehicles
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -47,6 +47,7 @@ api_v1_router.include_router(ocr.router)
|
||||
api_v1_router.include_router(sales.router)
|
||||
api_v1_router.include_router(datev.router)
|
||||
api_v1_router.include_router(copilot.router)
|
||||
api_v1_router.include_router(image_retouch.router)
|
||||
|
||||
# Health endpoint (no auth required)
|
||||
@api_v1_router.get("/health", tags=["health"])
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
"""SQLAlchemy model for image retouch results."""
|
||||
|
||||
import enum
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
||||
from sqlalchemy.dialects.postgresql import UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
class RetouchStatus(str, enum.Enum):
|
||||
pending = "pending"
|
||||
processing = "processing"
|
||||
completed = "completed"
|
||||
failed = "failed"
|
||||
|
||||
|
||||
class RetouchResult(Base):
|
||||
"""Retouch result entity tracking image retouching via Flux.1-Pro."""
|
||||
|
||||
__tablename__ = "retouch_results"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
primary_key=True,
|
||||
default=uuid.uuid4,
|
||||
)
|
||||
vehicle_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
UUID(as_uuid=True),
|
||||
ForeignKey("vehicles.id", ondelete="SET NULL"),
|
||||
nullable=True,
|
||||
index=True,
|
||||
)
|
||||
original_file_path: Mapped[str] = mapped_column(
|
||||
String(512), nullable=False
|
||||
)
|
||||
original_file_name: Mapped[str] = mapped_column(
|
||||
String(255), nullable=False
|
||||
)
|
||||
mime_type: Mapped[str] = mapped_column(
|
||||
String(100), nullable=False, default="image/png"
|
||||
)
|
||||
retouched_file_path: Mapped[str | None] = mapped_column(
|
||||
String(512), nullable=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="pending"
|
||||
)
|
||||
error_message: Mapped[str | None] = mapped_column(
|
||||
Text, nullable=True
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=False,
|
||||
server_default=func.now(),
|
||||
onupdate=func.now(),
|
||||
)
|
||||
|
||||
vehicle: Mapped["Vehicle"] = relationship("Vehicle")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<RetouchResult id={self.id} status={self.status}>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Serialize retouch result for API responses."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"vehicle_id": str(self.vehicle_id) if self.vehicle_id else None,
|
||||
"original_file_path": self.original_file_path,
|
||||
"original_file_name": self.original_file_name,
|
||||
"mime_type": self.mime_type,
|
||||
"retouched_file_path": self.retouched_file_path,
|
||||
"status": self.status,
|
||||
"error_message": self.error_message,
|
||||
"created_at": (
|
||||
self.created_at.isoformat() if self.created_at else None
|
||||
),
|
||||
"updated_at": (
|
||||
self.updated_at.isoformat() if self.updated_at else None
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
"""Image retouch router: process images, get results, price comparison."""
|
||||
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile, status
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.database import get_db
|
||||
from app.dependencies import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.retouch import RetouchStatus
|
||||
from app.schemas.retouch import (
|
||||
PriceCompareRequest,
|
||||
PriceCompareResponse,
|
||||
RetouchProcessResponse,
|
||||
RetouchResultResponse,
|
||||
)
|
||||
from app.services import retouch_service, price_compare_service
|
||||
from app.services.vehicle_service import get_vehicle_by_id
|
||||
from app.tasks.retouch_processing import run_retouch_processing
|
||||
|
||||
router = APIRouter(prefix="/retouch", tags=["retouch"])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/process",
|
||||
response_model=RetouchProcessResponse,
|
||||
status_code=status.HTTP_202_ACCEPTED,
|
||||
)
|
||||
async def process_image(
|
||||
background_tasks: BackgroundTasks,
|
||||
file: UploadFile = File(..., description="Image file to retouch"),
|
||||
vehicle_id: str | None = Form(None, description="Optional vehicle ID to link"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Upload an image file for retouching via Flux.1-Pro.
|
||||
|
||||
Returns 202 with retouch_id. Processing happens asynchronously.
|
||||
"""
|
||||
if not file or not file.filename:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"error": {"code": "NO_FILE", "message": "No file provided"}},
|
||||
)
|
||||
|
||||
mime_type = file.content_type or ""
|
||||
if not retouch_service.validate_mime_type(mime_type):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "INVALID_MIME_TYPE",
|
||||
"message": f"Invalid MIME type: {mime_type}. Only image/* types are allowed.",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
file_bytes = await file.read()
|
||||
|
||||
if not retouch_service.validate_file_size(len(file_bytes)):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={
|
||||
"error": {
|
||||
"code": "FILE_TOO_LARGE",
|
||||
"message": f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB",
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
# Parse optional vehicle_id
|
||||
parsed_vehicle_id: uuid.UUID | None = None
|
||||
vehicle_info = None
|
||||
if vehicle_id:
|
||||
try:
|
||||
parsed_vehicle_id = uuid.UUID(vehicle_id)
|
||||
except (ValueError, TypeError):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"error": {"code": "INVALID_VEHICLE_ID", "message": "Invalid vehicle UUID"}},
|
||||
)
|
||||
|
||||
# Fetch vehicle info for better retouch prompt
|
||||
vehicle = await get_vehicle_by_id(db, parsed_vehicle_id)
|
||||
if vehicle:
|
||||
vehicle_info = {
|
||||
"make": vehicle.make,
|
||||
"model": vehicle.model,
|
||||
"year": vehicle.year,
|
||||
"color": vehicle.color,
|
||||
}
|
||||
|
||||
try:
|
||||
result = await retouch_service.upload_retouch_file(
|
||||
db=db,
|
||||
file_bytes=file_bytes,
|
||||
file_name=file.filename or "upload.png",
|
||||
mime_type=mime_type,
|
||||
vehicle_id=parsed_vehicle_id,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail={"error": {"code": "UPLOAD_FAILED", "message": str(exc)}},
|
||||
)
|
||||
|
||||
# Queue background processing
|
||||
background_tasks.add_task(run_retouch_processing, result.id, vehicle_info)
|
||||
|
||||
return RetouchProcessResponse(
|
||||
message="Retouch processing queued",
|
||||
retouch_id=result.id,
|
||||
status=result.status.value if isinstance(result.status, RetouchStatus) else str(result.status),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/results/{result_id}",
|
||||
response_model=RetouchResultResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def get_retouch_result(
|
||||
result_id: uuid.UUID,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Get a single retouch result by ID.
|
||||
|
||||
Returns status, original and retouched file paths.
|
||||
If processing is still ongoing, status will be 'processing'.
|
||||
If processing failed, status will be 'failed' with error_message.
|
||||
"""
|
||||
result = await retouch_service.get_result(db, result_id)
|
||||
if result is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "RETOUCH_NOT_FOUND", "message": "Retouch result not found"}},
|
||||
)
|
||||
return RetouchResultResponse.model_validate(result)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/price-compare",
|
||||
response_model=PriceCompareResponse,
|
||||
status_code=status.HTTP_200_OK,
|
||||
)
|
||||
async def price_compare(
|
||||
body: PriceCompareRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""Compare prices for a vehicle by searching comparable listings.
|
||||
|
||||
Returns comparable listings and average price.
|
||||
If no comparable listings found, returns empty list with null average.
|
||||
"""
|
||||
try:
|
||||
result = await price_compare_service.compare_prices(db, body.vehicle_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail={"error": {"code": "VEHICLE_NOT_FOUND", "message": str(exc)}},
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Pydantic schemas for image retouch and price comparison endpoints."""
|
||||
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class RetouchProcessResponse(BaseModel):
|
||||
"""Response for POST /api/v1/retouch/process."""
|
||||
|
||||
message: str = "Retouch processing queued"
|
||||
retouch_id: uuid.UUID
|
||||
status: str = "pending"
|
||||
|
||||
|
||||
class RetouchResultResponse(BaseModel):
|
||||
"""Response for GET /api/v1/retouch/results/:id."""
|
||||
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: uuid.UUID
|
||||
vehicle_id: Optional[uuid.UUID] = None
|
||||
original_file_path: str
|
||||
original_file_name: str
|
||||
mime_type: str
|
||||
retouched_file_path: Optional[str] = None
|
||||
status: str
|
||||
error_message: Optional[str] = None
|
||||
created_at: Optional[datetime] = None
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class PriceCompareRequest(BaseModel):
|
||||
"""Request body for POST /api/v1/retouch/price-compare."""
|
||||
|
||||
vehicle_id: uuid.UUID = Field(..., description="Vehicle ID to compare prices for")
|
||||
|
||||
|
||||
class ComparableListing(BaseModel):
|
||||
"""A single comparable listing from mobile.de or mock data."""
|
||||
|
||||
title: str
|
||||
make: str
|
||||
model: str
|
||||
year: Optional[int] = None
|
||||
price: float
|
||||
mileage_km: Optional[int] = None
|
||||
location: Optional[str] = None
|
||||
url: Optional[str] = None
|
||||
source: str = "mobile.de"
|
||||
|
||||
|
||||
class PriceCompareResponse(BaseModel):
|
||||
"""Response for POST /api/v1/retouch/price-compare."""
|
||||
|
||||
vehicle_id: uuid.UUID
|
||||
comparable_listings: list[ComparableListing] = Field(default_factory=list)
|
||||
average_price: Optional[float] = None
|
||||
listing_count: int = 0
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Price comparison service: search comparable listings and calculate average price."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import random
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models.vehicle import Vehicle
|
||||
from app.schemas.retouch import ComparableListing, PriceCompareResponse
|
||||
from app.services.vehicle_service import get_vehicle_by_id
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _generate_mock_listings(vehicle: Vehicle) -> list[ComparableListing]:
|
||||
"""Generate mock mobile.de comparable listings based on vehicle data.
|
||||
|
||||
In production, this would call the mobile.de Search API.
|
||||
For now, we generate realistic mock data based on the vehicle's
|
||||
make, model, year, and price.
|
||||
"""
|
||||
listings: list[ComparableListing] = []
|
||||
base_price = float(vehicle.price) if vehicle.price else 25000.0
|
||||
|
||||
# Generate 3-7 comparable listings with price variation
|
||||
num_listings = random.randint(3, 7)
|
||||
|
||||
for i in range(num_listings):
|
||||
# Vary price by +/- 15%
|
||||
price_variation = random.uniform(-0.15, 0.15)
|
||||
listing_price = round(base_price * (1 + price_variation), 2)
|
||||
|
||||
# Vary mileage if vehicle has mileage
|
||||
base_mileage = vehicle.mileage_km or 100000
|
||||
mileage_variation = random.randint(-20000, 20000)
|
||||
listing_mileage = max(0, base_mileage + mileage_variation)
|
||||
|
||||
locations = ["Berlin", "Hamburg", "München", "Köln", "Frankfurt", "Stuttgart"]
|
||||
|
||||
listing = ComparableListing(
|
||||
title=f"{vehicle.make} {vehicle.model} {vehicle.year or ''}".strip(),
|
||||
make=vehicle.make,
|
||||
model=vehicle.model,
|
||||
year=vehicle.year,
|
||||
price=listing_price,
|
||||
mileage_km=listing_mileage,
|
||||
location=random.choice(locations),
|
||||
url=f"https://suchen.mobile.de/fahrzeuge/details.html?id={random.randint(1000000, 9999999)}",
|
||||
source="mobile.de",
|
||||
)
|
||||
listings.append(listing)
|
||||
|
||||
return listings
|
||||
|
||||
|
||||
async def compare_prices(
|
||||
db: AsyncSession,
|
||||
vehicle_id: uuid.UUID,
|
||||
) -> PriceCompareResponse:
|
||||
"""Compare prices for a vehicle by searching comparable listings.
|
||||
|
||||
Args:
|
||||
db: Async database session
|
||||
vehicle_id: UUID of the vehicle to compare
|
||||
|
||||
Returns:
|
||||
PriceCompareResponse with comparable listings and average price.
|
||||
If vehicle not found, raises ValueError.
|
||||
If no comparable listings found, returns empty list with null average.
|
||||
"""
|
||||
vehicle = await get_vehicle_by_id(db, vehicle_id)
|
||||
if vehicle is None:
|
||||
raise ValueError(f"Vehicle {vehicle_id} not found")
|
||||
|
||||
# Search for comparable listings
|
||||
# In production: call mobile.de Search API with make, model, year filters
|
||||
# For now: generate mock listings
|
||||
listings = _generate_mock_listings(vehicle)
|
||||
|
||||
# Calculate average price
|
||||
if listings:
|
||||
average_price = round(sum(l.price for l in listings) / len(listings), 2)
|
||||
else:
|
||||
average_price = None
|
||||
|
||||
return PriceCompareResponse(
|
||||
vehicle_id=vehicle_id,
|
||||
comparable_listings=listings,
|
||||
average_price=average_price,
|
||||
listing_count=len(listings),
|
||||
)
|
||||
@@ -0,0 +1,294 @@
|
||||
"""Retouch service: upload, process via Flux.1-Pro, and retrieve results."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import logging
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings
|
||||
from app.models.retouch import RetouchResult, RetouchStatus
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_MIME_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp"}
|
||||
|
||||
RETOUCH_SYSTEM_PROMPT = (
|
||||
"You are an expert automotive photo retoucher. "
|
||||
"Enhance the provided vehicle image with the following improvements: "
|
||||
"1. Remove or replace the background with a clean, professional studio backdrop. "
|
||||
"2. Correct color balance and enhance saturation for a vibrant, realistic look. "
|
||||
"3. Remove reflections and glare from windows and bodywork. "
|
||||
"4. Clean up minor blemishes, dust, and scratches on the vehicle surface. "
|
||||
"5. Ensure the vehicle is well-lit and centered. "
|
||||
"Return the enhanced image."
|
||||
)
|
||||
|
||||
|
||||
def validate_mime_type(mime_type: str) -> bool:
|
||||
"""Check if the MIME type is an allowed image type."""
|
||||
return mime_type.lower() in ALLOWED_MIME_TYPES
|
||||
|
||||
|
||||
def validate_file_size(file_size: int) -> bool:
|
||||
"""Check if file size is within the configured limit."""
|
||||
max_bytes = settings.MAX_FILE_SIZE_MB * 1024 * 1024
|
||||
return file_size <= max_bytes
|
||||
|
||||
|
||||
def _get_file_extension(mime_type: str) -> str:
|
||||
"""Map MIME type to file extension."""
|
||||
mapping = {
|
||||
"image/png": ".png",
|
||||
"image/jpeg": ".jpg",
|
||||
"image/jpg": ".jpg",
|
||||
"image/webp": ".webp",
|
||||
}
|
||||
return mapping.get(mime_type.lower(), ".png")
|
||||
|
||||
|
||||
def generate_retouch_prompt(vehicle_info: dict[str, Any] | None = None) -> str:
|
||||
"""Generate a retouch prompt based on optional vehicle info.
|
||||
|
||||
Args:
|
||||
vehicle_info: Optional dict with make, model, year, color etc.
|
||||
|
||||
Returns:
|
||||
A detailed prompt string for the image retouch model.
|
||||
"""
|
||||
base_prompt = (
|
||||
"Professional automotive photograph retouching: "
|
||||
"Remove the original background and replace with a clean white studio backdrop. "
|
||||
"Enhance color correction for realistic, vibrant tones. "
|
||||
"Remove reflections and glare from windows and paint. "
|
||||
"Clean up dust, scratches, and minor blemishes on the bodywork. "
|
||||
"Ensure even, professional lighting across the entire vehicle. "
|
||||
"Sharpen details on wheels, grille, and badges."
|
||||
)
|
||||
|
||||
if vehicle_info:
|
||||
parts = [base_prompt]
|
||||
make = vehicle_info.get("make")
|
||||
model = vehicle_info.get("model")
|
||||
color = vehicle_info.get("color")
|
||||
if make and model:
|
||||
parts.append(f"The vehicle is a {make} {model}.")
|
||||
if color:
|
||||
parts.append(f"The vehicle color is {color}; ensure it looks accurate and rich.")
|
||||
return " ".join(parts)
|
||||
|
||||
return base_prompt
|
||||
|
||||
|
||||
async def upload_retouch_file(
|
||||
db: AsyncSession,
|
||||
file_bytes: bytes,
|
||||
file_name: str,
|
||||
mime_type: str,
|
||||
vehicle_id: uuid.UUID | None = None,
|
||||
) -> RetouchResult:
|
||||
"""Save uploaded image and create a RetouchResult record.
|
||||
|
||||
Raises ValueError for invalid MIME type or file size.
|
||||
"""
|
||||
if not validate_mime_type(mime_type):
|
||||
raise ValueError(f"Invalid MIME type: {mime_type}. Only image/* types are allowed.")
|
||||
|
||||
if not validate_file_size(len(file_bytes)):
|
||||
raise ValueError(f"File size exceeds limit of {settings.MAX_FILE_SIZE_MB} MB")
|
||||
|
||||
upload_dir = settings.UPLOAD_DIR
|
||||
os.makedirs(upload_dir, exist_ok=True)
|
||||
|
||||
ext = _get_file_extension(mime_type)
|
||||
stored_filename = f"retouch_{uuid.uuid4().hex}{ext}"
|
||||
file_path = os.path.join(upload_dir, stored_filename)
|
||||
|
||||
with open(file_path, "wb") as f:
|
||||
f.write(file_bytes)
|
||||
|
||||
result = RetouchResult(
|
||||
original_file_path=file_path,
|
||||
original_file_name=file_name,
|
||||
mime_type=mime_type,
|
||||
status=RetouchStatus.pending.value,
|
||||
vehicle_id=vehicle_id,
|
||||
)
|
||||
db.add(result)
|
||||
await db.flush()
|
||||
await db.refresh(result)
|
||||
return result
|
||||
|
||||
|
||||
async def get_result(db: AsyncSession, result_id: uuid.UUID) -> RetouchResult | None:
|
||||
"""Get a retouch result by ID."""
|
||||
stmt = select(RetouchResult).where(RetouchResult.id == result_id)
|
||||
res = await db.execute(stmt)
|
||||
return res.scalar_one_or_none()
|
||||
|
||||
|
||||
async def list_results(
|
||||
db: AsyncSession,
|
||||
vehicle_id: uuid.UUID | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[RetouchResult], int]:
|
||||
"""List retouch results with optional vehicle filter and pagination."""
|
||||
count_stmt = select(func.count(RetouchResult.id))
|
||||
data_stmt = select(RetouchResult)
|
||||
|
||||
if vehicle_id is not None:
|
||||
count_stmt = count_stmt.where(RetouchResult.vehicle_id == vehicle_id)
|
||||
data_stmt = data_stmt.where(RetouchResult.vehicle_id == vehicle_id)
|
||||
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
data_stmt = data_stmt.order_by(RetouchResult.created_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(data_stmt)
|
||||
items = list(result.scalars().all())
|
||||
|
||||
return items, total
|
||||
|
||||
|
||||
async def process_retouch(
|
||||
db: AsyncSession,
|
||||
result_id: uuid.UUID,
|
||||
vehicle_info: dict[str, Any] | None = None,
|
||||
) -> RetouchResult:
|
||||
"""Process a retouch result by sending the image to Flux.1-Pro via OpenRouter.
|
||||
|
||||
Updates the result status to processing, calls the API, saves the
|
||||
retouched image, and sets status to completed. On failure, sets
|
||||
status to failed with an error message.
|
||||
"""
|
||||
result = await get_result(db, result_id)
|
||||
if result is None:
|
||||
raise ValueError(f"Retouch result {result_id} not found")
|
||||
|
||||
result.status = RetouchStatus.processing.value
|
||||
await db.flush()
|
||||
|
||||
try:
|
||||
with open(result.original_file_path, "rb") as f:
|
||||
image_bytes = f.read()
|
||||
|
||||
retouched_bytes = await _call_flux_pro(
|
||||
image_bytes=image_bytes,
|
||||
mime_type=result.mime_type,
|
||||
prompt=generate_retouch_prompt(vehicle_info),
|
||||
)
|
||||
|
||||
upload_dir = settings.UPLOAD_DIR
|
||||
ext = _get_file_extension(result.mime_type)
|
||||
retouched_filename = f"retouched_{result_id.hex}{ext}"
|
||||
retouched_path = os.path.join(upload_dir, retouched_filename)
|
||||
|
||||
with open(retouched_path, "wb") as f:
|
||||
f.write(retouched_bytes)
|
||||
|
||||
result.retouched_file_path = retouched_path
|
||||
result.status = RetouchStatus.completed.value
|
||||
result.error_message = None
|
||||
await db.flush()
|
||||
await db.refresh(result)
|
||||
logger.info("Retouch completed for result %s", result_id)
|
||||
return result
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("Retouch failed for result %s: %s", result_id, exc)
|
||||
result.status = RetouchStatus.failed.value
|
||||
result.error_message = str(exc)
|
||||
await db.flush()
|
||||
await db.refresh(result)
|
||||
return result
|
||||
|
||||
|
||||
async def _call_flux_pro(
|
||||
image_bytes: bytes,
|
||||
mime_type: str,
|
||||
prompt: str,
|
||||
api_key: str | None = None,
|
||||
model: str | None = None,
|
||||
) -> bytes:
|
||||
"""Send image to Flux.1-Pro via OpenRouter for retouching.
|
||||
|
||||
Returns the retouched image bytes.
|
||||
Raises httpx.HTTPStatusError or ValueError on failure.
|
||||
"""
|
||||
key = api_key or settings.OPENROUTER_API_KEY
|
||||
if not key:
|
||||
raise ValueError("OPENROUTER_API_KEY is not configured")
|
||||
|
||||
model_name = model or "black-forest-labs/flux-1-pro"
|
||||
b64_image = base64.b64encode(image_bytes).decode("utf-8")
|
||||
data_uri = f"data:{mime_type};base64,{b64_image}"
|
||||
|
||||
headers = {
|
||||
"Authorization": f"Bearer {key}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
payload: dict[str, Any] = {
|
||||
"model": model_name,
|
||||
"messages": [
|
||||
{
|
||||
"role": "system",
|
||||
"content": RETOUCH_SYSTEM_PROMPT,
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "text",
|
||||
"text": prompt,
|
||||
},
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": data_uri},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
"temperature": 0.7,
|
||||
"max_tokens": 4096,
|
||||
}
|
||||
|
||||
base_url = settings.OPENROUTER_BASE_URL.rstrip("/")
|
||||
url = f"{base_url}/chat/completions"
|
||||
|
||||
async with httpx.AsyncClient(timeout=httpx.Timeout(120.0)) as client:
|
||||
response = await client.post(url, headers=headers, json=payload)
|
||||
response.raise_for_status()
|
||||
|
||||
body = response.json()
|
||||
content = body.get("choices", [{}])[0].get("message", {}).get("content", "")
|
||||
|
||||
# The model may return a base64-encoded image or a URL
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get("type") == "image_url":
|
||||
url_or_data = part.get("image_url", {}).get("url", "")
|
||||
if url_or_data.startswith("data:"):
|
||||
b64_data = url_or_data.split(",", 1)[1]
|
||||
return base64.b64decode(b64_data)
|
||||
elif url_or_data.startswith("http"):
|
||||
async with httpx.AsyncClient(timeout=60.0) as dl_client:
|
||||
dl_resp = await dl_client.get(url_or_data)
|
||||
dl_resp.raise_for_status()
|
||||
return dl_resp.content
|
||||
|
||||
if isinstance(content, str) and content.startswith("data:"):
|
||||
b64_data = content.split(",", 1)[1]
|
||||
return base64.b64decode(b64_data)
|
||||
|
||||
# Fallback: if no image returned, return original bytes
|
||||
logger.warning("Flux.1-Pro did not return an image, returning original bytes")
|
||||
return image_bytes
|
||||
@@ -0,0 +1,38 @@
|
||||
"""Async retouch processing background task.
|
||||
|
||||
Creates its own DB session independent of the request session so the
|
||||
HTTP response can return immediately while processing continues.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.database import async_session_factory
|
||||
from app.services.retouch_service import process_retouch
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def run_retouch_processing(
|
||||
result_id: uuid.UUID,
|
||||
vehicle_info: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Background task: process retouch result asynchronously.
|
||||
|
||||
Creates its own DB session (independent of the request session)
|
||||
so the HTTP response can return immediately.
|
||||
"""
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
await process_retouch(session, result_id, vehicle_info)
|
||||
await session.commit()
|
||||
logger.info("Retouch processing completed for result %s", result_id)
|
||||
except Exception as exc:
|
||||
await session.rollback()
|
||||
logger.error("Retouch background task failed for %s: %s", result_id, exc)
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
Reference in New Issue
Block a user