try for better ocr puzzle

This commit is contained in:
2025-10-30 14:29:50 +01:00
parent 8960f551e6
commit 15de496501
15 changed files with 649 additions and 88 deletions
+36 -43
View File
@@ -3,16 +3,19 @@ import { ref, onMounted, computed } from 'vue'
import PuzzleCard from './components/PuzzleCard.vue'
import SubmissionForm from './components/SubmissionForm.vue'
import AdminPanel from './components/AdminPanel.vue'
import { puzzleHelpers, submissionHelpers, errorHelpers, apiService } from './services/apiService'
import type { SteamCollection, SteamCollectionItem, Submission, PuzzleResponse, UserInfo } from './types'
import { apiService, errorHelpers } from './services/apiService'
import { usePuzzlesStore } from './stores/puzzles'
import { useSubmissionsStore } from './stores/submissions'
import type { SteamCollection, PuzzleResponse, UserInfo } from './types'
// API data
// Pinia stores
const puzzlesStore = usePuzzlesStore()
const submissionsStore = useSubmissionsStore()
// Local state
const collections = ref<SteamCollection[]>([])
const puzzles = ref<SteamCollectionItem[]>([])
const submissions = ref<Submission[]>([])
const userInfo = ref<UserInfo | null>(null)
const isLoading = ref(true)
const showSubmissionModal = ref(false)
const error = ref<string>('')
// Mock data removed - using API data only
@@ -25,7 +28,7 @@ const isSuperuser = computed(() => {
// Computed property to get responses grouped by puzzle
const responsesByPuzzle = computed(() => {
const grouped: Record<number, PuzzleResponse[]> = {}
submissions.value.forEach(submission => {
submissionsStore.submissions.forEach(submission => {
submission.responses.forEach(response => {
// Handle both number and object types for puzzle field
const puzzleId = typeof response.puzzle === 'number' ? response.puzzle : response.puzzle.id
@@ -55,21 +58,20 @@ onMounted(async () => {
console.warn('User info error:', userResponse.error)
}
// Load puzzles from API
// Load puzzles from API using store
console.log('Loading puzzles...')
const loadedPuzzles = await puzzleHelpers.loadPuzzles()
puzzles.value = loadedPuzzles
console.log('Puzzles loaded:', loadedPuzzles.length)
await puzzlesStore.loadPuzzles()
console.log('Puzzles loaded:', puzzlesStore.puzzles.length)
// Create mock collection from loaded puzzles for display
if (loadedPuzzles.length > 0) {
if (puzzlesStore.puzzles.length > 0) {
collections.value = [{
id: 1,
steam_id: '3479142989',
title: 'PolyLAN 41',
description: 'Puzzle collection for PolyLAN 41 fil rouge',
author_name: 'Flame Legrems',
total_items: loadedPuzzles.length,
total_items: puzzlesStore.puzzles.length,
unique_visitors: 31,
current_favorites: 1,
created_at: new Date().toISOString(),
@@ -78,11 +80,10 @@ onMounted(async () => {
console.log('Collection created')
}
// Load existing submissions
// Load existing submissions using store
console.log('Loading submissions...')
const loadedSubmissions = await submissionHelpers.loadSubmissions()
submissions.value = loadedSubmissions
console.log('Submissions loaded:', loadedSubmissions.length)
await submissionsStore.loadSubmissions()
console.log('Submissions loaded:', submissionsStore.submissions.length)
console.log('Data load complete!')
@@ -104,32 +105,24 @@ const handleSubmission = async (submissionData: {
isLoading.value = true
error.value = ''
// Create submission via API
const response = await submissionHelpers.createFromFiles(
// Create submission via store
const submission = await submissionsStore.createSubmission(
submissionData.files,
puzzles.value,
submissionData.notes,
submissionData.manualValidationRequested
)
if (response.error) {
error.value = response.error
alert(`Submission failed: ${response.error}`)
return
}
if (response.data) {
// Add to local submissions list
submissions.value.unshift(response.data)
// Show success message
const puzzleNames = response.data.responses.map(r => r.puzzle_name).join(', ')
// Show success message
if (submission) {
const puzzleNames = submission.responses.map(r => r.puzzle_name).join(', ')
alert(`Solutions submitted successfully for puzzles: ${puzzleNames}`)
// Close modal
showSubmissionModal.value = false
} else {
alert('Submission created successfully!')
}
// Close modal
submissionsStore.closeSubmissionModal()
} catch (err) {
const errorMessage = errorHelpers.getErrorMessage(err)
error.value = errorMessage
@@ -141,16 +134,16 @@ const handleSubmission = async (submissionData: {
}
const openSubmissionModal = () => {
showSubmissionModal.value = true
submissionsStore.openSubmissionModal()
}
const closeSubmissionModal = () => {
showSubmissionModal.value = false
submissionsStore.closeSubmissionModal()
}
// Function to match puzzle name from OCR to actual puzzle
const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null => {
return puzzleHelpers.findPuzzleByName(puzzles.value, ocrPuzzleName)
const findPuzzleByName = (ocrPuzzleName: string) => {
return puzzlesStore.findPuzzleByName(ocrPuzzleName)
}
const reloadPage = () => {
@@ -237,7 +230,7 @@ const reloadPage = () => {
<!-- Puzzles Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<PuzzleCard
v-for="puzzle in puzzles"
v-for="puzzle in puzzlesStore.puzzles"
:key="puzzle.id"
:puzzle="puzzle"
:responses="responsesByPuzzle[puzzle.id] || []"
@@ -245,7 +238,7 @@ const reloadPage = () => {
</div>
<!-- Empty State -->
<div v-if="puzzles.length === 0" class="text-center py-12">
<div v-if="puzzlesStore.puzzles.length === 0" class="text-center py-12">
<div class="text-6xl mb-4">🧩</div>
<h3 class="text-xl font-bold mb-2">No Puzzles Available</h3>
<p class="text-base-content/70">Check back later for new puzzle collections!</p>
@@ -254,7 +247,7 @@ const reloadPage = () => {
</div>
<!-- Submission Modal -->
<div v-if="showSubmissionModal" class="modal modal-open">
<div v-if="submissionsStore.isSubmissionModalOpen" class="modal modal-open">
<div class="modal-box max-w-4xl">
<div class="flex justify-between items-center mb-4">
<h3 class="font-bold text-lg">Submit Solution</h3>
@@ -267,7 +260,7 @@ const reloadPage = () => {
</div>
<SubmissionForm
:puzzles="puzzles"
:puzzles="puzzlesStore.puzzles"
:find-puzzle-by-name="findPuzzleByName"
@submit="handleSubmission"
/>
+6 -3
View File
@@ -181,6 +181,7 @@
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { ocrService } from '../services/ocrService'
import { usePuzzlesStore } from '@/stores/puzzles'
import type { SubmissionFile, SteamCollectionItem } from '@/types'
interface Props {
@@ -195,6 +196,9 @@ interface Emits {
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
// Pinia store
const puzzlesStore = usePuzzlesStore()
const fileInput = ref<HTMLInputElement>()
const isDragOver = ref(false)
const error = ref('')
@@ -211,10 +215,9 @@ watch(files, (newFiles) => {
}, { deep: true })
// Watch for puzzle changes and update OCR service
watch(() => props.puzzles, (newPuzzles) => {
watch(() => puzzlesStore.puzzles, (newPuzzles) => {
if (newPuzzles && newPuzzles.length > 0) {
const puzzleNames = newPuzzles.map(puzzle => puzzle.title)
ocrService.setAvailablePuzzleNames(puzzleNames)
ocrService.setAvailablePuzzleNames(puzzlesStore.puzzleNames)
}
}, { immediate: true })
+4 -1
View File
@@ -1,5 +1,8 @@
import { createApp } from 'vue'
import App from '@/App.vue'
import { pinia } from '@/stores'
import './style.css'
createApp(App).mount('#app')
const app = createApp(App)
app.use(pinia)
app.mount('#app')
+297 -20
View File
@@ -48,6 +48,68 @@ export class OpusMagnumOCRService {
*/
setAvailablePuzzleNames(puzzleNames: string[]): void {
this.availablePuzzleNames = puzzleNames;
console.log('OCR service updated with puzzle names:', puzzleNames);
}
/**
* Configure OCR specifically for puzzle name recognition
* Uses aggressive character whitelisting and dictionary constraints
*/
private async configurePuzzleOCR(): Promise<void> {
if (!this.worker) return;
// Configure Tesseract for maximum constraint to our puzzle names
await this.worker.setParameters({
// Disable all system dictionaries to prevent interference
load_system_dawg: '0',
load_freq_dawg: '0',
load_punc_dawg: '0',
load_number_dawg: '0',
load_unambig_dawg: '0',
load_bigram_dawg: '0',
load_fixed_length_dawgs: '0',
// Use only characters from our puzzle names
tessedit_char_whitelist: this.getPuzzleCharacterSet(),
// Optimize for single words/short phrases
tessedit_pageseg_mode: 8 as any, // Single word
// Increase penalties for non-dictionary words
segment_penalty_dict_nonword: '2.0',
segment_penalty_dict_frequent_word: '0.001',
segment_penalty_dict_case_ok: '0.001',
segment_penalty_dict_case_bad: '0.1',
// Make OCR more conservative about character recognition
classify_enable_learning: '0',
classify_enable_adaptive_matcher: '1',
// Preserve word boundaries
preserve_interword_spaces: '1'
});
console.log('OCR configured for puzzle names with character set:', this.getPuzzleCharacterSet());
}
/**
* Get character set from available puzzle names for more accurate OCR (fallback)
*/
private getPuzzleCharacterSet(): string {
if (this.availablePuzzleNames.length === 0) {
// Fallback to common characters
return 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 -'
}
// Extract unique characters from all puzzle names
const chars = new Set<string>()
this.availablePuzzleNames.forEach(name => {
for (const char of name) {
chars.add(char)
}
})
return Array.from(chars).join('')
}
async extractOpusMagnumData(imageFile: File): Promise<OpusMagnumData> {
@@ -104,10 +166,8 @@ export class OpusMagnumOCRService {
tessedit_char_whitelist: '0123456789'
});
} else if (key === 'puzzle') {
// Puzzle name - allow alphanumeric, spaces, and dashes
await this.worker!.setParameters({
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 -'
});
// Puzzle name - use user words file for better matching
await this.configurePuzzleOCR();
} else {
// Default - allow all characters
await this.worker!.setParameters({
@@ -141,8 +201,17 @@ export class OpusMagnumOCRService {
// Ensure only digits remain
cleanText = cleanText.replace(/[^0-9]/g, '');
} else if (key === 'puzzle') {
// Post-process puzzle names with fuzzy matching
// Post-process puzzle names with aggressive matching to force selection from available puzzles
cleanText = this.findBestPuzzleMatch(cleanText);
// If we still don't have a match and we have available puzzles, force the best match
if (this.availablePuzzleNames.length > 0 && !this.availablePuzzleNames.includes(cleanText)) {
const forcedMatch = this.findBestPuzzleMatchForced(cleanText);
if (forcedMatch) {
cleanText = forcedMatch;
console.log(`Forced OCR match: "${text.trim()}" -> "${cleanText}"`);
}
}
}
(results as any)[key] = cleanText;
@@ -226,7 +295,7 @@ export class OpusMagnumOCRService {
}
/**
* Find the best matching puzzle name from available options
* Find the best matching puzzle name from available options using multiple strategies
*/
private findBestPuzzleMatch(ocrText: string): string {
if (!this.availablePuzzleNames.length) {
@@ -234,31 +303,155 @@ export class OpusMagnumOCRService {
}
const cleanedOcr = ocrText.trim();
if (!cleanedOcr) return '';
// First try exact match (case insensitive)
// Strategy 1: Exact match (case insensitive)
const exactMatch = this.availablePuzzleNames.find(
name => name.toLowerCase() === cleanedOcr.toLowerCase()
);
if (exactMatch) return exactMatch;
// Then try fuzzy matching
// Strategy 2: Substring match (either direction)
const substringMatch = this.availablePuzzleNames.find(
name => name.toLowerCase().includes(cleanedOcr.toLowerCase()) ||
cleanedOcr.toLowerCase().includes(name.toLowerCase())
);
if (substringMatch) return substringMatch;
// Strategy 3: Multiple fuzzy matching approaches
let bestMatch = cleanedOcr;
let bestScore = Infinity;
let bestScore = 0;
for (const puzzleName of this.availablePuzzleNames) {
// Calculate similarity scores
const distance = this.levenshteinDistance(
cleanedOcr.toLowerCase(),
puzzleName.toLowerCase()
);
const scores = [
this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName),
this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName),
this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2)
];
// Normalize by length to get a similarity ratio
const maxLength = Math.max(cleanedOcr.length, puzzleName.length);
const similarity = 1 - (distance / maxLength);
// Use the maximum score from all algorithms
const maxScore = Math.max(...scores);
// Consider it a good match if similarity is above 70%
if (similarity > 0.7 && distance < bestScore) {
bestScore = distance;
// Lower threshold for better matching - force selection even with moderate confidence
if (maxScore > bestScore && maxScore > 0.4) {
bestScore = maxScore;
bestMatch = puzzleName;
}
}
// Strategy 4: If no good match found, try character-based matching
if (bestScore < 0.6) {
const charMatch = this.findBestCharacterMatch(cleanedOcr);
if (charMatch) {
bestMatch = charMatch;
}
}
return bestMatch;
}
/**
* Calculate Levenshtein similarity (normalized)
*/
private calculateLevenshteinSimilarity(str1: string, str2: string): number {
const distance = this.levenshteinDistance(str1.toLowerCase(), str2.toLowerCase());
const maxLength = Math.max(str1.length, str2.length);
return maxLength === 0 ? 1 : 1 - (distance / maxLength);
}
/**
* Calculate Jaro-Winkler similarity
*/
private calculateJaroWinklerSimilarity(str1: string, str2: string): number {
const s1 = str1.toLowerCase();
const s2 = str2.toLowerCase();
if (s1 === s2) return 1;
const matchWindow = Math.floor(Math.max(s1.length, s2.length) / 2) - 1;
if (matchWindow < 0) return 0;
const s1Matches = new Array(s1.length).fill(false);
const s2Matches = new Array(s2.length).fill(false);
let matches = 0;
let transpositions = 0;
// Find matches
for (let i = 0; i < s1.length; i++) {
const start = Math.max(0, i - matchWindow);
const end = Math.min(i + matchWindow + 1, s2.length);
for (let j = start; j < end; j++) {
if (s2Matches[j] || s1[i] !== s2[j]) continue;
s1Matches[i] = true;
s2Matches[j] = true;
matches++;
break;
}
}
if (matches === 0) return 0;
// Count transpositions
let k = 0;
for (let i = 0; i < s1.length; i++) {
if (!s1Matches[i]) continue;
while (!s2Matches[k]) k++;
if (s1[i] !== s2[k]) transpositions++;
k++;
}
const jaro = (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3;
// Jaro-Winkler bonus for common prefix
let prefix = 0;
for (let i = 0; i < Math.min(s1.length, s2.length, 4); i++) {
if (s1[i] === s2[i]) prefix++;
else break;
}
return jaro + (0.1 * prefix * (1 - jaro));
}
/**
* Calculate N-gram similarity
*/
private calculateNGramSimilarity(str1: string, str2: string, n: number): number {
const s1 = str1.toLowerCase();
const s2 = str2.toLowerCase();
if (s1 === s2) return 1;
if (s1.length < n || s2.length < n) return 0;
const ngrams1 = new Set<string>();
const ngrams2 = new Set<string>();
for (let i = 0; i <= s1.length - n; i++) {
ngrams1.add(s1.substr(i, n));
}
for (let i = 0; i <= s2.length - n; i++) {
ngrams2.add(s2.substr(i, n));
}
const intersection = new Set([...ngrams1].filter(x => ngrams2.has(x)));
const union = new Set([...ngrams1, ...ngrams2]);
return intersection.size / union.size;
}
/**
* Find best match based on character frequency
*/
private findBestCharacterMatch(ocrText: string): string | null {
let bestMatch = null;
let bestScore = 0;
for (const puzzleName of this.availablePuzzleNames) {
const score = this.calculateCharacterFrequencyScore(ocrText.toLowerCase(), puzzleName.toLowerCase());
if (score > bestScore && score > 0.3) {
bestScore = score;
bestMatch = puzzleName;
}
}
@@ -266,6 +459,90 @@ export class OpusMagnumOCRService {
return bestMatch;
}
/**
* Calculate character frequency similarity
*/
private calculateCharacterFrequencyScore(str1: string, str2: string): number {
const freq1 = new Map<string, number>();
const freq2 = new Map<string, number>();
for (const char of str1) {
freq1.set(char, (freq1.get(char) || 0) + 1);
}
for (const char of str2) {
freq2.set(char, (freq2.get(char) || 0) + 1);
}
const allChars = new Set([...freq1.keys(), ...freq2.keys()]);
let similarity = 0;
let totalChars = 0;
for (const char of allChars) {
const count1 = freq1.get(char) || 0;
const count2 = freq2.get(char) || 0;
similarity += Math.min(count1, count2);
totalChars += Math.max(count1, count2);
}
return totalChars === 0 ? 0 : similarity / totalChars;
}
/**
* Force a match to available puzzle names - always returns a puzzle name
* This is used as a last resort to ensure OCR always selects from available puzzles
*/
private findBestPuzzleMatchForced(ocrText: string): string | null {
if (!this.availablePuzzleNames.length || !ocrText.trim()) {
return null;
}
const cleanedOcr = ocrText.trim().toLowerCase();
let bestMatch = this.availablePuzzleNames[0]; // Default to first puzzle
let bestScore = 0;
// Try all matching algorithms and pick the best overall score
for (const puzzleName of this.availablePuzzleNames) {
const scores = [
this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName),
this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName),
this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2),
this.calculateCharacterFrequencyScore(cleanedOcr, puzzleName.toLowerCase()),
// Add length similarity bonus
this.calculateLengthSimilarity(cleanedOcr, puzzleName.toLowerCase())
];
// Use weighted average with emphasis on character frequency and length
const weightedScore = (
scores[0] * 0.25 + // Levenshtein
scores[1] * 0.25 + // Jaro-Winkler
scores[2] * 0.2 + // N-gram
scores[3] * 0.2 + // Character frequency
scores[4] * 0.1 // Length similarity
);
if (weightedScore > bestScore) {
bestScore = weightedScore;
bestMatch = puzzleName;
}
}
console.log(`Forced match for "${ocrText}": "${bestMatch}" (score: ${bestScore.toFixed(3)})`);
return bestMatch;
}
/**
* Calculate similarity based on string length
*/
private calculateLengthSimilarity(str1: string, str2: string): number {
const len1 = str1.length;
const len2 = str2.length;
const maxLen = Math.max(len1, len2);
const minLen = Math.min(len1, len2);
return maxLen === 0 ? 1 : minLen / maxLen;
}
async terminate(): Promise<void> {
if (this.worker) {
+3
View File
@@ -0,0 +1,3 @@
import { createPinia } from 'pinia'
export const pinia = createPinia()
+78
View File
@@ -0,0 +1,78 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import type { SteamCollectionItem } from '@/types'
import { apiService } from '@/services/apiService'
export const usePuzzlesStore = defineStore('puzzles', () => {
// State
const puzzles = ref<SteamCollectionItem[]>([])
const isLoading = ref(false)
const error = ref<string>('')
// Getters
const puzzleNames = computed(() => puzzles.value.map(puzzle => puzzle.title))
const findPuzzleByName = computed(() => (name: string): SteamCollectionItem | null => {
if (!name) return null
// First try exact match (case insensitive)
const exactMatch = puzzles.value.find(
puzzle => puzzle.title.toLowerCase() === name.toLowerCase()
)
if (exactMatch) return exactMatch
// Then try partial match
const partialMatch = puzzles.value.find(
puzzle => puzzle.title.toLowerCase().includes(name.toLowerCase()) ||
name.toLowerCase().includes(puzzle.title.toLowerCase())
)
return partialMatch || null
})
// Actions
const loadPuzzles = async () => {
if (puzzles.value.length > 0) return // Already loaded
try {
isLoading.value = true
error.value = ''
const response = await apiService.getPuzzles()
if (response.error) {
error.value = response.error
console.error('Failed to load puzzles:', response.error)
return
}
if (response.data) {
puzzles.value = response.data
}
} catch (err) {
error.value = 'Failed to load puzzles'
console.error('Error loading puzzles:', err)
} finally {
isLoading.value = false
}
}
const refreshPuzzles = async () => {
puzzles.value = []
await loadPuzzles()
}
return {
// State
puzzles,
isLoading,
error,
// Getters
puzzleNames,
findPuzzleByName,
// Actions
loadPuzzles,
refreshPuzzles
}
})
+100
View File
@@ -0,0 +1,100 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import type { Submission, SubmissionFile } from '@/types'
import { submissionHelpers } from '@/services/apiService'
import { usePuzzlesStore } from './puzzles'
export const useSubmissionsStore = defineStore('submissions', () => {
// State
const submissions = ref<Submission[]>([])
const isLoading = ref(false)
const error = ref<string>('')
const isSubmissionModalOpen = ref(false)
// Actions
const loadSubmissions = async (limit = 20, offset = 0) => {
try {
isLoading.value = true
error.value = ''
const loadedSubmissions = await submissionHelpers.loadSubmissions(limit, offset)
if (offset === 0) {
submissions.value = loadedSubmissions
} else {
submissions.value.push(...loadedSubmissions)
}
} catch (err) {
error.value = 'Failed to load submissions'
console.error('Error loading submissions:', err)
} finally {
isLoading.value = false
}
}
const createSubmission = async (
files: SubmissionFile[],
notes?: string,
manualValidationRequested?: boolean
): Promise<Submission | undefined> => {
try {
isLoading.value = true
error.value = ''
const puzzlesStore = usePuzzlesStore()
const response = await submissionHelpers.createFromFiles(
files,
puzzlesStore.puzzles,
notes,
manualValidationRequested
)
if (response.error) {
error.value = response.error
throw new Error(response.error)
}
if (response.data) {
// Add to local submissions list
submissions.value.unshift(response.data)
return response.data
}
return undefined
} catch (err) {
error.value = err instanceof Error ? err.message : 'Failed to create submission'
throw err
} finally {
isLoading.value = false
}
}
const openSubmissionModal = () => {
isSubmissionModalOpen.value = true
}
const closeSubmissionModal = () => {
isSubmissionModalOpen.value = false
}
const refreshSubmissions = async () => {
submissions.value = []
await loadSubmissions()
}
return {
// State
submissions,
isLoading,
error,
isSubmissionModalOpen,
// Actions
loadSubmissions,
createSubmission,
openSubmissionModal,
closeSubmissionModal,
refreshSubmissions
}
})