#!/bin/bash # # Developed by Fred Weinhaus 10/30/2008 .......... revised 11/3/2015 # # ------------------------------------------------------------------------------ # # Licensing: # # Copyright © Fred Weinhaus # # My scripts are available free of charge for non-commercial use, ONLY. # # For use of my scripts in commercial (for-profit) environments or # non-free applications, please contact me (Fred Weinhaus) for # licensing arrangements. My email address is fmw at alink dot net. # # If you: 1) redistribute, 2) incorporate any of these scripts into other # free applications or 3) reprogram them in another scripting language, # then you must contact me for permission, especially if the result might # be used in a commercial or for-profit environment. # # My scripts are also subject, in a subordinate manner, to the ImageMagick # license, which can be found at: http://www.imagemagick.org/script/license.php # # ------------------------------------------------------------------------------ # #### # # USAGE: fuzzythresh [-r radius] [-g graph] infile outfile # USAGE: fuzzythresh [-help] # # OPTIONS: # # -r radius radius for spatial correlation; float; # radius>=0; the default=0 signifies to # ignore spatial correlation # -g graph graph specifies whether to generate a # histogram graph image displaying the # location and value of the threshold; # choices are: view or save; # default is no graph # ### # # NAME: FUZZYTHRESH # # PURPOSE: To automatically thresholds an image to binary (b/w) format # using the fuzzy c-means technique. # # DESCRIPTION: FUZZYTHRESH automatically thresholds an image to binary # (b/w) format. It assume the histogram is bimodal, i.e. is the composite # of two bell-shaped distributions representing the foreground and # background classes. The fuzzy c-means appoach iteratively thresholds the # image, computes a weighted mean for the foreground (above threshold data) # and background (at and below threshold value), computes a new threshold # equal to the average of these two means and repeats until there is no # change in threshold between successive iterations. The weighting factors # are the normalized inverse square difference between each pixel in the # foreground or background data sets and the corresponding mean. However, # to allow for spatial correlation of the graylevels among neighboring # pixels, a Gaussian filtered version of the weighting factors may be used. # This script is similar to the isodatathresh script, except uses a weighted # mean calculation. # # OPTIONS: # # -r radius ... RADIUS is the radius of a Gaussian (blur) filter to apply # to the weighting factors for the mean calculation. This permits spatial # correlation of the graylevels among neighboring pixels and is useful # to remove noise from the result. The radius value should be set to # the size of the features in the image. Values are floats greater than # zero. The default=0 signifies to ignore spatial correlation. # # -g graph ... GRAPH specifies whether to generate a graph (image) of # the histogram, displaying the location and value of the threshold. # The choices are: view, save and none. If graph=view is selected, the # graph will be created and displayed automatically, but not saved. # If graph=save is selected, then the graph will be created and saved # to a file using the infile name, with "_histog_isodata.gif" appended, # but the graph will not be displayed automatically. If -g option is # not specified, then no graph will be created. # # NOTE: It is highly recommended that the output not be specified # as a JPG image as that will cause compression and potentially a # non-binary (i.e. a graylevel) result. GIF is the recommended # output format. # # REFERENCES: see the following: # http://www.iiitmk.ac.in/wiki/images/d/d2/Fuzzy_CMeans.pdf # http://www.quantlet.com/mdstat/scripts/xag/html/xaghtmlframe149.html # http://home.dei.polimi.it/matteucc/Clustering/tutorial_html/cmeans.html # # CAVEAT: No guarantee that this script will work on all platforms, # nor that trapping of inconsistent parameters is complete and # foolproof. Use At Your Own Risk. # ###### # # set default values radius=0 graph="" #save or view # set directory for temporary files dir="." # suggestions are dir="." or dir="/tmp" # set up functions to report Usage and Usage with Description PROGNAME=`type $0 | awk '{print $3}'` # search for executable on path PROGDIR=`dirname $PROGNAME` # extract directory of program PROGNAME=`basename $PROGNAME` # base name of program usage1() { echo >&2 "" echo >&2 "$PROGNAME:" "$@" sed >&2 -e '1,/^####/d; /^###/g; /^#/!q; s/^#//; s/^ //; 4,$p' "$PROGDIR/$PROGNAME" } usage2() { echo >&2 "" echo >&2 "$PROGNAME:" "$@" sed >&2 -e '1,/^####/d; /^######/g; /^#/!q; s/^#*//; s/^ //; 4,$p' "$PROGDIR/$PROGNAME" } # function to report error messages errMsg() { echo "" echo $1 echo "" usage1 exit 1 } # function to test for minus at start of value of second part of option 1 or 2 checkMinus() { test=`echo "$1" | grep -c '^-.*$'` # returns 1 if match; 0 otherwise [ $test -eq 1 ] && errMsg "$errorMsg" } # test for correct number of arguments and get values if [ $# -eq 0 ] then # help information echo "" usage2 exit 0 elif [ $# -gt 6 ] then errMsg "--- TOO MANY ARGUMENTS WERE PROVIDED ---" else while [ $# -gt 0 ] do # get parameter values case "$1" in -h|-help) # help information echo "" usage2 exit 0 ;; -r) # get radius shift # to get the next parameter # test if parameter starts with minus sign errorMsg="--- INVALID RADIUS SPECIFICATION ---" checkMinus "$1" radius=`expr "$1" : '\([.0-9]*\)'` [ "$radius" = "" ] && errMsg "--- RADIUS=$radius MUST BE A NON-NEGATIVE FLOAT ---" ;; -g) # get graph shift # to get the next parameter # test if parameter starts with minus sign errorMsg="--- INVALID GRAPH SPECIFICATION ---" checkMinus "$1" graph="$1" [ "$graph" != "view" -a "$graph" != "save" ] && errMsg "--- GRAPH=$graph MUST BE EITHER VIEW OR SAVE ---" ;; -) # STDIN and end of arguments break ;; -*) # any other - argument errMsg "--- UNKNOWN OPTION ---" ;; *) # end of arguments break ;; esac shift # next option done # # get infile and outfile infile="$1" outfile="$2" fi # test that infile provided [ "$infile" = "" ] && errMsg "NO INPUT FILE SPECIFIED" # test that outfile provided [ "$outfile" = "" ] && errMsg "NO OUTPUT FILE SPECIFIED" # get outname from infile to use for graph inname=`convert $infile -format "%t" info:` histfile=${inname}_histog_fuzzy.gif tmpA1="$dir/fuzzythresh_1_$$.mpc" tmpA2="$dir/fuzzythresh_1_$$.cache" tmpM1="$dir/fuzzythresh_M_$$.mpc" tmpM2="$dir/fuzzythresh_M_$$.cache" tmpL1="$dir/fuzzythresh_L_$$.mpc" tmpL2="$dir/fuzzythresh_L_$$.cache" tmpH1="$dir/fuzzythresh_H_$$.mpc" tmpH2="$dir/fuzzythresh_H_$$.cache" tmpD1="$dir/fuzzythresh_D_$$.mpc" tmpD2="$dir/fuzzythresh_d_$$.cache" trap "rm -f $tmpA1 $tmpA2 $tmpM1 $tmpM2 $tmpL1 $tmpL2 $tmpH1 $tmpH2 $tmpD1 $tmpD2; exit 0" 0 trap "rm -f $tmpA1 $tmpA2 $tmpM1 $tmpM2 $tmpL1 $tmpL2 $tmpH1 $tmpH2 $tmpD1 $tmpD2 $histfile; exit 1" 1 2 3 15 # get im version im_version=`convert -list configure | \ sed '/^LIB_VERSION_NUMBER /!d; s//,/; s/,/,0/g; s/,0*\([0-9][0-9]\)/\1/g' | head -n 1` # colorspace RGB and sRGB swapped between 6.7.5.5 and 6.7.6.7 # though probably not resolved until the latter # then -colorspace gray changed to linear between 6.7.6.7 and 6.7.8.2 # then -separate converted to linear gray channels between 6.7.6.7 and 6.7.8.2, # though probably not resolved until the latter # so -colorspace HSL/HSB -separate and -colorspace gray became linear # but we need to use -set colorspace RGB before using them at appropriate times # so that results stay as in original script # The following was determined from various version tests using fuzzythresh. # with IM 6.7.4.10, 6.7.6.10, 6.7.8.10 if [ "$im_version" -lt "06070607" -o "$im_version" -gt "06070707" ]; then setcspace="-set colorspace RGB" else setcspace="" fi # no need for setcspace for grayscale or channels after 6.8.5.4 if [ "$im_version" -gt "06080504" ]; then setcspace="" fi if convert -quiet "$infile" $setcspace -colorspace Gray +repage "$tmpA1" then : ' do nothing ' else errMsg "--- FILE $infile DOES NOT EXIST OR IS NOT AN ORDINARY FILE, NOT READABLE OR HAG ZERO SIZE ---" fi # get image width and heigh ww=`convert $tmpA1 -format "%w" info:` hh=`convert $tmpA1 -format "%h" info:` getMinMax() { img="$1" if [ "$im_version" -ge "06030901" ] then min=`convert $img -format "%[min]" info:` max=`convert $img -format "%[max]" info:` min=`convert xc: -format "%[fx:100*$min/quantumrange]" info:` max=`convert xc: -format "%[fx:100*$max/quantumrange]" info:` else data=`convert $img -verbose info:` min=`echo "$data" | sed -n 's/^.*[Mm]in:.*[(]\([0-9.]*\).*$/\1/p ' | head -1` max=`echo "$data" | sed -n 's/^.*[Mm]ax:.*[(]\([0-9.]*\).*$/\1/p ' | head -1` min=`convert xc: -format "%[fx:100*$min)]" info:` max=`convert xc: -format "%[fx:100*$max)]" info:` fi } getMean() { img="$1" if [ "$im_version" -ge "06030901" ] then mean=`convert $img -format "%[mean]" info:` mean=`convert xc: -format "%[fx:100*$mean/quantumrange]" info:` else data=`convert $img -verbose info:` mean=`echo "$data" | sed -n 's/^.*[Mm]ean:.*[(]\([0-9.]*\).*$/\1/p ' | head -1` mean=`convert xc: -format "%[fx:100*$mean]" info:` fi } # set gaussian filter if [ "$radius" = "0" -o "$radius" = "0.0" ]; then filter="" else filter="-blur 0x${radius}" fi # process image using isodata approach echo "" echo "Compute Threshold" # begin threshold at ave of min and max getMinMax "$tmpA1" min=$min max=$max thresh=`convert xc: -format "%[fx:($min+$max)/2]" info:` oldthresh=0 diff=`convert xc: -format "%[fx:abs($thresh-$oldthresh)]" info:` test=`echo "$diff == 0" | bc` k=1 # iterate the following # threshold image at newmean # get weighted means above and below threshold # where weights are the normalized inverse squared differences of each pixel from the class mean # get average of hmean and lmean as new threshold # repeat until difference between oldthresh and current thresh is zero # # Note: low and high (below threshold and above threshold) means # are extracted by masking and not by histogram counting. # Thus they need to be corrected for mixture of black pixels. # So, mean from masks (Mmeasured) and actual low/high means, Mtrue, are related # Mmeasured=(Sum(Cw*Gw)+Sum(Cb*0))/Ct = Sum(Cw*Gw)/Ct, summed over whole image # where Cw=count of white mask pixels, Gw graylevel at those pixels, Ct=total image pixels # Mtrue=Sum(Cw*Gw)/Ctw, where Ctw is total count of only white (non-black) mask pixels # Ctw=Mean of the mask/100 # All graylevels are in range 0-100 # while [ "$test" != "1" ]; do echo "Iteration $k" oldthresh=$thresh # get high mask and its mean convert $tmpA1 -threshold ${thresh}% $tmpM1 getMean "$tmpM1" mmean=$mean # compute high membership as normalized inverse squared difference of each pixel from high mean # in this case we just use -compose difference -evalute pow 2 and negate it (so inverse squared distance scaled to Q) convert $tmpA1 \( -size ${ww}x${hh} xc:"gray($mmean%)" \) \ -compose difference -evaluate pow 2 $filter -negate $tmpD1 # apply multiply membership image by image as weighting and mask to get the resulting mean convert $tmpA1 $tmpD1 -compose multiply -composite \ $tmpM1 -compose multiply -composite \ $tmpH1 getMean "$tmpH1" hmean=$mean # echo "mmean=$mmean; hmean=$hmean" # correct hmean using mmean hmean=`convert xc: -format "%[fx:(100*$hmean/$mmean)]" info:` # get low mean # low mean = 100% - high mean nmean=`convert xc: -format "%[fx:(100-$mmean)]" info:` # compute low membership as normalized inverse squared difference of each pixel from high mean # in this case we just use -compose difference -evalute pow 2 and negate it (so inverse squared distance scaled to Q) convert $tmpA1 \( -size ${ww}x${hh} xc:"gray($nmean%)" \) \ -compose difference -evaluate pow 2 $filter -negate $tmpD1 # apply multiply membership image by image as weighting and mask to get the resulting mean # note: low mask is just the complement of the high mask, i.e. $tmpM1 -negate convert $tmpA1 $tmpD1 -compose multiply -composite \ \( $tmpM1 -negate \) -compose multiply -composite \ $tmpL1 getMean "$tmpL1" lmean=$mean # echo "nmean=$nmean; lmean=$lmean" # correct lmean using nmean lmean=`convert xc: -format "%[fx:(100*$lmean/$nmean)]" info:` # get new threshold thresh=`convert xc: -format "%[fx:($hmean+$lmean)/2]" info:` diff=`convert xc: -format "%[fx:abs($thresh-$oldthresh)]" info:` # test=`echo "$diff <= 0.000001" | bc` test=`convert xc: -format "%[fx:($diff<=0.01)?1:0]" info:` k=`expr $k + 1` # echo "hmean=$hmean; lmean=$lmean; oldthresh=$oldthresh; thresh=$thresh; diff=$diff" done convert $tmpM1 "$outfile" echo "Image Thresholded At $thresh%" echo "" if [ "$graph" != "" ]; then xx=`convert xc: -format "%[fx:floor(255*$thresh/100)]" info:` convert $tmpA1 -define histogram:unique-colors=false histogram:- | \ convert - -negate \ -stroke red -strokewidth 1 -draw "line $xx,0 $xx,200" \ -background gray -splice 0x30 \ -fill white -stroke white -strokewidth 1 \ -font ArialB -pointsize 24 \ -draw "text 4,22 'threshold=$thresh%'" -resize 50% \ -bordercolor gray50 -border 5 \ $histfile fi if [ "$graph" = "view" ]; then convert $histfile x: rm -f $histfile fi exit 0