Skip to content

Lua Filter

The Lua filter lets you write scripts that read and modify DICOM tags, route images to multiple destinations with per-destination modifications, and maintain state across images in the same study or series. Lua scripts are defined in a lua.yml file and execute against each incoming DICOM image during the prepare phase. If this file is invalid, DICOM Capacitor will halt with an error. If this file is missing, the Lua filter will be disabled.

In order to enable the Lua filter, you must add the lua filter to the filters section of your config.yml file.

yaml
# config.yml
filters: lua

The Lua filter can be combined with other filters. Filters execute in the order they appear:

yaml
# config.yml
filters: route, lua, mutate

Lua Script Entry Components

Each entry in lua.yml defines a script to run, with optional conditions for when it applies:

  • Script: (Required) Inline Lua code or a path to a .lua file. Paths ending in .lua are loaded from the configuration directory.
  • Description: (Optional) A human-readable label that appears in log messages.
  • Conditions: (Optional) A list of conditions that must be met for this entry to execute. Conditions are a shared concept described on the Conditions page.
  • AeTitles: (Optional) A list of destination AE titles to which this entry applies. If not provided, the entry applies to all destinations.
  • OnError: (Optional) Error handling behavior when a script fails. Defaults to skip.
    • skip: Log a warning and continue to the next entry.
    • fail: Stop the pipeline and move the file to the Failed state.

Script

The Script field accepts either inline Lua code or a file path:

yaml
# Inline script
- Script: |
    dataset:Set('PatientName', 'ANONYMOUS')

# File reference (loaded from the configuration directory)
- Script: scripts/anonymize.lua

File paths must end in .lua, must be relative to the configuration directory, and cannot contain ...

AeTitles

The AeTitles field restricts a script entry to specific destinations:

yaml
- Script: |
    dataset:Set('InstitutionName', 'HOSPITAL')
  AeTitles:
    - AI_SERVER
    - RESEARCH

Conditions

The Conditions field filters which images a script applies to based on DICOM tag values. All conditions must match (AND logic). Conditions are described in their own Conditions page.

yaml
- Script: scripts/route-ct.lua
  Conditions:
    - Tag: 0008,0060
      MatchExpression: CT

Lua API

Scripts have access to the following globals:

GlobalTypeDescription
datasetobjectRead and write DICOM tags with audit logging
routeobjectRoute images to additional destinations
fileobjectRead-only metadata about the current image
queueobjectRead-only queries against the in-memory processing queue
studytableKey-value state shared across images in the same study (24h TTL)
study.queueobjectRead-only queue queries scoped to the current study
seriestableKey-value state shared across images in the same series (24h TTL)
logobjectWrite to the common application log
print(...)functionLog output (appears in per-file item log only)
uid()functionGenerate a new DICOM UID
include(path)functionLoad a shared Lua library file

dataset

The dataset object reads and writes DICOM tags. Tags can be referenced by keyword (e.g., PatientName) or hex notation (e.g., 0010,0010 or 00100010). Every Set() and Remove() call is recorded in the audit log.

  • dataset:Get(tag) - Returns the tag value as a string, or nil if the tag is absent.
  • dataset:Set(tag, value) - Sets a tag to a new value. Creates the tag if it doesn't exist.
  • dataset:Remove(tag) - Removes a tag from the dataset. Silent no-op if the tag doesn't exist.
lua
local name = dataset:Get('PatientName')
local modality = dataset:Get('0008,0060')

dataset:Set('PatientName', 'ANONYMOUS')
dataset:Set('0010,0020', 'NEW_ID')

dataset:Remove('PatientBirthDate')

route

The route object controls where images are sent. Destinations must be defined in nodes.yml with NodeRole: Storage.

  • route:Add(destination) - Send a copy of the image to the specified destination.
  • route:Add(destination, function(ds) ... end) - Send a copy with per-destination modifications. The function receives a dataset proxy for the cloned copy; mutations only affect that copy.
  • route:Drop() - Remove the original image. Only routed copies will be sent.
lua
-- Send a copy to another destination
route:Add('AI_SERVER')

-- Route with per-destination modifications
route:Add('RESEARCH', function(ds)
    ds:Set('PatientName', 'ANONYMOUS')
    ds:Set('PatientID', '00000')
    ds:Remove('PatientBirthDate')
end)

-- Drop the original (only routed copies are sent)
route:Drop()

Important: Route lambdas execute after all lua.yml entries have completed. The lambda receives a clone of the dataset in its final state after all entries have run. If any lambda fails, the entire file fails and no output files are created — there is no partial persistence.

