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

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

ImageJ / Python Tips: 32bit 画像における setThreshold メソッド使用の際の注意点

 ImageJにおける、32bit 浮動小数点画像の閾値の設定ではまりました。一旦、 setThreshold メソッドで閾値を指定し、それをもとに roi 設定→ roi 塗りつぶしで、二値画像を作成しようとしています。そして、8bit, 16bit 整数正負記号なし画像の場合、ImageProcessor の setThreshold メソッドで意図通りの閾値設定 & 二値画像出力を行うことができます。

 ところが、32bit画像だと意図通りの二値画像が作れません。例えば 32 bit で 0.0~1.0 の値の範囲を持つ画像を読み込んで、0.5~1.0の間を選択する閾値を設定しようとしますが、やってみると、真っ黒か、真っ白の二値画像しか出力できず、どうやら0.0 か 1.0 の閾値しか設定できないようです。引数としては、double型 数値を取れるはずなのですが、どうも内部的に整数に丸められてしまっている疑いがあります。

 この問題ですが、結論から言うと、32 bit画像においては、閾値がどう表示されるかは、ディスプレイ明度範囲に依存しているようで、imagePlus の setDisplayRange メソッドでディスプレイ明度範囲を指定しないと正しく閾値を表示させることができません

試してみたこと

 0.1~1.0の32bit画像で閾値が意図通りに設定できず、閾値の指定値が整数に丸められている疑いがあるので、まず値を、例えば 65,535 や16,777,216倍し、閾値を整数で指定してみました。しかし、結果は変わらず、うまくいきませんでした。

 次に考えたのは、一旦 byteProsessor (8bit画像) もしくは、shortProcessor (16bit画像)  に変換して閾値を設定することです。しかしこれも同じです。

 頭を抱えました。しかし、ふと、ディスプレイ明度範囲設定の問題ではないかと思い直し、shortProcessor に直したうえで、setDisplayRange (0, 65535) で合わせて、整数値で閾値を設定してみると、ようやく意図通りの二値画像が出力できました。

 次に、これは bit 深度の問題ではなく、単純にディスプレイ明度範囲設定の問題ではないかと考え、0.1~1.0の32bit画像 を bit 深度変更を行わないまま、65,535倍し、setDisplayRange (0, 65535)を指定したうえで setThreshold メソッドを実行したところ、意図通りの二値画像が出力できました。

 さらに、ディスプレイ範囲を明示的に 0.0~1.0 に設定してやれば、1.0未満でも閾値を設定できることが分かりました。最初にうまくいかなかったのは、どうやら、32bit 画像の場合データクリッピングがないため、途中で画像を加工した時に1.0を超える値が出てしまい、そのため意図せずしてディスプレイ範囲が動いてしまったためではないかと推測します。

 byteProcessor や shortProcessor なら所定の範囲を超えるとデータがクリップしますので、ディスプレイ範囲が動くことはないのですが...

 なお、表示上は真っ黒、または真っ白になったとしても、内部データ的に閾値は設定されているものとは思いますが、しかしこのディスプレイ範囲情報はTIFFファイルなどに落としたときに引き継がれ、さらに別ソフトで読み込んでも、表示がおかしくなったままになります。かといって、UI上からは ImageJ であっても GIMP であっても変更できないようです。事実上、閾値の設定はディスプレイ範囲の値に依存するといっても差し支えないでしょう。

 以上のことから、32bit 画像の場合、ディスプレイ明度範囲を常に setDisplayRange メソッドで明示的に指定したうえで、setThreshold メソッドを実行する必要がある

...ということが明らかになりました。

 byteProcessor や shortProcessor の場合、事実上ディスプレイ明度範囲が、0~255 もしくは、0~65535 に固定されていますので、まず問題になることはありませんが、floatProcessor の場合ディスプレイ明度範囲を様々に変更可能ですので要注意です。おそらくは元々、byteProcessor や shortProcessorしかなかったのが、規格が拡張され floatProcessor が加わったものの、元々整数値で閾値を指定するという仕様が、そのまま維持されたためこのようになっているものと思われます。

 なお、補足ですが、byteProcessor や shortProcessor の場合、デフォルトのディスプレイ明度範囲を超えると値がクリップされます。しかし floatProcessor は値がクリップされませんので、これも32bit画像を扱う場合の要注意点です。

 

 以下、ImageJで開いている画像を、8bit相当で、128~255の範囲を白、それ以下を黒で表示する二値画像に変換し、保存するサンプルプログラムを掲示します。読み込んだ画像が32bitの場合、一旦 0 ~ 65535 の画像に変換し、閾値の設定を行った後、元のディスプレイ明度範囲 & 値に戻すようにしています。このプログラムは自由に改変して使っていただいて結構です。

 なお、変換過程を確認するために途中、何度か変換過程画像を表示し、一時停止するようにしてあります。

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

