Files
AI-Video/api/routers/quality.py
empty 3f59b324ad fix: Remove hardcoded ports, support custom port configuration
- Replace all hardcoded localhost:8000/3000/8501 with environment variables
- Frontend: Use API_PORT env var in next.config.ts
- Backend: Use API_PORT env var in editor.py and quality.py
- Web UI: Use API_PORT and EDITOR_PORT env vars in all Streamlit pages
- Update dev.sh to pass environment variables to all services
- Add .env.example with port configuration template

Now supports custom ports via environment variables:
  API_PORT=8080 EDITOR_PORT=3001 WEB_PORT=8502 ./dev.sh

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-01-10 16:13:02 +08:00

515 lines
17 KiB
Python

# Copyright (C) 2025 AIDC-AI
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
# http://www.apache.org/licenses/LICENSE-2.0
"""
Quality API router for AI quality features
Provides endpoints for:
- Character memory management
- Content filtering
- Style guard
- Quality gate evaluation
"""
from fastapi import APIRouter, HTTPException, Path, Body, File, UploadFile, Query
from pydantic import BaseModel, Field
from typing import List, Optional
from loguru import logger
import os
import shutil
from datetime import datetime
router = APIRouter(prefix="/quality", tags=["Quality"])
# ============================================================
# Schemas
# ============================================================
class CharacterSchema(BaseModel):
"""Character data"""
id: str
name: str
appearance_description: str = ""
clothing_description: str = ""
distinctive_features: List[str] = []
character_type: str = "person"
reference_image: Optional[str] = None
class CharacterCreateRequest(BaseModel):
"""Request to create a character"""
name: str = Field(..., description="Character name")
appearance_description: str = Field("", description="Visual appearance")
clothing_description: str = Field("", description="Clothing description")
distinctive_features: List[str] = Field(default_factory=list)
character_type: str = Field("person")
reference_image_path: Optional[str] = Field(None, description="Reference image path for VLM analysis")
class CharacterAnalyzeRequest(BaseModel):
"""Request to analyze a character image"""
image_path: str = Field(..., description="Path to the reference image")
class CharacterAnalyzeResponse(BaseModel):
"""Response from character image analysis"""
appearance_description: str = ""
clothing_description: str = ""
distinctive_features: List[str] = []
prompt_description: str = "" # Combined description for prompt injection
class ContentCheckRequest(BaseModel):
"""Request to check content"""
text: str = Field(..., description="Text to check")
class ContentCheckResponse(BaseModel):
"""Response for content check"""
passed: bool
category: str # safe, sensitive, blocked
flagged_items: List[str] = []
reason: Optional[str] = None
class StyleAnchorSchema(BaseModel):
"""Style anchor data"""
color_palette: str = ""
art_style: str = ""
composition_style: str = ""
texture: str = ""
lighting: str = ""
style_prefix: str = ""
reference_image: Optional[str] = None
class QualityScoreSchema(BaseModel):
"""Quality evaluation result"""
overall_score: float
aesthetic_score: float = 0.0
alignment_score: float = 0.0
technical_score: float = 0.0
passed: bool
issues: List[str] = []
# ============================================================
# In-memory storage (per storyboard)
# ============================================================
_character_stores: dict = {} # storyboard_id -> {char_id -> Character}
_style_anchors: dict = {} # storyboard_id -> StyleAnchor
# ============================================================
# Character Memory Endpoints
# ============================================================
@router.get(
"/characters/{storyboard_id}",
response_model=List[CharacterSchema]
)
async def get_characters(
storyboard_id: str = Path(..., description="Storyboard ID")
):
"""Get all characters for a storyboard"""
if storyboard_id not in _character_stores:
return []
return list(_character_stores[storyboard_id].values())
@router.post(
"/characters/{storyboard_id}",
response_model=CharacterSchema
)
async def create_character(
storyboard_id: str = Path(..., description="Storyboard ID"),
request: CharacterCreateRequest = Body(...)
):
"""
Register a new character
If reference_image_path is provided and appearance_description is empty,
VLM will analyze the image to extract appearance descriptions automatically.
"""
import uuid
if storyboard_id not in _character_stores:
_character_stores[storyboard_id] = {}
# Auto-analyze reference image if provided and no description
appearance_desc = request.appearance_description
clothing_desc = request.clothing_description
distinctive = request.distinctive_features
ref_image = request.reference_image_path
if ref_image and not appearance_desc:
try:
from pixelle_video.services.quality.character_analyzer import CharacterAnalyzer
analyzer = CharacterAnalyzer()
result = await analyzer.analyze_reference_image(ref_image)
if result.appearance_description:
appearance_desc = result.appearance_description
if result.clothing_description:
clothing_desc = result.clothing_description
if result.distinctive_features:
distinctive = result.distinctive_features
logger.info(f"Auto-analyzed character from image: {ref_image}")
except Exception as e:
logger.warning(f"Failed to auto-analyze character image: {e}")
char_id = f"char_{uuid.uuid4().hex[:8]}"
character = CharacterSchema(
id=char_id,
name=request.name,
appearance_description=appearance_desc,
clothing_description=clothing_desc,
distinctive_features=distinctive,
character_type=request.character_type,
reference_image=ref_image,
)
_character_stores[storyboard_id][char_id] = character.model_dump()
logger.info(f"Created character {request.name} for storyboard {storyboard_id}")
return character
@router.put(
"/characters/{storyboard_id}/{char_id}",
response_model=CharacterSchema
)
async def update_character(
storyboard_id: str = Path(..., description="Storyboard ID"),
char_id: str = Path(..., description="Character ID"),
request: CharacterCreateRequest = Body(...)
):
"""Update a character"""
if storyboard_id not in _character_stores:
raise HTTPException(status_code=404, detail="Storyboard not found")
if char_id not in _character_stores[storyboard_id]:
raise HTTPException(status_code=404, detail="Character not found")
character = CharacterSchema(
id=char_id,
name=request.name,
appearance_description=request.appearance_description,
clothing_description=request.clothing_description,
distinctive_features=request.distinctive_features,
character_type=request.character_type,
)
_character_stores[storyboard_id][char_id] = character.model_dump()
return character
@router.delete("/characters/{storyboard_id}/{char_id}")
async def delete_character(
storyboard_id: str = Path(..., description="Storyboard ID"),
char_id: str = Path(..., description="Character ID")
):
"""Delete a character"""
if storyboard_id not in _character_stores:
raise HTTPException(status_code=404, detail="Storyboard not found")
if char_id not in _character_stores[storyboard_id]:
raise HTTPException(status_code=404, detail="Character not found")
del _character_stores[storyboard_id][char_id]
return {"deleted": True}
@router.post(
"/characters/{storyboard_id}/analyze-image",
response_model=CharacterAnalyzeResponse
)
async def analyze_character_image(
storyboard_id: str = Path(..., description="Storyboard ID"),
request: CharacterAnalyzeRequest = Body(...)
):
"""
Analyze a character reference image using VLM
Extracts detailed appearance descriptions that can be used
to maintain character consistency across frames.
"""
from pixelle_video.services.quality.character_analyzer import CharacterAnalyzer
logger.info(f"Analyzing character image for storyboard {storyboard_id}: {request.image_path}")
analyzer = CharacterAnalyzer()
result = await analyzer.analyze_reference_image(request.image_path)
return CharacterAnalyzeResponse(
appearance_description=result.appearance_description,
clothing_description=result.clothing_description,
distinctive_features=result.distinctive_features,
prompt_description=result.to_prompt_description()
)
@router.post("/upload")
async def upload_file(
file: UploadFile = File(...),
storyboard_id: str = Query(..., description="Storyboard ID"),
type: str = Query("character", description="File type (character, reference)")
):
"""
Upload a file for character reference or other purposes.
Returns the saved file path that can be used for analysis.
"""
try:
# Create output directory
output_dir = f"output/{storyboard_id}"
os.makedirs(output_dir, exist_ok=True)
# Generate filename with timestamp
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f")
ext = os.path.splitext(file.filename)[1] or ".png"
filename = f"{type}_{timestamp}{ext}"
file_path = os.path.join(output_dir, filename)
# Save file
with open(file_path, "wb") as buffer:
content = await file.read()
buffer.write(content)
logger.info(f"Uploaded file to: {file_path}")
return {
"success": True,
"path": file_path,
"file_path": file_path,
"filename": filename
}
except Exception as e:
logger.error(f"Failed to upload file: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ============================================================
# Content Filter Endpoints
# ============================================================
@router.post(
"/check-content",
response_model=ContentCheckResponse
)
async def check_content(request: ContentCheckRequest):
"""Check text content for safety"""
from pixelle_video.services.quality.content_filter import ContentFilter
try:
content_filter = ContentFilter()
result = content_filter.check_text(request.text)
return ContentCheckResponse(
passed=result.passed,
category=result.category.value,
flagged_items=result.flagged_items,
reason=result.reason
)
except Exception as e:
logger.error(f"Content check failed: {e}")
# Default to safe if filter fails
return ContentCheckResponse(
passed=True,
category="safe",
flagged_items=[],
reason=None
)
# ============================================================
# Style Guard Endpoints
# ============================================================
@router.post(
"/extract-style/{storyboard_id}",
response_model=StyleAnchorSchema
)
async def extract_style(
storyboard_id: str = Path(..., description="Storyboard ID"),
image_path: str = Body(..., embed=True, description="Reference image path")
):
"""Extract style anchor from reference image"""
try:
# Convert URL to file path if needed
actual_path = image_path
if image_path.startswith("http"):
# Extract path from URL (format: http://localhost:{port}/api/files/{relative_path})
if "/api/files/" in image_path:
actual_path = "output/" + image_path.split("/api/files/")[-1]
# Check if file exists
import os
if not os.path.exists(actual_path):
logger.warning(f"Image file not found: {actual_path}, using default style")
# Return default style instead of failing
style_schema = StyleAnchorSchema(
color_palette="vibrant",
art_style="digital illustration",
composition_style="centered",
texture="smooth",
lighting="soft natural light",
style_prefix="high quality, detailed, vibrant colors",
)
_style_anchors[storyboard_id] = style_schema.model_dump()
return style_schema
from pixelle_video.services.quality.style_guard import StyleGuard
from api.dependencies import get_pixelle_video
# Get LLM service for VLM-based style extraction
pixelle_video = await get_pixelle_video()
llm_service = pixelle_video.llm if pixelle_video else None
style_guard = StyleGuard(llm_service=llm_service)
anchor = await style_guard.extract_style_anchor(actual_path)
style_schema = StyleAnchorSchema(
color_palette=anchor.color_palette,
art_style=anchor.art_style,
composition_style=anchor.composition_style,
texture=anchor.texture,
lighting=anchor.lighting,
style_prefix=anchor.style_prefix,
reference_image=anchor.reference_image
)
_style_anchors[storyboard_id] = style_schema.model_dump()
logger.info(f"Extracted style for storyboard {storyboard_id}")
return style_schema
except Exception as e:
logger.error(f"Style extraction failed: {e}")
# Return default style instead of failing
style_schema = StyleAnchorSchema(
color_palette="vibrant",
art_style="digital illustration",
composition_style="centered",
texture="smooth",
lighting="soft natural light",
style_prefix="high quality, detailed",
)
_style_anchors[storyboard_id] = style_schema.model_dump()
return style_schema
@router.get(
"/style/{storyboard_id}",
response_model=StyleAnchorSchema
)
async def get_style(
storyboard_id: str = Path(..., description="Storyboard ID")
):
"""Get current style anchor for storyboard"""
if storyboard_id not in _style_anchors:
raise HTTPException(status_code=404, detail="No style anchor found")
return StyleAnchorSchema(**_style_anchors[storyboard_id])
class ApplyStyleRequest(BaseModel):
"""Request to apply style"""
prompt: str = Field(..., description="Image prompt to style")
class ApplyStyleResponse(BaseModel):
"""Response with styled prompt"""
styled_prompt: str
@router.post(
"/apply-style/{storyboard_id}",
response_model=ApplyStyleResponse
)
async def apply_style(
storyboard_id: str = Path(..., description="Storyboard ID"),
request: ApplyStyleRequest = Body(...)
):
"""Apply style anchor to a prompt"""
if storyboard_id not in _style_anchors:
# Return original if no style
return ApplyStyleResponse(styled_prompt=request.prompt)
from pixelle_video.services.quality.style_guard import StyleGuard, StyleAnchor
try:
style_guard = StyleGuard()
anchor_data = _style_anchors[storyboard_id]
anchor = StyleAnchor(
color_palette=anchor_data.get("color_palette", ""),
art_style=anchor_data.get("art_style", ""),
composition_style=anchor_data.get("composition_style", ""),
texture=anchor_data.get("texture", ""),
lighting=anchor_data.get("lighting", ""),
style_prefix=anchor_data.get("style_prefix", ""),
)
styled = style_guard.apply_style(request.prompt, anchor)
return ApplyStyleResponse(styled_prompt=styled)
except Exception as e:
logger.error(f"Apply style failed: {e}")
return ApplyStyleResponse(styled_prompt=request.prompt)
# ============================================================
# Quality Gate Endpoints
# ============================================================
class EvaluateImageRequest(BaseModel):
"""Request to evaluate image"""
image_path: str = Field(..., description="Path to image")
prompt: str = Field(..., description="Prompt used to generate")
narration: Optional[str] = Field(None, description="Associated narration")
@router.post(
"/evaluate-image",
response_model=QualityScoreSchema
)
async def evaluate_image(request: EvaluateImageRequest):
"""Evaluate image quality"""
from pixelle_video.services.quality.quality_gate import QualityGate
try:
quality_gate = QualityGate()
score = quality_gate.evaluate_image(
image_path=request.image_path,
prompt=request.prompt,
narration=request.narration
)
return QualityScoreSchema(
overall_score=score.overall_score,
aesthetic_score=score.aesthetic_score,
alignment_score=score.alignment_score,
technical_score=score.technical_score,
passed=score.passed,
issues=score.issues
)
except Exception as e:
logger.error(f"Image evaluation failed: {e}")
# Default passing score
return QualityScoreSchema(
overall_score=0.7,
passed=True,
issues=[]
)