Hi,
I have been using for the last months a modified version of the MaskedStretch script and I think that it could be useful for someone. The original version by David Serrano works wonderfully, however, it is a bit slow. I have made some changes that can reduce a lot the computation time.
The main change is how to calculate the value of the MTF that it is applied in each step. The algorithm used by David is a binary search that applies N times the MTF to image varying the MTF parameter until the desired MTF is found. This would be very slow so the version by David uses an approximation that consists in applying the MTF to a reduced version of the image. This is the reason that the median of the resulting image is not the target median, although usually near enough.
In my version I use a different approximation. Instead applying the MTF to a thumbnail of the image, I apply the MTF only to the median value. Applying the MTF to a value is much faster than applying it to an image. The result is a MTF parameter very similar (although not the same) to that found by David's algorithm.
The result of the changes is that an stretch that in my computer takes 74.3 seconds with the version 0.8, only takes 25.8 with the version 0.9.
If anyone wants to try the script, this is the code:
/*
masked-stretch.js v0.9
Iteratively stretch histogram with an appropriate luminance mask each time.
Copyright (C) 2007 David Serrano, 2010 Andres del Pozo
This program is free software: you can redistribute it and/or modify it
under the terms of the GNU General Public License as published by the
Free Software Foundation, version 3 of the License.
This program is distributed in the hope that it will be useful, but WITHOUT
ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
more details.
You should have received a copy of the GNU General Public License along with
this program. If not, see <http://www.gnu.org/licenses/>.
*/
/*
Special thanks go to (in chronological order):
- Carlos Sonnenstein, Carlos Milovic and Juan Conejero for their invaluable
contributions (ideas, knowledge, code and probably more).
- My english teacher for telling me that a contribution can't be valuable ;)
(refer to v0.4 if you don't get this).
Changelog:
0.9: (By AdP) Several speed optimizations. It uses an approximation for
calculating the median of the image after applying N times an MTF.
Instead of using a thumbnail, the MTF is applied to the original
median.
0.8: Now supports mask blurring with a convolution.
User interface reorganization.
Changed default parameters.
0.7.1: Corrected a scoping bug spotted by Iñaki Lizaso.
0.7: Now masks can be blurred by removing wavelet layers with the à trous
algorithm (dyadic scaling sequence).
Bug fix: mask shadows clipping wasn't working at all.
Better settings management, following the aberration-spotter scheme.
Removed settings compatibility with v0.4.
0.6.1: Got rid of the confirmation dialog when closing a temporary image.
0.6: User can now specify the amount of shadows that will be clipped in
each mask.
0.5.1: Adapted Settings.lastReadOK calls to latest PixInsight (build 329).
0.5: Code for calculating mtf completely rewritten (again). Now, instead
of using a binary search like in 0.4, it does linear interpolations.
New option to delete the settings saved by the script.
Sets up an RGB working space that gives all three color components
the same importance when calculating the luminance.
0.4.1: Better Settings handling. Still reads v0.4 settings.
0.4: Code for calculating mtf completely rewritten. Now works over a
small copy of the image, with real HT's and real masks.
Increased maximum number of iters to 200. Users always want more ;).
More code reorganization. Now I even like it :).
0.3.1: Fixed a couple of issues with settings. Should work as expected now.
0.3: Now the code is more OO. Hope it's well structured.
Uses the new Math.mtf() built-in function. Checks if iter == 0.
Initial user interface (mostly copy & paste).
0.2: Some code reorganization.
Now deduces MTF from given values: iterations and target median.
0.1.1: Bug fix: masked was not being inverted.
0.1: Initial version.
*/
// TODO: restore the image's original RGBWS.
// TODO: couldn't manage to disable some GUI elements depending on which
// RadioButton is pressed.
#define VERSION "0.9"
#define E 1e-4 // epsilon
#define SET_PREFIX "MST"
#define BLUR_METHOD_NONE 0
#define BLUR_METHOD_ATW 1
#define BLUR_METHOD_CONV 2
#include <pjsr/ColorSpace.jsh>
#include <pjsr/DataType.jsh>
#include <pjsr/FrameStyle.jsh>
#include <pjsr/Interpolation.jsh>
#include <pjsr/NumericControl.jsh>
#include <pjsr/ResizeMode.jsh>
#include <pjsr/SampleType.jsh>
#include <pjsr/Sizer.jsh>
#include <pjsr/UndoFlag.jsh>
#feature-id Utilities > MaskedStretch_0.9
#feature-info At the time of stretching the histogram of an image, \
it is better to do it several times changing the mask used every \
time — a long, boring task that is perfect for script automation. \
This script does precisely that.<br/>\
<br/>\
Copyright © 2007 David Serrano, © 2010 Andres del Pozo
//#feature-icon masked-stretch.xpm
function err_dialog() { // bad name, I know
this.__base__ = Dialog;
this.__base__();
this.l = new Label (this);
with (this.l) {
margin = 15;
wordWrapping = true;
minWidth = 500;
text = "Unable to calculate MTF value, probably because the " +
"median desired is too far from the current one. Increase " +
"the number of iterations.";
}
this.b = new PushButton (this);
this.b.text = " OK ";
this.b.onClick = function() { this.dialog.ok(); };
this.hsizer = new HorizontalSizer;
with (this.hsizer) {
margin = 6;
spacing = 6;
addStretch();
add (this.b);
addStretch();
}
this.sizer = new VerticalSizer;
with (this.sizer) {
margin = 6;
spacing = 6;
add (this.l);
addSpacing (4);
add (this.hsizer);
}
this.windowTitle = "Error";
this.adjustToContents();
this.setFixedSize();
}
err_dialog.prototype = new Dialog;
var err_dl = new err_dialog;
function mst_user_data() {
this.iter = 15;
this.target_median = 0.08;
this.shadows_clipping = 0; // mask shadows clipping
this.blur_method = BLUR_METHOD_NONE;
this.atw_layers = 2; // numberOfLayers to remove to masks
this.atw_scaling = 0; // scaling function
this.conv_pixels = 7;
this.delete_settings = false;
}
var user_data = new mst_user_data;
function mst_engine() {
var stats = new ImageStatistics;
var win; // image window we'll modify
var hist = new HistogramTransformation;
var shadows_clipping = new HistogramTransformation;
var mtf;
this.atw_scaling_function = function (name, kernel) {
this.name = name;
this.kernel = kernel;
}
this.atw_scaling_functions = new Array (
new this.atw_scaling_function ("3x3 Linear Interpolation", [
1/16, 1/8, 1/16,
1/8, 1/4, 1/8,
1/16, 1/8, 1/16
]),
new this.atw_scaling_function ("3x3 Gaussian", [
1/25, 1/5, 1/25,
1/5, 1, 1/5,
1/24, 1/5, 1/25
]),
new this.atw_scaling_function ("5x5 B3 Spline", [
1/256, 1/64, 3/128, 1/64, 1/256,
1/64, 1/16, 3/32, 1/16, 1/64,
3/128, 3/32, 9/64, 3/32, 3/128,
1/64, 1/16, 3/32, 1/16, 1/64,
1/256, 1/64, 3/128, 1/64, 1/256
]),
new this.atw_scaling_function ("5x5 Gaussian", [
0.00723,0.0459, 0.085, 0.0459, 0.00723,
0.0459, 0.29155,0.53995,0.29155,0.0459,
0.085, 0.53995,1, 0.53995,0.085,
0.0459, 0.29155,0.53995,0.29155,0.0459,
0.00723,0.0459, 0.085, 0.0459, 0.00723
])
);
// Contributed by Juan Conejero. Will be removed when the same functionality
// makes its way to the PCL.
this.gaussian_filter = function (size, shape) {
const epsilon = 0.01; // maximum truncation error
size = Math.max (3, size|1); // ensure odd dimension >= 3
// The filter array
var h = new Array;
// Standard deviation of the filter distribution
var sigma = (size >> 1) / Math.pow (-shape * Math.ln (epsilon), 1/shape);
var rk = shape * Math.pow (sigma, shape);
// Optimized code
for (var i = 0, n2 = size >> 1, y = -n2; y <= n2; ++y) {
if (y > 0) {
for (var x = 0, yy = y+y; x < size; ++x, ++i) {
h[i] = h[i - yy*size];
}
} else {
for (var x = -n2; x <= n2; ++x, ++i) {
h[i] = (x > 0) ?
h[i - (x+x)] :
Math.exp (-Math.pow (Math.sqrt (x*x + y*y), shape) / rk);
}
}
}
return h;
}
this.set_win = function (w) {
if (w.isNull)
throw Error ("Invalid/No image window.");
this.win = w;
}
this.get_luma = function (view) {
var luma=new Image;
view.image.extractLuminance (luma);
return luma;
}
// obtains luma, then its median.
this.get_median = function (view) {
var l = this.get_luma (view);
stats.generate (l);
return stats.median;
};
this.modify_mask = function (view) {
// let's clip the shadows a bit
if (user_data.shadows_clipping) {
shadows_clipping.executeOn (view, false);
}
// do atw
if (user_data.blur_method == BLUR_METHOD_ATW) {
var atw = view.image.ATW (
this.atw_scaling_functions[user_data.atw_scaling].kernel,
user_data.atw_layers
);
with (view) {
beginProcess (UndoFlag_NoSwapFile);
image.transfer (atw[user_data.atw_layers]); // residual layer
endProcess();
}
}
// convolution
if (user_data.blur_method == BLUR_METHOD_CONV) {
with (view) {
beginProcess (UndoFlag_NoSwapFile);
if (user_data.conv_pixels <= 15) {
image.convolve (
this.gaussian_filter (user_data.conv_pixels, 2)
);
} else {
image.convolveFFT (
this.gaussian_filter (user_data.conv_pixels, 2)
);
}
endProcess();
}
}
}
this.stretch = function (win) {
var v = win.mainView;
hist.H = [
// shadows, midtones, highlights, ext_shadows, ext_hilights
[0, 0.5, 1, 0, 1], // R
[0, 0.5, 1, 0, 1], // G
[0, 0.5, 1, 0, 1], // B
[0, this.mtf, 1, 0, 1], // RGB
[0, 0.5, 1, 0, 1], // Alpha
];
// window that will hold the luminance for masking
var luma_w = new ImageWindow (
500, 500, 1, v.image.bitsPerSample,
v.image.sampleType == SampleType_Real,
false, "MST_luma"
);
for (var i = 0; i < user_data.iter; i++) {
var luma = this.get_luma (v);
// transfer image to window, modify and apply as mask
with (luma_w.mainView) {
beginProcess (UndoFlag_NoSwapFile);
image.transfer (luma); // luma is no longer an Image after this
endProcess();
}
this.modify_mask (luma_w.mainView);
//luma_w.show();
win.maskVisible = false;
win.maskInverted = true;
win.mask = luma_w;
// stretch
hist.executeOn (win.mainView, true);
// clean up
luma_w.removeMaskReferences(); // automatically removes mask from win
gc();
}
luma_w.undoAll();
luma_w.purge();
luma_w.close();
luma_w = null;
gc();
}
// simulateStretch applies the MTF to the median. The result is an
// approximation to applying the MTF to the image and then calculting the median
this.simulateStretch = function (win) {
var median = this.get_median(win.mainView);
for (var i = 0; i < user_data.iter; i++)
median=Math.mtf(this.mtf,median);
console.writeln(format("MTF=%.5f median=%.5f", this.mtf, median));
return median;
}
this._do_calc_mtf = function (w) {
console.writeln("Calculating the MTF value:");
var om = this.get_median (w.mainView); // original median
var tm = user_data.target_median;
var it = user_data.iter;
// if users don't try this, Murphy will do
if (Math.abs (om - tm) < E) {
this.mtf = 0.5;
return;
}
var mtf1; var median1;
var mtf2; var median2;
var temp_median;
if (om < tm) { // going to lighten the image
mtf1 = 0.20;
this.mtf = mtf1;
median1=this.simulateStretch(w);
// did we have a lucky strike?
if (Math.abs (median1 - tm) < E)
return; // this.mtf is already set
if (tm > median1) {
// user wants a big stretch, we won't handle it
this.mtf = -1;
}
mtf2 = 0.50; median2 = om;
} else {
mtf1 = 0.50; median1 = om;
mtf2 = 0.70;
this.mtf = mtf2;
median2=this.simulateStretch(w);
// did we have a lucky strike?
if (Math.abs (median2 - tm) < E)
return; // this.mtf is already set
if (tm < median2) {
// user wants a big stretch, we won't handle it
this.mtf = -1;
}
}
// we're not setting this.mtf to -1 in the following loop, but it
// can have this value from the previous checks made. So this is
// actually "if (-1 != this.mtf) { while (1) { blah blah; } }"
// with just one level of indentation
while (-1 != this.mtf) {
// heart of the algorithm
this.mtf = (1 * (tm-median2) / (median1-median2)) * (mtf1-mtf2) + mtf2;
temp_median=this.simulateStretch(w);
if (Math.abs (temp_median - tm) < E)
break; // this.mtf is already set
// USERS KEEP ON DEMANDING PERL!! ;)
// (($temp_median > $tm) ? ($mtf1, $median1) : ($mtf2, $median2)) =
// ($this_mtf, $temp_median);
if (temp_median > tm) { // image too light
mtf1 = this.mtf;
median1 = temp_median;
} else {
mtf2 = this.mtf;
median2 = temp_median;
}
}
gc();
}
this.calc_mtf = function() {
var v = this.win.mainView;
var thumb_h = 300;
var thumb_w= v.image.width*thumb_h/v.image.height;
// apply a better RGB working space
var rgbws = new RGBWorkingSpace;
with (rgbws) {
channels = [ // Y, x, y
[1.000000, 0.648431, 0.330856],
[1.000000, 0.321152, 0.597871],
[1.000000, 0.155886, 0.066044]
];
gamma = 2.20;
sRGBGamma = true;
applyGlobalRGBWS = false;
executeOn (v); // TODO: how to revert this?
}
// build HT object for the mask shadows clipping
shadows_clipping.H = [
// shadows, midtones, highlights, ext_shadows, ext_hilights
[0, 0.5, 1, 0, 1], // R
[0, 0.5, 1, 0, 1], // G
[0, 0.5, 1, 0, 1], // B
[user_data.shadows_clipping, 0.5, 1, 0, 1], // RGB
[0, 0.5, 1, 0, 1], // Alpha
];
this._do_calc_mtf (this.win);
if (-1 == this.mtf) {
err_dlg.execute();
return false;
}
return true;
}
this.work = function() {
this.stretch (this.win);
}
}
var engine = new mst_engine;
function mst_dialog() {
this.__base__ = Dialog;
this.__base__();
this.label_width = this.font.width ("Wavelets scaling function:");
this.edit_width = this.font.width ( "0000000" );
// help label
this.helpLabel = new Label (this);
with (this.helpLabel) {
frameStyle = FrameStyle_Box;
margin = 4;
wordWrapping = true;
useRichText = true;
text = "<p><b>MaskedStretch v"+VERSION+"</b> — A " +
"script to perform a histogram stretch of an image via several " +
"small luminance-masked histogram transformations. This is good for " +
"preventing small stars from growing without control.</p>" +
"<p>Copyright © 2007 David Serrano, © 2010 Andres del Pozo</p>" +
"<p>Choose the number of iterations and the median of the desired " +
"image (typically in the range 0.05 - 0.10). Then click 'Ok' " +
"and wait for a new image to be created. A smaller copy of the "+
"image will show the progress of the script.</p>" +
"<p>You can specify an optional number of <i>à trous</i> wavelet " +
" layers to be removed from the mask in order to blur it. Alternatively, " +
"it can be blurred using a convolution too.</p>" +
"<p>This program can't do aggressive changes with few iterations. " +
"An error message will appear if you try to do so.</p>";
}
// iterations
this.iter_NC = new NumericControl (this);
with (this.iter_NC) {
label.text = "Iterations:";
label.minWidth = this.label_width + 6+1; // align with labels inside GroupBox below
setRange (1, 200);
slider.setRange (0, 199);
//slider.minWidth = 150;
setPrecision (0);
setValue (user_data.iter);
edit.setFixedWidth( this.edit_width );
toolTip = "Number of histogram transformations."
onValueUpdated = function (value) { user_data.iter = value; };
}
// median
this.median_NC = new NumericControl (this);
with (this.median_NC) {
label.text = "Target median:";
label.minWidth = this.label_width + 6+1; // align with labels inside GroupBox below
setRange (0.001, 0.2);
slider.setRange (0, 1000);
slider.minWidth = 400;
setPrecision (3);
setValue (user_data.target_median);
edit.setFixedWidth( this.edit_width );
toolTip = "Desired median of the resulting image.";
onValueUpdated = function (value) { user_data.target_median = value; };
}
// mask shadows clipping
this.shadows_clipping_NC = new NumericControl (this);
with (this.shadows_clipping_NC) {
label.text = "Shadows clipping:";
label.minWidth = this.label_width;
setRange (0, 0.4);
slider.setRange (0, 401);
//slider.minWidth = 400;
setPrecision (3);
setValue (user_data.shadows_clipping);
edit.setFixedWidth( this.edit_width );
toolTip = "Shadows clipping to perform on each luminance mask";
onValueUpdated = function (value) { user_data.shadows_clipping = value; };
}
// mask blur method: label
this.blur_method_LBL = new Label (this);
with (this.blur_method_LBL) {
text = "Blur method:";
textAlignment = TextAlign_Right | TextAlign_VertCenter;
minWidth = this.label_width;
}
// mask blur method: radio buttons
this.blur_method_NONE_R = new RadioButton (this);
with (this.blur_method_NONE_R) {
text = "None";
if (user_data.blur_method == BLUR_METHOD_NONE) { checked = true; }
onCheck = function(checked) {
if (checked) {
user_data.blur_method = BLUR_METHOD_NONE;
} else {
}
};
}
this.blur_method_ATW_R = new RadioButton (this);
with (this.blur_method_ATW_R) {
text = "À trous wavelets";
if (user_data.blur_method == BLUR_METHOD_ATW) { checked = true; }
onCheck = function(checked) {
if (checked) {
user_data.blur_method = BLUR_METHOD_ATW;
} else {
}
};
}
this.blur_method_CONV_R = new RadioButton (this);
with (this.blur_method_CONV_R) {
text = "Convolution";
if (user_data.blur_method == BLUR_METHOD_CONV) { checked = true; }
onCheck = function(checked) {
if (checked) {
user_data.blur_method = BLUR_METHOD_CONV;
} else {
}
};
}
// mask blur method: sizer
this.blur_method_HS = new HorizontalSizer (this);
with (this.blur_method_HS) {
spacing = 16;
add (this.blur_method_LBL);
add (this.blur_method_NONE_R);
add (this.blur_method_ATW_R);
add (this.blur_method_CONV_R);
addStretch();
}
// mask atw layers to remove
this.atw_layers_NC = new NumericControl (this);
with (this.atw_layers_NC) {
label.text = "Wavelet layers to remove:";
label.minWidth = this.label_width;
setRange (1, 5);
slider.setRange (0, 5);
//slider.minWidth = 400;
setPrecision (0);
setValue (user_data.atw_layers);
edit.setFixedWidth( this.edit_width );
toolTip = "<p>À trous wavelet layers to remove from masks</p>";
onValueUpdated = function (value) { user_data.atw_layers = value; };
}
// mask atw scaling function: label
this.atw_scaling_LBL = new Label (this);
with (this.atw_scaling_LBL) {
text = "Wavelet scaling function:";
textAlignment = TextAlign_Right | TextAlign_VertCenter;
minWidth = this.label_width;
toolTip = "Scaling function used to calculate layers";
}
// mask atw scaling function: combobox
this.atw_scaling_CB = new ComboBox (this);
with (this.atw_scaling_CB) {
for (var i = 0; i < engine.atw_scaling_functions.length; i++) {
addItem (engine.atw_scaling_functions[i].name);
}
currentItem = user_data.atw_scaling;
toolTip = "Scaling function used to calculate layers";
onItemSelected = function (index) { user_data.atw_scaling = index; };
}
// mask atw scaling function: sizer
this.atw_scaling_HS = new HorizontalSizer (this);
with (this.atw_scaling_HS) {
spacing = 6;
add (this.atw_scaling_LBL);
add (this.atw_scaling_CB, 100);
}
// mask convolution kernel size
this.conv_pixels_NC = new NumericControl (this);
with (this.conv_pixels_NC) {
label.text = "Convolution kernel size:";
label.minWidth = this.label_width;
setRange (3, 31);
slider.setRange (0, 14);
setPrecision (0);
setValue (user_data.conv_pixels);
edit.setFixedWidth( this.edit_width );
toolTip = "Kernel size for the convolution, in pixels";
onValueUpdated = function (value) { user_data.conv_pixels = value; };
}
// mask sizer
this.mask_VS = new VerticalSizer (this);
with (this.mask_VS) {
margin = 6;
spacing = 6;
add (this.shadows_clipping_NC);
addSpacing (4);
add (this.blur_method_HS);
addSpacing (4);
add (this.atw_layers_NC);
add (this.atw_scaling_HS);
addSpacing (4);
add (this.conv_pixels_NC);
}
// mask groupbox
this.mask_GB = new GroupBox (this);
with (this.mask_GB) {
title = "Mask";
sizer = this.mask_VS;
}
// delete settings checkbox
this.ds_CB = new CheckBox (this);
with (this.ds_CB) {
text = "Delete settings and exit";
checked = user_data.delete_settings;
toolTip = "Check to remove the stored settings and exit the program.";
onCheck = function (checked) { user_data.delete_settings = checked; };
}
// buttons
this.ok_Button = new PushButton (this);
this.ok_Button.text = " OK ";
this.ok_Button.onClick = function() { this.dialog.ok(); };
this.cancel_Button = new PushButton (this);
this.cancel_Button.text = " Cancel ";
this.cancel_Button.onClick = function() { this.dialog.cancel(); };
this.buttons_Sizer = new HorizontalSizer;
this.buttons_Sizer.spacing = 4;
this.buttons_Sizer.addStretch();
this.buttons_Sizer.add (this.ok_Button);
this.buttons_Sizer.add (this.cancel_Button);
// pack everything
this.sizer = new VerticalSizer;
with (this.sizer) {
margin = 6;
spacing = 6;
add (this.helpLabel);
addSpacing (4);
add (this.iter_NC);
add (this.median_NC);
add (this.mask_GB);
addSpacing (4);
add (this.ds_CB);
addSpacing (4);
add (this.buttons_Sizer);
}
this.windowTitle = "MaskedStretch v" + VERSION;
this.adjustToContents();
this.setFixedSize();
}
mst_dialog.prototype = new Dialog;
function mst_settings() {
var conf = new Array (
// important: use the same name as the user_data variables
new Array ("iter", DataType_UInt8),
new Array ("target_median", DataType_Float),
new Array ("shadows_clipping", DataType_Float),
new Array ("blur_method", DataType_UInt8),
new Array ("atw_layers", DataType_UInt8),
new Array ("atw_scaling", DataType_UInt8),
new Array ("conv_pixels", DataType_UInt8)
);
this.load = function() {
var temp;
for (var i in conf) {
var str = conf[i][0];
var type = conf[i][1];
temp = Settings.read (SET_PREFIX + "/" + str, type);
if (Settings.lastReadOK) {
user_data[str] = temp;
} else {
console.writeln (format (
"Warning: couldn't read setting '%s/%s'", SET_PREFIX, str
));
}
}
}
this.save = function() {
for (var i in conf) {
var str = conf[i][0];
var type = conf[i][1];
Settings.write (SET_PREFIX + "/" + str, type, user_data[str]);
}
}
this.del = function() {
Settings.remove (SET_PREFIX);
}
}
var mst_set = new mst_settings;
function main() {
console.hide();
engine.set_win (ImageWindow.activeWindow);
mst_set.load();
var dialog = new mst_dialog; // has to be done after settings are loaded
if (!dialog.execute())
return;
if (user_data.delete_settings) {
mst_set.del();
console.show();
return;
} else {
mst_set.save();
}
console.abortEnabled = true;
var startts = new Date;
if (engine.calc_mtf()) {
console.writeln (format ("Using MTF of %6.04f.", engine.mtf));
engine.work();
} else {
console.writeln ("Image not modified (but its RGBWS *was* modified).");
}
var endts = new Date;
console.writeln (format ("<br><b>MaskedStretch</b>: %.2f s",
(endts.getTime() - startts.getTime()) / 1000));
console.show();
}
main();