flask 后端 + 微信小程序和网页两种前端:调用硬件(相机和录音)和上传至服务器

选择 flask 作为后端,因为后续还需要深度学习模型,python 语言最适配;而 flask 框架轻、学习成本低,所以选 flask 作为后端框架。

微信小程序封装了调用手机硬件的 api,通过它来调用手机的摄像头、录音机,非常方便。

网页端使用 JavaScript 调用则困难一些,走了很多弯路,在这里记录下来。

前提:已经配置好 python 环境、安装了 flask;

坑(备忘)

  • 访问摄像头,谷歌浏览器可以访问,但是 edge 不可以,百思不得其解,查到 浏览器调用摄像头拍照 说因为 windows 限制了访问权限,不只是打开网页的时候问你的那个,还有系统限制。解决方法是
    在这里插入图片描述
    这么做确实可以,可是移动端怎么办呢?我这个网页还是想在移动端使用,移动端怎么办呢……

flask 端

flask 的任务是收取前端传来的文件,保存在本地。

from flask import Flask, request, jsonify, render_template
app = Flask(__name__)
app.config.from_object(__name__)
app.config["JSON_AS_ASCII"] = False  # 防止中文乱码
app.json.ensure_ascii = False  # 防止中文乱码
# 设置上传文件夹
app.config['UPLOAD_FOLDER'] = r'D:\A_data_trans\test(改成你的位置)'

@app.route('/vqa', methods=['POST'])
def app_vqa():
    # 保存图片
    img_file = request.files['img']  # 这里规定了前端传图片过来的时候,用的关键字是 'img',别的,比如 'image' 就会拿不到
    if img_file.filename == '':
        return jsonify({'error': 'No image'}), 400
    try:
        image_path = os.path.join(app.config['UPLOAD_FOLDER'], img_file.filename)
        img_file.save(image_path)
        log(f"save image: {image_path}")
    except Exception as e:
        return jsonify({'error': str(e)}), 500

    # 传过来的就是文本
    question = request.form['question']  # 前端传来的文本信息都是放在 form 中的

    # 预测答案
    try:
        answer = vqa(image_path, question)
        return jsonify(answer)
    except Exception as e:
        return jsonify({'error': str(e)}), 500

# 接收文件的代码,其实和上面长得一样,略微有一 miu miu 区别
@app.route('/upload', methods=['POST'])
def app_upload_file():
    # 保存图片
    img_file = request.files['img']
    if img_file.filename == '':
        return jsonify({'error': 'No image'}), 400
    try:
        image_path = os.path.join(app.config['UPLOAD_FOLDER'], img_file.filename)
        img_file.save(image_path)
        shutil.copy(image_path, os.path.join(os.path.dirname(__file__), 'static/show.jpg'))  # 用于展示在网页上
        log(f"save image: {image_path}")
    except Exception as e:
        return jsonify({'error': str(e)}), 500

    try:
        # 传过来的就是文本
        question = request.form['question']
    except:
        question = "请描述图片内容"
    return jsonify({"image": img_file.filename, "question": question})


@app.route('/upload/speech', methods=['POST'])
def recognize_speech():
    speech_file = request.files['speech']
    try:
        save_path = os.path.join(app.config['UPLOAD_FOLDER'], speech_file.filename)
        speech_file_path = os.path.join(app.config['UPLOAD_FOLDER'], save_path)
        speech_file.save(speech_file_path)
        # question = speech2txt(speech_file_path)
        # print('百度识别结果:', question)
    except Exception as e:
        return jsonify({'error': str(e)}), 500
    return jsonify({"speech": speech_file.filename})

微信小程序

微信小程序端的任务是,调用手机相机,把相机画面展示给用户,加一个按钮,点击按钮拍照;另外一个按钮,点击可以把拍到的照片上传。

wxml 中,放上一个 camera 用来显示相机画面;放上几个 button,控制拍照、上传。

<!--index.wxml-->
<scroll-view class="scrollarea" scroll-y type="list">
<!-- 相机画面 -->
  <view class="my-container">
  <!-- 显示相机画面 -->
    <camera device-position="back" flash="off" binderror="error" style="width: 90%; height: 200px;"></camera>
  </view>
    <!-- 按钮集合 -->
  <view class="my-container">
    <!-- 拍照、录音、ocr 按钮 -->
    <view class="button-row">
      <!-- 拍摄照片按钮 -->
      <button class="btn-normal btn-large" hover-class="btn-pressed" bind:tap="takePhoto">拍摄图片</button>
      <!-- 录音得到 Question -->
      <button class="btn-normal btn-large" hover-class="btn-pressed" bind:touchstart="startRecord" bind:touchend="stopRecord">长按提问</button>
    </view>
    
    <!-- caption 和 vqa 按钮 -->
    <view class="button-row">
      <!-- 发送预测 caption 请求 -->
      <button class="btn-normal btn-large" hover-class="btn-pressed" bind:tap="predCaption">描述图片</button>
      <!-- 发送预测 vqa 请求 -->
      <button class="btn-normal btn-large" hover-class="btn-pressed" bind:tap="predVQA">回答问题</button>
    </view>
  </view>
