省型旧形国電の残影を求めて

戦前型旧形国電および鉄道と変褪色フィルム写真を中心とした写真補正編集の話題を扱います。他のサイトでは得られない、筆者独自開発の写真補正ツールや補正技法についても情報提供しています。写真補正技法への質問はコメント欄へどうぞ

ImageJ / Python プログラミング Tips: 32bit 画像のディスプレイ範囲をめぐる挙動の確認とその注意点

 ImageJ の32bit 画像の処理を行う際の挙動に良く分からない点があるので引き続き追及中ですが、いくつか新たに注意すべき点を見つけましたのでメモしておきます。

 まず、32bit画像を作成しようと、16bit画像を ImageJ 以外の画像処理ソフトを使って 32bit 浮動小数点画像を作成してみました。多くの画像処理ソフトでは32bit 浮動小数点画像を作成すると、正負符号つきを指定しない限りは、値が 0.0 ~ 1.0 の画像を作成するようです。ただいくつか気付いた点では、丸め誤差により最大値が微妙に 1.0 を超えたり、最小値が、0.0 を下回る場合があることに気づきました。

 当初ディスプレイ範囲の処理判断に、画像の最小値、最大値を使って判断していましたが、最大値が 1.0 を超える場合、ディスプレイ範囲の最大値を255に設定するということをやっていたら、当然ですが、このような場合、誤判断を起こします。

 ただ、getDisplayRangeMax() を使った場合は、値の最高値自体は丸め誤差で 1.0 を超える場合があっても、ディスプレイ範囲の方は 1.0 を超えることはなさそうです。断言はできませんが、このような判定には値 (明るさ) の最大値や最小値ではなくディスプレイ範囲の最大値や最小値を使ったほうがより正確な結果が得られるようです。また、場合によってはすべて真っ白とか真っ黒な画像を扱うことがありますが、当然ながらその際値に基づいてディスプレイ範囲を判断することは誤判断につながります。

 

 これらを検証するために、32bit 画像から他のビット深度の画像に相互変換したり、画像に値を掛けるなどの計算を行ったときに、ディスプレイ範囲を設定するとどうなるか、あるいは設定しなくても勝手に自動的にディスプレイ範囲が動くのかどうかを検証するプログラムを作ってみました。これは以前公開したbit深度変更プログラムを拡張したものです。以下そのコードです。

-----------------------------------------

# Converting image bit depth and max value
# (c) 2023 Ohnishi, Yasuo
from ij import IJ, ImagePlus
from ij import WindowManager as WM
from ij.io import DirectoryChooser, OpenDialog
from ij.plugin import ChannelSplitter, Duplicator
from ij.process import ImageConverter
from ij.gui import GenericDialog
from loci.plugins import BF
import os
import sys
from org.python.core import codecs
codecs.setDefaultEncoding('utf-8')

def cValue(Value, Mode, L_max):
#     Converting Luminance Value each in 8, 16, 32bit mode
        if Mode == "16-bit":
            Value = Value * 256
        elif Mode == "32-bit":
            Value = Value * (L_max / 255.999)
        else: # 8 or 24
            Value = Value
        return Value


def selectChannelUI(): 
    # dialog of selecting target channel for mask upper & lower threshold
    gd = GenericDialog("Channel Selection")
#    scolorCh = ["Lightness", "Luminosity", "Red","Green","Blue"]
    scolorCh = ["Red","Green","Blue"]
    gd.addChoice("Select Target Channel", scolorCh, scolorCh[0])
    gd.showDialog()

    colorCh =  gd.getNextChoice()
    if gd.wasCanceled():
        colorCh =  "None"

    return str(colorCh)
