《赖田捕手:追加篇》第 33 天:妥善运用 Heroku APP 暂存空间

第 33 天:妥善运用 Heroku APP 暂存空间

我很快的把书拿了起来,翻开封面。有一行我不认得的笔迹在书页上写着:如果我能保留一点空间让你在这边储存什麽档案的话,那麽,/tmp/就是我想为你留下的空间。

~节录自《聊天机器人的历史:我爸的暂存空间》

延伸自系列文 《从LINE BOT到资料视觉化:赖田捕手》

《赖田捕手:追加篇》:

今天您将知道:

  1. 如何取得使用者传送给 LINE BOT 的图片
  2. 如何将图片储存在 Heroku 暂存空间,并用ImageSendMessage发送给使用者

上一篇我们利用快速回覆按钮设计了一连串和使用者互动的过程,并用 Heroku Postgres 将使用者的输入资料储存起来。但最重要的双色打光 (Dual Lighting Effect) 还是不见踪影?是的,因此今天我们要来把这最重要的一块拼图给拼上了。

都给我画起来

虽然我们的 LINE BOT 还不知道该怎麽做到双色打光,但在第 32 天当中,我想大家都应该知道双色打光该怎麽做了。所以第一个任务很简单,让我们把双色打光的程序码装备到 LINE BOT 身上。先来看看我们目前的资料夹结构:

D:\appendix>tree /f
Folder PATH listing
Volume serial number is 9C33-6XXD
D:.
│   runtime.txt
│   requirements.txt
│   Procfile
│   Alma.py
│
└───app
    │   __init__.py
    │   models_for_line.py
    │   routes.py 
    │
    └───custom_models
            AlmaTalks.py
            CallDatabase.py

因为我想要维持分工明确的程序码,所以就开一个新的档案AlmaRenders.py,将所有用来执行双色打光的程序码都放进来吧:

D:\appendix>tree /f
Folder PATH listing
Volume serial number is 9C33-6XXD
D:.
│   runtime.txt
│   requirements.txt
│   Procfile
│   Alma.py
│
└───app
    │   __init__.py
    │   models_for_line.py
    │   routes.py 
    │
    └───custom_models
            AlmaTalks.py
            AlmaRenders.py
            CallDatabase.py

而档案里的详细内容则是:

  • app/custom_models/AlmaRenders.py
import numpy as np
from PIL import Image
from PIL import ImageDraw

def sigmoid(x, alpha):
    return 1 /(1 + np.exp(-x * alpha))

def create_gradient_layer(layer_im, gradient_factor, first_tone, second_tone):
    layer_gradient = Image.new('RGB', layer_im.size)
    draw = ImageDraw.Draw(layer_gradient)

    for i in range(layer_im.size[0]):
        value = sigmoid(i - layer_im.size[0] / 2, gradient_factor / layer_im.size[0])
        fill_color = np.array(first_tone) * value + np.array(second_tone) * (1 - value)
        draw.line([(i, 0), (i, layer_im.size[1]-1)], fill=tuple(fill_color.astype('int')))

    return layer_gradient

以及最重要的dual_tone_run

  • app/custom_models/AlmaRenders.py
from PIL import Image
from PIL import ImageEnhance
from PIL import ImageOps

def dual_tone_run(im, mode, gradient_factor, first_tone, second_tone):

    color_dict = {
        'red':    {'blend': (100, 10, 0),  
                   'composite': (100, 10, 0),  
                   'composite_invert': (255, 0, 0)},
        'orange': {'blend': (100, 50, 0), 
                   'composite': (100, 50, 0), 
                   'composite_invert': (255, 120, 0)},
        'yellow': {'blend': (100, 100, 0), 
                   'composite': (100, 100, 0), 
                   'composite_invert': (255, 255, 0)},
        'green':  {'blend': (10, 100, 0), 
                   'composite': (10, 100, 0),  
                   'composite_invert': (0, 255, 0)},
        'blue':   {'blend': (0, 10, 100), 
                   'composite': (0, 10, 100),  
                   'composite_invert': (0, 0, 255)},
        'purple': {'blend': (50, 0, 100),  
                   'composite': (50, 0, 100),  
                   'composite_invert': (120, 0, 255)}
    }

    layer_im = im.convert('RGBA')

    first_tone = color_dict[first_tone][mode]
    second_tone = color_dict[second_tone][mode]
    layer_gradient = create_gradient_layer(layer_im, gradient_factor, first_tone, second_tone).convert('RGBA')

    if mode == 'blend':
        dual_tone = Image.blend(layer_im, layer_gradient, 0.5)
    elif mode == 'composite':
        dual_tone = Image.composite(layer_im, layer_gradient, layer_im.convert('L'))
    elif mode == 'composite_invert':
        dual_tone = Image.composite(layer_im, layer_gradient, ImageOps.invert(layer_im.convert('L')).convert('L'))

    # 和第32天不同的地方
    return ImageEnhance.Color(dual_tone).enhance(2)