</scroll-view>

用到的 wxss

/**index.wxss**/
page {
  height: 100vh;
  display: flex;
  flex-direction: column;
}
.scrollarea {
  flex: 1;
  overflow-y: hidden;
}

.btn-normal {
  margin-top: 10px;
  padding: 10px;
  background-color: rgb(252, 226, 230);
  color: black;
  border-radius: 0ch;
  border-color: brown;
  border-width: 1px;
  border-style: dotted;
  cursor: pointer;
  height: 70px;
  line-height: 50px;
  width: 90%;
  text-align: center;
  font-size: xx-large;
}

.btn-large {
  height: 300px;
}

.btn-pressed {
  background-color: rgb(202, 129, 140);
  color: rgb(82, 75, 75);
}

.btn-human {
  background-color: darkseagreen;
}

.btn-human-pressed {
  background-color:rgb(89, 141, 89);
  color: rgb(75, 82, 77);
}

button:not([size=mini]) {
  width: 90%;
}

.useGuide {
  margin-top: 10px;
  margin-bottom: 10px;
  width: 90%;
}

.text-question {
  margin-top: 10px;
  width: 90%;
}

.my-container {  
  display: flex;  
  flex-direction: column;  
  align-items: center;  
  justify-content: center;  
}  
  
.button-row {  
  display: flex;  
  justify-content: space-between;
  width: 90%;
}  

.donot-display {
  display: none;
}

js 部分。因为微信小程序给封装得很好,所以基本没有什么坑,按照这个写就行,基本不出错。要注意各种 success 方法,要用 success: (res) => {} 的写法,不然在里面调用 this 是识别不到的。

Page({
	data: {
    serverUrl: 'http://改成你的',  // 服务器地址 
    photoData: '',  // 用户拍摄的图片
    speechData: '',  // 用户提问的录音文件
    textQuestion: '',  // 用户提问文本
    recorderManager: null,
    textAnswer: '',  // vqa模型识别的文本
  },
  // 点击拍照的方法在这里 (按钮绑定在 wxml 就写好了)
  takePhoto(e) {
    console.log("拍摄照片")
    const ctx = wx.createCameraContext();
    ctx.takePhoto({
      quality: 'low',
      success: (res) => {
        this.setData({
          photoData: res.tempImagePath  // res.tempImagePath 就可以拿到拍到的照片文件的 object url 地址,把这个地址传给服务器,就可以把该文件传给服务器
        });
      }
    });
  },
  // 控制长按录音的代码放在这里(按钮绑定在 wxml 就写好了)
  startRecord() {
    const recorderManager = wx.getRecorderManager();
    this.setData({ recorderManager });
	// 停止录音的回调方法;在这里我加了调用百度语音 api 的东西,这部分会另外写文详说,这里只放出来一部分。所以这里没有把录音文件上传,而是直接把语音识别的结果上传文件夹
    recorderManager.onStop((res) => {
      console.log('recorder stop', res);
      this.setData({ speechData: res.tempFilePath });
      var baiduAccessToken = wx.getStorageSync('baidu_yuyin_access_token');
      // 读取文件并转为 ArrayBuffer
      const fs = wx.getFileSystemManager();
      fs.readFile({
        filePath: res.tempFilePath,
        success: (res) => {
          const base64 = wx.arrayBufferToBase64(res.data);
          wx.request({
            url: 'https://vop.baidu.com/server_api',
            data: {
              format: 'pcm',
              rate: 16000,
              channel: 1,
              cuid: 'sdfdfdfsfs',
              token: baiduAccessToken,
              speech: base64,
              len: res.data.byteLength,
            },
            method: "POST",
            header: {
              'content-type': 'application/json'
            },
            success: (res) => {
              wx.hideLoading();
              console.log("拿到百度语音api返回的结果")
              console.log(res.data);
              var baiduResults = res.data.result;
              console.log(baiduResults[0]);
              if (baiduResults.lenth == 0) {
                wx.showToast({
                  title: '未识别要语音信息!',
                  icon: 'none',
                  duration: 3000
                })} else {
                  this.setData({textQuestion: baiduResults[0]});
                }
            }
          })
        }
      })
    });
	// 这里才是控制录音的参数;微信小程序端可以设置这些录音参数,因为后面要调用百度语音识别 api,该 api 仅支持采样率 16000 或 8000,对压缩格式也有要求,所以录音的时候要和 api 的要求保持一致
    recorderManager.start({
      format: 'PCM',
      duration: 20000,  // 最长支持 20s
      sampleRate:16000,
      encodeBitRate: 48000,
      numberOfChannels: 1,
      success: (res) => {
        console.log('开始录音');
      },
      fail: (err) => {
        console.error('录音失败', err);
      }
    });
  },
  // 上传的代码放在这里
  predVQA() {
    if (this.data.photoData != '' && this.data.textQuestion != ''){
      console.log('send img' + this.data.photoData);
      wx.uploadFile({
        filePath: this.data.photoData,
        name: 'img',  // 文件对应 key,后端通过该 key 获取文件;前后端注意保持一致
        url: this.data.serverUrl+'/vqa',
        formData: {'question': this.data.textQuestion},
        success: (res) => { 
          console.log('成功上传'); 
          if (res.statusCode == 200) {
            var answer = res.data
            this.setData({ textAnswer: answer })
          } else { console.error(res) }
        },
        fail: (err) => { console.error('上传失败'); }
      })
    }
  },
})

