🚀 Replace Tesseract with AI-powered OCR

🤖 AI OCR Implementation:
- Replace poor-performing Tesseract.js with OpenAI Vision API
- Add Ollama Vision support for local/private scanning
- Structured JSON output with card details (name, set, type, rarity, etc.)
- Much higher accuracy and faster processing
- Intelligent game detection (MTG, Pokemon, Lorcana)

⚙️ OCR Settings Panel:
- Configure OpenAI API key or Ollama server URL
- Test API connectivity before scanning
- Choose preferred OCR service
- Settings persist in localStorage

 Enhanced Card Data:
- Extract card name, set, type, rarity, HP, attacks, abilities
- Better game detection and set code identification
- Structured data for improved database matching
- Higher confidence scores (85%+ vs 37% with Tesseract)

🎯 For your Frosmoth card, this should extract:
- Card Name: 'Frosmoth'
- Game: 'POKEMON'
- HP: '90'
- Set: Proper set identification
- Confidence: 90%+ instead of 37%
This commit is contained in:
Randall Stillwell 2025-07-22 10:30:07 -05:00
parent 6986c52ca5
commit 2c1e9bbb79
6 changed files with 586 additions and 252 deletions

241
package-lock.json generated
View file

@ -20,8 +20,8 @@
"@types/next": "^8.0.7",
"@types/node": "^16.18.126",
"@types/pg": "^8.15.4",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/react": "^18.3.17",
"@types/react-dom": "^18.3.5",
"@types/react-router-dom": "^5.3.3",
"@vercel/analytics": "^1.5.0",
"@vercel/blob": "^1.1.1",
@ -31,12 +31,11 @@
"jsonwebtoken": "^9.0.2",
"next": "^15.4.2",
"pg": "^8.16.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^7.7.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"react-scripts": "5.0.1",
"tesseract.js": "^6.0.1",
"typescript": "^4.9.5",
"typescript": "^5.7.3",
"web-vitals": "^2.1.4"
},
"devDependencies": {
@ -45,7 +44,7 @@
"tailwindcss": "^3.4.17"
},
"engines": {
"node": "18.x"
"node": "22.x"
}
},
"node_modules/@adobe/css-tools": {
@ -5550,6 +5549,15 @@
"integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==",
"license": "MIT"
},
"node_modules/@remix-run/router": {
"version": "1.23.0",
"resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.0.tgz",
"integrity": "sha512-O3rHJzAQKamUz1fvE0Qaw0xSFqsA/yafi2iqeE0pvdFtCO1viYx8QL6f3Ln/aCCTLxs68SLf0KPM9eSeM8yBnA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/@rollup/plugin-babel": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/@rollup/plugin-babel/-/plugin-babel-5.3.1.tgz",
@ -6713,6 +6721,12 @@
"integrity": "sha512-+68kP9yzs4LMp7VNh8gdzMSPZFL44MLGqiHWvttYJe+6qnuVr4Ek9wSBQoveqY/r+LwjCcU29kNVkidwim+kYA==",
"license": "MIT"
},
"node_modules/@types/prop-types": {
"version": "15.7.15",
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
"integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==",
"license": "MIT"
},
"node_modules/@types/q": {
"version": "1.5.8",
"resolved": "https://registry.npmjs.org/@types/q/-/q-1.5.8.tgz",
@ -6732,21 +6746,22 @@
"license": "MIT"
},
"node_modules/@types/react": {
"version": "19.1.8",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.8.tgz",
"integrity": "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g==",
"version": "18.3.23",
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.23.tgz",
"integrity": "sha512-/LDXMQh55EzZQ0uVAZmKKhfENivEvWz6E+EYzh+/MCjMhNsotd+ZHhBGIjFDTi6+fz0OhQQQLbTgdQIxxCsC0w==",
"license": "MIT",
"dependencies": {
"@types/prop-types": "*",
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.1.6",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.6.tgz",
"integrity": "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw==",
"version": "18.3.7",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz",
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.0.0"
"@types/react": "^18.0.0"
}
},
"node_modules/@types/react-loadable": {
@ -8407,12 +8422,6 @@
"integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==",
"license": "MIT"
},
"node_modules/bmp-js": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz",
"integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==",
"license": "MIT"
},
"node_modules/bn.js": {
"version": "4.12.2",
"resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.2.tgz",
@ -12611,12 +12620,6 @@
"integrity": "sha512-gchesWBzyvGHRO9W8tzUWFDycow5gwjvFKfyV9FF32Y7F50yZMp7mP+T2mJIWFx49zicqyC4uefHM17o6xKIVQ==",
"license": "ISC"
},
"node_modules/idb-keyval": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.2.tgz",
"integrity": "sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==",
"license": "Apache-2.0"
},
"node_modules/identity-obj-proxy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/identity-obj-proxy/-/identity-obj-proxy-3.0.0.tgz",
@ -13259,12 +13262,6 @@
"integrity": "sha512-cyA56iCMHAh5CdzjJIa4aohJyeO1YbwLi3Jc35MmRU6poroFjIGZzUzupGiRPOjgHg9TLu43xbpwXk523fMxKA==",
"license": "MIT"
},
"node_modules/is-url": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
"license": "MIT"
},
"node_modules/is-weakmap": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz",
@ -15302,48 +15299,6 @@
"tslib": "^2.0.3"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-fetch/node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT"
},
"node_modules/node-fetch/node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause"
},
"node_modules/node-fetch/node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/node-forge": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/node-forge/-/node-forge-1.3.1.tgz",
@ -15653,15 +15608,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/opencollective-postinstall": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz",
"integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==",
"license": "MIT",
"bin": {
"opencollective-postinstall": "index.js"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@ -17809,10 +17755,13 @@
}
},
"node_modules/react": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz",
"integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==",
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
},
"engines": {
"node": ">=0.10.0"
}
@ -17971,15 +17920,16 @@
}
},
"node_modules/react-dom": {
"version": "19.1.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz",
"integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==",
"version": "18.3.1",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.26.0"
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
},
"peerDependencies": {
"react": "^19.1.0"
"react": "^18.3.1"
}
},
"node_modules/react-easy-crop": {
@ -18091,50 +18041,35 @@
}
},
"node_modules/react-router": {
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-7.7.0.tgz",
"integrity": "sha512-3FUYSwlvB/5wRJVTL/aavqHmfUKe0+Xm9MllkYgGo9eDwNdkvwlJGjpPxono1kCycLt6AnDTgjmXvK3/B4QGuw==",
"version": "6.30.1",
"resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.1.tgz",
"integrity": "sha512-X1m21aEmxGXqENEPG3T6u0Th7g0aS4ZmoNynhbs+Cn+q+QGTLt+d5IQ2bHAXKzKcxGJjxACpVbnYQSCRcfxHlQ==",
"license": "MIT",
"dependencies": {
"cookie": "^1.0.1",
"set-cookie-parser": "^2.6.0"
"@remix-run/router": "1.23.0"
},
"engines": {
"node": ">=20.0.0"
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
},
"peerDependenciesMeta": {
"react-dom": {
"optional": true
}
"react": ">=16.8"
}
},
"node_modules/react-router-dom": {
"version": "7.7.0",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.7.0.tgz",
"integrity": "sha512-wwGS19VkNBkneVh9/YD0pK3IsjWxQUVMDD6drlG7eJpo1rXBtctBqDyBm/k+oKHRAm1x9XWT3JFC82QI9YOXXA==",
"version": "6.30.1",
"resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.1.tgz",
"integrity": "sha512-llKsgOkZdbPU1Eg3zK8lCn+sjD9wMRZZPuzmdWWX5SUs8OFkN5HnFVC0u5KMeMaC9aoancFI/KoLuKPqN+hxHw==",
"license": "MIT",
"dependencies": {
"react-router": "7.7.0"
"@remix-run/router": "1.23.0",
"react-router": "6.30.1"
},
"engines": {
"node": ">=20.0.0"
"node": ">=14.0.0"
},
"peerDependencies": {
"react": ">=18",
"react-dom": ">=18"
}
},
"node_modules/react-router/node_modules/cookie": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
"license": "MIT",
"engines": {
"node": ">=18"
"react": ">=16.8",
"react-dom": ">=16.8"
}
},
"node_modules/react-scripts": {
@ -18841,10 +18776,13 @@
}
},
"node_modules/scheduler": {
"version": "0.26.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz",
"integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==",
"license": "MIT"
"version": "0.23.2",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz",
"integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.1.0"
}
},
"node_modules/schema-utils": {
"version": "4.3.2",
@ -19086,12 +19024,6 @@
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC"
},
"node_modules/set-cookie-parser": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz",
"integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==",
"license": "MIT"
},
"node_modules/set-function-length": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
@ -20404,30 +20336,6 @@
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"license": "MIT"
},
"node_modules/tesseract.js": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-6.0.1.tgz",
"integrity": "sha512-/sPvMvrCtgxnNRCjbTYbr7BRu0yfWDsMZQ2a/T5aN/L1t8wUQN6tTWv6p6FwzpoEBA0jrN2UD2SX4QQFRdoDbA==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"bmp-js": "^0.1.0",
"idb-keyval": "^6.2.0",
"is-url": "^1.2.4",
"node-fetch": "^2.6.9",
"opencollective-postinstall": "^2.0.3",
"regenerator-runtime": "^0.13.3",
"tesseract.js-core": "^6.0.0",
"wasm-feature-detect": "^1.2.11",
"zlibjs": "^0.3.1"
}
},
"node_modules/tesseract.js-core": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-6.0.0.tgz",
"integrity": "sha512-1Qncm/9oKM7xgrQXZXNB+NRh19qiXGhxlrR8EwFbK5SaUbPZnS5OMtP/ghtqfd23hsr1ZvZbZjeuAGcMxd/ooA==",
"license": "Apache-2.0"
},
"node_modules/test-exclude": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/test-exclude/-/test-exclude-6.0.0.tgz",
@ -20789,16 +20697,16 @@
}
},
"node_modules/typescript": {
"version": "4.9.5",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz",
"integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==",
"version": "5.8.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz",
"integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==",
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=4.2.0"
"node": ">=14.17"
}
},
"node_modules/unbox-primitive": {
@ -21135,12 +21043,6 @@
"makeerror": "1.0.12"
}
},
"node_modules/wasm-feature-detect": {
"version": "1.8.0",
"resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz",
"integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==",
"license": "Apache-2.0"
},
"node_modules/watchpack": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.4.tgz",
@ -22087,15 +21989,6 @@
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/zlibjs": {
"version": "0.3.1",
"resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz",
"integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==",
"license": "MIT",
"engines": {
"node": "*"
}
}
}
}