dual_tone_run当中的color_dict是执行双色打光时的色码对照表。当使用者选择线性叠图 (blend) 当作双色打光模式,并且选择红色 (red) 时,LINE BOT 就会根据color_dict选到(100, 0, 0)这个色码做为稍後执行双色打光的其中一种颜色。我发觉当使用线性叠图 (blend) 跟滤镜叠图 (composite) 模式时,颜色不要用得太鲜艳,结果会比较好。反之,当使用反式线性叠图 (invert_composite) 模式时,颜色需要相当鲜艳,结果才会好看。当然这是根据我的个人喜好选出来的颜色,大家也可以按照自己的需求给定想要的颜色。
另外,和第 32 天不太一样的地方,我们在最後面多了一个ImageEnhance.Color(image).enhance(2),用来将图片的颜色变得更饱满鲜艳一点。这是 Python 图像处理资源库PIL内建的一个非常好玩的功能。PIL一共提供 4 种ImageEnhance的类别,包括PIL.ImageEnhance.ColorPIL.ImageEnhance.ContrastPIL.ImageEnhance.Brightness、以及PIL.ImageEnhance.Sharpness,可以分别用来调整图像的色彩、对比、亮度、以及锐利度。使用的方式基本相同,以色彩为例:

from PIL import ImageEnhance

enhancer = ImageEnhance.Color(image)
enhanced_image = enhancer.enhance(factor)
  • 第二行:enhancer = ImageEnhance.Color(image)
    将想要做色彩调整的影像image作为参数,放入ImageEnhance.Color当中,初始化而得到enhancer这个物件。

  • 第三行:enhanced_image = enhancer.enhance(factor)
    新的enhancer物件具有一个内建的函式enhance。用大於 0 的任一数值factor当作参数,呼叫enhance这个方法,就可以得到一张根据factor调整过後的影像enhanced_image。在调整色彩时,将factor设为 0 会得到灰阶的影像。factor设为 1 则会得到原始影像,大於 1 则会得到强化色彩的影像。其他类别的执行逻辑也是如此,大家可以自己试试看。

都给我存起来

当装备上双色打光这个人人称羡的配件之後,我们的 LINE BOT 是不是就可以帮使用者进行新潮又前卫的影像处理了呢?先让我们来顺一顺整段流程。

  1. 首先,当使用者传送图片讯息给 LINE BOT 时,AlmaTalks.phase_start会被触发,替使用者初始化一笔资料。
  2. 接着,使用者会按照 LINE BOT 给出的QuickReply,在AlmaTalks.phase_intermediate的带领之下,一次一次的更新资料。
  3. 最後,当使用者透过QuickReplyButton设定好最後一个参数,也就是second_tone,这时AlmaTalks.phase_finish会被触发。也就是到这个时候,LINE BOT 要开始动起来,为影像添加双色打光效果。

让我们仔细看一看这个phase_finish

  • app/custom_models/AlmaTalks.py
from app.custom_models import AlmaRenders

def phase_finish(event):
    user_id = event.source.user_id
    postback_data = event.postback.data
    current_phase = postback_data.split('=')[0]

    record = CallDatabase.update_record(user_id, current_phase, postback_data.split('=')[1])

    mode = record[2]
    gradient_factor = int(record[3])
    first_tone = record[4]
    second_tone = record[5]

    # 所以我说那个im在哪里呢?
    im_dual_tone = AlmaRenders.dual_tone_run(im, mode, gradient_factor, first_tone, second_tone)

    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text=str(record))
    )

我们用CallDatabase.update_record更新使用者输入的参数,同时也拿到了更新之後的资料,包括模式、颜色梯度、第一种颜色、第二种颜色。这时候就可以利用dual_tone_run,对使用者一开始传送过来的图片做影像处理了,对吧?
等等,所以我说那个一开始传送过来的图片在哪里呢?没错,就是dual_tone_run在执行的时候需要的第一个参数im。没有imdual_tone_run,根本没有执行的必要。

虽然很残酷,但事实就是如此。

那我们该怎麽拿到使用者一开始传送过来的图片呢?幸运的是,这个问题,LINE 已经帮我们设想好了:

message_content = line_bot_api.get_message_content(message_id)

with open(file_path, 'wb') as fd:
    for chunk in message_content.iter_content():
        fd.write(chunk)

