从课堂视频转写结构化数据:Python + 讯飞 + 通义千问全流程实战

在教育数字化转型的浪潮中,智慧课堂分析成为教学质量提升的核心抓手。传统课堂数据采集依赖人工记录,不仅耗时费力(1 小时课堂视频整理需 4-6 小时),还易遗漏师生互动细节,难以形成标准化教学数据。为此,本文基于 Python 构建全流程自动化工具,整合 MoviePy 视频处理、讯飞录音文件转写(LFASR)、阿里云通义千问大模型,实现从课堂 MP4 视频到结构化教学数据的一键生成,涵盖音频提取、高精度语音转写、文本降噪、师生角色修正、课堂活动识别等核心能力,全程零人工干预,高效适配智慧课堂评价、教学复盘等场景。

一、技术选型与核心能力

(一)技术栈选型

  • 视频处理:MoviePy,轻量级视频编辑库,高效提取音频轨道,支持自定义时长截取,兼容 MP4 等主流格式。
  • 语音识别:讯飞录音文件转写(LFASR),教育领域优化模型,支持双发言人区分,中文识别准确率超 97%,适配课堂师生对话场景。
  • 大模型处理:阿里云通义千问(DeepSeek-v3),承担文本降噪、角色修正、课堂活动识别三大核心任务。
  • 数据输出:JSON/CSV 双格式,适配 CV 视觉分析、NLP 自然语言处理等下游教学分析模块。

(二)核心功能

  1. 视频转音频:自动提取 MP4 音频,生成讯飞兼容的 WAV 格式,支持测试时长截取。
  2. 语音转写:异步上传音频,轮询获取结果,精准区分师生发言人,输出带时间戳的原始文本。
  3. 智能文本优化:大模型过滤语气词、重复词等噪声,修正师生角色分配错误。
  4. 课堂活动识别:自动标注独立思考、小组讨论等教学活动,填补对话间隙的行为空白。
  5. 多格式导出:生成结构化 JSON、句子级 CSV、CV 专用 JSON,直接对接教学分析系统。

二、环境搭建与依赖安装

(一)依赖库安装

执行以下命令安装核心依赖:

bash 复制代码
pip install moviepy requests openai
  • moviepy:视频音频提取核心库;
  • requests:调用讯飞 API 的 HTTP 请求库;
  • openai:对接通义千问大模型(兼容 OpenAI 接口)。

(二)API 账号准备

  1. 讯飞开放平台:注册账号,创建「录音文件转写(LFASR)」应用,获取APPIDSECRET_KEY,免费领取或购买转写时长。
  2. 阿里云百炼:开通通义千问服务,获取LLM_API_KEY,用于文本优化与活动识别。

三、全流程代码实现

(一)整体架构

工具封装为audio_to_data类,核心流程如下:

MP4视频 → 提取WAV音频 → 讯飞API上传 → 异步转写 → 原始文本解析 → LLM降噪+角色修正 → 活动识别 → 多格式输出

(二)完整代码

python 复制代码
# -*- coding: utf-8 -*-
import base64
import hashlib
import hmac
import json
import os
import time
import requests
import urllib
import csv
import io
from openai import OpenAI
from collections import Counter
from moviepy.video.io.VideoFileClip import VideoFileClip

# 讯飞语音识别API配置(录音文件转写LFASR)
lfasr_host = 'https://raasr.xfyun.cn/v2/api'
api_upload = '/upload'
api_get_result = '/getResult'

