口袋拾荒 - 垃圾分类助手

基于 Taro 开发的垃圾分类辅助小程序,支持拍照识别、关键词搜索、字典检索等功能。

<封面摄于浙江·杭州的西溪湿地,与小潘同学秋游。>

GitHub: garbage-classification

口袋拾荒

技术栈

  • 小程序: Taro, GraphQL (Apollo), TypeScript, React (Hooks), Canvas
  • 后台: AntDesign, TypeScript, React
  • 服务端: Nest.js, Mongoose, GraphQL (Apollo), TypeScript, 百度AI
  • 爬虫: cheerio, superagent, Koa, Mongoose
Taro 图片(URL) 转 base64

百度AI 接口接受 base64 形式的图片,并且识别主体内容和轮廓。所以我们需要通过相机拍照,以 base64 的形式上传到服务端。然而 Taro.createCameraContext 返回的是图片在本地缓存地址,而不是图片文件,无法转换为 base64Taro 中也没相应的文档。后来发现,原来 Taro 是小程序的子集,在 Taro 中也能使用 wx API

// func.ts

export function url2base64(url) {
return new Promise((resolve, reject) => {
wx.getFileSystemManager().readFile({
filePath: url,
encoding: 'base64',
success: res => {
resolve(res.data)
},
fail: (err) => {
reject(err)
}
})
})
}
// page.ts

import {url2base64} from '~/func.js'

onCamera = () => {
const ctx = Taro.createCameraContext()
ctx.takePhoto({
quality: 'high',
success: async res => {
const base64 = await url2base64(res.tempImagePath)
const { width, height } = await Taro.getImageInfo({ src: res.tempImagePath })
this.handleCanvas(base64, res.tempImagePath, { width, height })
}
})
}
Canvas 绘制物品轮廓(自适应设备)

细说移动端 经典的REM布局 与 新秀VW布局

将图片转为 base64 后就可以请求 百度AI 了,成功识别出了物品的名称(text),轮廓(四个坐标点),现在需要通过 Canvas 将它们绘制在画布中。

遇到一个问题:图片在微信小程序IDE中调试没问题,但是一旦传到真机调试,比例就会失调,主要是因为文字和轮廓受设备分辨率影响进行了偏移。

之前看过一篇文章,有个概念:设备像素比 = 物理像素 / 设备独立像素。这里的偏移就是 dpr 导致的,所以我们只需要计算出 dpr,再将它缩放回来即可。(机智如我)

通过以下代码可以获取页面的实际尺寸:

const window = Taro.getSystemInfo()
const w = window.windowWidth
const h = window.windowHeight

再将 页面尺寸照片尺寸 做个除法计算即可:

handleCanvas = (base64, src, photo) => {
//获取设备实际尺寸
const w = res.windowWidth
const h = res.windowHeight - 90
//计算缩放比
const scale = {
w: w / photo.width,
h: h / photo.height
}
const ctx = Taro.createCanvasContext('canvas', this.$scope)
Taro.getSystemInfo({
success: async res => {
// 渲染图片
ctx.drawImage(src, 0, 0, w, h)
// 主体识别,获得轮廓
const subject: any = await baiduSubjectDetection(this.state.baiduToken, {
image: base64
})
const { height, width, top, left } = subject.result
// 内容识别,获得物品名称
const advance: any = await baiduAdvancedGeneral(this.state.baiduToken, {
image: base64
})
const { keyword } = advance.result[0]
//渲染轮廓和尺寸
this.setState({ keyword: keyword })
ctx.setStrokeStyle("#00ff00")
ctx.setLineWidth(2)
ctx.rect(left, top, width, height)
//适配屏幕尺寸进行缩放
ctx.scale(scale.w, scale.h)
ctx.setFillStyle("#00ff00")
ctx.setFontSize(18 / scale.w)
ctx.fillText(keyword, left, (top + 30))
ctx.stroke()
ctx.draw()
}
})
}
Canvas 绘制海报并保存到相册

推广页面的二维码海报也是用 Canvas 绘制的,实现了长按图片保存到相册功能,代码如下:

