compendious commited on
Commit
a5a80dd
·
0 Parent(s):

Initial commit. Clean .git history. No binaries. Nothin

Browse files
.github/README.md ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Optical Character Recognition
2
+
3
+ ![Demonstration](../imgs/demo.png)
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
+ &copy; 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
+ ![Demonstration](imgs/demo.png)
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