606 lines
20 KiB
Python
606 lines
20 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Face Guessing Game - Simplified version with auto-advance and no repeats
|
|
"""
|
|
|
|
import os
|
|
import random
|
|
import re
|
|
from pathlib import Path
|
|
from PIL import Image
|
|
import io
|
|
import base64
|
|
from flask import Flask, render_template_string, request, jsonify
|
|
import threading
|
|
import time
|
|
import webbrowser
|
|
import cv2
|
|
import numpy as np
|
|
from functools import lru_cache
|
|
|
|
app = Flask(__name__)
|
|
|
|
class FaceGuessingGame:
|
|
def __init__(self, images_dir="./images/"):
|
|
self.images_dir = Path(images_dir)
|
|
self.used_images = set()
|
|
self.available_images = []
|
|
self.current_image = None
|
|
self.current_answer = None
|
|
self.score = 0
|
|
self.total_games = 0
|
|
self.face_cascade = None
|
|
self.load_face_detector()
|
|
self.load_images()
|
|
|
|
def load_face_detector(self):
|
|
"""Load face detection cascade classifier"""
|
|
try:
|
|
cascade_path = cv2.data.haarcascades + 'haarcascade_frontalface_default.xml'
|
|
if os.path.exists(cascade_path):
|
|
self.face_cascade = cv2.CascadeClassifier(cascade_path)
|
|
print("✅ Face detector loaded")
|
|
else:
|
|
print("⚠️ Using fallback cropping")
|
|
except:
|
|
self.face_cascade = None
|
|
|
|
def load_images(self):
|
|
"""Load all image files from the images directory"""
|
|
if not self.images_dir.exists():
|
|
self.images_dir.mkdir(exist_ok=True)
|
|
print(f"📁 Created images directory: {self.images_dir}")
|
|
|
|
image_extensions = {'.jpg', '.jpeg', '.png', '.bmp', '.gif', '.tiff'}
|
|
all_images = [
|
|
f for f in self.images_dir.iterdir()
|
|
if f.is_file() and f.suffix.lower() in image_extensions
|
|
]
|
|
|
|
self.available_images = all_images.copy()
|
|
self.used_images.clear()
|
|
|
|
if all_images:
|
|
print(f"✅ Loaded {len(all_images)} images")
|
|
else:
|
|
print("⚠️ No images found - add images to ./images/ folder")
|
|
|
|
def filename_to_name(self, filename):
|
|
"""Convert filename to readable name"""
|
|
name = filename.stem
|
|
name = re.sub(r'[_-]', ' ', name)
|
|
name = re.sub(r'\s+', ' ', name).strip().title()
|
|
return name
|
|
|
|
def get_random_image(self):
|
|
"""Get a random unused image, reset if all used"""
|
|
if not self.available_images:
|
|
return None
|
|
|
|
if len(self.used_images) >= len(self.available_images):
|
|
# Reset if all images have been used
|
|
self.used_images.clear()
|
|
print("🔄 All images used - resetting game")
|
|
|
|
available = [img for img in self.available_images if img not in self.used_images]
|
|
if not available:
|
|
return None
|
|
|
|
image_file = random.choice(available)
|
|
self.used_images.add(image_file)
|
|
return image_file
|
|
|
|
@lru_cache(maxsize=50)
|
|
def crop_face_portion_cached(self, image_path_str, crop_ratio):
|
|
"""Cached version of face cropping"""
|
|
image_path = Path(image_path_str)
|
|
try:
|
|
result = self.detect_faces(image_path)
|
|
if result is None:
|
|
return self.crop_random_portion(image_path, crop_ratio)
|
|
|
|
faces, img = result
|
|
|
|
if len(faces) == 0:
|
|
return self.crop_random_portion(image_path, crop_ratio)
|
|
|
|
# Use the largest face found
|
|
x, y, w, h = sorted(faces, key=lambda x: x[2] * x[3], reverse=True)[0]
|
|
|
|
height, width = img.shape[:2]
|
|
center_x = x + w // 2
|
|
center_y = y + h // 2
|
|
|
|
# Ultra-hard cropping for higher difficulties
|
|
if crop_ratio <= 0.08:
|
|
crop_width = int(w * 0.4)
|
|
crop_height = int(h * 0.3)
|
|
center_y = y + int(h * 0.6)
|
|
elif crop_ratio <= 0.12:
|
|
crop_width = int(w * 0.5)
|
|
crop_height = int(h * 0.4)
|
|
center_y = y + int(h * random.choice([0.3, 0.5, 0.7]))
|
|
else:
|
|
crop_width = int(w * (0.3 + crop_ratio))
|
|
crop_height = int(h * (0.3 + crop_ratio))
|
|
|
|
crop_x1 = max(0, center_x - crop_width // 2)
|
|
crop_y1 = max(0, center_y - crop_height // 2)
|
|
crop_x2 = min(width, center_x + crop_width // 2)
|
|
crop_y2 = min(height, center_y + crop_height // 2)
|
|
|
|
if (crop_x2 - crop_x1) < 50 or (crop_y2 - crop_y1) < 50:
|
|
crop_x1, crop_y1, crop_x2, crop_y2 = x, y, x + w, y + h
|
|
|
|
cropped_img = img[crop_y1:crop_y2, crop_x1:crop_x2]
|
|
cropped_img_rgb = cv2.cvtColor(cropped_img, cv2.COLOR_BGR2RGB)
|
|
pil_img = Image.fromarray(cropped_img_rgb)
|
|
|
|
buffer = io.BytesIO()
|
|
pil_img.save(buffer, format='JPEG', quality=85)
|
|
img_str = base64.b64encode(buffer.getvalue()).decode()
|
|
|
|
return f"data:image/jpeg;base64,{img_str}"
|
|
|
|
except Exception as e:
|
|
return self.crop_random_portion(image_path, crop_ratio)
|
|
|
|
def detect_faces(self, image_path):
|
|
"""Detect faces in image"""
|
|
try:
|
|
img = cv2.imread(str(image_path))
|
|
if img is None:
|
|
return None
|
|
|
|
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
|
|
|
if self.face_cascade is not None:
|
|
faces = self.face_cascade.detectMultiScale(gray, 1.1, 5, minSize=(30, 30))
|
|
else:
|
|
height, width = gray.shape
|
|
faces = [(0, 0, width, height)]
|
|
|
|
return faces, img
|
|
except:
|
|
return None
|
|
|
|
def crop_random_portion(self, image_path, crop_ratio):
|
|
"""Fallback cropping method"""
|
|
try:
|
|
with Image.open(image_path) as img:
|
|
if img.mode != 'RGB':
|
|
img = img.convert('RGB')
|
|
|
|
width, height = img.size
|
|
crop_width = max(50, int(width * crop_ratio))
|
|
crop_height = max(50, int(height * crop_ratio))
|
|
|
|
x = random.randint(0, max(0, width - crop_width))
|
|
y = random.randint(0, max(0, height - crop_height))
|
|
|
|
cropped = img.crop((x, y, x + crop_width, y + crop_height))
|
|
|
|
buffer = io.BytesIO()
|
|
cropped.save(buffer, format='JPEG', quality=85)
|
|
img_str = base64.b64encode(buffer.getvalue()).decode()
|
|
|
|
return f"data:image/jpeg;base64,{img_str}"
|
|
except:
|
|
return None
|
|
|
|
def new_game(self, difficulty='normal'):
|
|
"""Start a new game with unused image"""
|
|
image_file = self.get_random_image()
|
|
if not image_file:
|
|
return None, None
|
|
|
|
self.current_image = image_file
|
|
self.current_answer = self.filename_to_name(image_file)
|
|
|
|
crop_ratios = {
|
|
'easy': 0.6, 'normal': 0.4, 'hard': 0.25,
|
|
'expert': 0.15, 'insane': 0.12, 'extreme': 0.08
|
|
}
|
|
|
|
crop_ratio = crop_ratios.get(difficulty, 0.4)
|
|
cropped_image = self.crop_face_portion_cached(str(image_file), crop_ratio)
|
|
|
|
return cropped_image, self.current_answer
|
|
|
|
def check_guess(self, guess):
|
|
"""Check if guess is correct (lenient matching)"""
|
|
if not self.current_answer or not guess:
|
|
return False
|
|
|
|
guess_clean = re.sub(r'[^a-zA-Z0-9\s]', '', guess.strip().lower())
|
|
answer_clean = re.sub(r'[^a-zA-Z0-9\s]', '', self.current_answer.lower())
|
|
|
|
# Exact match
|
|
if guess_clean == answer_clean:
|
|
return True
|
|
|
|
# Partial match (at least one word matches)
|
|
guess_words = set(guess_clean.split())
|
|
answer_words = set(answer_clean.split())
|
|
|
|
return len(guess_words & answer_words) > 0
|
|
|
|
def update_score(self, correct):
|
|
"""Update game statistics"""
|
|
self.total_games += 1
|
|
if correct:
|
|
self.score += 1
|
|
|
|
# Initialize game
|
|
game = FaceGuessingGame()
|
|
|
|
# Ultra-simple UI HTML Template
|
|
HTML_TEMPLATE = """
|
|
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Face Guess</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body {
|
|
font-family: system-ui, -apple-system, sans-serif;
|
|
background: #1a1a1a;
|
|
color: white;
|
|
min-height: 100vh;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
padding: 20px;
|
|
}
|
|
.container {
|
|
max-width: 500px;
|
|
width: 100%;
|
|
text-align: center;
|
|
}
|
|
.face-image {
|
|
width: 300px;
|
|
height: 300px;
|
|
object-fit: cover;
|
|
border: 3px solid #444;
|
|
border-radius: 12px;
|
|
margin: 0 auto 20px;
|
|
background: #2a2a2a;
|
|
display: none;
|
|
}
|
|
.input-container {
|
|
position: relative;
|
|
max-width: 300px;
|
|
margin: 0 auto 20px;
|
|
}
|
|
#guessInput {
|
|
width: 100%;
|
|
padding: 15px 50px 15px 15px;
|
|
font-size: 18px;
|
|
border: 2px solid #555;
|
|
border-radius: 8px;
|
|
background: #2a2a2a;
|
|
color: white;
|
|
text-align: center;
|
|
outline: none;
|
|
}
|
|
#guessInput:focus {
|
|
border-color: #007acc;
|
|
}
|
|
#guessInput:disabled {
|
|
opacity: 0.6;
|
|
}
|
|
.skip-btn {
|
|
position: absolute;
|
|
right: 8px;
|
|
top: 50%;
|
|
transform: translateY(-50%);
|
|
background: none;
|
|
border: none;
|
|
color: #888;
|
|
font-size: 24px;
|
|
cursor: pointer;
|
|
padding: 5px;
|
|
border-radius: 50%;
|
|
width: 35px;
|
|
height: 35px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
transition: all 0.2s;
|
|
}
|
|
.skip-btn:hover:not(:disabled) {
|
|
background: #333;
|
|
color: #fff;
|
|
}
|
|
.skip-btn:disabled {
|
|
opacity: 0.4;
|
|
cursor: not-allowed;
|
|
}
|
|
.stats {
|
|
display: flex;
|
|
justify-content: center;
|
|
gap: 30px;
|
|
margin-bottom: 20px;
|
|
font-size: 18px;
|
|
color: #ccc;
|
|
}
|
|
.stat { text-align: center; }
|
|
.stat-value {
|
|
font-size: 24px;
|
|
font-weight: bold;
|
|
color: white;
|
|
}
|
|
.difficulty {
|
|
margin: 15px 0;
|
|
}
|
|
select {
|
|
padding: 8px 12px;
|
|
border-radius: 6px;
|
|
background: #2a2a2a;
|
|
color: white;
|
|
border: 1px solid #555;
|
|
}
|
|
.message {
|
|
padding: 10px;
|
|
border-radius: 6px;
|
|
margin: 10px 0;
|
|
font-weight: bold;
|
|
display: none;
|
|
}
|
|
.correct { background: #2d5a2d; color: #90ee90; }
|
|
.incorrect { background: #5a2d2d; color: #ffb6c1; }
|
|
.skip { background: #3a3a5a; color: #aaaaff; }
|
|
.no-images {
|
|
padding: 40px;
|
|
background: #2a2a2a;
|
|
border-radius: 12px;
|
|
border: 2px dashed #555;
|
|
}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div class="container">
|
|
<h1>Rate:</h1>
|
|
|
|
<div class="stats">
|
|
<div class="stat">
|
|
<div>Score</div>
|
|
<div class="stat-value" id="score">0</div>
|
|
</div>
|
|
<div class="stat">
|
|
<div>Accuracy</div>
|
|
<div class="stat-value" id="accuracy">0%</div>
|
|
</div>
|
|
</div>
|
|
|
|
<img id="gameImage" class="face-image" alt="Who is this?">
|
|
|
|
<div class="difficulty">
|
|
<select id="difficulty" onchange="newGame()">
|
|
<option value="easy">Easy</option>
|
|
<option value="normal" selected>Normal</option>
|
|
<option value="hard">Hard</option>
|
|
<option value="expert">Expert</option>
|
|
<option value="insane">Insane</option>
|
|
<option value="extreme">Extreme</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div class="input-container">
|
|
<input type="text" id="guessInput" placeholder="Enter name..." autocomplete="off">
|
|
<button class="skip-btn" id="skipBtn" title="Skip this person">?</button>
|
|
</div>
|
|
|
|
<div id="message" class="message"></div>
|
|
|
|
<div id="noImages" class="no-images" style="display: none;">
|
|
<h3>No Images Found</h3>
|
|
<p>Add images to ./images/ folder to start playing</p>
|
|
</div>
|
|
</div>
|
|
|
|
<script>
|
|
let gameActive = false;
|
|
|
|
function handleKeyPress(event) {
|
|
if (event.key === 'Enter' && gameActive) {
|
|
submitGuess();
|
|
}
|
|
}
|
|
|
|
function submitGuess() {
|
|
if (!gameActive) return;
|
|
|
|
const guess = document.getElementById('guessInput').value.trim();
|
|
if (!guess) return;
|
|
|
|
gameActive = false;
|
|
document.getElementById('guessInput').disabled = true;
|
|
document.getElementById('skipBtn').disabled = true;
|
|
|
|
fetch('/guess', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ guess: guess })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
showMessage(data.correct ? '✓ Correct!' : '✗ Wrong!', data.correct);
|
|
updateStats(data.score, data.total_games, data.accuracy);
|
|
|
|
if (data.correct) {
|
|
// Auto-advance on correct guess
|
|
setTimeout(newGame, 800);
|
|
} else {
|
|
// Re-enable input for retry
|
|
setTimeout(() => {
|
|
document.getElementById('guessInput').disabled = false;
|
|
document.getElementById('skipBtn').disabled = false;
|
|
document.getElementById('guessInput').focus();
|
|
gameActive = true;
|
|
}, 1500);
|
|
}
|
|
});
|
|
}
|
|
|
|
function skipGame() {
|
|
if (!gameActive) return;
|
|
|
|
gameActive = false;
|
|
document.getElementById('guessInput').disabled = true;
|
|
document.getElementById('skipBtn').disabled = true;
|
|
|
|
fetch('/skip')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
showMessage(`Skipped! It was ${data.answer}`, 'skip');
|
|
updateStats(data.score, data.total_games, data.accuracy);
|
|
|
|
// Auto-advance after skip
|
|
setTimeout(newGame, 1500);
|
|
});
|
|
}
|
|
|
|
function newGame() {
|
|
const difficulty = document.getElementById('difficulty').value;
|
|
|
|
fetch('/new_game', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ difficulty: difficulty })
|
|
})
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.success) {
|
|
document.getElementById('gameImage').src = data.image_data;
|
|
document.getElementById('gameImage').style.display = 'block';
|
|
document.getElementById('noImages').style.display = 'none';
|
|
document.getElementById('guessInput').value = '';
|
|
document.getElementById('guessInput').disabled = false;
|
|
document.getElementById('skipBtn').disabled = false;
|
|
document.getElementById('guessInput').focus();
|
|
document.getElementById('message').style.display = 'none';
|
|
gameActive = true;
|
|
} else {
|
|
document.getElementById('gameImage').style.display = 'none';
|
|
document.getElementById('noImages').style.display = 'block';
|
|
gameActive = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
function showMessage(text, type) {
|
|
const msg = document.getElementById('message');
|
|
msg.textContent = text;
|
|
msg.className = `message ${type}`;
|
|
msg.style.display = 'block';
|
|
}
|
|
|
|
function updateStats(score, totalGames, accuracy) {
|
|
document.getElementById('score').textContent = score;
|
|
document.getElementById('accuracy').textContent = accuracy + '%';
|
|
}
|
|
|
|
// Initialize
|
|
document.getElementById('guessInput').addEventListener('keypress', handleKeyPress);
|
|
document.getElementById('skipBtn').addEventListener('click', skipGame);
|
|
|
|
window.onload = () => {
|
|
fetch('/check_images')
|
|
.then(r => r.json())
|
|
.then(data => {
|
|
if (data.has_images) {
|
|
newGame();
|
|
} else {
|
|
document.getElementById('noImages').style.display = 'block';
|
|
}
|
|
});
|
|
};
|
|
</script>
|
|
</body>
|
|
</html>
|
|
"""
|
|
|
|
@app.route('/')
|
|
def index():
|
|
"""Main game page"""
|
|
return render_template_string(HTML_TEMPLATE)
|
|
|
|
@app.route('/check_images')
|
|
def check_images():
|
|
"""Check if images are available"""
|
|
return jsonify({
|
|
'has_images': len(game.available_images) > 0,
|
|
'count': len(game.available_images)
|
|
})
|
|
|
|
@app.route('/new_game', methods=['POST'])
|
|
def new_game():
|
|
"""Start a new game"""
|
|
data = request.get_json()
|
|
difficulty = data.get('difficulty', 'normal')
|
|
|
|
image_data, answer = game.new_game(difficulty)
|
|
|
|
if image_data:
|
|
return jsonify({
|
|
'success': True,
|
|
'image_data': image_data
|
|
})
|
|
else:
|
|
return jsonify({
|
|
'success': False,
|
|
'message': 'No images available'
|
|
})
|
|
|
|
@app.route('/guess', methods=['POST'])
|
|
def make_guess():
|
|
"""Submit a guess"""
|
|
data = request.get_json()
|
|
guess = data.get('guess', '')
|
|
|
|
correct = game.check_guess(guess)
|
|
game.update_score(correct)
|
|
|
|
accuracy = round((game.score / game.total_games * 100) if game.total_games > 0 else 0)
|
|
|
|
return jsonify({
|
|
'correct': correct,
|
|
'answer': game.current_answer,
|
|
'score': game.score,
|
|
'total_games': game.total_games,
|
|
'accuracy': accuracy
|
|
})
|
|
|
|
@app.route('/skip')
|
|
def skip_game():
|
|
"""Skip current game"""
|
|
game.update_score(False)
|
|
|
|
accuracy = round((game.score / game.total_games * 100) if game.total_games > 0 else 0)
|
|
|
|
return jsonify({
|
|
'answer': game.current_answer,
|
|
'score': game.score,
|
|
'total_games': game.total_games,
|
|
'accuracy': accuracy
|
|
})
|
|
|
|
def open_browser():
|
|
"""Open browser after delay"""
|
|
time.sleep(1.5)
|
|
webbrowser.open('http://127.0.0.1:5000')
|
|
|
|
if __name__ == '__main__':
|
|
print("http://127.0.0.1:5000")
|
|
|
|
browser_thread = threading.Thread(target=open_browser)
|
|
browser_thread.daemon = True
|
|
browser_thread.start()
|
|
|
|
try:
|
|
app.run(debug=False, host='127.0.0.1', port=5000, use_reloader=False)
|
|
except KeyboardInterrupt:
|
|
pass |