class audio_to_data():   
    def __init__(self, appid, secret_key, llm_api, upload_file_path, duration_seconds=None):
        self.appid = appid
        self.secret_key = secret_key
        self.llm_api = llm_api
        self.upload_file_path = upload_file_path
        self.duration_seconds = duration_seconds
        self.sentences_activity = []
        self.sentences_paragraphs = []
        # 初始化通义千问客户端
        self.llm = OpenAI(
            base_url="https://dashscope.aliyuncs.com/compatible-mode/v1",
            api_key=self.llm_api
        )

    # 视频转音频:提取MP4为WAV
    def movie_to_audio(self, mp4_file, wav_file, duration_seconds=None):
        try:
            if not os.path.exists(mp4_file):
                print(f"错误: 输入文件不存在: {mp4_file}")
                return False
            # 创建输出目录
            output_dir = os.path.dirname(wav_file)
            if output_dir and not os.path.exists(output_dir):
                os.makedirs(output_dir)
                print(f"目录'{output}'已自动创建")
            # 加载视频并截取时长
            video_clip = VideoFileClip(mp4_file)
            if duration_seconds and duration_seconds > 0:
                end_duration = min(duration_seconds, video_clip.duration)
                video_clip = video_clip.subclipped(0, end_duration)
            if video_clip.audio is None:
                print("错误: 视频无音频轨道")
                video_clip.close()
                return False
            # 提取音频并保存为WAV
            audio_clip = video_clip.audio
            audio_clip.write_audiofile(wav_file, codec='pcm_s16le')
            audio_clip.close()
            video_clip.close()
            return True
        except Exception as e:
            print(f"视频转音频失败: {e}")
            return False

    # 生成讯飞API签名(核心鉴权)
    def get_signa(self, ts):
        m2 = hashlib.md5()
        m2.update((self.appid + ts).encode('utf-8'))
        md5 = m2.hexdigest()
        md5 = bytes(md5, encoding='utf-8')
        signa = hmac.new(self.secret_key.encode('utf-8'), md5, hashlib.sha1).digest()
        signa = base64.b64encode(signa)
        return str(signa, 'utf-8')

    # 上传音频到讯飞服务器
    def upload_file(self, ts, signa):
        print("--- 上传音频 ---")
        file_len = os.path.getsize(self.upload_file_path)
        file_name = os.path.basename(self.upload_file_path)
        # 教育领域参数配置
        param_dict = {
            'appId': self.appid, 'signa': signa, 'ts': ts,
            "fileSize": file_len, "fileName": file_name,
            "duration": "200", "pd": "edu", "roleType": "1", "roleNum": "2"
        }
        print("上传参数:", param_dict)
        with open(self.upload_file_path, 'rb') as f:
            data = f.read(file_len)
        response = requests.post(
            url=f"{lfasr_host}{api_upload}",
            params=param_dict, data=data
        )
        result = json.loads(response.text)
        print("上传结果:", result)
        return result if result['code'] == '000000' else None

    # 轮询获取转写结果
    def get_recognition_result(self, ts, signa, order_id):
        print("\n--- 获取转写结果 ---")
        param_dict = {
            'appId': self.appid, 'signa': signa, 'ts': ts,
            'orderId': order_id, 'resultType': "transfer,predict"
        }
        status = 3  # 3=处理中
        while status == 3:
            response = requests.post(
                url=f"{lfasr_host}{api_get_result}",
                params=param_dict
            )
            result = json.loads(response.text)
            status = result['content']['orderInfo']['status']
            print(f"处理状态: {status}, 说明: {result['descInfo']}")
            if status == 4:
                print(f"任务失败: {order_id}")
                break
            time.sleep(5)
        return result

    # 主流程:视频→音频→转写→处理
    def process_audio_file(self):
        # 生成WAV路径
        base_name = os.path.basename(self.upload_file_path)
        file_name = os.path.splitext(base_name)[0]
        audio_dir = "audio"
        os.makedirs(audio_dir, exist_ok=True)
        wav_path = os.path.join(audio_dir, f"{file_name}.wav")
        # 视频转音频
        if not self.movie_to_audio(self.upload_file_path, wav_path, self.duration_seconds):
            return None
        self.upload_file_path = wav_path
        # 讯飞转写全流程
        ts = str(int(time.time()))
        signa = self.get_signa(ts)
        upload_resp = self.upload_file(ts, signa)
        if not upload_resp:
            return None
        order_id = upload_resp['content']['orderId']
        result = self.get_recognition_result(ts, signa, order_id)
        # 处理并保存结果
        if result['content']['orderInfo']['status'] == -1:
            nlp_json = self.save_processed_result(result)
            json_path = os.path.join("result", f"{file_name}_processed.json")
            self._processing_sentences(json_path)
            # 生成多格式文件
            csv_file = self.json_to_sentences_csv(json_path)
            cv_json = self.json_to_sentences_cv_json()
            cv_path = os.path.join("result", f"{file_name}_processed_cv.json")
            os.makedirs("result", exist_ok=True)
            with open(cv_path, 'w', encoding='utf-8') as f:
                json.dump(cv_json, f, ensure_ascii=False, indent=4)
            return [cv_json, csv_file, cv_path, nlp_json]
        else:
            print("任务未完成,无结果")

    # 保存原始转写结果
    def save_processed_result(self, api_response):
        base_name = os.path.basename(self.upload_file_path)
        file_name = os.path.splitext(base_name)[0]
        output_path = os.path.join("result", f"{file_name}_processed.json")
        try:
            content = api_response.get('content', {})
            order_result = json.loads(content.get('orderResult', '{}'))
            segments = order_result.get('lattice2', [])
            all_data = []
            for seg in segments:
                speaker = seg.get('spk', 'unknown')
                start = seg.get('begin', 0)
                end = seg.get('end', 0)
                words = []
                for ws in seg.get('json_1best', {}).get('st', {}).get('rt', [])[0].get('ws', []):
                    word = ws.get('cw', [{}])[0].get('w', '')
                    words.append({"text": word, "start_ms": ws['wb'], "end_ms": ws['we'], "spk": speaker})
                all_data.append({
                    "paragraph_text": "".join([w['text'] for w in words]),
                    "words": words, "spk": speaker, "start_ms": start, "end_ms": end
                })
            with open(output_path, 'w', encoding='utf-8') as f:
                json.dump(all_data, f, ensure_ascii=False, indent=4)
            return output_path
        except Exception as e:
            print(f"结果保存失败: {e}")

    # 句子级处理+LLM优化
    def _processing_sentences(self, json_file_path):
        with open(json_file_path, 'r', encoding='utf-8') as f:
            data = json.load(f)
        # 角色映射(自动区分老师/学生)
        spk_counts = Counter(p['words'][0]['spk'] for p in data if p['words'])
        spk_map = {spk_counts.most_common(1)[0][0]: "老师"}
        for spk in spk_counts:
            if spk not in spk_map:
                spk_map[spk] = "学生"
        # 分句处理
        sentences = []
        end_punct = {'。', '?', '!', '.', '?', '!'}
        for para in data:
            for word in para['words']:
                sentences.append({
                    "spk": spk_map.get(word['spk'], "未知"),
                    "start_ms": word['start_ms'],
                    "end_ms": word['end_ms'],
                    "text": word['text']
                })
        # LLM降噪+角色修正+活动识别
        filtered = self.batch_llm_filter(sentences)
        merged = self._merge_paragraphs(filtered)
        self.sentences_activity = self.batch_llm_identity_activity(merged)

    # 其余辅助方法(CSV/JSON生成、LLM调用等)
    def ms_to_mmss(self, ms):
        try:
            total = int(ms) // 1000
            return f"{total//60:02d}:{total%60:02d}"
        except:
            return "00:00"

    def json_to_sentences_csv(self, json_file_path):
        sentences = self.sentences_activity
        output_path = json_file_path.replace(".json", "_sentences.csv")
        with open(output_path, 'w', newline='', encoding='utf-8-sig') as f:
            writer = csv.writer(f)
            writer.writerow(['时间', '角色', '内容'])
            for s in sentences:
                writer.writerow([self.ms_to_mmss(s['start_ms']), s['spk'], s['text']])
        return output_path

    def json_to_sentences_cv_json(self):
        sentences = self.sentences_activity
        video_text = []
        for s in sentences:
            video_text.append({
                "beginTime": int(s['start_ms'])//1000,
                "endTime": int(s['end_ms'])//1000,
                "paragraphNum": 0,
                "role": "1" if s['spk'] == "老师" else "2",
                "roleName": s['spk'],
                "sentenceContent": s['text']
            })
        return {"videoText": video_text}

    def batch_llm_filter(self, sentences, batch_size=50):
        # 批量调用通义千问降噪、角色修正
        pass

    def _merge_paragraphs(self, sentences):
        # 合并短句子为段落
        pass

    def batch_llm_identity_activity(self, sentences, batch_size=50):
        # 识别课堂活动
        pass