# Make binary image from open imege
#(c) 2023 Ohnishi, Yasuo
from ij import IJ, ImagePlus
from ij import WindowManager as WM
from ij.plugin import Duplicator
from ij.process import ImageConverter
from ij.process import ImageProcessor
from ij.plugin.filter import ThresholdToSelection
import os
from org.python.core import codecs
codecs.setDefaultEncoding('utf-8')

#-----------------
def set_Lmax(imp, Mode):
    # set Maximum value of Lightness
    L_max = imp.getProcessor().getMax()
    #IJ.showMessage("original getMax: " + str(L_max))
    if int(L_max) > 65536:
        L_max = 16777215
    elif int(L_max) > 256 or Mode == "16-bit":
        L_max = 65535
    elif int(L_max) > 1.0 or Mode == "8-bit":
        L_max = 255
    else:
        L_max = 1.0
    return L_max
#-----------------
def convValue2(Value, L_max):
#   Converting Luminance Value each in 8, 16, 32bit mode
    if L_max == 16777215:
        Value = int(Value * 65536)
    elif L_max == 65535:
        Value = int(Value * 256)
    elif L_max == 255:
        Value = int(Value)
    else: # L_max == 1.0
        Value = Value / 255
    return Value
#-----------------
def bit32to16(imp, L_max):
    imp.getProcessor().multiply(65535 / L_max)
    imp.setDisplayRange(0, 65535)
    ImageConverter(imp).convertToGray16()
    return imp
#-----------------
def bit16to32(imp, L_max):
    ImageConverter(imp).convertToGray32()
    imp.getProcessor().multiply(L_max / 65535.0)
    imp.setDisplayRange(0, L_max)
    return imp
#-----------------
def convert1to65535(imp, L_max):
    imp.getProcessor().multiply(65535 / L_max)
    imp.setDisplayRange(0, 65535)
    return imp
#-----------------
def convert65535to1(imp, L_max):
    imp.getProcessor().multiply(L_max / 65535.0)
    imp.setDisplayRange(0, L_max)
    return imp
#-----------------
def imageBinary(impOrg, lowerV, upperV, roiColor, maskname, Mode, L_max):
    # make binary mask image with setThreshold
    # lowerV: lowerThreshold / upperV: upperThreshold (double)
    # roiColor: Color Value, ROI filled with (double)
    # maskname: name of mask image (string)
    IJ.showMessage("call imageBinary")
    imp = Duplicator().run(impOrg)
    imp.setTitle("before convert L_max: " + str(L_max))
    imp.show()
    IJ.showMessage("imp before convert show")
    imp.hide()
    if Mode == "32-bit":
        #imp = bit32to16(imp, L_max)
        imp = convert1to65535(imp, L_max)
        L_maxTmp = 65535
    else:
        L_maxTmp = L_max
    imp.setTitle("Converted to 16bit L_max: " + str(L_max))
    imp.show()
    IJ.showMessage("Converted to 16bit show L_max: " + str(L_max))
    imp.hide()
    ip = imp.getProcessor()
    mask = ip.createProcessor(ip.width, ip.height)
    mask.setValue(convValue2(roiColor, L_maxTmp)) # with which color ROI filled
    ip.setThreshold(convValue2(lowerV, L_maxTmp), convValue2(upperV, L_maxTmp), \
        ImageProcessor.NO_LUT_UPDATE)
    roi = ThresholdToSelection.run(imp)
    mask.setRoi(roi)
    mask.fill(roi)
    maskimp = ImagePlus(maskname, mask)
    maskimp.setTitle("maskimp")
    maskimp.show()
    IJ.showMessage("Binary Mask show 16bit ")
    maskimp.hide()
    if Mode == "32-bit":
        #maskimp = bit16to32(maskimp, L_max)
        maskimp = convert65535to1(maskimp, L_max)
    maskimp.show()
    IJ.showMessage("Binary Mask show 32bit")
    maskimp.hide()
    return maskimp

#-main---------------------------
JobDo = True
window = WM.getActiveWindow()
imp0 = IJ.getImage()
file = imp0.getTitle()
try:
    folder = imp0.getOriginalFileInfo().directory
except:
    folder = IJ.getDirectory("Output_directory")
path = folder + file
if JobDo == True:
    bitdepth = imp0.getBitDepth()
    if bitdepth == 8:
        Mode = "8-bit"
    elif bitdepth ==16:
        Mode = "16-bit"
    elif bitdepth ==32:
        Mode = "32-bit"
    else:
        Mode = "16-bit"
    L_max = set_Lmax(imp0, Mode)
    imp0.setDisplayRange(0.0, L_max)
    stat0 = imp0.getStatistics()
    impMask = imageBinary(imp0, 128, 255.99, 259.99, "Mask", Mode, L_max)
    IJ.saveAsTiff(impMask, folder + file +"_BinaryTest.tif")