每一次使用者和 LINE BOT 的互动,每一个讯息,都有属於自己的编号,也就是message_id。只要透过这个message_id,就可以拿到当次互动的讯息内容,比如说图片、影片、音讯等资料。而我们就是要透过message_id来拿到使用者一开始传送过来的图片。而这个message_id会放在哪里呢?答案是event.message.id
如果是一般的文字讯息事件 (TextMessage),LINE BOT 会收到这样的内容:

{
    "events": [
        {
            "type": "message",
            "replyToken": "代表reply token的一串代码",
            "source": {
                "userId": "代表user id 的一串代码",
                "type": "user"
            },
            "timestamp": 1609663876391,
            "mode": "active",
            "message": {
                "type": "text",
                "id": "13316587131627",
                "text": "使用者输入的文字讯息"
            }
        }
    ],
    "destination": "代表LINE BOT的一串代码"
}

而如果是收到图片 (ImageMessage),LINE BOT 则会看到:

{
    "events": [
        {
            "type": "message",
            "replyToken": "代表reply token的一串代码",
            "source": {
                "userId": "代表user id 的一串代码",
                "type": "user"
            },
            "timestamp": 1609675744047,
            "mode": "active",
            "message": {
                "type": "image",
                "id": "13317467950018",
                "contentProvider": {
                    "type": "line"
                }
            }
        }
    ],
    "destination": "代表LINE BOT的一串代码"
}

和一般的文字讯息不同,图片讯息里面完全没有显示任何和图片有关的资料。要知道使用者究竟传了什麽图片给 LINE BOT,只能透过event.message.id
现在回头看看AlmaTalks.phase_start,有没有突然明白为什麽我们要在初始化使用者资料的时候顺便存入event.message.id了吗?

利用line_bot_api.get_message_content(message_id)拿到使用者传送给 LINE BOT 的图片内容之後,下一步我们得把这张图片存起来,这样我们才可以真正来使用这张图片。至於可以把图片存在哪里,我想答案已经呼之欲出了,那就是 Heroku 的暂存空间里,也就是/tmp/
Heroku 所提供的空间大概长这样:

/
│   bin 
│   dev 
│   etc 
│   lib 
│   lib64 
│   lost+found 
│   proc
│   sbin
│   sys
│   tmp 
│   usr
│   var
└───app
    │   runtime.txt
    │   requirements.txt
    │   Procfile
    │   Alma.py
    │
    └───app
        │   __init__.py
        │   models_for_line.py
        │   routes.py 
        │
        └───custom_models
                AlmaTalks.py
                CallDatabase.py

这种档案架构,对 Linux 系统熟悉的人应该不会陌生。不过我们现在先不管这些,不晓得大家有没有注意到,我们推向 Heroku 的档案,就放在/app/里呢!
而在这些空间里面,最能让我们自由运用的,就是/tmp/了。所以让我们试着把使用者发送过来的图片存到/tmp/来吧:

  • app/custom_models/AlmaRenders.py
def get_image(message_content, file_name):
    file_path = f"/tmp/{file_name}.png"

    with open(file_path, 'wb') as fd:
        for chunk in message_content.iter_content():
            fd.write(chunk)

    im = Image.open(file_path)
    return im

然後稍微修改一下AlmaTalks.py

  • AlmaTalks.py
from app.custom_models import AlmaRenders

def phase_finish(event):
    user_id = event.source.user_id
    postback_data = event.postback.data
    current_phase = postback_data.split('=')[0]

    record = CallDatabase.update_record(user_id, current_phase, postback_data.split('=')[1])

    # 所以我说那个im在这里
    im = AlmaRenders.get_image(message_content, event.reply_token)

    mode = record[2]
    gradient_factor = int(record[3])
    first_tone = record[4]
    second_tone = record[5]

    im_dual_tone = AlmaRenders.dual_tone_run(im, mode, gradient_factor, first_tone, second_tone)


    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text=str(record))
    )

太棒了,我们现在可以顺利执行dual_tone_run了。

都给我推起来

好的,现在我们的 LINE BOT 学会了双色打光,而且还可以根据使用者输入的条件,对使用者传送过来的图片佐以不同效果的双色打光,是不是很厉害啊。不过问题是,对於没有看到成品的使用者而言,这根本就不厉害啊。这是一个现实而且冷酷的世界,与其讨论过程如何如何,大家更看重的是成果?
那麽,该怎麽做才能够让使用者看到成果呢?直觉来想,当然是用 LINE 所提供的方法ImageSendMessage

