diff --git a/Project.toml b/Project.toml
index a16dcb853f410ae6c20ed6d3512a0966027c2265..5f5beef3ad2f1c6a0912e4c8d93d0527006a0d8f 100644
--- a/Project.toml
+++ b/Project.toml
@@ -1,2 +1,4 @@
[deps]
+JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1"
LazyArtifacts = "4af54fe1-eca0-43a8-85a7-787d91b784e3"
+PlanarLarvae = "c2615984-ef14-4d40-b148-916c85b43307"
diff --git a/scripts/revert_label_mapping.jl b/scripts/revert_label_mapping.jl
new file mode 100755
index 0000000000000000000000000000000000000000..6824269fc5dac5d883353ee850dda51a6cff9a20
--- /dev/null
+++ b/scripts/revert_label_mapping.jl
@@ -0,0 +1,154 @@
+#!/usr/bin/env bash
+#=
+if realpath --version &> /dev/null; then
+macos_realpath=$(cd "$(dirname "${BASH_SOURCE[0]}")"; pwd -P)
+PROJECT_DIR=$(dirname $macos_realpath)
+else
+PROJECT_DIR=$(dirname $(dirname $(realpath "${BASH_SOURCE[0]}")))
+fi
+if [ -z "$JULIA" ]; then JULIA=julia; fi
+exec $JULIA --project="$PROJECT_DIR" --color=yes --startup-file=no "${BASH_SOURCE[0]}" "$@"
+=#
+
+using PlanarLarvae.LarvaBase, PlanarLarvae.Datasets, PlanarLarvae.Formats
+using JSON3
+
+"""
+ revert_label_mapping(output_label_file, unmapped_label_file, edited_label_file, clfconfig_file)
+
+Reverse-map the labels while keeping the manual editions made on top of mapped labels.
+
+All input arguments are file paths.
+
+This is useful for example with taggers `20230311-0` and `20230311` (same predictions, with
+labels mapped from 12 labels to 7 labels):
+
+* `edited_label_file` saved after manually editing a file generated by the `20230311` tagger
+ (mapped labels);
+* `unmapped_label_file` generated by `20230311-0` (original labels prior to label mapping and
+ editing); can be a *trx.mat* file instead;
+* `clfconfig_file`: *clf_config.json* file for tagger `20230311` (specifies label mapping);
+* `output_label_file`: resulting json *.label* file.
+"""
+function revert_label_mapping(output_label_file, unmapped_label_file, edited_label_file, clfconfig_file)
+ clfconfig = JSON3.read(clfconfig_file)
+ legal_labels = collect(clfconfig["original_behavior_labels"])
+ mapping = zip(legal_labels, clfconfig["behavior_labels"])
+ reverse_mapping = mergewith(vcat, (Dict(v=>[k]) for (k, v) in mapping)...)
+ original_needed = Dict{String, Vector{String}}()
+ for (k, v) in pairs(reverse_mapping)
+ if 1 < length(v)
+ original_needed[k] = pop!(reverse_mapping, k)
+ end
+ end
+ reverse_mapping = Dict(k=>v[1] for (k, v) in pairs(reverse_mapping))
+ #
+ input_labels = load(edited_label_file)
+ original_labels = preload(unmapped_label_file)
+ if !isempty(original_needed)
+ if original_labels isa Formats.Trxmat
+ Formats.drop_spines!(original_labels)
+ Formats.drop_outlines!(original_labels)
+ end
+ original_labels = Formats.getnativerepr(original_labels)
+ if original_labels isa Run
+ decodelabels!(original_labels)
+ end
+ end
+ run = decodelabels!(getrun(input_labels))
+ #
+ for track in values(run)
+ labels = track[:labels]
+ for (i, labels′) in enumerate(labels)
+ label = if labels′ isa Vector
+ @assert length(labels′) == 2 && labels′[2] == "edited"
+ labels′[1]
+ else
+ @assert labels′ isa AbstractString
+ labels′
+ end
+ if label in keys(reverse_mapping)
+ label = reverse_mapping[label]
+ else
+ original = if original_labels isa Run
+ labels″ = original_labels[track.id][:labels][i]
+ labels″ isa Vector ? labels″ : [labels″]
+ elseif original_labels isa LarvaBase.Larvae
+ _, labels″ = original_labels[track.id][i]
+ convert(Vector{String}, labels″[:tags])
+ end
+ for label′ in original
+ if label′ in legal_labels
+ if label′ ∉ original_needed[label]
+ @warn "Restored label qualitatively differs" label label′ track.id step=i
+ end
+ label = label′
+ break
+ end
+ end
+ end
+ @assert label in legal_labels
+ if labels′ isa Vector
+ labels′[1] = label
+ else
+ labels′ = label
+ end
+ labels[i] = labels′
+ end
+ end
+ #
+ if run.attributes[:labels] isa AbstractDict
+ run.attributes[:labels][:names] = legal_labels
+ else
+ run.attributes[:labels] = legal_labels
+ end
+ secondary_labels = Datasets.getsecondarylabels(run)
+ if !isnothing(secondary_labels)
+ foreach(secondary_labels) do label
+ push!(legal_labels, label)
+ end
+ end
+ encodelabels!(run; labels=legal_labels, storelabels=false)
+ #
+ if :metadata in keys(run.attributes)
+ metadata = run.attributes[:metadata]
+ pop!(metadata, :software, nothing)
+ if isempty(metadata)
+ pop!(run.attributes, :metadata)
+ end
+ end
+ #
+ Datasets.to_json_file(output_label_file, run)
+end
+
+usage = """revert_label_mapping.jl
+
+Usage:
+ revert_label_mapping.jl <output-file> <unmapped-labels> <edited-labels> <clfconfig-file>
+
+Options:
+ -h, --help Show this message.
+
+Arguments:
+ <output-file> Output .label file path with reverse-mapped labels and manual editions.
+ <unmapped-labels> Unmapped .label file path (e.g. from tagger 20230311-0).
+ <edited-labels> .label file path with mapped labels (e.g. from tagger 20230311) and
+ manually edited afterwards.
+ <clfconfig-file> Tagger's clf_config.json file path with label mapping specification
+ (e.g. 20230311's).
+"""
+
+function main(args=ARGS)
+ if args isa AbstractString
+ args = split(args)
+ end
+ if length(args) != 4 || "-h" in args || "--help" in args
+ print(usage)
+ else
+ revert_label_mapping(args...)
+ end
+end
+
+if abspath(PROGRAM_FILE) == @__FILE__
+ main()
+end