matinsn2000 commited on
Commit
c6706bd
·
1 Parent(s): a7942ef

boilerplate for api gateways

Browse files
.gitignore ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.so
6
+ .Python
7
+ env/
8
+ venv/
9
+ ENV/
10
+ build/
11
+ develop-eggs/
12
+ dist/
13
+ downloads/
14
+ eggs/
15
+ .eggs/
16
+ lib/
17
+ lib64/
18
+ parts/
19
+ sdist/
20
+ var/
21
+ wheels/
22
+ *.egg-info/
23
+ .installed.cfg
24
+ *.egg
25
+
26
+ # IDEs
27
+ .vscode/
28
+ .idea/
29
+ *.swp
30
+ *.swo
31
+ *~
32
+ .DS_Store
33
+
34
+ # Project specific
35
+ uploads/
36
+ photos.db
37
+ faiss_index.bin
38
+ *.db
39
+ *.db-journal
40
+ .env
41
+ .env.local
42
+
43
+ # Logs
44
+ *.log
45
+ logs/
46
+
47
+ # Testing
48
+ .pytest_cache/
49
+ .coverage
50
+ htmlcov/
51
+
52
+ # Docker
53
+ .dockerignore
README.md CHANGED
@@ -1,10 +1,305 @@
1
- ---
2
- title: Cloudzy Ai Challenge
3
- emoji: 🦀
4
- colorFrom: red
5
- colorTo: gray
6
- sdk: docker
7
- pinned: false
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
1
+ # 🧭 Cloudzy AI - Cloud Photo Management Service
2
+
3
+ A FastAPI-based cloud photo management service with AI tagging, captioning, and semantic search using FAISS.
4
+
5
+ ## 🎯 Features
6
+
7
+ - **Photo Upload** - Upload images with automatic metadata generation
8
+ - **AI Analysis** - Automatic tag and caption generation
9
+ - **Semantic Search** - FAISS-powered similarity search on embeddings
10
+ - **Image-to-Image Search** - Find similar photos to a reference image
11
+ - **RESTful API** - Full REST API with automatic documentation
12
+ - **Docker Support** - Production-ready Docker and Docker Compose setup
13
+
14
+ ## 🛠️ Tech Stack
15
+
16
+ - **Backend**: FastAPI
17
+ - **Database**: SQLModel + SQLite (PostgreSQL ready)
18
+ - **Search Engine**: FAISS (Fast Approximate Nearest Neighbors)
19
+ - **Image Processing**: Pillow
20
+ - **ORM**: SQLModel
21
+ - **API Documentation**: Swagger/OpenAPI
22
+
23
+ ## 📋 Prerequisites
24
+
25
+ - Python 3.10+
26
+ - Docker & Docker Compose (optional)
27
+ - 2GB+ RAM for FAISS index
28
+
29
+ ## ⚙️ Installation
30
+
31
+ ### Local Development
32
+
33
+ 1. **Clone and setup**
34
+ ```bash
35
+ cd image_embedder
36
+ python -m venv venv
37
+ source venv/bin/activate # On Windows: venv\Scripts\activate
38
+ ```
39
+
40
+ 2. **Install dependencies**
41
+ ```bash
42
+ pip install -r requirements.txt
43
+ ```
44
+
45
+ 3. **Create uploads directory**
46
+ ```bash
47
+ mkdir -p uploads
48
+ ```
49
+
50
+ 4. **Run the server**
51
+ ```bash
52
+ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
53
+ ```
54
+
55
+ Server will start at `http://localhost:8000`
56
+
57
+ ### Docker
58
+
59
+ ```bash
60
+ # Build and run
61
+ docker compose up --build
62
+
63
+ # Run in background
64
+ docker compose up -d
65
+
66
+ # View logs
67
+ docker compose logs -f cloudzy_api
68
+
69
+ # Stop
70
+ docker compose down
71
+ ```
72
+
73
+ ## 🚀 API Endpoints
74
+
75
+ ### Upload Photo
76
+ ```bash
77
+ POST /upload
78
+ Content-Type: multipart/form-data
79
+
80
+ # Returns:
81
+ {
82
+ "id": 1,
83
+ "filename": "photo_20231023_120000.jpg",
84
+ "tags": ["nature", "landscape", "mountain"],
85
+ "caption": "A beautiful nature photograph",
86
+ "message": "Photo uploaded successfully with ID 1"
87
+ }
88
+ ```
89
+
90
+ ### Get Photo Metadata
91
+ ```bash
92
+ GET /photo/{id}
93
+
94
+ # Returns:
95
+ {
96
+ "id": 1,
97
+ "filename": "photo_20231023_120000.jpg",
98
+ "tags": ["nature", "landscape"],
99
+ "caption": "A beautiful landscape",
100
+ "embedding": [0.123, -0.456, ...], # 512-dim vector
101
+ "created_at": "2023-10-23T12:00:00"
102
+ }
103
+ ```
104
+
105
+ ### List All Photos
106
+ ```bash
107
+ GET /photos?skip=0&limit=10
108
+
109
+ # Returns: List of photo objects with pagination
110
+ ```
111
+
112
+ ### Semantic Search
113
+ ```bash
114
+ GET /search?q=mountain&top_k=5
115
+
116
+ # Returns:
117
+ {
118
+ "query": "mountain",
119
+ "results": [
120
+ {
121
+ "photo_id": 1,
122
+ "filename": "photo_1.jpg",
123
+ "tags": ["nature", "mountain"],
124
+ "caption": "Mountain landscape",
125
+ "distance": 0.123
126
+ },
127
+ ...
128
+ ],
129
+ "total_results": 5
130
+ }
131
+ ```
132
+
133
+ ### Image-to-Image Search
134
+ ```bash
135
+ POST /search/image-to-image?reference_photo_id=1&top_k=5
136
+
137
+ # Returns similar photos to reference photo 1
138
+ ```
139
+
140
+ ### Health Check
141
+ ```bash
142
+ GET /health
143
+
144
+ # Returns service status and FAISS index stats
145
+ ```
146
+
147
+ ## 📚 API Documentation
148
+
149
+ **Interactive Docs (Swagger UI)**:
150
+ ```
151
+ http://localhost:8000/docs
152
+ ```
153
+
154
+ **Alternative Docs (ReDoc)**:
155
+ ```
156
+ http://localhost:8000/redoc
157
+ ```
158
+
159
+ ## 🗂️ Project Structure
160
+
161
+ ```
162
+ image_embedder/
163
+ ├── app/
164
+ │ ├── __init__.py
165
+ │ ├── main.py # FastAPI app entry point
166
+ │ ├── database.py # SQLModel engine + session
167
+ │ ├── models.py # Photo database model
168
+ │ ├── schemas.py # Pydantic response models
169
+ │ ├── ai_utils.py # AI generation (tags, captions, embeddings)
170
+ │ ├── search_engine.py # FAISS index manager
171
+ │ │
172
+ │ ├── routes/
173
+ │ │ ├── __init__.py
174
+ │ │ ├── upload.py # POST /upload endpoint
175
+ │ │ ├── photo.py # GET /photo/:id and /photos endpoints
176
+ │ │ └── search.py # GET /search and image-to-image endpoints
177
+ │ │
178
+ │ └── utils/
179
+ │ ├── __init__.py
180
+ │ └── file_utils.py # File saving and management
181
+
182
+ ├── uploads/ # Stored images (created at runtime)
183
+ ├── faiss_index.bin # FAISS index file (created at runtime)
184
+ ├── photos.db # SQLite database (created at runtime)
185
+
186
+ ├── requirements.txt # Python dependencies
187
+ ├── Dockerfile
188
+ ├── docker-compose.yml
189
+ └── README.md
190
+ ```
191
+
192
+ ## 🔄 Development Workflow
193
+
194
+ ### Test Upload
195
+ ```bash
196
+ # Use curl
197
+ curl -X POST -F "file=@/path/to/image.jpg" http://localhost:8000/upload
198
+
199
+ # Or use Python
200
+ import requests
201
+ with open("image.jpg", "rb") as f:
202
+ response = requests.post(
203
+ "http://localhost:8000/upload",
204
+ files={"file": f}
205
+ )
206
+ print(response.json())
207
+ ```
208
+
209
+ ### Test Search
210
+ ```bash
211
+ # Query-based search
212
+ curl "http://localhost:8000/search?q=tree&top_k=5"
213
+
214
+ # Image-to-image search
215
+ curl -X POST "http://localhost:8000/search/image-to-image?reference_photo_id=1&top_k=5"
216
+ ```
217
+
218
+ ### View Database
219
+ ```bash
220
+ # Install sqlite3 CLI and view database
221
+ sqlite3 photos.db
222
+ > .tables
223
+ > SELECT * FROM photo;
224
+ > .quit
225
+ ```
226
+
227
+ ## 🧠 AI Features (Placeholder Phase)
228
+
229
+ Currently, AI functions use placeholder implementations:
230
+
231
+ - **Tags**: Generated from filename patterns + random selection from common tags
232
+ - **Captions**: Template-based generation from tags
233
+ - **Embeddings**: Deterministic random vectors (reproducible from filename)
234
+
235
+ ### Upgrade Path (Production)
236
+
237
+ 1. **CLIP Integration** (Recommended)
238
+ - Zero-shot image understanding
239
+ - Excellent for tagging and search
240
+ - ~1-2 sec per image on GPU
241
+
242
+ 2. **BLIP Integration** (Alternative)
243
+ - Visual question answering
244
+ - Better captions
245
+ - ~2-3 sec per image on GPU
246
+
247
+ 3. **Fine-tuned Models**
248
+ - Train on domain-specific data
249
+ - Improved accuracy
250
+ - Higher latency/complexity
251
+
252
+ ## 📊 Performance Considerations
253
+
254
+ - **FAISS Index**: Supports millions of embeddings
255
+ - **Database**: SQLite suitable for 100k+ photos; PostgreSQL for larger scale
256
+ - **Embeddings**: 512-dim vectors (adjustable)
257
+ - **Search**: <100ms for 100k+ embeddings on CPU
258
+
259
+ ## 🚨 Troubleshooting
260
+
261
+ ### FAISS Installation Issues
262
+ ```bash
263
+ # If faiss-cpu fails, try:
264
+ pip install faiss-cpu==1.7.4 --no-cache-dir
265
+ ```
266
+
267
+ ### SQLite Lock Error
268
+ ```bash
269
+ # Restart the application or remove locked database
270
+ rm photos.db
271
+ ```
272
+
273
+ ### Docker Build Issues
274
+ ```bash
275
+ # Rebuild without cache
276
+ docker compose build --no-cache
277
+ ```
278
+
279
+ ## 🔐 Security Notes
280
+
281
+ - ⚠️ Currently no authentication - add for production
282
+ - ⚠️ CORS allows all origins - restrict for production
283
+ - ⚠️ File upload validation needed - add size limits
284
+ - ⚠️ Use PostgreSQL + proper secrets management for production
285
+
286
+ ## 📝 Next Steps
287
+
288
+ 1. ✅ Core backend working
289
+ 2. ⬜ Add authentication (JWT)
290
+ 3. ⬜ Implement real AI models (CLIP/BLIP)
291
+ 4. ⬜ Add background job processing (Celery)
292
+ 5. ⬜ Frontend dashboard
293
+ 6. ⬜ Production deployment (Railway/AWS)
294
+
295
+ ## 📄 License
296
+
297
+ MIT License
298
+
299
+ ## 🤝 Contributing
300
+
301
+ Contributions welcome! Please test thoroughly before submitting.
302
+
303
  ---
