添加链接
link之家
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接
Python简单的批量校色工具

Python简单的批量校色工具

很久很久以前,老大给了个任务,因为里面照片扫描(Photogrammetry)用的越来越多, 我们需要一个能保证扫描出来的模型拥有比较精准的固有色,要写一个批量校色的小工具集成到照片扫描工具里。

结果最近其他工作室也要类似的功能,就有把之前的脚本翻出来写了个小工具, 发现这个脚本里面还是有一些比较有用的东西,就干脆发上来提供给有需要的人( 其实我也是抄的别人的~~~ )。


首先,校色(color-correcting)不是调色(color-grading),并不是为了调出好看的效果而执行的步骤,是为了尽可能的保证在不同的光照环境, 不同的拍摄设置下,依旧能够得到相对准的颜色的步骤。

既然目的是得到准确的颜色,那么必然要有一个颜色的标准值,我们项目中使用的是 X-Rite ColorChecker Passport

就是这个玩意:

玩摄影的朋友们一定很熟悉这个家伙,拍摄之前先用这个进行一下白平衡校准啥的,这里就不细讲了,我们的流程是对拍摄后的照片进行校色。


拍摄流程:

每拍摄一组照片之前, 在与被拍摄物体较为接近的光照条件下拍摄一张校色卡 ,然后保持相机参数不变,拍摄物件。

项目里的图片不能放上来,就大概想象一下吧:

主要拍摄红框里面那部分,我们的规则是这 部分至少要占图片的30%以上的面积 ,ruo实际使用中发现拍小点也行。

那么照片拍好了,我们的思路是:

1,把拍有色卡的那张照片的色卡颜色值提取出来,也即是 拍摄颜色值

2,把这一组 拍摄颜色值 标准色卡值 进行比较并校色,检测校色之后的值偏差是否足够小。

3,利用 拍摄颜色值 对接下来同一组照片进行校色。

标准色卡颜色值 (RGB-space, 实际校色是使用的是XYZ-space):


思路理清楚了, 我么看是看代码,显目里面的用的python:

首先是提取色卡颜色值,我们这里用了一个叫做colour-checker-detection 的库来检测色卡的位置与颜色:

如果是手动校色的话,这一步通常使用一个叫做: ColorChecker Camera Calibration v2.2.0 的工具来生成一个profile, 然后通过其他软件进行校色,实际使用中感觉实在太麻烦,不能自动化,还经常出来颜色不对,我们就没有采用。

def getColorCorrectionSwatches(colorChecker,cacheFolder):
    # we use no brighten by default
    bNoAutoBrighten = True
    if not os.path.exists(colorChecker):
        return False, False, []
    #利用rawpy将raw文件转换成tiff文件格式方便校色
    colorCheckerTiff = convert2sRGBTiff(colorChecker, cacheFolder)
    #First blur the image to get rid of noise in colorchecker
    #对有色卡的照片进行模糊操作, 这样能去点部分噪点达到更好的检测
    print("blurring " + colorCheckerTiff)
    blurtiff = os.path.join(cacheFolder, os.path.splitext(os.path.basename(colorChecker))[0]+ "_blur" + '.tiff')
    img = PythonMagick.Image(colorCheckerTiff)
    img.blur(0,10)
    img.write(blurtiff)
    #Retrieve the color correction swatch values from the given image
    print(f"Detecting color checker in {blurtiff}")
    #将图片中的非线性R'G'B'值转换成线性的RGB值
    image = colour.cctf_decoding(colour.io.read_image(blurtiff))
    #检测图片中的色卡值
    swatches = detect_colour_checkers_segmentation(image)
    if len(swatches) < 1:
        return False, False, []
    #对每个检测到的色卡值进行校色,并返回结果最准确的那个。
    Vresult, swatch = VerifyColorSwatches(swatches)
    return Vresult, bNoAutoBrighten, swatch
