背景
古话讲“读万卷书,不如行万里路”,笔者本人喜欢旅行,每次在和朋友旅行的过程中,都会拍摄一些的照片,用以记录当时的景色或者心情。从照片的产生到对照片进行处理的过程中,有几个重要的问题需要关注:
- 在旅游结束时,往往会与朋友交换旅行中的照片,目前的做法是通过微信或隔空投送等软件相互发送并保存;
- 在行程结束后的一段时间,如需寻找某张照片,需要明确一个拍摄时间范围,在进行图片的筛选,在照片数量比较多的情况下会非常浪费时间;
- 日积月累手机中存储了大量照片,这些照片占据了大量的手机空间,并且在进行手机设备更换时,这些照片的传输会浪费大量的时间;
所以作为一名乐于旅行的人,笔者本人非常希望有一款相册应用不仅可以存储旅程中的照片,还可以拥有丰富的权限管理,便于旅途中与朋友共同维护一个相册,另外还希望通过人工智能技术,可以帮助用户快速寻找到目标图片;同时作为一名开发者,笔者还希望所开发出的应用可以在用户使用频繁时,性能有所保证,在使用波谷时,可以尽可能的降低成本,并且所开发出的应用,具备足够的自运维能力,即无需笔者投入过多精力对应用的服务器等资源进行维护。
需求明确
通过对痛点的分析与需求细化,明确项目的核心需求点,主要包括小程序功能以及高可用、自运维的后端技术架构。
小程序端功能
-
具有基本相册的功能,主要包括:
- 相册相关:相册的创建、修改、删除,查看等;
- 照片相关:照片的上传,删除;
-
具有丰富的权限管理能力,即:
- 针对相册:可以创建类型丰富的相册类型,例如:
- 私有相册:个人私有相册,只有所有者查看和管理;
- 共享相册:可以分享的相册,所有者具有管理权,其他人具有查看权;
- 共建相册:可以共建的相册,所有者和指定共建者可以共同查看和管理相册;
- 针对图片:可以对图片进行多种模式的分享,包括不限于直接分享、指定用户分享、指定次数分享、闪照模式分享(即被分享者可以查看若干秒照片之后,被查看者端的照片会自动销毁);
- 针对用户:针对陌生人,好友以及黑名单用户设置不同的权限,例如陌生人可以在获得被分享的相册和图片时进行查看,好友可以看到已分享的相册和照片,黑名单用户不可查看用户的相册和照片;
- 针对相册:可以创建类型丰富的相册类型,例如:
-
具有快速索引能力:通过用户手动标签与人工智能的加持,可以通过文本进行图片的检索。此处的人工智能加持主要包括:
-
图片描述:即 Image Caption能力,通过对图片的理解,可以描述出图片的具体内容,如图1所示:
图1 Image Caption 效果预览
-
图像识别:在当前项目中图像识别指的是识别图片上的文字以及目标检测两部分,如图2所示:
图2 OCR 识别与目标检测效果预览
-
文本相似度算法:即通过搜索的文字内容与数据库中图片的描述信息作计算,获取最为相似的若干图片作为检索结果。数据库中图片的描述信息包括:用户手动添加的描述信息以及标签信息、图片的元信息(经纬度对应的国家、省份、城市等以及拍照时间等)、通过 Image Caption 技术提取出的描述信息,通过 OCR 技术提取出的图片文字信息以及通过目标检测能力提取的图片事物信息等。
-
后端技术架构需求
- 需要平衡性能与成本,即在流量峰值时,可以保持顺畅链接,保持可用性与稳定性,在流量波谷,可以自动释放更多资源以保证成本最低;
- 项目上线之后,整体的后端技术架构要具有一定的自运维能力,无需开发者频繁的对服务器等底层资源进行维护操作;
技术选型
基于后端技术架构需求,首先明确 Serverless 架构所带来的技术红利以及所主张的“把更专业的事情交给更专业的,开发者只需要关注自身的业务逻辑即可”思想,与期望效果非常匹配,所以项目后端技术选型将会采用 Serverless 架构实现,以阿里云 Serverless 架构为例:
- 函数计算:作为 Serverless 计算平台,主要承载核心的业务逻辑,通过 RESTful API 对小程序暴露对应的接口;通过对象存储等触发器,进行异步任务进行处理,例如对上传的图片进行压缩、基于人工智能的处理等;
- 对象存储:用于存储用户上传的照片信息以及压缩后的照片信息等;
- CDN:用于对静态资源进行加速,例如照片文件等;
- 硬盘挂载:主要用于存储 SQLite 数据库一部分人工智能的模型文件或配置文件等;值得注意的是,如果项目本身可能存在较大的用户量,或较大的流量,在 Serverless 架构下,存储在 NAS 中的 SQLite 数据库可能无法非常好的胜任对应的工作,此时可以将数据库升级为 MySQL 数据库,例如阿里云 RDS 等。
基于人工智能的加持部分,将会采用三个开源项目/框架进行相对应的视线:
- OCR 识别:采用百度开源的 PaddleOCR 项目,PaddleOCR 项目拥有非常优秀的 OCR 识别能力,支持多种语言的同时,识别率也非常高;
- 目标检测:采用 ImageAI 项目,ImageAI 是一个 Python 库,用户可以使用简单的几行代码构建具有独立的深度学习和计算机视觉功能的应用程序和系统;
- 图像描述(Image Caption):将会采用开源项目 https://github.com/DeepRNN/image_captioning,通过该项目与项目提供的模型,可以快速构建 Image Caption 的能力,用于生产;
针对小程序部分,将会采用 Javascript 技术栈搭配 ColorUI 进行实现,ColorUI 基础效果图如图3所示:
图3 ColorUI 基础效果图
ColorUI 是一个 CSS 库,目前已经可以适配微信小程序,通过引入关键的 CSS 文件:
@import "colorui/main.wxss";
@import "colorui/icon.wxss";
@import "app.css"; /* 你的项目css */
即可在项目中快速使用精美的布局、组件以及部分动态效果。
项目设计
基础架构设计
根据需求明确结果以及技术选型结果,可以明确项目的基础架构如图4所示:
图4 项目基础架构设计简图
在服务端存在8个函数,分别对外提供:RESTful API、异步任务触发、数据状态更新、图片压缩、图片元信息提取、图片理解、图片 OCR 识别以及图片目标检测;辅助计算平台的 BaaS 产品包括对象存储、CDN以及NAS硬盘挂载以及SLS日志服务等。
同时,为了更符合传统的开发习惯,和提升开发效率,也为了在开发期间可以在本地有一个更加亲切的调试环境、方案,该项目将会采用一些传统Web框架来直接进行开发,最后通过工具推到线上的环境中,如图5所示:
图5 项目本地开发与线上服务切换简图
小程序 UI 设计
小程序 UI 草图整体设计如图6所示:
图6 小程序端交互设计草图
页面主要分为几个部分:
- 相册列表:主要用于展示当前用户可以查看的相册列表;将会包括用户自己创建的相册,以及共建的相册;相册内如果有照片,会默认选择第一个照片当做封面,否则会有随机的选择一个默认封面作为相册的封面;
- 图片列表:主要用于展示相册的照片内容,主要包括以下细节:
- 可以通过手势操作,调整图片列表中的图片尺寸,以实现在一行显示更多图片内容;
- 图片列表所显示的图片为缩略图,通过点击图片可以查看详情,查看详情时将会加载原图;
- 相册管理:可以在此处查看具有管理权限的相册列表,并可以对指定相册进行更新操作(例如修改相册名、修改相册描述等)、删除操作等,同时也可以新建相册;
- 图片上传:可以在此页面通过选择指定相册,并选择要上传的图片,进而实现图片的上传功能;
- 图片检索:可以通过输入文本,进行图片的检索;
数据库设计
根据业务需求进行数据库的相关设计,如图7所示:
图7 数据库设计图
其中不同表对应的具体内容如下:
表1 相册(Album
)详情表
字段 | 类型 | 描述 |
---|---|---|
id | int | 主键,相册id |
name | varchar | 相册名 |
create_time | date | 创建时间 |
record_time | date | 记录时间 |
place | varchar | 地点 |
acl | int | 权限(0私密,1共享,2共建) |
password | varchar | 密码 |
description | text | 描述 |
remark | text | 备注(可选) |
lifecycle_state | int | 生命状态(1正常, 0删除) |
photo_count | int | 图片数量(默认0) |
acl_state | int | 权限状态 |
表2 标签(Tags
)详情表
字段 | 类型 | 描述 |
---|---|---|
id | int | 主键,标签id |
name | varchar | 相册名 |
remark | text | 备注(可选) |
表3 相册标签关系(Album_Tags
)详情表
字段 | 类型 | 描述 |
---|---|---|
id | int | 主键,相册标签关系id |
album | 外键 | 相册 |
tag | 外键 | 标签 |
表4 用户(User
)详情表
字段 | 类型 | 描述 |
---|---|---|
id | int | 主键,用户id |
username | varchar | 用户名 |
token | varchar | 用户Token |
avatar | varchar | 头像地址 |
place | varchar | 地区 |
gender | int | 性别 |
register_time | date | 注册时间 |
state | int | 用户状态(1可用,2注销) |
remark | text | 备注(可选) |
表5 用户相册关系(User_Album
)详情表
字段 | 类型 | 描述 |
---|---|---|
id | int | 主键,用户相册关系id |
user | 外键 | 用户 |
album | 外键 | 相册 |
type | int | 关系类型 |
remark | text | 备注(可选) |
表6 图片(Photo
)详情表
字段 | 类型 | 描述 |
---|---|---|
id | int | 主键,图片id |
create_time | date | 创建时间 |
update_time | date | 升级时间 |
album | 外键 | 相册 |
file_path | varchar | 图片地址 |
original | varchar | 图片原图 |
thumbnail | varchar | 图片压缩图 |
user | 外键 | 用户 |
description | text | 描述 |
remark | text | 备注(可选) |
state | int | 图片状态(1可用, -1删除,-2永久删除) |
delete_time | date | 删除时间 |
views | int | 查看次数 |
表7 用户关系(User_Relationship
)详情表
字段 | 类型 | 描述 |
---|---|---|
id | int | 主键,用户关系id |
origin | 外键 | 用户关系源 |
target | 外键 | 用户关系目标 |
remark | text | 备注(可选) |
type | int | 用户关系状态(1好友,-1黑名单) |
开发实现
数据库相关
在开发之初,需要根据已经设计好的数据库表,进行数据库的创建以及表的创建:
-
创建相册表(
Album
):CREATE TABLE Album ( id INTEGER PRIMARY KEY autoincrement NOT NULL, name CHAR(255) NOT NULL, create_time CHAR(255) NOT NULL, record_time CHAR(255) NOT NULL, place CHAR(255), acl INT NOT NULL, password CHAR(255), description TEXT, remark TEXT, lifecycle_state INT, photo_count INT NOT NULL, acl_state INT, picture CHAR(255) )
-
创建照片表(
Photo
):CREATE TABLE Photo ( id INTEGER PRIMARY KEY autoincrement NOT NULL, create_time TEXT NOT NULL, update_time TEXT NOT NULL, album CHAR(255) NOT NULL, file_token CHAR(255) NOT NULL, user INT NOT NULL, description CHAR(255) NOT NULL, remark TEXT, state INT NOT NULL, delete_time TEXT, place TEXT, name CHAR(255), views INT NOT NULL, delete_user CHAR(255), "user_description" TEXT )
-
创建标签表(
Tags
):CREATE TABLE Tags ( id INTEGER PRIMARY KEY autoincrement NOT NULL, name CHAR(255) NOT NULL UNIQUE, remark TEXT )
-
创建用户表(
User
):CREATE TABLE User ( id INTEGER PRIMARY KEY autoincrement NOT NULL, username CHAR(255) NOT NULL, token CHAR(255) NOT NULL UNIQUE, avatar CHAR(255) NOT NULL, secret CHAR(255) NOT NULL UNIQUE, place CHAR(255), gender INT NOT NULL, register_time CHAR(255) NOT NULL, state INT NOT NULL, remark TEXT )
-
创建用户关系表(
UserRelationship
):CREATE TABLE UserRelationship ( id INTEGER PRIMARY KEY autoincrement NOT NULL, origin INT NOT NULL, target INT NOT NULL, type INT NOT NULL, relationship CHAR(255) NOT NULL UNIQUE, remark TEXT )
-
创建相册标签关系表(
AlbumTag
):CREATE TABLE AlbumTag ( id INTEGER PRIMARY KEY autoincrement NOT NULL, album INT NOT NULL, tag INT NOT NULL )
-
创建相册用户关系表(
AlbumUser
):CREATE TABLE AlbumUser ( id INTEGER PRIMARY KEY autoincrement NOT NULL, user INT NOT NULL, album INT NOT NULL, type INT NOT NULL, album_user CHAR(255) NOT NULL UNIQUE, remark TEXT )
后端代码
应用初始化
应用存在诸多的方法和功能,这些方法和功能会涉及到部分公共方法,为了提高代码的复用率,需要将部分公共模块进行抽象并进行声明;除此之外,有一些对象需要进行相对应的初始化才可以被后续方法使用。主要包括:
-
对象存储相关
# oss bucket对象 bucket = oss2.Bucket(oss2.Auth(AccessKeyId, AccessKeySecret), OSS_REGION_ENDPOINT[Region]['public'], Bucket) # 预签名操作 ossPublicUrl = OSS_REGION_ENDPOINT[Region]['public'] sourcePublicUrl = "http://%s.%s" % (Bucket, ossPublicUrl) downloadUrl = "https://download.aialbum.net" uploadUrl = "https://upload.aialbum.net" replaceUrl = lambda method: downloadUrl if method == "GET" else uploadUrl getSourceUrl = lambda objectName, method="GET", expiry=600: bucket.sign_url(method, objectName, expiry) SignUrl = lambda objectName, method="GET", expiry=600: getSourceUrl(objectName, method, expiry).replace(sourcePublicUrl, replaceUrl(method)) thumbnailKey = lambda key: "photo/thumbnail/%s" % (key) if bucket.object_exists("photo/thumbnail/%s" % (key)) else "photo/original/%s" % (key)
-
响应结果相关
response = lambda message, error=False: {'Id': str(uuid.uuid4()), 'Body': { "Error": error, "Message": message, } if error else message}
-
初始化数据库连接对象
# 数据库连接对象 connection = sqlite3.connect(Database, timeout=2)
-
其他相关方法/组件
# 获取默认头像 defaultPicture = "%s/static/images/%s/%s.jpg" getAvatar = lambda: defaultPicture % (downloadUrl, "avatar", random.choice(range(1, 6))) getAlbumPicture = lambda: defaultPicture % (downloadUrl, "album", random.choice(range(1, 6))) # 获取随机字符串 seeds = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ' * 100 getRandomStr = lambda num=200: "".join(random.sample(seeds, num)) # md5加密 getMD5 = lambda content: hashlib.md5(content.encode("utf-8")).hexdigest() # 获取格式化时间 getTime = lambda: time.strftime("%Y-%m-%d %H:%M:%S", time.localtime())
数据库 CRUD 组件
由于项目本身存在大量 RESTful API,而这些 API 背后的逻辑基本都会涉及到数据库相关的操作,所以在项目中可以抽象出数据库 CRUD 模块:
# 数据库操作
def Action(sentence, data=(), throw=True):
'''
数据库操作
:param throw: 异常控制
:param sentence: 执行的语句
:param data: 传入的数据
:return:
'''
try:
for i in range(0,5):
try:
cursor = connection.cursor()
result = cursor.execute(sentence, data)
connection.commit()
return result
except Exception as e:
if "disk I/O error" in str(e):
time.sleep(0.2)
continue
elif "lock" in str(2):
time.sleep(1.1)
continue
else:
raise e
except Exception as err:
print(err)
if throw:
raise err
else:
return False
在其他的方法中,可以通过传入对应的参数,操作对应的数据库:
insertStmt = ("INSERT INTO User(`username`, `token`, `avatar`, `secret`, `place`, `gender`, `register_time`, `state`, `remark`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?);")
Action(insertStmt, (username, token, avatar, tempSecret, place, gender, str(getTime()), 1, ''))
照片上传
考虑到 Serverless 架构中的计算平台,即 FaaS 平台,通常都是由事件驱动,而一般情况下云厂商对实践传输的体积进行了一定的限制,通常是 6M 左右,如果直接上传图片,显然会出现大量的Event体积超限问题,那么在 Serverless 架构下,如何安全的,高性能的,且优雅的上传文件,就显得尤为重要了。
为了实现大图片的上传,将会采用通过对象存储预签名的方案进行照片的上传,整个路程包括两部分:
- 存储元数据以及获取预签名阶段:当客户端发起照片上传请求之后,服务端会根据参数信息将数据记录到数据库中,只不过此时数据的状态为“待上传”状态,与此同时将对象存储的预签名地址返回到客户端;
- 完成照片上传并进行异步处理阶段:当客户端在本地通过对象存储的预签名地址完成图片上传之后,对象存储会通过对象存储触发器触发函数计算进行图片的相关处理,这其中就有将对应照片数据的状态升级为“已上传”。
针对上述流程中的“存储元数据以及获取预签名阶段”,服务端的代码实现为:
# 图片管理:新增图片
@bottle.route('/picture/upload/url/get', method='POST')
def getPictureUploadUrl():
try:
# 参数获取
postData = json.loads(bottle.request.body.read().decode("utf-8"))
secret = postData.get('secret', None)
albumId = postData.get('album', None)
index = postData.get('index', None)
password = postData.get('password', None)
name = postData.get('name', "")
file = postData.get('file', "")
tempFileEnd = "." + file.split(".")[-1]
tempFileEnd = tempFileEnd if tempFileEnd in ['.png', '.jpg', '.bmp', 'jpeg', '.gif', '.svg', '.psd'] else ".png"
file_token = getMD5(str(albumId) + name + secret) + getRandomStr(50) + tempFileEnd
file_path = "photo/original/%s" % (file_token)
# 参数校验
if not checkParameter([secret, albumId, index]):
return False, response(ERROR['ParameterException'], 'ParameterException')
# 查看用户是否存在
user = Action("SELECT * FROM User WHERE `secret`=? AND `state`=1;", (secret,)).fetchone()
if not user:
return response(ERROR['UserInformationError'], 'UserInformationError')
# 权限鉴定
if checkAlbumPermission(albumId, user["id"], password) < 2:
return response(ERROR['PermissionException'], 'PermissionException')
insertStmt = ("INSERT INTO Photo (`create_time`, `update_time`, `album`, `file_token`, `user`, `description`, "
"`delete_user`, `remark`, `state`, `delete_time`, `views`, `place`, `name`) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)")
insertData = ("", getTime(), albumId, file_token, user["id"], "", "", "", 0, "", 0, "", name)
Action(insertStmt, insertData)
return response({"index": index, "url": SignUrl(file_path, "PUT", 600)})
except Exception as e:
print("Error: ", e)
return response(ERROR['SystemError'], 'SystemError')
针对上述流程中的“完成照片上传并进行异步处理阶段”阶段,服务端代码实现为:
def handler(event, context):
events = json.loads(event.decode("utf-8"))["events"]
for eveObject in events:
# 路径处理
file = eveObject["oss"]["object"]["key"]
targetFile = file.replace("original/", "thumbnail/")
localSourceFile = os.path.join("/tmp", file)
localTargetFile = localSourceFile.replace("original/", "thumbnail/")
# 获取图片信息
searchStmt = "SELECT * FROM Photo WHERE `file_token`=%s;"
photo = Action(searchStmt, (file.split('/')[-1], )).fetchone()
# 升级图片
updateStmt = "UPDATE Photo SET `state`=1 WHERE `file_token`=%s;"
Action(updateStmt, (file.split('/')[-1], ))
异步任务
所谓的异步任务指的是当客户端完成照片上传之后,对象存储将会触发函数计算进行照片的进一步处理,这个过程是异步进行的并且在此过程中将会存在诸多任务:
- 升级照片状态
- 照片压缩与存储
- 提取照片元信息
- 图片理解(Image Caption)
- 图片OCR识别
- 图片目标检测
图8 异步任务执行逻辑简图
如图8所示,在具体实现的过程中,上述异步任务中的6个任务可以进行编排:
- 6个任务在一同一个函数中实现(串行);这种做法相对简单,但是容错率比较低,因为函数计算有着固定的
timeout
配置,所以任何一个流程出现问题都会导致后续流程终止,即无法尽可能地完成6个任务;同时在同一个函数中执行对 CPU 与内存需求不同的任务,会导致成本的进一步上升; - 6个任务在不同的函数中实现:
- 串行:通过函数间调用实现整体的任务串联;这种做法难度适中,相比前者优势突出明显,由于每个任务执行都可以在独立的函数中实现,就意味着不同的函数可以拥有不同的配置,包括不限于内存配置,CPU配置以及超时时间配置,所以这种方法将会在成本与稳定性上有进一步提升。但是串行执行多个任务会导致整个流程过长,并且任务执行失败的重试逻辑需要开发者自行提供;
- 并行:通过 Serverless 工作流触发6个函数并行执行对应的任务;尽管这种方法会更为复杂,并引入了新的云产品作为链接,但是该方案无论是从性能、成本以及体验上来讲,都是更为优秀的。通过 Serverless 工作流并行触发不同的函数执行不同的业务逻辑,可以在上述方案的基础上大幅度节省时间成本,加速任务完成效率,与此同时 Serverless 工作自带有编排能力,即对于失败的任务是否重试,重试次数以及失败后的处理逻辑等,都是可以进行自定义的。
针对上述内容,下面通过部分代码列举出部分异步任务的实现思路和方法:
-
图像格式转换:
def PNG_JPG(PngPath, JpgPath): img = cv.imread(PngPath, 0) w, h = img.shape[::-1] infile = PngPath outfile = JpgPath img = Image.open(infile) img = img.resize((int(w / 2), int(h / 2)), Image.ANTIALIAS) try: if len(img.split()) == 4: r, g, b, a = img.split() img = Image.merge("RGB", (r, g, b)) img.convert('RGB').save(outfile, quality=70) os.remove(PngPath) else: img.convert('RGB').save(outfile, quality=70) os.remove(PngPath) return outfile except Exception as e: print(e) return False
-
图像的压缩:
image = Image.open(localSourceFile) width = 450 height = image.size[1] / (image.size[0] / width) imageObj = image.resize((int(width), int(height))) imageObj.save(localTargetFile)
-
获取图片基础信息:
def getPhotoInfo(img_path): img_exif = exifread.process_file(open(img_path, 'rb')) # 能够读取到属性 if img_exif: # 纬度数 latitude_gps = img_exif['GPS GPSLatitude'] # 经度数 longitude_gps = img_exif['GPS GPSLongitude'] # 拍摄时间 take_time = img_exif['EXIF DateTimeOriginal'] take_time = str(take_time).split(' ')[0].replace(":", "-") + ' ' + str(take_time).split(' ')[1] # 纬度、经度、拍摄时间 if latitude_gps and longitude_gps and take_time: # 对纬度、经度值原始值作进一步的处理 latitude = format_lati_long_data(latitude_gps) longitude = format_lati_long_data(longitude_gps) # 注意:由于gps获取的坐标在国内高德等主流地图上逆编码不够精确,这里需要转换为火星坐标系 location = wgs84togcj02(longitude, latitude) return { "time": take_time, "location": { "longitude": location[0], "latitude": location[1] } } return False return False
其中关于经纬度信息的获取主要包括:
-
WGS84转GCJ02(火星坐标系):
def wgs84togcj02(lng, lat): """ WGS84转GCJ02(火星坐标系) :param lng:WGS84坐标系的经度 :param lat:WGS84坐标系的纬度 :return: """ if out_of_china(lng, lat): # 判断是否在国内 return lng, lat dlat = transformlat(lng - 105.0, lat - 35.0) dlng = transformlng(lng - 105.0, lat - 35.0) radlat = lat / 180.0 * pi magic = math.sin(radlat) magic = 1 - ee * magic * magic sqrtmagic = math.sqrt(magic) dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * pi) dlng = (dlng * 180.0) / (a / sqrtmagic * math.cos(radlat) * pi) mglat = lat + dlat mglng = lng + dlng return [mglng, mglat]
-
GCJ02(火星坐标系)转GPS84:
def gcj02towgs84(lng, lat): """ GCJ02(火星坐标系)转GPS84 :param lng:火星坐标系的经度 :param lat:火星坐标系纬度 :return: """ if out_of_china(lng, lat): return lng, lat dlat = transformlat(lng - 105.0, lat - 35.0) dlng = transformlng(lng - 105.0, lat - 35.0) radlat = lat / 180.0 * pi magic = math.sin(radlat) magic = 1 - ee * magic * magic sqrtmagic = math.sqrt(magic) dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * pi) dlng = (dlng * 180.0) / (a / sqrtmagic * math.cos(radlat) * pi) mglat = lat + dlat mglng = lng + dlng return [lng * 2 - mglng, lat * 2 - mglat]
-
坐标转换:
def transformlat(lng, lat): ret = -100.0 + 2.0 * lng + 3.0 * lat + 0.2 * lat * lat + \ 0.1 * lng * lat + 0.2 * math.sqrt(math.fabs(lng)) ret += (20.0 * math.sin(6.0 * lng * pi) + 20.0 * math.sin(2.0 * lng * pi)) * 2.0 / 3.0 ret += (20.0 * math.sin(lat * pi) + 40.0 * math.sin(lat / 3.0 * pi)) * 2.0 / 3.0 ret += (160.0 * math.sin(lat / 12.0 * pi) + 320 * math.sin(lat * pi / 30.0)) * 2.0 / 3.0 return ret def transformlng(lng, lat): ret = 300.0 + lng + 2.0 * lat + 0.1 * lng * lng + \ 0.1 * lng * lat + 0.1 * math.sqrt(math.fabs(lng)) ret += (20.0 * math.sin(6.0 * lng * pi) + 20.0 * math.sin(2.0 * lng * pi)) * 2.0 / 3.0 ret += (20.0 * math.sin(lng * pi) + 40.0 * math.sin(lng / 3.0 * pi)) * 2.0 / 3.0 ret += (150.0 * math.sin(lng / 12.0 * pi) + 300.0 * math.sin(lng / 30.0 * pi)) * 2.0 / 3.0 return ret def out_of_china(lng, lat): """ 判断是否在国内,不在国内不做偏移 :param lng: :param lat: :return: """ if lng < 72.004 or lng > 137.8347: return True if lat < 0.8293 or lat > 55.8271: return True return False def format_lati_long_data(data): """ 对经度和纬度数据做处理,保留6位小数 :param data: 原始经度和纬度值 :return: """ # 删除左右括号和空格 data_list_tmp = str(data).replace('[', '').replace(']', '').split(',') data_list = [data.strip() for data in data_list_tmp] # 替换秒的值 data_tmp = data_list[-1].split('/') # 秒的值 data_sec = int(data_tmp[0]) / int(data_tmp[1]) / 3600 # 替换分的值 data_tmp = data_list[-2] # 分的值 data_minute = int(data_tmp) / 60 # 度的值 data_degree = int(data_list[0]) # 由于高德API只能识别到小数点后的6位 # 需要转换为浮点数,并保留为6位小数 result = "%.6f" % (data_degree + data_minute + data_sec) return float(result)
-
除了上述基础的操作之外,基于人工智能的部分操作:
-
通过 PaddleOCR 进行文字识别案例:
from paddleocr import PaddleOCR result = urllib.request.urlopen(urllib.request.Request(url=OcrUrl, data=json.dumps({"image": picture}).encode("utf-8"))).read().decode("utf-8") result = result["text"]
-
通过 ImageAI 进行目标标检测案例:
from imageai.Prediction import ImagePrediction # 模型加载 prediction = ImagePrediction() prediction.setModelTypeAsResNet() prediction.setModelPath("resnet50_weights_tf_dim_ordering_tf_kernels.h5") prediction.loadModel() predictions, probabilities = prediction.predictImage("./picture.jpg", result_count=5 ) for eachPrediction, eachProbability in zip(predictions, probabilities): print(str(eachPrediction) + " : " + str(eachProbability))
小程序相关
通过微信开发者工具可以进行小程序相关的开发,如图9所示:
图9 小程序开发示意图
在开发之前需要:
- 明确当前项目类型是小程序开发,并非小游戏选项;
- 明确采用 Javascript 技术栈搭建脚手架,并且不使用云开发;
除此之外还需要提前导入 ColorUI 相关组件,以确保在开发过程中可以直接进行引用。
公共方法
完成小程序页面的开发,开始对小程序的数据部分进行统一的抽象,例如请求后端的方法
// 统一请求接口
doPost: async function (uri, data, option = {
secret: true,
method: "POST"
}) {
let times = 20
const that = this
let initStatus = false
if (option.secret) {
while (!initStatus && times > 0) {
times = times - 1
if (this.globalData.secret) {
data.secret = this.globalData.secret
initStatus = true
break
}
await that.sleep(500)
}
} else {
initStatus = true
}
if (initStatus) {
return new Promise((resolve, reject) => {
wx.request({
url: that.url + uri,
data: data,
header: {
"Content-Type": "text/plain"
},
method: option.type ? option.type : "POST",
success: function (res) {
console.log("RES: ", res)
if (res.data.Body && res.data.Body.Error && res.data.Body.Error == "UserInformationError") {
wx.redirectTo({
url: '/pages/login/index',
})
} else {
resolve(res.data)
}
},
fail: function (res) {
reject(null)
}
})
})
}
}
例如登陆模块:
const that = this
const postData = {}
let initStatus = false
while (!initStatus) {
if (this.globalData.token) {
postData.token = this.globalData.token
initStatus = true
break
}
await that.sleep(200)
}
if (this.globalData.userInfo) {
postData.username = this.globalData.userInfo.nickName
postData.avatar = this.globalData.userInfo.avatarUrl
postData.place = this.globalData.userInfo.country || "" + this.globalData.userInfo.province || "" + this.globalData.userInfo.city || ""
postData.gender = this.globalData.userInfo.gender
}
try {
this.doPost('/login', postData, {
secret: false,
method: "POST"
}).then(function (result) {
if (result.secret) {
that.globalData.secret = result.secret
} else {
that.responseAction(
"登陆失败",
String(result.Body.Message)
)
}
})
} catch (ex) {
this.failRequest()
}
客户端文件上传
正如前文所述,Serverless 架构下进行尺寸较大图片上传需要分为两个步骤:
- 步骤一:获取 OSS 预签名地址;
- 步骤二:进行图片上传;
其中步骤1在小程序端的实现相当于是进行一次普通的 RESTful API 请求,例如:
app.doPost('/picture/upload/url/get', {
album: that.data.album[that.data.index].id,
index: i,
file: uploadFiles[i]}).then(function (result) {}
步骤2的实现,在小程序端会略显复杂,主要原因是小程序默认提供的uploadFile方法,只支持POST方法,而阿里云对象存储的SDK预签名只支持PUT与GET方法,所以这意味着用小程序默认提供的uploadFile方法无法实现将图片上传到对象存储;所以此时可以考虑采用小程序的wx.request(Object object)方法,并指定PUT方法来进行上传,例如:
wx.request({
method: 'PUT',
url: result.Body.url,
data: wx.getFileSystemManager().readFileSync(uploadFiles[result.Body.index]),
header: {
"Content-Type": " "
},
success(res) {
},
fail(res) {
},
complete(res) {
}
})
图片列表与手势操作
为了让这个工具更加符合常见的相册系统,可以通过手势操作来对列表进行部分操作,效果如图10所示:
图10 手势操作调整图片显示效果图
即可以通过双指进行放大缩小的操作来实现相册每行显示的数量。这一部分的实现方案基本上是:
/**
* 调整图片
*/
touchendCallback: function (e) {
this.setData({
distance: null
})
},
touchmoveCallback: function (e) {
if (e.touches.length == 1) {
return
}
// 监测到两个触点
let xMove = e.touches[1].clientX - e.touches[0].clientX
let yMove = e.touches[1].clientY - e.touches[0].clientY
let distance = Math.sqrt(xMove * xMove + yMove * yMove)
if (this.data.distance) {
// 已经存在前置状态
let tempDistance = this.data.distance - distance
let scale = parseInt(Math.abs(tempDistance / this.data.windowRate))
if (scale >= 1) {
let rowCount = tempDistance > 0 ? this.data.rowCount + scale : this.data.rowCount - scale
rowCount = rowCount <= 1 ? 1 : (rowCount >= 5 ? 5 : rowCount)
this.setData({
rowCount: rowCount,
rowWidthHeight: wx.getSystemInfoSync().windowWidth / rowCount,
distance: distance
})
}
} else {
// 不存在前置状态
this.setData({
distance: distance
})
}
}
项目预览
最终完成开发的小程序主要包括6个栏目和14个页面。这些页面主要有:
- 首页:
- 相册列表页:用于列举用户的相册(包括自己创建的相册以及与其他人共建的相册);通过点击对应相册可以查看到对应照片列表;
- 照片列表页:在首页所展示的默认照片列页可以查看用户最新上传的若干张照片;通过点击相册进入到的照片列表页可以查看到当前相册下的全部照片;
- 照片搜索页:可以通过关键字进行图片的检索;
- 照片上传页:可以通过选择指定相册进行照片的上传;
- 相册管理页:主要用于对相册信息的增删改查操作等;主要包括
- 增加相册:通过该页面可以进行相册的创建;
- 修改相册:通过该页面可以进行相册的更新;
- 删除相册:通过点击删除按钮可以删除相册,相册一旦被删除将无法恢复,但是照片会被存在“后悔药”页面;
- 个人中心页:主要是个人信息展示与社交关系、账号管理等,主要包括:
- 后悔药页面:用于存放已删除的照片,相当于回收站功能;
- 互动站页面:用于查看所有和自己有过互动的用户信息,例如对方查看自己所分享的照片之后,就代表对方与自己有过互动,此时就可以在当前页面查看到对方的基础信息(昵称和头像),同时也可以与对方解除关系或拉进黑名单;
- 黑名单页面:用于存储被自己屏蔽的用户,即黑名单用户无法查看自己分享的内容;
- 关于我们页面:用于介绍小程序的开发者以及介绍小程序的部分信息;
- 账号注销功能:通过点击该按钮,可以实现账号的注销功能,账号注销即意味着存储的照片等信息均会被删除;
- 登录注册:主要用于展示用户协议以及引导用户进行账号授权使用;这个模块也是应小程序开发规范要求以及需要通过授权后的
openid
进行用户身份识别使用;
部分页面的 UI 还原如下:
-
注册登录页面 UI 效果如图11所示:
图11 注册登录页面 UI 效果图
-
小程序首页(相册列表),相册详情页(照片列表页),个人中心页以及照片上传页面 UI 效果如图12所示:
图12 小程序其他页面效果图
总结
随着Serverless架构的发展,Serverless可以在更多的领域发挥着更重要的作用。本文通过自身的需求,转换为项目开发,通过传统框架迁移到Serverless架构,通过项目的本地调试,通过对象存储、云硬盘等产品与函数计算的融合,实现了一个基于人工智能相册的小程序。得益于小程序本身的技术红利,叠加了Serverless架构的技术红利,使得该项目的研发效能飞速提升,并且具有极致弹性、按量付费、服务端免运维等优点。
在未来,在和朋友出去玩耍,可以共同维护一个相册,即使时光荏苒,岁月如梭,若干年之后,也可以通过搜索“坐在海边喝酒”,来找回自己的青葱岁月。