Spaces:
Runtime error
Runtime error
Upload 26 files
Browse files- .env +5 -0
- app.py +1 -2
- app/__init__.py +23 -0
- app/config.py +8 -0
- app/models/feedback_manager.py +50 -0
- app/models/matcher.py +61 -0
- app/models/segmenter.py +47 -0
- app/routes/count.py +31 -0
- app/routes/feedback.py +27 -0
- app/routes/match.py +53 -0
- app/routes/segment.py +44 -0
- app/utils/feature_extraction.py +34 -0
- app/utils/preprocess.py +14 -0
- app/utils/visualize.py +19 -0
- deployments/Dockerfile +15 -0
- deployments/kubernetes.yaml +43 -0
- deployments/requirements.txt +12 -0
- main.py +6 -0
- models/mobile_sam.pt +3 -0
- models/yolov8n.pt +3 -0
- scripts/data_augmentation.py +24 -0
- scripts/train.py +30 -0
- setup.py +0 -0
- tests/test_feedback.py +21 -0
- tests/test_matching.py +26 -0
- tests/test_segmentation.py +20 -0
.env
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SECRET_KEY=your-secret-key
|
| 2 |
+
DATABASE_URL=postgresql://postgres:mysecretpassword@localhost:5432/piecefinder
|
| 3 |
+
REDIS_URL=redis://localhost:6379/0
|
| 4 |
+
YOLO_MODEL_PATH=/app/models/yolov8n.pt
|
| 5 |
+
SAM_MODEL_PATH=/app/models/mobile_sam.pth
|
app.py
CHANGED
|
@@ -2,6 +2,5 @@ from app import create_app
|
|
| 2 |
|
| 3 |
app = create_app()
|
| 4 |
|
| 5 |
-
|
| 6 |
if __name__ == '__main__':
|
| 7 |
-
app.run()
|
|
|
|
| 2 |
|
| 3 |
app = create_app()
|
| 4 |
|
|
|
|
| 5 |
if __name__ == '__main__':
|
| 6 |
+
app.run()
|
app/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Flask
|
| 2 |
+
from flask_sqlalchemy import SQLAlchemy
|
| 3 |
+
from flask_cors import CORS
|
| 4 |
+
|
| 5 |
+
db = SQLAlchemy()
|
| 6 |
+
|
| 7 |
+
def create_app():
|
| 8 |
+
app = Flask(__name__)
|
| 9 |
+
app.config['SQLALCHEMY_DATABASE_URI'] = 'postgresql://postgres:mysecretpassword@localhost:5432/piecefinder'
|
| 10 |
+
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
| 11 |
+
app.config['SECRET_KEY'] = 'your-secret-key'
|
| 12 |
+
app.config['REDIS_URL'] = 'redis://localhost:6379/0'
|
| 13 |
+
|
| 14 |
+
db.init_app(app)
|
| 15 |
+
CORS(app) # Enable CORS
|
| 16 |
+
|
| 17 |
+
with app.app_context():
|
| 18 |
+
db.create_all()
|
| 19 |
+
|
| 20 |
+
from app.routes import bp
|
| 21 |
+
app.register_blueprint(bp)
|
| 22 |
+
|
| 23 |
+
return app
|
app/config.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
|
| 3 |
+
class Config:
|
| 4 |
+
SECRET_KEY = os.environ.get('SECRET_KEY', 'default-secret-key')
|
| 5 |
+
DATABASE_URL = os.environ.get('DATABASE_URL', 'postgresql://user:pass@localhost/piecefinder')
|
| 6 |
+
REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
| 7 |
+
YOLO_MODEL_PATH = os.environ.get('YOLO_MODEL_PATH', 'yolov8n.pt')
|
| 8 |
+
SAM_MODEL_PATH = os.environ.get('SAM_MODEL_PATH', 'mobile_sam.pth')
|
app/models/feedback_manager.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from sqlalchemy import create_engine
|
| 2 |
+
import json
|
| 3 |
+
from app import create_app
|
| 4 |
+
|
| 5 |
+
class FeedbackManager:
|
| 6 |
+
def __init__(self):
|
| 7 |
+
app = create_app()
|
| 8 |
+
self.db = app.db
|
| 9 |
+
|
| 10 |
+
# Initialize feedback table if not exists
|
| 11 |
+
with self.db.connect() as conn:
|
| 12 |
+
conn.execute("""
|
| 13 |
+
CREATE TABLE IF NOT EXISTS feedback (
|
| 14 |
+
id SERIAL PRIMARY KEY,
|
| 15 |
+
data JSONB,
|
| 16 |
+
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
| 17 |
+
)
|
| 18 |
+
""")
|
| 19 |
+
|
| 20 |
+
def record_feedback(self, piece_id: int, slot_id: int, suggestions: dict, user_choice: int, is_correct: bool):
|
| 21 |
+
feedback_entry = {
|
| 22 |
+
'piece_id': piece_id,
|
| 23 |
+
'slot_id': slot_id,
|
| 24 |
+
'suggestions': suggestions,
|
| 25 |
+
'user_choice': user_choice,
|
| 26 |
+
'is_correct': is_correct
|
| 27 |
+
}
|
| 28 |
+
with self.db.connect() as conn:
|
| 29 |
+
conn.execute(
|
| 30 |
+
"INSERT INTO feedback (data) VALUES (%s)",
|
| 31 |
+
(json.dumps(feedback_entry),)
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
def get_training_data(self) -> dict:
|
| 35 |
+
positive, negative = [], []
|
| 36 |
+
with self.db.connect() as conn:
|
| 37 |
+
results = conn.execute("SELECT data FROM feedback").fetchall()
|
| 38 |
+
for row in results:
|
| 39 |
+
entry = json.loads(row[0])
|
| 40 |
+
if entry['is_correct']:
|
| 41 |
+
positive.append({
|
| 42 |
+
'piece_id': entry['piece_id'],
|
| 43 |
+
'slot_id': entry['slot_id']
|
| 44 |
+
})
|
| 45 |
+
else:
|
| 46 |
+
negative.append({
|
| 47 |
+
'piece_id': entry['piece_id'],
|
| 48 |
+
'slot_id': entry['slot_id']
|
| 49 |
+
})
|
| 50 |
+
return {'positive': positive, 'negative': negative}
|
app/models/matcher.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import numpy as np
|
| 2 |
+
import faiss
|
| 3 |
+
from sklearn.metrics.pairwise import cosine_similarity
|
| 4 |
+
import cv2
|
| 5 |
+
|
| 6 |
+
class PuzzleMatchingEngine:
|
| 7 |
+
def __init__(self):
|
| 8 |
+
self.weight_geometric = 0.35
|
| 9 |
+
self.weight_color = 0.30
|
| 10 |
+
self.weight_edge = 0.35
|
| 11 |
+
self.faiss_index = faiss.IndexFlatL2(10) # Assume 10D edge features
|
| 12 |
+
|
| 13 |
+
def find_matches(self, pieces: list, slots: list, top_k: int = 3) -> dict:
|
| 14 |
+
matches = {}
|
| 15 |
+
if not pieces or not slots:
|
| 16 |
+
return matches
|
| 17 |
+
|
| 18 |
+
# Prepare FAISS index with piece edge features
|
| 19 |
+
piece_edge_features = np.array([p['features']['edge'] for p in pieces], dtype=np.float32)
|
| 20 |
+
self.faiss_index.add(piece_edge_features)
|
| 21 |
+
|
| 22 |
+
for slot in slots:
|
| 23 |
+
slot_matches = []
|
| 24 |
+
slot_edge_features = np.array([slot['features']['edge']], dtype=np.float32)
|
| 25 |
+
distances, indices = self.faiss_index.search(slot_edge_features, top_k * 2)
|
| 26 |
+
|
| 27 |
+
for idx in indices[0]:
|
| 28 |
+
piece = pieces[idx]
|
| 29 |
+
geometric_score = self._calculate_geometric_similarity(piece['features']['geometric'], slot['features']['geometric'])
|
| 30 |
+
color_score = self._calculate_color_similarity(piece['features']['color'], slot['features']['color'])
|
| 31 |
+
edge_score = self._calculate_edge_similarity(piece['features']['edge'], slot['features']['edge'])
|
| 32 |
+
confidence = (
|
| 33 |
+
self.weight_geometric * geometric_score +
|
| 34 |
+
self.weight_color * color_score +
|
| 35 |
+
self.weight_edge * edge_score
|
| 36 |
+
)
|
| 37 |
+
slot_matches.append({
|
| 38 |
+
'piece_id': piece['id'],
|
| 39 |
+
'slot_id': slot['id'],
|
| 40 |
+
'confidence': float(confidence),
|
| 41 |
+
'geometric_score': float(geometric_score),
|
| 42 |
+
'color_score': float(color_score),
|
| 43 |
+
'edge_score': float(edge_score)
|
| 44 |
+
})
|
| 45 |
+
|
| 46 |
+
slot_matches.sort(key=lambda x: x['confidence'], reverse=True)
|
| 47 |
+
matches[slot['id']] = slot_matches[:top_k]
|
| 48 |
+
|
| 49 |
+
return matches
|
| 50 |
+
|
| 51 |
+
def _calculate_geometric_similarity(self, piece_features: dict, slot_features: dict) -> float:
|
| 52 |
+
# Compare area, perimeter, aspect ratio, etc.
|
| 53 |
+
area_diff = abs(piece_features['area'] - slot_features['area']) / max(piece_features['area'], slot_features['area'])
|
| 54 |
+
perimeter_diff = abs(piece_features['perimeter'] - slot_features['perimeter']) / max(piece_features['perimeter'], slot_features['perimeter'])
|
| 55 |
+
return 1.0 - (0.5 * area_diff + 0.5 * perimeter_diff)
|
| 56 |
+
|
| 57 |
+
def _calculate_color_similarity(self, piece_histogram: np.ndarray, slot_histogram: np.ndarray) -> float:
|
| 58 |
+
return cv2.compareHist(piece_histogram, slot_histogram, cv2.HISTCMP_CORREL)
|
| 59 |
+
|
| 60 |
+
def _calculate_edge_similarity(self, piece_features: np.ndarray, slot_features: np.ndarray) -> float:
|
| 61 |
+
return cosine_similarity(piece_features.reshape(1, -1), slot_features.reshape(1, -1))[0, 0]
|
app/models/segmenter.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import numpy as np
|
| 3 |
+
from ultralytics import YOLO
|
| 4 |
+
from segment_anything import SamPredictor, sam_model_registry
|
| 5 |
+
from app.utils.preprocess import preprocess_image
|
| 6 |
+
from app.utils.feature_extraction import extract_piece_features
|
| 7 |
+
|
| 8 |
+
class PuzzlePieceSegmenter:
|
| 9 |
+
def __init__(self):
|
| 10 |
+
# Load models
|
| 11 |
+
self.yolo = YOLO("yolov8n.pt") # Lightweight YOLOv8 model
|
| 12 |
+
self.sam = sam_model_registry["vit_b"](checkpoint="mobile_sam.pth")
|
| 13 |
+
self.predictor = SamPredictor(self.sam)
|
| 14 |
+
self.min_piece_area = 500
|
| 15 |
+
self.max_piece_area = 50000
|
| 16 |
+
|
| 17 |
+
def segment_pieces(self, image: np.ndarray) -> list:
|
| 18 |
+
# Preprocess image
|
| 19 |
+
processed = preprocess_image(image)
|
| 20 |
+
|
| 21 |
+
# YOLOv8 for coarse detection
|
| 22 |
+
results = self.yolo(processed)
|
| 23 |
+
boxes = results[0].boxes.xyxy.cpu().numpy()
|
| 24 |
+
|
| 25 |
+
# SAM for fine segmentation
|
| 26 |
+
self.predictor.set_image(processed)
|
| 27 |
+
pieces = []
|
| 28 |
+
piece_id = 0
|
| 29 |
+
|
| 30 |
+
for box in boxes:
|
| 31 |
+
masks, _, _ = self.predictor.predict(box_coordinates=box, multimask_output=False)
|
| 32 |
+
for mask in masks:
|
| 33 |
+
contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 34 |
+
for contour in contours:
|
| 35 |
+
area = cv2.contourArea(contour)
|
| 36 |
+
if self.min_piece_area <= area <= self.max_piece_area:
|
| 37 |
+
# Extract features and image for this piece
|
| 38 |
+
piece_image, features = extract_piece_features(processed, contour)
|
| 39 |
+
if features:
|
| 40 |
+
pieces.append({
|
| 41 |
+
'id': piece_id,
|
| 42 |
+
'image': cv2.imencode('.jpg', piece_image)[1].tobytes(),
|
| 43 |
+
'features': features
|
| 44 |
+
})
|
| 45 |
+
piece_id += 1
|
| 46 |
+
|
| 47 |
+
return pieces
|
app/routes/count.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, request, jsonify
|
| 2 |
+
from app.models.segmenter import PuzzlePieceSegmenter
|
| 3 |
+
import base64
|
| 4 |
+
import numpy as np
|
| 5 |
+
import cv2
|
| 6 |
+
|
| 7 |
+
count_bp = Blueprint('count', __name__)
|
| 8 |
+
|
| 9 |
+
@count_bp.route('/count', methods=['POST'])
|
| 10 |
+
def count_pieces():
|
| 11 |
+
try:
|
| 12 |
+
data = request.get_json()
|
| 13 |
+
image_base64 = data.get('image_base64')
|
| 14 |
+
if not image_base64:
|
| 15 |
+
return jsonify({'error': 'No image provided'}), 400
|
| 16 |
+
|
| 17 |
+
# Decode base64 image
|
| 18 |
+
image_data = base64.b64decode(image_base64)
|
| 19 |
+
nparr = np.frombuffer(image_data, np.uint8)
|
| 20 |
+
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 21 |
+
|
| 22 |
+
# Initialize segmenter
|
| 23 |
+
segmenter = PuzzlePieceSegmenter()
|
| 24 |
+
|
| 25 |
+
# Perform segmentation and count
|
| 26 |
+
pieces = segmenter.segment_pieces(image)
|
| 27 |
+
count = len(pieces)
|
| 28 |
+
|
| 29 |
+
return jsonify({'count': count}), 200
|
| 30 |
+
except Exception as e:
|
| 31 |
+
return jsonify({'error': str(e)}), 500
|
app/routes/feedback.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, request, jsonify
|
| 2 |
+
from app.models.feedback_manager import FeedbackManager
|
| 3 |
+
|
| 4 |
+
feedback_bp = Blueprint('feedback', __name__)
|
| 5 |
+
|
| 6 |
+
@feedback_bp.route('/feedback', methods=['POST'])
|
| 7 |
+
def submit_feedback():
|
| 8 |
+
try:
|
| 9 |
+
data = request.get_json()
|
| 10 |
+
piece_id = data.get('pieceId')
|
| 11 |
+
slot_id = data.get('slotId')
|
| 12 |
+
suggestions = data.get('suggestions')
|
| 13 |
+
user_choice = data.get('userChoice')
|
| 14 |
+
is_correct = data.get('isCorrect')
|
| 15 |
+
|
| 16 |
+
if not all([piece_id, slot_id, suggestions, user_choice is not None, is_correct is not None]):
|
| 17 |
+
return jsonify({'error': 'Missing required fields'}), 400
|
| 18 |
+
|
| 19 |
+
# Initialize feedback manager
|
| 20 |
+
feedback_manager = FeedbackManager()
|
| 21 |
+
|
| 22 |
+
# Record feedback
|
| 23 |
+
feedback_manager.record_feedback(piece_id, slot_id, suggestions, user_choice, is_correct)
|
| 24 |
+
|
| 25 |
+
return jsonify({'message': 'Feedback recorded successfully'}), 200
|
| 26 |
+
except Exception as e:
|
| 27 |
+
return jsonify({'error': str(e)}), 500
|
app/routes/match.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, request, jsonify
|
| 2 |
+
from app.models.matcher import PuzzleMatchingEngine
|
| 3 |
+
from app.models.segmenter import PuzzlePieceSegmenter
|
| 4 |
+
import base64
|
| 5 |
+
import numpy as np
|
| 6 |
+
import cv2
|
| 7 |
+
|
| 8 |
+
match_bp = Blueprint('match', __name__)
|
| 9 |
+
|
| 10 |
+
@match_bp.route('/match', methods=['POST'])
|
| 11 |
+
def match_pieces():
|
| 12 |
+
try:
|
| 13 |
+
data = request.get_json()
|
| 14 |
+
image_base64 = data.get('image_base64')
|
| 15 |
+
if not image_base64:
|
| 16 |
+
return jsonify({'error': 'No image provided'}), 400
|
| 17 |
+
|
| 18 |
+
# Decode base64 image
|
| 19 |
+
image_data = base64.b64decode(image_base64)
|
| 20 |
+
nparr = np.frombuffer(image_data, np.uint8)
|
| 21 |
+
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 22 |
+
|
| 23 |
+
# Initialize segmenter and matcher
|
| 24 |
+
segmenter = PuzzlePieceSegmenter()
|
| 25 |
+
matcher = PuzzleMatchingEngine()
|
| 26 |
+
|
| 27 |
+
# Segment pieces and slots (assuming slots are from a reference image)
|
| 28 |
+
pieces = segmenter.segment_pieces(image)
|
| 29 |
+
# For demo, assume slots are pre-segmented or same as pieces
|
| 30 |
+
slots = pieces # Replace with actual slot segmentation logic
|
| 31 |
+
|
| 32 |
+
# Perform matching
|
| 33 |
+
matches = matcher.find_matches(pieces, slots, top_k=3)
|
| 34 |
+
|
| 35 |
+
# Serialize matches
|
| 36 |
+
serialized_matches = []
|
| 37 |
+
for slot_id, slot_matches in matches.items():
|
| 38 |
+
for match in slot_matches:
|
| 39 |
+
serialized_matches.append({
|
| 40 |
+
'piece_id': match['piece_id'],
|
| 41 |
+
'slot_id': match['slot_id'],
|
| 42 |
+
'confidence': match['confidence'],
|
| 43 |
+
'geometric_score': match['geometric_score'],
|
| 44 |
+
'color_score': match['color_score'],
|
| 45 |
+
'edge_score': match['edge_score']
|
| 46 |
+
})
|
| 47 |
+
|
| 48 |
+
return jsonify({
|
| 49 |
+
'matches': serialized_matches,
|
| 50 |
+
'slots': [{'id': slot['id']} for slot in slots]
|
| 51 |
+
}), 200
|
| 52 |
+
except Exception as e:
|
| 53 |
+
return jsonify({'error': str(e)}), 500
|
app/routes/segment.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from flask import Blueprint, request, jsonify
|
| 2 |
+
from app.models.segmenter import PuzzlePieceSegmenter
|
| 3 |
+
import base64
|
| 4 |
+
import numpy as np
|
| 5 |
+
import cv2
|
| 6 |
+
|
| 7 |
+
segment_bp = Blueprint('segment', __name__)
|
| 8 |
+
|
| 9 |
+
@segment_bp.route('/segment', methods=['POST'])
|
| 10 |
+
def segment_image():
|
| 11 |
+
try:
|
| 12 |
+
data = request.get_json()
|
| 13 |
+
image_base64 = data.get('image_base64')
|
| 14 |
+
if not image_base64:
|
| 15 |
+
return jsonify({'error': 'No image provided'}), 400
|
| 16 |
+
|
| 17 |
+
# Decode base64 image
|
| 18 |
+
image_data = base64.b64decode(image_base64)
|
| 19 |
+
nparr = np.frombuffer(image_data, np.uint8)
|
| 20 |
+
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 21 |
+
|
| 22 |
+
# Initialize segmenter
|
| 23 |
+
segmenter = PuzzlePieceSegmenter()
|
| 24 |
+
|
| 25 |
+
# Perform segmentation
|
| 26 |
+
pieces = segmenter.segment_pieces(image)
|
| 27 |
+
|
| 28 |
+
# Serialize pieces
|
| 29 |
+
serialized_pieces = [
|
| 30 |
+
{
|
| 31 |
+
'id': piece['id'],
|
| 32 |
+
'image': base64.b64encode(piece['image']).decode('utf-8'),
|
| 33 |
+
'features': {
|
| 34 |
+
'geometric': piece['features']['geometric'],
|
| 35 |
+
'color': piece['features']['color'],
|
| 36 |
+
'edge': piece['features']['edge']
|
| 37 |
+
}
|
| 38 |
+
}
|
| 39 |
+
for piece in pieces
|
| 40 |
+
]
|
| 41 |
+
|
| 42 |
+
return jsonify({'pieces': serialized_pieces}), 200
|
| 43 |
+
except Exception as e:
|
| 44 |
+
return jsonify({'error': str(e)}), 500
|
app/utils/feature_extraction.py
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import numpy as np
|
| 3 |
+
|
| 4 |
+
def extract_piece_features(image: np.ndarray, contour: np.ndarray) -> tuple:
|
| 5 |
+
# Create mask for the piece
|
| 6 |
+
mask = np.zeros(image.shape[:2], dtype=np.uint8)
|
| 7 |
+
cv2.drawContours(mask, [contour], -1, 255, -1)
|
| 8 |
+
|
| 9 |
+
# Extract piece image
|
| 10 |
+
piece_image = cv2.bitwise_and(image, image, mask=mask)
|
| 11 |
+
|
| 12 |
+
# Geometric features
|
| 13 |
+
area = cv2.contourArea(contour)
|
| 14 |
+
perimeter = cv2.arcLength(contour, True)
|
| 15 |
+
rect = cv2.minAreaRect(contour)
|
| 16 |
+
aspect_ratio = rect[1][0] / rect[1][1] if rect[1][1] != 0 else 1.0
|
| 17 |
+
|
| 18 |
+
# Color histogram
|
| 19 |
+
color_histogram = cv2.calcHist([piece_image], [0, 1, 2], mask, [8, 8, 8], [0, 256, 0, 256, 0, 256])
|
| 20 |
+
color_histogram = cv2.normalize(color_histogram, color_histogram).flatten()
|
| 21 |
+
|
| 22 |
+
# Edge features (simplified)
|
| 23 |
+
gray = cv2.cvtColor(piece_image, cv2.COLOR_BGR2GRAY)
|
| 24 |
+
edges = cv2.Canny(gray, 100, 200)
|
| 25 |
+
edge_features = np.histogram(edges[mask == 255], bins=10, range=(0, 255))[0].astype(np.float32)
|
| 26 |
+
edge_features /= edge_features.sum() + 1e-10
|
| 27 |
+
|
| 28 |
+
features = {
|
| 29 |
+
'geometric': {'area': float(area), 'perimeter': float(perimeter), 'aspect_ratio': float(aspect_ratio)},
|
| 30 |
+
'color': color_histogram,
|
| 31 |
+
'edge': edge_features
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
return piece_image, features
|
app/utils/preprocess.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import numpy as np
|
| 3 |
+
|
| 4 |
+
def preprocess_image(image: np.ndarray) -> np.ndarray:
|
| 5 |
+
# Convert to LAB color space and apply CLAHE
|
| 6 |
+
lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB)
|
| 7 |
+
clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
|
| 8 |
+
lab[:, :, 0] = clahe.apply(lab[:, :, 0])
|
| 9 |
+
enhanced = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)
|
| 10 |
+
|
| 11 |
+
# Apply bilateral filtering
|
| 12 |
+
denoised = cv2.bilateralFilter(enhanced, 9, 75, 75)
|
| 13 |
+
|
| 14 |
+
return denoised
|
app/utils/visualize.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import numpy as np
|
| 3 |
+
|
| 4 |
+
def visualize_pieces(image: np.ndarray, pieces: list) -> np.ndarray:
|
| 5 |
+
vis_image = image.copy()
|
| 6 |
+
for piece in pieces:
|
| 7 |
+
contour = piece.get('contour') # Assume contour is stored
|
| 8 |
+
if contour is not None:
|
| 9 |
+
cv2.drawContours(vis_image, [contour], -1, (0, 255, 0), 2)
|
| 10 |
+
cv2.putText(
|
| 11 |
+
vis_image,
|
| 12 |
+
f"ID: {piece['id']}",
|
| 13 |
+
(int(contour[:, :, 0].min()), int(contour[:, :, 1].min()) - 10),
|
| 14 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 15 |
+
0.5,
|
| 16 |
+
(0, 0, 255),
|
| 17 |
+
1
|
| 18 |
+
)
|
| 19 |
+
return vis_image
|
deployments/Dockerfile
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
|
| 5 |
+
COPY requirements.txt .
|
| 6 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 7 |
+
|
| 8 |
+
COPY . .
|
| 9 |
+
|
| 10 |
+
ENV FLASK_APP=main.py
|
| 11 |
+
ENV FLASK_RUN_HOST=0.0.0.0
|
| 12 |
+
|
| 13 |
+
EXPOSE 5000
|
| 14 |
+
|
| 15 |
+
CMD ["flask", "run"]
|
deployments/kubernetes.yaml
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
apiVersion: apps/v1
|
| 2 |
+
kind: Deployment
|
| 3 |
+
metadata:
|
| 4 |
+
name: piecefinder-backend
|
| 5 |
+
spec:
|
| 6 |
+
replicas: 2
|
| 7 |
+
selector:
|
| 8 |
+
matchLabels:
|
| 9 |
+
app: piecefinder-backend
|
| 10 |
+
template:
|
| 11 |
+
metadata:
|
| 12 |
+
labels:
|
| 13 |
+
app: piecefinder-backend
|
| 14 |
+
spec:
|
| 15 |
+
containers:
|
| 16 |
+
- name: piecefinder-backend
|
| 17 |
+
image: piecefinder-backend:latest
|
| 18 |
+
ports:
|
| 19 |
+
- containerPort: 5000
|
| 20 |
+
env:
|
| 21 |
+
- name: DATABASE_URL
|
| 22 |
+
valueFrom:
|
| 23 |
+
secretKeyRef:
|
| 24 |
+
name: db-credentials
|
| 25 |
+
key: database-url
|
| 26 |
+
- name: REDIS_URL
|
| 27 |
+
valueFrom:
|
| 28 |
+
secretKeyRef:
|
| 29 |
+
name: redis-credentials
|
| 30 |
+
key: redis-url
|
| 31 |
+
---
|
| 32 |
+
apiVersion: v1
|
| 33 |
+
kind: Service
|
| 34 |
+
metadata:
|
| 35 |
+
name: piecefinder-backend
|
| 36 |
+
spec:
|
| 37 |
+
selector:
|
| 38 |
+
app: piecefinder-backend
|
| 39 |
+
ports:
|
| 40 |
+
- protocol: TCP
|
| 41 |
+
port: 80
|
| 42 |
+
targetPort: 5000
|
| 43 |
+
type: ClusterIP
|
deployments/requirements.txt
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
flask
|
| 2 |
+
opencv-python
|
| 3 |
+
numpy
|
| 4 |
+
ultralytics
|
| 5 |
+
segment-anything
|
| 6 |
+
faiss-cpu
|
| 7 |
+
tensorflow
|
| 8 |
+
scikit-image
|
| 9 |
+
sqlalchemy
|
| 10 |
+
psycopg2-binary
|
| 11 |
+
redis
|
| 12 |
+
flask-cors
|
main.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app import create_app
|
| 2 |
+
|
| 3 |
+
app = create_app()
|
| 4 |
+
|
| 5 |
+
if __name__ == '__main__':
|
| 6 |
+
app.run(debug=True, host='0.0.0.0', port=5000)
|
models/mobile_sam.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:6dbb90523a35330fedd7f1d3dfc66f995213d81b29a5ca8108dbcdd4e37d6c2f
|
| 3 |
+
size 40728226
|
models/yolov8n.pt
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:f59b3d833e2ff32e194b5bb8e08d211dc7c5bdf144b90d2c8412c47ccfc83b36
|
| 3 |
+
size 6549796
|
scripts/data_augmentation.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import cv2
|
| 2 |
+
import numpy as np
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
def generate_synthetic_puzzle(num_images: int, output_dir: str):
|
| 6 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 7 |
+
|
| 8 |
+
for i in range(num_images):
|
| 9 |
+
# Create blank image
|
| 10 |
+
image = np.ones((1000, 1000, 3), dtype=np.uint8) * 255
|
| 11 |
+
|
| 12 |
+
# Add random puzzle pieces (simplified)
|
| 13 |
+
for _ in range(np.random.randint(10, 50)):
|
| 14 |
+
x = np.random.randint(100, 900)
|
| 15 |
+
y = np.random.randint(100, 900)
|
| 16 |
+
size = np.random.randint(50, 200)
|
| 17 |
+
color = (np.random.randint(0, 256), np.random.randint(0, 256), np.random.randint(0, 256))
|
| 18 |
+
cv2.rectangle(image, (x, y), (x + size, y + size), color, -1)
|
| 19 |
+
|
| 20 |
+
# Save image
|
| 21 |
+
cv2.imwrite(os.path.join(output_dir, f'puzzle_{i}.jpg'), image)
|
| 22 |
+
|
| 23 |
+
if __name__ == '__main__':
|
| 24 |
+
generate_synthetic_puzzle(10, 'synthetic_puzzles')
|
scripts/train.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from app.models.feedback_manager import FeedbackManager
|
| 2 |
+
from app.models.matcher import PuzzleMatchingEngine
|
| 3 |
+
|
| 4 |
+
def retrain_model():
|
| 5 |
+
feedback_manager = FeedbackManager()
|
| 6 |
+
training_data = feedback_manager.get_training_data()
|
| 7 |
+
|
| 8 |
+
# Dummy retraining logic (adjust weights based on feedback)
|
| 9 |
+
matcher = PuzzleMatchingEngine()
|
| 10 |
+
positive_samples = training_data['positive']
|
| 11 |
+
negative_samples = training_data['negative']
|
| 12 |
+
|
| 13 |
+
# Example: Increase weights for correct matches
|
| 14 |
+
if positive_samples:
|
| 15 |
+
matcher.weight_geometric += 0.01
|
| 16 |
+
matcher.weight_color += 0.01
|
| 17 |
+
matcher.weight_edge += 0.01
|
| 18 |
+
total = matcher.weight_geometric + matcher.weight_color + matcher.weight_edge
|
| 19 |
+
matcher.weight_geometric /= total
|
| 20 |
+
matcher.weight_color /= total
|
| 21 |
+
matcher.weight_edge /= total
|
| 22 |
+
|
| 23 |
+
print("Model weights updated:", {
|
| 24 |
+
'geometric': matcher.weight_geometric,
|
| 25 |
+
'color': matcher.weight_color,
|
| 26 |
+
'edge': matcher.weight_edge
|
| 27 |
+
})
|
| 28 |
+
|
| 29 |
+
if __name__ == '__main__':
|
| 30 |
+
retrain_model()
|
setup.py
ADDED
|
File without changes
|
tests/test_feedback.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
from app.models.feedback_manager import FeedbackManager
|
| 3 |
+
|
| 4 |
+
class TestFeedback(unittest.TestCase):
|
| 5 |
+
def setUp(self):
|
| 6 |
+
self.feedback_manager = FeedbackManager()
|
| 7 |
+
|
| 8 |
+
def test_record_feedback(self):
|
| 9 |
+
self.feedback_manager.record_feedback(
|
| 10 |
+
piece_id=1,
|
| 11 |
+
slot_id=1,
|
| 12 |
+
suggestions={'piece_id': 1, 'confidence': 0.9},
|
| 13 |
+
user_choice=1,
|
| 14 |
+
is_correct=True
|
| 15 |
+
)
|
| 16 |
+
training_data = self.feedback_manager.get_training_data()
|
| 17 |
+
self.assertIn('positive', training_data)
|
| 18 |
+
self.assertGreaterEqual(len(training_data['positive']), 1)
|
| 19 |
+
|
| 20 |
+
if __name__ == '__main__':
|
| 21 |
+
unittest.main()
|
tests/test_matching.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
import numpy as np
|
| 3 |
+
from app.models.matcher import PuzzleMatchingEngine
|
| 4 |
+
|
| 5 |
+
class TestMatching(unittest.TestCase):
|
| 6 |
+
def setUp(self):
|
| 7 |
+
self.matcher = PuzzleMatchingEngine()
|
| 8 |
+
self.dummy_piece = {
|
| 9 |
+
'id': 0,
|
| 10 |
+
'features': {
|
| 11 |
+
'geometric': {'area': 1000, 'perimeter': 400, 'aspect_ratio': 1.0},
|
| 12 |
+
'color': np.zeros(512, dtype=np.float32),
|
| 13 |
+
'edge': np.zeros(10, dtype=np.float32)
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
def test_find_matches(self):
|
| 18 |
+
pieces = [self.dummy_piece]
|
| 19 |
+
slots = [self.dummy_piece]
|
| 20 |
+
matches = self.matcher.find_matches(pieces, slots)
|
| 21 |
+
self.assertIsInstance(matches, dict)
|
| 22 |
+
self.assertIn(0, matches)
|
| 23 |
+
self.assertGreaterEqual(len(matches[0]), 1)
|
| 24 |
+
|
| 25 |
+
if __name__ == '__main__':
|
| 26 |
+
unittest.main()
|
tests/test_segmentation.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import unittest
|
| 2 |
+
import cv2
|
| 3 |
+
import numpy as np
|
| 4 |
+
from app.models.segmenter import PuzzlePieceSegmenter
|
| 5 |
+
|
| 6 |
+
class TestSegmentation(unittest.TestCase):
|
| 7 |
+
def setUp(self):
|
| 8 |
+
self.segmenter = PuzzlePieceSegmenter()
|
| 9 |
+
self.test_image = np.zeros((1000, 1000, 3), dtype=np.uint8) # Dummy image
|
| 10 |
+
|
| 11 |
+
def test_segment_pieces(self):
|
| 12 |
+
pieces = self.segmenter.segment_pieces(self.test_image)
|
| 13 |
+
self.assertIsInstance(pieces, list)
|
| 14 |
+
for piece in pieces:
|
| 15 |
+
self.assertIn('id', piece)
|
| 16 |
+
self.assertIn('image', piece)
|
| 17 |
+
self.assertIn('features', piece)
|
| 18 |
+
|
| 19 |
+
if __name__ == '__main__':
|
| 20 |
+
unittest.main()
|