304
 
305
+ **Questions?** Check the interactive docs at `/docs` or review the code comments.
app.py CHANGED
@@ -1,7 +1,95 @@
 
1
  from fastapi import FastAPI
 
 
2
 
3
- app = FastAPI()
 
 
4
 
5
- @app.get("/")
6
- def greet_json():
7
- return {"Hello": "World!"}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI application entry point"""
2
  from fastapi import FastAPI
3
+ from fastapi.middleware.cors import CORSMiddleware
4
+ from contextlib import asynccontextmanager
5
 
6
+ from cloudzy.database import create_db_and_tables
7
+ from cloudzy.routes import upload, photo, search
8
+ from cloudzy.search_engine import SearchEngine
9
 
10
+ # Initialize search engine at startup
11
+ search_engine = None
12
+
13
+
14
+ @asynccontextmanager
15
+ async def lifespan(app: FastAPI):
16
+ """Manage app lifecycle - startup and shutdown"""
17
+ # Startup
18
+ print("🚀 Starting Cloudzy AI service...")
19
+ create_db_and_tables()
20
+
21
+ # Initialize search engine
22
+ global search_engine
23
+ search_engine = SearchEngine()
24
+ stats = search_engine.get_stats()
25
+ print(f"📊 FAISS Index loaded: {stats}")
26
+ print("✅ Application ready!")
27
+
28
+ yield
29
+
30
+ # Shutdown
31
+ print("🛑 Shutting down Cloudzy AI service...")
32
+
33
+
34
+ # Create FastAPI app
35
+ app = FastAPI(
36
+ title="Cloudzy AI",
37
+ description="Cloud photo management with AI tagging, captioning, and semantic search",
38
+ version="1.0.0",
39
+ lifespan=lifespan,
40
+ )
41
+
42
+ # Add CORS middleware
43
+ app.add_middleware(
44
+ CORSMiddleware,
45
+ allow_origins=["*"],
46
+ allow_credentials=True,
47
+ allow_methods=["*"],
48
+ allow_headers=["*"],
49
+ )
50
+
51
+ # Include routers
52
+ app.include_router(upload.router)
53
+ app.include_router(photo.router)
54
+ app.include_router(search.router)
55
+
56
+
57
+ @app.get("/", tags=["info"])
58
+ async def root():
59
+ """Root endpoint - API info"""
60
+ return {
61
+ "service": "Cloudzy AI",
62
+ "version": "1.0.0",
63
+ "description": "Cloud photo management with AI tagging, captioning, and semantic search",
64
+ "endpoints": {
65
+ "upload": "POST /upload - Upload a photo",
66
+ "get_photo": "GET /photo/{id} - Get photo metadata",
67
+ "list_photos": "GET /photos - List all photos",
68
+ "search": "GET /search?q=... - Semantic search",
69
+ "image_to_image": "POST /search/image-to-image - Similar images",
70
+ "docs": "/docs - Interactive API documentation",
71
+ }
72
+ }
73
+
74
+
75
+ @app.get("/health", tags=["info"])
76
+ async def health_check():
77
+ """Health check endpoint"""
78
+ global search_engine
79
+ stats = search_engine.get_stats() if search_engine else {}
80
+
81
+ return {
82
+ "status": "healthy",
83
+ "service": "Cloudzy AI",
84
+ "search_engine": stats,
85
+ }
86
+
87
+
88
+ if __name__ == "__main__":
89
+ import uvicorn
90
+ uvicorn.run(
91
+ "app:app",
92
+ host="0.0.0.0",
93
+ port=8000,
94
+ reload=True,
95
+ )
cloudzy/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Cloudzy AI - Cloud photo management service"""
cloudzy/ai_utils.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """AI utilities for generating tags, captions, and embeddings"""
2
+ import numpy as np
3
+ from typing import List, Tuple
4
+ import random
5
+
6
+
7
+ def generate_tags(filename: str) -> List[str]:
8
+ """
9
+ Generate tags for an image based on filename.
10
+ In production, this would use CLIP or similar models.
11
+ Currently using placeholder logic.
12
+ """
13
+ # Extract meaningful words from filename
14
+ name_parts = filename.lower().replace("_", " ").replace("-", " ").split()
15
+ name_parts = [p.replace(".jpg", "").replace(".png", "").replace(".jpeg", "")
16
+ for p in name_parts if p]
17
+
18
+ # Common image tags for demo
19
+ common_tags = [
20
+ "photo", "image", "landscape", "portrait", "nature", "architecture",
21
+ "people", "animal", "food", "object", "abstract", "text", "sunset",
22
+ "mountain", "beach", "forest", "urban", "indoor", "outdoor"
23
+ ]
24
+
25
+ # Select random subset of common tags + filename parts
26
+ tags = list(set(name_parts[:2] + random.sample(common_tags, min(3, len(common_tags)))))
27
+ return tags[:5] # Return up to 5 tags
28
+
29
+
30
+ def generate_caption(filename: str, tags: List[str]) -> str:
31
+ """
32
+ Generate a caption for an image.
33
+ In production, this would use BLIP or similar models.
34
+ Currently using placeholder logic.
35
+ """
36
+ caption_templates = [
37
+ "A beautiful {tag} photograph",
38
+ "Captured moment: {tag}",
39
+ "Scenic view of {tag}",
40
+ "Amazing {tag} scene",
41
+ "Photography: {tag} collection",
42
+ ]
43
+
44
+ tag = tags[0] if tags else "image"
45
+ template = random.choice(caption_templates)
46
+ return template.format(tag=tag)
47
+
48
+
49
+ def generate_embedding(filename: str, tags: List[str], caption: str) -> np.ndarray:
50
+ """
51
+ Generate a 512-dimensional embedding for semantic search.
52
+ In production, this would use CLIP or similar models.
53
+ Currently using placeholder random embeddings (reproducible from filename).
54
+ """
55
+ # Create a reproducible random embedding based on filename
56
+ # In production: use CLIP or similar to generate real embeddings
57
+ random.seed(hash(filename) % (2**32))
58
+ embedding = np.random.randn(512).astype(np.float32)
59
+ # Normalize to unit vector
60
+ embedding = embedding / np.linalg.norm(embedding)
61
+ return embedding
62
+
63
+
64
+ def generate_filename_embedding(filename: str) -> np.ndarray:
65
+ """
66
+ Generate a deterministic embedding from filename for testing.
67
+ Ensures same filename always gets same embedding.
68
+ """
69
+ random.seed(hash(filename) % (2**32))
70
+ embedding = np.random.randn(512).astype(np.float32)
71
+ embedding = embedding / np.linalg.norm(embedding)
72
+ return embedding
cloudzy/database.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Database configuration and session management"""
2
+ from sqlmodel import SQLModel, create_engine, Session
3
+ from typing import Generator
4
+ import os
5
+
6
+ DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./photos.db")
7
+
8
+ # SQLite-specific connect_args
9
+ connect_args = {"check_same_thread": False} if "sqlite" in DATABASE_URL else {}
10
+
11
+ engine = create_engine(
12
+ DATABASE_URL,
13
+ echo=False,
14
+ connect_args=connect_args,
15
+ )
16
+
17
+
18
+ def create_db_and_tables():
19
+ """Create all database tables"""
20
+ SQLModel.metadata.create_all(engine)
21
+
22
+
23
+ def get_session() -> Generator[Session, None, None]:
24
+ """Dependency for getting database session"""
25
+ with Session(engine) as session:
26
+ yield session
cloudzy/models.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SQLModel database models"""
2
+ from sqlmodel import SQLModel, Field
3
+ from typing import Optional
4
+ from datetime import datetime
5
+ import json
6
+
7
+
8
+ class Photo(SQLModel, table=True):
9
+ """Photo metadata model"""
10
+ id: Optional[int] = Field(default=None, primary_key=True)
11
+ filename: str = Field(index=True)
12
+ filepath: str # Full path to stored image
13
+ tags: str = Field(default="[]") # JSON string of tags
14
+ caption: str = Field(default="")
15
+ embedding: Optional[str] = Field(default=None) # JSON string of embedding vector
16
+ created_at: datetime = Field(default_factory=datetime.utcnow)
17
+
18
+ def get_tags(self) -> list[str]:
19
+ """Parse tags from JSON string"""
20
+ try:
21
+ return json.loads(self.tags)
22
+ except:
23
+ return []
24
+
25
+ def set_tags(self, tags: list[str]):
26
+ """Store tags as JSON string"""
27
+ self.tags = json.dumps(tags)
28
+
29
+ def get_embedding(self) -> Optional[list[float]]:
30
+ """Parse embedding from JSON string"""
31
+ try:
32
+ if self.embedding:
33
+ return json.loads(self.embedding)
34
+ except:
35
+ pass
36
+ return None
37
+
38
+ def set_embedding(self, embedding: list[float]):
39
+ """Store embedding as JSON string"""
40
+ self.embedding = json.dumps(embedding)
cloudzy/routes/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """API routes"""
cloudzy/routes/photo.py ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Photo retrieval endpoints"""
2
+ from fastapi import APIRouter, Depends, HTTPException
3
+ from sqlmodel import Session, select
4
+
5
+ from cloudzy.database import get_session
6
+ from cloudzy.models import Photo
7
+ from cloudzy.schemas import PhotoDetailResponse
8
+
9
+ router = APIRouter(tags=["photos"])
10
+
11
+
12
+ @router.get("/photo/{photo_id}", response_model=PhotoDetailResponse)
13
+ async def get_photo(
14
+ photo_id: int,
15
+ session: Session = Depends(get_session),
16
+ ):
17
+ """
18
+ Get photo metadata by ID.
19
+
20
+ Returns: Photo metadata including tags, caption, embedding info
21
+ """
22
+ statement = select(Photo).where(Photo.id == photo_id)
23
+ photo = session.exec(statement).first()
24
+
25
+ if not photo:
26
+ raise HTTPException(status_code=404, detail=f"Photo {photo_id} not found")
27
+
28
+ return PhotoDetailResponse(
29
+ id=photo.id,
30
+ filename=photo.filename,
31
+ tags=photo.get_tags(),
32
+ caption=photo.caption,
33
+ embedding=photo.get_embedding(),
34
+ created_at=photo.created_at,
35
+ )
36
+
37
+
38
+ @router.get("/photos", response_model=list[PhotoDetailResponse])
39
+ async def list_photos(
40
+ skip: int = 0,
41
+ limit: int = 10,
42
+ session: Session = Depends(get_session),
43
+ ):
44
+ """
45
+ List all photos with pagination.
46
+
47
+ Args:
48
+ skip: Number of photos to skip (pagination)
49
+ limit: Max photos to return (default 10)
50
+
51
+ Returns: List of photo metadata
52
+ """
53
+ if limit > 100:
54
+ limit = 100 # Cap limit at 100
55
+
56
+ statement = select(Photo).offset(skip).limit(limit)
57
+ photos = session.exec(statement).all()
58
+
59
+ return [
60
+ PhotoDetailResponse(
61
+ id=photo.id,
62
+ filename=photo.filename,
63
+ tags=photo.get_tags(),
64
+ caption=photo.caption,
65
+ embedding=photo.get_embedding(),
66
+ created_at=photo.created_at,
67
+ )
68
+ for photo in photos
69
+ ]
cloudzy/routes/search.py ADDED
@@ -0,0 +1,125 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Semantic search endpoint using FAISS"""
2
+ from fastapi import APIRouter, Query, Depends, HTTPException
3
+ from sqlmodel import Session, select
4
+ import numpy as np
5
+
6
+ from cloudzy.database import get_session
7
+ from cloudzy.models import Photo
8
+ from cloudzy.schemas import SearchResponse, SearchResult
9
+ from cloudzy.search_engine import SearchEngine
10
+ from cloudzy.ai_utils import generate_filename_embedding
11
+
12
+ router = APIRouter(tags=["search"])
13
+
14
+
15
+ @router.get("/search", response_model=SearchResponse)
16
+ async def search_photos(
17
+ q: str = Query(..., min_length=1, max_length=200, description="Search query"),
18
+ top_k: int = Query(5, ge=1, le=50, description="Number of results"),
19
+ session: Session = Depends(get_session),
20
+ ):
21
+ """
22
+ Semantic search for similar photos using FAISS.
23
+
24
+ Converts query to embedding and finds most similar images.
25
+
26
+ Args:
27
+ q: Search query (used to generate embedding)
28
+ top_k: Number of results to return (max 50)
29
+
30
+ Returns: List of similar photos with distance scores
31
+ """
32
+ # Generate embedding for query
33
+ query_embedding = generate_filename_embedding(q)
34
+
35
+ # Search in FAISS
36
+ search_engine = SearchEngine()
37
+ search_results = search_engine.search(query_embedding, top_k=top_k)
38
+
39
+ if not search_results:
40
+ return SearchResponse(
41
+ query=q,
42
+ results=[],
43
+ total_results=0,
44
+ )
45
+
46
+ # Fetch photo details from database
47
+ result_objects = []
48
+ for photo_id, distance in search_results:
49
+ statement = select(Photo).where(Photo.id == photo_id)
50
+ photo = session.exec(statement).first()
51
+
52
+ if photo: # Only include if photo exists in DB
53
+ result_objects.append(
54
+ SearchResult(
55
+ photo_id=photo.id,
56
+ filename=photo.filename,
57
+ tags=photo.get_tags(),
58
+ caption=photo.caption,
59
+ distance=distance,
60
+ )
61
+ )
62
+
63
+ return SearchResponse(
64
+ query=q,
65
+ results=result_objects,
66
+ total_results=len(result_objects),
67
+ )
68
+
69
+
70
+ @router.post("/search/image-to-image")
71
+ async def image_to_image_search(
72
+ reference_photo_id: int = Query(..., description="Reference photo ID"),
73
+ top_k: int = Query(5, ge=1, le=50),
74
+ session: Session = Depends(get_session),
75
+ ):
76
+ """
77
+ Find similar images to a reference photo (image-to-image search).
78
+
79
+ Args:
80
+ reference_photo_id: ID of the reference photo
81
+ top_k: Number of similar results
82
+
83
+ Returns: Similar photos
84
+ """
85
+ # Get reference photo
86
+ statement = select(Photo).where(Photo.id == reference_photo_id)
87
+ reference_photo = session.exec(statement).first()
88
+
89
+ if not reference_photo:
90
+ raise HTTPException(status_code=404, detail=f"Photo {reference_photo_id} not found")
91
+
92
+ # Get reference embedding
93
+ reference_embedding = reference_photo.get_embedding()
94
+ if not reference_embedding:
95
+ raise HTTPException(status_code=400, detail="Photo has no embedding")
96
+
97
+ # Search in FAISS
98
+ search_engine = SearchEngine()
99
+ search_results = search_engine.search(
100
+ np.array(reference_embedding, dtype=np.float32),
101
+ top_k=top_k + 1 # +1 to skip the reference photo itself
102
+ )
103
+
104
+ # Build results (skip first result which is the reference photo itself)
105
+ result_objects = []
106
+ for photo_id, distance in search_results[1:]: # Skip first result
107
+ statement = select(Photo).where(Photo.id == photo_id)
108
+ photo = session.exec(statement).first()
109
+
110
+ if photo:
111
+ result_objects.append(
112
+ SearchResult(
113
+ photo_id=photo.id,
114
+ filename=photo.filename,
115
+ tags=photo.get_tags(),
116
+ caption=photo.caption,
117
+ distance=distance,
118
+ )
119
+ )
120
+
121
+ return SearchResponse(
122
+ query=f"Similar to photo {reference_photo_id}",
123
+ results=result_objects[:top_k],
124
+ total_results=len(result_objects),
125
+ )
cloudzy/routes/upload.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Upload endpoint for photos"""
2
+ from fastapi import APIRouter, UploadFile, File, Depends, HTTPException, BackgroundTasks
3
+ from sqlmodel import Session
4
+ from pathlib import Path
5
+ import numpy as np
6
+
7
+ from cloudzy.database import get_session
8
+ from cloudzy.models import Photo
9
+ from cloudzy.schemas import UploadResponse
10
+ from cloudzy.utils.file_utils import save_uploaded_file
11
+ from cloudzy.ai_utils import generate_tags, generate_caption, generate_embedding
12
+ from cloudzy.search_engine import SearchEngine
13
+
14
+ router = APIRouter(tags=["photos"])
15
+
16
+ # Allowed image extensions
17
+ ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".gif", ".webp"}
18
+
19
+
20
+ def validate_image_file(filename: str) -> bool:
21
+ """Check if file has valid image extension"""
22
+ return Path(filename).suffix.lower() in ALLOWED_EXTENSIONS
23
+
24
+
25
+ @router.post("/upload", response_model=UploadResponse)
26
+ async def upload_photo(
27
+ file: UploadFile = File(...),
28
+ session: Session = Depends(get_session),
29
+ background_tasks: BackgroundTasks = None,
30
+ ):
31
+ """
32
+ Upload a photo and analyze it with AI.
33
+
34
+ - Validates file type
35
+ - Saves file to disk
36
+ - Generates tags, caption, and embedding
37
+ - Stores metadata in database
38
+ - Indexes embedding in FAISS
39
+
40
+ Returns: Photo metadata with ID
41
+ """
42
+ # Validate file
43
+ if not file.filename:
44
+ raise HTTPException(status_code=400, detail="No filename provided")
45
+
46
+ if not validate_image_file(file.filename):
47
+ raise HTTPException(
48
+ status_code=400,
49
+ detail=f"Invalid file type. Allowed: {', '.join(ALLOWED_EXTENSIONS)}"
50
+ )
51
+
52
+ # Read file content
53
+ content = await file.read()
54
+ if not content:
55
+ raise HTTPException(status_code=400, detail="Empty file")
56
+
57
+ # Save file to disk
58
+ saved_filename = save_uploaded_file(content, file.filename)
59
+ filepath = f"uploads/{saved_filename}"
60
+
61
+ # Generate AI analysis
62
+ tags = generate_tags(file.filename)
63
+ caption = generate_caption(file.filename, tags)
64
+ embedding = generate_embedding(file.filename, tags, caption)
65
+
66
+ # Create photo record
67
+ photo = Photo(
68
+ filename=saved_filename,
69
+ filepath=filepath,
70
+ caption=caption,
71
+ )
72
+ photo.set_tags(tags)
73
+ photo.set_embedding(embedding.tolist())
74
+
75
+ # Save to database
76
+ session.add(photo)
77
+ session.commit()
78
+ session.refresh(photo)
79
+
80
+ # Index in FAISS (in background if needed)
81
+ search_engine = SearchEngine()
82
+ search_engine.add_embedding(photo.id, embedding)
83
+
84
+ return UploadResponse(
85
+ id=photo.id,
86
+ filename=saved_filename,
87
+ tags=tags,
88
+ caption=caption,
89
+ message=f"Photo uploaded successfully with ID {photo.id}"
90
+ )
cloudzy/schemas.py ADDED
@@ -0,0 +1,49 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Pydantic response schemas"""
2
+ from pydantic import BaseModel
3
+ from typing import Optional, List
4
+ from datetime import datetime
5
+
6
+
7
+ class PhotoResponse(BaseModel):
8
+ """Response model for photo metadata"""
9
+ id: int
10
+ filename: str
11
+ tags: List[str]
12
+ caption: str
13
+ created_at: datetime
14
+
15
+ class Config:
16
+ from_attributes = True
17
+
18
+
19
+ class PhotoDetailResponse(PhotoResponse):
20
+ """Detailed photo response with embedding info"""
21
+ embedding: Optional[List[float]] = None
22
+
23
+
24
+ class SearchResult(BaseModel):
25
+ """Search result with similarity score"""
26
+ photo_id: int
27
+ filename: str
28
+ tags: List[str]
29
+ caption: str
30
+ distance: float # L2 distance (lower is more similar)
31
+
32
+ class Config:
33
+ from_attributes = True
34
+
35
+
36
+ class SearchResponse(BaseModel):
37
+ """Response for search endpoint"""
38
+ query: str
39
+ results: List[SearchResult]
40
+ total_results: int
41
+
42
+
43
+ class UploadResponse(BaseModel):
44
+ """Response after uploading a photo"""
45
+ id: int
46
+ filename: str
47
+ tags: List[str]
48
+ caption: str
49
+ message: str
cloudzy/search_engine.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FAISS-based semantic search engine"""
2
+ import faiss
3
+ import numpy as np
4
+ from typing import List, Tuple, Optional
5
+ import os
6
+
7
+
8
+ class SearchEngine:
9
+ """FAISS-based search engine for image embeddings"""
10
+
11
+ def __init__(self, dim: int = 512, index_path: str = "faiss_index.bin"):
12
+ self.dim = dim
13
+ self.index_path = index_path
14
+ self.id_map: List[int] = [] # Map FAISS indices to photo IDs
15
+
16
+ # Load existing index or create new one
17
+ if os.path.exists(index_path):
18
+ self.index = faiss.read_index(index_path)
19
+ else:
20
+ self.index = faiss.IndexFlatL2(dim)
21
+
22
+ def add_embedding(self, photo_id: int, embedding: np.ndarray) -> None:
23
+ """
24
+ Add an embedding to the index.
25
+
26
+ Args:
27
+ photo_id: Unique photo identifier
28
+ embedding: 1D numpy array of shape (dim,)
29
+ """
30
+ # Ensure embedding is float32 and correct shape
31
+ embedding = embedding.astype(np.float32).reshape(1, -1)
32
+
33
+ # Add to FAISS index
34
+ self.index.add(embedding)
35
+
36
+ # Track photo ID
37
+ self.id_map.append(photo_id)
38
+
39
+ # Save index to disk
40
+ self.save()
41
+
42
+ def search(self, query_embedding: np.ndarray, top_k: int = 5) -> List[Tuple[int, float]]:
43
+ """
44
+ Search for similar embeddings.
45
+
46
+ Args:
47
+ query_embedding: 1D numpy array of shape (dim,)
48
+ top_k: Number of results to return
49
+
50
+ Returns:
51
+ List of (photo_id, distance) tuples
52
+ """
53
+ if self.index.ntotal == 0:
54
+ return []
55
+
56
+ # Ensure query is float32 and correct shape
57
+ query_embedding = query_embedding.astype(np.float32).reshape(1, -1)
58
+
59
+ # Search in FAISS index
60
+ distances, indices = self.index.search(query_embedding, min(top_k, self.index.ntotal))
61
+
62
+ # Map back to photo IDs
63
+ results = [
64
+ (self.id_map[int(idx)], float(distance))
65
+ for distance, idx in zip(distances[0], indices[0])
66
+ ]
67
+
68
+ return results
69
+
70
+ def save(self) -> None:
71
+ """Save index to disk"""
72
+ faiss.write_index(self.index, self.index_path)
73
+
74
+ def load(self) -> None:
75
+ """Load index from disk"""
76
+ if os.path.exists(self.index_path):
77
+ self.index = faiss.read_index(self.index_path)
78
+
79
+ def get_stats(self) -> dict:
80
+ """Get index statistics"""
81
+ return {
82
+ "total_embeddings": self.index.ntotal,
83
+ "dimension": self.dim,
84
+ "id_map_size": len(self.id_map)
85
+ }
cloudzy/utils/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ """Utility modules"""
cloudzy/utils/file_utils.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """File handling utilities"""
2
+ import os
3
+ import shutil
4
+ from pathlib import Path
5
+ from datetime import datetime
6
+
7
+
8
+ UPLOAD_DIR = "uploads"
9
+
10
+
11
+ def ensure_upload_dir():
12
+ """Ensure uploads directory exists"""
13
+ Path(UPLOAD_DIR).mkdir(exist_ok=True)
14
+
15
+
16
+ def save_uploaded_file(file_content: bytes, original_filename: str) -> str:
17
+ """
18
+ Save uploaded file with timestamp to ensure uniqueness.
19
+
20
+ Args:
21
+ file_content: File bytes
22
+ original_filename: Original filename
23
+
24
+ Returns:
25
+ Saved filename
26
+ """
27
+ ensure_upload_dir()
28
+
29
+ # Generate unique filename with timestamp
30
+ timestamp = datetime.utcnow().strftime("%Y%m%d_%H%M%S_%f")[:-3]
31
+ name, ext = os.path.splitext(original_filename)
32
+ saved_filename = f"{name}_{timestamp}{ext}"
33
+
34
+ filepath = os.path.join(UPLOAD_DIR, saved_filename)
35
+
36
+ # Write file
37
+ with open(filepath, "wb") as f:
38
+ f.write(file_content)
39
+
40
+ return saved_filename
41
+
42
+
43
+ def get_file_path(filename: str) -> str:
44
+ """Get full path for a saved file"""
45
+ return os.path.join(UPLOAD_DIR, filename)
46
+
47
+
48
+ def file_exists(filename: str) -> bool:
49
+ """Check if a saved file exists"""
50
+ return os.path.exists(get_file_path(filename))
51
+
52
+
53
+ def delete_file(filename: str) -> bool:
54
+ """Delete a saved file"""
55
+ filepath = get_file_path(filename)
56
+ if os.path.exists(filepath):
57
+ os.remove(filepath)
58
+ return True
59
+ return False
requirements copy.txt ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.109.0
2
+ uvicorn[standard]==0.27.0
3
+ sqlmodel==0.0.16
4
+ pillow==10.1.0
5
+ numpy==1.26.3
6
+ scikit-learn==1.3.2
7
+ faiss-cpu==1.8.0
8
+ python-multipart==0.0.6
9
+ pydantic==2.6.1
10
+ pydantic-settings==2.1.0
11
+ setuptools>=68.0
requirements.txt CHANGED
@@ -1,2 +1,11 @@
1
- fastapi
2
- uvicorn[standard]
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.109.0
2
+ uvicorn[standard]==0.27.0
3
+ sqlmodel==0.0.16
4
+ pillow==10.1.0
5
+ numpy==1.26.3
6
+ scikit-learn==1.3.2
7
+ faiss-cpu==1.8.0
8
+ python-multipart==0.0.6
9
+ pydantic==2.6.1
10
+ pydantic-settings==2.1.0
11
+ setuptools>=68.0