If route:Add() is called multiple times with the same destination, the last call wins — the previous lambda (if any) is replaced.

file

The file object provides read-only metadata about the current image:

  • file.sourceAeTitle - The AE title of the system that sent this image.
  • file.destinationAeTitle - The AE title of the destination this image is being prepared for.
lua
if file.sourceAeTitle == 'CT_SCANNER_1' then
    dataset:Set('StationName', 'CT Room 1')
end

if file.destinationAeTitle == 'EXTERNAL_PACS' then
    dataset:Remove('PatientBirthDate')
end

queue

The queue object provides read-only access to the in-memory processing queue. This is the same data visible in the web UI's queue view. All queries run against the live in-memory database and return a snapshot at the time of the call.

Count methods return a number:

  • queue:total() — Total number of items in the queue.
  • queue:count(state) — Count of items in the specified state ("New", "Prepared", "Failed", "Rejected", "Expired").
  • queue:countByStudy(studyUID) — Count of items belonging to a study.
  • queue:countByStudy(studyUID, state) — Count of items belonging to a study in a specific state.
  • queue:countByDestination(aeTitle) — Count of items destined for a specific AE title.
  • queue:countByDestination(aeTitle, state) — Count filtered by destination and state.

Find methods return an item list (see Iterating Queue Items below):

  • queue:findByStudy(studyUID) — All items for a study.
  • queue:findByState(state) — All items in a state.
  • queue:findByDestination(aeTitle) — All items for a destination.
  • queue:findByDestination(aeTitle, state) — Items for a destination in a specific state.
lua
-- Check how many items are waiting globally
local pending = queue:count('New')
print('Pending items:', pending)

-- Check if a specific destination has failures
if queue:countByDestination('AI_SERVER', 'Failed') > 10 then
    -- Too many failures, skip routing to AI_SERVER
    print('AI_SERVER has too many failures, skipping')
else
    route:Add('AI_SERVER')
end

study.queue

The study.queue object provides the same query capabilities as queue, but automatically scoped to the current image's StudyInstanceUID. Available when the image has a StudyInstanceUID; nil otherwise.

  • study.queue:totalCount() — Total items in this study.
  • study.queue:countByState(state) — Items in this study with a specific state.
  • study.queue:items() — All items in this study.
  • study.queue:itemsByState(state) — Items in this study filtered by state.
  • study.queue:destinations() — Distinct destination AE titles for this study (string array).
  • study.queue:modalities() — Distinct modalities for this study (string array).
lua
-- Wait for all images before routing
local newCount = study.queue:countByState('New')
local preparedCount = study.queue:countByState('Prepared')
if newCount > 1 then
    -- More images still arriving, don't route yet
    error('retry: waiting for study to complete (' .. newCount .. ' images pending)')
end

-- Check which destinations this study has been routed to
local dests = study.queue:destinations()
print('Study destinations:', dests.Length)

Iterating Queue Items

The queue:find*() and study.queue:items*() methods return an item list object. Use count() and get(i) to iterate (1-based indexing):

lua
local items = study.queue:items()
for i = 1, items:count() do
    local item = items:get(i)
    print(item.state, item.destinationAeTitle, item.modality)
end

Each item exposes these read-only fields:

FieldTypeDescription
idnumberInternal record ID
statestring"New", "Prepared", "Failed", "Rejected", or "Expired"
sourceAeTitlestringAE title of the sender
destinationAeTitlestringAE title of the destination
studyInstanceUIDstringDICOM Study Instance UID
seriesInstanceUIDstringDICOM Series Instance UID
sopInstanceUIDstringDICOM SOP Instance UID
sopClassUIDstringDICOM SOP Class UID
modalitystringDICOM modality (e.g., "CT", "MR")
patientIDstringPatient ID
patientNamestringPatient name
accessionNumberstringAccession number
attemptCountnumberNumber of delivery attempts
lastErrorstringLast error message (if any)
formatstringFile format ("dcm", "json", "yml")
pendingStatestringDeferred state change (if any)
createdAtstringISO 8601 timestamp when the item entered the queue
updatedAtstringISO 8601 timestamp of the last state change

study and series

The study and series tables provide key-value storage scoped to the current image's StudyInstanceUID and SeriesInstanceUID respectively. State persists across all images sharing the same UID and is evicted after 24 hours of inactivity. State is held in memory and lost on service restart.

lua
-- Count images per study
study.image_count = (study.image_count or 0) + 1

-- Track per-series state
series.last_instance = dataset:Get('SOPInstanceUID')

log

