ImageOps for image viewer with multiple area selections support, e.g. selective blurring

What is it?

This script keeps selected portions of the image in the image viewer and blurs the rest. It is both a proof-of-concept and boilerplate for your own implementations. It supports multiple selections and if you navigate to a new image after adding areas to another images, the areas are automatically cleared.

Quick recap: In this thread @David talked about recompressing certain areas of images in different qualities while retaining other areas untouched. This brought me to the idea of using selective blurring instead but with adjustments this could also be used for compressing different portions in different qualities (I'd suggest converting everything to PNG first, reconverting back to JPG in last step). And Jon kindly added the new selection property to Viewer object I requested which was the last missing ingredient, which I tested today, and voila, this is the result.

Setup

There are 3 commands:

  • cuImageOpsClearCache: Clears the cache which keeps the selections; cache is necessary since the script might have been unloaded between adding areas

  • cuImageOpsAddToRect: Adds the selection to the list of areas to be processed

  • cuImageOpsBlurUnselectedPortions: Sample implementation - keeps the selected areas sharp while blurring the rest.

Add these buttons to your image viewer toolbar or assign keyboard shortcuts, etc. Use clear cache command if you want to start anew.

You need ImageMagick (e.g. Portable Win64 static at 8 bits-per-pixel) and need to set the path to magick.exe in script settings.

Usage

  1. Select an area and add it to the list (cuImageOpsAddToRect)
  2. Repeat as necessary...
  3. Click 8-ball (cuImageOpsBlurUnselectedPortions)

Adjust the generated file name in method getNewFileName() as you see fit.

At the moment, I am not planning to expand further on this idea, or fix bugs, or accept feature requests, but the code should be self-explanatory and you could put DOpus script dialogs on top and annotate areas with user input, draw borders around them, magnify them, export each area individually, etc. The sky is your limit.

Demo

Step 1:

Step 2:

Output:

Script:

// @ts-check
/* eslint quotes: ['error', 'single'] */
/* eslint-disable no-inner-declarations */
/* global ActiveXObject DOpus Script */
///<reference path="./_DOpusDefinitions.d.ts" />

var DEBUG = true;
function $dbg(/**@type {any}*/str) { if (DEBUG) DOpus.output(str); }
function $dbgClear() { if (DEBUG) DOpus.clearOutput(); }

$dbgClear();
var CACHE = 'CACHE';
var IMPATH = 'Magick.exe path';


function OnInit(/** @type {DOpusScriptInitData} */ initData) { // eslint-disable-line no-unused-vars
    initData.name = 'cuImageOps';
    initData.desc = 'Perform image operations on the image using selected portions in the DOpus viewer';
    initData.default_enable = true;
    initData.group = 'cy';

    initData.config[IMPATH] = 'full path to magick.exe';
}

function OnAddColumns(/** @type {DOpusAddColData} */ colData) { // eslint-disable-line no-unused-vars
    // nothing yet
}

function OnAddCommands(/** @type {DOpusAddCmdData} */ cmdData) { // eslint-disable-line no-unused-vars
    var cmd;

    cmd = cmdData.addCommand();
    cmd.name = 'cuImageOpsClearCache';
    cmd.method = 'cuImageOpsClearCache';
    cmd.icon = 'clearfilters';
    cmd.desc = 'clears the internal cache for image change detection, use only for debugging purposes';

    cmd = cmdData.addCommand();
    cmd.method = 'cuImageOpsAddToRect';
    cmd.name = 'cuImageOpsAddToRect';
    cmd.icon = 'recursivefilter';
    cmd.desc = 'adds the selected area to internal buffer, so you can perform operations on multiple areas';

    cmd = cmdData.addCommand();
    cmd.method = 'cuImageOpsBlurUnselectedPortions';
    cmd.name = 'cuImageOpsBlurUnselectedPortions';
    cmd.icon = '8ball';
    cmd.desc = 'performs the image operation - in this case blurring the unselected portions';
}


/** @returns void */
function cuImageOpsClearCache() { // eslint-disable-line no-unused-vars
    Script.vars.set(CACHE, '');
    $dbg('cleared cache');
}
/** @returns {string} */
function _cuImageSetCache(/** @type {DOpusViewerImage} */ img) {
    var _tmp = JSON.stringify(img, null, 4);
    Script.vars.set(CACHE, _tmp);
    return _tmp;
}
/** @returns {string} */
function _cuImageGetCache() {
    if (!Script.vars.exists(CACHE)) cuImageOpsClearCache();
    return Script.vars.get(CACHE);
}

function cuImageOpsAddToRect(/** @type {DOpusScriptCommandData} */ cmdData) { // eslint-disable-line no-unused-vars
    var v = cmdData.func.viewer;
    if (!v || !v.selection || !v.selection.width || !v.selection.height) return;
    // read the image from cache if any
    var img = getDOpusViewerImage('' + v.current.realpath, v.imageSize);
    // if the viewed image has been changed reset cache
    if (img && typeof img.path !== 'undefined' && img.path !== '' + v.current.realpath) {
        $dbg('image change detected, resetting image');
        cuImageOpsClearCache();
        img = new DOpusViewerImage('' + v.current.realpath, v.imageSize, v.selection);
    }
    // add the selection and set cache
    _cuImageSetCache(img.addToRects(v.selection));
    $dbg('current cache: ' + _cuImageGetCache());
}

function getNewFileName(/** @type {DOpusItem} */ item) {
    return '' + item.path + '\\' + item.name_stem + '-' + getISODateTime();
}

function cuImageOpsBlurUnselectedPortions(/** @type {DOpusScriptCommandData} */ cmdData) { // eslint-disable-line no-unused-vars
    var v = cmdData.func.viewer;
    if (!v) return;
    var img = getDOpusViewerImage('' + v.current.realpath, v.imageSize);
    $dbg(pmsprintf('performing operation -- path: %s, size: %s', img.path, JSON.stringify(img.size)));
    var magick_bin = Script.config[IMPATH];
    var cmdline, shell = new ActiveXObject('WScript.shell');

    var original_file = '' + v.current.realpath;
    var _outbase = getNewFileName(v.current);
    var output_file = _outbase + v.current.ext;

    var selected_areas = '';
    for (var rkey in img.rects) {
        /** @type {DOpusViewerRect}*/
        var r = img.rects[rkey];
        selected_areas += pmsprintf(' ( "%s" -crop %sx%s+%s+%s +repage ) -geometry +%s+%s -composite ', original_file, r.width, r.height, r.left, r.top, r.left, r.top);
    }

    cmdline = pmsprintf(
        '"%s" "%s" -blur 0x8 %s "%s"',
        magick_bin,
        original_file,
        selected_areas,
        output_file
    );
    $dbg(cmdline); shell.Run(cmdline, 0, true); // 0: hidden, true: wait
}


/** poor man's sprintf, only %s */
function pmsprintf(/** @type {String}*/ template) {
    var idx = 0, args = Array.prototype.slice.call(arguments, 1);
    return template.replace(/%s/g, function () {
        return idx < args.length ? args[idx++] : '%s';
    });
}

/** @returns {DOpusViewerImage} */
function getDOpusViewerImage(/** @type {string} */ path, /** @type {DOpusRect} */ size) {
    /** @type {DOpusViewerImage|undefined} */
    var img;
    var cache = _cuImageGetCache();
    if (cache) {
        try {
            $dbg('read from cache: ' + cache);
            var _tmp = JSON.parse(cache);
            img = new DOpusViewerImage(_tmp.path, _tmp.size, _tmp.rects);
        } catch (e) {
            $dbg('JSON.parse error: ' + e.toString());
        }
    }
    // if the image could not be parsed from cache (e.g. first time this script is called), create a new one
    if (!img) {
        $dbg('initializing new image');
        img = new DOpusViewerImage(path, size);
    }
    $dbg('getDOpusViewerImage() -- img: ' + JSON.stringify(img, null, 4));
    return img;
}


function DOpusViewerRect(/** @type {DOpusRect} */ rect) {
    this.left = rect.left;
    this.right = rect.right;
    this.top = rect.top;
    this.bottom = rect.bottom;
    this.width = rect.width;
    this.height = rect.height;
}

DOpusViewerRect.prototype.getID = function () {
    return pmsprintf('%s.%s.%s.%s', this.left, this.top, this.right, this.bottom);
};


function DOpusViewerImage(/** @type {string} */ path, /** @type {DOpusRect} */ size, /** @type {any} */ rects) {
    this.path = path;
    this.size = new DOpusViewerRect(size);
    if (rects && Object.keys(rects)) {
        for (var r in rects) this.addToRects(rects[r]);
    }
}

DOpusViewerImage.prototype.addToRects = function (/** @type {DOpusRect} */ rect) {
    if (!this.rects) this.rects = {};
    var _tmp = new DOpusViewerRect(rect);
    this.rects[_tmp.getID()] = _tmp;
    return this;
};


function getISODateTime() {
    var oDate = new Date();
    // sample call:
    // ISODateTime = new Date(getNewNameData.item.modify).toISODateTime();
    var vYear = oDate.getFullYear();
    var vMonth = '' + (1 + oDate.getMonth()); if (vMonth.length == 1) { vMonth = '0' + vMonth; }
    var vDay = oDate.getDate().toString(); if (vDay.length == 1) { vDay = '0' + vDay; }
    var vHours = oDate.getHours().toString(); if (vHours.length == 1) { vHours = '0' + vHours; }
    var vMinutes = oDate.getMinutes().toString(); if (vMinutes.length == 1) { vMinutes = '0' + vMinutes; }
    var vSeconds = oDate.getSeconds().toString(); if (vSeconds.length == 1) { vSeconds = '0' + vSeconds; }
    var ISODateTime = '' + vYear + vMonth + vDay + '-' + vHours + vMinutes + vSeconds;
    return ISODateTime;
}


/** {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Object/keys */
if (!Object.keys) {
    Object.keys = (function () {
        'use strict';
        var hasOwnProperty = Object.prototype.hasOwnProperty,
            hasDontEnumBug = !({ toString: null }).propertyIsEnumerable('toString'),
            dontEnums = [
                'toString',
                'toLocaleString',
                'valueOf',
                'hasOwnProperty',
                'isPrototypeOf',
                'propertyIsEnumerable',
                'constructor'
            ],
            dontEnumsLength = dontEnums.length;

        return function (obj) {
            if (typeof obj !== 'function' && (typeof obj !== 'object' || obj === null)) {
                throw new TypeError('Object.keys called on non-object');
            }
            var result = [], prop, i;
            for (prop in obj) {
                if (hasOwnProperty.call(obj, prop)) {
                    result.push(prop);
                }
            }
            if (hasDontEnumBug) {
                for (i = 0; i < dontEnumsLength; i++) {
                    if (hasOwnProperty.call(obj, dontEnums[i])) {
                        result.push(dontEnums[i]);
                    }
                }
            }
            return result;
        };
    }());
}
2 Likes

Thankyou for your script.
This isn't quite what I meant, but I am happy it has inspired you to write this.
I definitely think this script fills a need and will prove to be very useful.

I have installed the script, configured the path, and added the buttons to the Edit Menu of the Image Viewer. The script runs, but the resultant image is not created. I don't know why.
I have uploaded the script log file.

Edit: Problem fixed. I deleted the log file.

1 Like

Yep, definitely inspired by your idea & the app you mentioned. ImageMagick is something I'm not an expert in, otherwise I'd implemented what you requested, too. It's basically the very same algorithm though: clone the image, compress it in a low quality, and re-paste the original parts on top (or recompress in a better setting than the background, and then paste on top).

Everything looks fine, except the fact that the script does not check if and what IM returns if an error occurs. If you copy-paste & run the last line in a Windows cmd.exe prompt manually, what do you get? I suspect the (1) in name might cause problems.

"C:\4Prompt\Solution\Photo\ImageMagick-7.1.1-29-portable-Q8-x64\magick.exe" "H:\Photo_Processed\CellPhone\2022-11-05_21-13-25(1).png" -blur 0x8 ( "H:\Photo_Processed\CellPhone\2022-11-05_21-13-25(1).png" -crop 2035x1596+596+1587 +repage ) -geometry +596+1587 -composite ( "H:\Photo_Processed\CellPhone\2022-11-05_21-13-25(1).png" -crop 1626x1294+1364+688 +repage ) -geometry +1364+688 -composite "H:\Photo_Processed\CellPhone2022-11-05_21-13-25(1)-20240227-110821.png"

EDIT: nvm, I see the problem, this section is missing a slash: ...CellPhone2022... Probably there are a bunch of files in your H:\Photo_Processed folder now :smiley: Edited the script above, you can copy-paste it again.

1 Like

Yes, there were several output photos in the parent directory.
I installed the new version of the script and also eliminated (1) from the filename to simplify things.
The resultant images are now being generated in the proper folder.
Thanks Much !

Is there an easy way to adjust the degree of blurring ?

Yes, it is -blur 0x8 portion in cuImageOpsBlurUnselectedPortions(). Experiment with it to suit your needs.

-blur radius[xsigma]

  • radius: This specifies the radius of the Gaussian, in pixels, not counting the center pixel. The radius parameter specifies the number of pixels to be included in the blur calculation. A larger radius will result in a more significant blur effect.
  • xsigma: This specifies the standard deviation (sigma) of the Gaussian, in pixels. It defines the spread of the blur. If xsigma is not given, it defaults to the radius. The sigma parameter controls how the blur spreads. A larger sigma means that the image will be blurred over a wider area. If xsigma is provided, it follows the radius parameter and is separated by an x.