def VerifyColorSwatches(swatches):
    deviation = []
    #标准色卡颜色值
    rgb_RCCL = colour.XYZ_to_RGB(colour.xyY_to_XYZ(list(REFERENCE_COLOUR_CHECKER.data.values())),
        D65, D65, colour.RGB_COLOURSPACES['sRGB'].matrix_XYZ_to_RGB)
    for swatch in swatches:
        swatch_cc = colour.colour_correction(swatch, swatch, REFERENCE_SWATCHES)
        CCL = swatch_cc
        totalsum = 0.0
        for i in range(len(rgb_RCCL)):
            totalsum += abs(rgb_RCCL[i][0]-CCL[i][0]) + abs(rgb_RCCL[i][1]-CCL[i][1]) + abs(rgb_RCCL[i][2]-CCL[i][2])
        deviation.append(totalsum)
    print("swatches deviations: "+str(deviation))
    min_d = 100.0
    min_i = -1
    for i in range(len(deviation)):
        if deviation[i] < min_d:
            min_d = deviation[i]
            min_i = i
    print("The lowest devi is: " + str(min_d))
    result = False
    #通常插值和在8.0一下都可以接受
    if min_d < 3.0:
        print("devi is good ! ")
        result = True
    elif min_d < 5.0:
        print("devi is near average ! ")
        result = True
    elif min_d < 8.0:
        print("devi is near BAD ! the COLORS will be off!!!!")
        result = True
    else:
        print("devi is BAD ! the Swatches are completely off!!!!")
        result = False
    return result, swatches[min_i]
def convert2sRGBTiff(file, outfolder, bNo_Auto_Bright = True):
    Convert ALL to tiff in sRGB space
    tiffFile = ""
    extention = file.rsplit(".",1)[-1]
    tiffFile = os.path.join(outfolder, os.path.splitext(os.path.basename(file))[0] + '.tiff')
    #对于非raw格式,只做一个简单的转换
    if (extention == "jpg" or extention == "JPG" or
        extention == "png" or extention == "PNG" or
        extention == "tga" or extention == "TGA"):
        im = imageio.imread(file)
        imageio.imsave(tiffFile, im)
        return tiffFile
    #对raw格式,设置为不裁剪高光,不自动加亮,使用默认白平衡(校色会自带白平衡), 使用sRGB gamma
    else:
        try:
            raw = rawpy.imread(file)
            rgb = raw.postprocess(highlight_mode=0, no_auto_bright=bNo_Auto_Bright, use_camera_wb=True, gamma=(2.4, 12.92))
            tiffFile = os.path.join(outfolder, os.path.splitext(os.path.basename(file))[0] + '.tiff')
            imageio.imsave(tiffFile, rgb)
            return tiffFile
        except:
            print("Unsupported Format!!")
            return False

