前言
個人半學期的期末報告專案,因為想做的更好,所以自行研究了 Python 結合前端介面技術的幾個框架。
建議先備知識
- 前端開發經驗。
- Python 使用經驗。
- OpenCV 使用經驗。
最終成果預覽
Python UI 介面選擇
一開始課堂上是教預設的Qt軟體,但我覺得Qt醜醜的,所以忍不住去嘗試其他框架。
Tkinter
跟以前做window form很像,用程式碼掛載與編排UI物件。
from tkinter import * root = Tk() #創建label widget myLabel = Label(root, text="Hello world! ") # show on screen myLabel.pack() root.mainloop()
Qt Designer
在Anaconda預設功能, Anaconda資料夾下找到designer.exe啟動。
排版檔案會取為.ui附檔名,操作邏輯與Tk類似。
範例: 讀取圖片、變暗處理。
#CallMyWindow1.py import cv2 import numpy as np from PyQt5.QtGui import * import sys from PyQt5.QtWidgets import * from Mywindow3 import * class MyMainWindow(QMainWindow, Ui_MainWindow): def __init__(self, parent=None): super(MyMainWindow, self).__init__(parent) self.setupUi(self) self.loadBtn.clicked.connect(self.btnLoadfile_clicked) self.darkenBtn.clicked.connect(self.btnDarking_clicked) #讀取圖片 - 按鈕 def btnLoadfile_clicked(self): global img img=cv2.imread("Baboon.bmp") imgBGR = cv2.resize(img, (300, 300), interpolation=cv2.INTER_CUBIC) imgRGB = cv2.cvtColor(imgBGR, cv2.COLOR_BGR2RGB) height, width = imgRGB.shape[:2] qimg = QImage(imgRGB, width, height, QImage.Format_RGB888) qpix = QPixmap.fromImage(qimg) self.img.setPixmap(qpix) #圖片變暗 - 按鈕 def btnDarking_clicked(self): #........ self.resImg.setPixmap(qpix) if __name__=="__main__": app = QApplication(sys.argv) myWin = MyMainWindow() myWin.show() sys.exit(app.exec_())
Eel
讓使用者能用前端技術做UI的Python框架,內部使用Bottle Server框架,但不像flask能直接host在線上。
from turtle import position import eel import os #eel.init('./web') #資料夾 eel.init(f'{os.path.dirname(os.path.realpath(__file__))}/web') eel.start('main.html',size=(400,200),position=(500,500)) #網頁 #eel.start('main.html',mode ='chrome-app') #網頁 (app模式)
Flask / Django
使用Python編寫的輕量級Web應用框架。
範例: flask基本頁面,但其網頁模板概念跟Django雷同。
from flask import Flask app= Flask(__name__); @app.route('/') def index(): return "hello worldd123456" if __name__ =='__main__': app.run(debug=True , use_reloader=False port=5000 , host='0.0.0.0')
範例: Django網頁
創個views.py檔案,裡面定義Hello頁面內容
from django.http import HttpResponse def Hello(request): return HttpResponse("Hello world")
在urls.py定義Hello頁面的網址
from django.contrib import admin from django.urls import path #from django.conf.urls import url from django.urls import include, re_path from . import views urlpatterns = [ path('admin/', admin.site.urls), #url(r'^$' , views.Hello) re_path(r'^$' , views.Hello) #re_path使用regex ]
框架名稱 | 優點 | 缺點 |
---|---|---|
Tkinter | 像是用Python程式碼撰寫介面的Qt Designer。 | 邏輯與QtDesigner類似,同樣無法解決程式碼攏長、難做排版等問題。 |
Flask/Django | 較完整且可部屬網頁的web應用框架。 | Python與前端邏輯溝通較複雜,需使用ajax等呼叫方式。 |
eel | 旨在於結合前端技術開發桌面應用程式,前後端邏輯串接容易。 | 我試著包在docker環境下部屬但失敗多次,雖然文獻不多但似乎eel不支援網站部屬。 |
EEL基本使用
(推薦影片: https://youtu.be/FNPW2ZFksCQ)
python 呼叫js方法
python 方法上加@eel.expose 讓js端可以呼叫。
import eel import os @eel.expose def print_js(): print("Hi hi") #eel.init('./web') #資料夾 eel.init(f'{os.path.dirname(os.path.realpath(__file__))}/web') eel.start('main.html',mode ='chrome-app') #網頁 (app模式)
func.js檔案:
function hello(){ eel.print_js()() }
Async方法使用
import random import eel import os @eel.expose def getnum(): return random.randint(0,100) #eel.init('./web') #資料夾 eel.init(f'{os.path.dirname(os.path.realpath(__file__))}/web') eel.start('main.html',mode ='chrome-app') #網頁 (app模式) eel.sleep(5) #time.sleep(5) 要sleep就用eel
async function doA(){ var a=await eel.getnum()() alert(a) }
按下按鈕後,js端等待python處理結果後出現alert。
<!DOCTYPE html> <html> <head> <title></title> <script src="/eel.js"></script> <link rel="stylesheet" href="style.css"> <script src="func.js"></script> </head> <body> <h1>Hell world</h1> <button onclick="doA()">hello</button> </body> </html>
JS-EEL圖檔傳輸
跨語言開發首先遇到的問題便是如何傳遞圖片資訊? 由於處理後的圖片需轉換成前端html可顯示的based64編碼,其跟openCV imread讀取進來的圖片格式之間的關係又是如何? 為此我必須先了解從HTML的input 標籤輸入的圖片至python端之間的格式與轉換。我整理出如下圖這個流程:
(1.) 由input標籤開啟的圖片為大型二進位檔物件(Binary Large Object ,blob),代表了一個相當於檔案(原始資料)的不可變物件。
(2.) 需實作js的FileReader物件,將blob轉碼成based64字串。
(3.) 透過eel框架傳送字串至Python端。
(4.) 使用based64處理套件將字串解析成byte陣列。
(5.) 透過numpy將byte陣列轉成uint8陣列。
(6.) 透過cv2套件將uint陣列解碼成圖片,此時得到的檔案形同於imread進來的圖片。
(7.) 使用圖片資料進行影像處理操作。
(8.) 將處理完的圖片回傳至前端作顯示時,逆著遵循前述的轉碼步驟,先將圖片轉成byte陣列,再以based64編碼成字串透過eel框架溝通回前端,由js接收並設定img標籤的src。
JS讀取圖片
使用jQuery偵測input標籤Change事件,搭配fileReader轉成base64字串。
$("#img").on("change", function () { var file = this.files[0]; const fr = new FileReader(); fr.onload = function (e) { preview.src = e.target.result; currentFile = e.target.result; console.log(e.target.result); }; if (file) { fr.readAsDataURL(file); } else { console.log("沒有檔案"); } console.log(file); });
Python接收圖片
將base64字串透過轉碼,轉成opencv可使用的格式。
def data_uri_to_cv2_img(uri): encoded_data = uri.split(',')[1] img = base64.b64decode(encoded_data) npimg = np.frombuffer(img, np.uint8) image = cv2.imdecode(npimg,cv2.IMREAD_COLOR) return image
搭配OpenCV
範例: 將圖片轉灰階
eel.expose(SetImg); function SetImg(newimg) { preview.src = "data:image/png;base64," + newimg } function Gray() { currentFile = preview.src; eel.toGray(currentFile); }
import base64 import random from cv2 import imread import eel import os import cv2 import numpy as np def data_uri_to_cv2_img(uri): encoded_data = uri.split(',')[1] img = base64.b64decode(encoded_data) npimg = np.frombuffer(img, np.uint8) image = cv2.imdecode(npimg,cv2.IMREAD_COLOR) return image @eel.expose def toGray(url): img = data_uri_to_cv2_img(url) img=cv2.cvtColor(img,cv2.COLOR_BGR2GRAY) #TODO:.....回傳 eel.init(f'{os.path.dirname(os.path.realpath(__file__))}/web') eel.start('main.html',mode ='chrome-app') #網頁 (app模式)
Python傳回圖片
def img_to_base64(img): b64_str = cv2.imencode('.png', img)[1].tobytes() blob = base64.b64encode(b64_str) blob = blob.decode('utf-8') return blob
直方圖
直方圖常常用在檢視圖片顏色趨勢、集中位置等等。 (一開始使用Chart.js插件)
js 端
// 直方圖 eel.expose(UpdateGarphData); function UpdateGarphData(graphID , b,g,r) { var labs= [Array.from({ length: 255 }).map((currentElement, i) => i)] const ctx = document.getElementById(graphID).getContext("2d"); const chart = new Chart(ctx, { type: "bar", data: { labels:labs[0], datasets: [ { label: "b", data: b, backgroundColor: "blue", }, { label: "g", data: g, backgroundColor: "green", }, { label: "r", data: r, backgroundColor: "red", }, ], }, }); }
@eel.expose def histGraph(url): img = data_uri_to_cv2_img(url) # 單色 if img.ndim !=3: b=cv2.calcHist( [img], [0], None, [256], [0,256] ) b= b.astype("float").flatten().tolist() # 彩色 else: b=cv2.calcHist( [img], [0], None, [256], [0,256] ) g=cv2.calcHist( [img], [1], None, [256], [0,256] ) r=cv2.calcHist( [img], [2], None, [256], [0,256] ) b= b.astype("float").flatten().tolist() g= g.astype("float").flatten().tolist() r= r.astype("float").flatten().tolist() eel.UpdateGarphData("histogram",b,g,r)()
注意np array無法直接傳輸,所以要轉成一般array。
瀏覽器無法像Python的numpy與的plot套件快速做統計與圖表,加上本身也不支援多線程(Multi-threading),因此當圖片解析度大,需要統計的數變多,前端直方圖生成便會延遲,除了採用異步執行避免程序卡死外,我還試過多個輕量圖表套件如Chart.js、dygraphs、EChart.js,尋找對資料最友善的解決方案,雖然目前前端直方圖生成仍有延遲存在,是日後需想辦法解決的問題(目前已是在Python生成直方圖數據,再傳輸至前端表現,理論上只需處理255*3個顏色資料,我認為會卡的點在於繪製與自動尋找最小至最大值區間)。
等化功能
等化是將像素顏色拉得平均一點。
@eel.expose def equalizeHist(url): img = data_uri_to_cv2_img(url) g=img.copy() #單色 if g.ndim!=3: g=cv2.equalizeHist( img ); #彩色 else: for i in range(0,3): g[:,:,i]=cv2.equalizeHist(img[:,:,i]) histGraphByImg(g) blob = img_to_base64(g) eel.SetImg(blob)()
操作Hot Key
註冊document事件,並藉由array紀錄各步驟的base64字串。
const maxSaves=10; const previousSaves = []; const nextSaves = []; //上一步 function BackToPreviouse() { if (previousSaves.length > 0) { base_str=previousSaves.pop(); preview.src = base_str; SaveNextStep(base_str) } } //下一步 function BackToNext(){ if (nextSaves.length > 0) { base_str=nextSaves.pop(); SaveStep(base_str); preview.src = base_str; } } //紀錄步驟 function SaveStep(bs64){ if(previousSaves.length<maxSaves){ previousSaves.push(bs64); } else{ //移除第一個 previousSaves.shift(); previousSaves.push(bs64); } } //紀錄返回上一步步驟 function SaveNextStep(bs64){ if(nextSaves.length<maxSaves){ nextSaves.push(bs64); } else{ //移除第一個 nextSaves.shift(); nextSaves.push(bs64); } } // ***************** 註冊 hot key ********************* function RegiseterHotKey(e) { //上一步 if (e.ctrlKey && e.key === 'z') { BackToPreviouse(); } //下一步 if (e.ctrlKey && e.key === 'x') { BackToNext(); } } document.addEventListener('keyup', RegiseterHotKey, false); // ***************** --- *********************
JS寫出圖片
// Save as async function SaveAs() { const opts = { types: [ { description: "Images", accept: { "image/*": [".png", ".gif", ".jpeg", ".jpg"], }, }, ], excludeAcceptAllOption: true, multiple: false, }; //base64 轉 blob const url= decodeURIComponent(preview.src).split(",")[1]; const imgBlob = Uint8Array.from(atob(url), c => c.charCodeAt(0)); console.log(imgBlob); const newHandel = await window.showSaveFilePicker(opts); const writableStream = await newHandel.createWritable(); // write our file await writableStream.write(imgBlob); // close the file and write the contents to disk. await writableStream.close(); }
拖拉圖片
//preview 畫布拖拉 $(function () { $("#preview").draggable(); }); //畫布滑鼠滾輪縮放 function zoom(event) { event.preventDefault(); scale += event.deltaY * -0.02; // Restrict scale scale = Math.min(Math.max(0.125, scale), 500); $("#preview_slider").val(scale).change(); } let scale = 1; const el = document.querySelector("#preview"); el.onwheel = zoom; el.addEventListener("wheel", zoom);
JS事件
在修改圖片時觸發事件,如此便能最後續處理,例如圖片縮圖僅需在圖片有更動時才須更新。
//修改圖片事件: $(document).on("mainImageChanged",function(e,eventInfo){ console.log(eventInfo); //..... }) //修改圖片 function SetImg(newimg) { preview.src = "data:image/png;base64," + newimg; //觸發事件 $(document).trigger("mainImageChanged",preview) }
顏色通道
圖片RGB拆成可以分別開關的通道,在P圖的時候常會從通道中選一張二值化效果最好的通道做遮罩,不過目前本軟體的通道只能顯示、不能編輯(;´༎ຶД༎ຶ`) (時間不夠..)。
@eel.expose def useChannel(url, isR, isG, isB): isR = bool(isR) isG = bool(isG) isB = bool(isB) img = data_uri_to_cv2_img(url) g = np.zeros(img.shape) if isR: g[:, :, 2] = img[:, :, 2] if isB: g[:, :, 0] = img[:, :, 0] if isG: g[:, :, 1] = img[:, :, 1] blob = img_to_base64(g) eel.SetImg(blob)()
在地化
加上為了降低開發環境複雜度,本軟體前端使用較原生的JQuery輔助,沒有用如React.js等完整的web生態系統,能使用的工具較為受限,最後使用行之有名的i18n在地化解決方案應付小需求翻譯。
$.i18n({ //不要打預設語系就會自動抓瀏覽器語言 //locale: "zh-TW", //locale: "en", }); $.i18n() .load({ en: "/i18n/en.json", 'zh-TW': "/i18n/zh-TW.json", }) .done(function () { $('body').i18n(); }); function SetLanguage(lang){ $.i18n().locale = lang; $('body').i18n(); }
簡易圖層
<!-- 混和圖片 --> <div class="opt_btns_container"> <div class="opt_bar">加入圖片</div> <!-- 欲加入的圖片 --> <div class="side_small_preview_container" id="overlay"> <img class="channel_preview_img" id="newLayerImage" src="https://react.semantic-ui.com/images/wireframe/image.png"> <script> createSelect("overlay", "overlayMethod", "混合模式", [ { val: "normal", text: "正常" }, { val: "brigher", text: "變亮" }, { val: "darken", text: "變暗" }, ]) </script> <!-- 加入按鈕 --> <label for="layerImgInput"> <img class="toggleImg" id="ct_b" isToggled="true" onclick="LoadLayerImage(this)" src="./img/eye.png"></img> </label> <input type="file" id="layerImgInput" name="layerImgInput" hidden> <button class="opt_apply_btn" onclick="OverlayerImage()">套用</button> </div> </div>
HTML操作按鈕
//疊圖層 function OverlayerImage(){ b64_str = realImage; layer_b64_str =newLayerImage.src ; opt=overlayMethod.value; SaveStep(b64_str); eel.overlayImage(b64_str,layer_b64_str,opt) }
def overlayImage(url,layerUrl,opt): img = data_uri_to_cv2_img(url) img_overlay = data_uri_to_cv2_img(layerUrl) h, w = img_overlay.shape[:2] hh, ww = img.shape[:2] yoff = np.clip( round((hh-h)/2),0,hh) xoff =np.clip( round((ww-w)/2),0,ww) yMax= np.clip(yoff+h , 0 , hh) xMax= np.clip(xoff+w , 0 , ww) print(yoff,xoff , "|",yMax,xMax) xMin= min(ww,w) yMin= min(hh,h) result = img.copy() result[yoff:yMax, xoff:xMax] = img_overlay[:yMin,:xMin] #避免取出邊界 blob = img_to_base64(result) eel.SetImg(blob, false, false)()
小優化 — 副檔名溝通
原本以png回傳,但圖片大小因此暴增10倍
# 圖片轉base64 def img_to_base64(img): b64_str = cv2.imencode(".png", img)[1].tobytes() blob = base64.b64encode(b64_str) blob = blob.decode('utf-8') print("process Time --- %s seconds ---" % (time.time() - start_time)) return blob
解決辦法:互相溝通檔案型態
if (file) { fr.readAsDataURL(file); console.log(file); currentFileExtension =file.type.split("/")[1]; //更新 footer KB數 $("#fileSize_text").html(Math.round(file.size / 1024) + "KB"); //設定副檔名 eel.setFileExtension(currentFileExtension) } else { console.log("沒有檔案"); alert("不支援檔案類型"); }
@eel.expose def setFileExtension(ext): global current_file_extension current_file_extension="."+ext
【補充】
Base64 轉法造成卡頓?
原本以為卡頓問題是反覆的base64轉碼造成的。
import time image ="" # base64轉np image def data_uri_to_cv2_img(uri, forceUpdate=false): global image start_time = time.time() #print(image) if image!="" and not forceUpdate: return image uri_spt = uri.split(',') if(len(uri_spt)>1): encoded_data=uri_spt[1] else: encoded_data=uri_spt[0] img = base64.b64decode(encoded_data) npimg = np.frombuffer(img, np.uint8) image = cv2.imdecode(npimg, cv2.IMREAD_COLOR) print("base 64 decode Time --- %s seconds ---" % (time.time() - start_time)) return image
做了點log發現轉碼所需時間不長,主要問題是Chart.js插件效能問題。(javascript – ChartJS is slow – Stack Overflow)
base 64 decode Time --- 0.010005474090576172 seconds --- base 64 decode Time --- 0.007999897003173828 seconds --- base 64 decode Time --- 0.008002042770385742 seconds --- base 64 decode Time --- 0.007974386215209961 seconds --- base 64 decode Time --- 0.007002353668212891 seconds --- base 64 decode Time --- 0.0069997310638427734 seconds --- base 64 decode Time --- 0.00800013542175293 seconds --- base 64 decode Time --- 0.007972240447998047 seconds --- base 64 decode Time --- 0.007972240447998047 seconds ---
ERROR : WebSocket connection to ‘ws://localhost:8000/eel?page=main.html’ failed: Could not decode a text frame as UTF-8
https://github.com/python-eel/Eel/issues/299
在js呼叫另一個eel方法時,上一個還沒結束產生的ERROR。
打包輸出exe檔案
*EelPoriect拼錯字
cd src python -m eel app.py web --- python -m eel ./src/app.py ./src/web