324 lines
11 KiB
Lua
324 lines
11 KiB
Lua
--[[
|
|
This file is part of darktable,
|
|
copyright (c) 2026 darktable developers.
|
|
|
|
darktable 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.
|
|
|
|
darktable 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 darktable. If not, see <http://www.gnu.org/licenses/>.
|
|
]]
|
|
--[[
|
|
ai_denoise - example script demonstrating the darktable.ai Lua
|
|
API by denoising the selected images with tile-based inference.
|
|
|
|
Adds an "AI denoise" panel to the lighttable right column with a
|
|
"denoise selected" button. Each selected image is loaded through
|
|
the develop pipeline (so denoise sees the output of all enabled
|
|
IOPs, not the original raw), run tile-by-tile through the active
|
|
denoise model, and saved as a 16-bit TIFF grouped with the source.
|
|
|
|
Demonstrates:
|
|
* model lookup via dt.ai.model_for_task
|
|
* tensor crop/paste for tile-based inference
|
|
* linear <-> sRGB conversion (NR models train on gamma input)
|
|
* edge-replicated padding to suppress border artifacts
|
|
* background job with progress bar and cancellation
|
|
|
|
LIMITATIONS
|
|
* Image must be at least TILE_SIZE x TILE_SIZE (768 x 768 for
|
|
denoise-nind / denoise-nafnet); smaller inputs return an error.
|
|
* If a save fails mid-batch the Lua error propagates and aborts
|
|
the whole job. Wrap denoise_one in pcall if you need
|
|
batch-robust behaviour.
|
|
|
|
ADDITIONAL SOFTWARE NEEDED FOR THIS SCRIPT
|
|
* a denoise model enabled in preferences -> AI (denoise-nind,
|
|
denoise-nafnet, ...)
|
|
|
|
USAGE
|
|
* require this script from your luarc
|
|
* select one or more images in lighttable
|
|
* click the "denoise selected" button in the AI denoise panel
|
|
|
|
BUGS, COMMENTS, SUGGESTIONS
|
|
* file an issue on https://github.com/darktable-org/darktable
|
|
|
|
CHANGES
|
|
]]
|
|
|
|
local dt = require "darktable"
|
|
|
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
|
-- C O N S T A N T S
|
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
local MODULE_NAME = "ai_denoise"
|
|
|
|
-- darktable's NR models use static-shape ONNX: each model has a fixed
|
|
-- input dimension baked in at training time. The Lua API doesn't
|
|
-- expose this, so keep TILE_SIZE in sync with the model's
|
|
-- `attributes.input_sizes` in its config.json.
|
|
local TILE_SIZE = 768 -- denoise-nind, denoise-nafnet
|
|
|
|
-- overlap between adjacent tiles. larger values produce smoother
|
|
-- seams at the cost of more compute per pixel
|
|
local OVERLAP = 64
|
|
|
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
|
-- F U N C T I O N S
|
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
-- Surround the input with `pad` pixels on every side, replicating the
|
|
-- outermost rows/columns ("edge clamp" padding). Boundary tiles then
|
|
-- see plausible context instead of a literal image edge, so the
|
|
-- network's internal zero-padding no longer leaks into the visible
|
|
-- output as colour artifacts along the image rim.
|
|
local function pad_replicate(input, pad)
|
|
local C = input:shape()[2]
|
|
local H = input:shape()[3]
|
|
local W = input:shape()[4]
|
|
local padded = dt.ai.create_tensor({1, C, H + 2 * pad, W + 2 * pad})
|
|
|
|
-- center: original image
|
|
padded:paste(input, pad, pad)
|
|
|
|
-- left and right strips: replicate first / last column of input
|
|
local left_col = input:crop(0, 0, H, 1)
|
|
local right_col = input:crop(0, W - 1, H, 1)
|
|
for i = 0, pad - 1 do
|
|
padded:paste(left_col, pad, i)
|
|
padded:paste(right_col, pad, pad + W + i)
|
|
end
|
|
|
|
-- top and bottom strips: replicate first / last row of the already
|
|
-- horizontally-padded buffer so the four corners get filled correctly
|
|
local top_row = padded:crop(pad, 0, 1, W + 2 * pad)
|
|
local bottom_row = padded:crop(pad + H - 1, 0, 1, W + 2 * pad)
|
|
for i = 0, pad - 1 do
|
|
padded:paste(top_row, i, 0)
|
|
padded:paste(bottom_row, pad + H + i, 0)
|
|
end
|
|
|
|
return padded
|
|
end
|
|
|
|
-- Tile a [1,C,H,W] tensor through the model with overlap-blended seams.
|
|
-- Each tile is tile_size x tile_size, successive tiles step by
|
|
-- (tile_size - 2*overlap), and only the central (non-overlap) region
|
|
-- of each tile is pasted into the output. Image-boundary edges keep
|
|
-- their full extent; only image-interior seams drop the overlap margin.
|
|
--
|
|
-- The trailing tile in each axis is pinned to the image boundary so
|
|
-- the residual right/bottom strip is never smaller than 2*overlap --
|
|
-- otherwise it would have to be skipped (the model has a fixed input
|
|
-- size) and those pixels would never be written.
|
|
local function run_tiled(ctx, input, tile_size, overlap,
|
|
job, base_pct, span_pct)
|
|
local C = input:shape()[2]
|
|
local H = input:shape()[3]
|
|
local W = input:shape()[4]
|
|
if H < tile_size or W < tile_size then
|
|
return nil, string.format("image too small (%dx%d) for tile %d",
|
|
W, H, tile_size)
|
|
end
|
|
|
|
local step = tile_size - 2 * overlap
|
|
|
|
-- enumerate tile origins along one axis, ending exactly at the boundary
|
|
local function origins(extent)
|
|
local out, p = {}, 0
|
|
while p + tile_size < extent do
|
|
out[#out + 1] = p
|
|
p = p + step
|
|
end
|
|
out[#out + 1] = math.max(0, extent - tile_size)
|
|
return out
|
|
end
|
|
local ys, xs = origins(H), origins(W)
|
|
local total = #xs * #ys
|
|
|
|
local output = dt.ai.create_tensor({1, C, H, W})
|
|
local done = 0
|
|
|
|
for _, ty in ipairs(ys) do
|
|
for _, tx in ipairs(xs) do
|
|
if job and not job.valid then return nil, "cancelled" end
|
|
|
|
local tile_in = input:crop(ty, tx, tile_size, tile_size)
|
|
local tile_out = ctx:run(tile_in)
|
|
|
|
-- central region to keep: drop overlap on every inner edge;
|
|
-- full extent at image-boundary edges
|
|
local at_top = (ty == 0)
|
|
local at_left = (tx == 0)
|
|
local at_bottom = (ty + tile_size >= H)
|
|
local at_right = (tx + tile_size >= W)
|
|
|
|
local cy = at_top and 0 or overlap
|
|
local cx = at_left and 0 or overlap
|
|
local ch = tile_size - cy - (at_bottom and 0 or overlap)
|
|
local cw = tile_size - cx - (at_right and 0 or overlap)
|
|
|
|
output:paste(tile_out:crop(cy, cx, ch, cw), ty + cy, tx + cx)
|
|
|
|
done = done + 1
|
|
if job then
|
|
job.percent = base_pct + span_pct * (done / total)
|
|
end
|
|
end
|
|
end
|
|
|
|
return output
|
|
end
|
|
|
|
-- Denoise one image: load through the develop pipeline, run tiled
|
|
-- inference, save as 16-bit TIFF, group with the source.
|
|
local function denoise_one(ctx, img, tile_size, job, base_pct, span_pct)
|
|
-- darktable returns the develop-pipeline output as scene-linear RGB;
|
|
-- the NR models were trained on sRGB-gamma input, so we re-encode
|
|
-- before inference and decode back to linear before saving
|
|
local input = dt.ai.load_image(img)
|
|
if not input then return false, "load_image failed" end
|
|
input:linear_to_srgb()
|
|
|
|
-- pad so the image's outer rim is no longer at a tile boundary;
|
|
-- the padded strip absorbs the network's edge artifacts and gets
|
|
-- cropped away after inference
|
|
local H = input:shape()[3]
|
|
local W = input:shape()[4]
|
|
local padded_in = pad_replicate(input, OVERLAP)
|
|
|
|
local padded_out, err = run_tiled(ctx, padded_in, tile_size, OVERLAP,
|
|
job, base_pct, span_pct)
|
|
if not padded_out then return false, err end
|
|
|
|
local output = padded_out:crop(OVERLAP, OVERLAP, H, W)
|
|
output:srgb_to_linear()
|
|
|
|
local base = img.filename:match("(.+)%..+$") or img.filename
|
|
local out_path = img.path .. "/" .. base .. "_denoised.tif"
|
|
output:save_tiff(out_path, 16, img)
|
|
|
|
-- import the result and group with the source so they live together
|
|
-- in the lighttable
|
|
local imported = dt.database.import(out_path)
|
|
if imported then imported:group_with(img) end
|
|
return true
|
|
end
|
|
|
|
-- Button handler: looks up the active denoise model, fans out the
|
|
-- selected images, and reports progress via a cancellable job.
|
|
local function process_denoise()
|
|
local model_id = dt.ai.model_for_task("denoise")
|
|
if not model_id then
|
|
dt.print("no denoise model enabled -- pick one in preferences -> AI")
|
|
return
|
|
end
|
|
|
|
local images = dt.gui.selection()
|
|
if #images == 0 then
|
|
dt.print("select at least one image")
|
|
return
|
|
end
|
|
|
|
-- progress + cancel job. the cancel callback flips job.valid; the
|
|
-- main loop polls it between tiles to stop cleanly
|
|
local job = dt.gui.create_job(
|
|
string.format("AI denoise (%d image%s)",
|
|
#images, #images == 1 and "" or "s"),
|
|
true,
|
|
function(j) j.valid = false end)
|
|
|
|
dt.print_log(string.format(
|
|
"[ai_denoise] starting: %d image%s with model %s",
|
|
#images, #images == 1 and "" or "s", model_id))
|
|
local t_start = os.time()
|
|
|
|
local ctx = dt.ai.load_model(model_id)
|
|
if not ctx then
|
|
dt.print("failed to load model: " .. model_id)
|
|
job.valid = false
|
|
return
|
|
end
|
|
|
|
local span = 1.0 / #images
|
|
local done = 0
|
|
for i, img in ipairs(images) do
|
|
if not job.valid then break end
|
|
dt.print_log(string.format("[ai_denoise] [%d/%d] %s",
|
|
i, #images, img.filename))
|
|
local ok, err = denoise_one(ctx, img, TILE_SIZE, job,
|
|
(i - 1) * span, span)
|
|
if not ok then
|
|
dt.print(string.format("[%d/%d] %s: %s",
|
|
i, #images, img.filename, tostring(err)))
|
|
dt.print_log(string.format("[ai_denoise] [%d/%d] %s FAILED: %s",
|
|
i, #images, img.filename, tostring(err)))
|
|
else
|
|
done = done + 1
|
|
end
|
|
end
|
|
|
|
ctx:close()
|
|
job.valid = false
|
|
local elapsed = os.time() - t_start
|
|
dt.print(string.format("denoise complete: %d image%s",
|
|
#images, #images == 1 and "" or "s"))
|
|
dt.print_log(string.format(
|
|
"[ai_denoise] finished: %d/%d in %ds",
|
|
done, #images, elapsed))
|
|
end
|
|
|
|
-- script_manager integration: called when the user disables the script.
|
|
-- darktable can't remove a registered lib from the UI at runtime, so we
|
|
-- only stop any in-flight work; the panel itself remains until restart.
|
|
local function destroy()
|
|
-- nothing persistent to clean up: each click runs its own job and
|
|
-- closes its own model context
|
|
end
|
|
|
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
|
-- M A I N
|
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
dt.register_lib(
|
|
MODULE_NAME, -- plugin id
|
|
"AI denoise", -- displayed name
|
|
true, -- expandable
|
|
false, -- no reset button
|
|
{[dt.gui.views.lighttable] = {"DT_UI_CONTAINER_PANEL_RIGHT_CENTER", 100}},
|
|
dt.new_widget("box") {
|
|
orientation = "vertical",
|
|
dt.new_widget("button") {
|
|
label = "denoise selected",
|
|
clicked_callback = process_denoise,
|
|
}
|
|
},
|
|
nil, nil
|
|
)
|
|
|
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
|
-- S C R I P T M A N A G E R I N T E G R A T I O N
|
|
-- - - - - - - - - - - - - - - - - - - - - - - -
|
|
|
|
local script_data = {}
|
|
|
|
script_data.metadata = {
|
|
name = "AI denoise",
|
|
purpose = "tile-based AI denoise using the darktable.ai Lua API",
|
|
author = "Andrii Ryzhkov",
|
|
help = "https://docs.darktable.org/lua/stable/lua.scripts.manual/scripts/examples/ai_denoise"
|
|
}
|
|
|
|
script_data.destroy = destroy
|
|
|
|
return script_data
|