The log object writes to the common application log (the main service log visible on the Logs page). Use this for operational messages that should be visible to all operators, not just when inspecting a specific queue item.

  • log:info(...) — Informational message.
  • log:warn(...) — Warning.
  • log:error(...) — Error.
  • log:debug(...) — Debug (only visible at debug log level).

Arguments are converted to strings and separated by tabs. All messages are prefixed with [lua] in the log output.

lua
log:info('Processing started for', dataset:Get('PatientID'))
log:warn('Destination backlog detected:', backlog, 'items')
log:error('Required tag missing — failing file')

print

The print(...) function logs output to the per-file item log only. Use this for per-image debug output that operators see when inspecting a specific queue item. Arguments are converted to strings and separated by tabs.

lua
print('Processing', dataset:Get('PatientName'))
print('Modality:', dataset:Get('Modality'))

uid

The uid() function generates a new globally unique DICOM UID.

lua
route:Add('RESEARCH', function(ds)
    ds:Set('StudyInstanceUID', uid())
    ds:Set('SeriesInstanceUID', uid())
    ds:Set('SOPInstanceUID', uid())
end)

include

The include(path) function loads and executes a Lua file from the configuration directory. Files are loaded at most once per image (deduplicated by canonical path). The path must be relative, end in .lua, and cannot contain ...

lua
-- Load a shared library from the configuration directory
include('libs/anonymize.lua')
anonymize(dataset)
lua
-- libs/anonymize.lua
function anonymize(ds)
    ds:Set('PatientName', 'ANONYMOUS')
    ds:Set('PatientID', '00000')
    ds:Remove('PatientBirthDate')
    ds:Remove('PatientAddress')
    ds:Remove('ReferringPhysicianName')
end

Standard Library

The full Lua 5.4 standard library is available, including string, table, math, os, io, and more.

lua
dataset:Set('ContentDate', os.date('%Y%m%d'))

local desc = dataset:Get('StudyDescription') or ''
dataset:Set('StudyDescription', string.upper(desc))

Error Handling

Lua scripts can control processing flow using error() with two special keywords:

Keyword in messageBehavior
retryFile stays in queue, retried on the next processing cycle
failFile moves to the Failed state, pipeline stops
(anything else)Behavior depends on the entry's OnError setting

Only retry and fail are special keywords. Any other error message (including the word "skip") is handled by the entry's OnError setting — skip (the default) logs a warning and continues to the next entry, while fail stops the pipeline.

Keywords are matched case-insensitively as substrings, so error('retry: waiting for tag') triggers a retry.

lua
-- Retry if a required tag is missing
if dataset:Get('PatientName') == nil then
    error('retry: waiting for patient name')
end

-- Force failure for critical issues
if dataset:Get('Modality') == nil then
    error('fail: missing modality')
end

Rollback on Error

When a script entry errors, all side effects from that entry are atomically rolled back:

  • Dataset mutations are reverted to their pre-entry state
  • Route additions are reverted
  • Scoped state changes (study/series) are restored

Subsequent entries see the state as if the failed entry never ran. Previous entries' mutations are preserved.

How Lua Scripts Execute

For each DICOM image entering the prepare phase:

  1. One Lua VM is created for the image
  2. All matching lua.yml entries execute sequentially in the same VM
  3. After all entries complete, routing clones are materialized and lambdas execute
  4. The modified dataset is saved (or dropped if route:Drop() was called)

Because all entries share the same VM:

  • Variables set in one entry are visible to later entries
  • Dataset mutations accumulate across entries
  • Route additions accumulate across entries

Lua Examples

The following examples show a complete lua.yml file with common scenarios:

yaml
# lua.yml

# Example 1: Anonymize all images
# Strip patient information from every image that passes through
- Description: Strip PHI
  Script: |
    dataset:Set('PatientName', 'ANONYMOUS')
    dataset:Set('PatientID', '00000')
    dataset:Remove('PatientBirthDate')
    dataset:Remove('PatientAddress')
    dataset:Remove('ReferringPhysicianName')

# Example 2: Route CT images to an AI server
# Use conditions to match modality, then add a routing destination
- Description: Route CT to AI
  Script: |
    route:Add('AI_SERVER')
  Conditions:
    - Tag: 0008,0060
      MatchExpression: CT

