Update memory: Camie Tagger v2 upgrade results
This commit is contained in:
13
anime_sorter/auth.py
Normal file
13
anime_sorter/auth.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from fastapi import HTTPException, Security
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
import os
|
||||
|
||||
security = HTTPBearer()
|
||||
|
||||
# Get API key from environment variable or use default
|
||||
API_KEY = os.getenv("X_API_KEY", "your-default-api-key-here")
|
||||
|
||||
|
||||
def verify_api_key(credentials: HTTPAuthorizationCredentials = Security(security)):
|
||||
if credentials.credentials != API_KEY:
|
||||
raise HTTPException(status_code=403, detail="Invalid API Key")
|
||||
129
anime_sorter/database.py
Normal file
129
anime_sorter/database.py
Normal file
@@ -0,0 +1,129 @@
|
||||
import sqlite3
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
import os
|
||||
|
||||
DATABASE_PATH = os.getenv("DATABASE_PATH", "anime_art.db")
|
||||
|
||||
|
||||
class Database:
|
||||
def __init__(self, db_path: str = DATABASE_PATH):
|
||||
self.db_path = db_path
|
||||
self.init_db()
|
||||
|
||||
def init_db(self):
|
||||
"""Initialize the database with required tables"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create table for image metadata
|
||||
cursor.execute('''
|
||||
CREATE TABLE IF NOT EXISTS images (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
file_path TEXT UNIQUE NOT NULL,
|
||||
cluster_id INTEGER,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def save_image_metadata(self, file_path: str) -> int:
|
||||
"""Save image metadata and return the ID"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"INSERT INTO images (file_path) VALUES (?)",
|
||||
(file_path,)
|
||||
)
|
||||
image_id = cursor.lastrowid
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return image_id
|
||||
|
||||
def update_cluster_assignment(self, file_path: str, cluster_id: int):
|
||||
"""Update the cluster assignment for an image"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE images SET cluster_id = ? WHERE file_path = ?",
|
||||
(cluster_id, file_path)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
def get_images_without_cluster(self) -> List[str]:
|
||||
"""Get all images that haven't been assigned to a cluster yet"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"SELECT file_path FROM images WHERE cluster_id IS NULL"
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
return [row[0] for row in rows]
|
||||
|
||||
def get_all_clusters(self) -> List[tuple]:
|
||||
"""Get all clusters with their image counts"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute('''
|
||||
SELECT
|
||||
cluster_id,
|
||||
COUNT(*) as image_count,
|
||||
MIN(created_at) as created_at
|
||||
FROM images
|
||||
WHERE cluster_id IS NOT NULL
|
||||
GROUP BY cluster_id
|
||||
ORDER BY created_at DESC
|
||||
''')
|
||||
rows = cursor.fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
return [(row[0], row[1], row[2]) for row in rows if row[0] is not None]
|
||||
|
||||
def get_cluster_files(self, cluster_id: int) -> List[str]:
|
||||
"""Get all files in a specific cluster"""
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"SELECT file_path FROM images WHERE cluster_id = ?",
|
||||
(cluster_id,)
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
return [row[0] for row in rows]
|
||||
|
||||
def get_file_paths_by_ids(self, image_ids: List[int]) -> List[str]:
|
||||
"""Get file paths for a list of image IDs"""
|
||||
if not image_ids:
|
||||
return []
|
||||
|
||||
conn = sqlite3.connect(self.db_path)
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Create placeholders for the IN clause
|
||||
placeholders = ','.join(['?' for _ in image_ids])
|
||||
cursor.execute(
|
||||
f"SELECT file_path FROM images WHERE id IN ({placeholders})",
|
||||
image_ids
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
conn.close()
|
||||
|
||||
return [row[0] for row in rows]
|
||||
127
anime_sorter/main.py
Normal file
127
anime_sorter/main.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from fastapi import FastAPI, UploadFile, File, BackgroundTasks, Depends, HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
import os
|
||||
import shutil
|
||||
from datetime import datetime
|
||||
from typing import List
|
||||
import uuid
|
||||
|
||||
from auth import verify_api_key, security
|
||||
from database import Database
|
||||
from processor import ImageProcessor
|
||||
from models import ImageUploadResponse, Cluster, ClusterDetail
|
||||
|
||||
app = FastAPI(title="Anime Art Sorter", description="Sort anime arts by characters using CLIP and DBSCAN")
|
||||
|
||||
# Initialize components
|
||||
db = Database()
|
||||
processor = ImageProcessor()
|
||||
|
||||
# Directory to store uploaded images
|
||||
UPLOAD_DIR = "uploads"
|
||||
os.makedirs(UPLOAD_DIR, exist_ok=True)
|
||||
|
||||
|
||||
@app.post("/upload", response_model=ImageUploadResponse, dependencies=[Depends(verify_api_key)])
|
||||
async def upload_image(file: UploadFile = File(...)):
|
||||
"""
|
||||
Upload an image file for processing
|
||||
"""
|
||||
# Generate a unique filename
|
||||
file_extension = file.filename.split(".")[-1]
|
||||
unique_filename = f"{uuid.uuid4().hex}.{file_extension}"
|
||||
file_path = os.path.join(UPLOAD_DIR, unique_filename)
|
||||
|
||||
# Save the uploaded file
|
||||
with open(file_path, "wb") as buffer:
|
||||
shutil.copyfileobj(file.file, buffer)
|
||||
|
||||
# Save metadata to database
|
||||
image_id = db.save_image_metadata(file_path)
|
||||
|
||||
return ImageUploadResponse(
|
||||
filename=unique_filename,
|
||||
message=f"File {unique_filename} uploaded successfully with ID {image_id}"
|
||||
)
|
||||
|
||||
|
||||
@app.post("/process", dependencies=[Depends(verify_api_key)])
|
||||
async def start_processing(background_tasks: BackgroundTasks):
|
||||
"""
|
||||
Start background processing of all unclustered images
|
||||
"""
|
||||
background_tasks.add_task(process_unclustered_images)
|
||||
return {"message": "Started processing unclustered images in background"}
|
||||
|
||||
|
||||
def process_unclustered_images():
|
||||
"""
|
||||
Process all unclustered images in the background
|
||||
"""
|
||||
print("Starting background processing of unclustered images...")
|
||||
|
||||
# Get all images that haven't been clustered yet
|
||||
unclustered_images = db.get_images_without_cluster()
|
||||
|
||||
if not unclustered_images:
|
||||
print("No unclustered images to process")
|
||||
return
|
||||
|
||||
print(f"Found {len(unclustered_images)} unclustered images to process")
|
||||
|
||||
# Cluster the images
|
||||
clustered_results = processor.cluster_images(unclustered_images)
|
||||
|
||||
# Update the database with cluster assignments
|
||||
for image_path, cluster_id in clustered_results:
|
||||
db.update_cluster_assignment(image_path, cluster_id)
|
||||
|
||||
print(f"Completed processing {len(clustered_results)} images")
|
||||
|
||||
|
||||
@app.get("/clusters", response_model=List[Cluster], dependencies=[Depends(verify_api_key)])
|
||||
async def get_clusters():
|
||||
"""
|
||||
Get all clusters with their image counts
|
||||
"""
|
||||
clusters_data = db.get_all_clusters()
|
||||
|
||||
clusters = []
|
||||
for cluster_id, image_count, created_at in clusters_data:
|
||||
clusters.append(Cluster(
|
||||
id=cluster_id,
|
||||
image_count=image_count,
|
||||
created_at=datetime.fromisoformat(created_at) if isinstance(created_at, str) else created_at
|
||||
))
|
||||
|
||||
return clusters
|
||||
|
||||
|
||||
@app.get("/cluster/{cluster_id}", response_model=ClusterDetail, dependencies=[Depends(verify_api_key)])
|
||||
async def get_cluster(cluster_id: int):
|
||||
"""
|
||||
Get all files in a specific cluster
|
||||
"""
|
||||
files = db.get_cluster_files(cluster_id)
|
||||
|
||||
if not files:
|
||||
raise HTTPException(status_code=404, detail="Cluster not found or empty")
|
||||
|
||||
# Get the creation time of the first image in the cluster as cluster creation time
|
||||
# In a real implementation, we'd track cluster creation time separately
|
||||
# For now, we'll just use the current time
|
||||
return ClusterDetail(
|
||||
id=cluster_id,
|
||||
files=files,
|
||||
created_at=datetime.utcnow()
|
||||
)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
async def root():
|
||||
return {"message": "Welcome to Anime Art Sorter API! Use /docs for API documentation."}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
27
anime_sorter/models.py
Normal file
27
anime_sorter/models.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class ImageUploadResponse(BaseModel):
|
||||
filename: str
|
||||
message: str
|
||||
|
||||
|
||||
class Cluster(BaseModel):
|
||||
id: int
|
||||
image_count: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ClusterDetail(BaseModel):
|
||||
id: int
|
||||
files: List[str]
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class ImageMetadata(BaseModel):
|
||||
id: int
|
||||
file_path: str
|
||||
cluster_id: Optional[int]
|
||||
created_at: datetime
|
||||
92
anime_sorter/processor.py
Normal file
92
anime_sorter/processor.py
Normal file
@@ -0,0 +1,92 @@
|
||||
import torch
|
||||
from PIL import Image
|
||||
from sentence_transformers import SentenceTransformer
|
||||
from sklearn.cluster import DBSCAN
|
||||
import numpy as np
|
||||
import os
|
||||
from typing import List, Tuple
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ImageProcessor:
|
||||
def __init__(self, model_name: str = "clip-ViT-B-32"):
|
||||
"""
|
||||
Initialize the image processor with CLIP model
|
||||
:param model_name: Name of the CLIP model to use
|
||||
"""
|
||||
# Use ViT-B-32 variant of CLIP
|
||||
clip_model_name = "sentence-transformers/clip-ViT-B-32-multilingual-v1"
|
||||
self.model = SentenceTransformer(clip_model_name)
|
||||
|
||||
# Check if CUDA is available and set device accordingly
|
||||
self.device = "cuda" if torch.cuda.is_available() else "cpu"
|
||||
logger.info(f"Using device: {self.device}")
|
||||
self.model.to(self.device)
|
||||
|
||||
def generate_embeddings(self, image_paths: List[str]) -> np.ndarray:
|
||||
"""
|
||||
Generate embeddings for a list of image paths
|
||||
:param image_paths: List of paths to images
|
||||
:return: Array of embeddings
|
||||
"""
|
||||
images = []
|
||||
|
||||
for img_path in image_paths:
|
||||
try:
|
||||
# Load and preprocess image
|
||||
img = Image.open(img_path).convert('RGB')
|
||||
images.append(img)
|
||||
except Exception as e:
|
||||
logger.error(f"Error loading image {img_path}: {str(e)}")
|
||||
continue
|
||||
|
||||
if not images:
|
||||
return np.array([])
|
||||
|
||||
# Generate embeddings using the CLIP model
|
||||
embeddings = self.model.encode(images, convert_to_tensor=True)
|
||||
|
||||
# Move embeddings to CPU if they're on GPU for further processing
|
||||
if embeddings.is_cuda:
|
||||
embeddings = embeddings.cpu()
|
||||
|
||||
return embeddings.numpy()
|
||||
|
||||
def cluster_images(self, image_paths: List[str], eps: float = 0.5, min_samples: int = 2) -> List[Tuple[str, int]]:
|
||||
"""
|
||||
Cluster images based on visual similarity using DBSCAN
|
||||
:param image_paths: List of paths to images to cluster
|
||||
:param eps: Maximum distance between samples in the same cluster
|
||||
:param min_samples: Minimum number of samples in a cluster
|
||||
:return: List of tuples (image_path, cluster_id)
|
||||
"""
|
||||
if not image_paths:
|
||||
return []
|
||||
|
||||
logger.info(f"Generating embeddings for {len(image_paths)} images...")
|
||||
embeddings = self.generate_embeddings(image_paths)
|
||||
|
||||
if len(embeddings) == 0:
|
||||
logger.warning("No valid embeddings generated")
|
||||
return []
|
||||
|
||||
logger.info(f"Starting DBSCAN clustering with eps={eps}, min_samples={min_samples}...")
|
||||
|
||||
# Perform DBSCAN clustering
|
||||
clustering = DBSCAN(eps=eps, min_samples=min_samples, metric='cosine')
|
||||
cluster_labels = clustering.fit_predict(embeddings)
|
||||
|
||||
# Pair image paths with their cluster labels
|
||||
# Note: DBSCAN labels noise points as -1, we'll assign them to cluster 0
|
||||
result = []
|
||||
for i, img_path in enumerate(image_paths):
|
||||
cluster_id = int(cluster_labels[i]) if i < len(cluster_labels) else -1
|
||||
# Convert -1 (noise) labels to 0 to represent unclustered images
|
||||
if cluster_id == -1:
|
||||
cluster_id = 0
|
||||
result.append((img_path, cluster_id))
|
||||
|
||||
logger.info(f"Clustering completed. Found {len(set([label for _, label in result]))} clusters.")
|
||||
return result
|
||||
12
anime_sorter/requirements.txt
Normal file
12
anime_sorter/requirements.txt
Normal file
@@ -0,0 +1,12 @@
|
||||
fastapi==0.115.0
|
||||
uvicorn==0.32.0
|
||||
pydantic==2.9.2
|
||||
sentence-transformers==3.1.0
|
||||
torch==2.5.1
|
||||
torchvision==0.20.1
|
||||
Pillow==10.4.0
|
||||
scikit-learn==1.5.2
|
||||
numpy==1.26.4
|
||||
python-multipart==0.0.12
|
||||
python-dotenv==1.0.1
|
||||
sqlite3
|
||||
Reference in New Issue
Block a user