From 2c1e9bbb79e25b6b8c97ff945598a472c469a269 Mon Sep 17 00:00:00 2001 From: Randall Stillwell Date: Tue, 22 Jul 2025 10:30:07 -0500 Subject: [PATCH] =?UTF-8?q?=F0=9F=9A=80=20Replace=20Tesseract=20with=20AI-?= =?UTF-8?q?powered=20OCR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 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% --- package-lock.json | 241 +++++++++--------------------- package.json | 1 - src/components/CameraScanner.tsx | 114 +++++--------- src/components/OCRSettings.tsx | 217 +++++++++++++++++++++++++++ src/pages/Scanner.tsx | 19 ++- src/services/aiOcr.ts | 246 +++++++++++++++++++++++++++++++ 6 files changed, 586 insertions(+), 252 deletions(-) create mode 100644 src/components/OCRSettings.tsx create mode 100644 src/services/aiOcr.ts diff --git a/package-lock.json b/package-lock.json index 8125af6..e006c35 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": "*" - } } } } diff --git a/package.json b/package.json index 09a1116..f6e6843 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/src/components/CameraScanner.tsx b/src/components/CameraScanner.tsx index ac0ed54..5d7fcdc 100644 --- a/src/components/CameraScanner.tsx +++ b/src/components/CameraScanner.tsx @@ -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 = ({ 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(() => { diff --git a/src/components/OCRSettings.tsx b/src/components/OCRSettings.tsx new file mode 100644 index 0000000..97fd667 --- /dev/null +++ b/src/components/OCRSettings.tsx @@ -0,0 +1,217 @@ +import React, { useState, useEffect } from 'react'; +import { aiCardOCR, ollamaCardOCR } from '../services/aiOcr'; + +interface OCRSettingsProps { + onClose: () => void; +} + +const OCRSettings: React.FC = ({ 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 ( +
+
+
+

AI OCR Settings

+ +
+ +
+ {/* Service Selection */} +
+ +
+ + +
+
+ + {/* OpenAI Settings */} +
+

OpenAI Configuration

+
+
+ + setOpenaiKey(e.target.value)} + placeholder="sk-..." + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" + /> +

+ Get your API key from OpenAI Platform +

+
+
+ + {testResults.openai && ( + {testResults.openai} + )} +
+
+
+ + {/* Ollama Settings */} +
+

Ollama Configuration

+
+
+ + setOllamaUrl(e.target.value)} + placeholder="http://localhost:11434" + className="w-full px-3 py-2 border border-gray-300 rounded-md text-sm" + /> +

+ Requires LLaVA or similar vision model: ollama pull llava +

+
+
+ + {testResults.ollama && ( + {testResults.ollama} + )} +
+
+
+
+ +
+ + +
+
+
+ ); +}; + +export default OCRSettings; \ No newline at end of file diff --git a/src/pages/Scanner.tsx b/src/pages/Scanner.tsx index 41f3c6f..5372fcf 100644 --- a/src/pages/Scanner.tsx +++ b/src/pages/Scanner.tsx @@ -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(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 (
-

Card Scanner

+
+

Card Scanner

+ +

- Use OCR technology to quickly scan and identify your trading cards + Use AI-powered OCR to quickly scan and identify your trading cards

@@ -610,6 +620,11 @@ const Scanner: React.FC = () => {
  • • For foil cards, angle to reduce glare
  • + + {/* OCR Settings Modal */} + {showOCRSettings && ( + setShowOCRSettings(false)} /> + )} ); }; diff --git a/src/services/aiOcr.ts b/src/services/aiOcr.ts new file mode 100644 index 0000000..bf7f05c --- /dev/null +++ b/src/services/aiOcr.ts @@ -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 { + 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 { + 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 }; \ No newline at end of file