working ocr in ts

This commit is contained in:
2025-10-29 02:25:03 +01:00
parent 2a0f585c4f
commit 2d21ff0d55
12 changed files with 1689 additions and 9 deletions
+256 -1
View File
@@ -1,6 +1,261 @@
<script setup lang="ts">
import { ref, onMounted, computed } from 'vue'
import PuzzleCard from './components/PuzzleCard.vue'
import SubmissionForm from './components/SubmissionForm.vue'
import type { SteamCollection, SteamCollectionItem, Submission, PuzzleResponse } from './types'
// Mock data - replace with actual API calls later
const collections = ref<SteamCollection[]>([])
const puzzles = ref<SteamCollectionItem[]>([])
const submissions = ref<Submission[]>([])
const isLoading = ref(true)
const showSubmissionModal = ref(false)
// Mock data for development
const mockCollections: SteamCollection[] = [
{
id: 1,
steam_id: '3479142989',
title: 'PolyLAN 41',
description: 'Puzzle for PolyLAN 41 fil rouge',
author_name: 'Flame Legrems',
total_items: 10,
unique_visitors: 31,
current_favorites: 1,
created_at: '2025-05-29T11:19:24Z',
updated_at: '2025-05-30T22:15:09Z'
}
]
const mockPuzzles: SteamCollectionItem[] = [
{
id: 1,
steam_item_id: '3479143948',
title: 'P41-FLOC',
author_name: 'Flame Legrems',
description: 'A challenging puzzle involving complex molecular arrangements',
tags: ['puzzle', 'chemistry', 'advanced'],
order_index: 0,
collection: 1,
created_at: '2025-05-29T11:19:24Z',
updated_at: '2025-05-30T22:15:09Z'
},
{
id: 2,
steam_item_id: '3479143084',
title: 'P41-40',
author_name: 'Flame Legrems',
description: 'Test your optimization skills with this intricate design challenge',
tags: ['optimization', 'design'],
order_index: 1,
collection: 1,
created_at: '2025-05-29T11:19:24Z',
updated_at: '2025-05-30T22:15:09Z'
},
{
id: 3,
steam_item_id: '3479143304',
title: 'P41-39',
author_name: 'Flame Legrems',
description: 'A puzzle focusing on efficient resource management',
tags: ['efficiency', 'resources'],
order_index: 2,
collection: 1,
created_at: '2025-05-29T11:19:24Z',
updated_at: '2025-05-30T22:15:09Z'
},
{
id: 4,
steam_item_id: '3479143433',
title: 'P41-38',
author_name: 'Flame Legrems',
description: 'Master the art of precise timing in this temporal challenge',
tags: ['timing', 'precision'],
order_index: 3,
collection: 1,
created_at: '2025-05-29T11:19:24Z',
updated_at: '2025-05-30T22:15:09Z'
},
{
id: 5,
steam_item_id: '3479143537',
title: 'P41-37',
author_name: 'Flame Legrems',
description: 'Explore innovative solutions in this creative puzzle',
tags: ['creative', 'innovation'],
order_index: 4,
collection: 1,
created_at: '2025-05-29T11:19:24Z',
updated_at: '2025-05-30T22:15:09Z'
}
]
// Computed property to get responses grouped by puzzle
const responsesByPuzzle = computed(() => {
const grouped: Record<number, PuzzleResponse[]> = {}
submissions.value.forEach(submission => {
submission.responses.forEach(response => {
if (!grouped[response.puzzle_id]) {
grouped[response.puzzle_id] = []
}
grouped[response.puzzle_id].push(response)
})
})
return grouped
})
onMounted(async () => {
// Simulate API loading
await new Promise(resolve => setTimeout(resolve, 500))
collections.value = mockCollections
puzzles.value = mockPuzzles
isLoading.value = false
})
const handleSubmission = (submission: Submission) => {
console.log('Submission received:', submission)
// Add submission to the list
submissions.value.push({
...submission,
id: Date.now(), // Simple ID generation for demo
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
})
// Show success message
const puzzleNames = submission.responses.map(r => r.puzzle_name).join(', ')
alert(`Solutions submitted for puzzles: ${puzzleNames}`)
// Close modal
showSubmissionModal.value = false
}
const openSubmissionModal = () => {
showSubmissionModal.value = true
}
const closeSubmissionModal = () => {
showSubmissionModal.value = false
}
// Function to match puzzle name from OCR to actual puzzle
const findPuzzleByName = (ocrPuzzleName: string): SteamCollectionItem | null => {
if (!ocrPuzzleName) return null
// Try exact match first
let match = puzzles.value.find(p =>
p.title.toLowerCase() === ocrPuzzleName.toLowerCase()
)
if (!match) {
// Try partial match
match = puzzles.value.find(p =>
p.title.toLowerCase().includes(ocrPuzzleName.toLowerCase()) ||
ocrPuzzleName.toLowerCase().includes(p.title.toLowerCase())
)
}
return match || null
}
</script>
<template>
HELLO
<div class="min-h-screen bg-base-200">
<!-- Header -->
<div class="navbar bg-base-100 shadow-lg">
<div class="container mx-auto">
<div class="flex-1">
<h1 class="text-xl font-bold">Opus Magnum Puzzle Submitter</h1>
</div>
<div class="flex-none">
<button
@click="openSubmissionModal"
class="btn btn-primary"
>
<i class="mdi mdi-plus mr-2"></i>
Submit Solution
</button>
</div>
</div>
</div>
<!-- Main Content -->
<div class="container mx-auto px-4 py-8">
<!-- Loading State -->
<div v-if="isLoading" class="flex justify-center items-center min-h-[400px]">
<div class="text-center">
<span class="loading loading-spinner loading-lg"></span>
<p class="mt-4 text-base-content/70">Loading puzzles...</p>
</div>
</div>
<!-- Main Content -->
<div v-else class="space-y-8">
<!-- Collection Info -->
<div v-if="collections.length > 0" class="mb-8">
<div class="card bg-base-100 shadow-lg">
<div class="card-body">
<h2 class="card-title text-2xl">{{ collections[0].title }}</h2>
<p class="text-base-content/70">{{ collections[0].description }}</p>
<div class="flex flex-wrap gap-4 mt-4">
<div class="stat">
<div class="stat-title">Total Puzzles</div>
<div class="stat-value text-primary">{{ collections[0].total_items }}</div>
</div>
<div class="stat">
<div class="stat-title">Author</div>
<div class="stat-value text-sm">{{ collections[0].author_name }}</div>
</div>
<div class="stat">
<div class="stat-title">Visitors</div>
<div class="stat-value text-sm">{{ collections[0].unique_visitors }}</div>
</div>
</div>
</div>
</div>
</div>
<!-- Puzzles Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<PuzzleCard
v-for="puzzle in puzzles"
:key="puzzle.id"
:puzzle="puzzle"
:responses="responsesByPuzzle[puzzle.id] || []"
/>
</div>
<!-- Empty State -->
<div v-if="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>
</div>
</div>
</div>
<!-- Submission Modal -->
<div v-if="showSubmissionModal" 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>
<button
@click="closeSubmissionModal"
class="btn btn-sm btn-circle btn-ghost"
>
<i class="mdi mdi-close"></i>
</button>
</div>
<SubmissionForm
:puzzles="puzzles"
:find-puzzle-by-name="findPuzzleByName"
@submit="handleSubmission"
/>
</div>
<div class="modal-backdrop" @click="closeSubmissionModal"></div>
</div>
</div>
</template>
@@ -0,0 +1,300 @@
<template>
<div class="form-control w-full">
<label class="label">
<span class="label-text font-medium">Upload Solution Files</span>
<span class="label-text-alt text-xs">Images or GIFs only</span>
</label>
<div
class="border-2 border-dashed border-base-300 rounded-lg p-6 text-center hover:border-primary transition-colors duration-300"
:class="{ 'border-primary bg-primary/5': isDragOver }"
@drop="handleDrop"
@dragover.prevent="isDragOver = true"
@dragleave="isDragOver = false"
@dragenter.prevent
>
<input
ref="fileInput"
type="file"
multiple
accept="image/*,.gif"
class="hidden"
@change="handleFileSelect"
>
<div v-if="files.length === 0" class="space-y-4">
<div class="mx-auto w-12 h-12 text-base-content/40 flex items-center justify-center">
<i class="mdi mdi-cloud-upload text-5xl"></i>
</div>
<div>
<p class="text-base-content/70 mb-2">Drop your files here or</p>
<button
type="button"
@click="fileInput?.click()"
class="btn btn-primary btn-sm"
>
Choose Files
</button>
</div>
<p class="text-xs text-base-content/50">
Supported formats: JPG, PNG, GIF (max 10MB each)
</p>
</div>
<div v-else class="space-y-4">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="(file, index) in files"
:key="index"
class="relative group"
>
<div class="aspect-square rounded-lg overflow-hidden bg-base-200">
<img
:src="file.preview"
:alt="file.file.name"
class="w-full h-full object-cover"
>
</div>
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-200 rounded-lg flex items-center justify-center">
<button
@click="removeFile(index)"
class="btn btn-error btn-sm btn-circle"
>
<i class="mdi mdi-close"></i>
</button>
</div>
<div class="mt-2">
<p class="text-xs font-medium truncate">{{ file.file.name }}</p>
<p class="text-xs text-base-content/60">
{{ formatFileSize(file.file.size) }} {{ file.type.toUpperCase() }}
</p>
<!-- OCR Status and Results -->
<div v-if="file.ocrProcessing" class="mt-1 flex items-center gap-1">
<span class="loading loading-spinner loading-xs"></span>
<span class="text-xs text-info">Extracting puzzle data...</span>
</div>
<div v-else-if="file.ocrError" class="mt-1">
<p class="text-xs text-error">{{ file.ocrError }}</p>
</div>
<div v-else-if="file.ocrData" class="mt-1 space-y-1">
<div class="text-xs flex items-center justify-between">
<span class="font-medium text-success"> OCR Complete</span>
<button
@click="retryOCR(file)"
class="btn btn-xs btn-ghost"
title="Retry OCR"
>
<i class="mdi mdi-refresh"></i>
</button>
</div>
<div class="text-xs space-y-1 bg-base-200 p-2 rounded">
<div v-if="file.ocrData.puzzle">
<strong>Puzzle:</strong> {{ file.ocrData.puzzle }}
</div>
<div v-if="file.ocrData.cost">
<strong>Cost:</strong> {{ file.ocrData.cost }}
</div>
<div v-if="file.ocrData.cycles">
<strong>Cycles:</strong> {{ file.ocrData.cycles }}
</div>
<div v-if="file.ocrData.area">
<strong>Area:</strong> {{ file.ocrData.area }}
</div>
</div>
</div>
<!-- Manual OCR trigger for non-auto detected files -->
<div v-else-if="!file.ocrProcessing && !file.ocrError && !file.ocrData" class="mt-1">
<button
@click="processOCR(file)"
class="btn btn-xs btn-outline"
>
<i class="mdi mdi-text-recognition"></i>
Extract Puzzle Data
</button>
</div>
</div>
</div>
</div>
<div class="flex justify-center">
<button
type="button"
@click="fileInput?.click()"
class="btn btn-outline btn-sm"
>
Add More Files
</button>
</div>
</div>
</div>
<div v-if="error" class="label">
<span class="label-text-alt text-error">{{ error }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
import { ocrService, type OpusMagnumData } from '../services/ocrService'
import type { SubmissionFile } from '@/types'
interface Props {
modelValue: SubmissionFile[]
}
interface Emits {
'update:modelValue': [files: SubmissionFile[]]
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const fileInput = ref<HTMLInputElement>()
const isDragOver = ref(false)
const error = ref('')
const files = ref<SubmissionFile[]>([])
// Watch for external changes to modelValue
watch(() => props.modelValue, (newFiles) => {
files.value = newFiles
}, { immediate: true })
// Watch for internal changes and emit
watch(files, (newFiles) => {
emit('update:modelValue', newFiles)
}, { deep: true })
const handleFileSelect = (event: Event) => {
const target = event.target as HTMLInputElement
if (target.files) {
processFiles(Array.from(target.files))
}
}
const handleDrop = (event: DragEvent) => {
event.preventDefault()
isDragOver.value = false
if (event.dataTransfer?.files) {
processFiles(Array.from(event.dataTransfer.files))
}
}
const processFiles = async (newFiles: File[]) => {
error.value = ''
for (const file of newFiles) {
if (!isValidFile(file)) {
continue
}
try {
const preview = await createPreview(file)
const fileType = file.type.startsWith('image/gif') ? 'gif' : 'image'
const submissionFile: SubmissionFile = {
file,
preview,
type: fileType,
ocrProcessing: false,
ocrError: undefined,
ocrData: undefined
}
files.value.push(submissionFile)
// Start OCR processing for Opus Magnum images (with delay to ensure reactivity)
if (isOpusMagnumImage(file)) {
nextTick(() => {
processOCR(submissionFile)
})
}
} catch (err) {
error.value = `Failed to process ${file.name}`
}
}
}
const isValidFile = (file: File): boolean => {
// Check file type
if (!file.type.startsWith('image/')) {
error.value = `${file.name} is not a valid image file`
return false
}
// Check file size (10MB limit)
if (file.size > 10 * 1024 * 1024) {
error.value = `${file.name} is too large (max 10MB)`
return false
}
return true
}
const createPreview = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = (e) => resolve(e.target?.result as string)
reader.onerror = reject
reader.readAsDataURL(file)
})
}
const removeFile = (index: number) => {
files.value.splice(index, 1)
}
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const isOpusMagnumImage = (file: File): boolean => {
// Basic heuristic - could be enhanced with actual image analysis
return file.type.startsWith('image/') && file.size > 50000 // > 50KB likely screenshot
}
const processOCR = async (submissionFile: SubmissionFile) => {
// Find the file in the reactive array to ensure proper reactivity
const fileIndex = files.value.findIndex(f => f.file === submissionFile.file)
if (fileIndex === -1) return
// Update the reactive array directly
files.value[fileIndex].ocrProcessing = true
files.value[fileIndex].ocrError = undefined
files.value[fileIndex].ocrData = undefined
try {
console.log('Starting OCR processing for:', submissionFile.file.name)
await ocrService.initialize()
const ocrData = await ocrService.extractOpusMagnumData(submissionFile.file)
console.log('OCR completed:', ocrData)
// Force reactivity update
await nextTick()
files.value[fileIndex].ocrData = ocrData
await nextTick()
} catch (error) {
console.error('OCR processing failed:', error)
files.value[fileIndex].ocrError = 'Failed to extract puzzle data'
} finally {
files.value[fileIndex].ocrProcessing = false
}
}
const retryOCR = (submissionFile: SubmissionFile) => {
processOCR(submissionFile)
}
</script>
@@ -0,0 +1,137 @@
<template>
<div class="card bg-base-100 shadow-xl hover:shadow-2xl transition-shadow duration-300">
<div class="card-body">
<div class="flex items-start justify-between">
<div class="flex-1">
<h3 class="card-title text-lg font-bold">{{ puzzle.title }}</h3>
<p class="text-sm text-base-content/70 mb-2">by {{ puzzle.author_name }}</p>
<div class="flex items-center gap-2 mb-3">
<div class="badge badge-primary badge-sm">{{ puzzle.steam_item_id }}</div>
<div class="badge badge-ghost badge-sm">Order: {{ puzzle.order_index + 1 }}</div>
</div>
<p v-if="puzzle.description" class="text-sm text-base-content/80 mb-4 line-clamp-2">
{{ puzzle.description }}
</p>
<div v-if="puzzle.tags && puzzle.tags.length > 0" class="flex flex-wrap gap-1 mb-4">
<span
v-for="tag in puzzle.tags.slice(0, 3)"
:key="tag"
class="badge badge-outline badge-xs"
>
{{ tag }}
</span>
<span v-if="puzzle.tags.length > 3" class="badge badge-outline badge-xs">
+{{ puzzle.tags.length - 3 }} more
</span>
</div>
</div>
<div class="flex flex-col items-end gap-2">
<div class="tooltip" data-tip="View on Steam Workshop">
<a
:href="`https://steamcommunity.com/workshop/filedetails/?id=${puzzle.steam_item_id}`"
target="_blank"
class="btn btn-ghost btn-sm btn-square"
>
<i class="mdi mdi-steam text-lg"></i>
</a>
</div>
</div>
</div>
<!-- Responses Table -->
<div v-if="responses && responses.length > 0" class="mt-6">
<div class="divider">
<span class="text-sm font-medium">Solutions ({{ responses.length }})</span>
</div>
<div class="overflow-x-auto">
<table class="table table-xs">
<thead>
<tr>
<th>Cost</th>
<th>Cycles</th>
<th>Area</th>
<th>Files</th>
</tr>
</thead>
<tbody>
<tr v-for="response in responses" :key="response.id" class="hover">
<td>
<span v-if="response.cost" class="badge badge-success badge-xs">
{{ response.cost }}
</span>
<span v-else class="text-base-content/50">-</span>
</td>
<td>
<span v-if="response.cycles" class="badge badge-info badge-xs">
{{ response.cycles }}
</span>
<span v-else class="text-base-content/50">-</span>
</td>
<td>
<span v-if="response.area" class="badge badge-warning badge-xs">
{{ response.area }}
</span>
<span v-else class="text-base-content/50">-</span>
</td>
<td>
<div class="flex items-center gap-1">
<span class="badge badge-ghost badge-xs">{{ response.files.length }}</span>
<div class="tooltip" :data-tip="response.files.map(f => f.file.name).join(', ')">
<i class="mdi mdi-information-outline text-xs"></i>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- No responses state -->
<div v-else class="mt-6 text-center py-4 border-2 border-dashed border-base-300 rounded-lg">
<i class="mdi mdi-upload text-2xl text-base-content/40"></i>
<p class="text-sm text-base-content/60 mt-2">No solutions yet</p>
<p class="text-xs text-base-content/40">Upload solutions using the submit button</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import type { SteamCollectionItem, PuzzleResponse } from '@/types'
interface Props {
puzzle: SteamCollectionItem
responses?: PuzzleResponse[]
}
defineProps<Props>()
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes'
const k = 1024
const sizes = ['Bytes', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
}
const formatDate = (dateString: string): string => {
return new Date(dateString).toLocaleDateString()
}
</script>
<style scoped>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
</style>
@@ -0,0 +1,147 @@
<template>
<div class="card bg-base-100 shadow-xl">
<div class="card-body">
<h2 class="card-title text-xl mb-6">
<i class="mdi mdi-check-circle text-2xl text-primary"></i>
Submit Solution
</h2>
<form @submit.prevent="handleSubmit" class="space-y-6">
<!-- Detected Puzzles Summary -->
<div v-if="Object.keys(responsesByPuzzle).length > 0" class="alert alert-info">
<i class="mdi mdi-information-outline text-xl"></i>
<div class="flex-1">
<h4 class="font-bold">Detected Puzzles ({{ Object.keys(responsesByPuzzle).length }})</h4>
<div class="text-sm space-y-1 mt-1">
<div v-for="(data, puzzleName) in responsesByPuzzle" :key="puzzleName" class="flex justify-between">
<span>{{ puzzleName }}</span>
<span class="badge badge-ghost badge-sm">{{ data.files.length }} file(s)</span>
</div>
</div>
</div>
</div>
<!-- File Upload -->
<FileUpload v-model="submissionFiles" />
<!-- Notes -->
<div class="form-control">
<label class="label">
<span class="label-text font-medium">Notes (Optional)</span>
<span class="label-text-alt">{{ notesLength }}/500</span>
</label>
<textarea
v-model="notes"
class="textarea textarea-bordered h-24 resize-none"
placeholder="Add any notes about your solution, approach, or interesting findings..."
maxlength="500"
></textarea>
</div>
<!-- Submit Button -->
<div class="card-actions justify-end">
<button
type="submit"
class="btn btn-primary"
:disabled="isSubmitting"
>
<span v-if="isSubmitting" class="loading loading-spinner loading-sm"></span>
{{ isSubmitting ? 'Submitting...' : 'Submit Solution' }}
</button>
</div>
</form>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import FileUpload from './FileUpload.vue'
import type { SteamCollectionItem, SubmissionFile, Submission, PuzzleResponse } from '@/types'
interface Props {
puzzles: SteamCollectionItem[]
findPuzzleByName: (name: string) => SteamCollectionItem | null
}
interface Emits {
submit: [submission: Submission]
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const submissionFiles = ref<SubmissionFile[]>([])
const notes = ref('')
const isSubmitting = ref(false)
const notesLength = computed(() => notes.value.length)
const canSubmit = computed(() => {
return submissionFiles.value.length > 0 &&
!isSubmitting.value
})
// Group files by detected puzzle
const responsesByPuzzle = computed(() => {
const grouped: Record<string, { puzzle: SteamCollectionItem | null, files: SubmissionFile[] }> = {}
submissionFiles.value.forEach(file => {
if (file.ocrData?.puzzle) {
const puzzleName = file.ocrData.puzzle
if (!grouped[puzzleName]) {
grouped[puzzleName] = {
puzzle: props.findPuzzleByName(puzzleName),
files: []
}
}
grouped[puzzleName].files.push(file)
}
})
return grouped
})
const handleSubmit = async () => {
if (!canSubmit.value) return
isSubmitting.value = true
try {
const responses: PuzzleResponse[] = []
// Create responses for each detected puzzle
Object.entries(responsesByPuzzle.value).forEach(([puzzleName, data]) => {
if (data.puzzle) {
// Get OCR data from the first file with complete data
const fileWithOCR = data.files.find(f => f.ocrData?.cost || f.ocrData?.cycles || f.ocrData?.area)
responses.push({
puzzle_id: data.puzzle.id,
puzzle_name: puzzleName,
cost: fileWithOCR?.ocrData?.cost,
cycles: fileWithOCR?.ocrData?.cycles,
area: fileWithOCR?.ocrData?.area,
files: data.files
})
}
})
const submission: Submission = {
responses,
notes: notes.value.trim() || undefined
}
emit('submit', submission)
// Reset form
submissionFiles.value = []
notes.value = ''
} catch (error) {
console.error('Submission error:', error)
} finally {
isSubmitting.value = false
}
}
</script>
+1
View File
@@ -1,4 +1,5 @@
import { createApp } from 'vue'
import App from '@/App.vue'
import './style.css'
createApp(App).mount('#app')
+259
View File
@@ -0,0 +1,259 @@
import { createWorker } from 'tesseract.js';
export interface OpusMagnumData {
puzzle: string;
cost: string;
cycles: string;
area: string;
}
export interface OCRRegion {
x: number;
y: number;
width: number;
height: number;
}
export class OpusMagnumOCRService {
private worker: Tesseract.Worker | null = null;
// Regions based on main.py coordinates (adjusted for web usage)
private readonly regions: Record<string, OCRRegion> = {
puzzle: { x: 15, y: 600, width: 330, height: 28 },
cost: { x: 412, y: 603, width: 65, height: 22 },
cycles: { x: 577, y: 603, width: 65, height: 22 },
area: { x: 739, y: 603, width: 65, height: 22 }
};
async initialize(): Promise<void> {
if (this.worker) return;
this.worker = await createWorker('eng');
await this.worker.setParameters({
tessedit_ocr_engine_mode: '3',
tessedit_pageseg_mode: '7'
});
}
async extractOpusMagnumData(imageFile: File): Promise<OpusMagnumData> {
if (!this.worker) {
await this.initialize();
}
// Convert file to image element for canvas processing
const imageUrl = URL.createObjectURL(imageFile);
const img = new Image();
return new Promise((resolve, reject) => {
img.onload = async () => {
try {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// Extract text from each region
const results: Partial<OpusMagnumData> = {};
for (const [key, region] of Object.entries(this.regions)) {
const regionCanvas = document.createElement('canvas');
const regionCtx = regionCanvas.getContext('2d')!;
regionCanvas.width = region.width;
regionCanvas.height = region.height;
// Extract region from main image
regionCtx.drawImage(
canvas,
region.x, region.y, region.width, region.height,
0, 0, region.width, region.height
);
// Convert to grayscale and invert (similar to main.py processing)
const imageData = regionCtx.getImageData(0, 0, region.width, region.height);
this.preprocessImage(imageData);
regionCtx.putImageData(imageData, 0, 0);
// Configure OCR based on content type
let config: any = {};
if (key === 'cost') {
// Cost field has digits + 'G' for gold (content type: 'digits_with_6')
await this.worker!.setParameters({
tessedit_char_whitelist: '0123456789G'
});
} else if (key === 'cycles' || key === 'area') {
// Pure digits (content type: 'digits')
await this.worker!.setParameters({
tessedit_char_whitelist: '0123456789'
});
} else if (key === 'puzzle') {
// Puzzle name - allow alphanumeric, spaces, and dashes
await this.worker!.setParameters({
tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789 -'
});
} else {
// Default - allow all characters
await this.worker!.setParameters({
tessedit_char_whitelist: ''
});
}
// Perform OCR on the region
const { data: { text } } = await this.worker!.recognize(regionCanvas);
let cleanText = text.trim();
// Post-process based on field type
if (key === 'cost') {
// Handle common OCR misreadings where G is read as 6
// If the text ends with 6 and looks like it should be G, remove it
if (cleanText.endsWith('6') && cleanText.length > 1) {
// Check if removing the last character gives a reasonable cost value
const withoutLast = cleanText.slice(0, -1);
if (/^\d+$/.test(withoutLast)) {
cleanText = withoutLast;
}
}
// Remove any trailing G characters
cleanText = cleanText.replace(/G+$/g, '');
// Ensure only digits remain
cleanText = cleanText.replace(/[^0-9]/g, '');
} else if (key === 'cycles' || key === 'area') {
// Ensure only digits remain
cleanText = cleanText.replace(/[^0-9]/g, '');
} else if (key === 'puzzle') {
// Post-process puzzle names
cleanText = this.processPuzzleName(cleanText);
}
results[key as keyof OpusMagnumData] = cleanText;
}
URL.revokeObjectURL(imageUrl);
resolve({
puzzle: results.puzzle || '',
cost: results.cost || '',
cycles: results.cycles || '',
area: results.area || ''
});
} catch (error) {
URL.revokeObjectURL(imageUrl);
reject(error);
}
};
img.onerror = () => {
URL.revokeObjectURL(imageUrl);
reject(new Error('Failed to load image'));
};
img.src = imageUrl;
});
}
private preprocessImage(imageData: ImageData): void {
// Convert to grayscale and invert (similar to cv2.bitwise_not in main.py)
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
// Convert to grayscale
const gray = Math.round(0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]);
// Invert the grayscale value
const inverted = 255 - gray;
data[i] = inverted; // Red
data[i + 1] = inverted; // Green
data[i + 2] = inverted; // Blue
// Alpha channel (data[i + 3]) remains unchanged
}
}
private processPuzzleName(rawText: string): string {
let processed = rawText.trim();
// If no dash is present but we have digits, try to insert one
if (!processed.includes('-') && /\d/.test(processed)) {
// Common pattern: "P4141" should become "P41-41"
// Look for patterns like P[digits][digits] where the last part might be a separate number
const match = processed.match(/^([A-Z]+\d+)(\d{1,3})$/);
if (match) {
processed = `${match[1]}-${match[2]}`;
}
// Handle cases like "4141" -> "41-41" (missing P prefix)
else if (/^\d{3,4}$/.test(processed)) {
const mid = Math.floor(processed.length / 2);
processed = `P${processed.slice(0, mid)}-${processed.slice(mid)}`;
}
}
// Clean up spacing around dashes
processed = processed.replace(/\s*-\s*/g, '-');
// Ensure proper spacing
processed = processed.replace(/([A-Z])(\d)/g, '$1$2');
processed = processed.replace(/(\d)([A-Z])/g, '$1 $2');
// Add P prefix if missing and starts with digits
if (/^\d/.test(processed) && !processed.startsWith('P')) {
processed = 'P' + processed;
}
return processed;
}
async terminate(): Promise<void> {
if (this.worker) {
await this.worker.terminate();
this.worker = null;
}
}
// Utility method to validate if an image looks like an Opus Magnum screenshot
static isValidOpusMagnumImage(file: File): boolean {
// Basic validation - could be enhanced with actual image analysis
const validTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif'];
return validTypes.includes(file.type);
}
// Debug method to visualize OCR regions (similar to main.py debug rectangles)
static drawDebugRegions(imageFile: File): Promise<string> {
return new Promise((resolve, reject) => {
const imageUrl = URL.createObjectURL(imageFile);
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = img.width;
canvas.height = img.height;
ctx.drawImage(img, 0, 0);
// Draw debug rectangles
ctx.strokeStyle = '#00ff00';
ctx.lineWidth = 2;
const service = new OpusMagnumOCRService();
Object.values(service.regions).forEach(region => {
ctx.strokeRect(region.x, region.y, region.width, region.height);
});
URL.revokeObjectURL(imageUrl);
resolve(canvas.toDataURL());
};
img.onerror = () => {
URL.revokeObjectURL(imageUrl);
reject(new Error('Failed to load image for debug'));
};
img.src = imageUrl;
});
}
}
// Singleton instance for the application
export const ocrService = new OpusMagnumOCRService();
+39
View File
@@ -0,0 +1,39 @@
@import '@mdi/font/css/materialdesignicons.css';
@import "tailwindcss";
@plugin "daisyui";
@plugin "daisyui/theme" {
name: "dim";
default: false;
prefersdark: false;
color-scheme: "dark";
--color-base-100: oklch(30.857% 0.023 264.149);
--color-base-200: oklch(28.036% 0.019 264.182);
--color-base-300: oklch(26.346% 0.018 262.177);
--color-base-content: oklch(82.901% 0.031 222.959);
--color-primary: oklch(86.133% 0.141 139.549);
--color-primary-content: oklch(17.226% 0.028 139.549);
--color-secondary: oklch(73.375% 0.165 35.353);
--color-secondary-content: oklch(14.675% 0.033 35.353);
--color-accent: oklch(74.229% 0.133 311.379);
--color-accent-content: oklch(14.845% 0.026 311.379);
--color-neutral: oklch(24.731% 0.02 264.094);
--color-neutral-content: oklch(82.901% 0.031 222.959);
--color-info: oklch(86.078% 0.142 206.182);
--color-info-content: oklch(17.215% 0.028 206.182);
--color-success: oklch(86.171% 0.142 166.534);
--color-success-content: oklch(17.234% 0.028 166.534);
--color-warning: oklch(86.163% 0.142 94.818);
--color-warning-content: oklch(17.232% 0.028 94.818);
--color-error: oklch(82.418% 0.099 33.756);
--color-error-content: oklch(16.483% 0.019 33.756);
--radius-selector: 2rem;
--radius-field: 0.25rem;
--radius-box: 0.25rem;
--size-selector: 0.25rem;
--size-field: 0.25rem;
--border: 1px;
--depth: 0;
--noise: 0;
}
+59
View File
@@ -0,0 +1,59 @@
export interface SteamCollection {
id: number
steam_id: string
title: string
description: string
author_name: string
total_items: number
unique_visitors: number
current_favorites: number
created_at: string
updated_at: string
}
export interface SteamCollectionItem {
id: number
steam_item_id: string
title: string
author_name: string
description: string
tags: string[]
order_index: number
collection: number
created_at: string
updated_at: string
}
export interface OpusMagnumData {
puzzle: string
cost: string
cycles: string
area: string
}
export interface SubmissionFile {
file: File
preview: string
type: 'image' | 'gif'
ocrData?: OpusMagnumData
ocrProcessing?: boolean
ocrError?: string
}
export interface PuzzleResponse {
id?: number
puzzle_id: number
puzzle_name: string
cost?: string
cycles?: string
area?: string
files: SubmissionFile[]
}
export interface Submission {
id?: number
responses: PuzzleResponse[]
notes?: string
created_at?: string
updated_at?: string
}