選擇 flask 作為后端,因為后續(xù)還需要深度學(xué)習(xí)模型,python 語言最適配;而 flask 框架輕、學(xué)習(xí)成本低,所以選 flask 作為后端框架。
微信小程序封裝了調(diào)用手機硬件的 api,通過它來調(diào)用手機的攝像頭、錄音機,非常方便。
網(wǎng)頁端使用 JavaScript 調(diào)用則困難一些,走了很多彎路,在這里記錄下來。
前提:已經(jīng)配置好 python 環(huán)境、安裝了 flask;
flask 端
flask 的任務(wù)是收取前端傳來的文件,保存在本地。
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 # 防止中文亂碼
# 設(shè)置上傳文件夾
app.config['UPLOAD_FOLDER'] = r'D:\A_data_trans\test(改成你的位置)'
@app.route('/vqa', methods=['POST'])
def app_vqa():
# 保存圖片
img_file = request.files['img'] # 這里規(guī)定了前端傳圖片過來的時候,用的關(guān)鍵字是 '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 中的
# 預(yù)測答案
try:
answer = vqa(image_path, question)
return jsonify(answer)
except Exception as e:
return jsonify({'error': str(e)}), 500
# 接收文件的代碼,其實和上面長得一樣,略微有一 miu miu 區(qū)別
@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')) # 用于展示在網(wǎng)頁上
log(f"save image: {image_path}")
except Exception as e:
return jsonify({'error': str(e)}), 500
try:
# 傳過來的就是文本
question = request.form['question']
except:
question = "請描述圖片內(nèi)容"
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('百度識別結(jié)果:', question)
except Exception as e:
return jsonify({'error': str(e)}), 500
return jsonify({"speech": speech_file.filename})
微信小程序
微信小程序端的任務(wù)是,調(diào)用手機相機,把相機畫面展示給用戶,加一個按鈕,點擊按鈕拍照;另外一個按鈕,點擊可以把拍到的照片上傳。
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">
<!-- 發(fā)送預(yù)測 caption 請求 -->
<button class="btn-normal btn-large" hover-class="btn-pressed" bind:tap="predCaption">描述圖片</button>
<!-- 發(fā)送預(yù)測 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) => {}
的寫法,不然在里面調(diào)用 this
是識別不到的。
Page({
data: {
serverUrl: 'http://改成你的', // 服務(wù)器地址
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 地址,把這個地址傳給服務(wù)器,就可以把該文件傳給服務(wù)器
});
}
});
},
// 控制長按錄音的代碼放在這里(按鈕綁定在 wxml 就寫好了)
startRecord() {
const recorderManager = wx.getRecorderManager();
this.setData({ recorderManager });
// 停止錄音的回調(diào)方法;在這里我加了調(diào)用百度語音 api 的東西,這部分會另外寫文詳說,這里只放出來一部分。所以這里沒有把錄音文件上傳,而是直接把語音識別的結(jié)果上傳文件夾
recorderManager.onStop((res) => {
console.log('recorder stop', res);
this.setData({ speechData: res.tempFilePath });
var baiduAccessToken = wx.getStorageSync('baidu_yuyin_access_token');
// 讀取文件并轉(zhuǎn)為 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返回的結(jié)果")
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]});
}
}
})
}
})
});
// 這里才是控制錄音的參數(shù);微信小程序端可以設(shè)置這些錄音參數(shù),因為后面要調(diào)用百度語音識別 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', // 文件對應(yīng) 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('上傳失敗'); }
})
}
},
})
網(wǎng)頁端的實現(xiàn)
網(wǎng)頁端就要復(fù)雜很多……掉過很多坑真的很難搞……(這里感謝 b站 up主 “前端石頭”,其中攝像頭拍照和錄音的 js 代碼參考了他的代碼)
而且這里有 2 個關(guān)鍵的問題:
- 關(guān)于視頻拍照:如果我把展示視頻流的那個控件隱藏掉,那拍出來的照片就是黑的。在微信小程序里就不會有這個問題。原因是,它拍照的原理是,通過 canvas 控件在 video 控件上截圖,如果你隱藏掉了,自然沒有圖可截,就是黑的。我找了很多資料,貌似沒有別的解決方法,所以我只能把視頻放很小,放角落里……
- 關(guān)于錄音:js 調(diào)用硬件就是很有限制。因為我后面想接百度語音識別的 api,該 api 僅支持采樣率 16000 或者 8000 的音頻,但是 js 默認(rèn)錄音采樣率 48000。我找到一些人說,在
constrains
里面?zhèn)鲄ⅲ?,不僅沒用,而且傳了之后會導(dǎo)致音頻損壞……然后問了 chatgpt,它說 js 很難變,只能你先錄好,然后通過代碼改采樣率。我試了直接傳音頻到服務(wù)器,然后 python 代碼改采樣率。但是 python 代碼改采樣率用的那個包,在 Windows 下運行會報錯,還得下一個軟件怎么怎么設(shè)置……就是很麻煩。所以,暫時沒有找到優(yōu)雅的解決方案。
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();
// 注意,這里是第一個點:這里放進去的第一個參數(shù)是 key,后端就要通過這個 key 拿到文件。第二個參數(shù)是文件的二進制數(shù)據(jù),blob,別搞錯了!我會在 recorder.js 的代碼里給這個 speechBlob 賦值,總之它應(yīng)該是一個 Blob 對象。第三個參數(shù)是文件名,這個看你自己的需求。
speechFormData.append('speech', speechBlob, filename+'.wav');
// 這里是第二個點,把這個路徑換成你的位置。
// 而且我發(fā)現(xiàn),localhost 和 127.0.0.1 居然是有區(qū)別的,
// 我搞不太懂這二者的區(qū)別,但是有時候我填 127.0.0.1 就會告訴我跨域傳數(shù)據(jù)之類的,
// 總之很難……如果你部署到服務(wù)器的話,應(yīng)該是要改成服務(wù)器的地址的
fetch('http://localhost:8099/upload/speech', {
method: 'POST',
// 這里把 FormData 放到 body 傳過去;如果你還要傳別的數(shù)據(jù),都放到這個 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
文章來源:http://www.zghlxwxcb.cn/news/detail-851941.html
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) => {
// 攝像頭流媒體成功回調(diào)
this.stream = stream;
this.videoElement.srcObject = stream;
}, (e) => {
// 攝像頭流媒體失敗回調(diào)
if (e.message === 'Permission denied') {
alert('您已經(jīng)禁止使用攝像頭');
}
console.log('navigator.getUserMedia error: ', e);
})
}
getUserMedia(constrains, success, error) {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
//最新的標(biāo)準(zhǔn)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
文章來源地址http://www.zghlxwxcb.cn/news/detail-851941.html
// 錄音
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 = "錄音結(jié)束";
} 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("授權(quán)失?。?); }
);
} else {
console.error("瀏覽器不支持 getUserMedia");
}
到了這里,關(guān)于flask 后端 + 微信小程序和網(wǎng)頁兩種前端:調(diào)用硬件(相機和錄音)和上傳至服務(wù)器的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!