image_message = ImageSendMessage(
    original_content_url='https://example.com/original.jpg',
    preview_image_url='https://example.com/preview.jpg'
)

上面那一段是在line-bot-sdk官方文件当中所给出的用法。也就是说,要传送图片给使用者,我们必须要为图片创造出一个网址 (URL),而且必须是符合 HTTPS 传输协定的网址才行。
如果说是传送存在网路上的图片那还简单,现在我们要传送的是 LINE BOT 画出来的图片,这该怎麽做呢?
其中一种方法是,那我们就先将图片上传到网路上。由於 Imgur 提供了免费的网路空间,让大家可以自由上传自己的图片。只要我们教会 LINE BOT 如何把图片上传到 Imgur,就可以利用ImageSendMessage这个方法将图片送到使用者手上。有兴趣的可以参考 twtrubiks 大大在这里所提供的详细教学。

意思是,今天我们要讨论另一种方法。

这个方法可以充分利用我们今天上半场学到的内容,也就是将图片存在 Heroku 暂存空间。接着,再透过flask来为这张放在暂存空间里的图片创造出一个网址。等等,flask是什麽?大家不要忘了flask啊。虽然我们很少提,但 LINE BOT 之所以能够在 Heroku 上接收 LINE 传送过来的资料,靠的就是flask
flask是 Python 用来建立网路框架相当轻便好用的资料库。利用flask,我们在 Heroku 当中创造出路由,让 LINE 可以将资料传送过来。
等等,创造出路由?这不就是我们需要的吗。那详细来说到底可以怎麽来实作呢?不罗嗦,让我们直接看一段程序码:

  • app/custom_models/AlmaRenders.py
import os

def save_image(im, reply_token):
    file_path = f'/tmp/{reply_token}_DualTone.png'
    im.save(file_path)
    return f'https://{os.getenv("YOUR_HEROKU_APP_NAME")}.herokuapp.com/result/{reply_token}'

首先,在AlmaRenders.py这个档案里多补上一个函式save_image,用来储存 LINE BOT 做完双色打光之後的图片,一样,就存在 Heroku 的暂存空间里。为了不让不同使用者之间产生出来的图片互相混淆,我们可以将reply_token放进图片档的档名当中,作为识别。
接着让我们仔细看一看最後一行:

f'https://{os.getenv("YOUR_HEROKU_APP_NAME")}.herokuapp.com/result/{reply_token}'

这是一个虚假的网址,但也不全是凭空产生的。当我们在 Heroku 上新建立一个 APP,Heroku 就会帮我们创造出一个网址https://你-APP-的名字.herokuapp.com,用来连接到我们的 APP。也就是说,os.getenv("YOUR_HEROKU_APP_NAME")要填入的,就是你-APP-的名字。当然你也可以不一定要用环境变数来写这一段程序码。直接把你-APP-的名字填在上面也可以。我这边这麽做,是为了方便客制化。要怎麽设定环境变数,大家可以参考第 31 天
而在https://你-APP-的名字.herokuapp.com後面的路由,则是我们要请flask帮忙创造出来的。
因此这个函式执行的结果是,会把双色打光的图片存到 Heroku 暂存空间,接着传回一个虚假的网址。接着就要靠强大的flask来把这个虚假的网址变成实际存在,可以代表图片的网址了。
还记的我们把负责路由的程序码都放在routers.py这个档案里面吗,现在就是要在这个档案当中多加入一段程序码,来帮忙产生我们需要的路由:

  • app/routers.py
from app import app

from flask import send_from_directory

@app.route("/result/<token>")
def get_image_url(token):
    return send_from_directory('/tmp/', filename=f'{token}_DualTone.png')
  • 第三行:@app.route("/result/<token>")
    当有人呼叫https://你-APP-的名字.herokuapp.com这个网域底下的result/<token>,也就是https://你-APP-的名字.herokuapp.com/result/<token>的时候,就执行下面的函式。这个路由特殊之处,在於可以接受变数。被角括号<>所包起来的位置就是变数,以这边为例就是<token>。这个变数可以作为函式的参数而使用。

  • 第四行:def get_image_url(token):
    定义一个函式,并且接受一个从网址传来的参数token

  • 第五行:return send_from_directory('/tmp/', filename=f'{token}_DualTone.png')
    将指定资料夹底下的档案当作呼叫网址的结果,传送回去。这边所选择的档案,当然就是我们先前存在 Heroku 暂存空间的/tmp/{token}_DualTone.png
    send_from_directoryflask所提供的安全传送档案的函式,一般接收 3 个参数,如下所示:

@app.route('/uploads/<filename>')
def download_file(filename):
    return send_from_directory(directory=app.config['UPLOAD_FOLDER'],
                               filename=filename, as_attachment=True)

