
前言:
前段时间给客户做了一款线下活动小程序,里面有个需求:用户在现场拍一张人像,系统自动把人抠出来,合成到活动背景上,生成一张带 AI 标识的打卡合照。说白了就是一个"AI 打卡合照"功能。
我调研了几套方案:
一、最终效果与整体流程
页面操作流程:
选择活动背景 → 拍照 / 从相册选人像 → 等待 AI 抠图 → 拖拽缩放旋转人像 → 保存打卡合照

技术层面:
小程序端拍照/选图
↓
智能压缩(超过 3.5MB 压到 1920px)
↓
上传图片到小程序云存储
↓
调用云函数 segment-portrait
↓
云函数下载图片 → 调腾讯云 SegmentPortraitPic → 拿到抠图结果 url
↓
下载抠图结果 → 上传回云存储 → 返回 fileID
↓
小程序端获取临时 url → Canvas 合成背景+人像
↓
嵌入 AI 生成水印 → 保存到相册
为什么要把 AI 接口放在云函数里?
二、腾讯云:开通人体分析并拿到密钥【开通子账户,并设置对应抠图权限】
// cloudfunctions/segment-portrait/index.js
const SECRET_ID = '你的 SecretId';
const SECRET_KEY = '你的 SecretKey';
const REGION = 'ap-shanghai';
const SERVICE = 'bda';
const HOST = 'bda.tencentcloudapi.com';
const ACTION = 'SegmentPortraitPic';
const VERSION = '2020-03-24';
云函数 config.json 里记得把内存提到 512MB,大图片很容易把 128MB/256MB 打爆,导致 OOM。
三、小程序端:拍照、压缩、上传云存储
用户拍照或者选图之后,别急着传,先判断大小。腾讯云接口图片上限 5MB,前端走 base64 到云函数还会膨胀约 1.33 倍,所以我把安全阈值定在 3.5MB。
processAndCompose: function (photoPath) {
var that = this;
this.setData({ processing: true, processingText: '正在准备…' });
var fs = wx.getFileSystemManager();
fs.stat({
path: photoPath,
success: function (stat) {
var size = stat && stat.size ? stat.size : 0;
if (size > 0 && size <= 3.5 * 1024 * 1024) {
that._uploadAndSegment(photoPath);
} else {
wx.compressImage({
src: photoPath,
quality: 92,
compressedWidth: 1920,
success: function (res) {
that._uploadAndSegment(res.tempFilePath);
},
fail: function () {
that._uploadAndSegment(photoPath);
}
});
}
}
});
}
压缩完上传到云存储:
_uploadAndSegment: function (photoPath) {
var that = this;
var cloudPath = 'portrait-origin/' + Date.now() + '_origin.jpg';
wx.cloud.uploadFile({ cloudPath: cloudPath, filePath: photoPath })
.then(function (uploadRes) {
return wx.cloud.callFunction({
name: 'segment-portrait',
data: { personFileID: uploadRes.fileID }
});
})
.then(function (fnRes) {
var result = fnRes.result || {};
if (!result.success) {
that.showError(result.message || 'AI 抠图失败');
return;
}
if (!result.hasPerson) {
that.showError('未检测到清晰人像,请重新拍照');
return;
}
return wx.cloud.getTempFileURL({ fileList: [result.resultFileID] });
})
.then(function (urlRes) {
if (urlRes && urlRes.fileList && urlRes.fileList[0]) {
that.renderComposeCanvas(urlRes.fileList[0].tempFileURL);
}
})
.catch(function (err) {
that.showError(err.message || '网络异常');
});
}
注意:用户取消相册选择时 errMsg 里会带 cancel,这种不要弹错误框,静默返回即可,否则体验很差。
四、云函数核心:TC3-HMAC-SHA256 签名 + 人像分割
腾讯云 API 3.0 要求每个请求都带签名。如果你在云函数里不想额外引入 SDK,可以直接用 Node.js 原生 crypto 实现 TC3-HMAC-SHA256。
function sha256(data, secret) {
return secret
? crypto.createHmac('sha256', secret).update(data).digest()
: crypto.createHash('sha256').update(data).digest();
}
function buildAuthHeaders(params) {
const timestamp = Math.floor(Date.now() / 1000);
const date = new Date(timestamp * 1000).toISOString().substring(0, 10);
const payload = JSON.stringify(params);
const canonicalHeaders = 'content-type:application/json; charset=utf-8\\nhost:' + HOST + '\\n';
const signedHeaders = 'content-type;host';
const hashedRequestPayload = sha256(payload).toString('hex');
const canonicalRequest = [
'POST', '/', '', canonicalHeaders, signedHeaders, hashedRequestPayload
].join('\\n');
const credentialScope = date + '/' + SERVICE + '/tc3_request';
const stringToSign = [
'TC3-HMAC-SHA256', timestamp, credentialScope,
sha256(canonicalRequest).toString('hex')
].join('\\n');
const kDate = sha256(date, 'TC3' + SECRET_KEY);
const kService = sha256(SERVICE, kDate);
const kSigning = sha256('tc3_request', kService);
const signature = sha256(stringToSign, kSigning).toString('hex');
const authorization = 'TC3-HMAC-SHA256 ' +
'Credential=' + SECRET_ID + '/' + credentialScope + ', ' +
'SignedHeaders=' + signedHeaders + ', ' +
'Signature=' + signature;
return {
'Authorization': authorization,
'Content-Type': 'application/json; charset=utf-8',
'Host': HOST,
'X-TC-Action': ACTION,
'X-TC-Version': VERSION,
'X-TC-Timestamp': timestamp,
'X-TC-Region': REGION,
};
}
调接口时,一定用 RspImgType: 'url',不要 base64。base64 在大图场景下很容易触发云函数返回体大小限制,而且小程序端接收也慢。
var bdaResult = await callBdaAPI({
Image: imageBase64,
RspImgType: 'url', // 输出用 URL,避免 base64 超限
});
拿到 ResultImageUrl 之后,云函数再下载下来,上传回小程序云存储,最后返回 fileID 给前端。为什么多这一步?因为腾讯云返回的 url 是临时 url,有效期很短,前端直接用它画 canvas 可能画到一半就失效了,落到云存储里最稳~。
五、云函数重试:限流、超时、偶发失败都能扛(这一步能解决代码层面的抠图成功率达到80%以上,当然截至目前还未发现抠图失败情况加上以后,之前因为网络或文件体积或云函数超时等问题,抠图成功率全靠手气)
人像分割是 AI 接口,偶发限流或内部错误很正常。我在云函数里加了 3 次重试:
var retryableErrors = [
'RequestLimitExceeded',
'InternalError',
'FailedOperation.RequestTimeout',
'FailedOperation.ServerError',
'FailedOperation.RpcFail',
'FailedOperation.SegmentFailed'
];
if (retryableErrors.indexOf(errCode) !== –1 && retryCount < maxRetries) {
var delay = (retryCount + 1) * 1000;
setTimeout(function () {
resolve(callBdaAPI(params, retryCount + 1));
}, delay);
return;
}
建议 maxRetries = 3,每次延迟递增,既不会太激进,也不会让用户等太久。
六、Canvas 合成:背景保持原尺寸,人像可拖拽缩放旋转
画布尺寸我按背景图原始尺寸来初始化,这样保存出来的图片不会变形,也不会被强制裁切。
initCanvasSize: function (bgW, bgH) {
var systemInfo = wx.getSystemInfoSync();
var sw = systemInfo.windowWidth;
var sh = systemInfo.windowHeight – 100;
var maxDisplayH = sh * 0.78;
var scale = Math.min(sw / bgW, maxDisplayH / bgH);
this.setData({
canvasStyleWidth: bgW * scale,
canvasStyleHeight: bgH * scale,
canvasWidth: bgW,
canvasHeight: bgH
});
this._displayScale = scale;
}
canvas 节点用 type="2d",绘制时先画背景,再画人像。人像支持单指拖拽、双指缩放旋转:
onTouchStart: function (e) {
var t = e.touches;
if (t.length === 1) {
this._isDragging = true;
this._touchStartX = this._toCanvasX(t[0].x);
this._touchStartY = this._toCanvasY(t[0].y);
} else if (t.length === 2) {
this._initialDistance = this._dist(t[0], t[1]);
this._initialScale = this._personScale;
this._initialRotation = this._personRotation;
this._initialAngle = Math.atan2(t[1].y – t[0].y, t[1].x – t[0].x);
}
}
onTouchMove: function (e) {
var t = e.touches;
if (t.length === 1 && this._isDragging) {
var canvasX = this._toCanvasX(t[0].x);
var canvasY = this._toCanvasY(t[0].y);
this._personX += canvasX – this._touchStartX;
this._personY += canvasY – this._touchStartY;
this._touchStartX = canvasX;
this._touchStartY = canvasY;
} else if (t.length === 2) {
var d = this._dist(t[0], t[1]);
this._personScale = Math.max(0.1, Math.min(3,
this._initialScale * (d / this._initialDistance)));
var a = Math.atan2(t[1].y – t[0].y, t[1].x – t[0].x);
this._personRotation = this._initialRotation + (a – this._initialAngle);
}
this._throttle();
}
绘制时以人像中心点为基准做变换:
var cx = this._personX + (img.width * this._personScale) / 2;
var cy = this._personY + (img.height * this._personScale) / 2;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(this._personRotation);
ctx.scale(this._personScale, this._personScale);
ctx.drawImage(img, –img.width / 2, –img.height / 2, img.width, img.height);
ctx.restore();
七、AI 生成标识:水印必须画进图片里
这是审核重点。只是页面右上角飘一个"AI 生成"标签,微信审核会直接打回,因为保存到相册的图片上没有标识。
正确做法:在 canvas 绘制阶段就把水印写进去,同时页面保留一个视觉提示作为二次提醒。
// ===== AI 生成显著标识(画入图片,审核要求) =====
ctx.save();
var fontSize = Math.max(24, Math.floor(w * 0.03));
ctx.font = 'bold ' + fontSize + 'px sans-serif';
ctx.fillStyle = 'rgba(255,255,255,0.8)';
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
ctx.shadowColor = 'rgba(0,0,0,0.45)';
ctx.shadowBlur = 8;
ctx.shadowOffsetX = 1;
ctx.shadowOffsetY = 1;
ctx.fillText('AI 生成 · 人工智能合成', w – 28, h – 28);
ctx.restore();
字号取画布宽度的 3%,同时限制最小 24px,保证不同尺寸背景图都能看清。
八、保存图片与错误处理
合成完成后,用 canvasToTempFilePath 导出图片,再调用 saveImageToPhotosAlbum 保存到相册。
错误弹框我统一封装了一个组件,要求:
- 必须能显示原始错误日志(最多 3 行,超出省略)。
- 必须显示原因分析。
- 必须有"我知道了"按钮,且不能点蒙层关闭。
- 右上角保留关闭按钮。
showError: function (log, key) {
var info = errorHelper.getErrorInfo(key || log);
this.setData({
processing: false,
errorDialog: {
show: true,
title: info.title,
log: log || '无日志',
cause: info.cause
}
});
}
保存失败时也不要用 toast 一闪而过,必须用这个自定义错误框把原因讲清楚。
九、踩坑总结
十、结语
整个方案走下来,核心就是三段:前端负责选图压缩和交互、云函数负责安全签名和 AI 调用、Canvas 负责合成输出。腾讯云 SegmentPortraitPic 的抠图效果对于打卡合照这种场景完全够用,头发丝级别的精细度可能比不上专业修图软件,但活动现场快速生成一张可用合照绰绰有余。
如果你也在做类似功能,建议先把云函数签名调通,再拼前端 Canvas,最后处理水印和审核细节。祝大家一次过审。
标签:
#微信小程序 #腾讯云 #人体分析 #AI抠图 #小程序云开发

![[特殊字符]DeepSeek‑Harness(DSH)小白保姆教程-171主机测评](https://www.171host.com/wp-content/uploads/2026/08/20260816085112-6a817a009aabf-220x150.png)
