Python tkinter实现图片标注

安装依赖

$ python -m pip install --upgrade pip --user

$ python -m pip install Pillow --user

$ python -m pip install Numpy --user

$ python -m pip install OpenCV-Python --user

源代码:

#!/usr/bin/python
# -*- coding: UTF-8 -*-

import os
import sys
if sys.version_info < (3, 0):  
    import Tkinter as tk  # 导入 Tkinter 库
    from tkFileDialog import askopenfilename, askdirectory
else :
    import tkinter as tk  # 导入 Tkinter 库
    from tkinter.filedialog import askopenfilename, askdirectory
from PIL import Image, ImageTk, ImageDraw
from time import sleep
import numpy as np
import cv2 as cv
import collections

DEF_WIDTH = 1080
DEF_HEIGHT = 720
IMAGE_HEIGHT = 720
FRAME_LEFT_WIDTH = 360
# 太小的选定区域我们需要丢弃,防止误操作
MINI_RECT_AREA = 20 

class RawImageEditor:

    def __init__(self, win, img, rects):
        #变量X和Y用来记录鼠标左键按下的位置
        self.X = tk.IntVar(value=0)
        self.Y = tk.IntVar(value=0)

        self.sel = False
        self.lastDraw = None
        self.lastDraws = []
        self.imageScale = 1.0
        self.dispWidth = DEF_WIDTH  # 图片显示区域的最大高度,宽度
        self.dispHeight = DEF_HEIGHT
        self.rawImage = img
        self.calcImageScale(self.rawImage)
        self.dispWidth = int(self.imageScale * self.rawImage.width)
        self.dispHeight = int(self.imageScale * self.rawImage.height)
        # 图片缩放
        self.dispImage = self.rawImage.resize((self.dispWidth, self.dispHeight))
        # 选择区域
        self.selPositions = []
        for r in rects :
            self.selPositions.append((r[0] * self.imageScale, r[1] * self.imageScale, r[2] * self.imageScale, r[3] * self.imageScale))
        #创建顶级组件容器
        self.top = tk.Toplevel(win, width=self.dispWidth, height=self.dispHeight)
        #不显示最大化、最小化按钮
        self.top.overrideredirect(True)
        #center window on desktop
        nScreenWid, nScreenHei = self.top.maxsize()
        winPos = '%sx%s+%s+%s' % (int(self.top.winfo_reqwidth()), int(self.top.winfo_reqheight()), int((nScreenWid - self.top.winfo_reqwidth())/2), int((nScreenHei - self.top.winfo_reqheight())/2))
        self.top.geometry(winPos)
        # Make topLevelWindow remain on top until destroyed, or attribute changes.
        self.top.attributes('-topmost', 'true')
        self.canvas = tk.Canvas(self.top, bg='white', width=self.dispWidth, height=self.dispHeight)

        self.tkImage = ImageTk.PhotoImage(self.dispImage)
        self.canvas.create_image(self.dispWidth//2, self.dispHeight//2, image=self.tkImage)
        for r in self.selPositions :
            draw = self.canvas.create_rectangle(r[0], r[1], r[2], r[3], outline='green')
            self.lastDraws.append(draw)

        #鼠标左键按下的位置
        def onLeftButtonDown(event):
            self.X.set(event.x)
            self.Y.set(event.y)
            #开始截图
            self.sel = True

            #重新绘制已经选择的区域
            for draw in self.lastDraws :
                self.canvas.delete(draw)
            self.lastDraws = []
            for r in self.selPositions :
                draw = self.canvas.create_rectangle(r[0], r[1], r[2], r[3], outline='green')
                self.lastDraws.append(draw)

        self.canvas.bind('<Button-1>', onLeftButtonDown)
        #鼠标左键移动,显示选取的区域
        def onLeftButtonMove(event):
            if not self.sel:
                return
            try:
                #删除刚画完的图形,要不然鼠标移动的时候是黑乎乎的一片矩形
                self.canvas.delete(self.lastDraw)
            except Exception as e:
                pass
            self.lastDraw = self.canvas.create_rectangle(self.X.get(), self.Y.get(), event.x, event.y, outline='green')
        self.canvas.bind('<B1-Motion>', onLeftButtonMove)
        #获取鼠标左键抬起的位置,保存区域截图
        def onLeftButtonUp(event):
            if not self.sel:
                return
            self.sel = False
            sleep(0.1)
            #考虑鼠标左键从右下方按下而从左上方抬起的截图
            left, right = sorted([self.X.get(), event.x])
            top, bottom = sorted([self.Y.get(), event.y])
            if (right - left) * (bottom - top) > MINI_RECT_AREA :
                self.selPositions.append((left,top,right,bottom))
            #self.top.destroy()
        
        #鼠标右键按下
        def onRightButtonDown(event):
            self.sel = False
            self.top.destroy()
        self.canvas.bind('<Button-2>', onRightButtonDown)

        self.canvas.bind('<ButtonRelease-1>', onLeftButtonUp)
        self.canvas.pack(fill=tk.BOTH, expand=tk.YES)

    def calcImageScale(self, image) :
        w = image.width
        h = image.height
        self.imageScale = 1.0
        # 计算最小的缩放比例,保证原始宽高比
        if w > self.dispWidth and h > self.dispHeight :
            ws = self.dispWidth * 1.0 / w
            hs = self.dispHeight * 1.0 / h
            if ws < hs :
                self.imageScale = ws
            else :
                self.imageScale = hs
        elif w > self.dispWidth and h < self.dispHeight :
            self.imageScale = self.dispWidth * 1.0 / w
        elif w < self.dispWidth and h > self.dispHeight :
            self.imageScale = self.dispHeight * 1.0 / h

    def waitForWindow(self, win) :            
        win.wait_window(self.top)

    def selectedPositions(self) : 
        # 转换为原始像素位置
        realPos = []
        for r in self.selPositions :
            realPos.append((r[0] / self.imageScale, r[1] / self.imageScale, r[2] / self.imageScale, r[3] / self.imageScale))
        return realPos      

class MainWin(tk.Tk):

    def __init__(self):
        if sys.version_info >= (3, 0):
            super().__init__()
        else : 
            tk.Tk.__init__(self)
        self.title('图像处理工具')
        self.geometry('{}x{}'.format(DEF_WIDTH, DEF_HEIGHT))
        self.rawImagePath = ''
        self.rawImage = None  # self.rawImage 原始图像,未经过缩放处理
        self.transRawImage = None  # self.transRawImage 经过转换处理之后的原始图像,没有经过缩放处理
        self.dispImage = None  # self.dispImage 显示图像,可能经过缩放处理
        self.imageScale = 1.0  # 图片缩放比例,根据缩放比例进行显示的时候的缩放处理,后期选择区域的时候,需要进行缩放还原
        self.leftFrameWidth = FRAME_LEFT_WIDTH
        self.frameDispHeight = DEF_HEIGHT # 整个窗口的高度
        self.labelTextHeight = 18 # 文本标签的高度
        self.btnHeight = 30 # 按钮的高度
        self.brightnessScale = tk.StringVar() # 亮度比
        self.brightnessScale.set('1.0')
        self.defSavePath = '' # 默认保存路径
        self.imageDispWidth = IMAGE_HEIGHT  # 图片显示区域的最大高度,宽度
        self.imageDispHeight = self.frameDispHeight / 2 - self.labelTextHeight * 2
        self.liRect = collections.OrderedDict() # 选择区域
        self.rawImageEditor = None
        self.currentListBoxSelIdx = None #当前选择的项目

        self.setupUI()

    def scaleDisplayImage(self, image) :
        w = image.width
        h = image.height
        self.imageScale = 1.0
        # 计算最小的缩放比例,保证原始宽高比
        if w > self.imageDispWidth and h > self.imageDispHeight :
            ws = self.imageDispWidth * 1.0 / w
            hs = self.imageDispHeight * 1.0 / h
            if ws < hs :
                self.imageScale = ws
            else :
                self.imageScale = hs
        elif w > self.imageDispWidth and h < self.imageDispHeight :
            self.imageScale = self.imageDispWidth * 1.0 / w
        elif w < self.imageDispWidth and h > self.imageDispHeight :
            self.imageScale = self.imageDispHeight * 1.0 / h
        # 图片缩放
        return image.resize((int(self.imageScale * w), int(self.imageScale * h)))

    def loadImageCfgFile(self, imf):
        (path, name) = os.path.split(imf)
        cfgname = imf + '.txt'
        if (not os.path.exists(cfgname)) or (not os.path.isfile(cfgname)) :
            cfgname = os.path.join(path, 'mask', name) + '.txt'
        if (not os.path.exists(cfgname)) or (not os.path.isfile(cfgname)) :
            cfgname = os.path.join(path, 'cfg', name) + '.txt'            
        self.liRect.clear()
        if os.path.exists(cfgname) and os.path.isfile(cfgname) :
            with open(cfgname, "r") as f:
                lines = f.readlines()
                for line in lines:
                    rs = line.split(',')
                    r = (float(rs[0].strip()), float(rs[1].strip()), float(rs[2].strip()), float(rs[3].strip()))
                    if len(rs) > 4 :
                        self.liRect[r] = rs[4].strip()
                    else :
                        self.liRect[r] = ''

    # 打开图片时使用,传值(图)给展示函数
    def openAndDisplayImage(self):
        imF = self.selectImageFile()
        if '' != imF :
            self.rawImagePath = imF
            self.loadImageCfgFile(self.rawImagePath)
            self.drawListBox()
            self.rawImage = Image.open(self.rawImagePath)    
            self.rawImage = self.rawImage.convert('RGBA')
            self.drawRawImageDisp()
            self.image_l_trans.image = None
            self.transRawImage = None

    def drawListBox(self):
        self.l_box.delete(0, tk.END)
        for r in self.liRect.keys():
            r = '{},{},{},{}'.format(round(r[0],1), round(r[1],1), round(r[2],1), round(r[3],1))
            self.l_box.insert(0, r)

    def drawRawImageDisp(self, selItems=[]):            
        self.dispImage = self.scaleDisplayImage(self.rawImage)
        self.dispImage = self.dispImage.convert('RGB')
        draw = ImageDraw.Draw(self.dispImage)
        rs = list(self.liRect.keys())
        for i in range(len(rs)) :
            r = rs[i]
            if i in selItems :
                draw.rectangle((r[0] * self.imageScale, r[1] * self.imageScale, r[2] * self.imageScale, r[3] * self.imageScale), outline = "red")
            else :
                draw.rectangle((r[0] * self.imageScale, r[1] * self.imageScale, r[2] * self.imageScale, r[3] * self.imageScale), outline = "green")
        img = ImageTk.PhotoImage(self.dispImage)
        self.image_l_raw.config(image=img)
        self.image_l_raw.image = img

    def deleteSelectedItemFromListBox(self):
        #print(self.l_box.get(self.l_box.curselection()))
        idx = self.l_box.curselection()
        if len(idx) > 0 and (None == self.rawImageEditor) :
            ro = collections.OrderedDict()
            rs = self.liRect.keys()
            for i in range(len(rs)) :
                if i not in idx :
                    r = rs[i]
                    ro[r] = self.liRect[r]
            self.liRect = ro
            self.drawListBox() 
            self.drawRawImageDisp()  

    # 打开图片时使用,获得地址
    def selectImageFile(self):
        path = tk.StringVar()
        file_entry = tk.Entry(self, state='readonly', text=path)
        path_ = askopenfilename()
        path.set(path_)
        return file_entry.get()

    def rawImageLabelClicked(self, event):
        if (None != self.rawImage) and (None == self.rawImageEditor) :
            self.rawImageEditor = RawImageEditor(self, self.rawImage, self.liRect.keys())
            self.rawImageEditor.waitForWindow(self.image_l_raw)
            rs = self.rawImageEditor.selectedPositions()
            trs = collections.OrderedDict()
            for k in rs :
                if k in self.liRect :
                    trs[k] = self.liRect[k]
                else:
                    trs[k] = ''
            self.liRect = trs
            self.rawImageEditor = None
            self.drawListBox()
            self.drawRawImageDisp()

    def onRectListboxSelect(self, event):
        idx = self.l_box.curselection()
        if len(idx) > 0 and (self.currentListBoxSelIdx != idx) :
            self.currentListBoxSelIdx = idx
            ctx = ''
            rs = list(self.liRect.keys())
            for i in range(len(self.currentListBoxSelIdx)) :
                v = self.currentListBoxSelIdx[i]
                k = rs[v]
                ctx = ctx + self.liRect[k]
                if i < len(self.currentListBoxSelIdx)-1 :
                    ctx = ctx + ','
            self.edtListBoxSel.delete(0, tk.END)
            self.edtListBoxSel.insert(0, ctx)
            self.drawRawImageDisp(idx)

    def onEdtListBoxComplete(self, event):
        idx = self.l_box.curselection()
        if (len(idx) > 0) and (self.currentListBoxSelIdx == idx) :
            ctx = self.edtListBoxSel.get().strip()
            ctx = ctx.split(',')
            while len(idx) > len(ctx) :
                ctx.append('')
            rs = list(self.liRect.keys())
            n = 0
            for i in idx :
                r = rs[i]
                self.liRect[r] = ctx[n]
                n = n + 1        

    def drawTransImageDisp(self):            
        transImage = self.scaleDisplayImage(self.transRawImage)
        transImage = transImage.convert('L')
        img = ImageTk.PhotoImage(transImage)
        self.image_l_trans.config(image=img)
        self.image_l_trans.image = img

    def doTransRawImage(self):
        self.transRawImage = Image.new('L', (self.rawImage.width, self.rawImage.height))
        rs = self.liRect.keys()
        for r in rs :
            im = self.rawImage.crop(r)
            cv_im = cv.cvtColor(np.asarray(im), cv.COLOR_RGB2BGR)
            hsv = cv.cvtColor(cv_im, cv.COLOR_BGR2HSV)
            _, _, v = cv.split(hsv)
            avg = np.average(v.flatten())
            pixels = im.load()
            scale = float(self.brightnessScale.get())
            for j in range(im.height) :
                for i in range(im.width) :
                    hv = v[j,i]
                    if hv < avg * scale:
                        #im.putpixel((i, j), 0) #
                        pixels[i, j] = 0
                    '''else :
                        im.putpixel((i, j), (255, 255, 255, 255))'''
            self.transRawImage.paste(im, (int(r[0]),int(r[1])), mask = None) 

        self.drawTransImageDisp()

    def onTransRawImageBtnClicked(self):
        if None != self.rawImage :
            self.doTransRawImage()

    def saveTransCfg(self, pth):
        (path,name) = os.path.split(self.rawImagePath)
        cfg = os.path.join(pth, 'cfg')
        if os.path.exists(cfg) and os.path.isdir(cfg) :
            cfgname = os.path.join(cfg, name) + '.txt'
        else :
            cfgname = os.path.join(pth, name) + '.txt'
        with open(cfgname, "w") as f:
            for r in self.liRect.keys() :
                v = self.liRect[r]
                line = '{},{},{},{},{}\n'.format(r[0], r[1], r[2], r[3], v)
                f.write(line) 

    def saveCombinedImage(self, pth):
        im_A = cv.cvtColor(np.array(self.rawImage), cv.COLOR_RGB2BGR)
        im_B = cv.cvtColor(np.array(self.transRawImage), cv.COLOR_RGB2BGR)
        im_AB = np.concatenate([im_A, im_B], 1)
        ext = os.path.splitext(self.rawImagePath)[-1]
        ext = ext.lower()
        name = os.path.basename(self.rawImagePath)
        sp = name.split('.')[:-1]
        comb = os.path.join(pth, 'comb')
        if os.path.exists(comb) and os.path.isdir(comb) :
            name = sp[0] + ext
            pth = comb
        else :
            name = sp[0] + '_comb' + ext
        path_AB = os.path.join(pth, name)
        cv.imwrite(path_AB, im_AB)

    def saveTransImage(self, path):
        ext = os.path.splitext(self.rawImagePath)[-1]
        ext = ext.lower()
        (path,name) = os.path.split(self.rawImagePath)
        mask = os.path.join(path, 'mask')
        fn = name.split('.')[:-1]
        if os.path.exists(mask) and os.path.isdir(mask) : 
            fn = fn[0] + ext
        else :
            fn = fn[0] + '_mask' + ext
        im = os.path.join(mask, fn)
        if not im.endswith(ext) :
            im = im + ext
        self.transRawImage.save(im)

    def saveTransInformation(self, path):
        if '' != path :
           self.saveTransImage(path)
           self.saveTransCfg(path)
           self.saveCombinedImage(path)

    def onSaveTransRawImageBtnClicked(self):
        if None != self.transRawImage :
            (path,name) = os.path.split(self.rawImagePath)
            dirname = askdirectory(initialdir = os.path.expanduser(path), title = '保存结果')
            if '' != dirname :
                self.defSavePath = dirname
                self.saveTransInformation(self.defSavePath)

    def onDefaultSaveTransRawImageBtnClicked(self) :
        if (None != self.transRawImage) and ('' != self.defSavePath) :
            self.saveTransInformation(self.defSavePath)

    def setupUI(self):
        # 左边菜单栏
        left_f = tk.Frame(self, height=self.frameDispHeight, width=self.leftFrameWidth)
        left_f.pack(side=tk.LEFT)

        # 各种功能按钮名称及位置
        btnOpen = tk.Button(left_f, text='打开图像', command=self.openAndDisplayImage)
        btnOpen.place(y=25, x=30, width=300, height=self.btnHeight)
        btnTrans = tk.Button(left_f, text='处理图像', command=self.onTransRawImageBtnClicked)
        btnTrans.place(y=75, x=30, width=300, height=self.btnHeight)

        l_selRect = tk.Label(left_f, text = '鼠标选定区域')
        l_selRect.place(x=0, y=125, width=self.leftFrameWidth, height=self.labelTextHeight)
        
        '''列表'''
        self.l_box = tk.Listbox(left_f, exportselection=False)  # 创建两个列表组件
        self.l_box.place(x=1, y=125+self.labelTextHeight, width=self.leftFrameWidth-2, height=270)
        self.l_box.bind('<<ListboxSelect>>', self.onRectListboxSelect)
        self.drawListBox()

        # 选中列表注释
        ledt = tk.Label(left_f, text = '选中区域内容注释(回车结束):', anchor = 'w')
        ledt.place(x=1, y=420, width=self.leftFrameWidth-2)
        self.edtListBoxSel = tk.Entry(left_f)
        self.edtListBoxSel.place(x=1, y=440, width=self.leftFrameWidth-2)
        self.edtListBoxSel.bind('<Return>', self.onEdtListBoxComplete)

        # 亮度比例调节 
        tkScale = tk.Scale(left_f, from_=0.2, to=1.8, orient=tk.HORIZONTAL, resolution=0.1, length=self.leftFrameWidth-2, variable=self.brightnessScale, label = '亮度比:')
        tkScale.place(x=1, y=480)

        # 删除选定项
        btnDel = tk.Button(left_f, text='删除选定项', command=self.deleteSelectedItemFromListBox)
        btnDel.place(y=550, x=30, width=300, height=self.btnHeight)

        btnSave = tk.Button(left_f, text='保存结果', command=self.onSaveTransRawImageBtnClicked)
        btnSave.place(y=600, x=30, width=300, height=self.btnHeight)

        btnDefSave = tk.Button(left_f, text='默认或上次位置保存', command=self.onDefaultSaveTransRawImageBtnClicked)
        btnDefSave.place(y=650, x=30, width=300, height=self.btnHeight)

        # 右侧图像显示栏
        right_f = tk.Frame(self, height=self.frameDispHeight, width=self.imageDispWidth)
        right_f.pack(side=tk.RIGHT)

        l_rawT = tk.Label(right_f, text = '原始图片')
        l_rawT.place(x=0, y=0, width=self.imageDispWidth, height=self.labelTextHeight)

        self.image_l_raw = tk.Label(right_f, relief='ridge')
        self.image_l_raw.place(x=0, y=self.labelTextHeight, width=self.imageDispWidth, height=self.imageDispHeight)
        self.image_l_raw.bind("<Button-1>",self.rawImageLabelClicked)

        l_transT = tk.Label(right_f, text = '处理后图片')
        l_transT.place(x=0, y=self.labelTextHeight + self.imageDispHeight, width=self.imageDispWidth, height=self.labelTextHeight)

        self.image_l_trans = tk.Label(right_f, relief='ridge')
        self.image_l_trans.place(x=0, y=self.labelTextHeight + self.imageDispHeight + self.labelTextHeight, width=self.imageDispWidth, height=self.imageDispHeight)

if __name__ == '__main__' :
    win = MainWin()
    # 进入消息循环
    win.mainloop()

参考链接