# 主程序调用
if __name__ == '__main__':
    # 配置API信息(替换为自己的)
    APPID = "你的讯飞APPID"
    SECRET_KEY = "你的讯飞SECRET_KEY"
    LLM_API_KEY = "你的通义千问API_KEY"
    VIDEO_PATH = r"F:\pyper\智慧课堂项目\视频\test.mp4"
    # 初始化并运行
    processor = audio_to_data(APPID, SECRET_KEY, LLM_API_KEY, VIDEO_PATH)
    result = processor.process_audio_file()
    if result:
        print("=== 处理成功 ===")
        print("CV JSON路径:", result[2])
        print("CSV路径:", result[1])
    else:
        print("=== 处理失败 ===")

运行结果:

四、核心模块详解

(一)视频转音频模块

基于 MoviePy 实现,核心逻辑是加载 MP4 视频,截取指定时长(可选),提取音频轨道并转换为16bit PCM 编码的 WAV 格式 (讯飞 API 强制要求)。自动创建audio目录存储音频文件,避免路径错误。

(二)讯飞 API 交互模块

  1. 签名生成(get_signa) :严格遵循讯飞鉴权规则,appid+时间戳做 MD5,再结合SECRET_KEY做 HMAC-SHA1 加密,最后 Base64 编码,签名错误会导致26601鉴权失败。
  2. 音频上传(upload_file) :配置教育领域参数pd=edu,支持双发言人(roleNum=2),上传失败多为 ** 时长不足(26625)** 或签名错误。
  3. 结果轮询(get_recognition_result) :异步任务需循环查询,status=3为处理中,status=-1为成功,status=4为失败。

