chore: opus-submitter -> polylan-submitter
This commit is contained in:
@@ -0,0 +1,300 @@
|
||||
import type {
|
||||
SteamCollectionItem,
|
||||
Submission,
|
||||
PuzzleResponse,
|
||||
SubmissionFile,
|
||||
UserInfo
|
||||
} from '../types'
|
||||
|
||||
// API Configuration
|
||||
const API_BASE_URL = '/api'
|
||||
|
||||
// API Response Types
|
||||
interface ApiResponse<T> {
|
||||
data?: T
|
||||
error?: string
|
||||
status: number
|
||||
}
|
||||
|
||||
interface PaginatedResponse<T> {
|
||||
items: T[]
|
||||
count: number
|
||||
}
|
||||
|
||||
interface SubmissionStats {
|
||||
total_submissions: number
|
||||
total_responses: number
|
||||
needs_validation: number
|
||||
validated_submissions: number
|
||||
validation_rate: number
|
||||
}
|
||||
|
||||
// API Service Class
|
||||
export class ApiService {
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {}
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers,
|
||||
},
|
||||
...options,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: data.detail || `HTTP ${response.status}`,
|
||||
status: response.status
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
status: response.status
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Network error',
|
||||
status: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async uploadRequest<T>(
|
||||
endpoint: string,
|
||||
formData: FormData
|
||||
): Promise<ApiResponse<T>> {
|
||||
try {
|
||||
const response = await fetch(`${API_BASE_URL}${endpoint}`, {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
})
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
if (!response.ok) {
|
||||
return {
|
||||
error: data.detail || `HTTP ${response.status}`,
|
||||
status: response.status
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
data,
|
||||
status: response.status
|
||||
}
|
||||
} catch (error) {
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Network error',
|
||||
status: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Puzzle endpoints
|
||||
async getPuzzles(): Promise<ApiResponse<SteamCollectionItem[]>> {
|
||||
return this.request<SteamCollectionItem[]>('/submissions/puzzles')
|
||||
}
|
||||
|
||||
// Submission endpoints
|
||||
async getSubmissions(limit = 20, offset = 0): Promise<ApiResponse<PaginatedResponse<Submission>>> {
|
||||
return this.request<PaginatedResponse<Submission>>(
|
||||
`/submissions/submissions?limit=${limit}&offset=${offset}`
|
||||
)
|
||||
}
|
||||
|
||||
async getSubmission(id: string): Promise<ApiResponse<Submission>> {
|
||||
return this.request<Submission>(`/submissions/submissions/${id}`)
|
||||
}
|
||||
|
||||
async createSubmission(
|
||||
submissionData: {
|
||||
notes?: string
|
||||
manual_validation_requested?: boolean
|
||||
responses: Array<{
|
||||
puzzle_id: number
|
||||
puzzle_name: string
|
||||
cost?: number
|
||||
cycles?: number
|
||||
area?: number
|
||||
needs_manual_validation?: boolean
|
||||
ocr_confidence_cost?: number
|
||||
ocr_confidence_cycles?: number
|
||||
ocr_confidence_area?: number
|
||||
}>
|
||||
},
|
||||
files: File[]
|
||||
): Promise<ApiResponse<Submission>> {
|
||||
const formData = new FormData()
|
||||
|
||||
// Add JSON data
|
||||
formData.append('data', JSON.stringify(submissionData))
|
||||
|
||||
// Add files
|
||||
files.forEach((file) => {
|
||||
formData.append('files', file)
|
||||
})
|
||||
|
||||
return this.uploadRequest<Submission>('/submissions/submissions', formData)
|
||||
}
|
||||
|
||||
// Admin endpoints (require staff permissions)
|
||||
async validateResponse(
|
||||
responseId: number,
|
||||
validationData: {
|
||||
validated_cost?: number
|
||||
validated_cycles?: number
|
||||
validated_area?: number
|
||||
}
|
||||
): Promise<ApiResponse<PuzzleResponse>> {
|
||||
return this.request<PuzzleResponse>(`/submissions/responses/${responseId}/validate`, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(validationData),
|
||||
})
|
||||
}
|
||||
|
||||
async autoValidateResponses(responseId: number): Promise<ApiResponse<PuzzleResponse>> {
|
||||
return this.request<PuzzleResponse>(`/submissions/responses/${responseId}/validate/auto`, {
|
||||
method: 'PUT',
|
||||
})
|
||||
}
|
||||
|
||||
async getResponsesNeedingValidation(): Promise<ApiResponse<PuzzleResponse[]>> {
|
||||
return this.request<PuzzleResponse[]>('/submissions/responses/needs-validation')
|
||||
}
|
||||
|
||||
async validateSubmission(submissionId: string): Promise<ApiResponse<Submission>> {
|
||||
return this.request<Submission>(`/submissions/submissions/${submissionId}/validate`, {
|
||||
method: 'POST',
|
||||
})
|
||||
}
|
||||
|
||||
async deleteSubmission(submissionId: string): Promise<ApiResponse<{ detail: string }>> {
|
||||
return this.request<{ detail: string }>(`/submissions/submissions/${submissionId}`, {
|
||||
method: 'DELETE',
|
||||
})
|
||||
}
|
||||
|
||||
// Statistics endpoint
|
||||
async getStats(): Promise<ApiResponse<SubmissionStats>> {
|
||||
return this.request<SubmissionStats>('/submissions/stats')
|
||||
}
|
||||
|
||||
// Health check
|
||||
async healthCheck(): Promise<ApiResponse<{ status: string; service: string }>> {
|
||||
return this.request<{ status: string; service: string }>('/health')
|
||||
}
|
||||
|
||||
// User info
|
||||
async getUserInfo(): Promise<ApiResponse<UserInfo>> {
|
||||
return this.request<UserInfo>('/user')
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const apiService = new ApiService()
|
||||
|
||||
// Helper functions for common operations
|
||||
export const puzzleHelpers = {
|
||||
async loadPuzzles(): Promise<SteamCollectionItem[]> {
|
||||
const response = await apiService.getPuzzles()
|
||||
if (response.error) {
|
||||
console.error('Failed to load puzzles:', response.error)
|
||||
return []
|
||||
}
|
||||
return response.data || []
|
||||
},
|
||||
|
||||
findPuzzleByName(puzzles: SteamCollectionItem[], name: string): SteamCollectionItem | null {
|
||||
if (!name) return null
|
||||
|
||||
// Try exact match first
|
||||
let match = puzzles.find(p =>
|
||||
p.title.toLowerCase() === name.toLowerCase()
|
||||
)
|
||||
|
||||
if (!match) {
|
||||
// Try partial match
|
||||
match = puzzles.find(p =>
|
||||
p.title.toLowerCase().includes(name.toLowerCase()) ||
|
||||
name.toLowerCase().includes(p.title.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
return match || null
|
||||
}
|
||||
}
|
||||
|
||||
export const submissionHelpers = {
|
||||
async createFromFiles(
|
||||
files: SubmissionFile[],
|
||||
puzzles: SteamCollectionItem[],
|
||||
notes?: string,
|
||||
manualValidationRequested?: boolean
|
||||
): Promise<ApiResponse<Submission>> {
|
||||
|
||||
const responses = files.map(item => {
|
||||
|
||||
const puzzle = puzzleHelpers.findPuzzleByName(puzzles, item.ocrData?.puzzle || '')
|
||||
if (!puzzle) { return }
|
||||
return {
|
||||
puzzle_id: puzzle.id,
|
||||
puzzle_name: item.ocrData?.puzzle || '',
|
||||
cost: item.ocrData?.cost,
|
||||
cycles: item.ocrData?.cycles,
|
||||
area: item.ocrData?.area,
|
||||
needs_manual_validation: (item.ocrData?.confidence.overall ?? 0) <= 0.8,
|
||||
ocr_confidence_cost: item.ocrData?.confidence?.cost || 0.0,
|
||||
ocr_confidence_cycles: item.ocrData?.confidence?.cycles || 0.0,
|
||||
ocr_confidence_area: item.ocrData?.confidence?.area || 0.0
|
||||
}
|
||||
}).filter(item => item !== undefined)
|
||||
|
||||
// Extract actual File objects for upload
|
||||
const fileObjects = files.map(f => f.file)
|
||||
|
||||
return apiService.createSubmission({
|
||||
notes,
|
||||
manual_validation_requested: manualValidationRequested,
|
||||
responses
|
||||
}, fileObjects)
|
||||
},
|
||||
|
||||
async loadSubmissions(limit = 20, offset = 0): Promise<Submission[]> {
|
||||
const response = await apiService.getSubmissions(limit, offset)
|
||||
if (response.error) {
|
||||
console.error('Failed to load submissions:', response.error)
|
||||
return []
|
||||
}
|
||||
return response.data?.items || []
|
||||
}
|
||||
}
|
||||
|
||||
// Error handling utilities
|
||||
export const errorHelpers = {
|
||||
getErrorMessage(error: unknown): string {
|
||||
if (typeof error === 'string') return error
|
||||
if (error instanceof Error) return error.message
|
||||
if (typeof error === 'object' && error !== null && 'detail' in error) {
|
||||
return String((error as any).detail)
|
||||
}
|
||||
return 'An unknown error occurred'
|
||||
},
|
||||
|
||||
isNetworkError(error: unknown): boolean {
|
||||
return typeof error === 'string' && error.includes('Network')
|
||||
},
|
||||
|
||||
isValidationError(status: number): boolean {
|
||||
return status === 400
|
||||
},
|
||||
|
||||
isAuthError(status: number): boolean {
|
||||
return status === 401 || status === 403
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,600 @@
|
||||
import { OpusMagnumData } from '@/types';
|
||||
import { createWorker } from 'tesseract.js';
|
||||
|
||||
export interface OpusMagnumOCRData {
|
||||
puzzle: string;
|
||||
cost: string;
|
||||
cycles: string;
|
||||
area: string;
|
||||
confidence: {
|
||||
puzzle: number;
|
||||
cost: number;
|
||||
cycles: number;
|
||||
area: number;
|
||||
overall: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface OCRRegion {
|
||||
x: number;
|
||||
y: number;
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export class OpusMagnumOCRService {
|
||||
private worker: Tesseract.Worker | null = null;
|
||||
private availablePuzzleNames: string[] = [];
|
||||
|
||||
// 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 as any
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the list of available puzzle names for better OCR matching
|
||||
*/
|
||||
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> {
|
||||
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<OpusMagnumOCRData> = {};
|
||||
const confidenceScores: Record<string, number> = {};
|
||||
|
||||
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
|
||||
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 - use user words file for better matching
|
||||
await this.configurePuzzleOCR();
|
||||
} else {
|
||||
// Default - allow all characters
|
||||
await this.worker!.setParameters({
|
||||
tessedit_char_whitelist: ''
|
||||
});
|
||||
}
|
||||
|
||||
// Perform OCR on the region
|
||||
const { data: { text, confidence } } = await this.worker!.recognize(regionCanvas);
|
||||
let cleanText = text.trim();
|
||||
|
||||
// Store the confidence score for this field
|
||||
confidenceScores[key] = confidence / 100; // Tesseract returns 0-100, we want 0-1
|
||||
|
||||
// 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 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;
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(imageUrl);
|
||||
|
||||
// Calculate overall confidence as the average of all field confidences
|
||||
const confidenceValues = Object.values(confidenceScores);
|
||||
const overallConfidence = confidenceValues.length > 0
|
||||
? confidenceValues.reduce((sum, conf) => sum + conf, 0) / confidenceValues.length
|
||||
: 0;
|
||||
|
||||
resolve({
|
||||
puzzle: results.puzzle || '',
|
||||
cost: parseInt(results.cost || ''),
|
||||
cycles: parseInt(results.cycles || ''),
|
||||
area: parseInt(results.area || ''),
|
||||
confidence: {
|
||||
puzzle: confidenceScores.puzzle || 0,
|
||||
cost: confidenceScores.cost || 0,
|
||||
cycles: confidenceScores.cycles || 0,
|
||||
area: confidenceScores.area || 0,
|
||||
overall: overallConfidence,
|
||||
}
|
||||
});
|
||||
} 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate Levenshtein distance between two strings
|
||||
*/
|
||||
private levenshteinDistance(str1: string, str2: string): number {
|
||||
const matrix = Array(str2.length + 1).fill(null).map(() => Array(str1.length + 1).fill(null));
|
||||
|
||||
for (let i = 0; i <= str1.length; i++) matrix[0][i] = i;
|
||||
for (let j = 0; j <= str2.length; j++) matrix[j][0] = j;
|
||||
|
||||
for (let j = 1; j <= str2.length; j++) {
|
||||
for (let i = 1; i <= str1.length; i++) {
|
||||
const indicator = str1[i - 1] === str2[j - 1] ? 0 : 1;
|
||||
matrix[j][i] = Math.min(
|
||||
matrix[j][i - 1] + 1, // deletion
|
||||
matrix[j - 1][i] + 1, // insertion
|
||||
matrix[j - 1][i - 1] + indicator // substitution
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return matrix[str2.length][str1.length];
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the best matching puzzle name from available options using multiple strategies
|
||||
*/
|
||||
private findBestPuzzleMatch(ocrText: string): string {
|
||||
if (!this.availablePuzzleNames.length) {
|
||||
return ocrText.trim();
|
||||
}
|
||||
|
||||
const cleanedOcr = ocrText.trim();
|
||||
if (!cleanedOcr) return '';
|
||||
|
||||
// Strategy 1: Exact match (case insensitive)
|
||||
const exactMatch = this.availablePuzzleNames.find(
|
||||
name => name.toLowerCase() === cleanedOcr.toLowerCase()
|
||||
);
|
||||
if (exactMatch) return exactMatch;
|
||||
|
||||
// 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 = 0;
|
||||
|
||||
for (const puzzleName of this.availablePuzzleNames) {
|
||||
const scores = [
|
||||
this.calculateLevenshteinSimilarity(cleanedOcr, puzzleName),
|
||||
this.calculateJaroWinklerSimilarity(cleanedOcr, puzzleName),
|
||||
this.calculateNGramSimilarity(cleanedOcr, puzzleName, 2)
|
||||
];
|
||||
|
||||
// Use the maximum score from all algorithms
|
||||
const maxScore = Math.max(...scores);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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();
|
||||
Reference in New Issue
Block a user