我很快的把書拿了起來,翻開封面。有一行我不認得的筆跡在書頁上寫著:如果我能保留一點空間讓妳在這邊儲存什麼檔案的話,那麼,
/tmp/
就是我想為妳留下的空間。~節錄自《聊天機器人的歷史:我爸的暫存空間》
延伸自系列文 《從LINE BOT到資料視覺化:賴田捕手》
《賴田捕手:追加篇》:
今天您將知道:
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.Color
、PIL.ImageEnhance.Contrast
、PIL.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 是不是就可以幫使用者進行新潮又前衛的影像處理了呢?先讓我們來順一順整段流程。
AlmaTalks.phase_start
會被觸發,替使用者初始化一筆資料。QuickReply
,在AlmaTalks.phase_intermediate
的帶領之下,一次一次的更新資料。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
。沒有im
的dual_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_directory
是flask
所提供的安全傳送檔案的函式,一般接收 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_message
跟push_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
也要發送兩則訊息 (參數跟圖片) 給使用者。當然大家在設計的時候不一定要這樣做,畢竟使用者在意的只是成果 (圖片)?過程如何 (參數) 就隨意吧。
那麼來看看今天的成果吧!
圖一、雙色打光草泥馬
圖二、感謝 Logos By Nick 的熱情教學
好的,相信大家知道接下來又要進入最重要的工商時間了。是的,感謝 iT邦幫忙 和 博碩文化,LINE Bot by Python 全攻略 集結成書了,歡迎有興趣的大家前往購書喔。