第一个参数directory限定了档案存放的资料夹。只有在这个资料夹当中的档案才能被传送给发来请求的使用者。以我们来说,当然是暂存资料夹/tmp/。第二个参数filename就是指定的档案名称。第三个参数as_attachment则可以控制档案是否以附件的形式下载,预设值是False。这边我们希望直接开启图片档,而不是以附件形式下载,所以不用特别加上这一个参数。

稍微整理一下头绪,是不是觉得所有拼图都拚上了呢?

那麽应该不用我再多说ImageSendMessage当中的参数该怎麽填了吧:

  • app/custom_models/AlmaTalks.py
from app.custom_models import AlmaRenders

from linebot.models import TextSendMessage, ImageSendMessage

def phase_finish(event):
    user_id = event.source.user_id
    postback_data = event.postback.data
    current_phase = postback_data.split('=')[0]

    record = CallDatabase.update_record(user_id, current_phase, postback_data.split('=')[1])

    message_content = line_bot_api.get_message_content(record[1])

    # 取得使用者发送过来的图片
    im = AlmaRenders.get_image(message_content, event.reply_token)

    # 执行双色打光
    mode = record[2]
    gradient_factor = int(record[3])
    first_tone = record[4]
    second_tone = record[5]
    im_dual_tone = AlmaRenders.dual_tone_run(im, mode, gradient_factor, first_tone, second_tone)

    # 将双色打光图片存到暂存空间
    im_url = AlmaRenders.save_image(im_dual_tone, event.reply_token)

    # 将使用者输入的设定回传给使用者
    line_bot_api.reply_message(
        event.reply_token,
        TextSendMessage(text=str(record))
    )

    # 将双色打光图片回传给使用者
    line_bot_api.push_message(
        user_id,
        ImageSendMessage(
            original_content_url=im_url,
            preview_image_url=im_url
        )
    )

我们的 LINE BOT 真的是非常贴心。根据上面那段程序码,最後使用者不仅会收到自己当初输入的条件,还会拿到一张藉由该条件产生出来的双色打光图片。这时大家或许会问,reply_messagepush_message有什麽差别呢?reply_message是依靠reply_token来回覆使用者讯息的,而push_message则是透过user_id来将讯息传送给指定的使用者。讲到这里,大家可能还是一头雾水,那麽再直白一点好了:reply_message是免费的,而push_message则有使用限制,一个月一个 LINE BOT 最多可以推送 500 则讯息,再多就要收钱了。那为什麽不都用reply_message就好了呢?答案是,reply_token只能用一次。因此这里的逻辑是这样的:LINE BOT 可以根据使用者发送过来的每一则讯息做出一个回覆 (reply_message),但根据同一个讯息想要做出第二个回覆,或是想要主动发送讯息给使用者,就只能用push_message了。
所以我才说我们的 LINE BOT 真的是非常贴心,不惜动用push_message也要发送两则讯息 (参数跟图片) 给使用者。当然大家在设计的时候不一定要这样做,毕竟使用者在意的只是成果 (图片)?过程如何 (参数) 就随意吧。
那麽来看看今天的成果吧!

https://ithelp.ithome.com.tw/upload/images/20210103/20120178dawQmGXBVu.png
图一、双色打光草泥马

https://ithelp.ithome.com.tw/upload/images/20210103/201201789wZStLMaNQ.png
图二、感谢 Logos By Nick 的热情教学

好的,相信大家知道接下来又要进入最重要的工商时间了。是的,感谢 iT邦帮忙 和 博硕文化,LINE Bot by Python 全攻略 集结成书了,欢迎有兴趣的大家前往购书喔。


<<:  R语言个人小笔记

>>:  AWS架构完善的五个支柱

Ruby解题分享-Implement strStr() && Search Insert Position

下雨的周六...偷懒最适合... Implement strStr() 题目连结:https://l...

Day 01: ML基础第一步 Python基础入门

前言 Python是一种易於学习且功能强大的程序语言,可以呼叫使用相当完整的标准资料库,我们也称之为...

【Day22】Git 版本控制 - 修改 commit 纪录:rebase

commit 版本的时候可以写下一些讯息,以便他人或未来自己查看的时候可以快速理解。但是,有时候写程...

[Day10] 如何实现图片填色功能 (完结)

#733 - Flood Fill 连结: https://leetcode.com/proble...

Day 8 : HTML – 为什麽Flex没有justify-items和justify-self,而grid却有?

如标题!这篇就是要来聊聊为什麽Flex没有,而grid却有 以下我们都会以讨论justify-sel...