现在我们得到了一个较为准确的 拍摄颜色值( swatch

我们可以利用它对每张图片进行校色,一个loop就行, 因为校色过程比较慢,也可以使用多线程,或者直接丢到渲染及上面去(推荐,项目里面是用的一个8台机器的小农场,同时也是用来搞照片扫描的)

核心部分大概是这样的:

    for file in files:
        #利用rawpy将raw文件转换成tiff文件格式方便校色
        tifffile = convert2sRGBTiff(file, CacheDir)
        print("ColorCorrecting: "+tifffile)
        #将图片中的非线性R'G'B'值转换成线性的RGB值
        image = colour.cctf_decoding(colour.io.read_image(os.path.join(CacheDir, tifffile)))
        #使用拍摄颜色值(swatch)进行校色
        cc_image = colour.colour_correction(image, swatch, REFERENCE_SWATCHES, 'Finlayson 2015')
        #这里将校色完成的图片存为32位图像
        tiff_CC_32 = os.path.join(CacheDir, os.path.splitext(os.path.basename(file))[0] + "_CC" +'.tiff')
        #将图片中的线性的RGB值值转换成非线性R'G'B'
        colour.io.write_image(colour.cctf_encoding(cc_image), tiff_CC_32)# write out 32bit image
        print("Save CC image to jpg: "+file)
        cctifffile = os.path.join(CacheDir, os.path.splitext(os.path.basename(file))[0] + '.tiff')
        #ccpngfile = os.path.join(folderpath,'ColorCorrected', os.path.splitext(os.path.basename(file))[0] + '.tiff')
        #转换为8位图片以节约空间
        img = PythonMagick.Image(tiff_CC_32)
        img.depth(8)
        img.write(cctifffile)
        #项目要求转换为JPG并缩放尺寸
        Final_ccJpgfile = os.path.join(colorCorrectFolder, os.path.splitext(os.path.basename(file))[0] + '.jpg')
        img = Image.open(cctifffile)
        """disable resize
        if max(img.size[0], img.size[1]) > DestFrameSize:
            ResizePercent = DestFrameSize/float(max(img.size[0], img.size[1]))
            img = img.resize((int(img.size[0]*ResizePercent), int(img.size[1]*ResizePercent)), 5)
        img.save(Final_ccJpgfile, quality=100, subsampling=0)

到这一步,照片已经校色完毕了,但是有一部分软件对照片要求metadata,就是拍摄相机类型,焦距,ISO, 快门速度那一类的信息。这些信息通常是内置在照片里的,但是我们这么转换来转换去的,这部分信息就丢了,现在我们利用 exiftool 这个工具把信息都传递过来:

exiftoolPath = "exiftool.exe -m -overwrite_original_in_place -tagsFromFile"
def TransferMetaData(SourceFile, DistFile):
    command = exiftoolPath
    command += " "+ SourceFile
    command += " "+ DistFile
    os.system(command)


大功告成!!

现在输出的照片便是较好色的图片,用来做扫面还是贴图随你便,如果不放心的见可以进PS吸一下颜色和标准色对比~~~~


完整的小工具代码:

import os, array
import shutil, glob
import multiprocessing
import sys, time
import tkinter as tk
import tkinter.filedialog
import threading
import colour
from PIL import Image
from colour_checker_detection import detect_colour_checkers_segmentation
import imageio, rawpy
import PythonMagick
from collections import OrderedDict
from OpenImageIO import ImageOutput, ImageInput
D65 = colour.CCS_ILLUMINANTS['CIE 1931 2 Degree Standard Observer']['D65']
REFERENCE_COLOUR_CHECKER = colour.CCS_COLOURCHECKERS['ColorChecker24 - After November 2014']
REFERENCE_SWATCHES = colour.XYZ_to_RGB(
    colour.xyY_to_XYZ(list(REFERENCE_COLOUR_CHECKER.data.values())),
    REFERENCE_COLOUR_CHECKER.illuminant, D65,
    colour.RGB_COLOURSPACES['sRGB'].matrix_XYZ_to_RGB)
exiftoolPath = "exiftool.exe -m -overwrite_original_in_place -tagsFromFile"
#Global variables
ColorChecker = ""
PhotosDir = ""
OutPutDir = ""
#ProcessNum = 4
status = ""
bBusy = False
PhotoNum = 0
def getColorCorrectionSwatches(colorChecker,cacheFolder):
    # we use no brighten by default
    bNoAutoBrighten = True
    if not os.path.exists(colorChecker):
        return False, False, []
    #利用rawpy将raw文件转换成tiff文件格式方便校色
    colorCheckerTiff = convert2sRGBTiff(colorChecker, cacheFolder)
    #First blur the image to get rid of noise in colorchecker
    #对有色卡的照片进行模糊操作, 这样能去点部分噪点达到更好的检测
    print("blurring " + colorCheckerTiff)
    blurtiff = os.path.join(cacheFolder, os.path.splitext(os.path.basename(colorChecker))[0]+ "_blur" + '.tiff')
    img = PythonMagick.Image(colorCheckerTiff)
    img.blur(0,10)
    img.write(blurtiff)
    #Retrieve the color correction swatch values from the given image
    print(f"Detecting color checker in {blurtiff}")
    #将图片中的非线性R'G'B'值转换成线性的RGB值
    image = colour.cctf_decoding(colour.io.read_image(blurtiff))
    #检测图片中的色卡值
    swatches = detect_colour_checkers_segmentation(image)
    if len(swatches) < 1:
        return False, False, []
    #对每个检测到的色卡值进行校色,并返回结果最准确的那个。
    Vresult, swatch = VerifyColorSwatches(swatches)
    return Vresult, bNoAutoBrighten, swatch
def VerifyColorSwatches(swatches):
    deviation = []
    #标准色卡颜色值
    rgb_RCCL = colour.XYZ_to_RGB(colour.xyY_to_XYZ(list(REFERENCE_COLOUR_CHECKER.data.values())),
        D65, D65, colour.RGB_COLOURSPACES['sRGB'].matrix_XYZ_to_RGB)
    for swatch in swatches:
        swatch_cc = colour.colour_correction(swatch, swatch, REFERENCE_SWATCHES)
        CCL = swatch_cc
        totalsum = 0.0
        for i in range(len(rgb_RCCL)):
            totalsum += abs(rgb_RCCL[i][0]-CCL[i][0]) + abs(rgb_RCCL[i][1]-CCL[i][1]) + abs(rgb_RCCL[i][2]-CCL[i][2])
        deviation.append(totalsum)
    print("swatches deviations: "+str(deviation))
    min_d = 100.0
    min_i = -1
    for i in range(len(deviation)):
        if deviation[i] < min_d:
            min_d = deviation[i]
            min_i = i
    print("The lowest devi is: " + str(min_d))
    result = False
    #通常插值和在8.0一下都可以接受
    if min_d < 3.0:
        print("devi is good ! ")
        result = True
    elif min_d < 5.0:
        print("devi is near average ! ")
        result = True
    elif min_d < 8.0:
        print("devi is near BAD ! the COLORS will be off!!!!")
        result = True
    else:
        print("devi is BAD ! the Swatches are completely off!!!!")
        result = False
    return result, swatches[min_i]
def CCProcess(files, Vresult, swatch, colorCorrectFolder, CacheDir):
    #folder = folderpath.split("\\")[-1]
    #LocalCcCache = os.path.join(LocalCachPath, folder, "Cache")
    for file in files:
        #利用rawpy将raw文件转换成tiff文件格式方便校色
        tifffile = convert2sRGBTiff(file, CacheDir)
        print("ColorCorrecting: "+tifffile)
        #将图片中的非线性R'G'B'值转换成线性的RGB值
        image = colour.cctf_decoding(colour.io.read_image(os.path.join(CacheDir, tifffile)))
        #使用拍摄颜色值(swatch)进行校色
        cc_image = colour.colour_correction(image, swatch, REFERENCE_SWATCHES, 'Finlayson 2015')
        #这里将校色完成的图片存为32位图像
        tiff_CC_32 = os.path.join(CacheDir, os.path.splitext(os.path.basename(file))[0] + "_CC" +'.tiff')
        #将图片中的线性的RGB值值转换成非线性R'G'B'
        colour.io.write_image(colour.cctf_encoding(cc_image), tiff_CC_32)# write out 32bit image
        print("Save CC image to jpg: "+file)
        cctifffile = os.path.join(CacheDir, os.path.splitext(os.path.basename(file))[0] + '.tiff')
        #ccpngfile = os.path.join(folderpath,'ColorCorrected', os.path.splitext(os.path.basename(file))[0] + '.tiff')
        #转换为8位图片以节约空间
        img = PythonMagick.Image(tiff_CC_32)
        img.depth(8)
        img.write(cctifffile)
        #项目要求转换为JPG并缩放尺寸
        Final_ccJpgfile = os.path.join(colorCorrectFolder, os.path.splitext(os.path.basename(file))[0] + '.jpg')
        img = Image.open(cctifffile)
        """disable resize
        if max(img.size[0], img.size[1]) > DestFrameSize:
            ResizePercent = DestFrameSize/float(max(img.size[0], img.size[1]))
            img = img.resize((int(img.size[0]*ResizePercent), int(img.size[1]*ResizePercent)), 5)
        img.save(Final_ccJpgfile, quality=100, subsampling=0)
        TransferMetaData(file, Final_ccJpgfile)
def convert2sRGBTiff(file, outfolder, bNo_Auto_Bright = True):
    Convert ALL to tiff in sRGB space
    tiffFile = ""
    extention = file.rsplit(".",1)[-1]
    tiffFile = os.path.join(outfolder, os.path.splitext(os.path.basename(file))[0] + '.tiff')
    #对于非raw格式,只做一个简单的转换
    if (extention == "jpg" or extention == "JPG" or
        extention == "png" or extention == "PNG" or
        extention == "tga" or extention == "TGA"):
        im = imageio.imread(file)
        imageio.imsave(tiffFile, im)
        return tiffFile
    #对raw格式,设置为不裁剪高光,不自动加亮,使用默认白平衡(校色会自带白平衡), 使用sRGB gamma
    else:
        try:
            raw = rawpy.imread(file)
            rgb = raw.postprocess(highlight_mode=0, no_auto_bright=bNo_Auto_Bright, use_camera_wb=True, gamma=(2.4, 12.92))
            tiffFile = os.path.join(outfolder, os.path.splitext(os.path.basename(file))[0] + '.tiff')
            imageio.imsave(tiffFile, rgb)
            return tiffFile
        except:
            print("Unsupported Format!!")
            return False
def TransferMetaData(SourceFile, DistFile):
    command = exiftoolPath
    command += " "+ SourceFile
    command += " "+ DistFile
    os.system(command)
def CleanUP(CacheDir):
    if os.path.exists(CacheDir):
        shutil.rmtree(CacheDir) 
def MainCCProcess(ColorChecker, folderpath, OutPutDir, Threads):
    DisableButtons()
    Format = ColorChecker.rsplit(".",1)[-1]
    FileList = glob.glob(os.path.join(folderpath, '*.'+Format))
    if len(FileList) < 2:
        print("No file to CC!")
        return False
    #CCDir = os.path.join(folderpath, "ColorCorrected")
    #os.makedirs(CCDir, exist_ok=True)
    CCDir = OutPutDir
    CleanUP(CCDir)
    os.makedirs(CCDir, exist_ok=True)
    CacheDir = os.path.join(CCDir,"Cache")
    os.makedirs(CacheDir, exist_ok=True)
    global PhotoNum
    PhotoNum = len(FileList)
    print("Prepare ColorChecker")
    Vresult, bNoAutoBrighten, swatch = getColorCorrectionSwatches(ColorChecker, CacheDir)
    if Vresult:
        print("ColorCheckerDetected")
    else:
        print("ColorCheckerDetectionFailed, Abort")
        CleanUp(CCDir)
        return False
    print("***Start Mass ColorCorrcting***")
    Process = []
    FramePerProcess = int(len(FileList)/int(Threads))
    for i in range(Threads+1):
        LastFrame = min((i+1)*FramePerProcess, len(FileList))
        files = FileList[i*FramePerProcess:LastFrame]
        x = multiprocessing.Process(target=CCProcess, args=(files, Vresult, swatch, CCDir, CacheDir,))
        Process.append(x)
        x.start()
        time.sleep(5)
    for ps in Process:
        ps.join()
    EnableButtons()
def ColorCorrect():
    if ColorChecker == "" or PhotosDir == "" or OutPutDir == "":
        print("Invalid Settings!")
        return False
    BakeT = threading.Thread(target=MainCCProcess, args=(ColorChecker,PhotosDir,OutPutDir,int(ProcessNumBlock.get()),))
    BakeT.start()
    #MainCCProcess("F:\PhotoGrammetry\ColorCorrector\\123\ColorChecker.dng","F:\PhotoGrammetry\ColorCorrector\\123",4)
def Choose_ColourChecker():
    global ColorChecker
    filename = tk.filedialog.askopenfilename()
    ColorCheckerBlock["text"] = filename
    ColorChecker = filename
    print(filename)
def Choose_PhotoDir():
    global PhotosDir
    filedir = tk.filedialog.askdirectory()
    PhotosDirBlock["text"] = filedir
    PhotosDir = filedir
    print(filedir)
def Choose_OutPutDir():
    global OutPutDir
    filedir = tk.filedialog.askdirectory()
    OutPutDirBlock["text"] = filedir
    OutPutDir = filedir
    print(filedir)
def DisableButtons():
    ChooseColorChecker["state"] = tk.DISABLED
    ChoosePhotosDir["state"] = tk.DISABLED
    ChooseOutputDir["state"] = tk.DISABLED
    StartButton["state"] = tk.DISABLED
    global bBusy
    bBusy = True
def EnableButtons():
    ChooseColorChecker["state"] = tk.NORMAL
    ChoosePhotosDir["state"] = tk.NORMAL
    ChooseOutputDir["state"] = tk.NORMAL
    StartButton["state"] = tk.NORMAL
    global bBusy, status
    bBusy = False
    status = "WaitingForCommand"
def GetProgress():
    global PhotoNum
    if PhotoNum == 0:
        return 0
    else:
        FileNum = len(glob.glob(os.path.join(OutPutDir, '*.jpg')))
        progress = int(float(FileNum)/float(PhotoNum)*100.0)
        return progress
if __name__ == "__main__":
    multiprocessing.freeze_support()
    Window = tk.Tk()
    Window.title("ColourCorrector")
    Window.geometry("800x150")
    #DecimateBakeButton = tk.Button(Window, text = "CC", width = 20, height = 1, command=ColorCorrect)
    #DecimateBakeButton.grid(row=0,column=0,sticky="W")
    bDiffuse = tk.BooleanVar()
    bNormal = tk.BooleanVar()
    bAO = tk.BooleanVar()
    bVColortoTex = tk.BooleanVar()
    TriCount = tk.IntVar()
    #UI Elements
    #l.grid(row=3)
    ColorCheckerL = tk.Label(Window, text = "ColourChecker(色卡路径): ", width = 25, height = 1  )
    ColorCheckerL.grid(row=0, column=0)
    ColorCheckerBlock = tk.Label(Window, text = "None(未指定色卡)", width = 50, height = 1)
    ColorCheckerBlock.grid(row=0, column=1)
    ChooseColorChecker = tk.Button(Window, text = "...", width = 4, height = 1, command=Choose_ColourChecker)
    ChooseColorChecker.grid(row=0,column=2,sticky="E")
    PhotosDirL = tk.Label(Window, text = "Photos(待较色路径): ", width = 25, height = 1  )
    PhotosDirL.grid(row=1, column=0)
    PhotosDirBlock = tk.Label(Window, text = "None(未指定路径)", width = 50, height = 1 )
    PhotosDirBlock.grid(row=1, column=1)
    ChoosePhotosDir = tk.Button(Window, text = "...", width = 4, height = 1,  command=Choose_PhotoDir)#
    ChoosePhotosDir.grid(row=1,column=2,sticky="E")
    OutPutDirL = tk.Label(Window, text = "OutPut(输出路径): ", width = 25, height = 1)
    OutPutDirL.grid(row=2, column=0)
    OutPutDirBlock = tk.Label(Window, text = "None(未指定输出)", width = 50, height = 1)
    OutPutDirBlock.grid(row=2, column=1)
    ChooseOutputDir = tk.Button(Window, text = "...", width = 4, height = 1, command=Choose_OutPutDir)
    ChooseOutputDir.grid(row=2,column=2,sticky="E")
    ProcessNumL = tk.Label(Window, text = "ProcessNum(进程数)", width = 30, height = 1)
    ProcessNumL.grid(row=3,column=0,sticky="E")
    ProcessNumBlock = tk.Entry(Window, width = 15)
    ProcessNumBlock.insert(0, "3")
    ProcessNumBlock.grid(row=3,column=1,sticky="W")
    StartButton = tk.Button(Window, text = "Start ColorCorrection(开始较色)", width = 30, height = 1, command=ColorCorrect)
    StartButton.grid(row=3,column=2,sticky="W")
    l = tk.Label(Window, text = "ABC", width = 80, height = 1, font=("Arial"))
    l.place(x=5,y=125)
    def UpdateStatus():
        global status, bBusy
        percent = GetProgress()
        status = str(percent)+"%"
        if bBusy:
            if status[-3:] == "...":
                status = status[:-3]
            else:
                status += "."