# Example 3: Fan-out with per-destination anonymization
# Send mammography to multiple destinations with different PHI policies.
# The original goes to MAMMO_PACS unmodified. The AI vendor gets an
# anonymized copy. The teaching archive gets fully de-identified data
# with new UIDs.
- Description: Distribute mammography
  Script: |
    route:Add('MAMMO_PACS')
    route:Add('AI_VENDOR', function(ds)
        ds:Set('PatientName', 'ANONYMOUS')
        ds:Set('PatientID', '00000')
        ds:Set('InstitutionName', 'REDACTED')
    end)
    route:Add('TEACHING_ARCHIVE', function(ds)
        ds:Set('PatientName', 'TEACHING')
        ds:Set('PatientID', uid())
        ds:Set('StudyInstanceUID', uid())
        ds:Set('SeriesInstanceUID', uid())
        ds:Set('SOPInstanceUID', uid())
        ds:Remove('PatientBirthDate')
        ds:Remove('PatientAddress')
        ds:Remove('ReferringPhysicianName')
    end)
  Conditions:
    - Tag: 0008,0060
      MatchExpression: MG

# Example 4: Drop and reroute
# Redirect vascular ultrasound to a specialized PACS. The original
# destination is dropped so only the rerouted copy is sent.
- Description: Redirect vascular US
  Script: |
    route:Add('VASCULAR_PACS')
    route:Drop()
  Conditions:
    - Tag: 0008,0060
      MatchExpression: US
    - Tag: 0008,1030
      MatchExpression: '*VASCULAR*'

# Example 5: Enrich images based on source
# Tag images with the originating scanner so downstream systems can
# identify where the study was acquired.
- Description: Tag by source scanner
  Script: |
    local sources = {
        CT_SCANNER_1  = 'CT Room 1 - Main Building',
        CT_SCANNER_2  = 'CT Room 2 - Emergency',
        MR_SCANNER    = 'MRI Suite A',
    }
    local label = sources[file.sourceAeTitle]
    if label then
        dataset:Set('StationName', label)
    end

# Example 6: Study-level image counting with conditional routing
# Count images per study and route large studies to a dedicated
# archive server. Uses scoped state to track counts across images.
- Description: Route large studies
  Script: |
    study.image_count = (study.image_count or 0) + 1
    if study.image_count > 500 then
        route:Add('LARGE_STUDY_ARCHIVE')
    end

# Example 7: Shared library for reusable anonymization
# Factor common logic into a library file and call it from multiple
# entries. The include() function loads each file at most once.
- Description: Anonymize for research
  Script: |
    include('libs/phi.lua')
    route:Add('RESEARCH_ARCHIVE', function(ds)
        strip_phi(ds)
        ds:Set('PatientID', uid())
    end)

# Example 8: Destination-specific enrichment
# Only runs when preparing images for the AI_SERVER destination.
# Adds institutional metadata that the AI vendor requires.
- Description: Enrich for AI server
  Script: |
    dataset:Set('InstitutionName', 'GENERAL HOSPITAL')
    dataset:Set('InstitutionalDepartmentName', 'RADIOLOGY')
  AeTitles:
    - AI_SERVER

# Example 9: Critical script with fail-on-error
# Validate that required tags are present before sending to an
# external system. If validation fails, the image is held rather
# than sent incomplete.
- Description: Validate required tags
  Script: |
    local required = {'PatientID', 'PatientName', 'Modality', 'StudyInstanceUID'}
    for _, tag in ipairs(required) do
        if dataset:Get(tag) == nil then
            error('fail: missing required tag ' .. tag)
        end
    end
  AeTitles:
    - EXTERNAL_PACS
  OnError: fail

# Example 10: Timestamp injection and string manipulation
# Normalize study descriptions to uppercase and stamp images with
# the processing date using the Lua standard library.
- Description: Normalize and timestamp
  Script: |
    local desc = dataset:Get('StudyDescription') or ''
    dataset:Set('StudyDescription', string.upper(desc))
    dataset:Set('ContentDate', os.date('%Y%m%d'))
    dataset:Set('ContentTime', os.date('%H%M%S'))

# Example 11: Queue-aware routing
# Only route to the AI server when the queue isn't backed up there.
# Uses the global queue API to check destination health before routing.
- Description: Route to AI if healthy
  Script: |
    local failed = queue:countByDestination('AI_SERVER', 'Failed')
    local pending = queue:countByDestination('AI_SERVER', 'New')
    if failed > 20 then
        print('AI_SERVER has ' .. failed .. ' failures, skipping')
    elseif pending > 100 then
        print('AI_SERVER backlog at ' .. pending .. ', skipping')
    else
        route:Add('AI_SERVER')
    end
  Conditions:
    - Tag: 0008,0060
      MatchExpression: CT

