iT邦幫忙

0

《賴田捕手:追加篇》第 33 天:妥善運用 Heroku APP 暫存空間

  • 分享至 

  • xImage
  •  

第 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 全攻略 集結成書了,歡迎有興趣的大家前往購書喔。


圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言