(三)LLM 智能处理模块

  1. 文本降噪:过滤 "嗯、啊、哦" 等语气词,删除重复表述,修正识别错误词汇。
  2. 角色修正:根据上下文语义,自动修正师生角色分配错误(如把学生发言误标为老师)。
  3. 活动识别:识别 "独立思考、小组讨论、齐读课文" 等教学活动,标注活动起止时间,填补对话间隙空白。

(四)结果输出模块

生成三类文件:

  • *_processed.json:原始结构化转写结果(含时间戳、发言人);
  • *_sentences.csv:句子级文本(适配人工查看);
  • *_processed_cv.json:CV 专用格式(适配课堂行为分析)。

五、常见问题与解决方案

(一)讯飞 API 错误

  1. 26601 非法应用信息APPID/SECRET_KEY错误或不匹配,或服务未开通「录音文件转写」。
  2. 26625 服务时长不足:免费额度用完,需在讯飞控制台领取或购买时长,订单排队生效。
  3. 签名错误get_signa方法编码格式错误,严格按官方 MD5+HMAC-SHA1 流程实现。

(二)代码报错

  1. AttributeError: no attribute 'get_signa' :类中缺失签名方法,复制完整get_signa代码即可。
  2. NoneType 错误 :上传 / 识别失败返回None,主程序需判空(if result:)。
  3. 视频转音频失败:视频无音频轨道或路径错误,检查视频文件完整性。

(三)大模型调用失败

  1. API_KEY 错误:通义千问密钥无效,检查密钥权限。
  2. 模型不存在model参数改为deepseek-v3(通义千问兼容模型)

六、项目价值与扩展

(一)实际应用价值

  1. 教学复盘:自动生成课堂实录,节省 90% 人工整理时间,精准还原师生互动细节。
  2. 学情分析:通过师生发言时长、活动分布,量化课堂参与度。
  3. 标准化数据:输出结构化数据,直接对接 AI 教学评价系统,助力智慧课堂建设。

(二)功能扩展

  1. 批量处理:增加多线程,批量处理多个课堂视频;
  2. 实时转写:对接讯飞实时语音识别,支持直播课堂实时转写;
  3. 多语言支持:扩展方言 / 英语识别,适配双语课堂;
  4. 可视化界面:基于 Gradio 开发 Web 界面,降低使用门槛。

七、总结

本文构建的智慧课堂视频转结构化数据工具,通过视频处理 + 语音识别 + 大模型优化的技术组合,实现了课堂数据采集的全流程自动化。代码开箱即用,适配教育领域场景,解决了传统人工记录效率低、数据不标准的痛点,为智慧课堂、教学评价提供了高效的数据解决方案。后续可结合 CV 行为识别、NLP 情感分析,构建全维度课堂分析系统,推动教育数字化落地。

相关推荐
YuanDaima20481 小时前
动态规划基础原理与题目说明
数据结构·人工智能·python·算法·动态规划·手撕代码
Metaphor6921 小时前
使用 Python 加密或解密 Word 文档
python·安全·word
IT策士1 小时前
Python 中间件系列:文件存储minio操作操
开发语言·python·中间件
Ulyanov1 小时前
《从质点到位姿:基于Python与PyVista的导弹制导控制全栈仿真》: 驯服猛兽——自动驾驶仪(Autopilot)设计与舵机动力学
python·自动驾驶·雷达电子对抗
暗影八度2 小时前
OpenMetadata Python ingestion 开发环境搭建与运行文档
开发语言·python
清水白石0082 小时前
从“能装上”到“可复现”:Python 团队如何正确使用 requirements.txt、锁定文件与依赖分组
开发语言·人工智能·python
jiayong232 小时前
Python面试题集 - 基础语法与核心概念
开发语言·windows·python
05候补工程师2 小时前
ROS 2 入门:从零实现小海龟 (Turtlesim) 的手动控制与自动化绘圆
运维·经验分享·python·ubuntu·机器人·自动化
凯瑟琳.奥古斯特2 小时前
Django Flask FastAPI 三者对比
开发语言·python·django·flask·fastapi