diff --git a/REPO_INTRODUCTION.md b/REPO_INTRODUCTION.md new file mode 100644 index 0000000..6cb8305 --- /dev/null +++ b/REPO_INTRODUCTION.md @@ -0,0 +1,121 @@ +# 详细仓库介绍与使用说明 + +本文档将深入介绍本仓库的内容、工作原理、硬件接口定义以及功能切换方法。 + +## 1. 仓库内容概览 + +本仓库是一个 AI 智能盲人眼镜系统的完整实现,包含: +- **ESP32 固件** (`compile/`): 负责音视频采集、IMU 数据传输以及音频播放。 +- **Python 后端服务** (`app_main.py` 等): 负责核心 AI 逻辑处理,包括物体检测、盲道分割、语音识别与合成等。 +- **Web 监控界面** (`templates/`, `static/`): 提供实时视频流预览与调试功能。 +- **模型文件** (`model/`): 存放 YOLO、MediaPipe 等预训练模型。 + +详细的文件结构说明请参考 `PROJECT_STRUCTURE.md`。 + +## 2. 系统工作原理 + +系统采用 **端云协同** (Edge-Cloud Collaboration) 架构: + +1. **感知层 (ESP32)**: + - **视频采集**: ESP32-CAM 通过 WebSocket (`/ws/camera`) 实时推送 JPEG 图像流。 + - **音频采集**: 通过 PDM 麦克风采集音频,经 WebSocket (`/ws_audio`) 上传至服务器。 + - **姿态感知**: 通过 SPI 接口读取 ICM42688 IMU 数据,经 UDP 发送至服务器。 + - **音频播放**: 接收服务器生成的音频流,通过 I2S 接口驱动扬声器播放。 + +2. **计算层 (Python Server)**: + - **主控服务 (`app_main.py`)**: 基于 FastAPI 构建,负责 WebSocket 连接管理与任务分发。 + - **语音交互 (`asr_core.py`, `omni_client.py`)**: 使用阿里云 DashScope 进行语音识别 (ASR) 与大模型对话 (Qwen-Omni)。 + - **视觉导航 (`workflow_*.py`)**: + - **盲道导航**: 使用 YOLO 分割模型识别盲道区域,结合光流算法进行稳像,计算行走方向。 + - **过马路辅助**: 识别斑马线与红绿灯,引导用户安全通过。 + - **物品查找**: 结合 YOLO-E 开放词汇检测与 MediaPipe 手部追踪,引导用户抓取物品。 + - **状态管理 (`navigation_master.py`)**: 维护系统全局状态机,根据用户语音指令切换不同模式。 + +3. **交互层**: + - **语音反馈**: 系统通过 TTS (文本转语音) 或大模型直接生成语音,指导用户行动。 + - **Web 界面**: 开发者可通过浏览器实时查看摄像头画面、识别结果与系统状态。 + +## 3. 硬件接口定义 (Pinout) + +本系统默认支持 **Seeed Studio XIAO ESP32S3** 开发板。引脚定义如下(基于 `compile/compile.ino` 和 `compile/camera_pins.h`): + +### 3.1 摄像头接口 (DVP) +| 信号 | GPIO (XIAO ESP32S3) | 描述 | +| :--- | :--- | :--- | +| PWDN | -1 | 断电控制 (未使用) | +| RESET| -1 | 复位控制 (未使用) | +| XCLK | 10 | 外部时钟 | +| SIOD | 40 | SCCB 数据 (I2C SDA) | +| SIOC | 39 | SCCB 时钟 (I2C SCL) | +| Y9 | 48 | 数据位 9 | +| Y8 | 11 | 数据位 8 | +| Y7 | 12 | 数据位 7 | +| Y6 | 14 | 数据位 6 | +| Y5 | 16 | 数据位 5 | +| Y4 | 18 | 数据位 4 | +| Y3 | 17 | 数据位 3 | +| Y2 | 15 | 数据位 2 | +| VSYNC| 38 | 垂直同步 | +| HREF | 47 | 水平参考 | +| PCLK | 13 | 像素时钟 | + +### 3.2 麦克风接口 (PDM RX) +| 信号 | GPIO | 描述 | +| :--- | :--- | :--- | +| CLK | 42 | PDM 时钟 | +| DAT | 41 | PDM 数据 | + +### 3.3 扬声器接口 (I2S TX -> MAX98357A) +| 信号 | GPIO | 描述 | +| :--- | :--- | :--- | +| BCLK | 7 | 位时钟 | +| LRCK | 8 | 左右声道时钟 | +| DIN | 9 | 数据输入 | + +### 3.4 IMU 接口 (SPI -> ICM42688) +| 信号 | GPIO | 描述 | +| :--- | :--- | :--- | +| SCK | 1 (D0) | SPI 时钟 | +| MOSI | 2 (D1) | 主出从入 | +| MISO | 3 (D2) | 主入从出 | +| CS | 4 (D3) | 片选信号 | + +> **注意**: 如果使用其他 ESP32 开发板(如 AI-Thinker),请参考 `compile/camera_pins.h` 修改引脚定义。 + +## 4. 功能切换方法 + +系统主要通过 **语音指令** 进行功能切换。由于没有物理按键进行模式选择,用户需要直接说出特定关键词来触发相应功能。 + +### 4.1 核心状态机 (`NavigationMaster`) +系统通过 `navigation_master.py` 中的状态机管理当前模式。主要状态包括: +- `IDLE`: 空闲状态(默认) +- `CHAT`: 智能对话模式 +- `BLINDPATH_NAV`: 盲道导航模式 +- `CROSSING`: 过马路模式 +- `ITEM_SEARCH`: 物品查找模式 +- `TRAFFIC_LIGHT_DETECTION`: 红绿灯检测模式 + +### 4.2 语音指令列表 + +用户说话时无需唤醒词,直接说出以下指令即可切换功能: + +| 功能模式 | 触发指令 (关键词) | 描述 | +| :--- | :--- | :--- | +| **盲道导航** | "开始导航", "盲道导航" | 启动盲道识别与行走引导 | +| | "停止导航", "结束导航" | 退出盲道导航,返回空闲状态 | +| **过马路辅助** | "开始过马路", "帮我过马路" | 启动斑马线识别与过街引导 | +| | "过马路结束", "结束过马路" | 退出过马路模式 | +| **红绿灯检测** | "检测红绿灯", "看红绿灯" | 启动红绿灯识别与播报 | +| | "停止检测", "停止红绿灯" | 停止红绿灯检测 | +| **物品查找** | "帮我找一下 [物品名]" | 例如 "帮我找一下红牛",启动物品搜索 | +| | "找到了", "拿到了" | 确认物品已找到,退出查找模式 | +| **智能对话** | "帮我看看这是什么" | 拍摄当前画面并进行 AI 识别描述 | +| | (其他自然语言问题) | 进行常规 AI 对话 | + +### 4.3 自动状态流转 +除了语音指令外,部分功能会自动流转状态: +- 在 **盲道导航** 中,如果检测到路口或斑马线,系统可能会自动提示或辅助切换。 +- 在 **过马路** 模式下,系统会自动经历 "寻找斑马线" -> "等待绿灯" -> "过马路" -> "寻找对面盲道" 的流程。 + +--- +希望这份文档能帮助您更好地理解与使用本仓库!如有疑问,请查阅 `README.md` 或提 Issue 交流。 diff --git a/ineedyou/full_stack_web/.gitignore b/ineedyou/full_stack_web/.gitignore new file mode 100644 index 0000000..a1e1416 --- /dev/null +++ b/ineedyou/full_stack_web/.gitignore @@ -0,0 +1,18 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class + +# Node +node_modules/ +dist/ +.env.local + +# Django +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Docker +.dockerignore diff --git a/ineedyou/full_stack_web/ARCHITECTURE.md b/ineedyou/full_stack_web/ARCHITECTURE.md new file mode 100644 index 0000000..bdad511 --- /dev/null +++ b/ineedyou/full_stack_web/ARCHITECTURE.md @@ -0,0 +1,106 @@ +# AI Glass Full Stack Web System 架构与文件功能说明 + +本文件详细说明 `ineedyou/full_stack_web` 文件夹中各个文件与目录的具体功能与职责。 + +## 目录结构概览 + +```text +ineedyou/full_stack_web/ +├── docker-compose.yml # Docker 编排配置,一键启动整个系统的核心文件 +├── README.md # 简要使用说明与启动指南 +├── ARCHITECTURE.md # 本文件,详细的架构与文件功能说明 +├── backend/ # 后端目录 (Django + Django REST Framework) +└── frontend/ # 前端目录 (React + Vite + Material UI) +``` + +--- + +## 1. 后端 (Backend - Django) + +后端主要负责处理业务逻辑、数据库交互以及提供前端所需的 RESTful API。 + +* **`backend/Dockerfile`** + * **功能**: 定义后端容器的构建过程。 + * **行为**: 基于 Python 3.9,安装 `requirements.txt` 中的依赖,并设置执行 `entrypoint.sh` 脚本作为启动入口。 +* **`backend/requirements.txt`** + * **功能**: 列出后端项目所需的所有 Python 第三方库(如 django, djangorestframework, psycopg2 等)。 +* **`backend/manage.py`** + * **功能**: Django 项目的标准命令行工具,用于执行数据库迁移、运行开发服务器、创建应用等。 +* **`backend/entrypoint.sh`** + * **功能**: 后端容器的启动脚本。 + * **行为**: + 1. 轮询等待 PostgreSQL 数据库启动就绪。 + 2. 自动执行数据库迁移 (`makemigrations` 和 `migrate`)。 + 3. **自动创建默认的系统管理员 (`admin`) 和普通用户 (`user1`),并重置他们的密码确保可登录,同时为他们生成 API Token。** + 4. 启动 Django 服务。 + +### 后端子模块 (Apps) + +#### `backend/config/` (主配置) +* **`settings.py`**: Django 核心配置文件,包含数据库连接信息 (读取 Docker 环境变量)、跨域设置 (CORS)、已安装的 App 列表等。 +* **`urls.py`**: 主路由文件,将 API 请求分发到 `users` 和 `devices` 模块,并配置了 Swagger API 接口文档路由。 +* **`wsgi.py` / `asgi.py`**: Web 服务器网关接口,用于生产环境部署。 + +#### `backend/users/` (用户与认证) +* **`models.py`**: 继承了 Django 的默认 User 模型,用于区分管理员 (Admin) 和普通用户。 +* **`views.py`**: 提供登录接口 (`CustomAuthToken`),验证账号密码并返回 Token;提供用户信息获取接口 (`UserInfoView`)。 +* **`urls.py`**: 定义认证相关的路由(如 `/api/auth/login/`)。 + +#### `backend/devices/` (设备与日志管理) +* **`models.py`**: + * `Device`: 定义 AI 眼镜设备的数据结构(设备ID、名称、所属用户、在线状态等)。 + * `DeviceLog`: 定义设备上传的日志结构(时间戳、日志级别、OCR文本等记录)。 +* **`serializers.py`**: 数据序列化器,负责将 Django 模型对象与 JSON 数据相互转换,供 API 使用。 +* **`views.py`**: 提供设备与日志的增删改查 API 接口。内置权限控制:管理员可查看所有数据,普通用户只能查看绑定到自己账号的设备与日志。 +* **`urls.py`**: 定义设备管理的路由(如 `/api/devices/` 和 `/api/logs/`)。 + +--- + +## 2. 前端 (Frontend - React) + +前端主要负责与用户进行交互,展示数据并调用后端 API。 + +* **`frontend/Dockerfile`** + * **功能**: 定义前端容器的多阶段构建过程。 + * **行为**: 第一阶段使用 Node.js 编译 React TypeScript 代码;第二阶段将编译好的静态文件放入 Nginx 服务器中进行部署。 +* **`frontend/nginx.conf`** + * **功能**: Nginx 配置文件,用于处理单页应用 (SPA) 的路由回退机制,确保在直接访问子路由时不会报 404 错误。 +* **`frontend/package.json` / `package-lock.json`** + * **功能**: Node.js 项目配置文件,定义了项目依赖(React, Material UI, Axios 等)以及打包脚本 (`npm run build`)。 +* **`frontend/vite.config.ts`** + * **功能**: Vite 构建工具的配置文件,提供极速的冷启动和热更新功能。 +* **`frontend/tsconfig.json` / `tsconfig.node.json`** + * **功能**: TypeScript 编译器的配置文件,确保代码的类型安全。 + +### 前端源码 (`frontend/src/`) + +* **`main.tsx`** + * **功能**: React 应用的入口文件。 + * **行为**: 挂载根组件,并全局注入 Material UI 的暗色主题 (Dark Theme)。 +* **`App.tsx`** + * **功能**: 主组件,负责整个前端的路由配置 (React Router)。 + * **行为**: 定义了 `/login` 和 `/` (Dashboard) 路由,并通过 `PrivateRoute` 保护需要登录的页面。 +* **`context/AuthContext.tsx`** + * **功能**: 全局状态管理 (React Context)。 + * **行为**: 管理用户的登录状态、存储 Token、从后端拉取用户信息,并提供全局可用的 `login` 和 `logout` 函数。 + +### 前端页面 (`frontend/src/pages/`) + +* **`Login.tsx`** + * **功能**: 用户登录页面。 + * **行为**: 渲染登录表单,收集用户名和密码,调用 `AuthContext` 的 `login` 方法向后端发起请求。如果失败会显示 "Invalid credentials"。 +* **`Dashboard.tsx`** + * **功能**: 主控制台页面。 + * **行为**: 登录成功后跳转至此。根据用户角色 (Admin 或 User) 展示不同的欢迎语。向后端请求 `/api/devices/` 和 `/api/logs/` 接口,并将获取到的设备列表和操作日志渲染到 Material UI 的数据表格中。 + +--- + +## 3. 部署与运行 (Docker Compose) + +* **`docker-compose.yml`** + * **功能**: 容器编排文件,负责将上述前后端及数据库串联起来。 + * **定义了三个服务**: + 1. **`db`**: 运行 PostgreSQL 15 数据库。将数据持久化挂载到 `postgres_data` volume 中,防止重启数据丢失。 + 2. **`backend`**: 构建后端镜像并运行。暴露 `8000` 端口,连接到 `db` 服务,并注入连接数据库所需的账号密码环境变量。 + 3. **`frontend`**: 构建前端 Nginx 镜像并运行。暴露 `3000` 端口供浏览器访问。 + * **依赖关系**: `frontend` 等待 `backend` 启动,`backend` 等待 `db` 就绪 (`healthcheck`)。实现了一条命令稳定启动。 diff --git a/ineedyou/full_stack_web/README.md b/ineedyou/full_stack_web/README.md new file mode 100644 index 0000000..5a06e58 --- /dev/null +++ b/ineedyou/full_stack_web/README.md @@ -0,0 +1,75 @@ +# AI Glass Full Stack Web System + +This is a comprehensive full-stack system designed to manage the AI Glass devices, users, and logs. It uses: + +- **Backend**: Django (Python) + Django REST Framework. +- **Frontend**: React (JavaScript/TypeScript) + Material UI. +- **Database**: PostgreSQL. +- **Deployment**: Docker Compose. + +## How to Run + +1. **Navigate to the folder**: + ```bash + cd ineedyou/full_stack_web + ``` + +2. **Start the services**: + ```bash + docker-compose up -d --build + ``` + This command will build the backend and frontend images, start the PostgreSQL database, and bring up the entire stack. + +3. **Access the System**: + - **Frontend (Dashboard)**: Open your browser and go to `http://localhost:3000` + - **Backend API**: `http://localhost:8000/api/` (Browsable API) + - **Admin Panel**: `http://localhost:8000/admin/` + +## Windows Troubleshooting + +If you encounter an error like: +> `open //./pipe/dockerDesktopLinuxEngine: The system cannot find the file specified.` + +This means Docker Desktop is not running or not properly configured. + +1. **Start Docker Desktop**: Ensure the Docker Desktop application is open and running in the background. The whale icon should be visible in your system tray. +2. **Wait for Engine Startup**: It may take a minute for the Docker engine to fully initialize. +3. **WSL2 Integration**: If using WSL2, go to Docker Desktop Settings -> Resources -> WSL Integration, and ensure your Linux distribution (e.g., Ubuntu) is checked. + +## Default Credentials + +### Superuser (Admin) +- **Username**: `admin` +- **Password**: `admin123` +- **Email**: `admin@example.com` + +### Regular User +- **Username**: `user1` +- **Password**: `user123` +- **Email**: `user1@example.com` + +## Features + +- **User Authentication**: Login/Register/Logout using JWT tokens. +- **Role-Based Access**: + - **Admin**: Can manage all users, view all device logs, and configure system settings. + - **User**: Can view their own device status, logs, and update profile. +- **Device Management**: Register and monitor AI Glass devices. +- **Log Viewer**: Historical data of OCR scans, navigation events, and errors. + +## Development + +- **Backend**: Located in `backend/`. Uses Django. +- **Frontend**: Located in `frontend/`. Uses React (create-react-app or Vite). +- **Database**: Data is persisted in a Docker volume `postgres_data`. + +## Stopping the System + +To stop the containers: +```bash +docker-compose down +``` +To stop and remove volumes (reset database): +```bash +docker-compose down -v +``` diff --git a/ineedyou/full_stack_web/backend/.gitattributes b/ineedyou/full_stack_web/backend/.gitattributes new file mode 100644 index 0000000..0fd5822 --- /dev/null +++ b/ineedyou/full_stack_web/backend/.gitattributes @@ -0,0 +1,2 @@ +# Force bash scripts to always use LF line endings, even on Windows +*.sh text eol=lf diff --git a/ineedyou/full_stack_web/backend/Dockerfile b/ineedyou/full_stack_web/backend/Dockerfile new file mode 100644 index 0000000..0447810 --- /dev/null +++ b/ineedyou/full_stack_web/backend/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.9-slim + +ENV PYTHONDONTWRITEBYTECODE 1 +ENV PYTHONUNBUFFERED 1 + +WORKDIR /app + +# Install netcat and dos2unix +RUN apt-get update && apt-get install -y netcat-traditional dos2unix && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt /app/ +RUN pip install --upgrade pip && \ + pip install -r requirements.txt + +COPY . /app/ + +# Convert line endings of entrypoint.sh to LF (Unix format) to fix Windows execution issues +RUN dos2unix /app/entrypoint.sh && chmod +x /app/entrypoint.sh + +# Use entrypoint script to handle migrations and user creation +ENTRYPOINT ["/app/entrypoint.sh"] diff --git a/ineedyou/full_stack_web/backend/config/__init__.py b/ineedyou/full_stack_web/backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ineedyou/full_stack_web/backend/config/asgi.py b/ineedyou/full_stack_web/backend/config/asgi.py new file mode 100644 index 0000000..787b362 --- /dev/null +++ b/ineedyou/full_stack_web/backend/config/asgi.py @@ -0,0 +1,16 @@ +""" +ASGI config for config project. + +It exposes the ASGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/ +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_asgi_application() diff --git a/ineedyou/full_stack_web/backend/config/settings.py b/ineedyou/full_stack_web/backend/config/settings.py new file mode 100644 index 0000000..8aeae47 --- /dev/null +++ b/ineedyou/full_stack_web/backend/config/settings.py @@ -0,0 +1,122 @@ +""" +Django settings for the AI Glass project. +""" + +from pathlib import Path +import os +from decouple import config + +# Build paths inside the project like this: BASE_DIR / 'subdir'. +BASE_DIR = Path(__file__).resolve().parent.parent + +# Quick-start development settings - unsuitable for production +SECRET_KEY = config('SECRET_KEY', default='django-insecure-default-key-for-dev') + +# SECURITY WARNING: don't run with debug turned on in production! +DEBUG = config('DEBUG', default=True, cast=bool) + +ALLOWED_HOSTS = ['*'] + +# Application definition +INSTALLED_APPS = [ + 'django.contrib.admin', + 'django.contrib.auth', + 'django.contrib.contenttypes', + 'django.contrib.sessions', + 'django.contrib.messages', + 'django.contrib.staticfiles', + # Third-party + 'rest_framework', + 'rest_framework.authtoken', + 'corsheaders', + 'drf_yasg', + # Local + 'users', + 'devices', +] + +MIDDLEWARE = [ + 'django.middleware.security.SecurityMiddleware', + 'django.contrib.sessions.middleware.SessionMiddleware', + 'corsheaders.middleware.CorsMiddleware', # CORS + 'django.middleware.common.CommonMiddleware', + 'django.middleware.csrf.CsrfViewMiddleware', + 'django.contrib.auth.middleware.AuthenticationMiddleware', + 'django.contrib.messages.middleware.MessageMiddleware', + 'django.middleware.clickjacking.XFrameOptionsMiddleware', +] + +ROOT_URLCONF = 'config.urls' + +TEMPLATES = [ + { + 'BACKEND': 'django.template.backends.django.DjangoTemplates', + 'DIRS': [], + 'APP_DIRS': True, + 'OPTIONS': { + 'context_processors': [ + 'django.template.context_processors.debug', + 'django.template.context_processors.request', + 'django.contrib.auth.context_processors.auth', + 'django.contrib.messages.context_processors.messages', + ], + }, + }, +] + +WSGI_APPLICATION = 'config.wsgi.application' + +# Database +DB_NAME = config('DB_NAME', default='aiglass_db') +DB_USER = config('DB_USER', default='aiglass_user') +DB_PASS = config('DB_PASS', default='aiglass_password') +DB_HOST = config('DB_HOST', default='db') +DB_PORT = config('DB_PORT', default='5432') + +DATABASES = { + 'default': { + 'ENGINE': 'django.db.backends.postgresql', + 'NAME': DB_NAME, + 'USER': DB_USER, + 'PASSWORD': DB_PASS, + 'HOST': DB_HOST, + 'PORT': DB_PORT, + } +} + +# Password validation +AUTH_PASSWORD_VALIDATORS = [ + { 'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator', }, + { 'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', }, + { 'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator', }, + { 'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator', }, +] + +# Internationalization +LANGUAGE_CODE = 'en-us' +TIME_ZONE = 'UTC' +USE_I18N = True +USE_TZ = True + +# Static files (CSS, JavaScript, Images) +STATIC_URL = 'static/' + +# Default primary key field type +DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField' + +# DRF Settings +REST_FRAMEWORK = { + 'DEFAULT_AUTHENTICATION_CLASSES': [ + 'rest_framework.authentication.TokenAuthentication', + 'rest_framework.authentication.SessionAuthentication', + ], + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.IsAuthenticated', + ], +} + +# CORS Settings +CORS_ALLOW_ALL_ORIGINS = True # For development; restrict in production + +# Custom User Model +AUTH_USER_MODEL = 'users.User' diff --git a/ineedyou/full_stack_web/backend/config/urls.py b/ineedyou/full_stack_web/backend/config/urls.py new file mode 100644 index 0000000..f52ff29 --- /dev/null +++ b/ineedyou/full_stack_web/backend/config/urls.py @@ -0,0 +1,28 @@ +""" +Main URL Configuration +""" +from django.contrib import admin +from django.urls import path, include +from rest_framework import permissions +from drf_yasg.views import get_schema_view +from drf_yasg import openapi + +schema_view = get_schema_view( + openapi.Info( + title="AI Glass API", + default_version='v1', + description="API documentation for AI Glass Backend", + terms_of_service="https://www.google.com/policies/terms/", + contact=openapi.Contact(email="admin@example.com"), + license=openapi.License(name="BSD License"), + ), + public=True, + permission_classes=(permissions.AllowAny,), +) + +urlpatterns = [ + path('admin/', admin.site.urls), + path('api/auth/', include('users.urls')), + path('api/', include('devices.urls')), # devices.urls now includes 'devices/' and 'logs/' + path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'), +] diff --git a/ineedyou/full_stack_web/backend/config/wsgi.py b/ineedyou/full_stack_web/backend/config/wsgi.py new file mode 100644 index 0000000..8ae71e3 --- /dev/null +++ b/ineedyou/full_stack_web/backend/config/wsgi.py @@ -0,0 +1,16 @@ +""" +WSGI config for config project. + +It exposes the WSGI callable as a module-level variable named ``application``. + +For more information on this file, see +https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/ +""" + +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + +application = get_wsgi_application() diff --git a/ineedyou/full_stack_web/backend/devices/__init__.py b/ineedyou/full_stack_web/backend/devices/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ineedyou/full_stack_web/backend/devices/apps.py b/ineedyou/full_stack_web/backend/devices/apps.py new file mode 100644 index 0000000..83a5109 --- /dev/null +++ b/ineedyou/full_stack_web/backend/devices/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class DevicesConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'devices' diff --git a/ineedyou/full_stack_web/backend/devices/models.py b/ineedyou/full_stack_web/backend/devices/models.py new file mode 100644 index 0000000..a54fbdb --- /dev/null +++ b/ineedyou/full_stack_web/backend/devices/models.py @@ -0,0 +1,32 @@ +from django.db import models +from django.conf import settings + +class Device(models.Model): + """ + Represents an AI Glass Device (ESP32). + """ + owner = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='devices') + name = models.CharField(max_length=100) + device_id = models.CharField(max_length=50, unique=True, help_text="Hardware ID/MAC") + is_active = models.BooleanField(default=True) + last_seen = models.DateTimeField(null=True, blank=True) + + # Settings (example) + volume = models.IntegerField(default=50) + mode = models.CharField(max_length=20, default='IDLE') + + def __str__(self): + return f"{self.name} ({self.device_id})" + +class DeviceLog(models.Model): + """ + Stores logs from the device (OCR results, navigation events). + """ + device = models.ForeignKey(Device, on_delete=models.CASCADE, related_name='logs') + timestamp = models.DateTimeField(auto_now_add=True) + level = models.CharField(max_length=10, default='INFO') # INFO, WARN, ERROR + message = models.TextField() + context_data = models.JSONField(null=True, blank=True) # E.g. OCR text, GPS coords + + def __str__(self): + return f"[{self.timestamp}] {self.device.name}: {self.message[:50]}" diff --git a/ineedyou/full_stack_web/backend/devices/serializers.py b/ineedyou/full_stack_web/backend/devices/serializers.py new file mode 100644 index 0000000..0a84862 --- /dev/null +++ b/ineedyou/full_stack_web/backend/devices/serializers.py @@ -0,0 +1,27 @@ +from rest_framework import serializers +from .models import Device, DeviceLog +from django.contrib.auth import get_user_model + +User = get_user_model() + +class DeviceLogSerializer(serializers.ModelSerializer): + """ + Serializer for the DeviceLog model. + """ + device_name = serializers.CharField(source='device.name', read_only=True) + + class Meta: + model = DeviceLog + fields = ['id', 'device', 'device_name', 'timestamp', 'level', 'message', 'context_data'] + read_only_fields = ['timestamp'] + +class DeviceSerializer(serializers.ModelSerializer): + """ + Serializer for the Device model. + """ + owner = serializers.PrimaryKeyRelatedField(queryset=User.objects.all(), required=False) # Only needed for admins + + class Meta: + model = Device + fields = ['id', 'name', 'device_id', 'is_active', 'last_seen', 'volume', 'mode', 'owner'] + read_only_fields = ['last_seen'] diff --git a/ineedyou/full_stack_web/backend/devices/urls.py b/ineedyou/full_stack_web/backend/devices/urls.py new file mode 100644 index 0000000..be39703 --- /dev/null +++ b/ineedyou/full_stack_web/backend/devices/urls.py @@ -0,0 +1,11 @@ +from django.urls import path, include +from rest_framework.routers import DefaultRouter +from .views import DeviceViewSet, DeviceLogViewSet + +router = DefaultRouter() +router.register(r'devices', DeviceViewSet, basename='device') +router.register(r'logs', DeviceLogViewSet, basename='devicelog') + +urlpatterns = [ + path('', include(router.urls)), +] diff --git a/ineedyou/full_stack_web/backend/devices/views.py b/ineedyou/full_stack_web/backend/devices/views.py new file mode 100644 index 0000000..fb513a9 --- /dev/null +++ b/ineedyou/full_stack_web/backend/devices/views.py @@ -0,0 +1,57 @@ +from rest_framework import viewsets, permissions +from .models import Device, DeviceLog +from .serializers import DeviceSerializer, DeviceLogSerializer +from django.db.models import Q + +class IsOwnerOrAdmin(permissions.BasePermission): + """ + Object-level permission to only allow owners of an object or admins to edit it. + """ + def has_object_permission(self, request, view, obj): + if request.user.is_staff: + return True + return obj.owner == request.user + +class DeviceViewSet(viewsets.ModelViewSet): + """ + API endpoint that allows devices to be viewed or edited. + - Admins see all devices. + - Users see only their own devices. + """ + serializer_class = DeviceSerializer + permission_classes = [permissions.IsAuthenticated, IsOwnerOrAdmin] + + def get_queryset(self): + user = self.request.user + if user.is_staff: + return Device.objects.all() + return Device.objects.filter(owner=user) + + def perform_create(self, serializer): + # Automatically assign owner if not admin or if admin didn't specify + if not self.request.user.is_staff or 'owner' not in serializer.validated_data: + serializer.save(owner=self.request.user) + else: + serializer.save() + +class DeviceLogViewSet(viewsets.ModelViewSet): + """ + API endpoint that allows device logs to be viewed. + - Admins see all logs. + - Users see logs only for their own devices. + """ + serializer_class = DeviceLogSerializer + permission_classes = [permissions.IsAuthenticated] # Read-only for most users usually, but let's allow CRUD for simulation + + def get_queryset(self): + user = self.request.user + if user.is_staff: + return DeviceLog.objects.all() + return DeviceLog.objects.filter(device__owner=user) + + def perform_create(self, serializer): + # Ensure user can only create logs for their own devices (unless admin) + device = serializer.validated_data['device'] + if not self.request.user.is_staff and device.owner != self.request.user: + raise permissions.PermissionDenied("You do not own this device.") + serializer.save() diff --git a/ineedyou/full_stack_web/backend/entrypoint.sh b/ineedyou/full_stack_web/backend/entrypoint.sh new file mode 100755 index 0000000..0d5772b --- /dev/null +++ b/ineedyou/full_stack_web/backend/entrypoint.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +# We deliberately DO NOT use 'set -e' here so the server always tries to start +# even if a migration or initialization step fails. This makes debugging much easier. + +echo "=====================================" +echo "Starting AI Glass Backend Entrypoint" +echo "=====================================" + +echo "[1/4] Waiting for PostgreSQL at $DB_HOST:$DB_PORT..." +MAX_RETRIES=30 +RETRY_COUNT=0 +while ! nc -z $DB_HOST $DB_PORT; do + sleep 1 + RETRY_COUNT=$((RETRY_COUNT+1)) + if [ $RETRY_COUNT -ge $MAX_RETRIES ]; then + echo "Error: Database did not start in time. Continuing anyway..." + break + fi +done +echo "Database check complete." + +echo "[2/4] Applying migrations..." +python manage.py makemigrations users devices || echo "makemigrations failed, moving on..." +python manage.py migrate || echo "migrate failed, moving on..." + +echo "[3/4] Creating default users and generating tokens..." +python init_db.py || echo "init_db.py failed, moving on..." + +echo "[4/4] Starting Django Server on 0.0.0.0:8000..." +echo "=====================================" +exec python manage.py runserver 0.0.0.0:8000 diff --git a/ineedyou/full_stack_web/backend/init_db.py b/ineedyou/full_stack_web/backend/init_db.py new file mode 100644 index 0000000..b13a9c1 --- /dev/null +++ b/ineedyou/full_stack_web/backend/init_db.py @@ -0,0 +1,48 @@ +import os +import django +import sys + +def init_db(): + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + django.setup() + + from django.contrib.auth import get_user_model + from rest_framework.authtoken.models import Token + + User = get_user_model() + + try: + print("Starting user initialization...", flush=True) + + # Superuser + admin_user, created = User.objects.get_or_create(username='admin', defaults={'email': 'admin@example.com'}) + admin_user.set_password('admin123') + admin_user.is_staff = True + admin_user.is_superuser = True + admin_user.save() + if created: + print("Superuser 'admin' created successfully.", flush=True) + else: + print("Superuser 'admin' already exists (password reset to default).", flush=True) + + Token.objects.get_or_create(user=admin_user) + print("Token ensured for 'admin'.", flush=True) + + # Regular user + regular_user, created = User.objects.get_or_create(username='user1', defaults={'email': 'user1@example.com'}) + regular_user.set_password('user123') + regular_user.save() + if created: + print("User 'user1' created successfully.", flush=True) + else: + print("User 'user1' already exists (password reset to default).", flush=True) + + Token.objects.get_or_create(user=regular_user) + print("Token ensured for 'user1'.", flush=True) + + except Exception as e: + print(f"CRITICAL ERROR during user initialization: {e}", flush=True) + sys.exit(1) + +if __name__ == '__main__': + init_db() diff --git a/ineedyou/full_stack_web/backend/manage.py b/ineedyou/full_stack_web/backend/manage.py new file mode 100644 index 0000000..bc9d1a5 --- /dev/null +++ b/ineedyou/full_stack_web/backend/manage.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +import os +import sys + +def main(): + """Run administrative tasks.""" + os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'config.settings') + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Couldn't import Django. Are you sure it's installed and " + "available on your PYTHONPATH environment variable? Did you " + "forget to activate a virtual environment?" + ) from exc + execute_from_command_line(sys.argv) + +if __name__ == '__main__': + main() diff --git a/ineedyou/full_stack_web/backend/requirements.txt b/ineedyou/full_stack_web/backend/requirements.txt new file mode 100644 index 0000000..fa472bb --- /dev/null +++ b/ineedyou/full_stack_web/backend/requirements.txt @@ -0,0 +1,8 @@ +django>=4.2,<5.0 +djangorestframework>=3.14,<3.15 +psycopg2-binary>=2.9,<3.0 +django-cors-headers>=4.3,<5.0 +python-decouple>=3.8,<4.0 +gunicorn>=21.2,<22.0 +python-dotenv>=1.0,<2.0 +drf-yasg>=1.21.7 # Swagger/OpenAPI documentation \ No newline at end of file diff --git a/ineedyou/full_stack_web/backend/users/__init__.py b/ineedyou/full_stack_web/backend/users/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ineedyou/full_stack_web/backend/users/apps.py b/ineedyou/full_stack_web/backend/users/apps.py new file mode 100644 index 0000000..c8e827f --- /dev/null +++ b/ineedyou/full_stack_web/backend/users/apps.py @@ -0,0 +1,5 @@ +from django.apps import AppConfig + +class UsersConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'users' diff --git a/ineedyou/full_stack_web/backend/users/models.py b/ineedyou/full_stack_web/backend/users/models.py new file mode 100644 index 0000000..f6213c4 --- /dev/null +++ b/ineedyou/full_stack_web/backend/users/models.py @@ -0,0 +1,11 @@ +from django.contrib.auth.models import AbstractUser +from django.db import models + +class User(AbstractUser): + """ + Custom User Model. + Roles: + - is_staff=True -> Admin (can manage users, view all logs) + - is_staff=False -> Regular User (can view own device/logs) + """ + pass diff --git a/ineedyou/full_stack_web/backend/users/urls.py b/ineedyou/full_stack_web/backend/users/urls.py new file mode 100644 index 0000000..2fcfa7f --- /dev/null +++ b/ineedyou/full_stack_web/backend/users/urls.py @@ -0,0 +1,7 @@ +from django.urls import path +from .views import CustomAuthToken, UserInfoView + +urlpatterns = [ + path('login/', CustomAuthToken.as_view(), name='api_login'), + path('me/', UserInfoView.as_view(), name='api_me'), +] diff --git a/ineedyou/full_stack_web/backend/users/views.py b/ineedyou/full_stack_web/backend/users/views.py new file mode 100644 index 0000000..38eff40 --- /dev/null +++ b/ineedyou/full_stack_web/backend/users/views.py @@ -0,0 +1,36 @@ +from rest_framework.authtoken.views import ObtainAuthToken +from rest_framework.authtoken.models import Token +from rest_framework.response import Response +from rest_framework.views import APIView +from rest_framework import status +from django.contrib.auth import get_user_model +from rest_framework.permissions import IsAuthenticated + +User = get_user_model() + +class CustomAuthToken(ObtainAuthToken): + """ + Login endpoint. Returns token and user role. + """ + def post(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data, context={'request': request}) + serializer.is_valid(raise_exception=True) + user = serializer.validated_data['user'] + token, created = Token.objects.get_or_create(user=user) + return Response({ + 'token': token.key, + 'user_id': user.pk, + 'username': user.username, + 'is_staff': user.is_staff + }) + +class UserInfoView(APIView): + permission_classes = [IsAuthenticated] + + def get(self, request): + user = request.user + return Response({ + 'username': user.username, + 'is_staff': user.is_staff, + 'email': user.email, + }) diff --git a/ineedyou/full_stack_web/docker-compose.yml b/ineedyou/full_stack_web/docker-compose.yml new file mode 100644 index 0000000..a10b131 --- /dev/null +++ b/ineedyou/full_stack_web/docker-compose.yml @@ -0,0 +1,59 @@ +services: + db: + image: postgres:15 + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + - POSTGRES_DB=aiglass_db + - POSTGRES_USER=aiglass_user + - POSTGRES_PASSWORD=aiglass_password + restart: always + networks: + - aiglass_network + healthcheck: + test: ["CMD-SHELL", "pg_isready -U aiglass_user -d aiglass_db"] + interval: 5s + timeout: 5s + retries: 5 + + backend: + build: + context: ./backend + dockerfile: Dockerfile + # Overriding entrypoint to fix line endings AT RUNTIME since the host volume mount + # overwrites the container's dos2unix-fixed file with Windows CRLF files. + entrypoint: ["sh", "-c", "dos2unix /app/entrypoint.sh && bash /app/entrypoint.sh"] + volumes: + - ./backend:/app + ports: + - "8000:8000" + environment: + - DB_HOST=db + - DB_PORT=5432 + - DB_NAME=aiglass_db + - DB_USER=aiglass_user + - DB_PASS=aiglass_password + - PYTHONUNBUFFERED=1 + depends_on: + db: + condition: service_healthy + networks: + - aiglass_network + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "3000:80" + depends_on: + - backend + networks: + - aiglass_network + +networks: + aiglass_network: + driver: bridge + +volumes: + postgres_data: diff --git a/ineedyou/full_stack_web/frontend/Dockerfile b/ineedyou/full_stack_web/frontend/Dockerfile new file mode 100644 index 0000000..d171563 --- /dev/null +++ b/ineedyou/full_stack_web/frontend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:18-alpine AS build + +WORKDIR /app + +# Only copy package.json initially (package-lock.json might not exist yet) +COPY package.json ./ +RUN npm install + +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=build /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] diff --git a/ineedyou/full_stack_web/frontend/index.html b/ineedyou/full_stack_web/frontend/index.html new file mode 100644 index 0000000..c5c43e5 --- /dev/null +++ b/ineedyou/full_stack_web/frontend/index.html @@ -0,0 +1,13 @@ + + +
+ + + +Connect your ESP32 to ws://SERVER_IP:8081/ws/camera
") + +@app.websocket("/ws/camera") +async def websocket_endpoint(websocket: WebSocket): + global last_ocr_time, current_frame + await websocket.accept() + print(f"[SERVER] ESP32 Connected from {websocket.client}") + + try: + while True: + # 接收二进制数据 (JPEG) + data = await websocket.receive_bytes() + current_frame = data + + # 检查是否到了OCR识别的时间 + now = time.time() + if now - last_ocr_time > OCR_INTERVAL: + last_ocr_time = now + + # 启动异步OCR任务,不阻塞视频流接收 + asyncio.create_task(process_ocr(data)) + + except WebSocketDisconnect: + print("[SERVER] ESP32 Disconnected") + except Exception as e: + print(f"[SERVER] Error: {e}") + +async def process_ocr(image_bytes): + if not API_KEY: + print("[OCR] Skipping: API Key not set.") + return + + print(f"\n[OCR] Capture frame ({len(image_bytes)} bytes), sending to Qwen-Omni...") + + try: + # 编码为Base64 + encoded_string = base64.b64encode(image_bytes).decode('utf-8') + + # 构建请求 + content_list = [ + { + "type": "image_url", + "image_url": {"url": f"data:image/jpeg;base64,{encoded_string}"} + }, + {"type": "text", "text": "请读取这张图片里的所有文字,并直接输出原文,不要加任何修饰。"} + ] + + print("[OCR] Waiting for response...") + full_text = "" + # 使用 async for 迭代异步生成器 + async for piece in omni_client.stream_chat(content_list, voice="Cherry", audio_format="wav"): + if piece.text_delta: + print(piece.text_delta, end="", flush=True) + full_text += piece.text_delta + + print("\n[OCR] Done.") + + except Exception as e: + print(f"[OCR] Error processing image: {e}") + +if __name__ == "__main__": + if not API_KEY: + print("Error: DASHSCOPE_API_KEY not set. Please set it in environment variables or .env file.") + # exit(1) # 不强制退出,允许仅作为视频流服务器运行 + + print(f"Starting OCR Test Server on {HOST}:{PORT}") + uvicorn.run(app, host=HOST, port=PORT) diff --git a/ineedyou/ocr_test/omni_client.py b/ineedyou/ocr_test/omni_client.py new file mode 100644 index 0000000..6b17e66 --- /dev/null +++ b/ineedyou/ocr_test/omni_client.py @@ -0,0 +1,82 @@ +# omni_client.py +# -*- coding: utf-8 -*- +import os +from typing import AsyncGenerator, Dict, Any, List, Optional + +from openai import AsyncOpenAI + +# ===== OpenAI 兼容(达摩院 DashScope 兼容模式)===== +# 必须从环境变量获取 API Key,不再硬编码 +API_KEY = os.getenv("DASHSCOPE_API_KEY") +if not API_KEY: + # 尝试从 .env 文件加载 (如果存在) + try: + from dotenv import load_dotenv + load_dotenv() + API_KEY = os.getenv("DASHSCOPE_API_KEY") + except ImportError: + pass + +if not API_KEY: + raise RuntimeError("未设置 DASHSCOPE_API_KEY 环境变量") + +QWEN_MODEL = "qwen-omni-turbo" + +# 兼容模式 (使用异步客户端) +oai_client = AsyncOpenAI( + api_key=API_KEY, + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1", +) + +class OmniStreamPiece: + """对外的统一增量数据:text/audio 二选一或同时。""" + def __init__(self, text_delta: Optional[str] = None, audio_b64: Optional[str] = None): + self.text_delta = text_delta + self.audio_b64 = audio_b64 + +async def stream_chat( + content_list: List[Dict[str, Any]], + voice: str = "Cherry", + audio_format: str = "wav", +) -> AsyncGenerator[OmniStreamPiece, None]: + """ + 发起一轮 Omni-Turbo ChatCompletions 流式对话: + - content_list: OpenAI chat 的 content,多模态(image_url/text) + - 以 stream=True 返回 + - 增量产出:OmniStreamPiece(text_delta=?, audio_b64=?) + """ + # 使用 await 调用异步客户端 + completion = await oai_client.chat.completions.create( + model=QWEN_MODEL, + messages=[{"role": "user", "content": content_list}], + modalities=["text", "audio"], + audio={"voice": voice, "format": audio_format}, + stream=True, + stream_options={"include_usage": True}, + ) + + # 异步迭代 + async for chunk in completion: + text_delta: Optional[str] = None + audio_b64: Optional[str] = None + + if getattr(chunk, "choices", None): + c0 = chunk.choices[0] + delta = getattr(c0, "delta", None) + # 文本增量 + if delta and getattr(delta, "content", None): + piece = delta.content + if piece: + text_delta = piece + # 音频分片 + if delta and getattr(delta, "audio", None): + aud = delta.audio + audio_b64 = aud.get("data") if isinstance(aud, dict) else getattr(aud, "data", None) + if audio_b64 is None: + msg = getattr(c0, "message", None) + if msg and getattr(msg, "audio", None): + ma = msg.audio + audio_b64 = ma.get("data") if isinstance(ma, dict) else getattr(ma, "data", None) + + if (text_delta is not None) or (audio_b64 is not None): + yield OmniStreamPiece(text_delta=text_delta, audio_b64=audio_b64) diff --git a/ineedyou/ocr_test/requirements.txt b/ineedyou/ocr_test/requirements.txt new file mode 100644 index 0000000..0781b8c --- /dev/null +++ b/ineedyou/ocr_test/requirements.txt @@ -0,0 +1,6 @@ +openai +requests +fastapi +uvicorn +python-dotenv +websockets \ No newline at end of file diff --git a/ineedyou/ocr_test/test_ocr.py b/ineedyou/ocr_test/test_ocr.py new file mode 100644 index 0000000..6cc4be7 --- /dev/null +++ b/ineedyou/ocr_test/test_ocr.py @@ -0,0 +1,75 @@ +import asyncio +import base64 +import os +import requests +from omni_client import stream_chat, OmniStreamPiece + +# Use a dummy image with text +IMAGE_URL = "https://dummyimage.com/600x400/000/fff&text=HELLO+WORLD+OCR+TEST" +IMAGE_PATH = "sample_text.png" + +# Ensure API Key is set +if not os.getenv("DASHSCOPE_API_KEY"): + print("Error: DASHSCOPE_API_KEY environment variable is not set.") + print("Please set it before running this script.") + # We don't exit here because omni_client might have loaded it from .env + +def download_image(): + if not os.path.exists(IMAGE_PATH): + print(f"Downloading sample image from {IMAGE_URL}...") + try: + headers = {'User-Agent': 'Mozilla/5.0'} + response = requests.get(IMAGE_URL, headers=headers, timeout=10) + if response.status_code == 200: + with open(IMAGE_PATH, 'wb') as f: + f.write(response.content) + print(f"Download complete: {IMAGE_PATH}") + return True + else: + print(f"Failed to download image: {response.status_code}") + return False + except Exception as e: + print(f"Error downloading image: {e}") + return False + return True + +async def main(): + if not download_image(): + print("Skipping OCR test because image download failed.") + return + + try: + with open(IMAGE_PATH, "rb") as image_file: + encoded_string = base64.b64encode(image_file.read()).decode('utf-8') + except Exception as e: + print(f"Error reading image file: {e}") + return + + # Construct the multimodal message + content_list = [ + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{encoded_string}"} + }, + {"type": "text", "text": "请读取这张图片里的所有文字,并直接输出原文,不要加任何修饰。"} + ] + + print("Sending image to Qwen-Omni for OCR...") + print("-" * 40) + + full_text = "" + try: + # Iterate over the async generator + async for piece in stream_chat(content_list, voice="Cherry", audio_format="wav"): + if piece.text_delta: + print(piece.text_delta, end="", flush=True) + full_text += piece.text_delta + except Exception as e: + print(f"\nError during API call: {e}") + print("Make sure DASHSCOPE_API_KEY is valid.") + + print("\n" + "-" * 40) + print("OCR Test Finished.") + +if __name__ == "__main__": + asyncio.run(main())