# Example 12: Study completeness check
# Use study.queue to detect partially processed studies and tag
# them for operator review.
- Description: Flag partial studies
  Script: |
    local total = study.queue:totalCount()
    local prepared = study.queue:countByState('Prepared')
    local failed = study.queue:countByState('Failed')
    if failed > 0 and prepared > 0 then
        -- Some images failed, some succeeded — flag for review
        dataset:Set('ImageComments',
            'PARTIAL_STUDY: ' .. failed .. ' of ' .. total .. ' failed')
    end

Multi-File Project Examples

The examples above use inline scripts for brevity. In practice, you'll want to split logic into external .lua files and shared libraries. Below are complete multi-file setups.

Queue-Aware Routing with Shared Libraries

This setup uses a shared PHI stripping library, a station label library, and an external routing script that checks queue health before routing.

File layout:

config-dir/
├── config.yml          # filters: lua
├── nodes.yml
├── lua.yml
├── libs/
│   ├── phi.lua
│   └── station-labels.lua
└── scripts/
    └── route-smart.lua
yaml
# lua.yml

# Anonymize images going to the test viewer
- Description: Anonymize for viewer
  Script: |
    include('libs/phi.lua')
    strip_phi(dataset)
    dataset:Set('PatientID', uid())
  AeTitles:
    - VIEWER_TEST

# Smart routing for CT — checks queue health, labels stations
- Description: Smart CT routing
  Script: scripts/route-smart.lua
  Conditions:
    - Tag: 0008,0060
      MatchExpression: CT
lua
-- libs/phi.lua — Reusable PHI stripping
function strip_phi(ds)
    ds:Set('PatientName', 'ANONYMOUS')
    ds:Set('PatientID', '00000')
    ds:Remove('PatientBirthDate')
    ds:Remove('PatientAddress')
    ds:Remove('ReferringPhysicianName')
    ds:Remove('InstitutionName')
    ds:Remove('InstitutionAddress')
end
lua
-- libs/station-labels.lua — Map AE titles to human-readable names
STATION_LABELS = {
    CT_SCANNER_1  = 'CT Room 1 - Main Building',
    CT_SCANNER_2  = 'CT Room 2 - Emergency',
    MR_SCANNER    = 'MRI Suite A',
    US_SCANNER    = 'Ultrasound Bay 3',
}

function label_station(ds)
    local label = STATION_LABELS[file.sourceAeTitle]
    if label then
        ds:Set('StationName', label)
    end
end
lua
-- scripts/route-smart.lua — Queue-aware routing with health checks
include('libs/station-labels.lua')
label_station(dataset)

-- Check destination health before routing
local failed = queue:countByDestination('ARCHIVE', 'Failed')
local backlog = queue:countByDestination('ARCHIVE', 'New')

if failed > 50 then
    log:warn('ARCHIVE has', failed, 'failures — holding')
    error('retry: destination has too many failures')
elseif backlog > 200 then
    log:warn('ARCHIVE backlog at', backlog, '— routing with warning')
    dataset:Set('ImageComments', 'ROUTED_UNDER_BACKLOG:' .. backlog)
end

route:Add('ARCHIVE')

-- Tag partial studies for operator attention
local total = study.queue:totalCount()
local studyFailed = study.queue:countByState('Failed')
if studyFailed > 0 then
    log:warn('Study has', studyFailed, 'of', total, 'items failed')
    dataset:Set('ImageComments',
        'PARTIAL_STUDY: ' .. studyFailed .. '/' .. total .. ' failed')
end

Fan-Out with De-Identification

Route mammography to three destinations: the clinical PACS unmodified, a research archive with basic anonymization, and a cloud AI vendor with full de-identification and new UIDs.

yaml
# lua.yml
- Description: Distribute mammography
  Script: |
    include('libs/phi.lua')

    -- Research gets anonymized copy
    route:Add('RESEARCH', function(ds)
        strip_phi(ds)
        ds:Set('PatientID', uid())
    end)

    -- Cloud AI gets fully de-identified copy with new UIDs
    route:Add('CLOUD_AI', function(ds)
        strip_phi(ds)
        ds:Set('PatientName', 'DEIDENTIFIED')
        ds:Set('PatientID', uid())
        ds:Set('StudyInstanceUID', uid())
        ds:Set('SeriesInstanceUID', uid())
        ds:Set('SOPInstanceUID', uid())
    end)

    log:info('Mammography fan-out:',
        file.sourceAeTitle, '->', file.destinationAeTitle,
        '+ RESEARCH + CLOUD_AI')
  Conditions:
    - Tag: 0008,0060
      MatchExpression: MG