//获取画布尺寸
const window = Taro.getSystemInfoSync()
const w = window.windowWidth * 0.8
const h = window.windowWidth * 0.8 * 9 / 5

//保存海报到相册
const handleSaveImage = async () => {
//将 canvas 保存到缓存中
let res = await Taro.canvasToTempFilePath({
x: 0,
y: 0,
width: w,
height: h,
canvasId: 'canvas',
fileType: 'png'
})
//将缓存中的图片保存到相册中
let saveRes = await Taro.saveImageToPhotosAlbum({
filePath: res.tempFilePath
})
if (saveRes.errMsg === 'saveImageToPhotosAlbum:ok') {
Taro.showToast({
title: '图片已保存到相册'
})
} else {
Taro.showToast({
title: '保存失败'
})
}
}

const renderCanvas = (text = '快来扫一扫吧') => {
const ctx = Taro.createCanvasContext('canvas', null)
//背景铺满
ctx.drawImage(background, 0, 0, w, h)
ctx.setFillStyle("#ffffff")
//文字居中
ctx.setFontSize(22)
ctx.setTextAlign('center')
ctx.fillText('口袋拾荒', w / 2, h - 200)
ctx.setFontSize(12)
ctx.fillText(`- ${text} -`, w / 2, h - 180)
ctx.draw()
}

by the way,文字居中官方文档中是用 setTextAlign('center') 实现的,但是实际却没有效果。因为它是以整个画布的宽度的一半的中心轴为基准线,我们还需要将文字的横轴移到画布中心轴:ctx.fillText(text, w / 2, h)

数据爬虫

爬虫功能很简单,用 superagent 库请求网站获取 html 文档,再用 cheerio 库解析标签清洗数据,最后用 mongoose 存到数据库中。

//爬取数据
const superagent = require('superagent')
const cheerio = require('cheerio')

class Eight {
constructor() {
this.url = {
other: 'https://www.8684.cn/ljfl_glj',
food: 'https://www.8684.cn/ljfl_slj',
harmful: 'https://www.8684.cn/ljfl_yhlj',
recyclable: 'https://www.8684.cn/ljfl_khslj'
}
}

parse = (body) => {
const $ = cheerio.load(body.text)
let arr = []
$('.list-col4 li a').each((index, ele) => {
arr.push($(ele).text())
})
return arr
}

run = async () => {
const { url, parse } = this
let data = []
for (const i in url) {
const res = await superagent.get(url[i])
data.push({
key: i,
value: parse(res)
})
}
return data
}
}

exports.Eight = Eight
//存到数据库

const Koa = require('koa')
const app = new Koa()
const mongoose = require('mongoose')
const { Eight } = require('./source')

const config = {
hostname: 'localhost',
port: 3000,
}

app.use(async ctx => {
ctx.body = 'Hello World'
})

app.listen(config.port, config.hostname)

console.info('Server is running at http://%s:%s . Press Ctrl+C to stop.', config.hostname, config.port)

mongoose.connect('mongodb://localhost/garbage')

const Garbage = mongoose.model('Garbage', { name: String, categoryId: String });

const eight = new Eight()
eight.run().then(res => {
const data = res[0].value
for (const i of data) {
Garbage.create({name:i, categoryId: '5e427fe8558c2a31cd450fbc'})
}
})
Taro 封装 Apollo 请求库

简单封装一下 GraphQL 请求库,方便调用。

// /api/graphql.ts

import Taro from '@tarojs/taro'
import ApolloClient from 'apollo-boost'

const uri = 'https://xxx.com/graphql'

const fetch = (url, { body: data, ...fetchOptions }) => {
return Taro.request({ url, data, ...fetchOptions, dataType: 'txt', responseType: 'text' })
.then((res) => {
res.text = () => Promise.resolve(res.data)
return res
}).catch(error => {
console.error(error)
},
);
}

export default new ApolloClient({ uri, fetch })
// /api/gql.ts

import { gql } from 'apollo-boost'

export const category = gql`
query {
category {
name
id
image
}
}
`
// page.ts

import { category } from '~/api/gql'

const res = await graphql.query({ query: category })

真机调试内网穿透

详见该文章 -> 小程序真机调试问题

查看评论