前言
個人半學期的期末報告專案,因為想做的更好,所以自行研究了 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



