我想实现的效果是,我的服务器提供两个路由网址,网页A用于拍照、然后录音,把照片和录音传给服务器,服务器发射信号,通知另一个路由的网页B更新,把刚刚传来的照片和录音显示在网页上。
然后网页B用户根据这个照片和录音,回答一些问题,输入答案的文本,传给服务器。服务器拿到答案后,再发射信号,把这个结果显示在网页A上。
这就得用到双向通信(其实有点类似两个网页聊天的功能,而且支持发送语音、图片、文本三种消息)。这里用的是 socket.io 包。在本地写还是很好写的,但是,部署到服务器上之后,就出了很多 bug。很多坑,这里把我遇到的记下来,防止再次犯错。
这里只记录关键代码,也就是容易掉坑的代码。整个项目我之后会上传到 github 上。传好了补连接。
整体的逻辑
- 建立双向通道
- 网页A上传文件,得到服务器传回的提示信号(code: 200),然后
socket.emit("upload_completed")
,通知服务器数据已经上传了 - 服务器监听
upload_completed
信号,收到该信号后,服务器作为中转站,广播信号emit('data_updated', data, broadcast=True)
通知前端该更新数据了 - 网页 B 监听
data_updated
信号,修改自己的页面,展示图片和录音。等用户在该网页填好答案之后,点击发送按钮,网页 B 发生信号socket.emit("annotated_answer")
- 服务器监听
annotated_answer
信号,收到该信号后,作为中转站,广播信号emit('send_answer', answer, broadcast=True)
- 网页 A 监听信号
send_answer
,收到该信号后,把结果显示在网页上
特别拎出来的坑,特别注意
- 运行 flask 代码的时候调用 socketio 的 run 方法,不是用 app 的 run 方法,不然没法双向连接的;但是在服务器端部署的时候,用 uwsgi 跑上,它就是默认调用 app.run,很崩溃的啊这个;所以服务器端部署的时候,用 gunicorn (这个部署,真的,翻遍全网才找到,落泪了)
- socket 连接的地址,本地调试的时候填的是 localhost,但是传到服务器要改成服务器的地址,不该的话,连不上的!!
- 刚刚更新模型的时候又出毛病,爬上来更新。这次没有报任何错误,但是网页就是访问不到。检查了一个小时,发现是因为梯子忘记关了
- 部署后手机端打不开录音设备和摄像头,那是因为,媒体设备只能在 https 协议下,或者 http://localhost 下访问,所以要用这个功能,就必须去申请 ssl 证书。阿里云有免费的 20 张,好好把握。
flask 代码
我这里只贴最关键的代码,加上注释,直接把这个代码粘上去,是会报错的。
@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})
@socketio.on('upload_completed')
def handle_upload_completed(data):
# pip install flask-socketio eventlet
print(data)
try:
emit('data_updated', data, broadcast=True)
except Exception as e:
print(e)
emit('error', {'error': str(e)})
@socketio.on('upload_speech_completed')
def handle_upload_speech_completed(data):
# pip install flask-socketio eventlet
try:
emit('data_speech_updated', data, broadcast=True)
except Exception as e:
print(e)
emit('error', {'error': str(e)})
@socketio.on('annotated_answer')
def handle_annotated_answer(answer):
log(f'get answer from annotator: {answer}')
try:
emit('send_answer', answer, broadcast=True)
except Exception as e:
print(e)
if __name__ == '__main__':
# app.run(host='0.0.0.0', port=8099)
# 这个地方!!看清楚!看清楚!要调用 socketio 的 run 方法,不是用 app 的 run 方法,不然没法双向连接的
socketio.run(app, host='0.0.0.0', allow_unsafe_werkzeug=True, port=8099)
网页 A 的代码
注意这里只贴了一部分代码,关于文件怎么上传的,也就是引入的 camera.js 和 recorder.js 这俩文件的内容,在我这这篇文章里贴了: flask 后端 + 微信小程序和网页两种前端:调用硬件(相机和录音)和上传至服务器
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">
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.min.js"></script>
</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>
// 这里最最最关键的就是这个网址,如果你在本地跑,要填 localhost,不能填 127.0.0.1;如果是部署在服务器,要填成服务器的地址,不然肯定是连不上的。
const socket = io.connect('http://localhost:8099'); // 连接到Flask服务器
socket.on('send_answer', function (data) {
// 接收到服务器返回的答案,震动提示,把答案显示在页面上
console.log('接收到答案:', data);
document.getElementById('answer').textContent = data;
navigator.vibrate([200]); // 震动提示收到答案
})
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>
// 绑定 caption 按钮
document.getElementById('captionButton').onclick = function () {
if (imageBlob == null) {
alert('请先拍摄照片,再点击“描述图片”按钮')
} else {
const captionFormData = new FormData();
let imgFilename = randomFilename()+'.jpg';
captionFormData.append('img', imageBlob, imgFilename);
captionFormData.append('question', '请描述图片内容');
fetch('http://localhost:8099/upload', {
method: 'POST',
body: captionFormData
})
.then(response => {
console.log('response:', response);
if (response.status === 200) {
console.log('发射信号 upload_completed');
// 注意!!这里发射的信号,带的数据,得是URL.createObjectURL(imageBlob)不能是别的不能是别的不能是别的,重要的事情说3遍!!不然无法正确地显示在网页 B 上
socket.emit('upload_completed', {'image': URL.createObjectURL(imageBlob),
'question': '请描述图片内容'});
}
})
.then(data => console.log('data:', data))
.catch(error => console.error(error));
}
};
// 绑定 vqa 按钮
document.getElementById('vqaButton').onclick = function () {
if (imageBlob == null) {
alert('请先拍摄照片,再点击“描述图片”按钮')
} else {
if (speechBlob == null) {
alert('您还没有提问,请先点击录音按钮录音提问')
} else {
let filename = randomFilename();
// 先发语音再发图片,因为发了图片之后会提示听录音
const speechFormData = new FormData();
speechFormData.append('speech', speechBlob, filename+'.wav');
fetch('http://localhost:8099/upload/speech', {
method: 'POST',
body: speechFormData
})
.then(response => {
console.log('response:', response);
if (response.status === 200) {
console.log('成功上传音频', response);
socket.emit('upload_speech_completed',
{'speech': window.URL.createObjectURL(speechBlob)})
}
})
.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('发射信号 upload_completed');
socket.emit('upload_completed', {
'image': URL.createObjectURL(imageBlob),
'question': '请听录音'});
}
})
.then(data => console.log('data:', data))
.catch(error => console.error(error));
}
}
};
</script>
</body>
</html>
网页 B 的代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>human-annotation</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.1/socket.io.min.js"></script>
</head>
<body>
<img id="image" src="" alt="Your Image">
<audio id="audioPlayer" controls class="audio-player"></audio>
<div style="display: flex">提问:<div id="question"></div></div>
<input type="text" id="textInput" placeholder="请输入答案...">
<button id="submitButton">发送</button>
<script>
// 这里也是,大坑,大坑啊!!这个地址要填对,本地用 localhost,云端用云端服务器地址啊!
var socket = io.connect('http://localhost:8099'); // 连接到Flask服务器
socket.on('data_updated', function(data) {
// 当接收到来自服务器的数据时,更新页面内容
var img = document.getElementById('image');
img.src = data.image;
console.log('img.src');
// document.getElementById('image').innerHTML = '<img src="' + data.image + '" alt="Uploaded Image">';
document.getElementById('question').textContent = data.question;
});
socket.on('data_speech_updated', function (data) {
var audioPlayer = document.getElementById("audioPlayer");
audioPlayer.src = data.speech;
});
// 监听按钮点击事件
document.getElementById('submitButton').addEventListener('click', function() {
// 获取输入框中的文本
var message = document.getElementById('textInput').value;
// 验证消息是否为空
if (message.trim() !== '') {
// 通过Socket.IO发送消息给服务器
socket.emit('annotated_answer', message);
// 清空输入框
document.getElementById('textInput').value = '';
} else {
alert('Please enter a message.');
}
});
</script>
</body>
</html>
部署
用 gunicorn 部署
配置文件:
运行命令: