deckhearth/lib/ai-ocr.js

527 lines
17 KiB
JavaScript
Raw Permalink Normal View History

// AI OCR Service for Trading Card Recognition
export class AICardOCR {
constructor() {
this.apiKey = null;
this.baseUrl = 'https://api.openai.com/v1';
}
setApiKey(apiKey) {
this.apiKey = apiKey;
}
async analyzeCard(imageDataUrl) {
if (!this.apiKey) {
throw new Error('OpenAI API key not configured');
}
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
IMPORTANT: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore:
- Random objects, books, papers
- Screenshots of websites or apps
- Blurry or unclear images
- Non-card gaming items
If you detect a trading card, extract the following information in JSON format:
{
"isCard": true,
"cardName": "exact card name as printed",
"setName": "set name if visible",
"setCode": "set code/symbol if visible",
"cardNumber": "collector number if visible",
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
"cardType": "creature, instant, trainer, etc.",
"rarity": "common, uncommon, rare, mythic, etc.",
"manaCost": "mana cost if visible",
"hp": "HP or power if visible",
"abilities": ["list of abilities/attacks if visible"],
"flavorText": "flavor text if clearly readable",
"artist": "artist name if visible",
"confidence": 85,
"rawText": "all visible text on the card"
}
If NO trading card is detected, respond with:
{
"isCard": false,
"confidence": 0,
"reason": "No trading card detected in image"
}
Focus on accuracy over speed. Only extract data you can clearly read.`;
try {
const response = await fetch(`${this.baseUrl}/chat/completions`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini',
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: prompt
},
{
type: 'image_url',
image_url: {
url: imageDataUrl,
detail: 'high'
}
}
]
}
],
max_tokens: 1000,
temperature: 0.1
})
});
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status}`);
}
const data = await response.json();
const content = data.choices[0]?.message?.content;
if (!content) {
throw new Error('No response from OpenAI');
}
// Parse JSON response
let result;
try {
// Clean up the response - remove markdown code blocks if present
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
result = JSON.parse(cleanContent);
} catch (parseError) {
console.error('Failed to parse OpenAI JSON response:', content);
// Fallback: try to extract card name from raw text
const cardNameMatch = content.match(/card.*?name.*?[:"]\s*([^"'\n]+)/i);
result = {
isCard: !!cardNameMatch,
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
confidence: 30,
rawText: content,
reason: 'Failed to parse structured response'
};
}
// Ensure we have the required structure
return {
isCard: result.isCard || false,
cardName: result.cardName || null,
setName: result.setName || null,
setCode: result.setCode || null,
cardNumber: result.cardNumber || null,
game: result.game || null,
cardType: result.cardType || null,
rarity: result.rarity || null,
manaCost: result.manaCost || null,
hp: result.hp || null,
abilities: result.abilities || [],
flavorText: result.flavorText || null,
artist: result.artist || null,
confidence: result.confidence || 0,
rawText: result.rawText || content,
reason: result.reason || null
};
} catch (error) {
console.error('OpenAI Vision API error:', error);
throw error;
}
}
}
export class OllamaVisionOCR {
constructor() {
this.baseUrl = 'http://localhost:11434';
}
setBaseUrl(url) {
this.baseUrl = url;
}
async analyzeCard(imageDataUrl) {
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
IMPORTANT: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore random objects, books, papers, screenshots, or blurry images.
If you detect a trading card, extract this information in JSON format:
{
"isCard": true,
"cardName": "exact card name as printed",
"setName": "set name if visible",
"setCode": "set code if visible",
"cardNumber": "collector number if visible",
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
"cardType": "creature, instant, trainer, etc.",
"rarity": "common, uncommon, rare, mythic, etc.",
"confidence": 85,
"rawText": "all visible text"
}
If NO trading card detected, respond: {"isCard": false, "confidence": 0, "reason": "No trading card detected"}`;
try {
// Convert data URL to base64
const base64Data = imageDataUrl.split(',')[1];
const response = await fetch(`${this.baseUrl}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'llava:latest',
prompt: prompt,
images: [base64Data],
stream: false,
options: {
temperature: 0.1,
top_p: 0.9
}
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status}`);
}
const data = await response.json();
const content = data.response;
if (!content) {
throw new Error('No response from Ollama');
}
// Parse JSON response
let result;
try {
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
result = JSON.parse(cleanContent);
} catch (parseError) {
console.error('Failed to parse Ollama JSON response:', content);
result = {
isCard: false,
confidence: 0,
rawText: content,
reason: 'Failed to parse response'
};
}
return {
isCard: result.isCard || false,
cardName: result.cardName || null,
setName: result.setName || null,
setCode: result.setCode || null,
cardNumber: result.cardNumber || null,
game: result.game || null,
cardType: result.cardType || null,
rarity: result.rarity || null,
manaCost: result.manaCost || null,
hp: result.hp || null,
abilities: result.abilities || [],
flavorText: result.flavorText || null,
artist: result.artist || null,
confidence: result.confidence || 0,
rawText: result.rawText || content,
reason: result.reason || null
};
} catch (error) {
console.error('Ollama Vision API error:', error);
throw error;
}
}
}
export class PuterVisionOCR {
constructor() {
this.puterLoaded = false;
this.authFailed = false;
}
async loadPuterJS() {
if (this.puterLoaded || typeof window === 'undefined') return;
return new Promise((resolve, reject) => {
const script = document.createElement('script');
script.src = 'https://js.puter.com/v2/';
script.onload = () => {
this.puterLoaded = true;
resolve();
};
script.onerror = reject;
document.head.appendChild(script);
});
}
async analyzeCard(imageDataUrl) {
// If we've already failed auth, don't try again
if (this.authFailed) {
throw new Error('Puter.js authentication failed. Please use OpenAI or Ollama instead.');
}
try {
await this.loadPuterJS();
if (!window.puter) {
throw new Error('Puter.js not loaded');
}
// Check if user is authenticated with Puter
try {
await window.puter.auth.getUser();
} catch (authError) {
console.warn('Puter.js authentication required. Please sign in to Puter.com first.');
this.authFailed = true;
throw new Error('Puter.js requires authentication. Please use OpenAI or Ollama instead, or sign in to Puter.com first.');
}
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
CRITICAL: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore:
- Random objects, books, papers, phone screens
- Screenshots of websites or digital interfaces
- Blurry, unclear, or dark images
- Non-card gaming items or accessories
If you detect a trading card, extract information in this JSON format:
{
"isCard": true,
"cardName": "exact card name as printed on the card",
"setName": "set name if visible",
"setCode": "set code/symbol if visible",
"cardNumber": "collector number if visible",
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
"cardType": "creature, instant, sorcery, trainer, etc.",
"rarity": "common, uncommon, rare, mythic, etc.",
"manaCost": "mana cost if visible",
"hp": "HP or power if visible",
"abilities": ["list of abilities or attacks if clearly readable"],
"confidence": 85,
"rawText": "all text visible on the card"
}
If NO trading card is clearly visible, respond with:
{
"isCard": false,
"confidence": 0,
"reason": "No trading card detected in image"
}
Be conservative - only extract data you can clearly read. Quality over quantity.`;
const response = await window.puter.ai.chat(prompt, imageDataUrl, {
model: "gpt-4o"
});
if (!response) {
throw new Error('No response from Puter.js');
}
// Parse JSON response
let result;
try {
// Clean up the response - remove markdown code blocks if present
const cleanContent = response.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
result = JSON.parse(cleanContent);
} catch (parseError) {
console.error('Failed to parse Puter JSON response:', response);
// Try to extract card name from raw response
const cardNameMatch = response.match(/card.*?name.*?[:"]\s*([^"'\n,}]+)/i);
result = {
isCard: !!cardNameMatch,
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
confidence: 30,
rawText: response,
reason: 'Failed to parse structured response'
};
}
// Ensure we have the required structure
return {
isCard: result.isCard || false,
cardName: result.cardName || null,
setName: result.setName || null,
setCode: result.setCode || null,
cardNumber: result.cardNumber || null,
game: result.game || null,
cardType: result.cardType || null,
rarity: result.rarity || null,
manaCost: result.manaCost || null,
hp: result.hp || null,
abilities: result.abilities || [],
flavorText: result.flavorText || null,
artist: result.artist || null,
confidence: result.confidence || 0,
rawText: result.rawText || response,
reason: result.reason || null
};
} catch (error) {
console.error('Puter.js Vision API error:', error);
// Mark auth as failed if it's an auth-related error
if (error.message.includes('authentication') || error.message.includes('auth') || error.message.includes('401')) {
this.authFailed = true;
}
throw error;
}
}
}
// Gemini Vision OCR using Google's Gemini API
export class GeminiVisionOCR {
constructor() {
this.apiKey = null;
}
setApiKey(apiKey) {
this.apiKey = apiKey;
}
async analyzeCard(imageDataUrl) {
if (!this.apiKey) {
throw new Error('Gemini API key not configured');
}
// Convert data URL to base64
const base64Data = imageDataUrl.split(',')[1];
if (!base64Data) {
throw new Error('Invalid image data format');
}
const prompt = `You are a specialized trading card recognition system. Analyze this image and determine if it contains a trading card (Magic: The Gathering, Pokemon, Yu-Gi-Oh, Lorcana, etc.).
CRITICAL: Only respond with card data if you can clearly identify a TRADING CARD in the image. Ignore:
- Random objects, books, papers, phone screens
- Screenshots of websites or digital interfaces
- Blurry, unclear, or dark images
- Non-card gaming items or accessories
If you detect a trading card, extract information in this JSON format:
{
"isCard": true,
"cardName": "exact card name as printed on the card",
"setName": "set name if visible",
"setCode": "set code/symbol if visible",
"cardNumber": "collector number if visible",
"game": "MTG, Pokemon, YuGiOh, Lorcana, etc.",
"cardType": "creature, instant, sorcery, trainer, etc.",
"rarity": "common, uncommon, rare, mythic, etc.",
"manaCost": "mana cost if visible",
"hp": "HP or power if visible",
"abilities": ["list of abilities or attacks if clearly readable"],
"confidence": 85,
"rawText": "all text visible on the card"
}
If NO trading card is clearly visible, respond with:
{
"isCard": false,
"confidence": 0,
"reason": "No trading card detected in image"
}
Be conservative - only extract data you can clearly read. Quality over quantity.`;
try {
const response = await fetch('https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'x-goog-api-key': this.apiKey
},
body: JSON.stringify({
contents: [{
parts: [
{ text: prompt },
{
inline_data: {
mime_type: 'image/jpeg',
data: base64Data
}
}
]
}],
generationConfig: {
thinkingConfig: {
thinkingBudget: 0 // Disable thinking for faster response
}
}
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
throw new Error(`Gemini API error: ${response.status} - ${errorData.error?.message || 'Unknown error'}`);
}
const data = await response.json();
const content = data.candidates?.[0]?.content?.parts?.[0]?.text;
if (!content) {
throw new Error('No response from Gemini API');
}
// Parse JSON response
let result;
try {
// Clean up the response - remove markdown code blocks if present
const cleanContent = content.replace(/```json\n?/g, '').replace(/```\n?/g, '').trim();
result = JSON.parse(cleanContent);
} catch (parseError) {
console.error('Failed to parse Gemini JSON response:', content);
// Try to extract card name from raw response
const cardNameMatch = content.match(/card.*?name.*?[:"]\s*([^"'\n,}]+)/i);
result = {
isCard: !!cardNameMatch,
cardName: cardNameMatch ? cardNameMatch[1].trim() : null,
confidence: 30,
rawText: content,
reason: 'Failed to parse structured response'
};
}
// Ensure we have the required structure
return {
isCard: result.isCard || false,
cardName: result.cardName || null,
setName: result.setName || null,
setCode: result.setCode || null,
cardNumber: result.cardNumber || null,
game: result.game || null,
cardType: result.cardType || null,
rarity: result.rarity || null,
manaCost: result.manaCost || null,
hp: result.hp || null,
abilities: result.abilities || [],
flavorText: result.flavorText || null,
artist: result.artist || null,
confidence: result.confidence || 0,
rawText: result.rawText || content,
reason: result.reason || null
};
} catch (error) {
console.error('Gemini Vision API error:', error);
throw error;
}
}
}
// Export singleton instances
export const aiCardOCR = new AICardOCR();
export const ollamaCardOCR = new OllamaVisionOCR();
export const puterCardOCR = new PuterVisionOCR();
export const geminiCardOCR = new GeminiVisionOCR();