diff --git a/doc/图像分类/PixPin_2025-08-22_14-27-02.mp4 b/doc/图像分类/PixPin_2025-08-22_14-27-02.mp4
new file mode 100644
index 0000000..b69a0b2
Binary files /dev/null and b/doc/图像分类/PixPin_2025-08-22_14-27-02.mp4 differ
diff --git a/doc/图像分类/PixPin_2025-08-22_14-40-16.png b/doc/图像分类/PixPin_2025-08-22_14-40-16.png
new file mode 100644
index 0000000..312572e
Binary files /dev/null and b/doc/图像分类/PixPin_2025-08-22_14-40-16.png differ
diff --git a/doc/图像分类/README.md b/doc/图像分类/README.md
new file mode 100644
index 0000000..c2c5b4c
--- /dev/null
+++ b/doc/图像分类/README.md
@@ -0,0 +1,93 @@
+## 图像分类
+> 核心概念: 迁移学习、分类模型、图像识别、可视化
+### 图像分类模型怎么“看”世界的?
+想象一下,你刚出生时,并不知道什么是“猫”,什么是“狗”。但随着你慢慢长大,爸爸妈妈会指着图片告诉你:“看,这是猫!喵呜~” “这是狗!汪汪!” 慢慢地,你就能区分它们了。
+
+AI也是一样! 它需要大量的图片来学习。分类模型就像一个“图像辨认大师”,它的任务就是学会区分不同类别的图片,比如猫、狗、汽车、飞机等等。
+
+
+
+
+
+### 尝试一下!
+
+
+
++ 上传你的训练图片--给AI“上课”!
+
+比如,如果你想让AI区分“苹果”和“香蕉”,你就需要在“苹果”这个类别下上传一些苹果的图片,在“香蕉”这个类别下上传一些香蕉的图片。为每个类别上传图片即可。
+
+同时,尝试上传不同角度、不同光线、不同大小的苹果图片。确保AI能“看到”不同特征。
+
+
++ 点击“训练模型”--让AI“学习”!
+
+模型便会从你提供的不同样本中学习到特征,这个过程可能只需要几秒钟甚至更短。
+
+
++ 测试你的AI——看看它学得怎么样!
+
+现在是检验成果的时候了!上传一张新的图片,比如你没给AI看过的苹果或香蕉。
+
+AI会立刻告诉你,它觉得这张图片是“苹果”还是“香蕉”,还会告诉你它有多大的把握(比如“98%确定是苹果”)。
+
+哇塞! 是不是超酷?你的AI现在也能“看懂”图片了!
+
+
+
+
+
+
+
+玩了几轮之后,你可能会有更多的问题:
+
++ 如果AI判断错了怎么办?
+
+这可能意味着你需要给AI更多的图片,或者图片不够清晰、不够多样。
+
++ 图片数量越多越好吗?
+
+思考题: 如果我只给AI看一张图片,它能学会吗?
+
++ AI还能做些什么?
+
+展望: 你觉得街上的无人驾驶汽车是怎么“看”路况的?医生是怎么通过AI分析X光片的?这些都离不开图像识别技术!
+
+
+### 小秘密:AI学习的“加速器”——迁移学习!
+
+你可能会好奇:“哇,让AI学这么多东西,肯定要花好久好久吧?”
+
+如果你让AI从零开始学习——就像你从出生开始学所有东西一样——那确实会非常非常慢,需要成百上千张图片来学习每个类别,并且用专业的机器训练数小时甚至数天。这是一个既耗时又昂贵的过程,不是我们普通人能轻松尝试的。
+
+
+在AI的世界里,有一个类似的“加速器”,它叫迁移学习(Transfer Learning)。
+
+想象一下,我们已经有一个超级聪明的AI,它已经“看”了几百万张图片,并且学会了如何识别图像中各种基础的形状、颜色、纹理(就像它已经学会了平衡能力)。
+
+现在,我们想让这个AI学会一个新任务,比如区分“蛋糕”和“披萨”。如果让它从零开始学习,会很慢。
+
+“借用”超级大脑: 想象一下,我们已经有一个超级聪明的AI“教授”,它已经“看”了几百万张图片,并且学会了如何识别图像中各种基础的形状、颜色、纹理、边缘特征(就像它已经学会了平衡能力,更像是学会了识别世间万物的“零部件”)。
+
+我们的秘诀: 这个“教授”就是预训练模型,比如我们用的 MobileNet!它已经学习过上千种物品的特征,能准确地识别出我们日常生活中常见的各种物品。
+
+“快速定制”新技能: 现在,我们想让这个AI“教授”学会一个新任务,比如区分“苹果”和“香蕉”。我们不需要让它从头开始学习,那样太慢了!
+
+聪明做法: 我们直接“借用”MobileNet这个“教授”最擅长识别特征的“头部”(就像它最强大、最敏锐的“眼睛”和“大脑前部”),让它帮忙将你上传的“苹果”和“香蕉”图片,转化为它能理解的“特征描述”(比如“这是圆形、红色、有把手”、“这是长条形、黄色、弯曲的”)。
+
+然后,我们只需要给这些特征描述后面再添加一个非常简单的“小助理”模型,由它来学习和记住这些特征分别代表“苹果”还是“香蕉”就行了!
+
+-------------------
+
+如果我们要从头开始训练一个图像分类模型,那么对于每个类我们需要成百上千张图片,并且使用专业的机器训练数小时甚至数天。这是一个耗时且昂贵的过程。
+
+而迁移学习则可以让我们“借用”一个已经训练好的模型,只需要少量的新数据就可以训练出一个准确的模型。这大大节省了时间和金钱。
+
+这里我们使用了mobilenet的预训练模型,其学习过上千中物品的特征,能够准确地识别出物品。因此我们使用这个预训练模型的头部作为特征检测器,它能够识别1000种它见过的物品。现在它观察一种新的物品时,就会给出1000个其见过物品的概率,我们只需要在后部再添加一个简单的分类模型,就可以快速实现分类了。
+
+我们只需要训练少量的新数据,就可以训练出一个准确的模型。这就是迁移学习的魔力所在!
+
+这样,我们的AI就能:
+
++ 只需要少量的新数据(几张图片!),
++ 在短短几秒钟内,就能训练出一个准确的图片分类模型!
\ No newline at end of file
diff --git a/doc/姿态分类/PixPin_2025-08-22_14-29-43.mp4 b/doc/姿态分类/PixPin_2025-08-22_14-29-43.mp4
new file mode 100644
index 0000000..67ed0ca
Binary files /dev/null and b/doc/姿态分类/PixPin_2025-08-22_14-29-43.mp4 differ
diff --git a/doc/姿态分类/PixPin_2025-08-22_14-31-23.mp4 b/doc/姿态分类/PixPin_2025-08-22_14-31-23.mp4
new file mode 100644
index 0000000..82a87d0
Binary files /dev/null and b/doc/姿态分类/PixPin_2025-08-22_14-31-23.mp4 differ
diff --git a/doc/姿态分类/PixPin_2025-08-22_14-32-27.mp4 b/doc/姿态分类/PixPin_2025-08-22_14-32-27.mp4
new file mode 100644
index 0000000..2b95bdf
Binary files /dev/null and b/doc/姿态分类/PixPin_2025-08-22_14-32-27.mp4 differ
diff --git a/doc/姿态分类/PixPin_2025-08-22_14-33-40.mp4 b/doc/姿态分类/PixPin_2025-08-22_14-33-40.mp4
new file mode 100644
index 0000000..1fdb477
Binary files /dev/null and b/doc/姿态分类/PixPin_2025-08-22_14-33-40.mp4 differ
diff --git a/doc/姿态分类/PixPin_2025-08-22_14-35-32.png b/doc/姿态分类/PixPin_2025-08-22_14-35-32.png
new file mode 100644
index 0000000..2f37d97
Binary files /dev/null and b/doc/姿态分类/PixPin_2025-08-22_14-35-32.png differ
diff --git a/doc/姿态分类/PixPin_2025-08-22_14-37-34.png b/doc/姿态分类/PixPin_2025-08-22_14-37-34.png
new file mode 100644
index 0000000..3523b94
Binary files /dev/null and b/doc/姿态分类/PixPin_2025-08-22_14-37-34.png differ
diff --git a/doc/姿态分类/README.md b/doc/姿态分类/README.md
new file mode 100644
index 0000000..cc56f6d
--- /dev/null
+++ b/doc/姿态分类/README.md
@@ -0,0 +1,113 @@
+## AI身体语言大师:你的酷炫姿态识别挑战! 🤸♀️🧘♂️
+> 核心概念: 姿态检测、人体关键点、机器学习、KNN分类、实时互动、可视化
+
+### 开篇:AI也能“读懂”你的动作?——姿态分类模型怎么“看”身体语言?
+
+嘿,未来科技探索者们!你有没有想过,手机上的健身App是如何判断你深蹲是否标准的?舞蹈游戏又是如何给你打分的?这可不是什么魔术,而是今天我们将一起揭秘的——人工智能(AI) 如何“看懂”我们的身体动作!
+
+想象一下,你和朋友玩“你画我猜”,你们需要通过各种姿势来表达词语。人类的大脑很擅长理解这些姿势。那AI呢?它也能像我们一样“读懂”你的身体语言吗?
+
+答案是:能! 我们的AI姿态分类模型,就像一个“身体语言大师”,它的任务就是学会区分各种身体姿态,比如“站立”、“坐下”、“挥手”、“跳跃”,甚至是你自己创造的酷炫动作!
+
+----------------------
+
+### AI如何“看穿”你的身体——人体关键点与KNN分类!
+
+你可能会好奇:“AI怎么知道我摆的是什么姿势呢?”
+
+但它有自己的“秘密武器”——姿态检测模型!
+
++ “AI骨架”——人体关键点:
+想象你是一个木偶,身上有手肘、膝盖、肩膀、脚踝等可以弯曲和转动的关节。这些关节就是AI眼中的“人体关键点”!
+我们的网站使用了一个叫 MoveNet 的超强AI模型,它能实时从你的摄像头画面中,精准地“描绘”出你的身体骨架,定位出大约17个关键点的位置(比如你的左肩在哪里,右膝盖在哪里)。这些关键点就像一个个数字坐标,描述了你身体的形状。
+
++ “找相似”高手——KNN分类(K-近邻算法):
+现在,AI有了你的“骨架图”(一系列数字关键点)。那它怎么知道这副骨架代表的是“站立”还是“坐下”呢?
+这里就要用到另一个简单又聪明的AI算法——KNN分类(K-近邻算法)!
+
++ 它的原理很简单: 想象你有一群朋友,他们穿着不同的运动服(代表不同的姿态类别)。当你摆出一个新姿势时,KNN就会问:“这个新姿势,跟我的哪几个朋友最像呢?”
+
++ 它会测量你新姿势的“骨架数据”,和所有它已经“认识”的姿势数据进行比较。哪个姿势跟你的“形状”最接近,AI就判断你当前摆的就是那个姿势!
+这样,我们的AI就能:
+
+实时捕捉你的身体骨架!
+通过“找朋友”的方式,快速判断你摆的是什么姿势!
+是不是超酷?我们的可视化网站就是帮你亲手体验这个AI“身体语言大师”的神奇力量!
+
+亲手打造你的AI“身体语言大师”!——尝试一下!
+现在,就让我们打开你的“AI训练营”网站,亲手“教”AI识别你的姿态吧!
+
+[]
+
+### 步骤一:创建你的专属“姿态词典”——动态添加姿态类别!
+
+在网站的“控制面板”区域,你会看到一个“+ 增加分类”按钮。点击它!
+会出现一个新分类,比如叫“Class 1”。你可以点击输入框,把它改成你喜欢的名字,比如“站立”、“蹲下”、“举手礼”、“瑜伽A式”等。
+💡 小贴士: 你可以多创建几个类别,比如“左手举起”、“右手举起”、“双手高举”,看看AI能不能区分这些细微的差别!
+### 步骤二:录入你的“姿态样本”——教AI认识你的动作!
+
+这是最关键的一步!让AI看你的动作,记住你的“骨架数据”。
+
+选择一个类别: 比如你创建了“站立”这个类别。
+摆好姿势: 面对摄像头,摆出你想要教AI的“站立”姿势。
+
+### 点击“自动采集”或“采集样本”:
+“采集样本”: 每点击一次按钮,AI就会记录你当前的一个姿势样本。
+✨ 推荐使用“自动采集”: 点击它后,AI会在短短几秒内,自动、高效率地为你记录下10个姿势样本!这样你就不用一直按按钮啦!
+💡 小贴士: 采集样本时,姿势要标准,但也可以稍微有轻微的晃动,这有助于AI更好地学习这个姿势的“精髓”!
+为每个类别都采集足够的样本: 就像你考试前多做练习题一样,样本越多,AI就学得越准!每个类别至少采集10个以上样本会比较理想。
+更新UI: 你会看到每个类别旁边显示着“(X 样本)”,数字会随着你的采集而增加。
+步骤三:点击“开始预测”——看看AI是不是你的“身体语言大师”!
+
+当所有姿态类别都采集完样本后,你就可以开始检测了!
+
+点击“开始预测”按钮。
+现在,你再面对摄像头摆出你教过的姿态,AI会立刻告诉你,它觉得你摆的是哪个姿态,还会告诉你它有多大的把握(比如“姿态:站立 (95%)”)。
+哇塞!是不是超酷?你的AI现在也能“看懂”你的身体语言了!
+
+
+
+
+
+
+
+
+
+
+-------------
+
+玩了几轮之后,你可能会有更多的问题和想法,这正是一个AI探索家的开始!
+
+如果AI判断错了怎么办?
+
+这可能意味着你需要给AI采集更多的样本,或者你的训练样本和测试时的姿势不够一致。
+试试看: 重新选择判断错误的类别,再多采集一些样本,特别是那些AI容易混淆的类似姿势的样本!
+姿势样本数量越多越好吗?
+
+思考题: 如果我只为每个姿态采集一个样本,AI能学会吗?(答案:很难!就像你只看了一个标准动作,就想教别人跳舞一样难!)
+你可以尝试: 分别用少量(比如每个类别3-5个)和大量(比如每个类别20-30个)样本训练AI,对比一下它们的识别准确率!你会发现一个有趣的规律!
+AI还能“读懂”什么?—— 拓展你的AI想象力!
+
+展望: 你觉得这种姿态识别技术还能用在哪里?比如,智能健身镜纠正你的动作?游戏中的体感交互?老年人摔倒检测?AI辅助康复训练?
+集思广益: 想象一下,AI还能帮你解决什么问题?比如,识别你打篮球时的投篮姿势是否标准?识别手语动作?发挥你的创意,分享给你的小伙伴!未来属于你,AI的边界由你定义!
+进阶挑战:保存和加载你的AI“身体记忆”! (模型导入/导出)
+你有没有辛辛苦苦训练好一个AI,结果关掉网页就都没了的经历?别担心!我们的网站还提供了模型导入/导出功能,让你的AI“身体记忆”永不丢失!
+
+“导出模型”: 训练好模型后,点击“导出模型”按钮,你的AI“身体记忆”(包含所有姿态类别和样本数据)就会保存为一个JSON文件下载到你的电脑里。
+“导入模型”: 下次你再打开网站,或者和朋友分享你的酷炫模型时,点击“导入模型”,选择你之前保存的JSON文件,你的AI就会立刻“恢复记忆”,知道你教过它的所有姿态了!
+恭喜你!你就是未来的AI创造者! ✨
+通过这个酷炫的可视化网站,你已经亲身体验了AI是如何:
+
+“描绘”出你的身体骨架(姿态检测)!
+通过“找朋友”的方式学会识别不同姿态(KNN分类)!
+使用“超级加速器”(无需从零训练,因为我们用的MoveNet已经很聪明了!)实现快速学习!
+亲手塑造一个能“看懂”你身体语言的AI!
+这只是人工智能世界的一小部分精彩!未来,AI会融入我们生活的方方面面。而你,作为新一代的青少年,正是未来AI的创造者和使用者!保持好奇心,继续探索,也许下一个改变世界的AI应用,就出自你手!
+
+祝你AI探秘之旅,姿态无限! 🌟
+
+
+
+
+
+
\ No newline at end of file
diff --git a/doc/音频检测/README.md b/doc/音频检测/README.md
new file mode 100644
index 0000000..61ba43f
--- /dev/null
+++ b/doc/音频检测/README.md
@@ -0,0 +1,135 @@
+
+
+## **AI听力超能力:你的智能语音助手训练营!** 👂🎤
+
+> **核心概念:** 迁移学习、声音分类、音频识别、机器学习、实时互动
+
+---
+
+### **开篇:AI也能“听懂”悄悄话?——声音分类模型怎么“听”世界?**
+
+嘿,未来科技探索者们!
+你有没有对着智能音箱说“播放音乐”,它就能立刻响应?或者手机上的App能识别你哼唱的歌曲?这些“耳朵超灵敏”的AI可不是魔法,而是今天我们将一起揭秘的——**人工智能(AI)** 如何“听懂”我们的声音世界!
+
+想象一下,你第一次听到“猫”的叫声(喵呜~),“狗”的叫声(汪汪~),慢慢地,你就知道这两种声音代表不同的动物了。你的大脑在听到声音后,能分辨出它们的音高、音色、节奏等特点,并和记忆中的声音进行匹配。
+
+**AI也是一样!** 但它需要大量的声音样本来学习。我们的AI**声音分类模型**,就像一个“听音辨声大师”,它的任务就是通过学习海量音频,学会区分不同类型的声音,比如“拍手声”、“风声”、“人说话声”,甚至是你的口令词!
+
+---
+
+### **大揭秘:AI如何“捕捉”声音的秘密——音频特征与迁移学习!**
+
+你可能会问:“声音那么复杂,AI怎么知道我在说什么,或者发出了什么声音呢?”
+
+AI确实没有耳朵,但它有自己的“秘密武器”——**音频分析和机器学习**!
+
+1. **“AI耳朵”——声音特征提取:**
+ 当声音进入麦克风,它会变成一串复杂的数字信号。我们的AI会用一种特殊的“魔法”,把这些数字信号变成一张张图片——我们称之为**“声谱图”**(Specgram)。声谱图就像是声音的“指纹”,它用颜色和图案,形象地展示了声音的音高、响度、持续时间等信息。
+ AI识别声音,就像识别这些“声谱图”一样,它从中找出独特的模式,比如拍手声可能有一个快速、高亮的图案,而风声则可能是连续、低沉的模糊图案。
+
+2. **“超级加速器”——迁移学习(声学版)!**
+ 还记得我们之前说的“迁移学习”吗?在声音领域,它依然是个超级英雄!
+ 如果你让AI从零开始学习各种声音,那它得听几百万小时的噪音和语音,才能学会区分最基础的“人声”和“环境音”。这太慢了!
+ **我们用的方法是:** 借用一个已经很聪明的AI“听音教授”——它已经“听”了数不清的音频,学会了识别各种基本的声音特征(比如噪音是什么样的,短暂的敲击声是什么样的)。
+ * **我们的秘诀就是:** **Speech Commands 模型**!这个模型已经预先学习了上千种日常声音和口令词。它就像一个拥有“顺风耳”的AI侦探👂,能从复杂的音频中捕捉到最关键的“声音指纹”。
+ * **它的“超级大脑”里已经内置了对“背景噪音”(`_background_noise_`)的理解!** 这意味着它已经知道,那些杂乱无章、没有特定意义的声音长什么样。所以,我们最重要的一步就是先教它识别你所处的“背景噪音”——这样它就能把你的目标声音和周围的环境音区分开来。
+ * 然后,我们只需要给它播放少量你想要识别的**特定声音样本**(比如“拍手”,或你自己说的“开始”),它就能在这个“教授”的基础上,非常快速地学会区分这些新声音!
+
+3. **“找朋友”高手——KNN分类(K-近邻算法):**
+ 和姿态分类一样,AI在提取出声音的“特征指纹”后,就会用KNN算法来“找朋友”。它会将你当前听到的声音指纹,与它学过的所有声音指纹进行比较,哪个声音指纹“最像”,它就认为当前听到的是那种声音!
+
+**这样,我们的AI就能:**
+
+* **智能过滤环境杂音(通过背景噪音学习)!**
+* **快速学会并识别你的自定义声音!**
+
+是不是超酷?我们的可视化网站就是帮你亲手体验这个AI“听音辨声大师”的神奇力量!
+
+---
+
+### **亲手打造你的AI“听音辨声大师”!——尝试一下!**
+
+现在,就让我们打开你的“AI训练营”网站,亲手“教”AI识别你的声音吧!
+[预留图片页面]
+在页面上,你会看到几个主要区域:**状态信息**、**背景噪音录制**、**自定义类别管理**、**模型控制**和**预测结果显示**。
+
+**步骤一:🤫 录制背景噪音——教AI听清你的声音!**
+
+这是非常关键的第一步!为了让AI更好地识别你想要的目标声音,它需要先知道你所处环境的“无意义”声音是怎样的。
+
+* 找到“**🤫 1. 录制背景噪音**”区域。
+* 保持周围环境安静,没有你想要识别的特定声音。
+* 点击“**录制样本**”按钮(背景噪音的按钮通常是红色或特别标记的)。
+* AI会自动为你录制几段背景噪音。你会看到“样本数量”在增加。
+ * **💡 小贴士:** 确保录制时不要发出你打算训练的目标声音!多录制一些(比如 5-10 个)效果会更好!
+
+**步骤二:🗣️ 录制你想要分类的声音——创建你的“声音字典”!**
+
+现在,是时候教AI认识你自己的声音了!
+
+* **添加新类别:** 在“**🗣️ 2. 录制您要分类的声音**”下方的输入框中,输入你想要识别的声音的名称(比如“拍手”、“吹口哨”、“开始”、“停止”)。然后点击“**添加类别**”按钮。
+* 你会看到一个专属的类别块出现,里面有“样本数量”和“录制样本”按钮。
+* **为每个类别录制样本:**
+ * 选择一个你刚创建的类别。
+ * 每次点击“**录制样本**”按钮,就发出那个声音一次。例如,如果你创建了“拍手”类别,就对着麦克风拍一次手。
+ * AI会自动帮你录制多段样本。你会看到对应类别的“样本数量”在增加。
+ * **💡 小贴士:** 试着从不同强度、不同方式发出同一个声音,让AI学习到更丰富的特征!每个类别至少录制5-10个样本效果最好。
+* 重复以上步骤,添加并录制所有你想要识别的声音类别!
+
+**步骤三:🚀 训练模型——让AI“融会贯通”!**
+
+当你为所有类别(包括背景噪音)都录制了足够的样本后,就可以让AI开始学习了。
+
+* 点击“**🚀 3. 训练模型**”按钮。
+* 你会看到“状态”区域显示模型的训练进度(例如“训练 Epoch 1/50”)。这个过程可能需要一些时间,取决于你的样本数量和网络速度。
+* **耐心等待:** 当“状态”显示“模型训练完成!”时,恭喜你,你的AI已经学成了!
+
+**步骤四:👂 开始识别——看看你的AI有多强!**
+
+现在,是检验成果的时候了!
+
+* 点击“**👂 4. 开始识别**”按钮。
+* 对着麦克风发出你训练过的声音,或者只是保持安静(让它识别背景噪音)。
+* AI会立刻告诉你,它听到了什么声音,还会告诉你它有多大的把握(例如:“预测结果:拍手 (置信度: 98%)”)。
+* **哇塞!是不是超酷?你的AI现在也能“听懂”你的指令了!**
+* 当你玩够了,点击“**⏸️ 停止识别**”按钮,就可以暂停识别了。
+
+---
+
+### **挑战你的AI!—— 成为AI“声音训练专家”!**
+
+玩了几轮之后,你可能会有更多的问题和想法,这正是一个AI探索家的开始!
+
+* **如果AI判断错了怎么办?**
+ * 这可能意味着你需要给AI采集更多的样本,或者你的样本不够清晰、不够多样。
+ * **试试看:** 重新选择判断错误的类别,再多采集一些样本,特别是那些AI容易混淆的类似声音的样本!
+
+* **声音样本数量越多越好吗?**
+ * **思考题:** 如果我只为每个声音采集一个样本,AI能学会吗?(答案:很难!就像你只听过一次课就想考满分一样难!AI需要多听多练才能成为专家。)
+ * **你可以尝试:** 分别用少量(比如每个类别3-5个)和大量(比如每个类别20-30个)样本训练AI,对比一下它们的识别准确率!你会发现一个有趣的规律!
+
+* **AI还能“听懂”什么?—— 拓展你的AI想象力!**
+ * **展望:** 你觉得这种声音识别技术还能用在哪里?比如,识别家里的电器开关声?宠物叫声的识别?婴儿啼哭声的分析?智能安防系统识别异常声音(玻璃破碎、警报)?
+ * **集思广益:** 想象一下,AI还能帮你解决什么问题?发挥你的创意,分享给你的小伙伴!**未来属于你,AI的边界由你定义!**
+
+### **进阶挑战:保存和加载你的AI“声音记忆”! (数据导入/导出)**
+
+你有没有辛辛苦苦训练好一个AI,结果关掉网页就都没了的经历?别担心!我们的网站还提供了模型导入/导出功能,让你的AI“声音记忆”永不丢失!
+
+* **“💾 导出数据”:** 训练好模型后,点击“导出数据”按钮,你的AI“声音记忆”(包含所有声音类别和样本数据)就会保存为一个`.bin`文件下载到你的电脑里。
+* **“📂 导入数据”:** 下次你再打开网站,或者和朋友分享你的酷炫模型时,点击“导入数据”,选择你之前保存的`.bin`文件,你的AI就会立刻“恢复记忆”,知道你教过它的所有声音了!
+
+---
+
+### **恭喜你!你就是未来的AI创造者!** ✨
+
+通过这个酷炫的可视化网站,你已经亲身体验了AI是如何:
+
+* **“听”取声音并提取特征(通过声谱图)!**
+* **智能过滤环境噪音(通过背景噪音学习)!**
+* **快速学会并识别你的各种自定义声音(音频迁移学习和KNN分类)!**
+* 亲手塑造一个能“听懂”你声音的AI!
+
+这只是人工智能世界的一小部分精彩!未来,AI会融入我们生活的方方面面。而你,作为新一代的青少年,正是未来AI的创造者和使用者!保持好奇心,继续探索,也许下一个改变世界的AI应用,就出自你手!
+
+**祝你AI探秘之旅,声音无限!** 🔊
\ No newline at end of file
diff --git a/game/分类器/index.html b/game/分类器/index.html
new file mode 100644
index 0000000..ef65931
--- /dev/null
+++ b/game/分类器/index.html
@@ -0,0 +1,53 @@
+
+
+
+
+
+ Web Serial KNN Classifier
+
+
+
+
+
+
+
+
+
+
+
📦 Web Serial 实时分类器
+
+
正在检查 Web Serial API 兼容性...
+
+
+
+
+
+
正在加载 MobileNet 和 KNN 模型...
+
+
+
+
+
+
+
+
+
等待识别...
+
+
+
+
+
diff --git a/game/分类器/models/knn-model.bin b/game/分类器/models/knn-model.bin
new file mode 100644
index 0000000..5d56701
Binary files /dev/null and b/game/分类器/models/knn-model.bin differ
diff --git a/game/分类器/models/knn-model.json b/game/分类器/models/knn-model.json
new file mode 100644
index 0000000..c180302
--- /dev/null
+++ b/game/分类器/models/knn-model.json
@@ -0,0 +1 @@
+{"dataset":{"0":{"start":0,"length":38400},"1":{"start":38400,"length":38400}},"classList":[{"id":"class-1","name":"Class 1","images":[]},{"id":"class-2","name":"Class 2","images":[]}],"k":3,"featureDim":1280,"date":"2025-08-24T02:07:00.303Z","dataFile":"knn-model.bin"}
\ No newline at end of file
diff --git a/game/分类器/models/test.py b/game/分类器/models/test.py
new file mode 100644
index 0000000..2445500
--- /dev/null
+++ b/game/分类器/models/test.py
@@ -0,0 +1,44 @@
+import numpy as np
+import json
+
+json_file_path = './knn-model.json'
+bin_file_path = './knn-model.bin'
+
+with open(json_file_path, 'r') as f:
+ model_data = json.load(f)
+
+with open(bin_file_path, 'rb') as f:
+ binary_full_data = f.read()
+
+feature_dim = model_data['featureDim']
+
+print(f"Feature Dimension: {feature_dim}")
+
+for label, meta in model_data['dataset'].items():
+ start_byte = meta['start']
+ length_byte = meta['length']
+
+ print(f"\n--- Class: {label} ---")
+ print(f"Start Byte: {start_byte}")
+ print(f"Length Byte: {length_byte}")
+
+ # 提取当前类别的数据片段
+ class_binary_data = binary_full_data[start_byte : start_byte + length_byte]
+
+ # 转换为 Float32 数组
+ try:
+ class_features_elements = np.frombuffer(class_binary_data, dtype=np.float32)
+ num_elements = len(class_features_elements)
+ print(f"Float32 elements extracted: {num_elements}")
+
+ if num_elements % feature_dim == 0:
+ num_samples = num_elements // feature_dim
+ print(f"SUCCESS: Aligned. Number of samples: {num_samples}")
+ else:
+ print(f"ERROR: Not aligned. {num_elements} elements / {feature_dim} dim = {num_elements / feature_dim} (not an integer).")
+ print(f"Expected length in bytes to be multiple of {feature_dim * 4} = {feature_dim * 4} bytes.")
+ print(f"Actual length in bytes: {length_byte}. Remainder when dividing by {feature_dim * 4}: {length_byte % (feature_dim * 4)}")
+
+ except Exception as e:
+ print(f"Error processing binary data for class {label}: {e}")
+
diff --git a/game/分类器/script.js b/game/分类器/script.js
new file mode 100644
index 0000000..8c65ee3
--- /dev/null
+++ b/game/分类器/script.js
@@ -0,0 +1,604 @@
+// script.js
+
+const VIDEO = document.getElementById('webcam');
+const CONNECT_SERIAL_BTN = document.getElementById('connectSerialBtn');
+const DISCONNECT_SERIAL_BTN = document.getElementById('disconnectSerialBtn');
+const LOAD_MODEL_BTN = document.getElementById('loadModelBtn');
+const START_WEBCAM_BTN = document.getElementById('startWebcamBtn');
+const STOP_WEBCAM_BTN = document.getElementById('stopWebcamBtn');
+const MODEL_STATUS = document.getElementById('modelStatus');
+const SERIAL_STATUS = document.getElementById('serialStatus');
+const PREDICTION_OUTPUT = document.getElementById('prediction');
+
+let mobilenet; // 这个变量将存储加载后的 MobileNet 模型实例
+let knnClassifier; // 这个变量将存储 KNN 分类器实例
+let classNames = []; // 类别名称将从加载的模型中获取
+let webcamStream = null;
+let isPredicting = false;
+
+// Web Serial API 变量
+let serialPort = null;
+let serialWriter = null;
+const SERIAL_BAUD_RATE = 9600;
+const SERIAL_SEND_MIN_INTERVAL = 500; // 最短发送间隔,500ms,用于内部节流
+let lastSerialCommand = ''; // 用于sendToSerialPort内部的节流(避免重复命令+短间隔)
+let lastSerialSendTime = 0;
+
+// ===================================
+// 新增变量:追踪串口连接状态,并记录上一次发送到串口的类别命令
+let isSerialConnectedState = false; // 跟踪串口的逻辑连接状态
+let lastSentClassCommand = null; // 上一次"成功通过确认机制并实际发送"的命令
+// ===================================
+
+// ===================================
+// 新增变量:用于实现“确认式”发送逻辑
+let pendingCommandToSend = null; // 正在等待确认的命令
+let pendingCommandTimerId = null; // 确认定时器ID
+const CONFIRMATION_DELAY_MS = 100; // 等待 100 毫秒确认 class 是否稳定
+// ===================================
+
+
+// ===================================
+// Helper Functions (UI Status)
+// ===================================
+function showStatus(element, type, message) {
+ element.className = `status-message status-${type}`;
+ element.textContent = message;
+}
+
+function updateSerialUI(isConnected) {
+ CONNECT_SERIAL_BTN.disabled = isConnected;
+ DISCONNECT_SERIAL_BTN.disabled = !isConnected;
+ isSerialConnectedState = isConnected; // 更新我们维护的串口连接状态
+ if (!isConnected) {
+ showStatus(SERIAL_STATUS, 'info', '串口未连接。点击 "连接串口" 开始。');
+ }
+}
+
+function updateWebcamUI(isRunning) {
+ START_WEBCAM_BTN.disabled = isRunning;
+ STOP_WEBCAM_BTN.disabled = !isRunning;
+}
+
+function updateModelUI(isLoaded) {
+ LOAD_MODEL_BTN.disabled = false; // 总是允许重新加载模型
+ START_WEBCAM_BTN.disabled = !isLoaded; // 模型加载后才能启动摄像头
+}
+
+// ===================================
+// Core Logic: Model & Webcam
+// ===================================
+async function initModel() {
+ showStatus(MODEL_STATUS, 'info', '正在加载 MobileNet 模型...');
+ try {
+ mobilenet = await window.mobilenet.load({ version: 2, alpha: 1.0 });
+ knnClassifier = window.knnClassifier.create();
+ showStatus(MODEL_STATUS, 'success', 'MobileNet 模型和 KNN 分类器已加载。请加载模型文件。');
+ updateModelUI(false); // 此时 MobileNet 已加载,但 KNN 分类器本身还需加载数据
+ } catch (error) {
+ showStatus(MODEL_STATUS, 'error', `模型加载失败: ${error.message}`);
+ console.error('MobileNet/KNN加载失败:', error);
+ }
+}
+
+// 提取 MobileNet 特征
+async function getFeatures(img) {
+ if (!mobilenet) {
+ throw new Error("MobileNet model is not loaded.");
+ }
+ return tf.tidy(() => {
+ const embeddings = mobilenet.infer(img, true);
+ const norm = tf.norm(embeddings);
+ const normalized = tf.div(embeddings, norm);
+ return normalized;
+ });
+}
+
+/**
+ * 加载 KNN 模型文件,支持 '.json' 和 '.bin' 两部分文件。
+ * 用户需要依次选择对应的 .json 和 .bin 文件。
+ */
+async function loadKNNModel() {
+ const inputJson = document.createElement('input');
+ inputJson.type = 'file';
+ inputJson.accept = '.json';
+ inputJson.multiple = false;
+
+ showStatus(MODEL_STATUS, 'info', '请先选择 KNN 模型配置文件 (.json)...');
+
+ inputJson.onchange = async (e) => {
+ const jsonFile = e.target.files[0];
+ if (!jsonFile) {
+ showStatus(MODEL_STATUS, 'info', '未选择 .json 文件。');
+ updateModelUI(false);
+ return;
+ }
+
+ showStatus(MODEL_STATUS, 'info', `正在解析 ${jsonFile.name}...`);
+
+ let modelData;
+ try {
+ const reader = new FileReader();
+ const jsonText = await new Promise((resolve, reject) => {
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsText(jsonFile);
+ });
+ modelData = JSON.parse(jsonText);
+
+ if (!modelData.dataFile) {
+ console.warn('模型JSON文件不包含 "dataFile" 字段,尝试以旧的单文件JSON格式加载。');
+ return loadSingleJsonModel(modelData);
+ }
+
+ } catch (error) {
+ showStatus(MODEL_STATUS, 'error', `解析 .json 文件失败: ${error.message}`);
+ console.error('解析 .json 失败:', error);
+ updateModelUI(false);
+ return;
+ }
+
+ const inputBin = document.createElement('input');
+ inputBin.type = 'file';
+ inputBin.accept = '.bin';
+ inputBin.multiple = false;
+
+ showStatus(MODEL_STATUS, 'info', `已加载 .json 文件。请选择对应的权重文件 "${modelData.dataFile}" (.bin)...`);
+
+ inputBin.onchange = async (eBin) => {
+ const binFile = eBin.target.files[0];
+ if (!binFile) {
+ showStatus(MODEL_STATUS, 'info', '未选择 .bin 文件。');
+ updateModelUI(false);
+ return;
+ }
+
+ if (binFile.name !== modelData.dataFile) {
+ showStatus(MODEL_STATUS, 'error', `选择的 .bin 文件名 "${binFile.name}" 与 .json 中定义的 "${modelData.dataFile}" 不匹配!请选择正确的文件。`);
+ updateModelUI(false);
+ return;
+ }
+
+ showStatus(MODEL_STATUS, 'info', `正在读取 ${binFile.name} (二进制权重文件)...`);
+ let binData;
+ try {
+ const reader = new FileReader();
+ const arrayBuffer = await new Promise((resolve, reject) => {
+ reader.onload = () => resolve(reader.result);
+ reader.onerror = () => reject(reader.error);
+ reader.readAsArrayBuffer(binFile);
+ });
+ binData = new Float32Array(arrayBuffer);
+ } catch (error) {
+ showStatus(MODEL_STATUS, 'error', `读取 .bin 文件失败: ${error.message}`);
+ console.error('读取 .bin 失败:', error);
+ updateModelUI(false);
+ return;
+ }
+
+ try {
+ knnClassifier.clearAllClasses();
+
+ Object.keys(modelData.dataset).forEach(label => {
+ const classDataMeta = modelData.dataset[label];
+ const startFloat32ElementIndex = classDataMeta.start;
+ const numFloat32Elements = classDataMeta.length;
+
+ const featureDim = modelData.featureDim || 1280;
+
+ const classFeatures = binData.subarray(startFloat32ElementIndex, startFloat32ElementIndex + numFloat32Elements);
+
+ if (classFeatures.length === 0) {
+ console.warn(`类别 ${label} 没有找到特征数据,跳过。`);
+ return;
+ }
+
+ if (classFeatures.length % featureDim !== 0) {
+ const actualSamples = classFeatures.length / featureDim;
+ console.error(
+ `--- 类别: ${label} ---`,
+ `起始 Float32 元素索引: ${startFloat32ElementIndex}`,
+ `该类别 Float32 元素数量: ${numFloat32Elements}`,
+ `ERROR: 特征数据长度 (${classFeatures.length} 个 Float32 元素) 与特征维度 (${featureDim}) 不匹配!` +
+ `实际样本数计算为 ${actualSamples} (预期为整数)。`,
+ `请检查您的模型导出逻辑和训练数据的完整性。`
+ );
+ throw new Error("模型数据完整性错误:特征数据长度与维度不匹配。");
+ }
+
+ const numSamples = classFeatures.length / featureDim;
+
+ for (let i = 0; i < numSamples; i++) {
+ const startIndex = i * featureDim;
+ const endIndex = (i + 1) * featureDim;
+ const sampleFeatures = classFeatures.subarray(startIndex, endIndex);
+
+ const sampleTensor = tf.tensor(sampleFeatures, [1, featureDim]);
+
+ knnClassifier.addExample(sampleTensor, parseInt(label));
+ tf.dispose(sampleTensor);
+ }
+ });
+
+ if (modelData.classList && Array.isArray(modelData.classList)) {
+ classNames = modelData.classList.map(c => c.name);
+ } else {
+ console.warn('模型JSON中未找到 classList 字段或格式不正确,使用默认类别名称。');
+ classNames = Object.keys(modelData.dataset).map(key => `Class ${parseInt(key) + 1}`);
+ }
+
+ showStatus(MODEL_STATUS, 'success', `模型 "${jsonFile.name}" 及权重加载成功!类别: ${classNames.join(', ')}。`);
+ updateModelUI(true);
+
+ } catch (error) {
+ showStatus(MODEL_STATUS, 'error', `处理模型数据失败: ${error.message}`);
+ console.error('处理模型数据失败:', error);
+ updateModelUI(false);
+ }
+ };
+ inputBin.click();
+ };
+ inputJson.click();
+}
+
+/**
+ * 辅助函数:处理旧的单文件JSON模型格式( dataset 字段直接包含数据而不是偏移量)
+ * @param {object} modelData - 已解析的 JSON 模型数据
+ */
+async function loadSingleJsonModel(modelData) {
+ try {
+ knnClassifier.clearAllClasses();
+ Object.keys(modelData.dataset).forEach(key => {
+ const data = modelData.dataset[key];
+ const featureDim = modelData.featureDim || 1280;
+ if (data.length % featureDim !== 0) {
+ throw new Error(`类别 ${key} 的特征数据长度 ${data.length} 与特征维度 ${featureDim} 不匹配!`);
+ }
+ const numSamples = data.length / featureDim;
+ const tensor = tf.tensor(data, [numSamples, featureDim]);
+ knnClassifier.addExample(tensor, parseInt(key));
+ });
+
+ if (modelData.classList && Array.isArray(modelData.classList)) {
+ classNames = modelData.classList.map(c => c.name);
+ } else if (modelData.classNames && Array.isArray(modelData.classNames)) {
+ classNames = modelData.classNames;
+ } else {
+ console.warn('模型JSON中未找到 classList/classNames 字段,使用默认类别名称。');
+ classNames = Object.keys(modelData.dataset).map(key => `Class ${parseInt(key) + 1}`);
+ }
+
+ showStatus(MODEL_STATUS, 'success', `模型 (单文件JSON格式) 加载成功!类别: ${classNames.join(', ')}。`);
+ updateModelUI(true);
+ } catch (error) {
+ showStatus(MODEL_STATUS, 'error', `加载单文件JSON模型失败: ${error.message}`);
+ console.error('加载单文件JSON模型失败:', error);
+ updateModelUI(false);
+ }
+}
+
+
+async function startWebcam() {
+ if (webcamStream) return;
+
+ if (!knnClassifier || knnClassifier.getNumClasses() === 0) {
+ showStatus(MODEL_STATUS, 'error', '请先加载训练好的模型!');
+ return;
+ }
+
+ try {
+ const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' }, audio: false });
+ VIDEO.srcObject = stream;
+ webcamStream = stream;
+ updateWebcamUI(true);
+
+ VIDEO.onloadeddata = () => {
+ // ===================================
+ // 启动摄像头时,重置所有发送状态,确保第一次识别结果可发送
+ if (pendingCommandTimerId) {
+ clearTimeout(pendingCommandTimerId);
+ pendingCommandTimerId = null;
+ }
+ pendingCommandToSend = null;
+ lastSentClassCommand = null;
+ // ===================================
+ isPredicting = true;
+ predictLoop();
+ showStatus(MODEL_STATUS, 'info', '摄像头已启动,开始实时预测...');
+ };
+
+ } catch (error) {
+ showStatus(MODEL_STATUS, 'error', `无法访问摄像头: ${error.message}`);
+ console.error('启动摄像头失败:', error);
+ updateWebcamUI(false);
+ }
+}
+
+function stopWebcam() {
+ if (webcamStream) {
+ webcamStream.getTracks().forEach(track => track.stop());
+ webcamStream = null;
+ }
+ isPredicting = false;
+ VIDEO.srcObject = null;
+ updateWebcamUI(false);
+ PREDICTION_OUTPUT.textContent = '等待识别...';
+ showStatus(MODEL_STATUS, 'info', '摄像头已停止。');
+
+ // ===================================
+ // 停止摄像头时,清除任何待确认的命令,并发送“停止”或“复位”命令
+ if (pendingCommandTimerId) {
+ clearTimeout(pendingCommandTimerId);
+ pendingCommandTimerId = null;
+ }
+ pendingCommandToSend = null;
+
+ // 假设 '0' 是停止/复位命令,并且只有当上次发送的不是 '0' 时才发送
+ if (lastSentClassCommand !== '0') {
+ sendToSerialPort('0');
+ }
+ lastSentClassCommand = null; // 重置,确保下次启动摄像头时能发送初始状态
+ // ===================================
+}
+
+// ============== 重要的修改区域 start ==============
+// 定义一个全局变量,用于存储当前帧识别到的命令,供 setTimeout 回调使用
+let currentCommandInFrame = '0'; // 初始值默认是 '0' (复位/无动作)
+
+async function predictLoop() {
+ if (!isPredicting) return;
+
+ if (VIDEO.readyState === 4 && VIDEO.videoWidth > 0 && VIDEO.videoHeight > 0) {
+ let commandCandidate = '0'; // 每次循环开始时,将当前帧的候选命令初始化为'0'
+
+ try {
+ const features = await getFeatures(VIDEO);
+ const k = 3;
+
+ if (!knnClassifier || knnClassifier.getNumClasses() === 0) {
+ features.dispose();
+ PREDICTION_OUTPUT.textContent = 'KNN 分类器未就绪或无数据。';
+ commandCandidate = '0'; // 使用默认命令
+ } else {
+ const prediction = await knnClassifier.predictClass(features, k);
+ features.dispose();
+
+ if (prediction && prediction.confidences) {
+ let maxConfidence = 0;
+ let predictedClassIndex = -1;
+
+ const confidencesArray = Object.entries(prediction.confidences).map(([key, value]) => ({ index: parseInt(key), confidence: value }));
+
+ confidencesArray.forEach(({ index, confidence }) => {
+ if (confidence > maxConfidence) {
+ maxConfidence = confidence;
+ predictedClassIndex = index;
+ }
+ });
+
+ const confidenceThreshold = 0.75; // 75%置信度
+ if (predictedClassIndex !== -1 && maxConfidence > confidenceThreshold) {
+ const className = classNames[predictedClassIndex] || `Class ${predictedClassIndex + 1}`;
+ const percentage = (maxConfidence * 100).toFixed(1);
+ PREDICTION_OUTPUT.textContent = `识别为: ${className} (${percentage}%)`;
+
+ // 根据类别设置本帧的候选命令
+ if (predictedClassIndex === 0) {
+ commandCandidate = '1';
+ } else if (predictedClassIndex === 1) {
+ commandCandidate = '2';
+ } else {
+ commandCandidate = '0'; // 未匹配到特定类别,或默认复位
+ }
+ } else {
+ PREDICTION_OUTPUT.textContent = `未知或不确定... (最高置信度: ${(maxConfidence * 100).toFixed(1)}%)`;
+ commandCandidate = '0'; // 不确定也发送'0'回退
+ }
+ } else {
+ PREDICTION_OUTPUT.textContent = '无法识别。';
+ commandCandidate = '0'; // 无法识别也发送 '0' 回退
+ }
+ }
+ } catch (error) {
+ console.error('预测错误:', error);
+ PREDICTION_OUTPUT.textContent = `预测错误: ${error.message}`;
+ commandCandidate = '0'; // 错误时也发送'0'
+ }
+
+ // =========================================================
+ // 核心逻辑:确认式发送机制 (Persistence / Confirmation)
+ // =========================================================
+
+ // 更新全局变量 currentCommandInFrame,指示当前帧的预测结果
+ currentCommandInFrame = commandCandidate;
+
+ // 只有当当前帧的候选命令与上一次实际发送的命令不同时,才进入确认流程
+ if (currentCommandInFrame !== lastSentClassCommand) {
+ // 如果目前没有待确认的命令,或者待确认的命令与当前不同
+ if (pendingCommandToSend === null || pendingCommandToSend !== currentCommandInFrame) {
+ if (pendingCommandTimerId) {
+ clearTimeout(pendingCommandTimerId); // 清除旧的等待定时器
+ pendingCommandTimerId = null;
+ // console.log(`[${new Date().toLocaleTimeString()}] 命令在确认前再次变化,取消旧的确认定时器.`);
+ }
+
+ pendingCommandToSend = currentCommandInFrame; // 设置新的待确认命令
+
+ // 设置一个定时器,在 CONFIRMATION_DELAY_MS 后进行确认
+ pendingCommandTimerId = setTimeout(async () => {
+ // 当定时器触发时,再次检查全局的 currentCommandInFrame
+ // 如果它仍然与我们开始等待的命令相同,则确认并发送
+ if (currentCommandInFrame === pendingCommandToSend) {
+ console.log(`[${new Date().toLocaleTimeString()}] 确认命令 "${pendingCommandToSend}" 稳定,正在发送。`);
+ await sendToSerialPort(pendingCommandToSend);
+ lastSentClassCommand = pendingCommandToSend; // 更新上一次实际发送的命令
+ } else {
+ console.log(`[${new Date().toLocaleTimeString()}] 命令在确认期内再次变化 (${pendingCommandToSend} -> ${currentCommandInFrame}),不发送。`);
+ }
+ pendingCommandToSend = null; // 清理待确认状态
+ pendingCommandTimerId = null; // 清理定时器ID
+ }, CONFIRMATION_DELAY_MS);
+
+ // console.log(`[${new Date().toLocaleTimeString()}] 检测到新候选命令 "${currentCommandInFrame}",启动 ${CONFIRMATION_DELAY_MS}ms 确认计时器。`);
+ }
+ // 如果 currentCommandInFrame 1. 仍然与 pendingCommandToSend 相同
+ // 并且 2. 不同于 lastSentClassCommand
+ // 说明它处在“正在等待确认”的状态,不需要做任何事情,让当前定时器继续运行
+ } else { // 当前帧的命令与上次实际发送的命令相同
+ // 如果已经发送了相同的命令,或者回到了已发送的命令,那么清除待确认定时器
+ if (pendingCommandTimerId) {
+ clearTimeout(pendingCommandTimerId);
+ pendingCommandTimerId = null;
+ pendingCommandToSend = null; // 清理待确认状态
+ // console.log(`[${new Date().toLocaleTimeString()}] 当前命令 "${currentCommandInFrame}" 与上次发送命令相同,或回摆,取消所有待确认发送。`);
+ }
+ }
+ // =========================================================
+ }
+ requestAnimationFrame(predictLoop); // 继续下一帧预测
+}
+// ============== 重要的修改区域 end ==============
+
+
+// ===================================
+// Web Serial API Logic
+// ===================================
+async function checkWebSerialCompatibility() {
+ if ('serial' in navigator) {
+ showStatus(SERIAL_STATUS, 'info', 'Web Serial API 可用!');
+ CONNECT_SERIAL_BTN.disabled = false;
+ } else {
+ showStatus(SERIAL_STATUS, 'error', 'Web Serial API 在此浏览器中不可用。请使用最新版 Chrome!');
+ CONNECT_SERIAL_BTN.disabled = true;
+ }
+}
+
+async function connectSerial() {
+ showStatus(SERIAL_STATUS, 'info', '正在请求连接串口...');
+ try {
+ serialPort = await navigator.serial.requestPort();
+
+ serialPort.addEventListener('disconnect', () => {
+ const disconnectTime = new Date().toLocaleString();
+ console.warn(`[${disconnectTime}] ❗️❗️❗️ 串口已断开连接事件触发! `); // 强调日志
+ showStatus(SERIAL_STATUS, 'error', '串口连接已丢失!');
+ disconnectSerial();
+ });
+
+ await serialPort.open({ baudRate: SERIAL_BAUD_RATE });
+ console.log('串口已成功打开。尝试获取写入器...');
+ serialWriter = serialPort.writable.getWriter();
+ console.log('写入器已获取。');
+
+ console.log('串口已连接并打开。');
+ showStatus(SERIAL_STATUS, 'success', `串口已连接 (Baud: ${SERIAL_BAUD_RATE})`);
+ updateSerialUI(true); // 更新状态为已连接
+ } catch (error) {
+ console.error('连接串口失败:', error);
+ if (error.name === 'NotFoundError') {
+ showStatus(SERIAL_STATUS, 'warning', '连接串口请求已取消或未选择端口。请选择一个设备。');
+ } else if (error.name === 'SecurityError') {
+ showStatus(SERIAL_STATUS, 'error', `连接串口失败: ${error.message}。请确保您在安全上下文 (HTTPS 或 localhost) 中运行。`);
+ } else {
+ showStatus(SERIAL_STATUS, 'error', `连接串口失败: ${error.message}`);
+ }
+ updateSerialUI(false); // 更新状态为未连接
+ }
+}
+
+async function disconnectSerial() {
+ console.trace(`[${new Date().toLocaleString()}] disconnectSerial() called`);
+
+ if (!isSerialConnectedState && !serialPort && !serialWriter) {
+ console.log('Already in disconnected state, skipping further cleanup.');
+ return;
+ }
+
+ // ===================================
+ // 断开连接时,清除任何待确认的定时器和状态
+ if (pendingCommandTimerId) {
+ clearTimeout(pendingCommandTimerId);
+ pendingCommandTimerId = null;
+ }
+ pendingCommandToSend = null;
+ // ===================================
+
+ try {
+ if (serialWriter) {
+ await serialWriter.close();
+ serialWriter = null;
+ }
+ if (serialPort && serialPort.readable) {
+ const reader = serialPort.readable.getReader();
+ if (reader) {
+ await reader.cancel();
+ reader.releaseLock();
+ }
+ }
+ if (serialPort && serialPort.isOpen) {
+ await serialPort.close();
+ }
+ serialPort = null;
+ console.log('串口已断开。');
+ showStatus(SERIAL_STATUS, 'info', '串口已断开。');
+ } catch (error) {
+ console.error('断开串口失败或串口已处于非连接状态:', error);
+ showStatus(SERIAL_STATUS, 'error', `断开串口失败: ${error.message}`);
+ } finally {
+ updateSerialUI(false); // 无论如何都确保UI和状态更新为断开
+ lastSentClassCommand = null; // 断开连接时,重置上一次发送的命令,确保重连后第一次变化能发送
+ }
+}
+
+/**
+ * 低级串口发送函数。包含内部的频繁发送节流。
+ * @param {string} command - 要发送的命令字符串。
+ */
+async function sendToSerialPort(command) {
+ if (!isSerialConnectedState) {
+ // console.warn('串口已断开(逻辑状态),无法发送命令。');
+ return;
+ }
+
+ if (!serialWriter) {
+ console.warn(`[${new Date().toLocaleString()}] 串口连接状态为 true,但 serialWriter 不可用。尝试重置串口连接。`);
+ disconnectSerial();
+ return;
+ }
+
+ // 避免频繁发送相同的命令 (sendToSerialPort 内部的节流)
+ const currentTime = new Date().getTime();
+ if (command === lastSerialCommand && (currentTime - lastSerialSendTime < SERIAL_SEND_MIN_INTERVAL)) {
+ return; // 命令相同且发送间隔太短,不发送
+ }
+
+ try {
+ const encoder = new TextEncoder();
+ const data = encoder.encode(command + '\n');
+ await serialWriter.write(data);
+ console.log(`[${new Date().toLocaleTimeString()}] 实际发送串口命令: ${command}`);
+ lastSerialCommand = command; // 更新内部节流用的命令
+ lastSerialSendTime = currentTime;
+ } catch (error) {
+ console.error(`[${new Date().toLocaleTimeString()}] 实际发送串口命令失败: ${error.message}`);
+ showStatus(SERIAL_STATUS, 'error', `发送串口命令失败: ${error.message},正在断开串口。`);
+ disconnectSerial();
+ }
+}
+
+// ===================================
+// Event Listeners
+// ===================================
+CONNECT_SERIAL_BTN.addEventListener('click', connectSerial);
+DISCONNECT_SERIAL_BTN.addEventListener('click', disconnectSerial);
+LOAD_MODEL_BTN.addEventListener('click', loadKNNModel);
+START_WEBCAM_BTN.addEventListener('click', startWebcam);
+STOP_WEBCAM_BTN.addEventListener('click', stopWebcam);
+
+// ===================================
+// Initialization
+// ===================================
+document.addEventListener('DOMContentLoaded', () => {
+ checkWebSerialCompatibility(); // 检查 Web Serial API 兼容性
+ initModel(); // 加载 MobileNet 和 KNN 分类器实例
+});
diff --git a/归档.zip b/归档.zip
new file mode 100644
index 0000000..1b5e407
Binary files /dev/null and b/归档.zip differ