From 9e4a72e098ac65aec730998439c702b164f1032d Mon Sep 17 00:00:00 2001 From: Bill Ferguson Date: Wed, 8 Aug 2018 22:41:25 -0400 Subject: [PATCH 1/2] Creeated image_stack.lua based on Pat David's blog entry, https://patdavid.net/2013/05/noise-removal-in-photos-with-median_6.html. Updated README.md to reflect the new script. --- README.md | 1 + contrib/image_stack.lua | 582 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 583 insertions(+) create mode 100644 contrib/image_stack.lua diff --git a/README.md b/README.md index a26eacc..66de2f0 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,7 @@ geoToolbox|No|LMW|A toolbox of geo functions gimp|No|LMW|Open an image in GIMP for editing and return the result gpx_export|No|LMW|Export a GPX track file from selected images GPS data hugin|No|LMW|Combine selected images into a panorama and return the result +image_stack|No|LMW|Combine a stack of images to remove noise or transient objects kml_export|No|L|Export photos with a KML file for usage in Google Earth LabelsToTags|?|LMW|Apply tags based on color labels and ratings passport_guide|Yes|LMW|Add passport cropping guide to darkroom crop tool diff --git a/contrib/image_stack.lua b/contrib/image_stack.lua new file mode 100644 index 0000000..b1ef0f7 --- /dev/null +++ b/contrib/image_stack.lua @@ -0,0 +1,582 @@ +--[[ + + image_stack.lua - process a stack of images + + Copyright (C) 2018 Bill Ferguson . + Copyright (C) 2016,2017 Holger Klemm + + 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; either version 3 of the License, or + (at your option) any later version. + + 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 . +]] +--[[ + image_stack - export a stack of images and process them, returning the result + + This script provides another storage (export target) for darktable. Selected + images are exported in the specified format to temporary storage. The images are aligned + if the user requests it. When the images are ready, imagemagick is launched and uses + the selected evaluate-sequence operator to process the images. The output file is written + to a filename representing the imput files in the format specified by the user. The resulting + image is imported into the film roll. The source images can be tagged as part of the file + creation so that a user can later find the contributing images. + + ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT + * align_image_stack - http://www.hugin.org + * imagemagick - http://www.imagemagick.org + + USAGE + * require this script from your main lua file + * select the images to process with image_stack + * in the export dialog select "image stack" and select the format and bit depth for the + exported image + * Select whether the images need to be aligned. + * Select the stack operator + * Select the output format + * Select whether to tag the source images used to create the resulting file + * Specify executable locations if necessary + * Press "export" + * The resulting image will be imported + + NOTES + Mean is a fairly quick operation. On my machine (i7-6800K, 16G) it takes a few seconds. Median, on the other hand + takes approximately 10x longer to complete. Processing 10 and 12 image stacks took over a minute. I didn't test all + the other functions, but the ones I did fell between Mean and Median performance wise. + + BUGS, COMMENTS, SUGGESTIONS + * Send to Bill Ferguson, wpferguson@gmail.com + + CHANGES + + THANKS + * Thanks to Pat David and his blog entry on blending images, https://patdavid.net/2013/05/noise-removal-in-photos-with-median_6.html +]] + +local dt = require "darktable" +local du = require "lib/dtutils" +local df = require "lib/dtutils.file" +local dtsys = require "lib/dtutils.system" +local gettext = dt.gettext +local job = nil + +-- works with LUA API version 5.0.0 +dt.configuration.check_version(...,{5,0,0}) + +-- Tell gettext where to find the .mo file translating messages for a particular domain +gettext.bindtextdomain("image_stack",dt.configuration.config_dir.."/lua/locale/") + +local function _(msgid) + return gettext.dgettext("image_stack", msgid) +end + +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +-- GUI definitions +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +local label_align_options= dt.new_widget("section_label"){ + label = _('image align options') +} + +local label_image_stack_options = dt.new_widget("section_label"){ + label = _("image stack options") +} + +local label_executable_locations = dt.new_widget("section_label"){ + label = _("executable locations") +} + +if not(dt.preferences.read("align_image_stack", "initialized", "bool")) then + dt.preferences.write("align_image_stack", "def_radial_distortion", "bool", false) + dt.preferences.write("align_image_stack", "def_optimize_field", "bool", false) + dt.preferences.write("align_image_stack", "def_optimize_image_center", "bool", true) + dt.preferences.write("align_image_stack", "def_auto_crop", "bool", true) + dt.preferences.write("align_image_stack", "def_distortion", "bool", true) + dt.preferences.write("align_image_stack", "def_grid_size", "integer", 5) + dt.preferences.write("align_image_stack", "def_control_points", "integer", 8) + dt.preferences.write("align_image_stack", "def_control_points_remove", "integer", 3) + dt.preferences.write("align_image_stack", "def_correlation", "integer", 9) + dt.preferences.write("align_image_stack", "initialized", "bool", true) +end + +if not(dt.preferences.read("image_stack", "initialized", "bool")) then + dt.preferences.write("image_stack", "align_images", "bool", true) + dt.preferences.write("image_stack", "stack_function", "integer", 2) + dt.preferences.write("image_stack", "output_format", "integer", 6) + dt.preferences.write("image_stack", "tag_images", "bool", true) + dt.preferences.write("image_stack", "initialized", "bool", true) +end + +local chkbtn_will_align = dt.new_widget("check_button"){ + label = _('perform image alignment'), + value = dt.preferences.read("image_stack", "align_images", "bool"), + tooltip = _('align the image stack before processing') +} + +local chkbtn_radial_distortion = dt.new_widget("check_button"){ + label = _('optimize radial distortion for all images'), + value = dt.preferences.read("align_image_stack", "def_radial_distortion", "bool"), + tooltip = _('optimize radial distortion for all images, \nexcept for first'), +} + +local chkbtn_optimize_field = dt.new_widget("check_button"){ + label = _('optimize field of view for all images'), + value = dt.preferences.read("align_image_stack", "def_optimize_field", "bool"), + tooltip =_('optimize field of view for all images, except for first. \nUseful for aligning focus stacks (DFF) with slightly \ndifferent magnification.'), +} + +local chkbtn_optimize_image_center = dt.new_widget("check_button"){ + label = _('optimize image center shift for all images'), + value = dt.preferences.read("align_image_stack", "def_optimize_image_center", "bool"), + tooltip =_('optimize image center shift for all images, \nexcept for first.'), +} + +local chkbtn_auto_crop = dt.new_widget("check_button"){ + label = _('auto crop the image'), + value = dt.preferences.read("align_image_stack", "def_auto_crop", "bool"), + tooltip =_('auto crop the image to the area covered by all images.'), +} + +local chkbtn_distortion = dt.new_widget("check_button"){ + label = _('load distortion from lens database'), + value = dt.preferences.read("align_image_stack", "def_distortion", "bool"), + tooltip =_('try to load distortion information from lens database'), +} + +local cmbx_grid_size = dt.new_widget("combobox"){ + label = _('image grid size'), + tooltip =_('break image into a rectangular grid \nand attempt to find num control points in each section.\ndefault: (5x5)'), + value = dt.preferences.read("align_image_stack", "def_grid_size", "integer"), --5 + "1", "2", "3","4","5","6","7","8","9", + reset_callback = function(self) + self.value = dt.preferences.read("align_image_stack", "def_grid_size", "integer") + end +} + +local cmbx_control_points = dt.new_widget("combobox"){ + label = _('control points/grid'), + tooltip =_('number of control points (per grid, see option -g) \nto create between adjacent images \ndefault: (8).'), + value = dt.preferences.read("align_image_stack", "def_control_points", "integer"), --8, "1", "2", "3","4","5","6","7","8","9", + "1", "2", "3","4","5","6","7","8","9", + reset_callback = function(self) + self.value = dt.preferences.read("align_image_stack", "def_control_points", "integer") + end +} + +local cmbx_control_points_remove = dt.new_widget("combobox"){ + label = _('remove control points with error'), + tooltip =_('remove all control points with an error higher \nthan num pixels \ndefault: (3)'), + value = dt.preferences.read("align_image_stack", "def_control_points_remove", "integer"), --3, "1", "2", "3","4","5","6","7","8","9", + "1", "2", "3","4","5","6","7","8","9", + reset_callback = function(self) + self.value = dt.preferences.read("align_image_stack", "def_control_points_remove", "integer") + end +} + +local cmbx_correlation = dt.new_widget("combobox"){ + label = _('correlation threshold for control points'), + tooltip =_('correlation threshold for identifying \ncontrol points \ndefault: (0.9).'), + value = dt.preferences.read("align_image_stack", "def_correlation", "integer"), --9, "0,1", "0,2", "0,3","0,4","0,5","0,6","0,7","0,8","0,9", + "0.1", "0.2", "0.3","0.4","0.5","0.6","0.7","0.8","0.9","1.0", + reset_callback = function(self) + self.value = dt.preferences.read("align_image_stack", "def_correlation", "integer") + end +} + +local cmbx_stack_function = dt.new_widget("combobox"){ + label = _("select stack function"), + tooltip = _("select function to be \napplied to image stack"), + value = dt.preferences.read("image_stack", "stack_function", "integer"), "Mean", "Median", "Abs", "Add", "And", "Divide", "Max", "Min", + "Or", "Subtract", "Sum", "Xor", + reset_callback = function(self) + self.value = dt.preferences.read("image_stack", "stack_function", "integer") + end +} + +local cmbx_output_format = dt.new_widget("combobox"){ + label = _("select output format"), + tooltip = _("choose the format for the resulting image"), + value = dt.preferences.read("image_stack", "output_format", "integer"), "EXR", "JPG", "PNG", "PNM", "PPM", "TIF", + reset_callback = function(self) + self.value = dt.preferences.read("image_stack", "output_format", "integer") + end +} + +local chkbtn_tag_source_file = dt.new_widget("check_button"){ + label = _("tag source images used?"), + value = dt.preferences.read("image_stack", "tag_images", "bool"), + tooltip = _("tag the source images used to create the output file?") +} + +local image_stack_widget = dt.new_widget("box"){ + orientation = "vertical", + label_align_options, + chkbtn_will_align, + chkbtn_radial_distortion, + chkbtn_optimize_field, + chkbtn_optimize_image_center, + chkbtn_auto_crop, + chkbtn_distortion, + cmbx_grid_size, + cmbx_control_points, + cmbx_control_points_remove, + cmbx_correlation, + dt.new_widget("separator"){}, + dt.new_widget("separator"){}, + label_image_stack_options, + cmbx_stack_function, + cmbx_output_format, + chkbtn_tag_source_file, +} + +local executables = {"align_image_stack", "convert"} + +if dt.configuration.running_os ~= "linux" then + image_stack_widget[#image_stack_widget + 1] = df.executable_path_widget(executables) +end + +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +-- local functions +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +local function show_status(storage, image, format, filename, number, total, high_quality, extra_data) + dt.print(string.format(_("Export Image %i/%i"), number, total)) +end + +-- read the gui and populate the align_image_stack arguments + +local function get_align_image_stack_arguments() + + --Setup Align Image Stack Arguments-- + local align_args = "" + if (chkbtn_radial_distortion.value) then align_args = align_args .. " -d" end + if (chkbtn_optimize_field.value) then align_args = align_args .. " -m" end + if (chkbtn_optimize_image_center.value) then align_args = align_args .. " -i" end + if (chkbtn_auto_crop.value) then align_args = align_args .. " -C" end + if (chkbtn_distortion.value) then align_args = align_args .. " --distortion" end + + dt.preferences.write("align_image_stack", "def_radial_distortion", "bool", chkbtn_radial_distortion.value) + dt.preferences.write("align_image_stack", "def_optimize_field", "bool", chkbtn_optimize_field.value) + dt.preferences.write("align_image_stack", "def_optimize_image_center", "bool", chkbtn_optimize_image_center.value) + dt.preferences.write("align_image_stack", "def_auto_crop", "bool", chkbtn_auto_crop.value) + dt.preferences.write("align_image_stack", "def_distortion", "bool", chkbtn_distortion.value) + + align_args = align_args.." -g "..cmbx_grid_size.value + align_args = align_args.." -c "..cmbx_control_points.value + align_args = align_args.." -t "..cmbx_control_points_remove.value + align_args = align_args.." --corr="..cmbx_correlation.value + + dt.preferences.write("align_image_stack", "def_grid_size", "integer", cmbx_grid_size.selected) + dt.preferences.write("align_image_stack", "def_control_points", "integer", cmbx_control_points.selected) + dt.preferences.write("align_image_stack", "def_control_points_remove", "integer", cmbx_control_points_remove.selected) + dt.preferences.write("align_image_stack", "def_correlation", "integer", cmbx_correlation.selected) + + if (dt.preferences.read("align_image_stack", "align_use_gpu", "bool")) then align_args = align_args .. " --gpu" end + + align_args = align_args .. " -a " .. dt.configuration.tmp_dir .. "/aligned_ " + + return align_args +end + +-- extract, and sanitize, an image list from the supplied image table + +local function extract_image_list(image_table) + local img_list = "" + local result = {} + + for img,expimg in pairs(image_table) do + table.insert(result, expimg) + end + table.sort(result) + for _,exp_img in ipairs(result) do + img_list = img_list .. " " .. df.sanitize_filename(exp_img) + end + return img_list, #result +end + +-- don't leave files laying around the operating system + +local function cleanup(img_list) + dt.print_log("image list is " .. img_list) + files = du.split(img_list, " ") + for _,f in ipairs(files) do + f = string.gsub(f, '[\'\"]', "") + os.remove(f) + end +end + +-- List files based on a search pattern. This is cross platform compatible +-- but the windows version is recursive in order to get ls type listings. +-- Normally this shouldn't be a problem, but if you use this code just beware. +-- If you want to do it non recursively, then remove the /s argument from dir +-- and grab the path component from the search string and prepend it to the files +-- found. + +local function list_files(search_string) + local ls = "ls " + local files = {} + local dir_path = nil + local cnt = 1 + + if dt.configuration.running_os == "windows" then + ls = "dir /b/s " + search_string = string.gsub(search_string, "/", "\\\\") + end + + local f = io.popen(ls .. search_string) + if f then + local found_file = f:read() + while found_file do + files[cnt] = found_file + cnt = cnt + 1 + found_file = f:read() + end + f:close() + end + return files +end + +-- create a filename from a multi image set. The image list is sorted, then +-- combined with first and last if more than 3 images or a - separated list +-- if three images or less. + +local function make_output_filename(image_table) + local images = {} + local cnt = 1 + local max_distinct_names = 3 + local name_separator = "-" + local outputFileName = nil + local result = {} + + for img,expimg in pairs(image_table) do + table.insert(result, expimg) + end + table.sort(result) + for _,img in pairs(result) do + images[cnt] = df.get_basename(img) + cnt = cnt + 1 + end + + cnt = cnt - 1 + + if cnt > 1 then + if cnt > max_distinct_names then + -- take the first and last + outputFileName = images[1] .. name_separator .. images[cnt] + else + -- join them + outputFileName = du.join(images, name_separator) + end + else + -- return the single name + outputFileName = images[cnt] + end + + return outputFileName +end + +-- get the path where the collection is stored + +local function extract_collection_path(image_table) + local collection_path = nil + for i,_ in pairs(image_table) do + collection_path = i.path + break + end + return collection_path +end + +-- copy an images database attributes to another image. This only +-- copies what the database knows, not the actual exif data in the +-- image itself. + +local function copy_image_attributes(from, to, ...) + local args = {...} + if #args == 0 then + args[1] = "all" + end + if args[1] == "all" then + args[1] = "rating" + args[2] = "colors" + args[3] = "exif" + args[4] = "meta" + args[5] = "GPS" + end + for _,arg in ipairs(args) do + if arg == "rating" then + to.rating = from.rating + elseif arg == "colors" then + to.red = from.red + to.blue = from.blue + to.green = from.green + to.yellow = from.yellow + to.purple = from.purple + elseif arg == "exif" then + to.exif_maker = from.exif_maker + to.exif_model = from.exif_model + to.exif_lens = from.exif_lens + to.exif_aperture = from.exif_aperture + to.exif_exposure = from.exif_exposure + to.exif_focal_length = from.exif_focal_length + to.exif_iso = from.exif_iso + to.exif_datetime_taken = from.exif_datetime_taken + to.exif_focus_distance = from.exif_focus_distance + to.exif_crop = from.exif_crop + elseif arg == "GPS" then + to.elevation = from.elevation + to.longitude = from.longitude + to.latitude = from.latitude + elseif arg == "meta" then + to.publisher = from.publisher + to.title = from.title + to.creator = from.creator + to.rights = from.rights + to.description = from.description + else + dt.print_error("Unrecognized option to copy_image_attributes: " .. arg) + end + end +end + +local function stop_job() + job.valid = false +end + +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +-- main program +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +local function image_stack(storage, image_table, extra_data) + + local will_align = chkbtn_will_align.value + local img_list, image_count = extract_image_list(image_table) + local tmp_dir = dt.configuration.tmp_dir .. "/" + local stack_function = cmbx_stack_function.value + local output_format = cmbx_output_format.value + local tag_source = chkbtn_tag_source_file.value + local tasks = 3 + + if will_align then + tasks = tasks + 1 + end + + if tag_source then + tasks = tasks + 1 + end + + local percent_step = 1 / tasks + + -- update preferences + dt.preferences.write("image_stack", "align_images", "bool", will_align) + dt.preferences.write("image_stack", "stack_function", "integer", cmbx_stack_function.selected) + dt.preferences.write("image_stack", "output_format", "integer", cmbx_output_format.selected) + + if image_count < 2 then + dt.print(_("ERROR: at least 2 images required for image stacking, exiting...")) + dt.print_error(_(image_count .. " image(s) selected, at least 2 required")) + return + end + + job = dt.gui.create_job("image stack", true, stop_job) + job.percent = job.percent + percent_step + + -- align images if requested + if will_align then + local align_image_stack_executable = df.check_if_bin_exists("align_image_stack") + if align_image_stack_executable then + local align_args = get_align_image_stack_arguments() + local align_image_stack_command = align_image_stack_executable .. align_args .. " " .. img_list + dt.print_log(align_image_stack_command) + dt.print(_("aligning images...")) + local result = dtsys.external_command(align_image_stack_command) + if result == 0 then + dt.print(_("images aligned")) + local files = list_files(tmp_dir .. "aligned_*") + cleanup(img_list) + img_list = extract_image_list(files) + job.percent = job.percent + percent_step + else + dt.print(_("ERROR: image alignment failed")) + dt.print_error(_("image alignment failed")) + cleanup(img_list) + return + end + else + dt.print(_("ERROR: align_image_stack not found")) + dt.print_error(_("align_image_stack not found")) + cleanup(img_list) + return + end + end + + -- stack images + local output_filename = tmp_dir .. make_output_filename(image_table) .. "." .. string.lower(output_format) + local convert_arguments = img_list .. " -evaluate-sequence " .. stack_function .. " " .. df.sanitize_filename(output_filename) + local convert_executable = df.check_if_bin_exists("convert") + -- tif images exported from darktable have private tiff tags embedded in them and convert complains about them + local ignore_tif_tags = " -quiet -define tiff:ignore-tags=40965,42032,42033,42034,42036,18246,18249,36867,34864,34866 " + if convert_executable then + local convert_command = convert_executable .. ignore_tif_tags .. convert_arguments + dt.print_log(_("convert command is " .. convert_command)) + dt.print(_("processing image stack")) + local result = dtsys.external_command(convert_command) + if result == 0 then + dt.print(_("image stack processed")) + cleanup(img_list) + job.percent = job.percent + percent_step + + -- import image + dt.print(_("importing result")) + local film_roll_path = extract_collection_path(image_table) + local import_filename = df.create_unique_filename(film_roll_path .. "/" .. df.get_filename(output_filename)) + df.file_move(output_filename, import_filename) + imported_image = dt.database.import(import_filename) + local created_tag = dt.tags.create("Created with|image_stack") + dt.tags.attach(created_tag, imported_image) + for img,_ in pairs(image_table) do + copy_image_attributes(img, imported_image) + break + end + job.percent = job.percent + percent_step + + -- tag images if requested + + if tag_source then + dt.print(_("tagging source images")) + local source_tag = dt.tags.create("Source file|" .. imported_image.filename) + for img, _ in pairs(image_table) do + dt.tags.attach(source_tag, img) + end + job.percent = job.percent + percent_step + end + else + dt.print(_("ERROR: image stack processing failed")) + cleanup(img_list) + end + else + dt.print(_("ERROR: convert executable not found")) + dt.print_error(_("convert executable not found")) + cleanup(img_list) + end + job.valid = false +end + +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +-- darktable integration +-- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + +dt.preferences.register("align_image_stack", "align_use_gpu", -- name + "bool", -- type + _('align image stack: use GPU for remaping'), -- label + _('set the GPU remapping for image align'), -- tooltip + false) + +dt.register_storage("module_image_stack", _("image stack"), show_status, image_stack, nil, nil, image_stack_widget) + From 380a1b66e691304f8087276f81608fb0a21cb354 Mon Sep 17 00:00:00 2001 From: Bill Ferguson Date: Wed, 22 Aug 2018 19:33:11 -0400 Subject: [PATCH 2/2] Added path separator constant and used it where necessary. Added comment to attribute copy --- contrib/image_stack.lua | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/contrib/image_stack.lua b/contrib/image_stack.lua index b1ef0f7..fd03fb6 100644 --- a/contrib/image_stack.lua +++ b/contrib/image_stack.lua @@ -67,6 +67,9 @@ local dtsys = require "lib/dtutils.system" local gettext = dt.gettext local job = nil +-- path separator constant +local PS = dt.configuration.running_os == "windows" and "\\" or "/" + -- works with LUA API version 5.0.0 dt.configuration.check_version(...,{5,0,0}) @@ -323,7 +326,7 @@ local function list_files(search_string) local ls = "ls " local files = {} local dir_path = nil - local cnt = 1 + local count = 1 if dt.configuration.running_os == "windows" then ls = "dir /b/s " @@ -334,8 +337,8 @@ local function list_files(search_string) if f then local found_file = f:read() while found_file do - files[cnt] = found_file - cnt = cnt + 1 + files[count] = found_file + count = count + 1 found_file = f:read() end f:close() @@ -440,7 +443,7 @@ local function copy_image_attributes(from, to, ...) to.rights = from.rights to.description = from.description else - dt.print_error("Unrecognized option to copy_image_attributes: " .. arg) + dt.print_error(_("Unrecognized option to copy_image_attributes: " .. arg)) end end end @@ -457,7 +460,7 @@ local function image_stack(storage, image_table, extra_data) local will_align = chkbtn_will_align.value local img_list, image_count = extract_image_list(image_table) - local tmp_dir = dt.configuration.tmp_dir .. "/" + local tmp_dir = dt.configuration.tmp_dir .. PS local stack_function = cmbx_stack_function.value local output_format = cmbx_output_format.value local tag_source = chkbtn_tag_source_file.value @@ -535,11 +538,13 @@ local function image_stack(storage, image_table, extra_data) -- import image dt.print(_("importing result")) local film_roll_path = extract_collection_path(image_table) - local import_filename = df.create_unique_filename(film_roll_path .. "/" .. df.get_filename(output_filename)) + local import_filename = df.create_unique_filename(film_roll_path .. PS .. df.get_filename(output_filename)) df.file_move(output_filename, import_filename) imported_image = dt.database.import(import_filename) - local created_tag = dt.tags.create("Created with|image_stack") + local created_tag = dt.tags.create(_("Created with|image_stack")) dt.tags.attach(created_tag, imported_image) + -- all the images are the same except for time, so just copy the attributes + -- from the first for img,_ in pairs(image_table) do copy_image_attributes(img, imported_image) break @@ -550,7 +555,7 @@ local function image_stack(storage, image_table, extra_data) if tag_source then dt.print(_("tagging source images")) - local source_tag = dt.tags.create("Source file|" .. imported_image.filename) + local source_tag = dt.tags.create(_("Source file|" .. imported_image.filename)) for img, _ in pairs(image_table) do dt.tags.attach(source_tag, img) end