Update memory: Camie Tagger v2 upgrade results

This commit is contained in:
root
2026-03-30 12:27:20 +02:00
commit c3033db6a7
1082 changed files with 649864 additions and 0 deletions

13
anime_sorter/auth.py Normal file
View 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
View 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
View 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
View 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
View 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

View 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