Files
INF6B/guessinggame/main.py
2025-10-01 10:46:21 +02:00

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