#--
def inputParmUI(Mode, V_Max, L_Max, imp0): 
    # dialog of input parameters
    gd = GenericDialog("Input Parameters")
    gd.addMessage("Original Image Bitdepth: " + Mode + "  Max Lightness: " + str(V_Max))
    dispMin = imp0.getDisplayRangeMin()
    dispMax = imp0.getDisplayRangeMax()

    gd.addMessage("Original Display Range: " + format(dispMin, '.7f') + " ~ " + format(dispMax, '.7f'))
    imageFormats = ["8-bit","16-bit","32-bit"]
    if Mode == "8-bit":
        gd.addChoice("Select Convert Format", imageFormats, imageFormats[0])
    elif Mode == "16-bit":
        gd.addChoice("Select Convert Format", imageFormats, imageFormats[1])
    else:
        gd.addChoice("Select Convert Format", imageFormats, imageFormats[2])
    maxValues = ["1.0","255","65535","16777215"]
    if L_Max == 1.0:
        gd.addChoice("Select Max Value (only available in 32bit)", maxValues, maxValues[0])
    elif L_Max == 255:
        gd.addChoice("Select Max Value (only available in 32bit)", maxValues, maxValues[1])
    elif L_Max == 65535:
        gd.addChoice("Select Max Value (only available in 32bit)", maxValues, maxValues[2])
    else:
        gd.addChoice("Select Max Value (only available in 32bit)", maxValues, maxValues[3])
    dispMaxValues = ["Keep current value","1.0","255","65535","16777215"]
    gd.addChoice("Set Display Range Max Value", dispMaxValues, dispMaxValues[0])
    gd.addNumericField("Multiply", 1.0, 1)  #NField 0
    gd.addNumericField("Add or Subtract (-255 ~ 0 ~ 255)", 0.0, 1)  #NField 0


    gd.showDialog()

    imageFormat =  gd.getNextChoice()
    maxValue =  gd.getNextChoice()
    dispMaxValue = gd.getNextChoice()
    multiplyValue = gd.getNextNumber()
    addValue = gd.getNextNumber()

    if gd.wasCanceled():
        imageFormat =  "None"
        maxValue =  "None"
    if Mode == "8-bit" and imageFormat == "16-bit":
        multiplyFactor = 256
    elif Mode == "8-bit" and imageFormat == "32-bit":
        multiplyFactor = float(maxValue) / 255
    elif Mode == "16-bit" and imageFormat == "32-bit":
        multiplyFactor = float(maxValue) / 65535
    elif Mode == "32-bit" and imageFormat == "32-bit":
        multiplyFactor = float(maxValue) / float(L_Max)
    else:
        multiplyFactor = 1.0
    if dispMaxValue != "Keep current value":
        dispMaxValue = float(dispMaxValue)
    else:
        dispMaxValue = -99999

    return str(imageFormat), multiplyFactor, float(dispMaxValue), dispMin, dispMax, \
        multiplyValue, addValue

#--
window = WM.getActiveWindow()
if not window:
    od = OpenDialog("Choose a file")
    folder = od.getDirectory()
    filename = od.getFileName()
    if filename:
        path = folder + filename
        IJ.log(path)
        IJ.log(folder)
        IJ.log(filename)
# Always Open file with Bio-format Plugin
        imps = BF.openImagePlus(folder + filename) 
        imp0 = imps[0] #imp0: Original Image 
        imp0.show()
    else:
        IJ.showMessage("File is not selected.")
else: # target image is already open
    imp0 = IJ.getImage()
    imagename = imp0.getTitle()
    try:
        folder = imp0.getOriginalFileInfo().directory
    except:
        folder = IJ.getDirectory("Output_directory")
# get number of channels (Mono or RGB?)
stackSize = imp0.getStackSize() # stackSize=1: Mono / 3: RGB
# get bitdepth
bitdepth = imp0.getBitDepth()
if bitdepth == 8:
    Mode = "8-bit"
elif bitdepth ==16:
    Mode = "16-bit"
elif bitdepth ==32:
    Mode = "32-bit"
# get Max value
V_Max = imp0.getDisplayRangeMax()
if V_Max > 65536:
    L_Max = 16777215
elif V_Max > 256:
    L_Max = 65535
elif V_Max > 1.01:
    L_Max = 255
else:
    L_Max = 1.0

if stackSize > 1:
    colorch = selectChannelUI()
    impsCh = ChannelSplitter.split(imp0) #Channel Splitting of imp0
    if colorch == "Red":
        imp1 = impsCh[0]
    elif colorch == "Green":
        imp1 = impsCh[1]
    elif colorch == "Blue":
        imp1 = impsCh[2]
    elif colorch == "None":
        sys.exit()
else:
    imp1 = Duplicator().run(imp0)
imp1.show()
imp0.hide()
imageFormat, multiplyFactor, dispMaxValue, dispMin, dispMax, \
    multiplyValue, addValue = inputParmUI(Mode, V_Max, L_Max, imp0)
if imageFormat == "8-bit":
    ImageConverter(imp1).convertToGray8()
elif imageFormat == "16-bit":
    ImageConverter(imp1).convertToGray16()
elif imageFormat == "32-bit":
    ImageConverter(imp1).convertToGray32()