网页端的实现

网页端就要复杂很多……掉过很多坑真的很难搞……(这里感谢 b站 up主 “前端石头”,其中摄像头拍照和录音的 js 代码参考了他的代码)

而且这里有 2 个关键的问题:

  1. 关于视频拍照:如果我把展示视频流的那个控件隐藏掉,那拍出来的照片就是黑的。在微信小程序里就不会有这个问题。原因是,它拍照的原理是,通过 canvas 控件在 video 控件上截图,如果你隐藏掉了,自然没有图可截,就是黑的。我找了很多资料,貌似没有别的解决方法,所以我只能把视频放很小,放角落里……
  2. 关于录音:js 调用硬件就是很有限制。因为我后面想接百度语音识别的 api,该 api 仅支持采样率 16000 或者 8000 的音频,但是 js 默认录音采样率 48000。我找到一些人说,在 constrains 里面传参,但是,不仅没用,而且传了之后会导致音频损坏……然后问了 chatgpt,它说 js 很难变,只能你先录好,然后通过代码改采样率。我试了直接传音频到服务器,然后 python 代码改采样率。但是 python 代码改采样率用的那个包,在 Windows 下运行会报错,还得下一个软件怎么怎么设置……就是很麻烦。所以,暂时没有找到优雅的解决方案。

html

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Title</title>
    <link rel="stylesheet" href="{{ url_for('static', filename='css/full_button.css') }}" type="text/css">
</head>
<body>
    <div style="display: flex">
        <div>
            <video id="videoElement" autoplay="autoplay" muted="muted" style="width: 40px"></video>
            <img id="photo" alt="你的照片" src="" style="display: none">
        </div>
        <div id="answer" class="answer-text">答案等待中...</div>
    </div>

    <div class="button-grid">
        <button id="snapButton">拍摄照片</button>
        <button id="recorderButton">录音</button>
        <button id="captionButton">描述图片</button>
        <button id="vqaButton">回答问题</button>
    </div>

