Spaces:
Sleeping
Sleeping
Commit ·
a5a80dd
0
Parent(s):
Initial commit. Clean .git history. No binaries. Nothin
Browse files- .github/README.md +87 -0
- .github/sync_to_hf.yaml +17 -0
- .gitignore +12 -0
- Dockerfile +7 -0
- LICENSE +7 -0
- README.md +95 -0
- backend/__init__.py +1 -0
- backend/app.py +62 -0
- backend/model.py +69 -0
- backend/utils.py +62 -0
- frontend/helpers/Sanitizer.js +29 -0
- frontend/helpers/barGraphs.js +36 -0
- frontend/helpers/footer.js +60 -0
- frontend/helpers/kSlider.js +10 -0
- frontend/index.html +60 -0
- frontend/main.js +186 -0
- frontend/style.css +209 -0
- requirements.txt +7 -0
.github/README.md
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Optical Character Recognition
|
| 2 |
+
|
| 3 |
+

|
| 4 |
+
|
| 5 |
+
Use the tool live [here](https://ocr.varak.dev).
|
| 6 |
+
|
| 7 |
+
This is an optical character recognition (OCR) tool that extracts characters from drawings. Using basic JS/HTML, we create a canvas to draw on, convert the drawing toa usable 28x28 pixel format, and send to the backend for prediction. In the backend, we use FastAPI to handle requests, with the only routes being `\` for the home page and `\predict` for predictions. The prediction route uses a pre-trained CNN model that is able to recognize all English characters and digits. For more details on the architecture, the dataset, and training process, check out [this writeup](https://github.com/intelligent-username/CNN/tree/main/char), which presents the simpler of two models trained for my writeup on CNNs (this one).
|
| 8 |
+
|
| 9 |
+
## Usage
|
| 10 |
+
|
| 11 |
+
Once again, the tool is hosted [here](https://ocr.varak.dev) for easy access.
|
| 12 |
+
|
| 13 |
+
Draw the character you want to recognize on the left canvas. The right canvas will display the top-k predictions, where k can be adjusted using the slider below it. The slider is capped at 15 since, after 15, all of the predictions are basically at 0% probability.
|
| 14 |
+
|
| 15 |
+
To run this project locally, take the following steps:
|
| 16 |
+
|
| 17 |
+
### Installation
|
| 18 |
+
|
| 19 |
+
1. Clone this repository.
|
| 20 |
+
|
| 21 |
+
```bash
|
| 22 |
+
git clone https://github.com/intelligent-username/OCR
|
| 23 |
+
cd OCR
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
2. Install the Python dependencies to a virtual environment.
|
| 27 |
+
|
| 28 |
+
```bash
|
| 29 |
+
python -m venv OCR-env
|
| 30 |
+
OCR-env\Scripts\activate # On Windows
|
| 31 |
+
source OCR-env/bin/activate # On macOS/Linux
|
| 32 |
+
pip install -r requirements.txt
|
| 33 |
+
```
|
| 34 |
+
|
| 35 |
+
3. Run the backend.
|
| 36 |
+
|
| 37 |
+
<!--
|
| 38 |
+
|
| 39 |
+
Note that, to run the backend from the `backend/` folder, some adjustments to the file paths in `app.py` need to be made, since this version of the project is for the HuggingFace deployment, which uses the root directory as the working directory. The only real difference will be to add `../` to the files paths. Here's the list of changes to make in `app.py`:
|
| 40 |
+
|
| 41 |
+
Change lines 5 and 6 to:
|
| 42 |
+
|
| 43 |
+
```python
|
| 44 |
+
from utils import predict_image
|
| 45 |
+
from model import EMNIST_VGG
|
| 46 |
+
|
| 47 |
+
```
|
| 48 |
+
|
| 49 |
+
- Change line 40 to:
|
| 50 |
+
|
| 51 |
+
```python
|
| 52 |
+
model.load_state_dict(t.load("EMNIST_CNN.pth", map_location=device, weights_only=True))
|
| 53 |
+
|
| 54 |
+
```
|
| 55 |
+
|
| 56 |
+
- Change line 43 to:
|
| 57 |
+
|
| 58 |
+
```python
|
| 59 |
+
model = t.load("EMNIST_CNN.pth", map_location=device, weights_only=False)
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
- Change line 47 to:
|
| 63 |
+
|
| 64 |
+
```python
|
| 65 |
+
app.mount("/static", StaticFiles(directory="frontend"), name="static")
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
- Change line 51 to:
|
| 69 |
+
|
| 70 |
+
```python
|
| 71 |
+
path = os.path.join("..", "frontend", "index.html")
|
| 72 |
+
```
|
| 73 |
+
|
| 74 |
+
You may want to run it from the backend folder if you really want to avoid typing `backend.` at the beginning of the uvicorn command.
|
| 75 |
+
|
| 76 |
+
-->
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
```bash
|
| 80 |
+
uvicorn backend.app:app --reload
|
| 81 |
+
```
|
| 82 |
+
|
| 83 |
+
4. Once the backend is running, go to [http://127.0.0.1:8000/](http://127.0.0.1:8000/) in your web browser to access the frontend. This link will appear in the terminal when you run the backend.
|
| 84 |
+
|
| 85 |
+
## License
|
| 86 |
+
|
| 87 |
+
This project is licensed under the MIT License. For details, see the [LICENSE](LICENSE) file.
|
.github/sync_to_hf.yaml
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Sync to Hugging Face
|
| 2 |
+
on:
|
| 3 |
+
push:
|
| 4 |
+
branches: [main]
|
| 5 |
+
jobs:
|
| 6 |
+
build:
|
| 7 |
+
runs-on: ubuntu-latest
|
| 8 |
+
steps:
|
| 9 |
+
- name: Checkout code
|
| 10 |
+
uses: actions/checkout@v3
|
| 11 |
+
with:
|
| 12 |
+
fetch-depth: 0
|
| 13 |
+
- name: Push to HF
|
| 14 |
+
env:
|
| 15 |
+
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 16 |
+
run: |
|
| 17 |
+
git push --force https://intelligent-username:$HF_TOKEN@huggingface.co/spaces/compendious/EMNIST-OCR main
|
.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Cache
|
| 2 |
+
|
| 3 |
+
**pycache**/
|
| 4 |
+
*cpython*
|
| 5 |
+
backend/EMNIST_CNN.pth
|
| 6 |
+
|
| 7 |
+
# Tests, cache, etc.
|
| 8 |
+
*test*
|
| 9 |
+
*TEMP*
|
| 10 |
+
*old*
|
| 11 |
+
*OLD*
|
| 12 |
+
|
Dockerfile
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.10-slim
|
| 2 |
+
WORKDIR /code
|
| 3 |
+
COPY requirements.txt .
|
| 4 |
+
RUN pip install --no-cache-dir -r requirements.txt
|
| 5 |
+
COPY . .
|
| 6 |
+
# We run from /code so that 'frontend/' and 'models/' are visible to app.py
|
| 7 |
+
CMD ["uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
LICENSE
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
© 2026 intelligent-username
|
| 2 |
+
|
| 3 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
| 4 |
+
|
| 5 |
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
| 6 |
+
|
| 7 |
+
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
README.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: EMNIST Predictor
|
| 3 |
+
emoji: 🖊️
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: black
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
---
|
| 9 |
+
# Optical Character Recognition
|
| 10 |
+
|
| 11 |
+

|
| 12 |
+
|
| 13 |
+
Use the tool live [here](https://ocr.varak.dev).
|
| 14 |
+
|
| 15 |
+
This is an optical character recognition (OCR) tool that extracts characters from drawings. Using basic JS/HTML, we create a canvas to draw on, convert the drawing toa usable 28x28 pixel format, and send to the backend for prediction. In the backend, we use FastAPI to handle requests, with the only routes being `\` for the home page and `\predict` for predictions. The prediction route uses a pre-trained CNN model that is able to recognize all English characters and digits. For more details on the architecture, the dataset, and training process, check out [this writeup](https://github.com/intelligent-username/CNN/tree/main/char), which presents the simpler of two models trained for my writeup on CNNs (this one).
|
| 16 |
+
|
| 17 |
+
## Usage
|
| 18 |
+
|
| 19 |
+
Once again, the tool is hosted [here](https://ocr.varak.dev) for easy access.
|
| 20 |
+
|
| 21 |
+
Draw the character you want to recognize on the left canvas. The right canvas will display the top-k predictions, where k can be adjusted using the slider below it. The slider is capped at 15 since, after 15, all of the predictions are basically at 0% probability.
|
| 22 |
+
|
| 23 |
+
To run this project locally, take the following steps:
|
| 24 |
+
|
| 25 |
+
### Installation
|
| 26 |
+
|
| 27 |
+
1. Clone this repository.
|
| 28 |
+
|
| 29 |
+
```bash
|
| 30 |
+
git clone https://github.com/intelligent-username/OCR
|
| 31 |
+
cd OCR
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
2. Install the Python dependencies to a virtual environment.
|
| 35 |
+
|
| 36 |
+
```bash
|
| 37 |
+
python -m venv OCR-env
|
| 38 |
+
OCR-env\Scripts\activate # On Windows
|
| 39 |
+
source OCR-env/bin/activate # On macOS/Linux
|
| 40 |
+
pip install -r requirements.txt
|
| 41 |
+
```
|
| 42 |
+
|
| 43 |
+
3. Run the backend.
|
| 44 |
+
|
| 45 |
+
<!--
|
| 46 |
+
|
| 47 |
+
Note that, to run the backend from the `backend/` folder, some adjustments to the file paths in `app.py` need to be made, since this version of the project is for the HuggingFace deployment, which uses the root directory as the working directory. The only real difference will be to add `../` to the files paths. Here's the list of changes to make in `app.py`:
|
| 48 |
+
|
| 49 |
+
Change lines 5 and 6 to:
|
| 50 |
+
|
| 51 |
+
```python
|
| 52 |
+
from utils import predict_image
|
| 53 |
+
from model import EMNIST_VGG
|
| 54 |
+
|
| 55 |
+
```
|
| 56 |
+
|
| 57 |
+
- Change line 40 to:
|
| 58 |
+
|
| 59 |
+
```python
|
| 60 |
+
model.load_state_dict(t.load("EMNIST_CNN.pth", map_location=device, weights_only=True))
|
| 61 |
+
|
| 62 |
+
```
|
| 63 |
+
|
| 64 |
+
- Change line 43 to:
|
| 65 |
+
|
| 66 |
+
```python
|
| 67 |
+
model = t.load("EMNIST_CNN.pth", map_location=device, weights_only=False)
|
| 68 |
+
```
|
| 69 |
+
|
| 70 |
+
- Change line 47 to:
|
| 71 |
+
|
| 72 |
+
```python
|
| 73 |
+
app.mount("/static", StaticFiles(directory="frontend"), name="static")
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
- Change line 51 to:
|
| 77 |
+
|
| 78 |
+
```python
|
| 79 |
+
path = os.path.join("..", "frontend", "index.html")
|
| 80 |
+
```
|
| 81 |
+
|
| 82 |
+
You may want to run it from the backend folder if you really want to avoid typing `backend.` at the beginning of the uvicorn command.
|
| 83 |
+
|
| 84 |
+
-->
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
```bash
|
| 88 |
+
uvicorn backend.app:app --reload
|
| 89 |
+
```
|
| 90 |
+
|
| 91 |
+
4. Once the backend is running, go to [http://127.0.0.1:8000/](http://127.0.0.1:8000/) in your web browser to access the frontend. This link will appear in the terminal when you run the backend.
|
| 92 |
+
|
| 93 |
+
## License
|
| 94 |
+
|
| 95 |
+
This project is licensed under the MIT License. For details, see the [LICENSE](LICENSE) file.
|
backend/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
# Just for Docker
|
backend/app.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch as t
|
| 2 |
+
import asyncio
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
+
from backend.utils import predict_image
|
| 6 |
+
from backend.model import EMNIST_VGG
|
| 7 |
+
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
|
| 10 |
+
from fastapi import FastAPI
|
| 11 |
+
from fastapi.responses import FileResponse
|
| 12 |
+
from fastapi.staticfiles import StaticFiles
|
| 13 |
+
from contextlib import asynccontextmanager
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@asynccontextmanager
|
| 17 |
+
async def lifespan(app: FastAPI):
|
| 18 |
+
try:
|
| 19 |
+
yield
|
| 20 |
+
except asyncio.CancelledError:
|
| 21 |
+
print("Code likely edited, restarting server...")
|
| 22 |
+
return # Suppressing annoying tracebacks on --reload
|
| 23 |
+
except Exception:
|
| 24 |
+
# real startup/shutdown failure
|
| 25 |
+
raise
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
app = FastAPI(lifespan=lifespan)
|
| 29 |
+
|
| 30 |
+
device = t.device("cuda" if t.cuda.is_available() else "cpu")
|
| 31 |
+
print(f"Server running on: {device}")
|
| 32 |
+
|
| 33 |
+
# Instantiate the empty architecture first
|
| 34 |
+
model = EMNIST_VGG(num_classes=62).to(device)
|
| 35 |
+
|
| 36 |
+
# Load the weights safely
|
| 37 |
+
# Note: If this fails, it means your file is still the old "full model" format.
|
| 38 |
+
# If so, re-run your training script to generate a clean state_dict.
|
| 39 |
+
try:
|
| 40 |
+
model.load_state_dict(t.load("backend/EMNIST_CNN.pth", map_location=device, weights_only=True))
|
| 41 |
+
except Exception as e:
|
| 42 |
+
print("State dict load failed, trying legacy full-load (not recommended for long term):", e)
|
| 43 |
+
model = t.load("backend/EMNIST_CNN.pth", map_location=device, weights_only=False)
|
| 44 |
+
|
| 45 |
+
model.eval()
|
| 46 |
+
|
| 47 |
+
app.mount("/static", StaticFiles(directory="frontend"), name="static")
|
| 48 |
+
|
| 49 |
+
@app.get("/")
|
| 50 |
+
async def read_index():
|
| 51 |
+
path = os.path.join("frontend", "index.html")
|
| 52 |
+
return FileResponse(path)
|
| 53 |
+
|
| 54 |
+
class PredictRequest(BaseModel):
|
| 55 |
+
image: list[float] # flat 28*28 array
|
| 56 |
+
k: int = 10 # number of top predictions to return
|
| 57 |
+
|
| 58 |
+
@app.post("/predict")
|
| 59 |
+
def predict(req: PredictRequest):
|
| 60 |
+
print(f"Predicting... +{1+1}")
|
| 61 |
+
# top_k currently set to 10 to preserve existing behavior
|
| 62 |
+
return predict_image(req.image, model, device, top_k=req.k)
|
backend/model.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
VGG-style CNN for EMNIST character classification.
|
| 3 |
+
See the README for a better description.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import torch
|
| 7 |
+
import torch.nn as nn
|
| 8 |
+
import torch.nn.functional as F
|
| 9 |
+
|
| 10 |
+
class ConvBlock(nn.Module):
|
| 11 |
+
"""Convolutional block: 2 conv layers, ReLU, MaxPool"""
|
| 12 |
+
def __init__(self, in_channels, out_channels, padding=1, pool_kernel=2, pool_stride=2):
|
| 13 |
+
super(ConvBlock, self).__init__()
|
| 14 |
+
self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, padding=padding)
|
| 15 |
+
self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, padding=padding)
|
| 16 |
+
self.pool = nn.MaxPool2d(kernel_size=pool_kernel, stride=pool_stride)
|
| 17 |
+
def forward(self, x):
|
| 18 |
+
x = F.relu(self.conv1(x))
|
| 19 |
+
x = F.relu(self.conv2(x))
|
| 20 |
+
x = self.pool(x)
|
| 21 |
+
return x
|
| 22 |
+
|
| 23 |
+
class EMNIST_VGG(nn.Module):
|
| 24 |
+
"""
|
| 25 |
+
The actual CNN that will be trained.
|
| 26 |
+
Brought to you by composition.
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
def __init__(self, num_classes=62):
|
| 30 |
+
super(EMNIST_VGG, self).__init__()
|
| 31 |
+
|
| 32 |
+
# The two blocks
|
| 33 |
+
self.conv1 = ConvBlock(in_channels=1, out_channels=32, pool_kernel=2, pool_stride=2)
|
| 34 |
+
self.bn1 = nn.BatchNorm2d(32)
|
| 35 |
+
self.conv2 = ConvBlock(in_channels=32, out_channels=64, pool_stride=2)
|
| 36 |
+
self.bn2 = nn.BatchNorm2d(64)
|
| 37 |
+
self.conv3 = ConvBlock(in_channels=64, out_channels=128, pool_stride=1)
|
| 38 |
+
self.bn3 = nn.BatchNorm2d(128)
|
| 39 |
+
self.conv4 = ConvBlock(in_channels=128, out_channels=256, pool_stride=1)
|
| 40 |
+
self.bn4 = nn.BatchNorm2d(256)
|
| 41 |
+
|
| 42 |
+
# Flatten layer (no parameters needed, only reshaping)
|
| 43 |
+
self.flatten = nn.Flatten()
|
| 44 |
+
|
| 45 |
+
# (Since the Dense layers just take flat inputs)
|
| 46 |
+
|
| 47 |
+
# Two fully-connected layers
|
| 48 |
+
|
| 49 |
+
# For the first layer, notice that, due to the stride and pool sizes, we need to adjust the input size to 256 * 5 * 5
|
| 50 |
+
self.fc1 = nn.Linear(256 * 5 * 5, 256)
|
| 51 |
+
self.dropout = nn.Dropout(p=0.5)
|
| 52 |
+
|
| 53 |
+
# Classifier
|
| 54 |
+
self.fc2 = nn.Linear(256, num_classes)
|
| 55 |
+
|
| 56 |
+
def forward(self, x):
|
| 57 |
+
x = self.conv1(x)
|
| 58 |
+
x = self.bn1(x)
|
| 59 |
+
x = self.conv2(x)
|
| 60 |
+
x = self.bn2(x)
|
| 61 |
+
x = self.conv3(x)
|
| 62 |
+
x = self.bn3(x)
|
| 63 |
+
x = self.conv4(x)
|
| 64 |
+
x = self.bn4(x)
|
| 65 |
+
x = self.flatten(x)
|
| 66 |
+
x = F.relu(self.fc1(x))
|
| 67 |
+
x = self.dropout(x)
|
| 68 |
+
x = self.fc2(x)
|
| 69 |
+
return x
|
backend/utils.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import torch as t
|
| 2 |
+
import numpy as np
|
| 3 |
+
|
| 4 |
+
MAX_CLASSES = 15
|
| 5 |
+
|
| 6 |
+
def predict_image(image: list[float], model, device, top_k: int = 10) -> dict:
|
| 7 |
+
# RESHAPE
|
| 8 |
+
# Input comes in as flat 784 list -> (1 batch, 1 channel, 28 height, 28 width)
|
| 9 |
+
x = t.tensor(image, dtype=t.float32).view(1, 1, 28, 28)
|
| 10 |
+
|
| 11 |
+
# Invert
|
| 12 |
+
x = 1.0 - x
|
| 13 |
+
# Match the loader.py normalization
|
| 14 |
+
x = (x - 0.1307) / 0.3081
|
| 15 |
+
|
| 16 |
+
# ROTATE FOR EMNIST
|
| 17 |
+
# The frontend sends an "Upright" image.
|
| 18 |
+
# EMNIST models are trained on (sideways) images. They're still supposed to recognize upright ones,
|
| 19 |
+
# but this pre-rotation helps.
|
| 20 |
+
# We flip the last two dimensions (Height and Width) to match the model's worldview.
|
| 21 |
+
x = x.transpose(-1, -2)
|
| 22 |
+
|
| 23 |
+
# Send to GPU if available
|
| 24 |
+
x = x.to(device)
|
| 25 |
+
|
| 26 |
+
# So glad I made this
|
| 27 |
+
# # --------------- DEBUG --------------- #
|
| 28 |
+
# # This prints the image to your SERVER TERMINAL so you can see what the model sees.
|
| 29 |
+
# print("\n------ INCOMING IMAGE ------")
|
| 30 |
+
# img_data = x.squeeze().cpu().numpy()
|
| 31 |
+
# for row in img_data:
|
| 32 |
+
# line = ""
|
| 33 |
+
# for pixel in row:
|
| 34 |
+
# # Use distinct chars for different intensity
|
| 35 |
+
# if pixel > 0.7: line += "@"
|
| 36 |
+
# elif pixel > 0.3: line += "."
|
| 37 |
+
# else: line += " "
|
| 38 |
+
# print(line)
|
| 39 |
+
# print("------------------------------\n")
|
| 40 |
+
# # ------------------------------------ #
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# Ensure top_k is an int within valid range
|
| 44 |
+
top_k = max(1, min(MAX_CLASSES, int(top_k)))
|
| 45 |
+
|
| 46 |
+
with t.no_grad():
|
| 47 |
+
logits = model(x)
|
| 48 |
+
probs = t.softmax(logits, dim=1)
|
| 49 |
+
topk = t.topk(probs, k=top_k)
|
| 50 |
+
|
| 51 |
+
label_map = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
| 52 |
+
|
| 53 |
+
# Move back to CPU for response processing
|
| 54 |
+
indices = topk.indices[0].cpu().numpy()
|
| 55 |
+
values = topk.values[0].cpu().numpy()
|
| 56 |
+
|
| 57 |
+
results = [{"char": label_map[i], "prob": float(p)} for i, p in zip(indices, values)]
|
| 58 |
+
|
| 59 |
+
# Debug print to see if the model is confident or guessing
|
| 60 |
+
print(f"Top prediction: {results[0]['char']} ({results[0]['prob']:.4f})")
|
| 61 |
+
|
| 62 |
+
return {"predictions": results}
|
frontend/helpers/Sanitizer.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const blockSize = 15; // 420 / 28 = 15
|
| 2 |
+
|
| 3 |
+
// Input Processor
|
| 4 |
+
|
| 5 |
+
function getEMNISTInput(binaryArray, canvasWidth = 420, canvasHeight = 420, blockSize = 15) {
|
| 6 |
+
const downsampled = new Float32Array(28 * 28);
|
| 7 |
+
|
| 8 |
+
for (let y = 0; y < 28; y++) {
|
| 9 |
+
for (let x = 0; x < 28; x++) {
|
| 10 |
+
let sum = 0;
|
| 11 |
+
for (let by = 0; by < blockSize; by++) {
|
| 12 |
+
for (let bx = 0; bx < blockSize; bx++) {
|
| 13 |
+
const ix = x * blockSize + bx;
|
| 14 |
+
const iy = y * blockSize + by;
|
| 15 |
+
// Assuming binaryArray is 1 for White (bg), 0 for Black (ink)
|
| 16 |
+
sum += binaryArray[iy * canvasWidth + ix];
|
| 17 |
+
}
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
// Calculate average brightness (0.0 to 1.0)
|
| 21 |
+
let avg = sum / (blockSize * blockSize);
|
| 22 |
+
|
| 23 |
+
// INVERT: Make background 0, Ink 1
|
| 24 |
+
// IMPORTANT: PRESERVE THE GRAYSCALE!
|
| 25 |
+
downsampled[y * 28 + x] = 1.0 - avg;
|
| 26 |
+
}
|
| 27 |
+
}
|
| 28 |
+
return Array.from(downsampled);
|
| 29 |
+
}
|
frontend/helpers/barGraphs.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function drawPredictionGraph(canvas, predictions) {
|
| 2 |
+
const ctx = canvas.getContext("2d");
|
| 3 |
+
const w = canvas.width;
|
| 4 |
+
const h = canvas.height;
|
| 5 |
+
const padding = 20;
|
| 6 |
+
const labelSpace = 40; // Space for character labels on left
|
| 7 |
+
const barSpace = w - padding * 2 - labelSpace - 60; // Max width for bars (leaving room for %)
|
| 8 |
+
|
| 9 |
+
// Clear and set white background
|
| 10 |
+
ctx.clearRect(0, 0, w, h);
|
| 11 |
+
ctx.fillStyle = "white";
|
| 12 |
+
ctx.fillRect(0, 0, w, h);
|
| 13 |
+
|
| 14 |
+
const barHeight = (h - 2 * padding) / predictions.length - 10;
|
| 15 |
+
|
| 16 |
+
predictions.forEach((pred, i) => {
|
| 17 |
+
const y = padding + i * (barHeight + 10);
|
| 18 |
+
const barWidth = Math.max(pred.prob * barSpace, 2); // Ensure tiny bars are visible
|
| 19 |
+
|
| 20 |
+
// 1. Draw Label (Character)
|
| 21 |
+
ctx.fillStyle = "black";
|
| 22 |
+
ctx.font = "bold 24px monospace";
|
| 23 |
+
ctx.textAlign = "center";
|
| 24 |
+
ctx.fillText(pred.char, padding + labelSpace / 2, y + barHeight / 1.5);
|
| 25 |
+
|
| 26 |
+
// 2. Draw Bar
|
| 27 |
+
ctx.fillStyle = pred.prob > 0.5 ? "black" : "#555"; // Darker for high confidence
|
| 28 |
+
ctx.fillRect(padding + labelSpace, y, barWidth, barHeight);
|
| 29 |
+
|
| 30 |
+
// 3. Draw Percentage
|
| 31 |
+
ctx.fillStyle = "#333";
|
| 32 |
+
ctx.font = "14px monospace";
|
| 33 |
+
ctx.textAlign = "left";
|
| 34 |
+
ctx.fillText((pred.prob * 100).toFixed(2) + "%", padding + labelSpace + barWidth + 5, y + barHeight / 1.6);
|
| 35 |
+
});
|
| 36 |
+
}
|
frontend/helpers/footer.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
function initFooter() {
|
| 2 |
+
const footer = document.createElement('footer');
|
| 3 |
+
footer.className = 'footer';
|
| 4 |
+
|
| 5 |
+
// ------------------------------------------------
|
| 6 |
+
// Left: Copyright
|
| 7 |
+
const copyright = document.createElement('div');
|
| 8 |
+
copyright.className = 'footer-left';
|
| 9 |
+
copyright.textContent = '© 2026 intelligent-username';
|
| 10 |
+
// ------------------------------------------------
|
| 11 |
+
|
| 12 |
+
// ------------------------------------------------
|
| 13 |
+
// Centre: Project info
|
| 14 |
+
const info = document.createElement('div');
|
| 15 |
+
info.className = 'footer-center';
|
| 16 |
+
info.textContent = 'This project currently supports all English lowercase and uppercase letters (a-z, A-Z), as well as all arabic numerals (0-9).';
|
| 17 |
+
// ------------------------------------------------
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
// ------------------------------------------------
|
| 21 |
+
// Right: GitHub link
|
| 22 |
+
|
| 23 |
+
const github = document.createElement('div');
|
| 24 |
+
github.className = 'footer-right';
|
| 25 |
+
const githubLink = document.createElement('a');
|
| 26 |
+
githubLink.href = 'https://github.com/intelligent-username/OCR';
|
| 27 |
+
githubLink.target = '_blank';
|
| 28 |
+
githubLink.rel = 'noopener noreferrer';
|
| 29 |
+
|
| 30 |
+
// GitHub logo
|
| 31 |
+
const logoSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
|
| 32 |
+
logoSvg.setAttribute('width', '20');
|
| 33 |
+
logoSvg.setAttribute('height', '20');
|
| 34 |
+
logoSvg.setAttribute('viewBox', '0 0 24 24');
|
| 35 |
+
logoSvg.setAttribute('fill', 'currentColor');
|
| 36 |
+
logoSvg.innerHTML = `<path d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v 3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"/>`; // This took too long just to get right, don't get rid :)
|
| 37 |
+
githubLink.appendChild(logoSvg);
|
| 38 |
+
|
| 39 |
+
// GitHub text
|
| 40 |
+
const githubText = document.createElement('span');
|
| 41 |
+
githubText.textContent = 'GitHub';
|
| 42 |
+
githubLink.appendChild(githubText);
|
| 43 |
+
|
| 44 |
+
github.appendChild(githubLink);
|
| 45 |
+
// ------------------------------------------------
|
| 46 |
+
|
| 47 |
+
// Assemble footer
|
| 48 |
+
footer.appendChild(copyright);
|
| 49 |
+
footer.appendChild(info);
|
| 50 |
+
footer.appendChild(github);
|
| 51 |
+
|
| 52 |
+
document.body.appendChild(footer);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
// Initialize footer when DOM is loaded
|
| 56 |
+
if (document.readyState === 'loading') {
|
| 57 |
+
document.addEventListener('DOMContentLoaded', initFooter);
|
| 58 |
+
} else {
|
| 59 |
+
initFooter();
|
| 60 |
+
}
|
frontend/helpers/kSlider.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
const slider = document.getElementById("k-slider");
|
| 2 |
+
const output = document.getElementById("k-value");
|
| 3 |
+
|
| 4 |
+
// Initialize w/ default
|
| 5 |
+
output.textContent = slider.value;
|
| 6 |
+
|
| 7 |
+
// Update number instantly on drag
|
| 8 |
+
slider.addEventListener("input", function() {
|
| 9 |
+
output.textContent = this.value;
|
| 10 |
+
});
|
frontend/index.html
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<title>EMNIST OCR Canvas</title>
|
| 6 |
+
<link rel="stylesheet" href="/static/style.css" />
|
| 7 |
+
</head>
|
| 8 |
+
<body>
|
| 9 |
+
<!-- Header / Banner -->
|
| 10 |
+
<header class="header">
|
| 11 |
+
|
| 12 |
+
<h1>Handwritten Optical Character Recognition</h1>
|
| 13 |
+
<p class="underline">
|
| 14 |
+
Draw a single character on the canvas below. The model will predict what it is
|
| 15 |
+
</p>
|
| 16 |
+
<p>
|
| 17 |
+
Use this to test out how recognition models work or to see how clear your handwriting is to a computer.
|
| 18 |
+
</p>
|
| 19 |
+
|
| 20 |
+
</header>
|
| 21 |
+
|
| 22 |
+
<!-- Main Layout -->
|
| 23 |
+
<main class="container">
|
| 24 |
+
<!-- Left: Drawing Canvas -->
|
| 25 |
+
<section class="drawing-section">
|
| 26 |
+
<h2>Draw Here</h2>
|
| 27 |
+
<canvas
|
| 28 |
+
id="draw-canvas"
|
| 29 |
+
width="420"
|
| 30 |
+
height="420"
|
| 31 |
+
style="width: 420px; height: 420px; border: 1px solid black;"
|
| 32 |
+
></canvas>
|
| 33 |
+
<div class="controls">
|
| 34 |
+
<button id="clear-btn">Clear</button>
|
| 35 |
+
</div>
|
| 36 |
+
</section>
|
| 37 |
+
|
| 38 |
+
<!-- Right side reserved for predictions-->
|
| 39 |
+
<section class="prediction-section">
|
| 40 |
+
<h2>Predictions</h2>
|
| 41 |
+
<canvas
|
| 42 |
+
id="prediction-canvas"
|
| 43 |
+
width="420"
|
| 44 |
+
height="420"
|
| 45 |
+
></canvas>
|
| 46 |
+
<div class="slider-container">
|
| 47 |
+
<input type="range" id="k-slider" min="1" max="15" step="1" value="10">
|
| 48 |
+
<div id="k-value">10</div>
|
| 49 |
+
</div>
|
| 50 |
+
</section>
|
| 51 |
+
</main>
|
| 52 |
+
|
| 53 |
+
<script src="/static/helpers/barGraphs.js"></script>
|
| 54 |
+
<script src="/static/helpers/Sanitizer.js"></script>
|
| 55 |
+
<script src="/static/helpers/kSlider.js"></script>
|
| 56 |
+
<script src="/static/helpers/footer.js"></script>
|
| 57 |
+
<script src="/static/main.js"></script>
|
| 58 |
+
|
| 59 |
+
</body>
|
| 60 |
+
</html>
|
frontend/main.js
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// main.js is just for putting everything together quickly and simply. Don't grow into a monolith or complicate the project.
|
| 2 |
+
|
| 3 |
+
const canvas = document.getElementById("draw-canvas");
|
| 4 |
+
// Use willReadFrequently to optimize repeated getImageData readbacks
|
| 5 |
+
const ctx = canvas.getContext("2d", { willReadFrequently: true });
|
| 6 |
+
|
| 7 |
+
const predCanvas = document.getElementById("prediction-canvas");
|
| 8 |
+
const predCtx = predCanvas.getContext("2d");
|
| 9 |
+
|
| 10 |
+
// Canvas drawing setup
|
| 11 |
+
ctx.lineWidth = 42;
|
| 12 |
+
ctx.lineCap = "round";
|
| 13 |
+
ctx.strokeStyle = "black";
|
| 14 |
+
|
| 15 |
+
// Track drawing state
|
| 16 |
+
let drawing = false;
|
| 17 |
+
let erase = false;
|
| 18 |
+
let lastLogTime = 0;
|
| 19 |
+
// Milliseconds between read/process cycles (tweakable)
|
| 20 |
+
let logGapMs = 250;
|
| 21 |
+
// Processing loop control
|
| 22 |
+
let processingLoopRunning = false;
|
| 23 |
+
// Store latest predictions
|
| 24 |
+
let latestPredictions = [];
|
| 25 |
+
|
| 26 |
+
// Function to redraw the graph with current k
|
| 27 |
+
function redrawGraph() {
|
| 28 |
+
const k = parseInt(document.getElementById("k-slider").value);
|
| 29 |
+
if (latestPredictions.length > 0) {
|
| 30 |
+
drawPredictionGraph(predCanvas, latestPredictions.slice(0, k));
|
| 31 |
+
}
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
// Function to perform prediction with current canvas content
|
| 35 |
+
function performPrediction() {
|
| 36 |
+
try {
|
| 37 |
+
// 1. Extract and Normalize (Don't Binarize!)
|
| 38 |
+
const inputArray = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
| 39 |
+
const alphaValues = Array.from(inputArray.data)
|
| 40 |
+
.filter((_, i) => i % 4 === 3) // Keep only Alpha
|
| 41 |
+
.map(a => a / 255.0); // Normalize 0-255 to 0.0-1.0
|
| 42 |
+
|
| 43 |
+
// 2. Downsample (Using the modified function below)
|
| 44 |
+
const temp = getEMNISTInput(alphaValues);
|
| 45 |
+
|
| 46 |
+
// console.log("Input Array: ", inputArray);
|
| 47 |
+
// console.log("Temp: ", temp);
|
| 48 |
+
|
| 49 |
+
// (Preview drawing removed — bar graph will visualize predictions)
|
| 50 |
+
|
| 51 |
+
// Send to server with error handling (non-blocking)
|
| 52 |
+
fetch("/predict", {
|
| 53 |
+
method: "POST",
|
| 54 |
+
headers: { "Content-Type": "application/json" },
|
| 55 |
+
body: JSON.stringify({ image: temp, k: 15 }),
|
| 56 |
+
})
|
| 57 |
+
.then(async response => {
|
| 58 |
+
if (!response.ok) {
|
| 59 |
+
const text = await response.text();
|
| 60 |
+
throw new Error(`Server error: ${response.status} ${response.statusText} - ${text}`);
|
| 61 |
+
}
|
| 62 |
+
return response.json();
|
| 63 |
+
})
|
| 64 |
+
.then(data => {
|
| 65 |
+
// console.log("Predictions:", data.predictions);
|
| 66 |
+
latestPredictions = data.predictions;
|
| 67 |
+
redrawGraph();
|
| 68 |
+
})
|
| 69 |
+
.catch(error => console.error("Error:", error));
|
| 70 |
+
} catch (err) {
|
| 71 |
+
console.error("Prediction error:", err);
|
| 72 |
+
}
|
| 73 |
+
}
|
| 74 |
+
|
| 75 |
+
// Prevent context menu on right click
|
| 76 |
+
canvas.addEventListener("contextmenu", (e) => {
|
| 77 |
+
e.preventDefault();
|
| 78 |
+
});
|
| 79 |
+
|
| 80 |
+
canvas.addEventListener("mousedown", (e) => {
|
| 81 |
+
drawing = true;
|
| 82 |
+
const rect = canvas.getBoundingClientRect();
|
| 83 |
+
const x = e.clientX - rect.left;
|
| 84 |
+
const y = e.clientY - rect.top;
|
| 85 |
+
ctx.beginPath();
|
| 86 |
+
ctx.moveTo(x, y);
|
| 87 |
+
|
| 88 |
+
if (e.button === 2) { // Right click
|
| 89 |
+
erase = true;
|
| 90 |
+
ctx.strokeStyle = "white";
|
| 91 |
+
} else {
|
| 92 |
+
erase = false;
|
| 93 |
+
ctx.strokeStyle = "black";
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
// Start the processing loop if not already running
|
| 97 |
+
if (!processingLoopRunning) {
|
| 98 |
+
processingLoopRunning = true;
|
| 99 |
+
requestAnimationFrame(processingLoop);
|
| 100 |
+
}
|
| 101 |
+
});
|
| 102 |
+
|
| 103 |
+
canvas.addEventListener("mouseup", () => {
|
| 104 |
+
drawing = false;
|
| 105 |
+
erase = false;
|
| 106 |
+
ctx.strokeStyle = "black";
|
| 107 |
+
});
|
| 108 |
+
|
| 109 |
+
// Processing loop runs while `drawing` is true and throttles work via `logGapMs`.
|
| 110 |
+
function processingLoop() {
|
| 111 |
+
if (!drawing) {
|
| 112 |
+
processingLoopRunning = false;
|
| 113 |
+
return;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
const now = Date.now();
|
| 117 |
+
if (now - lastLogTime >= logGapMs) {
|
| 118 |
+
lastLogTime = now;
|
| 119 |
+
|
| 120 |
+
// Schedule after paint
|
| 121 |
+
requestAnimationFrame(() => {
|
| 122 |
+
performPrediction();
|
| 123 |
+
});
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
requestAnimationFrame(processingLoop);
|
| 127 |
+
}
|
| 128 |
+
canvas.addEventListener("mouseleave", () => {
|
| 129 |
+
drawing = false;
|
| 130 |
+
erase = false;
|
| 131 |
+
ctx.strokeStyle = "black";
|
| 132 |
+
});
|
| 133 |
+
canvas.addEventListener("mousemove", draw);
|
| 134 |
+
|
| 135 |
+
canvas.addEventListener("touchstart", (e) => {
|
| 136 |
+
e.preventDefault();
|
| 137 |
+
drawing = true;
|
| 138 |
+
const touch = e.touches[0];
|
| 139 |
+
const rect = canvas.getBoundingClientRect();
|
| 140 |
+
ctx.beginPath();
|
| 141 |
+
ctx.moveTo(touch.clientX - rect.left, touch.clientY - rect.top);
|
| 142 |
+
|
| 143 |
+
// Start the processing loop if not already running
|
| 144 |
+
if (!processingLoopRunning) {
|
| 145 |
+
processingLoopRunning = true;
|
| 146 |
+
requestAnimationFrame(processingLoop);
|
| 147 |
+
}
|
| 148 |
+
}, { passive: false });
|
| 149 |
+
|
| 150 |
+
canvas.addEventListener("touchmove", (e) => {
|
| 151 |
+
e.preventDefault();
|
| 152 |
+
if (!drawing) return;
|
| 153 |
+
const touch = e.touches[0];
|
| 154 |
+
const rect = canvas.getBoundingClientRect();
|
| 155 |
+
ctx.lineTo(touch.clientX - rect.left, touch.clientY - rect.top);
|
| 156 |
+
ctx.stroke();
|
| 157 |
+
}, { passive: false });
|
| 158 |
+
|
| 159 |
+
canvas.addEventListener("touchend", (e) => {
|
| 160 |
+
drawing = false;
|
| 161 |
+
}, { passive: false });
|
| 162 |
+
|
| 163 |
+
// Draw function for mouse
|
| 164 |
+
function draw(e) {
|
| 165 |
+
if (!drawing) return;
|
| 166 |
+
const rect = canvas.getBoundingClientRect();
|
| 167 |
+
const x = e.clientX - rect.left;
|
| 168 |
+
const y = e.clientY - rect.top;
|
| 169 |
+
ctx.lineTo(x, y);
|
| 170 |
+
ctx.stroke();
|
| 171 |
+
|
| 172 |
+
// (Processing loop now handles getImageData and updates; draw() stays lightweight)
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
// Clear button
|
| 176 |
+
document.getElementById("clear-btn").addEventListener("click", () => {
|
| 177 |
+
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
| 178 |
+
// Clear and reset prediction canvas to white so it's visually clear
|
| 179 |
+
predCtx.clearRect(0, 0, predCanvas.width, predCanvas.height);
|
| 180 |
+
predCtx.fillStyle = 'white';
|
| 181 |
+
predCtx.fillRect(0, 0, predCanvas.width, predCanvas.height);
|
| 182 |
+
latestPredictions = [];
|
| 183 |
+
});
|
| 184 |
+
|
| 185 |
+
// Slider change listener to redraw graph
|
| 186 |
+
document.getElementById("k-slider").addEventListener("input", redrawGraph);
|
frontend/style.css
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
body {
|
| 2 |
+
font-family: 'Consolas', 'Courier New', monospace;
|
| 3 |
+
margin: 0;
|
| 4 |
+
padding: 0;
|
| 5 |
+
background-color: #f5f5f5;
|
| 6 |
+
color: #333;
|
| 7 |
+
min-height: 100vh;
|
| 8 |
+
display: flex;
|
| 9 |
+
flex-direction: column;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
.header {
|
| 13 |
+
background-color: #ffffff;
|
| 14 |
+
text-align: center;
|
| 15 |
+
padding: 20px;
|
| 16 |
+
border-bottom: 1px solid #e0e0e0;
|
| 17 |
+
margin-bottom: 20px;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
.header h1 {
|
| 21 |
+
margin: 0;
|
| 22 |
+
font-size: 2.5em;
|
| 23 |
+
color: #333;
|
| 24 |
+
}
|
| 25 |
+
|
| 26 |
+
.header p {
|
| 27 |
+
margin: 10px 0 0 0;
|
| 28 |
+
font-size: 1.1em;
|
| 29 |
+
color: #666;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.container {
|
| 33 |
+
display: flex;
|
| 34 |
+
justify-content: center;
|
| 35 |
+
align-items: flex-start;
|
| 36 |
+
gap: 40px;
|
| 37 |
+
padding: 20px;
|
| 38 |
+
flex-grow: 1;
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
.drawing-section, .prediction-section {
|
| 42 |
+
background-color: #ffffff;
|
| 43 |
+
border: 1px solid #e0e0e0;
|
| 44 |
+
border-radius: 8px;
|
| 45 |
+
padding: 20px;
|
| 46 |
+
min-width: 300px;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.drawing-section h2 {
|
| 50 |
+
text-align: center;
|
| 51 |
+
margin-top: 0;
|
| 52 |
+
color: #333;
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
#draw-canvas {
|
| 56 |
+
display: block;
|
| 57 |
+
margin: 0 auto 20px;
|
| 58 |
+
border: 1px solid #ccc;
|
| 59 |
+
border-radius: 4px;
|
| 60 |
+
background-color: white;
|
| 61 |
+
cursor: crosshair;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.controls {
|
| 65 |
+
text-align: center;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
#clear-btn {
|
| 69 |
+
background-color: #777;
|
| 70 |
+
color: white;
|
| 71 |
+
border: none;
|
| 72 |
+
padding: 10px 20px;
|
| 73 |
+
border-radius: 4px;
|
| 74 |
+
font-size: 1em;
|
| 75 |
+
cursor: pointer;
|
| 76 |
+
transition: background-color 0.3s;
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
#clear-btn:hover {
|
| 80 |
+
background-color: #555;
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
.prediction-section h2 {
|
| 84 |
+
text-align: center;
|
| 85 |
+
margin-top: 0;
|
| 86 |
+
color: #292929;
|
| 87 |
+
}
|
| 88 |
+
|
| 89 |
+
#prediction-canvas {
|
| 90 |
+
display: block;
|
| 91 |
+
margin: 0 auto;
|
| 92 |
+
border: 1px solid #ccc;
|
| 93 |
+
border-radius: 4px;
|
| 94 |
+
background-color: white;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
@media (max-width: 768px) {
|
| 98 |
+
.container {
|
| 99 |
+
flex-direction: column;
|
| 100 |
+
align-items: center;
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
.drawing-section, .prediction-section {
|
| 104 |
+
min-width: 280px;
|
| 105 |
+
}
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
.slider-container {
|
| 109 |
+
width: 100%; /* Occupy full width of parent */
|
| 110 |
+
text-align: center;
|
| 111 |
+
padding: 10px 0;
|
| 112 |
+
}
|
| 113 |
+
|
| 114 |
+
#k-slider {
|
| 115 |
+
/* -webkit-appearance: none; */
|
| 116 |
+
width: 100%;
|
| 117 |
+
height: 4px;
|
| 118 |
+
background: #e0e0e0;
|
| 119 |
+
border-radius: 2px;
|
| 120 |
+
outline: none;
|
| 121 |
+
margin: 0;
|
| 122 |
+
padding: 0;
|
| 123 |
+
cursor: pointer;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/* Chrome/Safari/Edge Thumb */
|
| 127 |
+
#k-slider::-webkit-slider-thumb {
|
| 128 |
+
-webkit-appearance: none;
|
| 129 |
+
width: 16px;
|
| 130 |
+
height: 16px;
|
| 131 |
+
background: black;
|
| 132 |
+
border-radius: 50%;
|
| 133 |
+
cursor: pointer;
|
| 134 |
+
transition: transform 0.1s;
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
#k-slider::-webkit-slider-thumb:hover {
|
| 138 |
+
transform: scale(1.2);
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
/* Firefox Thumb */
|
| 142 |
+
#k-slider::-moz-range-thumb {
|
| 143 |
+
width: 16px;
|
| 144 |
+
height: 16px;
|
| 145 |
+
background: black;
|
| 146 |
+
border: none;
|
| 147 |
+
border-radius: 50%;
|
| 148 |
+
cursor: pointer;
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
#k-value {
|
| 152 |
+
font-family: monospace;
|
| 153 |
+
font-size: 14px;
|
| 154 |
+
margin-top: 8px;
|
| 155 |
+
color: #333;
|
| 156 |
+
}
|
| 157 |
+
/* Footer Styles */
|
| 158 |
+
footer.footer {
|
| 159 |
+
background-color: #ffffff;
|
| 160 |
+
border-top: 1px solid #e0e0e0;
|
| 161 |
+
padding: 20px;
|
| 162 |
+
margin-top: 40px;
|
| 163 |
+
display: flex;
|
| 164 |
+
justify-content: space-between;
|
| 165 |
+
align-items: center;
|
| 166 |
+
gap: 20px;
|
| 167 |
+
font-size: 0.9em;
|
| 168 |
+
color: #666;
|
| 169 |
+
flex-wrap: wrap;
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
.footer-left {
|
| 173 |
+
flex: 1;
|
| 174 |
+
min-width: 150px;
|
| 175 |
+
}
|
| 176 |
+
|
| 177 |
+
.footer-center {
|
| 178 |
+
flex: 2;
|
| 179 |
+
text-align: center;
|
| 180 |
+
min-width: 300px;
|
| 181 |
+
}
|
| 182 |
+
|
| 183 |
+
.footer-right {
|
| 184 |
+
flex: 1;
|
| 185 |
+
text-align: right;
|
| 186 |
+
min-width: 120px;
|
| 187 |
+
}
|
| 188 |
+
|
| 189 |
+
.footer-right a {
|
| 190 |
+
display: inline-flex;
|
| 191 |
+
align-items: center;
|
| 192 |
+
gap: 8px;
|
| 193 |
+
color: #333;
|
| 194 |
+
text-decoration: none;
|
| 195 |
+
transition: color 0.3s ease;
|
| 196 |
+
font-weight: 500;
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
.footer-right a:hover {
|
| 200 |
+
color: #0969da;
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
.footer-right svg {
|
| 204 |
+
transition: fill 0.3s ease;
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
.footer-right a:hover svg {
|
| 208 |
+
fill: #0969da;
|
| 209 |
+
}
|
requirements.txt
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi
|
| 2 |
+
uvicorn
|
| 3 |
+
aiofiles
|
| 4 |
+
torch --index-url https://download.pytorch.org/whl/cpu
|
| 5 |
+
torchvision --index-url https://download.pytorch.org/whl/cpu
|
| 6 |
+
numpy
|
| 7 |
+
pydantic
|