imp1.getProcessor().multiply(multiplyFactor)
imp1.getProcessor().multiply(multiplyValue)
imp1.getProcessor().add(cValue(addValue, imageFormat, L_Max))

if dispMaxValue != -99999:
    imp1.setDisplayRange(0.0, dispMaxValue)
imp1.getProcessor().setLut(None)
stat = imp1.getStatistics()
dispMin1 = imp1.getDisplayRangeMin()
dispMax1 = imp1.getDisplayRangeMax()

string = ""
if dispMaxValue == -99999 \
    and (dispMin != dispMin1 or dispMax != dispMax1):
    string = "Display range auto changed"

imp1.updateAndDraw()
IJ.showMessage(" Multiply Factor according to Format Change: "+ str(multiplyFactor) + \
    "\n Min: " + str(stat.min) + " Mean: " + str(stat.mean) + \
    " Max: " + str(stat.max) + "\n Disp Min: " + format(dispMin1, '.7f') \
    + " Disp Max: " + format(dispMax1, '.7f') + \
    "\n " + string + "\n Multiplying Value: " + str(multiplyValue) + \
    "\n Adding Value: " + str(addValue))

imp0.close()

------------------------------------------------------

 これをImageJ に読み込んで動作させると次のようになります。

1) 最初に読み込むファイルを指定するダイアログが出るので、読み込むファイルを指定します。

2) 次にどのチャンネルに対して操作するのか、チャンネル選択ダイアログが出ます。

チャンネル選択ダイアログ

3) チャンネルを選択すると、画像変換パラメータ入力ダイアログが現れます。

パラメータ入力ダイアログ

 このダイアログの一番上には、オリジナル画像のビット深度と最大値、次に、オリジナルディスプレイ範囲が表示されます。上の 32bit 画像の場合明るさの最大値が1.0を超えていることが分かります。但しディスプレイ範囲の最大値は、1.0 を僅かに下回っています。

 次に変換したい画像ビット深度の指定、その下は、32bit 画像に変換する場合の最大値の指定 (1.0, 255, 65535, 1677万72115 から選択。8bit の場合は255, 16bit の場合は65535 に最大値は固定されます)、そして、ディスプレイ範囲の最大値の指定 (デフォルト値は、現在のディスプレイ範囲を維持、です。なお値を指定した場合のディスプレイ範囲の最小値は 0.0 になります)  です。オリジナルと同じビット深度を指定するとビット深度の変更は行いません。

 さらに、任意の値で、値を掛けたり、足したり引いたりできる値を指定する数値ボックスが続きます。加減算に関しては 8 bit 相当値で値を指定します。

4) 値を指定して実行すると、結果画像が表示されるとともに、結果を知らせるダイアログが開きます。

結果ダイアログと画像

 上に表示されている結果ダイアログはもともと 32-bit の画像を、16-bit に変換した場合のサンプルです。 

  ダイアログの最初の行は、フォーマット変換に伴う画像の筆者のプログラムによる自動的な乗算調整の値が表示されます。1.0 は乗算調整がなかったということです。但し以下の値を見ると、最大値が65535に変更されていますので、筆者のプログラムの内部では乗算調整は行っていませんが、 ImageJ で32 > 16-bit フォーマット変換に伴う値の自動調整があったことが分かります。

 次は結果画像の最小値、平均値、最大値です。

 そして現在のディスプレイ範囲の最小値と最大値です。

 もし ImageJ による自動的なディスプレイ範囲の変更があった場合は、Display range auto changed と表示されます。

 その後はパラメータ指定ダイアログで入力した掛け算の乗数と、加減算の加減算値が表示されます。

 

 以上のプログラムを動かしてわかることは、すでに指摘していることもありますが...

・ビット深度を深いほうから浅いほうに動かす際は、ImageJ 上で自動的に値のスケーリングやディスプレイ範囲の調整を行う。しかし浅いほうから深いほうに動かす際は自動的なスケーリングをやってくれない。

 なお、このプログラム内では 8bit 画像を 16bit に変更する際に、値を 256 倍しています。

・画像の掛け算を行っても、自動的にディスプレイ範囲の調整は行わないので、必要に応じて自分でディスプレイ範囲の調整を行う必要がある。

・但し、フォーマットを一切変えず、意図的にディスプレイ範囲を変えなくても、32bit 画像に加減乗算を加えると、微妙にディスプレイ範囲が変わる。

 

 この3番目は結構困りものかもしれません。