{#    <input type="text" id="textQuestion" placeholder="请输入问题...">#}
    <script>
        var imageBlob = null;  // 拍摄的图片
        var speechBlob = null;  // 提出的问题

        // 生成随机文件名
        function randomFilename() {
            let now = new Date().getTime();
            let str = `xxxxxxxx-xxxx-${now}-yxxx`;
            return str.replace(/[xy]/g, function(c) {
                const r = Math.random() * 16 | 0;
                const v = c === 'x' ? r : (r & 0x3 | 0x8);
                return v.toString(16)
            })
        }
    </script>
    <script type="text/javascript" src="../static/js/user_camera.js"></script>
    <script type="text/javascript" src="../static/js/user_recorder.js"></script>
    <script>
        // 绑定 vqa 按钮
        document.getElementById('vqaButton').onclick = function () {
            if (imageBlob == null) {
                alert('请先拍摄照片,再点击“描述图片”按钮')
            } else {
                if (speechBlob == null) {
                    alert('您还没有提问,请先点击录音按钮录音提问')
                } else {
                    let filename = randomFilename();
                    const speechFormData = new FormData();
                    // 注意,这里是第一个点:这里放进去的第一个参数是 key,后端就要通过这个 key 拿到文件。第二个参数是文件的二进制数据,blob,别搞错了!我会在 recorder.js 的代码里给这个 speechBlob 赋值,总之它应该是一个 Blob 对象。第三个参数是文件名,这个看你自己的需求。
                    speechFormData.append('speech', speechBlob, filename+'.wav');
                    // 这里是第二个点,把这个路径换成你的位置。
                    // 而且我发现,localhost 和 127.0.0.1 居然是有区别的,
                    // 我搞不太懂这二者的区别,但是有时候我填 127.0.0.1 就会告诉我跨域传数据之类的,
                    // 总之很难……如果你部署到服务器的话,应该是要改成服务器的地址的
                    fetch('http://localhost:8099/upload/speech', {
                        method: 'POST',
                        // 这里把 FormData 放到 body 传过去;如果你还要传别的数据,都放到这个 FormData 里就可以传过去
                        body: speechFormData
                    })
                    .then(response => {
                        console.log('response:', response);
                        if (response.status === 200) {
                            console.log('成功上传音频', response);
                        }
                    })
                    .then(data => console.log('data:', data))
                    .catch(error => console.error(error));

                    const imgFormData = new FormData();
                    imgFormData.append('img', imageBlob, filename+'.jpg');
                    fetch('http://localhost:8099/upload', {
                        method: 'POST',
                        body: imgFormData
                        })
                        .then(response => {
                            console.log('response:', response);
                            if (response.status === 200) {
                                console.log('上传完成');
                            }
                            })
                        .then(data => console.log('data:', data))
                        .catch(error => console.error(error));
                }
            }
        };
    </script>
</body>
</html>

javascript 的部分

有两个文件,放在 static 文件夹的 js 文件夹下:

user_camera.js

class SnapVideo {
    // 摄像头流媒体
    stream;
    // 页面dom
    videoElement = document.getElementById('videoElement');
    snapButton = document.getElementById('snapButton');
    photoElement = document.getElementById('photo');
    constructor() {
        const constraints = {
            audio: true,
            video: {
                facingMode: "environment",  // "user" 代表前置摄像头
                width: 448,  // 视频宽度
                height: 448,
                frameRate: 60,  // 每秒 60 帧
            }
        };
        // 绑定方法
        this.snapButton.onclick = () => this.takeSnapshot();
        // this.videoElement.width = constraints.video.width;
        // this.videoElement.height = constraints.video.height;
        // 获取摄像头流媒体
        this.getUserMedia(constraints, (stream) => {
            // 摄像头流媒体成功回调
            this.stream = stream;
            this.videoElement.srcObject = stream;
        }, (e) => {
            // 摄像头流媒体失败回调
            if (e.message === 'Permission denied') {
                alert('您已经禁止使用摄像头');
            }
            console.log('navigator.getUserMedia error: ', e);
        })
    }
    getUserMedia(constrains, success, error) {
        if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
            //最新的标准API
            navigator.mediaDevices.getUserMedia(constrains).then(success).catch(error);
        } else if (navigator.webkitGetUserMedia) {
            //webkit核心浏览器
            navigator.webkitGetUserMedia(constraints, success, error)
        } else if (navigator.getUserMedia) {
            //旧版API
            navigator.getUserMedia(constraints, success, error);
        }
    }
    // 拍照
    takeSnapshot() {
        console.log('点击了拍摄按钮');
        // 利用 canvas 截取视频图片
        const canvas = document.createElement('canvas');
        const context = canvas.getContext('2d');
        canvas.width = this.videoElement.videoWidth;
        canvas.height = this.videoElement.videoHeight;
        context.drawImage(this.videoElement, 0, 0, canvas.width, canvas.height);
        this.photoElement.src = canvas.toDataURL('image/png');
        canvas.toBlob(function (blob) {
        	// 把 blob 赋给 imageBlob;注意这个 imageBlob 是在 html 文件中声明的!!
            imageBlob = new Blob([blob], {type: "image/png"});
        }, "image/png", 1);
        // this.photoElement.style.display = 'block';
    }
}

new SnapVideo();

另一个文件是 user_recorder.js

// 录音
const recordBtn = document.getElementById('recorderButton');

if (navigator.mediaDevices.getUserMedia) {
    let chunks = [];
    // 注意,这里这个 audio 传参只能传 true,传别的,录到的音频就是损坏的!!
    const constraints = { audio: true };
    navigator.mediaDevices.getUserMedia(constraints).then(
        stream => {
            const mediaRecorder = new MediaRecorder(stream);
            recordBtn.onclick = () => {
                console.log("点击");
                if (mediaRecorder.state === "recording") {
                    mediaRecorder.stop();
                    recordBtn.textContent = "录音结束";
                } else {
                    mediaRecorder.start();
                    recordBtn.textContent = "录音中...";
                }
            };
            mediaRecorder.ondataavailable = e => {
                chunks.push(e.data);
            };
            mediaRecorder.onstop = e => {
            	// 一样的,把 blob 赋给 speechBlob,这个也是在 html 里面的 <script> 声明的
                speechBlob = new Blob(chunks, {type: "audio/wav"});
                chunks = [];
            }
        },
        () => { console.error("授权失败!"); }
    );
} else {
    console.error("浏览器不支持 getUserMedia");
}
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值