【徵文賞-動態網頁】優選|使用前端技術製作自己的修圖軟體吧(Python + OpenCV + eel) – 林慶佳

前言

個人半學期的期末報告專案,因為想做的更好,所以自行研究了 Python 結合前端介面技術的幾個框架。

建議先備知識

  1. 前端開發經驗。
  2. Python 使用經驗。
  3. 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
jpg編碼檔案大小: 120KB
png檔案變成 1MB

解決辦法:互相溝通檔案型態

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檔案

Eel is a little Python library for making simple Electron-like offline HTML/JS GUI apps, with… (curatedpython.com)

*EelPoriect拼錯字

cd src
python -m eel app.py web

---
python -m eel ./src/app.py ./src/web
PHP Code Snippets Powered By : XYZScripts.com