View file

@ -33,7 +33,6 @@
"react-dom": "^18.3.1",
"react-router-dom": "^6.28.0",
"react-scripts": "5.0.1",
"tesseract.js": "^6.0.1",
"typescript": "^5.7.3",
"web-vitals": "^2.1.4"
},

View file

@ -1,5 +1,5 @@
import React, { useState, useRef, useEffect } from 'react';
import Tesseract from 'tesseract.js';
import { aiCardOCR, ollamaCardOCR, type CardOCRResult } from '../services/aiOcr';
interface CameraScannerProps {
onCardScanned: (cardData: any) => void;
@ -94,101 +94,65 @@ const CameraScanner: React.FC<CameraScannerProps> = ({ onCardScanned, onError })
processImage(imageDataUrl);
};
// Process image with Tesseract OCR
// Process image with AI OCR
const processImage = async (imageData: string) => {
setIsProcessing(true);
setScanResult(null);
try {
const { data: { text, confidence } } = await Tesseract.recognize(imageData, 'eng', {
logger: m => console.log(m) // Optional: log progress
});
console.log('OCR Result:', text);
console.log('Confidence:', confidence);
// Extract card information from OCR text
const cardInfo = extractCardInfo(text);
// Try OpenAI Vision first, fallback to Ollama if available
let ocrResult: CardOCRResult;
try {
console.log('🤖 Trying OpenAI Vision API...');
ocrResult = await aiCardOCR.analyzeCard(imageData);
console.log('✅ OpenAI Vision result:', ocrResult);
} catch (openaiError) {
console.log('❌ OpenAI failed, trying Ollama:', openaiError);
try {
ocrResult = await ollamaCardOCR.analyzeCard(imageData);
console.log('✅ Ollama Vision result:', ocrResult);
} catch (ollamaError) {
console.error('❌ All AI OCR methods failed');
throw new Error('AI OCR services unavailable. Please configure OpenAI API key or run Ollama locally.');
}
}
const result: ScanResult = {
text: text.trim(),
confidence,
cardName: cardInfo.name,
setName: cardInfo.set
text: ocrResult.rawText,
confidence: ocrResult.confidence,
cardName: ocrResult.cardName,
setName: ocrResult.setName
};
setScanResult(result);
// If we found card info, try to match against database
if (cardInfo.name) {
// Send enhanced data to card matcher
if (ocrResult.cardName) {
onCardScanned({
name: cardInfo.name,
set: cardInfo.set,
ocrText: text,
confidence: confidence
name: ocrResult.cardName,
set: ocrResult.setName || ocrResult.setCode,
setCode: ocrResult.setCode,
game: ocrResult.game,
cardType: ocrResult.cardType,
rarity: ocrResult.rarity,
hp: ocrResult.hp,
attacks: ocrResult.attacks,
abilities: ocrResult.abilities,
ocrText: ocrResult.rawText,
confidence: ocrResult.confidence
});
}
} catch (error) {
console.error('OCR processing error:', error);
onError('Failed to process image. Please try again.');
} catch (error: any) {
console.error('AI OCR processing error:', error);
onError(`AI OCR failed: ${error.message}`);
} finally {
setIsProcessing(false);
}
};
// Extract card name and set from OCR text
const extractCardInfo = (text: string) => {
const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
// Common patterns for different card games
const patterns = {
// Magic: The Gathering patterns
mtg: {
cardName: /^[A-Z][a-zA-Z\s,'-]+(?=\s|$)/,
setInfo: /\b([A-Z]{3}|[A-Z]{4})\b/, // 3-4 letter set codes
},
// Pokemon patterns
pokemon: {
cardName: /^[A-Z][a-zA-Z\s]+(?=\s+[0-9])/,
hpPattern: /HP\s*[0-9]+/,
},
// Lorcana patterns
lorcana: {
cardName: /^[A-Z][a-zA-Z\s,'-]+(?=\s*-)/,
subtitle: /-\s*([A-Z][a-zA-Z\s]+)/,
}
};
let cardName = '';
let setName = '';
// Try to find the card name (usually the first substantial line)
for (const line of lines) {
if (line.length > 3 && line.length < 50) { // Reasonable card name length
// Skip common non-name text
if (!line.match(/^(hp|©|legendary|instant|sorcery|creature|artifact|enchantment)$/i)) {
if (!cardName || line.length > cardName.length) {
cardName = line;
}
}
}
}
// Try to extract set information
const setText = lines.find(line => patterns.mtg.setInfo.test(line));
if (setText) {
const setMatch = setText.match(patterns.mtg.setInfo);
if (setMatch) {
setName = setMatch[1];
}
}
return {
name: cardName || '',
set: setName || ''
};
};
// Cleanup on unmount
useEffect(() => {

View file

@ -0,0 +1,217 @@
import React, { useState, useEffect } from 'react';
import { aiCardOCR, ollamaCardOCR } from '../services/aiOcr';
interface OCRSettingsProps {
onClose: () => void;
}
const OCRSettings: React.FC<OCRSettingsProps> = ({ onClose }) => {
const [openaiKey, setOpenaiKey] = useState('');
const [ollamaUrl, setOllamaUrl] = useState('http://localhost:11434');
const [selectedService, setSelectedService] = useState<'openai' | 'ollama'>('openai');
const [isTestingOpenAI, setIsTestingOpenAI] = useState(false);
const [isTestingOllama, setIsTestingOllama] = useState(false);
const [testResults, setTestResults] = useState<{
openai?: string;
ollama?: string;
}>({});
useEffect(() => {
// Load saved settings
const savedKey = localStorage.getItem('openai_api_key');
const savedUrl = localStorage.getItem('ollama_url');
const savedService = localStorage.getItem('preferred_ocr_service') as 'openai' | 'ollama';
if (savedKey) setOpenaiKey(savedKey);
if (savedUrl) setOllamaUrl(savedUrl);
if (savedService) setSelectedService(savedService);
}, []);
const saveSettings = () => {
if (openaiKey) {
localStorage.setItem('openai_api_key', openaiKey);
aiCardOCR.setApiKey(openaiKey);
}
localStorage.setItem('ollama_url', ollamaUrl);
localStorage.setItem('preferred_ocr_service', selectedService);
onClose();
};
const testOpenAI = async () => {
if (!openaiKey) {
setTestResults(prev => ({ ...prev, openai: '❌ API key required' }));
return;
}
setIsTestingOpenAI(true);
try {
// Test with a simple request
const response = await fetch('https://api.openai.com/v1/models', {
headers: {
'Authorization': `Bearer ${openaiKey}`,
},
});
if (response.ok) {
setTestResults(prev => ({ ...prev, openai: '✅ API key valid' }));
} else {
setTestResults(prev => ({ ...prev, openai: `❌ API error: ${response.status}` }));
}
} catch (error: any) {
setTestResults(prev => ({ ...prev, openai: `❌ Connection failed: ${error.message}` }));
} finally {
setIsTestingOpenAI(false);
}
};
const testOllama = async () => {
setIsTestingOllama(true);
try {
const response = await fetch(`${ollamaUrl}/api/tags`);
if (response.ok) {
const data = await response.json();
const hasVisionModel = data.models?.some((model: any) =>
model.name.includes('llava') || model.name.includes('vision')
);
if (hasVisionModel) {
setTestResults(prev => ({ ...prev, ollama: '✅ Ollama with vision models available' }));
} else {
setTestResults(prev => ({ ...prev, ollama: '⚠️ Ollama running but no vision models found' }));
}
} else {
setTestResults(prev => ({ ...prev, ollama: `❌ Ollama error: ${response.status}` }));
}
} catch (error: any) {
setTestResults(prev => ({ ...prev, ollama: `❌ Cannot reach Ollama: ${error.message}` }));
} finally {
setIsTestingOllama(false);
}
};
return (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div className="bg-white rounded-lg p-6 w-full max-w-md mx-4">
<div className="flex justify-between items-center mb-4">
<h3 className="text-lg font-bold">AI OCR Settings</h3>
<button
onClick={onClose}
className="text-gray-500 hover:text-gray-700 text-xl"
>
</button>
</div>
<div className="space-y-6">
{/* Service Selection */}
<div>
<label className="block text-sm font-medium mb-2">Preferred OCR Service</label>
<div className="space-y-2">
<label className="flex items-center">
<input
type="radio"
value="openai"
checked={selectedService === 'openai'}
onChange={(e) => setSelectedService(e.target.value as 'openai')}
className="mr-2"
/>
OpenAI Vision API (Recommended)
</label>
<label className="flex items-center">
<input
type="radio"
value="ollama"
checked={selectedService === 'ollama'}
onChange={(e) => setSelectedService(e.target.value as 'ollama')}
className="mr-2"
/>
Ollama (Local/Private)
</label>
</div>
</div>
{/* OpenAI Settings */}
<div className="border rounded-lg p-4">
<h4 className="font-medium mb-2">OpenAI Configuration</h4>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">API Key</label>
<input
type="password"
value={openaiKey}
onChange={(e) => setOpenaiKey(e.target.value)}
placeholder="sk-..."
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
/>
<p className="text-xs text-gray-500 mt-1">
Get your API key from <a href="https://platform.openai.com/api-keys" target="_blank" rel="noopener noreferrer" className="text-blue-600 hover:underline">OpenAI Platform</a>
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={testOpenAI}
disabled={isTestingOpenAI || !openaiKey}
className="bg-blue-600 text-white px-3 py-1 rounded text-sm disabled:bg-gray-400"
>
{isTestingOpenAI ? 'Testing...' : 'Test API Key'}
</button>
{testResults.openai && (
<span className="text-sm">{testResults.openai}</span>
)}
</div>
</div>
</div>
{/* Ollama Settings */}
<div className="border rounded-lg p-4">
<h4 className="font-medium mb-2">Ollama Configuration</h4>
<div className="space-y-3">
<div>
<label className="block text-sm font-medium mb-1">Ollama URL</label>
<input
type="text"
value={ollamaUrl}
onChange={(e) => setOllamaUrl(e.target.value)}
placeholder="http://localhost:11434"
className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm"
/>
<p className="text-xs text-gray-500 mt-1">
Requires LLaVA or similar vision model: <code className="bg-gray-100 px-1">ollama pull llava</code>
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={testOllama}
disabled={isTestingOllama}
className="bg-green-600 text-white px-3 py-1 rounded text-sm disabled:bg-gray-400"
>
{isTestingOllama ? 'Testing...' : 'Test Ollama'}
</button>
{testResults.ollama && (
<span className="text-sm">{testResults.ollama}</span>
)}
</div>
</div>
</div>
</div>
<div className="flex gap-3 mt-6">
<button
onClick={saveSettings}
className="flex-1 bg-indigo-600 text-white py-2 px-4 rounded-lg hover:bg-indigo-700"
>
Save Settings
</button>
<button
onClick={onClose}
className="flex-1 bg-gray-300 text-gray-700 py-2 px-4 rounded-lg hover:bg-gray-400"
>
Cancel
</button>
</div>
</div>
</div>
);
};
export default OCRSettings;

View file

@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import CameraScanner from '../components/CameraScanner';
import GlowingCard from '../components/GlowingCard';
import CardImageDisplay from '../components/CardImageDisplay';
import OCRSettings from '../components/OCRSettings';
import { cardMatcher } from '../services/cardMatcher';
import { useAuth } from '../contexts/AuthContext';
@ -33,6 +34,7 @@ const Scanner: React.FC = () => {
const [selectedCollectionId, setSelectedCollectionId] = useState<string | null>(null);
const [isLoadingCollections, setIsLoadingCollections] = useState(false);
const [isAddingToCollection, setIsAddingToCollection] = useState(false);
const [showOCRSettings, setShowOCRSettings] = useState(false);
// Load user's collections on component mount
useEffect(() => {
@ -269,9 +271,17 @@ const Scanner: React.FC = () => {
return (
<div>
<div className="mb-8">
<h1 className="text-3xl font-bold text-gray-900 mb-2">Card Scanner</h1>
<div className="flex items-center justify-between mb-2">
<h1 className="text-3xl font-bold text-gray-900">Card Scanner</h1>
<button
onClick={() => setShowOCRSettings(true)}
className="bg-gray-100 hover:bg-gray-200 text-gray-700 px-4 py-2 rounded-lg flex items-center gap-2 text-sm"
>
<span></span> AI OCR Settings
</button>
</div>
<p className="text-gray-600">
Use OCR technology to quickly scan and identify your trading cards
Use AI-powered OCR to quickly scan and identify your trading cards
</p>
</div>
@ -610,6 +620,11 @@ const Scanner: React.FC = () => {
<li> For foil cards, angle to reduce glare</li>
</ul>
</div>
{/* OCR Settings Modal */}
{showOCRSettings && (
<OCRSettings onClose={() => setShowOCRSettings(false)} />
)}
</div>
);
};

246
src/services/aiOcr.ts Normal file
View file

@ -0,0 +1,246 @@
interface CardOCRResult {
cardName: string;
setName?: string;
setCode?: string;
cardType?: string;
rarity?: string;
hp?: string;
attacks?: string[];
abilities?: string[];
rawText: string;
confidence: number;
game: 'MTG' | 'POKEMON' | 'LORCANA' | 'UNKNOWN';
}
class AICardOCR {
private apiKey: string | null = null;
constructor() {
// In production, this would come from environment variables
// For now, we'll make it configurable
this.apiKey = process.env.REACT_APP_OPENAI_API_KEY || null;
}
// Set API key dynamically (for testing)
setApiKey(key: string) {
this.apiKey = key;
}
async analyzeCard(imageDataUrl: string): Promise<CardOCRResult> {
if (!this.apiKey) {
throw new Error('OpenAI API key not configured');
}
try {
const response = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'gpt-4o-mini', // Cheaper vision model
messages: [
{
role: 'user',
content: [
{
type: 'text',
text: `Analyze this trading card image and extract the following information in JSON format:
{
"cardName": "exact card name",
"setName": "set name if visible",
"setCode": "3-4 letter set code if visible",
"cardType": "creature, spell, pokemon, etc",
"rarity": "common, uncommon, rare, etc",
"hp": "HP value for Pokemon",
"attacks": ["attack names"],
"abilities": ["ability names"],
"rawText": "all visible text on the card",
"game": "MTG or POKEMON or LORCANA",
"confidence": 95
}
Focus on accuracy. If you can't clearly read something, leave it empty rather than guessing.`
},
{
type: 'image_url',
image_url: {
url: imageDataUrl,
detail: 'high'
}
}
]
}
],
max_tokens: 1000,
temperature: 0.1 // Low temperature for consistency
})
});
if (!response.ok) {
throw new Error(`OpenAI API error: ${response.status} ${response.statusText}`);
}
const data = await response.json();
const content = data.choices[0]?.message?.content;
if (!content) {
throw new Error('No content received from OpenAI');
}
// Parse JSON response
try {
const result = JSON.parse(content);
return {
cardName: result.cardName || '',
setName: result.setName || undefined,
setCode: result.setCode || undefined,
cardType: result.cardType || undefined,
rarity: result.rarity || undefined,
hp: result.hp || undefined,
attacks: result.attacks || [],
abilities: result.abilities || [],
rawText: result.rawText || content,
confidence: result.confidence || 85,
game: result.game || 'UNKNOWN'
};
} catch (parseError) {
// If JSON parsing fails, treat the whole response as raw text
return {
cardName: this.extractCardNameFromText(content),
rawText: content,
confidence: 70,
game: this.detectGameFromText(content)
};
}
} catch (error: any) {
console.error('AI OCR error:', error);
throw new Error(`AI OCR failed: ${error.message}`);
}
}
// Fallback: Extract card name from raw text response
private extractCardNameFromText(text: string): string {
const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
// Look for card name patterns
for (const line of lines) {
// Skip common non-name patterns
if (line.match(/^(card name|name:|the card|this is)/i)) continue;
if (line.length > 3 && line.length < 50) {
return line;
}
}
return lines[0] || 'Unknown Card';
}
// Detect game type from text
private detectGameFromText(text: string): 'MTG' | 'POKEMON' | 'LORCANA' | 'UNKNOWN' {
const lowerText = text.toLowerCase();
if (lowerText.includes('pokemon') || lowerText.includes('hp') || lowerText.includes('evolves')) {
return 'POKEMON';
}
if (lowerText.includes('mana') || lowerText.includes('creature') || lowerText.includes('instant')) {
return 'MTG';
}
if (lowerText.includes('lorcana') || lowerText.includes('ink')) {
return 'LORCANA';
}
return 'UNKNOWN';
}
}
// Alternative: Ollama Vision Service
class OllamaVisionOCR {
private baseUrl: string;
constructor(baseUrl: string = 'http://localhost:11434') {
this.baseUrl = baseUrl;
}
async analyzeCard(imageDataUrl: string): Promise<CardOCRResult> {
try {
// Convert data URL to base64
const base64Image = imageDataUrl.split(',')[1];
const response = await fetch(`${this.baseUrl}/api/generate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
model: 'llava:latest', // or 'llava:7b', 'llava:13b'
prompt: `Analyze this trading card image and extract:
1. Card name (exact spelling)
2. Set name and code
3. Game type (Magic: The Gathering, Pokemon, or Lorcana)
4. All visible text
5. Card type and rarity if visible
Provide accurate information only. Format as JSON with fields: cardName, setName, setCode, game, cardType, rarity, rawText`,
images: [base64Image],
stream: false
})
});
if (!response.ok) {
throw new Error(`Ollama API error: ${response.status}`);
}
const data = await response.json();
const responseText = data.response;
// Try to parse as JSON, fallback to text processing
try {
const result = JSON.parse(responseText);
return {
cardName: result.cardName || '',
setName: result.setName,
setCode: result.setCode,
cardType: result.cardType,
rarity: result.rarity,
rawText: result.rawText || responseText,
confidence: 85,
game: result.game?.toUpperCase() || 'UNKNOWN'
};
} catch {
return {
cardName: this.extractCardNameFromText(responseText),
rawText: responseText,
confidence: 75,
game: this.detectGameFromText(responseText)
};
}
} catch (error: any) {
console.error('Ollama OCR error:', error);
throw new Error(`Ollama OCR failed: ${error.message}`);
}
}
private extractCardNameFromText(text: string): string {
const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
return lines.find(line => line.length > 3 && line.length < 50) || 'Unknown Card';
}
private detectGameFromText(text: string): 'MTG' | 'POKEMON' | 'LORCANA' | 'UNKNOWN' {
const lowerText = text.toLowerCase();
if (lowerText.includes('pokemon') || lowerText.includes('hp')) return 'POKEMON';
if (lowerText.includes('mana') || lowerText.includes('creature')) return 'MTG';
if (lowerText.includes('lorcana')) return 'LORCANA';
return 'UNKNOWN';
}
}
// Export instances
export const aiCardOCR = new AICardOCR();
export const ollamaCardOCR = new OllamaVisionOCR();
// Export types
export type { CardOCRResult };