Spaces:
Sleeping
Sleeping
Aniruddh commited on
Commit ·
8608e55
0
Parent(s):
clean new branch
Browse files- .dockerignore +35 -0
- .gitattributes +6 -0
- .gitignore +35 -0
- .idea/.gitignore +8 -0
- .idea/inspectionProfiles/Project_Default.xml +21 -0
- .idea/inspectionProfiles/profiles_settings.xml +6 -0
- Dockerfile +52 -0
- Readme.md +9 -0
- app/.gitignore +24 -0
- app/README.md +12 -0
- app/eslint.config.js +29 -0
- app/index.html +13 -0
- app/package-lock.json +0 -0
- app/package.json +29 -0
- app/public/vite.svg +1 -0
- app/src/App.jsx +115 -0
- app/src/assets/react.svg +1 -0
- app/src/components/ImageCanvas.jsx +147 -0
- app/src/components/ImageUploader.jsx +29 -0
- app/src/components/Loginform.jsx +77 -0
- app/src/components/Page.jsx +160 -0
- app/src/components/Popup.jsx +170 -0
- app/src/components/ShapeOverlay.jsx +67 -0
- app/src/components/ZoomControls.jsx +11 -0
- app/src/hooks/useLogout.jsx +31 -0
- app/src/hooks/useZoom.jsx +52 -0
- app/src/hooks/useautoLogout.jsx +46 -0
- app/src/index.css +68 -0
- app/src/main.jsx +10 -0
- app/src/styles/App.css +95 -0
- app/src/styles/LoginForm.css +53 -0
- app/src/styles/Zoom.css +30 -0
- app/vite.config.js +7 -0
- backend/__init__.py +0 -0
- backend/app.py +58 -0
- backend/requirements.txt +59 -0
- backend/routes/auth.py +161 -0
- backend/routes/detect.py +209 -0
- backend/routes/llm.py +82 -0
- backend/routes/regions_detect.py +105 -0
- backend/sessions.db +0 -0
.dockerignore
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Node & Python cache
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.pyc
|
| 4 |
+
*.pyo
|
| 5 |
+
*.pyd
|
| 6 |
+
*.db
|
| 7 |
+
*.sqlite3
|
| 8 |
+
*.log
|
| 9 |
+
*.pid
|
| 10 |
+
|
| 11 |
+
# Virtual envs
|
| 12 |
+
venv/
|
| 13 |
+
env/
|
| 14 |
+
.venv/
|
| 15 |
+
|
| 16 |
+
# Node modules
|
| 17 |
+
node_modules/
|
| 18 |
+
app/node_modules/
|
| 19 |
+
|
| 20 |
+
# Git & local config
|
| 21 |
+
.git/
|
| 22 |
+
.gitignore
|
| 23 |
+
.gitattributes
|
| 24 |
+
.lfsconfig
|
| 25 |
+
|
| 26 |
+
# OS / IDE files
|
| 27 |
+
.DS_Store
|
| 28 |
+
Thumbs.db
|
| 29 |
+
.vscode/
|
| 30 |
+
.idea/
|
| 31 |
+
*.swp
|
| 32 |
+
*.swo
|
| 33 |
+
|
| 34 |
+
# HF build logs
|
| 35 |
+
output.log
|
.gitattributes
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Auto detect text files and perform LF normalization
|
| 2 |
+
* text=auto
|
| 3 |
+
*.pptx filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.jpg filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.jpeg filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
# Frontend (Vite in app/)
|
| 3 |
+
app/node_modules/
|
| 4 |
+
app/dist/
|
| 5 |
+
app/.vite/
|
| 6 |
+
app/.env
|
| 7 |
+
app/.env.local
|
| 8 |
+
app/.env.*.local
|
| 9 |
+
|
| 10 |
+
# Backend
|
| 11 |
+
backend/.env
|
| 12 |
+
backend/__pycache__/
|
| 13 |
+
backend/*.pyc
|
| 14 |
+
|
| 15 |
+
stem/
|
| 16 |
+
|
| 17 |
+
.venv
|
| 18 |
+
|
| 19 |
+
.DS_Store
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# Logs
|
| 24 |
+
|
| 25 |
+
logs
|
| 26 |
+
*.log
|
| 27 |
+
npm-debug.log*
|
| 28 |
+
yarn-debug.log*
|
| 29 |
+
yarn-error.log*
|
| 30 |
+
pnpm-debug.log*
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
Images/
|
| 34 |
+
app/public/images/
|
| 35 |
+
*.pptx
|
.idea/.gitignore
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Default ignored files
|
| 2 |
+
/shelf/
|
| 3 |
+
/workspace.xml
|
| 4 |
+
# Editor-based HTTP Client requests
|
| 5 |
+
/httpRequests/
|
| 6 |
+
# Datasource local storage ignored files
|
| 7 |
+
/dataSources/
|
| 8 |
+
/dataSources.local.xml
|
.idea/inspectionProfiles/Project_Default.xml
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<component name="InspectionProjectProfileManager">
|
| 2 |
+
<profile version="1.0">
|
| 3 |
+
<option name="myName" value="Project Default" />
|
| 4 |
+
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
| 5 |
+
<inspection_tool class="PyPep8NamingInspection" enabled="true" level="WEAK WARNING" enabled_by_default="true">
|
| 6 |
+
<option name="ignoredErrors">
|
| 7 |
+
<list>
|
| 8 |
+
<option value="N806" />
|
| 9 |
+
<option value="N802" />
|
| 10 |
+
</list>
|
| 11 |
+
</option>
|
| 12 |
+
</inspection_tool>
|
| 13 |
+
<inspection_tool class="PyStubPackagesAdvertiser" enabled="true" level="WARNING" enabled_by_default="true">
|
| 14 |
+
<option name="ignoredPackages">
|
| 15 |
+
<list>
|
| 16 |
+
<option value="pandas-stubs==2.3.2.250827" />
|
| 17 |
+
</list>
|
| 18 |
+
</option>
|
| 19 |
+
</inspection_tool>
|
| 20 |
+
</profile>
|
| 21 |
+
</component>
|
.idea/inspectionProfiles/profiles_settings.xml
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<component name="InspectionProjectProfileManager">
|
| 2 |
+
<settings>
|
| 3 |
+
<option name="USE_PROJECT_PROFILE" value="false" />
|
| 4 |
+
<version value="1.0" />
|
| 5 |
+
</settings>
|
| 6 |
+
</component>
|
Dockerfile
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ---------- Stage 1: build React (under app/) ----------
|
| 2 |
+
FROM node:20-alpine AS fe
|
| 3 |
+
WORKDIR /fe
|
| 4 |
+
|
| 5 |
+
# Copy manifests explicitly from app/
|
| 6 |
+
COPY app/package.json ./
|
| 7 |
+
COPY app/package-lock.json ./
|
| 8 |
+
|
| 9 |
+
# Debug: verify files arrived
|
| 10 |
+
RUN echo "LIST /fe after copying manifests:" && ls -la
|
| 11 |
+
|
| 12 |
+
# Use lockfile if present; otherwise fallback to npm install
|
| 13 |
+
RUN if [ -f package-lock.json ]; then npm ci --no-audit; else npm install; fi
|
| 14 |
+
|
| 15 |
+
# Copy the rest of the frontend source
|
| 16 |
+
COPY app/ .
|
| 17 |
+
|
| 18 |
+
# Build Vite app -> /fe/dist
|
| 19 |
+
RUN npm run build
|
| 20 |
+
RUN echo "LIST /fe/dist after build:" && ls -la /fe/dist
|
| 21 |
+
|
| 22 |
+
# ---------- Stage 2: FastAPI runtime ----------
|
| 23 |
+
FROM python:3.10-slim
|
| 24 |
+
|
| 25 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 26 |
+
PYTHONUNBUFFERED=1
|
| 27 |
+
|
| 28 |
+
# System libs for OpenCV/EasyOCR
|
| 29 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 30 |
+
build-essential libgl1 libglib2.0-0 ca-certificates \
|
| 31 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 32 |
+
|
| 33 |
+
WORKDIR /app
|
| 34 |
+
|
| 35 |
+
# Copy backend code
|
| 36 |
+
COPY backend/ /app/backend/
|
| 37 |
+
|
| 38 |
+
# Copy built frontend into the folder FastAPI serves
|
| 39 |
+
COPY --from=fe /fe/dist/ /app/backend/frontend/
|
| 40 |
+
|
| 41 |
+
# Python deps (Torch CPU first)
|
| 42 |
+
RUN pip install --no-cache-dir --upgrade pip \
|
| 43 |
+
&& pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu \
|
| 44 |
+
&& pip install --no-cache-dir -r backend/requirements.txt \
|
| 45 |
+
&& pip install --no-cache-dir uvicorn==0.30.1
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
# Hugging Face port + persistent DB path
|
| 49 |
+
ENV PORT=7860
|
| 50 |
+
ENV DB_PATH=/data/sessions.db
|
| 51 |
+
|
| 52 |
+
CMD ["uvicorn", "backend.app:app", "--host", "0.0.0.0", "--port", "7860"]
|
Readme.md
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Gen AI for STEM Education
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_file: Dockerfile
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
app/.gitignore
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Logs
|
| 2 |
+
logs
|
| 3 |
+
*.log
|
| 4 |
+
npm-debug.log*
|
| 5 |
+
yarn-debug.log*
|
| 6 |
+
yarn-error.log*
|
| 7 |
+
pnpm-debug.log*
|
| 8 |
+
lerna-debug.log*
|
| 9 |
+
|
| 10 |
+
node_modules
|
| 11 |
+
dist
|
| 12 |
+
dist-ssr
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Editor directories and files
|
| 16 |
+
.vscode/*
|
| 17 |
+
!.vscode/extensions.json
|
| 18 |
+
.idea
|
| 19 |
+
.DS_Store
|
| 20 |
+
*.suo
|
| 21 |
+
*.ntvs*
|
| 22 |
+
*.njsproj
|
| 23 |
+
*.sln
|
| 24 |
+
*.sw?
|
app/README.md
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# React + Vite
|
| 2 |
+
|
| 3 |
+
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
| 4 |
+
|
| 5 |
+
Currently, two official plugins are available:
|
| 6 |
+
|
| 7 |
+
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh
|
| 8 |
+
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
| 9 |
+
|
| 10 |
+
## Expanding the ESLint configuration
|
| 11 |
+
|
| 12 |
+
If you are developing a production application, we recommend using TypeScript with type-aware lint rules enabled. Check out the [TS template](https://github.com/vitejs/vite/tree/main/packages/create-vite/template-react-ts) for information on how to integrate TypeScript and [`typescript-eslint`](https://typescript-eslint.io) in your project.
|
app/eslint.config.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import js from '@eslint/js'
|
| 2 |
+
import globals from 'globals'
|
| 3 |
+
import reactHooks from 'eslint-plugin-react-hooks'
|
| 4 |
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
| 5 |
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
| 6 |
+
|
| 7 |
+
export default defineConfig([
|
| 8 |
+
globalIgnores(['dist']),
|
| 9 |
+
{
|
| 10 |
+
files: ['**/*.{js,jsx}'],
|
| 11 |
+
extends: [
|
| 12 |
+
js.configs.recommended,
|
| 13 |
+
reactHooks.configs['recommended-latest'],
|
| 14 |
+
reactRefresh.configs.vite,
|
| 15 |
+
],
|
| 16 |
+
languageOptions: {
|
| 17 |
+
ecmaVersion: 2020,
|
| 18 |
+
globals: globals.browser,
|
| 19 |
+
parserOptions: {
|
| 20 |
+
ecmaVersion: 'latest',
|
| 21 |
+
ecmaFeatures: { jsx: true },
|
| 22 |
+
sourceType: 'module',
|
| 23 |
+
},
|
| 24 |
+
},
|
| 25 |
+
rules: {
|
| 26 |
+
'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }],
|
| 27 |
+
},
|
| 28 |
+
},
|
| 29 |
+
])
|
app/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
| 6 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<title>Vite + React</title>
|
| 8 |
+
</head>
|
| 9 |
+
<body>
|
| 10 |
+
<div id="root"></div>
|
| 11 |
+
<script type="module" src="/src/main.jsx"></script>
|
| 12 |
+
</body>
|
| 13 |
+
</html>
|
app/package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
app/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "app",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "0.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "vite",
|
| 8 |
+
"build": "vite build",
|
| 9 |
+
"lint": "eslint .",
|
| 10 |
+
"preview": "vite preview"
|
| 11 |
+
},
|
| 12 |
+
"dependencies": {
|
| 13 |
+
"axios": "^1.11.0",
|
| 14 |
+
"react": "^19.1.1",
|
| 15 |
+
"react-dom": "^19.1.1",
|
| 16 |
+
"react-router-dom": "^7.9.1"
|
| 17 |
+
},
|
| 18 |
+
"devDependencies": {
|
| 19 |
+
"@eslint/js": "^9.33.0",
|
| 20 |
+
"@types/react": "^19.1.10",
|
| 21 |
+
"@types/react-dom": "^19.1.7",
|
| 22 |
+
"@vitejs/plugin-react": "^5.0.0",
|
| 23 |
+
"eslint": "^9.33.0",
|
| 24 |
+
"eslint-plugin-react-hooks": "^5.2.0",
|
| 25 |
+
"eslint-plugin-react-refresh": "^0.4.20",
|
| 26 |
+
"globals": "^16.3.0",
|
| 27 |
+
"vite": "^7.1.2"
|
| 28 |
+
}
|
| 29 |
+
}
|
app/public/vite.svg
ADDED
|
|
app/src/App.jsx
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef } from "react";
|
| 2 |
+
import { BrowserRouter, Routes, Route } from "react-router-dom";
|
| 3 |
+
import ImageUploader from "./components/ImageUploader";
|
| 4 |
+
import ImageCanvas from "./components/ImageCanvas";
|
| 5 |
+
import LoginForm from "./components/Loginform";
|
| 6 |
+
import Page from "./components/Page";
|
| 7 |
+
import useLogout from "./hooks/useLogout";
|
| 8 |
+
import useautoLogout from "./hooks/useautoLogout";
|
| 9 |
+
import "./styles/App.css";
|
| 10 |
+
|
| 11 |
+
function MainPage() {
|
| 12 |
+
const [imageUrl, setImageUrl] = useState(null);
|
| 13 |
+
const [rawCircles, setRawCircles] = useState([]);
|
| 14 |
+
const [rawTexts, setRawTexts] = useState([]);
|
| 15 |
+
const [circles, setCircles] = useState([]);
|
| 16 |
+
const [texts, setTexts] = useState([]);
|
| 17 |
+
const [selectedShape, setSelectedShape] = useState(null);
|
| 18 |
+
const [loaded, setLoaded] = useState(false);
|
| 19 |
+
const [error, setError] = useState(null);
|
| 20 |
+
const [imageInfo, setImageInfo] = useState(null);
|
| 21 |
+
|
| 22 |
+
const [user, setUser] = useState(null);
|
| 23 |
+
const [sessionId, setSessionId] = useState(null);
|
| 24 |
+
|
| 25 |
+
const imgRef = useRef(null);
|
| 26 |
+
|
| 27 |
+
// Shared logout hook
|
| 28 |
+
const handleLogout = useLogout(sessionId, setUser, setSessionId, setImageUrl);
|
| 29 |
+
|
| 30 |
+
// Auto logout (inactivity + tab close)
|
| 31 |
+
useautoLogout(sessionId, handleLogout, 10 * 1000);
|
| 32 |
+
|
| 33 |
+
return (
|
| 34 |
+
<div className="container">
|
| 35 |
+
<h1 className="heading">Generative AI for Stem Education</h1>
|
| 36 |
+
|
| 37 |
+
{!user ? (
|
| 38 |
+
<LoginForm setUser={setUser} setSessionId={setSessionId} />
|
| 39 |
+
) : (
|
| 40 |
+
<>
|
| 41 |
+
<ImageUploader
|
| 42 |
+
setImageUrl={setImageUrl}
|
| 43 |
+
resetStates={() => {
|
| 44 |
+
setLoaded(false);
|
| 45 |
+
setError(null);
|
| 46 |
+
setRawCircles([]);
|
| 47 |
+
setRawTexts([]);
|
| 48 |
+
setCircles([]);
|
| 49 |
+
setTexts([]);
|
| 50 |
+
setSelectedShape(null);
|
| 51 |
+
}}
|
| 52 |
+
/>
|
| 53 |
+
|
| 54 |
+
{error && <div className="error">{error}</div>}
|
| 55 |
+
|
| 56 |
+
{imageUrl && (
|
| 57 |
+
<ImageCanvas
|
| 58 |
+
imageUrl={imageUrl}
|
| 59 |
+
imgRef={imgRef}
|
| 60 |
+
setLoaded={setLoaded}
|
| 61 |
+
setError={setError}
|
| 62 |
+
setImageInfo={setImageInfo}
|
| 63 |
+
setCircles={setCircles}
|
| 64 |
+
setTexts={setTexts}
|
| 65 |
+
setRawCircles={setRawCircles}
|
| 66 |
+
setRawTexts={setRawTexts}
|
| 67 |
+
loaded={loaded}
|
| 68 |
+
imageInfo={imageInfo}
|
| 69 |
+
circles={circles}
|
| 70 |
+
texts={texts}
|
| 71 |
+
setSelectedShape={setSelectedShape}
|
| 72 |
+
selectedShape={selectedShape}
|
| 73 |
+
sessionId={sessionId}
|
| 74 |
+
/>
|
| 75 |
+
)}
|
| 76 |
+
|
| 77 |
+
{selectedShape && (
|
| 78 |
+
<>
|
| 79 |
+
<div
|
| 80 |
+
style={{
|
| 81 |
+
position: "fixed",
|
| 82 |
+
top: 0,
|
| 83 |
+
left: 0,
|
| 84 |
+
width: "100%",
|
| 85 |
+
height: "100%",
|
| 86 |
+
zIndex: 999,
|
| 87 |
+
}}
|
| 88 |
+
onClick={() => setSelectedShape(null)}
|
| 89 |
+
/>
|
| 90 |
+
</>
|
| 91 |
+
)}
|
| 92 |
+
</>
|
| 93 |
+
)}
|
| 94 |
+
|
| 95 |
+
{user && (
|
| 96 |
+
<button onClick={handleLogout} className="logout-button">
|
| 97 |
+
Logout
|
| 98 |
+
</button>
|
| 99 |
+
)}
|
| 100 |
+
</div>
|
| 101 |
+
);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
function App() {
|
| 105 |
+
return (
|
| 106 |
+
<BrowserRouter>
|
| 107 |
+
<Routes>
|
| 108 |
+
<Route path="/" element={<MainPage />} />
|
| 109 |
+
<Route path="/page" element={<Page />} />
|
| 110 |
+
</Routes>
|
| 111 |
+
</BrowserRouter>
|
| 112 |
+
);
|
| 113 |
+
}
|
| 114 |
+
|
| 115 |
+
export default App;
|
app/src/assets/react.svg
ADDED
|
|
app/src/components/ImageCanvas.jsx
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ImageCanvas.jsx
|
| 3 |
+
*
|
| 4 |
+
* This component is responsible for:
|
| 5 |
+
* - Displaying an uploaded image
|
| 6 |
+
* - Sending the image to a backend for text/shape detection
|
| 7 |
+
* - Scaling the returned coordinates to match the displayed image
|
| 8 |
+
* - Rendering overlays for detected circles/texts
|
| 9 |
+
* - Allowing zoom in/out (via buttons and scroll wheel)
|
| 10 |
+
* - Showing a popup when a shape is selected
|
| 11 |
+
*/
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
import React, { useRef } from "react";
|
| 15 |
+
import ShapeOverlay from "./ShapeOverlay";
|
| 16 |
+
import Popup from "./Popup";
|
| 17 |
+
import ZoomControls from "./ZoomControls";
|
| 18 |
+
import useZoom from "../hooks/useZoom";
|
| 19 |
+
|
| 20 |
+
function ImageCanvas({
|
| 21 |
+
imageUrl,
|
| 22 |
+
imgRef,
|
| 23 |
+
setLoaded,
|
| 24 |
+
setError,
|
| 25 |
+
setImageInfo,
|
| 26 |
+
setCircles,
|
| 27 |
+
setTexts,
|
| 28 |
+
setRawCircles,
|
| 29 |
+
setRawTexts,
|
| 30 |
+
loaded,
|
| 31 |
+
imageInfo,
|
| 32 |
+
circles,
|
| 33 |
+
texts,
|
| 34 |
+
setSelectedShape,
|
| 35 |
+
selectedShape,
|
| 36 |
+
}) {
|
| 37 |
+
const wrapperRef = useRef(null);
|
| 38 |
+
const { zoom, zoomIn, zoomOut, handleWheel } = useZoom({ min: 1, max: 3, step: 0.25 });
|
| 39 |
+
|
| 40 |
+
const handleImageLoad = async () => {
|
| 41 |
+
if (!imgRef.current) return;
|
| 42 |
+
|
| 43 |
+
const info = {
|
| 44 |
+
naturalWidth: imgRef.current.naturalWidth,
|
| 45 |
+
naturalHeight: imgRef.current.naturalHeight,
|
| 46 |
+
clientWidth: imgRef.current.clientWidth,
|
| 47 |
+
clientHeight: imgRef.current.clientHeight,
|
| 48 |
+
scaleX: imgRef.current.clientWidth / imgRef.current.naturalWidth,
|
| 49 |
+
scaleY: imgRef.current.clientHeight / imgRef.current.naturalHeight,
|
| 50 |
+
};
|
| 51 |
+
setImageInfo(info);
|
| 52 |
+
setLoaded(true);
|
| 53 |
+
|
| 54 |
+
try {
|
| 55 |
+
const blob = await fetch(imageUrl).then((r) => r.blob());
|
| 56 |
+
const formData = new FormData();
|
| 57 |
+
formData.append("file", blob, "image.png");
|
| 58 |
+
|
| 59 |
+
const res = await fetch("/detect/", {
|
| 60 |
+
method: "POST",
|
| 61 |
+
body: formData,
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${await res.text()}`);
|
| 65 |
+
|
| 66 |
+
const data = await res.json();
|
| 67 |
+
const rawCircles = data.circles || [];
|
| 68 |
+
const rawTexts = data.texts || [];
|
| 69 |
+
|
| 70 |
+
setRawCircles(rawCircles);
|
| 71 |
+
setRawTexts(rawTexts);
|
| 72 |
+
|
| 73 |
+
const scaledCircles = rawCircles.map((c) => ({
|
| 74 |
+
...c,
|
| 75 |
+
x: c.x * info.scaleX,
|
| 76 |
+
y: c.y * info.scaleY,
|
| 77 |
+
r: c.r * Math.min(info.scaleX, info.scaleY),
|
| 78 |
+
}));
|
| 79 |
+
|
| 80 |
+
const scaledTexts = rawTexts.map((t) => ({
|
| 81 |
+
...t,
|
| 82 |
+
x1: t.x1 * info.scaleX,
|
| 83 |
+
y1: t.y1 * info.scaleY,
|
| 84 |
+
x2: t.x2 * info.scaleX,
|
| 85 |
+
y2: t.y2 * info.scaleY,
|
| 86 |
+
}));
|
| 87 |
+
|
| 88 |
+
setCircles(scaledCircles);
|
| 89 |
+
setTexts(scaledTexts);
|
| 90 |
+
} catch (err) {
|
| 91 |
+
setError(`Failed to detect shapes: ${err.message}`);
|
| 92 |
+
console.error(err);
|
| 93 |
+
}
|
| 94 |
+
};
|
| 95 |
+
|
| 96 |
+
return (
|
| 97 |
+
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
| 98 |
+
<ZoomControls zoom={zoom} zoomIn={zoomIn} zoomOut={zoomOut} />
|
| 99 |
+
|
| 100 |
+
<div
|
| 101 |
+
className="image-container"
|
| 102 |
+
style={{ position: "relative", display: "inline-block", overflow: "auto" }}
|
| 103 |
+
onWheel={handleWheel}
|
| 104 |
+
>
|
| 105 |
+
<div
|
| 106 |
+
ref={wrapperRef}
|
| 107 |
+
className="zoom-wrapper"
|
| 108 |
+
style={{
|
| 109 |
+
position: "relative",
|
| 110 |
+
width: imageInfo ? imageInfo.clientWidth : "auto",
|
| 111 |
+
height: imageInfo ? imageInfo.clientHeight : "auto",
|
| 112 |
+
transform: `scale(${zoom})`,
|
| 113 |
+
transformOrigin: "0 0",
|
| 114 |
+
transition: "transform 120ms ease-out",
|
| 115 |
+
}}
|
| 116 |
+
>
|
| 117 |
+
<img
|
| 118 |
+
ref={imgRef}
|
| 119 |
+
src={imageUrl}
|
| 120 |
+
alt="uploaded"
|
| 121 |
+
onLoad={handleImageLoad}
|
| 122 |
+
style={{
|
| 123 |
+
width: imageInfo ? imageInfo.clientWidth : "100%",
|
| 124 |
+
height: imageInfo ? imageInfo.clientHeight : "auto",
|
| 125 |
+
display: "block",
|
| 126 |
+
userSelect: "none",
|
| 127 |
+
}}
|
| 128 |
+
/>
|
| 129 |
+
{loaded && imageInfo && (
|
| 130 |
+
<ShapeOverlay
|
| 131 |
+
imageInfo={imageInfo}
|
| 132 |
+
circles={circles}
|
| 133 |
+
texts={texts}
|
| 134 |
+
setSelectedShape={setSelectedShape}
|
| 135 |
+
/>
|
| 136 |
+
)}
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
|
| 140 |
+
{selectedShape && imageInfo && (
|
| 141 |
+
<Popup selectedShape={selectedShape} onClose={() => setSelectedShape(null)} zoom={zoom} />
|
| 142 |
+
)}
|
| 143 |
+
</div>
|
| 144 |
+
);
|
| 145 |
+
}
|
| 146 |
+
|
| 147 |
+
export default ImageCanvas;
|
app/src/components/ImageUploader.jsx
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ImageUploader.jsx
|
| 3 |
+
*
|
| 4 |
+
* This component provides a file input to upload an image.
|
| 5 |
+
* - When a user selects an image, it creates a temporary object URL
|
| 6 |
+
* - Updates the parent component with the new image URL
|
| 7 |
+
* - Resets any existing detection/overlay states to start fresh
|
| 8 |
+
*/
|
| 9 |
+
function ImageUploader({ setImageUrl, resetStates }) {
|
| 10 |
+
const handleUpload = (e) => {
|
| 11 |
+
const file = e.target.files[0];
|
| 12 |
+
if (!file) return;
|
| 13 |
+
|
| 14 |
+
const url = URL.createObjectURL(file);
|
| 15 |
+
setImageUrl(url);
|
| 16 |
+
resetStates();
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
return (
|
| 20 |
+
<input
|
| 21 |
+
type="file"
|
| 22 |
+
accept="image/*"
|
| 23 |
+
onChange={handleUpload}
|
| 24 |
+
className="file-input"
|
| 25 |
+
/>
|
| 26 |
+
);
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
export default ImageUploader;
|
app/src/components/Loginform.jsx
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* LoginForm.jsx
|
| 3 |
+
*
|
| 4 |
+
* This component renders a login form where a user enters their name and email.
|
| 5 |
+
* - On submit, it sends the credentials to the backend login API.
|
| 6 |
+
* - If successful, it stores the user info and session ID in the parent state.
|
| 7 |
+
* - Handles loading state and displays error messages if login fails.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
import { useState } from "react";
|
| 12 |
+
import "../styles/LoginForm.css";
|
| 13 |
+
|
| 14 |
+
function LoginForm({ setUser, setSessionId }) {
|
| 15 |
+
const [name, setName] = useState("");
|
| 16 |
+
const [email, setEmail] = useState("");
|
| 17 |
+
const [loading, setLoading] = useState(false);
|
| 18 |
+
const [error, setError] = useState("");
|
| 19 |
+
|
| 20 |
+
const handleSubmit = async (e) => {
|
| 21 |
+
e.preventDefault();
|
| 22 |
+
setLoading(true);
|
| 23 |
+
setError("");
|
| 24 |
+
|
| 25 |
+
try {
|
| 26 |
+
const response = await fetch("/auth/login", {
|
| 27 |
+
method: "POST",
|
| 28 |
+
body: new URLSearchParams({ name, email }),
|
| 29 |
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
| 30 |
+
});
|
| 31 |
+
|
| 32 |
+
const data = await response.json();
|
| 33 |
+
|
| 34 |
+
if (response.ok && data.session_id) {
|
| 35 |
+
setUser({ name, email });
|
| 36 |
+
setSessionId(data.session_id);
|
| 37 |
+
} else {
|
| 38 |
+
setError(data.message || "Login failed. Please try again.");
|
| 39 |
+
}
|
| 40 |
+
} catch (err) {
|
| 41 |
+
console.error("Login error:", err);
|
| 42 |
+
setError("Network error. Please check your connection and try again.");
|
| 43 |
+
} finally {
|
| 44 |
+
setLoading(false);
|
| 45 |
+
}
|
| 46 |
+
};
|
| 47 |
+
|
| 48 |
+
return (
|
| 49 |
+
<div className="login-container">
|
| 50 |
+
<h2>Login</h2>
|
| 51 |
+
<form onSubmit={handleSubmit} className="login-form">
|
| 52 |
+
<input
|
| 53 |
+
type="text"
|
| 54 |
+
placeholder="Enter Name"
|
| 55 |
+
value={name}
|
| 56 |
+
onChange={(e) => setName(e.target.value)}
|
| 57 |
+
required
|
| 58 |
+
disabled={loading}
|
| 59 |
+
/>
|
| 60 |
+
<input
|
| 61 |
+
type="email"
|
| 62 |
+
placeholder="Enter Email"
|
| 63 |
+
value={email}
|
| 64 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 65 |
+
required
|
| 66 |
+
disabled={loading}
|
| 67 |
+
/>
|
| 68 |
+
{error && <div className="error-message" style={{color: 'red', margin: '10px 0'}}>{error}</div>}
|
| 69 |
+
<button type="submit" disabled={loading}>
|
| 70 |
+
{loading ? "Logging in..." : "Login"}
|
| 71 |
+
</button>
|
| 72 |
+
</form>
|
| 73 |
+
</div>
|
| 74 |
+
);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
export default LoginForm;
|
app/src/components/Page.jsx
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Page.jsx
|
| 3 |
+
*
|
| 4 |
+
* This component displays a single page image and highlights a specific circle on it.
|
| 5 |
+
* - Reads query parameters `image` (page image URL) and `circle` (circle text to highlight)
|
| 6 |
+
* - Loads the image and calculates scale info for proper overlays
|
| 7 |
+
* - Sends the image to the backend detection API to get all circles
|
| 8 |
+
* - Finds the circle that matches the target text and highlights it
|
| 9 |
+
* - Supports zooming via buttons and mouse wheel
|
| 10 |
+
*/
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
import { useState, useEffect, useRef } from "react";
|
| 14 |
+
import { useSearchParams } from "react-router-dom";
|
| 15 |
+
import ZoomControls from "./ZoomControls";
|
| 16 |
+
import useZoom from "../hooks/useZoom";
|
| 17 |
+
import "../styles/zoom.css";
|
| 18 |
+
|
| 19 |
+
function Page() {
|
| 20 |
+
const [searchParams] = useSearchParams();
|
| 21 |
+
const targetCircleText = searchParams.get("circle");
|
| 22 |
+
const pageImage = searchParams.get("image");
|
| 23 |
+
|
| 24 |
+
const [highlightCircle, setHighlightCircle] = useState(null);
|
| 25 |
+
const [imageInfo, setImageInfo] = useState(null);
|
| 26 |
+
|
| 27 |
+
const imgRef = useRef(null);
|
| 28 |
+
const wrapperRef = useRef(null);
|
| 29 |
+
|
| 30 |
+
const { zoom, zoomIn, zoomOut, handleWheel } = useZoom({ min: 1, max: 3, step: 0.25 });
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
const handleImageLoad = () => {
|
| 34 |
+
if (!imgRef.current) return;
|
| 35 |
+
|
| 36 |
+
const info = {
|
| 37 |
+
naturalWidth: imgRef.current.naturalWidth,
|
| 38 |
+
naturalHeight: imgRef.current.naturalHeight,
|
| 39 |
+
clientWidth: imgRef.current.clientWidth,
|
| 40 |
+
clientHeight: imgRef.current.clientHeight,
|
| 41 |
+
scaleX: imgRef.current.clientWidth / imgRef.current.naturalWidth,
|
| 42 |
+
scaleY: imgRef.current.clientHeight / imgRef.current.naturalHeight,
|
| 43 |
+
};
|
| 44 |
+
setImageInfo(info);
|
| 45 |
+
};
|
| 46 |
+
|
| 47 |
+
useEffect(() => {
|
| 48 |
+
if (!pageImage || !targetCircleText) {
|
| 49 |
+
setHighlightCircle(null);
|
| 50 |
+
return;
|
| 51 |
+
}
|
| 52 |
+
|
| 53 |
+
const detect = async () => {
|
| 54 |
+
try {
|
| 55 |
+
const blob = await fetch(pageImage).then((res) => res.blob());
|
| 56 |
+
const formData = new FormData();
|
| 57 |
+
formData.append("file", blob, "page.png");
|
| 58 |
+
|
| 59 |
+
const resp = await fetch("/detect/", {
|
| 60 |
+
method: "POST",
|
| 61 |
+
body: formData,
|
| 62 |
+
});
|
| 63 |
+
|
| 64 |
+
if (!resp.ok) throw new Error(`Detection failed: ${await resp.text()}`);
|
| 65 |
+
|
| 66 |
+
const data = await resp.json();
|
| 67 |
+
const circles = data.circles || [];
|
| 68 |
+
|
| 69 |
+
const targetCircle = circles.find(
|
| 70 |
+
(c) =>
|
| 71 |
+
c.circle_text &&
|
| 72 |
+
c.circle_text.trim().toLowerCase() === targetCircleText.trim().toLowerCase()
|
| 73 |
+
);
|
| 74 |
+
|
| 75 |
+
setHighlightCircle(targetCircle || null);
|
| 76 |
+
} catch (err) {
|
| 77 |
+
console.error("Detection error:", err);
|
| 78 |
+
setHighlightCircle(null);
|
| 79 |
+
}
|
| 80 |
+
};
|
| 81 |
+
|
| 82 |
+
detect();
|
| 83 |
+
}, [pageImage, targetCircleText]);
|
| 84 |
+
|
| 85 |
+
const getScaledCircle = () => {
|
| 86 |
+
if (!highlightCircle || !imageInfo) return null;
|
| 87 |
+
|
| 88 |
+
return {
|
| 89 |
+
cx: highlightCircle.x * imageInfo.scaleX,
|
| 90 |
+
cy: highlightCircle.y * imageInfo.scaleY,
|
| 91 |
+
r: highlightCircle.r * Math.min(imageInfo.scaleX, imageInfo.scaleY),
|
| 92 |
+
};
|
| 93 |
+
};
|
| 94 |
+
|
| 95 |
+
const scaledCircle = getScaledCircle();
|
| 96 |
+
|
| 97 |
+
return (
|
| 98 |
+
<div style={{ position: "relative", width: "100%", height: "100%" }}>
|
| 99 |
+
<ZoomControls zoom={zoom} zoomIn={zoomIn} zoomOut={zoomOut} />
|
| 100 |
+
|
| 101 |
+
{pageImage && (
|
| 102 |
+
<div
|
| 103 |
+
className="image-container"
|
| 104 |
+
style={{ position: "relative", display: "inline-block", overflow: "auto" }}
|
| 105 |
+
onWheel={handleWheel}
|
| 106 |
+
>
|
| 107 |
+
<div
|
| 108 |
+
ref={wrapperRef}
|
| 109 |
+
className="zoom-wrapper"
|
| 110 |
+
style={{
|
| 111 |
+
position: "relative",
|
| 112 |
+
width: imageInfo ? imageInfo.clientWidth : "auto",
|
| 113 |
+
height: imageInfo ? imageInfo.clientHeight : "auto",
|
| 114 |
+
transform: `scale(${zoom})`,
|
| 115 |
+
transformOrigin: "0 0",
|
| 116 |
+
transition: "transform 120ms ease-out",
|
| 117 |
+
}}
|
| 118 |
+
>
|
| 119 |
+
<img
|
| 120 |
+
ref={imgRef}
|
| 121 |
+
src={pageImage}
|
| 122 |
+
alt="Page"
|
| 123 |
+
onLoad={handleImageLoad}
|
| 124 |
+
style={{
|
| 125 |
+
width: imageInfo ? imageInfo.clientWidth : "100%",
|
| 126 |
+
height: imageInfo ? imageInfo.clientHeight : "auto",
|
| 127 |
+
display: "block",
|
| 128 |
+
userSelect: "none",
|
| 129 |
+
}}
|
| 130 |
+
onError={(e) => {
|
| 131 |
+
console.error("Image failed to load:", pageImage);
|
| 132 |
+
e.target.alt = "Failed to load image";
|
| 133 |
+
}}
|
| 134 |
+
/>
|
| 135 |
+
|
| 136 |
+
{/* Circle overlay */}
|
| 137 |
+
{scaledCircle && (
|
| 138 |
+
<svg
|
| 139 |
+
width={imageInfo?.clientWidth || 0}
|
| 140 |
+
height={imageInfo?.clientHeight || 0}
|
| 141 |
+
style={{ position: "absolute", top: 0, left: 0, pointerEvents: "none" }}
|
| 142 |
+
>
|
| 143 |
+
<circle
|
| 144 |
+
cx={scaledCircle.cx}
|
| 145 |
+
cy={scaledCircle.cy}
|
| 146 |
+
r={scaledCircle.r}
|
| 147 |
+
stroke="blue"
|
| 148 |
+
strokeWidth="3"
|
| 149 |
+
fill="none"
|
| 150 |
+
/>
|
| 151 |
+
</svg>
|
| 152 |
+
)}
|
| 153 |
+
</div>
|
| 154 |
+
</div>
|
| 155 |
+
)}
|
| 156 |
+
</div>
|
| 157 |
+
);
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
export default Page;
|
app/src/components/Popup.jsx
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Popup.jsx
|
| 3 |
+
*
|
| 4 |
+
* This component displays a popup box with information about a selected shape (circle or text).
|
| 5 |
+
* - For circles: shows page number and circle text, with a button to navigate to the corresponding page.
|
| 6 |
+
* - For text: fetches additional info from the LLM backend and displays it.
|
| 7 |
+
* - Supports zoomed image coordinates without scaling the popup itself.
|
| 8 |
+
* - Provides a close button to dismiss the popup.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
import { useState, useEffect } from "react";
|
| 13 |
+
|
| 14 |
+
function Popup({ selectedShape, onClose, zoom = 1 }) {
|
| 15 |
+
const [info, setInfo] = useState(null);
|
| 16 |
+
|
| 17 |
+
useEffect(() => {
|
| 18 |
+
if (!selectedShape || selectedShape.r) return;
|
| 19 |
+
setInfo("Loading...");
|
| 20 |
+
fetch("/llm/generate_info/", {
|
| 21 |
+
method: "POST",
|
| 22 |
+
headers: { "Content-Type": "application/json" },
|
| 23 |
+
body: JSON.stringify({ content: selectedShape.text || "Unlabeled text" }),
|
| 24 |
+
})
|
| 25 |
+
.then((res) => {
|
| 26 |
+
if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
|
| 27 |
+
return res.json();
|
| 28 |
+
})
|
| 29 |
+
.then((data) => setInfo(data.info))
|
| 30 |
+
.catch((error) => {
|
| 31 |
+
console.error("LLM fetch error:", error);
|
| 32 |
+
setInfo("Error generating info");
|
| 33 |
+
});
|
| 34 |
+
}, [selectedShape]);
|
| 35 |
+
|
| 36 |
+
if (!selectedShape) return null;
|
| 37 |
+
|
| 38 |
+
// ✅ Apply zoom only to coordinates, not to popup size
|
| 39 |
+
const left =
|
| 40 |
+
(selectedShape.r
|
| 41 |
+
? selectedShape.x + selectedShape.r + 10
|
| 42 |
+
: selectedShape.x2 + 10) * zoom;
|
| 43 |
+
|
| 44 |
+
const top =
|
| 45 |
+
(selectedShape.r
|
| 46 |
+
? selectedShape.y - selectedShape.r / 2
|
| 47 |
+
: selectedShape.y1) * zoom;
|
| 48 |
+
|
| 49 |
+
// Redirect handler
|
| 50 |
+
const handleRedirect = (e, pageNumber, circleText) => {
|
| 51 |
+
e.preventDefault();
|
| 52 |
+
e.stopPropagation();
|
| 53 |
+
|
| 54 |
+
if (!pageNumber) {
|
| 55 |
+
console.log("Missing navigation data:", { pageNumber });
|
| 56 |
+
return;
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
const pageImageUrl = `/images/${pageNumber}.png`;
|
| 60 |
+
let targetUrl = `/page?image=${encodeURIComponent(pageImageUrl)}`;
|
| 61 |
+
if (circleText) {
|
| 62 |
+
targetUrl += `&circle=${encodeURIComponent(circleText)}`;
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
window.open(targetUrl, "_blank", "noopener,noreferrer");
|
| 66 |
+
onClose();
|
| 67 |
+
};
|
| 68 |
+
|
| 69 |
+
return (
|
| 70 |
+
<div
|
| 71 |
+
className="popup-box"
|
| 72 |
+
onClick={(e) => e.stopPropagation()}
|
| 73 |
+
style={{
|
| 74 |
+
position: "absolute",
|
| 75 |
+
left: `${left}px`,
|
| 76 |
+
top: `${top}px`,
|
| 77 |
+
zIndex: 1001,
|
| 78 |
+
backgroundColor: "orange",
|
| 79 |
+
border: "1px solid #ccc",
|
| 80 |
+
borderRadius: "8px",
|
| 81 |
+
padding: "10px",
|
| 82 |
+
boxShadow: "0 2px 10px rgba(0,0,0,0.1)",
|
| 83 |
+
minWidth: "200px",
|
| 84 |
+
transform: "scale(1)",
|
| 85 |
+
}}
|
| 86 |
+
>
|
| 87 |
+
{selectedShape.r ? (
|
| 88 |
+
<div>
|
| 89 |
+
<h4>Circle Information</h4>
|
| 90 |
+
{selectedShape.page_number ? (
|
| 91 |
+
<ul style={{ listStyle: "none", padding: 0 }}>
|
| 92 |
+
<li style={{ marginBottom: "8px" }}>
|
| 93 |
+
<strong>Page Number:</strong> {selectedShape.page_number}
|
| 94 |
+
</li>
|
| 95 |
+
{selectedShape.circle_text && (
|
| 96 |
+
<li>
|
| 97 |
+
<strong>Circle Text:</strong> {selectedShape.circle_text}
|
| 98 |
+
</li>
|
| 99 |
+
)}
|
| 100 |
+
<li style={{ marginTop: "5px" }}>
|
| 101 |
+
<button
|
| 102 |
+
style={{
|
| 103 |
+
cursor: "pointer",
|
| 104 |
+
color: "white",
|
| 105 |
+
backgroundColor: "#007bff",
|
| 106 |
+
border: "none",
|
| 107 |
+
padding: "5px 10px",
|
| 108 |
+
borderRadius: "4px",
|
| 109 |
+
}}
|
| 110 |
+
onClick={(e) =>
|
| 111 |
+
handleRedirect(
|
| 112 |
+
e,
|
| 113 |
+
selectedShape.page_number,
|
| 114 |
+
selectedShape.circle_text
|
| 115 |
+
)
|
| 116 |
+
}
|
| 117 |
+
>
|
| 118 |
+
Go to page {selectedShape.page_number}
|
| 119 |
+
</button>
|
| 120 |
+
</li>
|
| 121 |
+
</ul>
|
| 122 |
+
) : (
|
| 123 |
+
<p>No page number available for navigation.</p>
|
| 124 |
+
)}
|
| 125 |
+
<button
|
| 126 |
+
onClick={onClose}
|
| 127 |
+
style={{
|
| 128 |
+
marginTop: "10px",
|
| 129 |
+
padding: "5px 10px",
|
| 130 |
+
backgroundColor: "#6c757d",
|
| 131 |
+
color: "white",
|
| 132 |
+
border: "none",
|
| 133 |
+
borderRadius: "4px",
|
| 134 |
+
cursor: "pointer",
|
| 135 |
+
}}
|
| 136 |
+
>
|
| 137 |
+
Close
|
| 138 |
+
</button>
|
| 139 |
+
</div>
|
| 140 |
+
) : (
|
| 141 |
+
<div>
|
| 142 |
+
<h4>Detected Text</h4>
|
| 143 |
+
<p>
|
| 144 |
+
<strong>Text:</strong> {selectedShape.text || "Text"}
|
| 145 |
+
</p>
|
| 146 |
+
<p>
|
| 147 |
+
<strong>Info:</strong> {info || "Click to generate info"}
|
| 148 |
+
</p>
|
| 149 |
+
<button
|
| 150 |
+
onClick={onClose}
|
| 151 |
+
style={{
|
| 152 |
+
marginTop: "10px",
|
| 153 |
+
padding: "5px 10px",
|
| 154 |
+
backgroundColor: "#6c757d",
|
| 155 |
+
color: "white",
|
| 156 |
+
border: "none",
|
| 157 |
+
borderRadius: "4px",
|
| 158 |
+
cursor: "pointer",
|
| 159 |
+
}}
|
| 160 |
+
>
|
| 161 |
+
Close
|
| 162 |
+
</button>
|
| 163 |
+
</div>
|
| 164 |
+
)}
|
| 165 |
+
</div>
|
| 166 |
+
);
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
export default Popup;
|
| 170 |
+
|
app/src/components/ShapeOverlay.jsx
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* ShapeOverlay.jsx
|
| 3 |
+
*
|
| 4 |
+
* This component renders an SVG overlay on top of an image to visually highlight
|
| 5 |
+
* detected circles and text regions. Each shape is clickable, allowing users
|
| 6 |
+
* to select a shape and view detailed information via a popup.
|
| 7 |
+
*/
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
import React from "react";
|
| 11 |
+
|
| 12 |
+
function ShapeOverlay({ imageInfo, circles, texts, setSelectedShape }) {
|
| 13 |
+
const handleShapeClick = (e, shape) => {
|
| 14 |
+
e.preventDefault();
|
| 15 |
+
e.stopPropagation();
|
| 16 |
+
console.log("Shape clicked:", shape);
|
| 17 |
+
setSelectedShape(shape);
|
| 18 |
+
};
|
| 19 |
+
|
| 20 |
+
return (
|
| 21 |
+
<svg
|
| 22 |
+
className="overlay-svg"
|
| 23 |
+
width={imageInfo.clientWidth}
|
| 24 |
+
height={imageInfo.clientHeight}
|
| 25 |
+
viewBox={`0 0 ${imageInfo.clientWidth} ${imageInfo.clientHeight}`}
|
| 26 |
+
style={{
|
| 27 |
+
position: "absolute",
|
| 28 |
+
top: 0,
|
| 29 |
+
left: 0,
|
| 30 |
+
pointerEvents: "auto",
|
| 31 |
+
zIndex: 10,
|
| 32 |
+
transformOrigin: "0 0",
|
| 33 |
+
}}
|
| 34 |
+
>
|
| 35 |
+
{circles.map((c) => (
|
| 36 |
+
<circle
|
| 37 |
+
key={`circle-${c.id}`}
|
| 38 |
+
cx={c.x}
|
| 39 |
+
cy={c.y}
|
| 40 |
+
r={c.r}
|
| 41 |
+
fill="rgba(255, 0, 0, 0.22)"
|
| 42 |
+
stroke="red"
|
| 43 |
+
strokeWidth="2"
|
| 44 |
+
onClick={(e) => handleShapeClick(e, c)}
|
| 45 |
+
style={{ cursor: "pointer", pointerEvents: "all" }}
|
| 46 |
+
/>
|
| 47 |
+
))}
|
| 48 |
+
|
| 49 |
+
{texts.map((t) => (
|
| 50 |
+
<rect
|
| 51 |
+
key={`text-${t.id}`}
|
| 52 |
+
x={t.x1}
|
| 53 |
+
y={t.y1}
|
| 54 |
+
width={t.x2 - t.x1}
|
| 55 |
+
height={t.y2 - t.y1}
|
| 56 |
+
fill="rgba(0,255,0,0.18)"
|
| 57 |
+
stroke="green"
|
| 58 |
+
strokeWidth="2"
|
| 59 |
+
onClick={(e) => handleShapeClick(e, t)}
|
| 60 |
+
style={{ cursor: "pointer", pointerEvents: "all" }}
|
| 61 |
+
/>
|
| 62 |
+
))}
|
| 63 |
+
</svg>
|
| 64 |
+
);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
export default ShapeOverlay;
|
app/src/components/ZoomControls.jsx
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from "react";
|
| 2 |
+
import "../styles/Zoom.css";
|
| 3 |
+
|
| 4 |
+
export default function ZoomControls({ zoom, zoomIn, zoomOut }) {
|
| 5 |
+
return (
|
| 6 |
+
<div className="zoom-controls">
|
| 7 |
+
<button onClick={zoomIn} title="Zoom in" className="zoom-btn">+</button>
|
| 8 |
+
<button onClick={zoomOut} title="Zoom out" className="zoom-btn">−</button>
|
| 9 |
+
</div>
|
| 10 |
+
);
|
| 11 |
+
}
|
app/src/hooks/useLogout.jsx
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* useLogout.js
|
| 3 |
+
*
|
| 4 |
+
* This custom React hook returns a memoized logout function.
|
| 5 |
+
* - Sends a logout request to the backend API with the current session ID.
|
| 6 |
+
* - Clears user-related state (user info, session ID, and uploaded image) on logout.
|
| 7 |
+
* - Ensures the function reference is stable with useCallback for performance.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
import { useCallback } from "react";
|
| 12 |
+
|
| 13 |
+
export default function useLogout(sessionId, setUser, setSessionId, setImageUrl) {
|
| 14 |
+
return useCallback(async () => {
|
| 15 |
+
if (!sessionId) return;
|
| 16 |
+
|
| 17 |
+
try {
|
| 18 |
+
await fetch("http://localhost:8001/logout", {
|
| 19 |
+
method: "POST",
|
| 20 |
+
headers: { "Content-Type": "application/json" },
|
| 21 |
+
body: JSON.stringify({ session_id: sessionId }),
|
| 22 |
+
});
|
| 23 |
+
} catch (err) {
|
| 24 |
+
console.error("Logout error:", err);
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
setUser(null);
|
| 28 |
+
setSessionId(null);
|
| 29 |
+
setImageUrl(null);
|
| 30 |
+
}, [sessionId, setUser, setSessionId, setImageUrl]);
|
| 31 |
+
}
|
app/src/hooks/useZoom.jsx
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* useZoom.js
|
| 3 |
+
*
|
| 4 |
+
* This custom React hook manages zoom functionality for UI elements such as images.
|
| 5 |
+
* - Allows zooming in, zooming out, and setting exact zoom levels.
|
| 6 |
+
* - Supports zoom via buttons, keyboard shortcuts (Ctrl/Cmd + +, -, 0), and mouse wheel.
|
| 7 |
+
* - Enforces minimum and maximum zoom limits and configurable zoom step.
|
| 8 |
+
*/
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
import { useState, useEffect } from "react";
|
| 12 |
+
|
| 13 |
+
export default function useZoom({ min = 1, max = 3, step = 0.25 }) {
|
| 14 |
+
const [zoom, setZoom] = useState(1);
|
| 15 |
+
|
| 16 |
+
const clamp = (v) => Math.min(Math.max(v, min), max);
|
| 17 |
+
|
| 18 |
+
const zoomIn = () => setZoom((z) => clamp(Number((z + step).toFixed(2))));
|
| 19 |
+
const zoomOut = () => setZoom((z) => clamp(Number((z - step).toFixed(2))));
|
| 20 |
+
const setZoomExact = (v) => setZoom(clamp(Number(v)));
|
| 21 |
+
|
| 22 |
+
useEffect(() => {
|
| 23 |
+
const handleKeyDown = (e) => {
|
| 24 |
+
if (e.ctrlKey || e.metaKey) {
|
| 25 |
+
if (e.key === "+" || e.key === "=") {
|
| 26 |
+
e.preventDefault();
|
| 27 |
+
zoomIn();
|
| 28 |
+
} else if (e.key === "-") {
|
| 29 |
+
e.preventDefault();
|
| 30 |
+
zoomOut();
|
| 31 |
+
} else if (e.key === "0") {
|
| 32 |
+
e.preventDefault();
|
| 33 |
+
setZoomExact(1);
|
| 34 |
+
}
|
| 35 |
+
}
|
| 36 |
+
};
|
| 37 |
+
|
| 38 |
+
window.addEventListener("keydown", handleKeyDown);
|
| 39 |
+
return () => window.removeEventListener("keydown", handleKeyDown);
|
| 40 |
+
}, []);
|
| 41 |
+
|
| 42 |
+
const handleWheel = (e) => {
|
| 43 |
+
if (e.ctrlKey || e.metaKey) {
|
| 44 |
+
e.preventDefault();
|
| 45 |
+
const delta = -e.deltaY;
|
| 46 |
+
const factor = delta > 0 ? 1 + step : 1 - step;
|
| 47 |
+
setZoom((z) => clamp(Math.round(z * factor * 100) / 100));
|
| 48 |
+
}
|
| 49 |
+
};
|
| 50 |
+
|
| 51 |
+
return { zoom, zoomIn, zoomOut, handleWheel };
|
| 52 |
+
}
|
app/src/hooks/useautoLogout.jsx
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* useAutoLogout.js
|
| 3 |
+
*
|
| 4 |
+
* This custom React hook automatically logs out a user after a period of inactivity.
|
| 5 |
+
* - Tracks user activity (mouse movement, clicks, key presses, scrolling).
|
| 6 |
+
* - Resets the inactivity timer on any interaction.
|
| 7 |
+
* - Calls the provided handleLogout function if the user is inactive for the specified timeout.
|
| 8 |
+
* - Also logs out the user when the window is closed or refreshed.
|
| 9 |
+
*/
|
| 10 |
+
|
| 11 |
+
import { useEffect } from "react";
|
| 12 |
+
|
| 13 |
+
export default function useAutoLogout(sessionId, handleLogout, timeout) {
|
| 14 |
+
useEffect(() => {
|
| 15 |
+
if (!sessionId) return;
|
| 16 |
+
|
| 17 |
+
let timer;
|
| 18 |
+
|
| 19 |
+
const resetTimer = () => {
|
| 20 |
+
clearTimeout(timer);
|
| 21 |
+
timer = setTimeout(() => {
|
| 22 |
+
console.log("Auto logout: inactive for 5 mins");
|
| 23 |
+
handleLogout();
|
| 24 |
+
}, timeout);
|
| 25 |
+
};
|
| 26 |
+
|
| 27 |
+
window.addEventListener("mousemove", resetTimer);
|
| 28 |
+
window.addEventListener("keydown", resetTimer);
|
| 29 |
+
window.addEventListener("click", resetTimer);
|
| 30 |
+
window.addEventListener("scroll", resetTimer);
|
| 31 |
+
|
| 32 |
+
const handleUnload = () => handleLogout();
|
| 33 |
+
window.addEventListener("beforeunload", handleUnload);
|
| 34 |
+
|
| 35 |
+
resetTimer();
|
| 36 |
+
|
| 37 |
+
return () => {
|
| 38 |
+
clearTimeout(timer);
|
| 39 |
+
window.removeEventListener("mousemove", resetTimer);
|
| 40 |
+
window.removeEventListener("keydown", resetTimer);
|
| 41 |
+
window.removeEventListener("click", resetTimer);
|
| 42 |
+
window.removeEventListener("scroll", resetTimer);
|
| 43 |
+
window.removeEventListener("beforeunload", handleUnload);
|
| 44 |
+
};
|
| 45 |
+
}, [sessionId, handleLogout, timeout]);
|
| 46 |
+
}
|
app/src/index.css
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
|
| 3 |
+
line-height: 1.5;
|
| 4 |
+
font-weight: 400;
|
| 5 |
+
|
| 6 |
+
color-scheme: light dark;
|
| 7 |
+
color: rgba(255, 255, 255, 0.87);
|
| 8 |
+
background-color: #242424;
|
| 9 |
+
|
| 10 |
+
font-synthesis: none;
|
| 11 |
+
text-rendering: optimizeLegibility;
|
| 12 |
+
-webkit-font-smoothing: antialiased;
|
| 13 |
+
-moz-osx-font-smoothing: grayscale;
|
| 14 |
+
}
|
| 15 |
+
|
| 16 |
+
a {
|
| 17 |
+
font-weight: 500;
|
| 18 |
+
color: #646cff;
|
| 19 |
+
text-decoration: inherit;
|
| 20 |
+
}
|
| 21 |
+
a:hover {
|
| 22 |
+
color: #535bf2;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
body {
|
| 26 |
+
margin: 0;
|
| 27 |
+
display: flex;
|
| 28 |
+
place-items: center;
|
| 29 |
+
min-width: 320px;
|
| 30 |
+
min-height: 100vh;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
h1 {
|
| 34 |
+
font-size: 3.2em;
|
| 35 |
+
line-height: 1.1;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
button {
|
| 39 |
+
border-radius: 8px;
|
| 40 |
+
border: 1px solid transparent;
|
| 41 |
+
padding: 0.6em 1.2em;
|
| 42 |
+
font-size: 1em;
|
| 43 |
+
font-weight: 500;
|
| 44 |
+
font-family: inherit;
|
| 45 |
+
background-color: #1a1a1a;
|
| 46 |
+
cursor: pointer;
|
| 47 |
+
transition: border-color 0.25s;
|
| 48 |
+
}
|
| 49 |
+
button:hover {
|
| 50 |
+
border-color: #646cff;
|
| 51 |
+
}
|
| 52 |
+
button:focus,
|
| 53 |
+
button:focus-visible {
|
| 54 |
+
outline: 4px auto -webkit-focus-ring-color;
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
@media (prefers-color-scheme: light) {
|
| 58 |
+
:root {
|
| 59 |
+
color: #213547;
|
| 60 |
+
background-color: #ffffff;
|
| 61 |
+
}
|
| 62 |
+
a:hover {
|
| 63 |
+
color: #747bff;
|
| 64 |
+
}
|
| 65 |
+
button {
|
| 66 |
+
background-color: #f9f9f9;
|
| 67 |
+
}
|
| 68 |
+
}
|
app/src/main.jsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { StrictMode } from 'react'
|
| 2 |
+
import { createRoot } from 'react-dom/client'
|
| 3 |
+
import './index.css'
|
| 4 |
+
import App from './App.jsx'
|
| 5 |
+
|
| 6 |
+
createRoot(document.getElementById('root')).render(
|
| 7 |
+
<StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</StrictMode>,
|
| 10 |
+
)
|
app/src/styles/App.css
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.container {
|
| 2 |
+
position: relative;
|
| 3 |
+
width: 100vw;
|
| 4 |
+
height: 100vh;
|
| 5 |
+
margin: 0;
|
| 6 |
+
padding: 20px;
|
| 7 |
+
box-sizing: border-box;
|
| 8 |
+
text-align: center;
|
| 9 |
+
}
|
| 10 |
+
.logout-button {
|
| 11 |
+
position: absolute;
|
| 12 |
+
top: 20px;
|
| 13 |
+
right: 20px;
|
| 14 |
+
cursor: pointer;
|
| 15 |
+
padding: 5px 10px;
|
| 16 |
+
border-radius: 4px;
|
| 17 |
+
border: none;
|
| 18 |
+
background-color: #007bff;
|
| 19 |
+
color: white;
|
| 20 |
+
transform: none;
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
.heading {
|
| 24 |
+
font-size: 50px;
|
| 25 |
+
font-weight: bold;
|
| 26 |
+
margin-bottom: 20px;
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
.file-input {
|
| 30 |
+
margin-bottom: 20px;
|
| 31 |
+
width: 100%;
|
| 32 |
+
padding: 6px;
|
| 33 |
+
}
|
| 34 |
+
|
| 35 |
+
.error {
|
| 36 |
+
margin-bottom: 15px;
|
| 37 |
+
padding: 10px;
|
| 38 |
+
background-color: #fee2e2;
|
| 39 |
+
border: 1px solid #fca5a5;
|
| 40 |
+
color: #b91c1c;
|
| 41 |
+
border-radius: 6px;
|
| 42 |
+
}
|
| 43 |
+
.image-container {
|
| 44 |
+
position: relative;
|
| 45 |
+
display: inline-block;
|
| 46 |
+
border: 2px solid #d1d5db;
|
| 47 |
+
border-radius: 8px;
|
| 48 |
+
}
|
| 49 |
+
|
| 50 |
+
.uploaded-image {
|
| 51 |
+
max-width: 100%;
|
| 52 |
+
max-height: 100%;
|
| 53 |
+
display: block;
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
.overlay-svg {
|
| 57 |
+
position: absolute;
|
| 58 |
+
top: 0;
|
| 59 |
+
left: 0;
|
| 60 |
+
width: 100%;
|
| 61 |
+
height: 100%;
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
.circle, rect {
|
| 65 |
+
cursor: pointer;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.popup-overlay {
|
| 69 |
+
position: absolute;
|
| 70 |
+
top: 0;
|
| 71 |
+
left: 0;
|
| 72 |
+
width: 100%;
|
| 73 |
+
height: 100%;
|
| 74 |
+
z-index: 1000;
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
+
.popup-box {
|
| 78 |
+
list-style-type: none;
|
| 79 |
+
position: absolute;
|
| 80 |
+
background-color: #3b82f6;
|
| 81 |
+
color: white;
|
| 82 |
+
padding: 6px 10px;
|
| 83 |
+
border-radius: 6px;
|
| 84 |
+
min-width: 50px;
|
| 85 |
+
max-width: 100px;
|
| 86 |
+
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
| 87 |
+
font-size: 15px;
|
| 88 |
+
line-height: 1.2;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.popup-box ul {
|
| 92 |
+
list-style-type: none;
|
| 93 |
+
padding-left: 0;
|
| 94 |
+
margin: 0;
|
| 95 |
+
}
|
app/src/styles/LoginForm.css
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.login-container {
|
| 2 |
+
max-width: 300px;
|
| 3 |
+
margin: 0.5rem auto;
|
| 4 |
+
padding: 1rem;
|
| 5 |
+
border: 1px solid #ddd;
|
| 6 |
+
border-radius: 8px;
|
| 7 |
+
background: #fafafa;
|
| 8 |
+
color: #333;
|
| 9 |
+
font-size: 1.2rem;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
.login-form {
|
| 13 |
+
display: flex;
|
| 14 |
+
flex-direction: column;
|
| 15 |
+
gap: 0.75rem;
|
| 16 |
+
}
|
| 17 |
+
|
| 18 |
+
.login-form input {
|
| 19 |
+
padding: 0.5rem;
|
| 20 |
+
font-size: 1rem;
|
| 21 |
+
background-color: #080b0da1;
|
| 22 |
+
font-size: 1.2rem;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.login-form button {
|
| 26 |
+
padding: 0.5rem;
|
| 27 |
+
background: #007bff;
|
| 28 |
+
color: white;
|
| 29 |
+
border: none;
|
| 30 |
+
border-radius: 4px;
|
| 31 |
+
cursor: pointer;
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
.login-form button:hover {
|
| 35 |
+
background: #0056b3;
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
.logout-button {
|
| 39 |
+
position: fixed;
|
| 40 |
+
top: 1rem;
|
| 41 |
+
right: 1rem;
|
| 42 |
+
padding: 0.5rem 1rem;
|
| 43 |
+
background: #007bff;
|
| 44 |
+
color: white;
|
| 45 |
+
border: none;
|
| 46 |
+
border-radius: 4px;
|
| 47 |
+
cursor: pointer;
|
| 48 |
+
z-index: 1000;
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
.logout-button:hover {
|
| 52 |
+
background: #0056b3;
|
| 53 |
+
}
|
app/src/styles/Zoom.css
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.zoom-controls {
|
| 2 |
+
position: fixed;
|
| 3 |
+
top: 100px;
|
| 4 |
+
right: 16px;
|
| 5 |
+
z-index: 2000;
|
| 6 |
+
display: flex;
|
| 7 |
+
flex-direction: row;
|
| 8 |
+
gap: 8px;
|
| 9 |
+
background: rgba(255, 255, 255, 0.9);
|
| 10 |
+
padding: 8px;
|
| 11 |
+
border-radius: 8px;
|
| 12 |
+
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
|
| 13 |
+
}
|
| 14 |
+
|
| 15 |
+
.zoom-btn {
|
| 16 |
+
width: 36px;
|
| 17 |
+
height: 36px;
|
| 18 |
+
font-size: 20px;
|
| 19 |
+
line-height: 36px;
|
| 20 |
+
text-align: center;
|
| 21 |
+
padding: 0;
|
| 22 |
+
cursor: pointer;
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
.zoom-label {
|
| 26 |
+
font-size: 12px;
|
| 27 |
+
display: flex;
|
| 28 |
+
align-items: center;
|
| 29 |
+
justify-content: center;
|
| 30 |
+
}
|
app/vite.config.js
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite'
|
| 2 |
+
import react from '@vitejs/plugin-react'
|
| 3 |
+
|
| 4 |
+
// https://vite.dev/config/
|
| 5 |
+
export default defineConfig({
|
| 6 |
+
plugins: [react()],
|
| 7 |
+
})
|
backend/__init__.py
ADDED
|
File without changes
|
backend/app.py
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import FastAPI
|
| 2 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
+
from starlette.responses import JSONResponse, FileResponse
|
| 4 |
+
|
| 5 |
+
from fastapi.staticfiles import StaticFiles
|
| 6 |
+
|
| 7 |
+
import os
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
from backend.routes import detect, llm,auth, regions_detect
|
| 11 |
+
|
| 12 |
+
app = FastAPI()
|
| 13 |
+
|
| 14 |
+
app.add_middleware(
|
| 15 |
+
CORSMiddleware,
|
| 16 |
+
allow_origins=["*"],
|
| 17 |
+
allow_credentials=True,
|
| 18 |
+
allow_methods=["*"],
|
| 19 |
+
allow_headers=["*"],
|
| 20 |
+
)
|
| 21 |
+
|
| 22 |
+
app.include_router(detect.router, prefix="/detect", tags=["Detection"])
|
| 23 |
+
app.include_router(llm.router, prefix="/llm", tags=["LLM"])
|
| 24 |
+
app.include_router(auth.router, prefix="/auth", tags=["Auth"])
|
| 25 |
+
app.include_router(regions_detect.router, prefix="/detect", tags=["Region Detection"])
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@app.get("/healthz")
|
| 30 |
+
def health():
|
| 31 |
+
return {"ok": True}
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# @app.get("/", tags=["Root"])
|
| 36 |
+
# def read_root():
|
| 37 |
+
# return JSONResponse({"message": "Backend is running!"})
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
HERE = Path(__file__).resolve().parent
|
| 41 |
+
FE_DIR = HERE / "frontend"
|
| 42 |
+
|
| 43 |
+
app.mount("/assets", StaticFiles(directory=str(FE_DIR / "assets")), name="assets")
|
| 44 |
+
app.mount("/images", StaticFiles(directory=str(FE_DIR / "images")), name="images")
|
| 45 |
+
|
| 46 |
+
@app.get("/")
|
| 47 |
+
def index():
|
| 48 |
+
return FileResponse(FE_DIR / "index.html")
|
| 49 |
+
|
| 50 |
+
@app.get("/favicon.co")
|
| 51 |
+
def favicon():
|
| 52 |
+
return JSONResponse({"detail": "No favicon configured"}, status_code=404)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
# FRONTEND_DIR = os.path.join(os.path.dirname(__file__), "frontend")
|
| 56 |
+
# if os.path.isdir(FRONTEND_DIR):
|
| 57 |
+
# app.mount("/", StaticFiles(directory=FRONTEND_DIR, html=True), name="frontend")
|
| 58 |
+
|
backend/requirements.txt
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
annotated-types==0.7.0
|
| 2 |
+
anyio==4.11.0
|
| 3 |
+
certifi==2025.10.5
|
| 4 |
+
click==8.1.8
|
| 5 |
+
distro==1.9.0
|
| 6 |
+
dnspython==2.7.0
|
| 7 |
+
easyocr==1.7.0
|
| 8 |
+
email-validator==2.3.0
|
| 9 |
+
exceptiongroup==1.3.0
|
| 10 |
+
fastapi==0.111.1
|
| 11 |
+
fastapi-cli==0.0.14
|
| 12 |
+
filelock==3.19.1
|
| 13 |
+
fsspec==2025.9.0
|
| 14 |
+
groq==0.33.0
|
| 15 |
+
h11==0.16.0
|
| 16 |
+
httpcore==1.0.9
|
| 17 |
+
httptools==0.7.1
|
| 18 |
+
httpx==0.28.1
|
| 19 |
+
idna==3.11
|
| 20 |
+
imageio==2.37.0
|
| 21 |
+
Jinja2==3.1.6
|
| 22 |
+
lazy_loader==0.4
|
| 23 |
+
markdown-it-py==3.0.0
|
| 24 |
+
MarkupSafe==3.0.3
|
| 25 |
+
mdurl==0.1.2
|
| 26 |
+
mpmath==1.3.0
|
| 27 |
+
networkx==3.2.1
|
| 28 |
+
ninja==1.13.0
|
| 29 |
+
numpy==1.26.4
|
| 30 |
+
#opencv-python==4.8.1.78
|
| 31 |
+
opencv-python-headless==4.8.1.78
|
| 32 |
+
packaging==25.0
|
| 33 |
+
Pillow==9.5.0
|
| 34 |
+
pyclipper==1.3.0.post6
|
| 35 |
+
pydantic==2.7.1
|
| 36 |
+
pydantic_core==2.18.2
|
| 37 |
+
Pygments==2.19.2
|
| 38 |
+
python-bidi==0.6.7
|
| 39 |
+
python-dotenv==1.0.1
|
| 40 |
+
python-multipart==0.0.20
|
| 41 |
+
PyYAML==6.0.3
|
| 42 |
+
rich==14.2.0
|
| 43 |
+
rich-toolkit==0.15.1
|
| 44 |
+
scikit-image==0.24.0
|
| 45 |
+
scipy==1.13.1
|
| 46 |
+
shapely==2.0.7
|
| 47 |
+
shellingham==1.5.4
|
| 48 |
+
sniffio==1.3.1
|
| 49 |
+
starlette==0.37.2
|
| 50 |
+
sympy==1.14.0
|
| 51 |
+
tifffile==2024.8.30
|
| 52 |
+
#torch==2.8.0
|
| 53 |
+
#torchvision==0.23.0
|
| 54 |
+
typer==0.20.0
|
| 55 |
+
typing_extensions==4.15.0
|
| 56 |
+
uvicorn==0.38.0
|
| 57 |
+
uvloop==0.22.1
|
| 58 |
+
watchfiles==1.1.1
|
| 59 |
+
websockets==15.0.1
|
backend/routes/auth.py
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
auth.py
|
| 3 |
+
|
| 4 |
+
This module defines authentication routes for handling user login and logout sessions
|
| 5 |
+
using FastAPI. It manages session creation and termination by storing session details
|
| 6 |
+
(name, email, session ID, start time, and end time) in a local SQLite database (`sessions.db`).
|
| 7 |
+
Each session is uniquely identified by a UUID.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from fastapi import APIRouter, Form
|
| 11 |
+
from pydantic import BaseModel
|
| 12 |
+
from datetime import datetime
|
| 13 |
+
import sqlite3
|
| 14 |
+
import uuid
|
| 15 |
+
import os
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
# Create a FastAPI router instance for handling authentication routes
|
| 19 |
+
router = APIRouter()
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
DB_PATH = os.environ.get("DB_PATH", "sessions.db")
|
| 25 |
+
|
| 26 |
+
def get_conn():
|
| 27 |
+
conn = sqlite3.connect(DB_PATH, check_same_thread=False)
|
| 28 |
+
|
| 29 |
+
conn.execute("""
|
| 30 |
+
CREATE TABLE IF NOT EXISTS sessions (
|
| 31 |
+
id TEXT PRIMARY KEY,
|
| 32 |
+
name TEXT,
|
| 33 |
+
email TEXT,
|
| 34 |
+
start_time TEXT,
|
| 35 |
+
end_time TEXT
|
| 36 |
+
);
|
| 37 |
+
""")
|
| 38 |
+
|
| 39 |
+
return conn
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
@router.post("/login")
|
| 43 |
+
def login(name: str = Form(...), email: str = Form(...)):
|
| 44 |
+
|
| 45 |
+
sid = str(uuid.uuid4())
|
| 46 |
+
with get_conn() as conn:
|
| 47 |
+
conn.execute(
|
| 48 |
+
"INSERT INTO sessions (id, name, email, start_time, end_time) VALUES (?, ?, ?, ?, ?)",
|
| 49 |
+
(sid, name, email, datetime.now().isoformat(), None)
|
| 50 |
+
)
|
| 51 |
+
|
| 52 |
+
return {"session_id": sid, "status": "ok"}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
@router.post("/logout")
|
| 56 |
+
def logout(session_id: str = Form(...)):
|
| 57 |
+
with get_conn() as conn:
|
| 58 |
+
conn.execute(
|
| 59 |
+
"UPDATE sessions SET end_time = ? WHERE id = ? AND end_time is NULL",
|
| 60 |
+
(datetime.now().isoformat(), session_id)
|
| 61 |
+
|
| 62 |
+
)
|
| 63 |
+
return {"status": "ok"}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
# def init_db():
|
| 73 |
+
# """
|
| 74 |
+
# Initializes the SQLite database and creates the `sessions` table if it does not already exist.
|
| 75 |
+
# The `sessions` table stores:
|
| 76 |
+
# - id (str): Unique identifier for the session (UUID)
|
| 77 |
+
# - name (str): Name of the user
|
| 78 |
+
# - email (str): Email of the user
|
| 79 |
+
# - start_time (str): ISO formatted string marking when the session started
|
| 80 |
+
# - end_time (str): ISO formatted string marking when the session ended (nullable)
|
| 81 |
+
# """
|
| 82 |
+
# conn = sqlite3.connect("sessions.db")
|
| 83 |
+
# cursor = conn.cursor()
|
| 84 |
+
# cursor.execute("""
|
| 85 |
+
# CREATE TABLE IF NOT EXISTS sessions (
|
| 86 |
+
# id TEXT PRIMARY KEY,
|
| 87 |
+
# name TEXT,
|
| 88 |
+
# email TEXT,
|
| 89 |
+
# start_time TEXT,
|
| 90 |
+
# end_time TEXT
|
| 91 |
+
# )
|
| 92 |
+
# """)
|
| 93 |
+
# conn.commit()
|
| 94 |
+
# conn.close()
|
| 95 |
+
#
|
| 96 |
+
#
|
| 97 |
+
# # Initialize the database on module load
|
| 98 |
+
# init_db()
|
| 99 |
+
#
|
| 100 |
+
#
|
| 101 |
+
# class LogoutRequest(BaseModel):
|
| 102 |
+
# """
|
| 103 |
+
# Request model for logging out a session.
|
| 104 |
+
# Expects:
|
| 105 |
+
# - session_id (str): The unique identifier of the session to be terminated.
|
| 106 |
+
# """
|
| 107 |
+
# session_id: str
|
| 108 |
+
#
|
| 109 |
+
#
|
| 110 |
+
# @router.post("/login")
|
| 111 |
+
# async def login(name: str = Form(...), email: str = Form(...)):
|
| 112 |
+
# """
|
| 113 |
+
# Handles user login.
|
| 114 |
+
#
|
| 115 |
+
# - Accepts user `name` and `email` as form data.
|
| 116 |
+
# - Generates a unique session ID using UUID.
|
| 117 |
+
# - Captures the session's start time in UTC (ISO format).
|
| 118 |
+
# - Stores the session details in the SQLite database (`sessions` table).
|
| 119 |
+
# - Returns the generated session ID and the session start time.
|
| 120 |
+
#
|
| 121 |
+
# This function essentially begins a new user session.
|
| 122 |
+
# """
|
| 123 |
+
# session_id = str(uuid.uuid4())
|
| 124 |
+
# start_time = datetime.utcnow().isoformat()
|
| 125 |
+
#
|
| 126 |
+
# conn = sqlite3.connect("sessions.db")
|
| 127 |
+
# cursor = conn.cursor()
|
| 128 |
+
# cursor.execute(
|
| 129 |
+
# "INSERT INTO sessions (id, name, email, start_time, end_time) VALUES (?, ?, ?, ?, ?)",
|
| 130 |
+
# (session_id, name, email, start_time, None),
|
| 131 |
+
# )
|
| 132 |
+
# conn.commit()
|
| 133 |
+
# conn.close()
|
| 134 |
+
#
|
| 135 |
+
# return {"session_id": session_id, "start_time": start_time}
|
| 136 |
+
#
|
| 137 |
+
#
|
| 138 |
+
# @router.post("/logout")
|
| 139 |
+
# async def logout(request: LogoutRequest):
|
| 140 |
+
# """
|
| 141 |
+
# Handles user logout.
|
| 142 |
+
#
|
| 143 |
+
# - Accepts a `LogoutRequest` object containing the session ID.
|
| 144 |
+
# - Records the current UTC time as the session's end time (ISO format).
|
| 145 |
+
# - Updates the corresponding session record in the database by setting its `end_time`.
|
| 146 |
+
# - Returns a confirmation message along with the recorded end time.
|
| 147 |
+
#
|
| 148 |
+
# This function effectively ends a user session.
|
| 149 |
+
# """
|
| 150 |
+
# end_time = datetime.utcnow().isoformat()
|
| 151 |
+
#
|
| 152 |
+
# conn = sqlite3.connect("sessions.db")
|
| 153 |
+
# cursor = conn.cursor()
|
| 154 |
+
# cursor.execute(
|
| 155 |
+
# "UPDATE sessions SET end_time = ? WHERE id = ?",
|
| 156 |
+
# (end_time, request.session_id),
|
| 157 |
+
# )
|
| 158 |
+
# conn.commit()
|
| 159 |
+
# conn.close()
|
| 160 |
+
#
|
| 161 |
+
# return {"message": "Session ended", "end_time": end_time}
|
backend/routes/detect.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
detect.py
|
| 3 |
+
|
| 4 |
+
This module defines image-processing routes and functions for detecting circles and text
|
| 5 |
+
from uploaded images using OpenCV and EasyOCR.
|
| 6 |
+
|
| 7 |
+
Key functionalities:
|
| 8 |
+
- Detect circular regions in an image and extract text inside/near them.
|
| 9 |
+
- Detect textual regions across the entire image (excluding numeric-only text and quotes).
|
| 10 |
+
- Provide a FastAPI endpoint (`POST /`) that accepts an image file and returns
|
| 11 |
+
detected circles with text plus extracted non-numeric text regions.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from fastapi import APIRouter, File, UploadFile
|
| 15 |
+
import cv2
|
| 16 |
+
import numpy as np
|
| 17 |
+
import easyocr
|
| 18 |
+
import re
|
| 19 |
+
|
| 20 |
+
# Initialize FastAPI router for detection-related endpoints
|
| 21 |
+
router = APIRouter()
|
| 22 |
+
|
| 23 |
+
# Initialize EasyOCR reader (supports English by default)
|
| 24 |
+
reader = easyocr.Reader(['en'])
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def detect_circles_with_text_from_image_bytes(image_bytes):
|
| 28 |
+
"""
|
| 29 |
+
Detects circular shapes in the given image and extracts text within/around each circle.
|
| 30 |
+
|
| 31 |
+
Steps:
|
| 32 |
+
1. Convert image bytes into an OpenCV image.
|
| 33 |
+
2. Convert to grayscale for circle detection.
|
| 34 |
+
3. Use Hough Circle Transform to detect circles.
|
| 35 |
+
4. For each detected circle:
|
| 36 |
+
- Crop the circular region with some padding.
|
| 37 |
+
- Perform OCR (EasyOCR) on the cropped region.
|
| 38 |
+
- Identify possible `page_number` (format: a<digits>.<digits>) and
|
| 39 |
+
`circle_text` (purely numeric).
|
| 40 |
+
- Collect raw texts recognized in that region.
|
| 41 |
+
5. Return a structured list of circles with metadata.
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
List of dictionaries containing:
|
| 45 |
+
- id (int): Circle index
|
| 46 |
+
- x, y (int): Circle center coordinates
|
| 47 |
+
- r (int): Circle radius
|
| 48 |
+
- page_number (str): Extracted page number if detected
|
| 49 |
+
- circle_text (str): Extracted numeric text if detected
|
| 50 |
+
- raw_texts (list): All OCR results from that circle region
|
| 51 |
+
"""
|
| 52 |
+
try:
|
| 53 |
+
nparr = np.frombuffer(image_bytes, np.uint8)
|
| 54 |
+
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 55 |
+
|
| 56 |
+
if img is None:
|
| 57 |
+
print("Failed to decode image")
|
| 58 |
+
return []
|
| 59 |
+
|
| 60 |
+
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
|
| 61 |
+
|
| 62 |
+
# Detect circles using Hough Circle Transform
|
| 63 |
+
circles = cv2.HoughCircles(
|
| 64 |
+
gray,
|
| 65 |
+
cv2.HOUGH_GRADIENT,
|
| 66 |
+
dp=1.2,
|
| 67 |
+
minDist=20,
|
| 68 |
+
param1=50,
|
| 69 |
+
param2=100,
|
| 70 |
+
minRadius=50,
|
| 71 |
+
maxRadius=100
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
results = []
|
| 75 |
+
if circles is not None:
|
| 76 |
+
circles = np.round(circles[0, :]).astype("int")
|
| 77 |
+
|
| 78 |
+
for i, (x, y, r) in enumerate(circles):
|
| 79 |
+
# Crop region around circle with padding
|
| 80 |
+
top = max(y - r - 20, 0)
|
| 81 |
+
bottom = min(y + r + 20, img.shape[0])
|
| 82 |
+
left = max(x - r - 20, 0)
|
| 83 |
+
right = min(x + r + 20, img.shape[1])
|
| 84 |
+
crop = img[top:bottom, left:right]
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
ocr_result = reader.readtext(crop)
|
| 88 |
+
texts = [res[1].strip() for res in ocr_result]
|
| 89 |
+
except Exception as e:
|
| 90 |
+
print(f"OCR error for circle {i}: {e}")
|
| 91 |
+
texts = []
|
| 92 |
+
|
| 93 |
+
# Extract structured info
|
| 94 |
+
page_number, circle_text = "", ""
|
| 95 |
+
for t in texts:
|
| 96 |
+
t_clean = t.strip()
|
| 97 |
+
|
| 98 |
+
if re.match(r"^a\d+\.\d+$", t_clean, re.IGNORECASE):
|
| 99 |
+
page_number = t_clean
|
| 100 |
+
|
| 101 |
+
elif re.match(r"^\d+$", t_clean):
|
| 102 |
+
circle_text = t_clean
|
| 103 |
+
|
| 104 |
+
results.append({
|
| 105 |
+
"id": i + 1,
|
| 106 |
+
"x": int(x),
|
| 107 |
+
"y": int(y),
|
| 108 |
+
"r": int(r),
|
| 109 |
+
"page_number": page_number,
|
| 110 |
+
"circle_text": circle_text,
|
| 111 |
+
"raw_texts": texts
|
| 112 |
+
})
|
| 113 |
+
|
| 114 |
+
return results
|
| 115 |
+
|
| 116 |
+
except Exception as e:
|
| 117 |
+
print(f"Circle detection error: {e}")
|
| 118 |
+
return []
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def detect_text_from_image_bytes(image_bytes):
|
| 122 |
+
"""
|
| 123 |
+
Detects text regions from the entire image, excluding numeric-only text and
|
| 124 |
+
strings with quotes.
|
| 125 |
+
|
| 126 |
+
Steps:
|
| 127 |
+
1. Convert image bytes to an OpenCV image.
|
| 128 |
+
2. Run EasyOCR to detect text with bounding boxes.
|
| 129 |
+
3. Skip text if it:
|
| 130 |
+
- Contains quotes (single/double).
|
| 131 |
+
- Contains any digits.
|
| 132 |
+
4. Collect bounding box coordinates and the cleaned text.
|
| 133 |
+
|
| 134 |
+
Returns:
|
| 135 |
+
List of dictionaries containing:
|
| 136 |
+
- id (int): Text index
|
| 137 |
+
- x1, y1, x2, y2 (int): Bounding box coordinates
|
| 138 |
+
- text (str): Extracted text string
|
| 139 |
+
"""
|
| 140 |
+
try:
|
| 141 |
+
nparr = np.frombuffer(image_bytes, np.uint8)
|
| 142 |
+
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 143 |
+
|
| 144 |
+
if img is None:
|
| 145 |
+
print("Failed to decode image for text detection")
|
| 146 |
+
return []
|
| 147 |
+
|
| 148 |
+
results = reader.readtext(img)
|
| 149 |
+
text_boxes = []
|
| 150 |
+
|
| 151 |
+
for i, (bbox, text, confidence) in enumerate(results):
|
| 152 |
+
# Skip text containing quotes or numbers
|
| 153 |
+
if "'" in text or '"' in text:
|
| 154 |
+
continue
|
| 155 |
+
|
| 156 |
+
if any(char.isdigit() for char in text):
|
| 157 |
+
continue
|
| 158 |
+
|
| 159 |
+
try:
|
| 160 |
+
# Extract bounding box coordinates
|
| 161 |
+
x_coords = [point[0] for point in bbox]
|
| 162 |
+
y_coords = [point[1] for point in bbox]
|
| 163 |
+
|
| 164 |
+
x1, x2 = int(min(x_coords)), int(max(x_coords))
|
| 165 |
+
y1, y2 = int(min(y_coords)), int(max(y_coords))
|
| 166 |
+
|
| 167 |
+
text_boxes.append({
|
| 168 |
+
"id": i + 1,
|
| 169 |
+
"x1": x1,
|
| 170 |
+
"y1": y1,
|
| 171 |
+
"x2": x2,
|
| 172 |
+
"y2": y2,
|
| 173 |
+
"text": text.strip()
|
| 174 |
+
})
|
| 175 |
+
except Exception as e:
|
| 176 |
+
print(f"Error processing text box {i}: {e}")
|
| 177 |
+
continue
|
| 178 |
+
|
| 179 |
+
return text_boxes
|
| 180 |
+
|
| 181 |
+
except Exception as e:
|
| 182 |
+
print(f"Text detection error: {e}")
|
| 183 |
+
return []
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@router.post("/")
|
| 187 |
+
async def detect_circles(file: UploadFile = File(...)):
|
| 188 |
+
"""
|
| 189 |
+
FastAPI endpoint to detect circles and text from an uploaded image.
|
| 190 |
+
|
| 191 |
+
Steps:
|
| 192 |
+
1. Accepts an image file via POST request.
|
| 193 |
+
2. Reads image bytes.
|
| 194 |
+
3. Runs circle detection (with OCR inside circles).
|
| 195 |
+
4. Runs general text detection across the entire image.
|
| 196 |
+
5. Returns results as a JSON response containing:
|
| 197 |
+
- circles: List of detected circles with text info
|
| 198 |
+
- texts: List of detected text regions outside circles
|
| 199 |
+
"""
|
| 200 |
+
try:
|
| 201 |
+
image_bytes = await file.read()
|
| 202 |
+
circles_with_text = detect_circles_with_text_from_image_bytes(image_bytes)
|
| 203 |
+
texts = detect_text_from_image_bytes(image_bytes)
|
| 204 |
+
|
| 205 |
+
return {"circles": circles_with_text, "texts": texts}
|
| 206 |
+
|
| 207 |
+
except Exception as e:
|
| 208 |
+
print(f"Detection endpoint error: {e}")
|
| 209 |
+
return {"error": str(e), "circles": [], "texts": []}
|
backend/routes/llm.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
llm.py
|
| 3 |
+
|
| 4 |
+
This module defines a FastAPI router for interacting with a Large Language Model (LLM)
|
| 5 |
+
using the Groq API.
|
| 6 |
+
|
| 7 |
+
Key functionalities:
|
| 8 |
+
- Accept user-provided text.
|
| 9 |
+
- Query an LLM (LLaMA-3.1-8b-instant) via Groq.
|
| 10 |
+
- Return a simplified explanation of the input text in less than 100 words.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
from fastapi import APIRouter, HTTPException
|
| 14 |
+
from pydantic import BaseModel
|
| 15 |
+
from groq import Groq
|
| 16 |
+
import os
|
| 17 |
+
from dotenv import load_dotenv
|
| 18 |
+
|
| 19 |
+
# Load environment variables from .env file
|
| 20 |
+
load_dotenv()
|
| 21 |
+
api_key = os.getenv("GROQ_API_KEY")
|
| 22 |
+
|
| 23 |
+
# Initialize Groq client with API key
|
| 24 |
+
client = Groq(api_key=api_key)
|
| 25 |
+
|
| 26 |
+
# Initialize FastAPI router for LLM endpoints
|
| 27 |
+
router = APIRouter()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
class LLMRequest(BaseModel):
|
| 31 |
+
"""
|
| 32 |
+
Request model for generating simplified information from text.
|
| 33 |
+
|
| 34 |
+
Fields:
|
| 35 |
+
- content (str): The raw text content provided by the user.
|
| 36 |
+
"""
|
| 37 |
+
content: str
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
def generate_info_from_llm(text: str) -> str:
|
| 41 |
+
"""
|
| 42 |
+
Sends the given text to the Groq LLM (LLaMA-3.1-8b-instant) and requests a
|
| 43 |
+
simplified explanation.
|
| 44 |
+
|
| 45 |
+
Steps:
|
| 46 |
+
1. Create a chat completion request with the model.
|
| 47 |
+
2. Prompt the LLM to explain the input text in simple terms (under 100 words).
|
| 48 |
+
3. Extract and return the model's response as a string.
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
text (str): The text to be explained.
|
| 52 |
+
|
| 53 |
+
Returns:
|
| 54 |
+
str: Simplified explanation of the input text.
|
| 55 |
+
"""
|
| 56 |
+
completion = client.chat.completions.create(
|
| 57 |
+
model="llama-3.1-8b-instant",
|
| 58 |
+
messages=[
|
| 59 |
+
{
|
| 60 |
+
"role": "user",
|
| 61 |
+
"content": f"Explain in simple terms the meaning of the content {text} in less than 100 words"
|
| 62 |
+
}
|
| 63 |
+
]
|
| 64 |
+
)
|
| 65 |
+
return completion.choices[0].message.content.strip()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@router.post("/generate_info")
|
| 69 |
+
async def generate_info_endpoint(request: LLMRequest):
|
| 70 |
+
"""
|
| 71 |
+
FastAPI endpoint to generate simplified information from user text.
|
| 72 |
+
|
| 73 |
+
Steps:
|
| 74 |
+
1. Accept a POST request with JSON containing `content`.
|
| 75 |
+
2. Pass the content to the `generate_info_from_llm` function.
|
| 76 |
+
3. Return the simplified explanation in JSON format.
|
| 77 |
+
"""
|
| 78 |
+
try:
|
| 79 |
+
info = generate_info_from_llm(request.content)
|
| 80 |
+
return {"info": info}
|
| 81 |
+
except Exception as e:
|
| 82 |
+
raise HTTPException(status_code=500, detail=str(e))
|
backend/routes/regions_detect.py
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
region_detection.py
|
| 3 |
+
|
| 4 |
+
This module provides functionality to detect text inside a specific region
|
| 5 |
+
of an uploaded image using OpenCV and EasyOCR.
|
| 6 |
+
|
| 7 |
+
Key functionalities:
|
| 8 |
+
- Extract a specified rectangular region from an image.
|
| 9 |
+
- Perform OCR (Optical Character Recognition) on the cropped region.
|
| 10 |
+
- Return detected text boxes along with their coordinates.
|
| 11 |
+
- Return the cropped region as a base64-encoded image.
|
| 12 |
+
"""
|
| 13 |
+
|
| 14 |
+
from fastapi import APIRouter, File, UploadFile, Form
|
| 15 |
+
import cv2
|
| 16 |
+
import numpy as np
|
| 17 |
+
import easyocr
|
| 18 |
+
import base64
|
| 19 |
+
|
| 20 |
+
# Initialize FastAPI router
|
| 21 |
+
router = APIRouter()
|
| 22 |
+
|
| 23 |
+
# Initialize EasyOCR reader (English language)
|
| 24 |
+
reader = easyocr.Reader(['en'])
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def detect_text_in_region(img, region):
|
| 28 |
+
"""
|
| 29 |
+
Detects text within a specified rectangular region of an image.
|
| 30 |
+
|
| 31 |
+
Steps:
|
| 32 |
+
1. Crop the region of interest (ROI) from the original image.
|
| 33 |
+
2. Run EasyOCR to detect text inside the cropped region.
|
| 34 |
+
3. Adjust bounding box coordinates relative to the original image.
|
| 35 |
+
4. Convert the cropped region to base64 for return.
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
img (numpy.ndarray): The original OpenCV image.
|
| 39 |
+
region (tuple): A tuple (x, y, w, h) specifying the top-left
|
| 40 |
+
coordinates, width, and height of the region.
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
tuple:
|
| 44 |
+
- text_boxes (list of dict): Each dict contains:
|
| 45 |
+
- id (int): Box index
|
| 46 |
+
- x1, y1 (int): Top-left coordinates
|
| 47 |
+
- x2, y2 (int): Bottom-right coordinates
|
| 48 |
+
- text (str): Detected text
|
| 49 |
+
- crop_base64 (str): Base64-encoded cropped image.
|
| 50 |
+
"""
|
| 51 |
+
x, y, w, h = region
|
| 52 |
+
crop = img[y:y+h, x:x+w]
|
| 53 |
+
results = reader.readtext(crop)
|
| 54 |
+
text_boxes = []
|
| 55 |
+
|
| 56 |
+
for i, (bbox, text, prob) in enumerate(results):
|
| 57 |
+
(top_left, _, bottom_right, _) = bbox
|
| 58 |
+
top_left = [int(top_left[0] + x), int(top_left[1] + y)]
|
| 59 |
+
bottom_right = [int(bottom_right[0] + x), int(bottom_right[1] + y)]
|
| 60 |
+
text_boxes.append({
|
| 61 |
+
"id": i+1,
|
| 62 |
+
"x1": top_left[0],
|
| 63 |
+
"y1": top_left[1],
|
| 64 |
+
"x2": bottom_right[0],
|
| 65 |
+
"y2": bottom_right[1],
|
| 66 |
+
"text": text
|
| 67 |
+
})
|
| 68 |
+
|
| 69 |
+
# Convert cropped region to base64 string
|
| 70 |
+
_, buffer = cv2.imencode(".jpg", crop)
|
| 71 |
+
crop_base64 = base64.b64encode(buffer).decode("utf-8")
|
| 72 |
+
|
| 73 |
+
return text_boxes, crop_base64
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.post("/region-detect")
|
| 77 |
+
async def detect_in_region(
|
| 78 |
+
file: UploadFile = File(...),
|
| 79 |
+
x: int = Form(...),
|
| 80 |
+
y: int = Form(...),
|
| 81 |
+
w: int = Form(...),
|
| 82 |
+
h: int = Form(...)
|
| 83 |
+
):
|
| 84 |
+
"""
|
| 85 |
+
FastAPI endpoint to detect text within a user-specified region of an uploaded image.
|
| 86 |
+
|
| 87 |
+
Steps:
|
| 88 |
+
1. Accepts an image file and region coordinates (x, y, w, h).
|
| 89 |
+
2. Decodes the image into an OpenCV format.
|
| 90 |
+
3. Calls `detect_text_in_region` to extract text and crop region.
|
| 91 |
+
4. Returns:
|
| 92 |
+
- Detected text boxes with coordinates and recognized text.
|
| 93 |
+
- Cropped image region as a base64 string.
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
"""
|
| 97 |
+
image_bytes = await file.read()
|
| 98 |
+
nparr = np.frombuffer(image_bytes, np.uint8)
|
| 99 |
+
img = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
|
| 100 |
+
|
| 101 |
+
detections, crop_base64 = detect_text_in_region(img, (x, y, w, h))
|
| 102 |
+
return {
|
| 103 |
+
"detections": detections,
|
| 104 |
+
"cropped_image": f"data:image/jpeg;base64,{crop_base64}"
|
| 105 |
+
}
|
backend/sessions.db
ADDED
|
Binary file (32.8 kB). View file
|
|
|