diff --git a/Manifest.toml b/Manifest.toml index cf255d7a031755ab63cb7eef5552258efa33f301..af56fc636a1f1a66302a588746ca80d003f3e614 100644 --- a/Manifest.toml +++ b/Manifest.toml @@ -1,8 +1,8 @@ # This file is machine-generated - editing it directly is not advised -julia_version = "1.10.8" +julia_version = "1.10.9" manifest_format = "2.0" -project_hash = "029a32b6e21d0f2c52e6dccd0a009e9681bdff18" +project_hash = "51a3effe31474c33a1cea491c41e13148b8635d0" [[deps.AbstractFFTs]] deps = ["LinearAlgebra"] @@ -503,10 +503,10 @@ uuid = "0234f1f7-429e-5d53-9886-15a909be8d59" version = "1.14.2+1" [[deps.HTTP]] -deps = ["Base64", "CodecZlib", "ConcurrentUtilities", "Dates", "ExceptionUnwrapping", "Logging", "LoggingExtras", "MbedTLS", "NetworkOptions", "OpenSSL", "Random", "SimpleBufferStream", "Sockets", "URIs", "UUIDs"] -git-tree-sha1 = "1336e07ba2eb75614c99496501a8f4b233e9fafe" +deps = ["Base64", "CodecZlib", "ConcurrentUtilities", "Dates", "ExceptionUnwrapping", "Logging", "LoggingExtras", "MbedTLS", "NetworkOptions", "OpenSSL", "PrecompileTools", "Random", "SimpleBufferStream", "Sockets", "URIs", "UUIDs"] +git-tree-sha1 = "c67b33b085f6e2faf8bf79a61962e7339a81129c" uuid = "cd3eb016-35fb-5094-929b-558a96fad6f3" -version = "1.10.10" +version = "1.10.15" [[deps.HarfBuzz_jll]] deps = ["Artifacts", "Cairo_jll", "Fontconfig_jll", "FreeType2_jll", "Glib_jll", "Graphite2_jll", "JLLWrappers", "Libdl", "Libffi_jll"] @@ -848,6 +848,11 @@ git-tree-sha1 = "1d2dd9b186742b0f317f2530ddcbf00eebb18e96" uuid = "23992714-dd62-5051-b70f-ba57cb901cac" version = "0.10.7" +[[deps.MIMEs]] +git-tree-sha1 = "1833212fd6f580c20d4291da9c1b4e8a655b128e" +uuid = "6c6e2e6c-3030-632d-7369-2d6c69616d65" +version = "1.0.0" + [[deps.MKL_jll]] deps = ["Artifacts", "IntelOpenMP_jll", "JLLWrappers", "LazyArtifacts", "Libdl", "oneTBB_jll"] git-tree-sha1 = "f046ccd0c6db2832a9f639e2c669c6fe867e5f4f" @@ -1025,7 +1030,7 @@ version = "3.2.4+0" [[deps.OpenLibm_jll]] deps = ["Artifacts", "Libdl"] uuid = "05823500-19ac-5b8b-9628-191a04bc5112" -version = "0.8.1+2" +version = "0.8.1+4" [[deps.OpenMPI_jll]] deps = ["Artifacts", "CompilerSupportLibraries_jll", "Hwloc_jll", "JLLWrappers", "LazyArtifacts", "Libdl", "MPIPreferences", "TOML", "Zlib_jll"] @@ -1062,6 +1067,12 @@ git-tree-sha1 = "dfdf5519f235516220579f949664f1bf44e741c5" uuid = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" version = "1.6.3" +[[deps.Oxygen]] +deps = ["DataStructures", "Dates", "HTTP", "JSON3", "MIMEs", "Reexport", "RelocatableFolders", "Requires", "Sockets", "Statistics", "StructTypes"] +git-tree-sha1 = "2ad010b0de6172faf1d09ed5e0837eb0b7355bd8" +uuid = "df9a0d86-3283-4920-82dc-4555fc0d1d8b" +version = "1.5.16" + [[deps.PCRE2_jll]] deps = ["Artifacts", "Libdl"] uuid = "efcefdf7-47ab-520b-bdef-62a2eaa19f15" diff --git a/Project.toml b/Project.toml index 60848ef53384d300254e240b3260af26052804df..4707dabd64ff184de9b7d7c20e66c91d37e4f4e1 100644 --- a/Project.toml +++ b/Project.toml @@ -9,6 +9,8 @@ Colors = "5ae59095-9a9b-59fe-a467-6f913c188581" Dates = "ade2ca70-3891-5945-98fb-dc099432e06a" DocOpt = "968ba79b-81e4-546f-ab3a-2eecfa62a9db" Format = "1fa38f19-a742-5d3f-a2b9-30dd87b9d5f8" +HTTP = "cd3eb016-35fb-5094-929b-558a96fad6f3" +JSON3 = "0f8b85d8-7281-11e9-16c2-39a750bddbf1" LinearAlgebra = "37e2e46d-f89d-539d-b4ee-838fcccc9c8e" Logging = "56ddb016-857b-54e1-b83d-db4d58db5568" Makie = "ee78f7c6-11fb-53f2-987a-cfe4a2b5a57a" @@ -18,6 +20,7 @@ NyxWidgets = "c288fd06-43d3-4b04-8307-797133353e2e" Observables = "510215fc-4207-5dde-b226-833fc4488ee2" ObservationPolicies = "6317928a-6b1a-42e8-b853-b8e2fc3e9ca3" OrderedCollections = "bac558e1-5e72-5ebc-8fee-abe8a469f55d" +Oxygen = "df9a0d86-3283-4920-82dc-4555fc0d1d8b" PlanarLarvae = "c2615984-ef14-4d40-b148-916c85b43307" Random = "9a3f8284-a2c9-5f02-9a11-845980a1fd5c" StaticArrays = "90137ffa-7385-5640-81b9-e52037218182" diff --git a/README.md b/README.md index 1b9a1b286b9ae0235f27bf794435e79375c3edfd..9996377e17efce0fab5afc88c784e9a5affc5836 100644 --- a/README.md +++ b/README.md @@ -213,19 +213,6 @@ The `--browser` argument may open a new tab in your web browser, but this featur The first time the application is loaded, it may take a while for a window in your web browser to open, and the data to be plotted. -### From the *Julia* interpreter - -As an alternative to the *larvatagger* script or command, in the *LarvaTagger* directory created above, launch the *Julia* interpreter: -``` -julia --project=. -``` -In the interpreter, to launch the editor, type: -``` -julia> using LarvaTagger; display(larvaeditor("path/to/data/file")) -``` - -To exit the interpreter, type `exit()` or press Ctrl+D. - ### macOS On some computers (typically macOS computers), the 2D larva view may show up twice as small as expected. diff --git a/src/LarvaTagger.jl b/src/LarvaTagger.jl index 00d344cc349bbf9d23082cd6168c350f5e7cb788..5c96f8ecf48b8737b3f44c59f0dea6a957975ceb 100644 --- a/src/LarvaTagger.jl +++ b/src/LarvaTagger.jl @@ -32,6 +32,8 @@ include("players.jl") include("controllers.jl") include("Taggers.jl") using .Taggers +include("REST/REST.jl") +using .REST include("files.jl") include("backends.jl") include("cli_base.jl") diff --git a/src/REST/Client.jl b/src/REST/Client.jl new file mode 100644 index 0000000000000000000000000000000000000000..61814f43a12209c35a7e6ea2576db718e79a5b67 --- /dev/null +++ b/src/REST/Client.jl @@ -0,0 +1,225 @@ +module Client + +using PlanarLarvae.Datasets +import ..Taggers: Taggers, Tagger +import HTTP: HTTP +using JSON3 +using OrderedCollections: OrderedDict +using Observables + +export RemoteTagger, LTBackend, connect, listmodels, active_model_instance + +mutable struct RemoteTagger + endpoint::AbstractString + token::AbstractString # empty if not connected + backend::AbstractString + model_instance::AbstractString + output_filenames::Dict{String, String} # renaming rules +end + +function RemoteTagger(endpoint, backend, model_instance) + RemoteTagger(endpoint, "", backend, model_instance, Dict{String, String}()) +end + +connected(tagger) = !isempty(tagger.token) + +function connect!(tagger) + tagger.token = gettoken(tagger) + return tagger +end + +function gettoken(tagger) + if isempty(tagger.token) + resp = HTTP.get("$(tagger.endpoint)/get-token/$(tagger.backend)/$(tagger.model_instance)") + transcode(String, resp.body) + else + tagger.token + end +end + +function url(tagger::RemoteTagger, switch) + token = tagger.token + @assert !isnothing(token) + return "$(tagger.endpoint)/$switch/$(tagger.backend)/$(tagger.model_instance)/$token" +end + +function Base.close(tagger::RemoteTagger) + HTTP.get(url(tagger, "close")) + tagger.token = "" +end + +function listfiles(tagger::RemoteTagger, srcdir::String) + resp = HTTP.get("$(url(tagger, "list-files"))/$srcdir") + @info "listfiles" transcode(String, resp.body) + String[] +end + +function Taggers.pull(tagger::RemoteTagger, destdir::String) + cmd = url(tagger, "pull-file") + # destdir can be empty on macOS + destdir = isempty(destdir) ? pwd() : realpath(destdir) # strip end slash + destfiles = String[] + for filename in listfiles(tagger, "processed") + resp = HTTP.get("$cmd/$filename") + destfile = joinpath(destdir, filename) + open(destfile, "w") do f + write(f, resp.body) + end + push!(destfiles, destfile) + end + return destfiles +end + +function Taggers.resetdata(tagger::RemoteTagger, dir=nothing) + query = url(tagger, "reset-data") + if !isnothing(dir) + query = "$query/$dir" + end + HTTP.get(query) +end + +function Taggers.pushfile(tagger::RemoteTagger, src, dst) + @assert dst == basename(src) + Taggers.pushfile(tagger, src) +end + +function Taggers.pushfile(tagger::RemoteTagger, src) + request = url(tagger, "push-file") + @info "dummy push-file" src +end + +function Taggers.push(tagger::RemoteTagger, file::String, metadata; clean=true) + clean ? Taggers.resetdata(tagger) : Taggers.resetdata(tagger, "raw") + Taggers.push(tagger, file) + if !isnothing(metadata) + mktempdir() do dir + metadatafile = joinpath(dir, "metadata") + Datasets.to_json_file(metadatafile, metadata) + Taggers.pushfile(tagger, metadatafile) + end + end +end + +Taggers.run(tagger::RemoteTagger, switch) = HTTP.get(url(tagger, switch); retry=false) + +Taggers.predict(tagger::RemoteTagger) = Taggers.run(tagger, "predict") + +Taggers.embed(tagger::RemoteTagger) = Taggers.run(tagger, "embed") + + +struct LTBackend + endpoint + metadata + taggers + active_tagging_backend + active_tagger +end + +function LTBackend(endpoint) + metadata = Dict{String, OrderedDict{Symbol, Union{String, Dict{String, OrderedDict{Symbol, String}}}}}() + taggers = OrderedDict{String, OrderedDict{String, RemoteTagger}}() + active_tagging_backend = Observable{Union{Nothing, String}}(nothing) + active_tagger = Observable{Union{Nothing, RemoteTagger}}(nothing) + on(active_tagging_backend) do tagging_backend + if isnothing(tagging_backend) + active_tagger[] = nothing + elseif isnothing(active_tagger[]) || active_tagger[].backend != tagging_backend + active_tagger[] = first(values(taggers[tagging_backend])) + else + notify(active_tagger) + end + end + on(active_tagger) do tagger + if !isnothing(tagger) + @info "Tagger selected" backend=tagger.backend instance=tagger.model_instance + if tagger.backend != active_tagging_backend[] + active_tagging_backend[] = tagger.backend + end + end + end + LTBackend(endpoint, metadata, taggers, active_tagging_backend, active_tagger) +end + +function active_model_instance(back::LTBackend) + obs = Observable{Union{Nothing, String}}(nothing) + active_tagger = back.active_tagger + on(active_tagger) do tagger + obs[] = isnothing(tagger) ? nothing : tagger.model_instance + end + on(obs) do model_instance + tagger = active_tagger[] + if model_instance != tagger.model_instance + active_tagger[] = back.taggers[tagger.backend][model_instance] + end + end + return obs +end + +function connect(back::LTBackend; refresh_rate=0.5, preselect_tagger=false) + while true + resp = HTTP.get("$(back.endpoint)/status") + resp = transcode(String, resp.body) + if resp == "up" + break + else + sleep(refresh_rate) + end + end + listtaggers(back) + #@info "listtaggers" back.taggers back.metadata + if preselect_tagger + back.active_tagging_backend[] = first(collect(keys(back.taggers))) + end +end + +function simpleconvert(json::JSON3.Object) + OrderedDict(key => simpleconvert(val) for (key, val) in pairs(json)) +end + +simpleconvert(json::JSON3.Array) = simpleconvert.(json) + +simpleconvert(val) = val + +function listtaggers(back::LTBackend) + endpoint = back.endpoint + resp = HTTP.get("$(endpoint)/list-taggers") + resp = transcode(String, resp.body) + json = JSON3.read(resp) + @assert json isa JSON3.Array + taggers = simpleconvert(json) + for tagger in taggers + tagging_backend = tagger[:name] + back.metadata[tagging_backend] = OrderedDict( + key => (key === :models ? Dict(model[:name] => OrderedDict( + key′=> val′ for (key′, val′) in pairs(model) if key′!== :name) + for model in val) : val) + for (key, val) in pairs(tagger) if key !== :name) + back.taggers[tagging_backend] = OrderedDict( + model[:name] => RemoteTagger(endpoint, tagging_backend, model[:name]) + for model in tagger[:models]) + end + return taggers +end + +listmodels(back::LTBackend) = listmodels(back, Val(false)) + +listmodels(back::LTBackend, ::Val{false}) = collect(keys(back.taggers)) + +function listmodels(back::LTBackend, ::Val{true}) + map(back.active_tagging_backend) do tagging_backend + isnothing(tagging_backend) ? String[] : collect(keys(back.taggers[tagging_backend])) + end +end + +function Taggers.predict(back::LTBackend, file::String; metadata=nothing) + tagger = back.active_tagger[] + isnothing(tagger) && throw("no active tagger") + connected(tagger) || connect!(tagger) + Taggers.push(tagger, file, metadata) + Taggers.predict(tagger) + labelfile = Taggers.pull(tagger, dirname(file)) + @assert length(labelfile) == 1 + return labelfile[1] +end + +end diff --git a/src/REST/Model.jl b/src/REST/Model.jl new file mode 100644 index 0000000000000000000000000000000000000000..781ce7ab71a807fd8c48344d98bf8c329139b3ba --- /dev/null +++ b/src/REST/Model.jl @@ -0,0 +1,224 @@ +module Model + +import ..Taggers: Taggers, Tagger +import HTTP: HTTP +import JSON3 +using OrderedCollections: OrderedDict + +export LTBackend, gettoken, resetdata, listfiles, pushfile, pullfile, listtaggers, predict, + embed + +# pure part of the Server module (no global state) + +struct LTBackend + root + tokens + lock +end + +function LTBackend() + root = Ref{AbstractString}("") + tokens = Dict{String, Dict{String, Dict{String, Float64}}}() + lock = ReentrantLock() + LTBackend(root, tokens, lock) +end + +Base.lock(f::Function, backend::LTBackend) = lock(f, backend.lock) + +Base.isready(backend::LTBackend) = isdir(backend.root[]) + +get!(dict::AbstractDict{K, V}, key::K) where {K, V} = Base.get!(dict, key, V()) + +function get!(dict::AbstractDict{K, D}, key1::K, key2, keys...) where {K, D} + get!(get!(dict, key1), key2, keys...) +end + +function gettagger(lt_backend, tagging_backend_dir, model_instance) + @assert isready(lt_backend) + tagging_backend_path = joinpath(lt_backend.root[], tagging_backend_dir) + @assert Taggers.isbackend(tagging_backend_path) + lock(lt_backend) do + tagger = Taggers.isolate(Tagger(tagging_backend_path, model_instance)) + token = tagger.sandbox + tokens = get!(lt_backend.tokens, tagging_backend_dir, model_instance) + @assert token ∉ keys(tokens) + tokens[token] = time() + return tagger + end +end + +function gettagger(lt_backend, tagging_backend_dir, model_instance, token) + @assert isready(lt_backend) + lock(lt_backend) do + tokens = lt_backend.tokens + @assert tagging_backend_dir in keys(tokens) + tokens = lt_backend.tokens[tagging_backend_dir] + @assert model_instance in keys(tokens) + tokens = tokens[model_instance] + @assert token in keys(tokens) + end + tagging_backend_path = joinpath(lt_backend.root[], tagging_backend_dir) + tagger = Tagger(tagging_backend_path, model_instance, token) + return tagger +end + +## + +function gettoken(lt_backend, backend_dir, model_instance) + tagger = gettagger(lt_backend, backend_dir, model_instance) + return tagger.sandbox +end + +function Base.close(lt_backend::LTBackend, backend_dir, model_instance, token) + tagger = gettagger(lt_backend, backend_dir, model_instance, token) + Taggers.removedata(tagger) + lock(lt_backend) do + pop!(lt_backend.tokens[backend_dir][model_instance], token) + end + nothing +end + +function resetdata(lt_backend, backend_dir, model_instance, token, datadir=nothing) + tagger = gettagger(lt_backend, backend_dir, model_instance, token) + if isnothing(datadir) + Taggers.resetdata(tagger) + else + Taggers.resetdata(tagger, datadir) + end + nothing +end + +function listfiles(lt_backend, backend_dir, model_instance, token, data_dir) + tagger = gettagger(lt_backend, backend_dir, model_instance, token) + dir = Taggers.datadir(tagger, data_dir) + ls = [] + for (parent, _, files) in walkdir(dir; follow_symlinks=true) + if parent == dir + append!(ls, files) + else + parent = relpath(parent, dir) + for file in files + push!(ls, joinpath(parent, file)) + end + end + end + isempty(ls) ? "[]" : "[\"" * join(ls, "\", \"") * "\"]" +end + +""" + pushfile(::LTBackend, ::HTTP.Request, backend_dir, model_instance, token) + +Handle `push-file` queries, *i.e.* receive file. +""" +function pushfile(lt_backend, request, backend_dir, model_instance, token) + tagger = gettagger(lt_backend, backend_dir, model_instance, token) + @assert request.method == "POST" + body = request.body + @assert body isa Vector{UInt8} + k = findfirst(c -> c in (0x0d, 0x0a), body) - 1 + filesep = body[1:k] + linesep = if body[k+2] == 0x0a + @assert body[k+1] == 0x0d + body[k+1:k+2] + else + body[k+1] + end + filesep = vcat(linesep, filesep) + n = length(filesep) + dk = length(linesep) + bodies = Vector{UInt8}[] + while k < length(body) + i, j = 1, k + dk + 1 + for outer k in j:length(body) + if body[k] == filesep[i] + i += 1 + if n < i + push!(bodies, body[j:k-n]) + break + end + else + i = 1 + end + end + end + linesep = transcode(String, linesep) + for body in bodies + k = HTTP.Parsers.find_end_of_header(body) + @assert 0 < k + rawheader = transcode(String, body[1:k-2dk]) + content = body[k+1:end] + @debug "push-file" rawheader + header = Dict{Symbol, AbstractString}() + for line in split(rawheader, linesep) + if isempty(header) + for pair in split(line, "; ") + parts = split(pair, '"') + if length(parts) == 1 + key, val = split(pair, ": ") + header[Symbol(key)] = val + else + key = parts[1] + @assert endswith(key, '=') + key = Symbol(key[1:end-1]) + val = join(parts[2:end-1], '"') + header[key] = val + end + end + else + @assert startswith(line, "Content-Type: ") + key, val = split(line, ": ") + header[Symbol(key)] = val + end + end + @info "push-file" header length(content) + filename = header[:filename] + dest = joinpath(Taggers.datadir(tagger, "raw"), filename) + open(dest, "w") do f + write(f, content) + end + end +end + +function pullfile(lt_backend, backend_dir, model_instance, token, filename) + tagger = gettagger(lt_backend, backend_dir, model_instance, token) + src = joinpath(Taggers.datadir(tagger, "processed"), filename) + header = Dict("Content-Disposition" => "form-data") + body = open(src) + return HTTP.Response(200, header, body) +end + +function listtaggers(lt_backend) + inventory = Vector{OrderedDict{String, Any}}() + backends_dir = lt_backend.root[] + for tagging_backend in readdir(backends_dir) + tagging_backend_path = joinpath(backends_dir, tagging_backend) + Taggers.isbackend(tagging_backend_path) || continue + models_dir = joinpath(tagging_backend_path, "models") + models = [OrderedDict( + "name" => dir, + "description" => "", + "homepage" => "", + ) for dir in readdir(models_dir) if isdir(joinpath(models_dir, dir))] + push!(inventory, OrderedDict( + "name" => tagging_backend, + "description" => "", + "homepage" => "", + "models" => models, + )) + end + return JSON3.write(inventory) +end + +function predict(lt_backend, backend_dir, model_instance, token) + tagger = gettagger(lt_backend, backend_dir, model_instance, token) + # blocking; should we run async and expose a token-specific status api call? + Taggers.predict(tagger; skip_make_dataset=true) +end + +function embed(lt_backend, backend_dir, model_instance, token) + tagger = gettagger(lt_backend, backend_dir, model_instance, token) + # blocking, like predict + Taggers.embed(tagger; skip_make_dataset=true) +end + +end diff --git a/src/REST/REST.jl b/src/REST/REST.jl new file mode 100644 index 0000000000000000000000000000000000000000..e4465b9d98c337db5c2c52c5c7aa6e7394c87a06 --- /dev/null +++ b/src/REST/REST.jl @@ -0,0 +1,8 @@ +module REST + +using ..Taggers + +include("Server.jl") +include("Client.jl") + +end diff --git a/src/REST/Server.jl b/src/REST/Server.jl new file mode 100644 index 0000000000000000000000000000000000000000..4bd2bf57fe951ea4acd3d8f00a1b6b403b06bdca --- /dev/null +++ b/src/REST/Server.jl @@ -0,0 +1,130 @@ +module Server + +using Oxygen; @oxidise +import ..Taggers + +include("Model.jl") +using .Model + +export run_backend + +function run_backend(backend::LTBackend; async=false, port=9285, kwargs...) + @assert isready(backend) + serve(; async=async, port=port, kwargs...) +end + +# the Oxygen module has global state; as a consequence, the server must also have global +# state +const lt_backend = LTBackend() + +function run_backend(root::AbstractString; kwargs...) + lt_backend.root[] = root + run_backend(; kwargs...) +end + +run_backend(; kwargs...) = run_backend(lt_backend; kwargs...) + + +@get "/status" function(request) + return "up" +end + + +@get "/get-token/{backend_dir}/{model_instance}" function( + request, + backend_dir::String, + model_instance::String, + ) + gettoken(lt_backend, backend_dir, model_instance) +end + + +@get "/close/{backend_dir}/{model_instance}/{token}" function( + request, + backend_dir::String, + model_instance::String, + token::String, + ) + close(lt_backend, backend_dir, model_instance, token) +end + + +@get "/reset-data/{backend_dir}/{model_instance}/{token}" function( + request, + backend_dir::String, + model_instance::String, + token::String, + ) + resetdata(lt_backend, backend_dir, model_instance, token) +end + + +@get "/reset-data/{backend_dir}/{model_instance}/{token}/{data_dir}" function( + request, + backend_dir::String, + model_instance::String, + token::String, + data_dir::String, + ) + resetdata(lt_backend, backend_dir, model_instance, token, data_dir) +end + + +@get "/list-files/{backend_dir}/{model_instance}/{token}/{data_dir}" function( + request, + backend_dir::String, + model_instance::String, + token::String, + data_dir::String, + ) + listfiles(lt_backend, backend_dir, model_instance, token, data_dir) +end + + +@post "/push-file/{backend_dir}/{model_instance}/{token}" function( + request, + backend_dir::String, + model_instance::String, + token::String, + ) + pushfile(lt_backend, request, backend_dir, model_instance, token) +end + + +@get "/pull-file/{backend_dir}/{model_instance}/{token}/{filename}" function( + request, + backend_dir::String, + model_instance::String, + token::String, + filename::String, + ) + pullfile(lt_backend, backend_dir, model_instance, token, filename) +end + + +@get "/list-taggers" function(request) + listtaggers(lt_backend) +end + + +@get "/predict/{backend_dir}/{model_instance}/{token}" function( + request, + backend_dir::String, + model_instance::String, + token::String, + ) + predict(lt_backend, backend_dir, model_instance, token) +end + + +@get "/embed/{backend_dir}/{model_instance}/{token}" function( + request, + backend_dir::String, + model_instance::String, + token::String, + ) + embed(lt_backend, backend_dir, model_instance, token) +end + + +end diff --git a/src/Taggers.jl b/src/Taggers.jl index b66ef40de5e133bc456ed95eedb44ec27a99e826..e7864cca49f69ca21cfcb13371bec6d006b39733 100644 --- a/src/Taggers.jl +++ b/src/Taggers.jl @@ -11,14 +11,15 @@ struct Tagger output_filenames::Dict{String, String} end -function Tagger(backend_dir::String, model_instance::String) - Tagger(backend_dir, model_instance, nothing, Dict{String, String}()) +function Tagger(backend_dir::String, model_instance::String, + sandbox::Union{Nothing, String}=nothing) + Tagger(backend_dir, model_instance, sandbox, Dict{String, String}()) end Tagger(backend_dir, model_instance) = Tagger(string(backend_dir), string(model_instance)) function isolate(tagger) rawdatadir = joinpath(tagger.backend_dir, "data", "raw") - mkdir(rawdatadir) + isdir(rawdatadir) || mkpath(rawdatadir) rawdatadir = mktempdir(rawdatadir; cleanup=false) Tagger(tagger.backend_dir, tagger.model_instance, basename(rawdatadir), tagger.output_filenames) @@ -50,15 +51,15 @@ tagging_backend_command(tagger::Tagger) = tagging_backend_command(tagger.backend modeldir(tagger::Tagger) = joinpath(tagger.backend_dir, "models", tagger.model_instance) -datadir(tagger::Tagger, stage::String) = joinpath(tagger.backend_dir, "data", stage, +datadir(tagger::Tagger, stage) = joinpath(tagger.backend_dir, "data", stage, something(tagger.sandbox, tagger.model_instance)) -function reset(tagger::Tagger) +function reset(tagger) resetmodel(tagger) resetdata(tagger) end -function reset(dir::String) +function reset(dir::AbstractString) try rm(dir; recursive=true) catch @@ -69,13 +70,13 @@ end resetmodel(tagger::Tagger) = reset(modeldir(tagger)) -function resetdata(tagger::Tagger) +function resetdata(tagger) for dir in ("raw", "interim", "processed") resetdata(tagger, dir) end end -resetdata(tagger::Tagger, dir::String) = reset(datadir(tagger, dir)) +resetdata(tagger::Tagger, dir) = reset(datadir(tagger, dir)) function removedata(tagger::Tagger) for dir in ("raw", "interim", "processed") @@ -166,6 +167,72 @@ function push(tagger::Tagger, inputdata::String) return destination end +## new implementation for push + +function pushfile(tagger, src, dst) + backend_name = basename(realpath(tagger.backend_dir)) + @info "Pushing file to backend" backend=backend_name instance=tagger.model_instance src dst + src = normpath(src) + dst = normpath(joinpath(datadir(tagger, "raw"), dst)) + if dst != src + dstdir = dirname(dst) + mkpath(dstdir) + open(src, "r") do f + open(dst, "w") do g + write(g, read(f)) + end + end + end + return dst +end + +pushfile(tagger, src) = pushfile(tagger, src, basename(src)) + +function pushdir(tagger, src, dst=nothing) + raw = datadir(tagger, "raw") + dst = isnothing(dst) ? raw : joinpath(raw, dst) + symlink(src, dst) + return dst +end + +function push(tagger, inputdata::AbstractString) + destination = nothing + if occursin('*', inputdata) + repository = Dataloaders.Repository(inputdata) + for file in Formats.find_associated_files(Dataloaders.files(repository)) + srcfile = file.source + dstfile = relpath(srcfile, repository.root) + pushfile(tagger, srcfile, dstfile) + end + elseif isdir(inputdata) + srcdir = realpath(inputdata) # strip the end slashes + resetdata(tagger, "raw") + pushdir(tagger, srcdir) + elseif endswith(inputdata, ".txt") + files_by_dir = Dict{String, Vector{String}}() + for file in readlines(inputdata) + parent = dirname(file) + push!(get!(files_by_dir, parent, String[]), abspath(file)) + end + for (dir, files) in pairs(files_by_dir) + for file in Formats.find_associated_files(files) + srcfile = file.source + dstfile = joinpath(dir, basename(srcfile)) + pushfile(tagger, srcfile, dstfile) + end + end + else + for file in Formats.find_associated_files(abspath(inputdata)) + srcfile = file.source + dstfile = pushfile(tagger, srcfile) + if isnothing(destination) + destination = dstfile + end + end + end + return destination +end + function pull(tagger::Tagger, dest_dir::String) proc_data_dir = datadir(tagger, "processed") isdir(proc_data_dir) || throw("no processed data directory found") diff --git a/src/backends.jl b/src/backends.jl index 41f5c9ba385b52d7e1765bdeda93b66e4a61e19e..d734ad19d81921c70ee3f8bef25a36e324d78ebb 100644 --- a/src/backends.jl +++ b/src/backends.jl @@ -46,26 +46,40 @@ Backends(controller, location) = Backends(controller, string(location)) function getbackends(controller, location=nothing) controller = gethub(controller) - try - return controller[:backends] - catch - backends = Backends(controller, location) - Observables.notify(backends.active_backend) - controller[:backends] = backends - return backends + if haskey(controller, :backends) + controller[:backends] + else + if !isnothing(location) && startswith(location, "http://") + back = REST.Client.LTBackend(location) + REST.Client.connect(back; preselect_tagger=true) + controller[:backends] = back + else + backends = Backends(controller, location) + Observables.notify(backends.active_backend) + controller[:backends] = backends + end end end -function Taggers.push(model::Backends, file::String; clean=true, metadata=true) +get_active_backend(backends::Backends) = backends.active_backend +get_model_instances(backends::Backends) = backends.model_instances +get_model_instance(backends::Backends) = backends.model_instance +get_backend_names(backends::Backends) = backends.backends + +get_active_backend(back::REST.Client.LTBackend) = back.active_tagging_backend +get_model_instances(back::REST.Client.LTBackend) = REST.Client.listmodels(back, Val(true)) +get_model_instance(back::REST.Client.LTBackend) = REST.Client.active_model_instance(back) +get_backend_names(back::REST.Client.LTBackend) = REST.Client.listmodels(back) + +function Taggers.push(model::Backends, file::String; clean=true, metadata=nothing) tagger = gettagger(model) clean ? resetdata(tagger) : resetdata(tagger, "raw") dest_file = Taggers.push(tagger, file) - if metadata + if !isnothing(metadata) # save the metadata to file, so that the backend can reproduce them in the output # file for predicted labels dest_file = joinpath(dirname(dest_file), "metadata") - metadata = Observables.to_value(getmetadatatable(model.controller)) - PlanarLarvae.Datasets.to_json_file(dest_file, asdict(metadata)) + PlanarLarvae.Datasets.to_json_file(dest_file, metadata) end end @@ -76,25 +90,32 @@ function Taggers.pull(model::Backends, destdir::String) return Taggers.pull(tagger, destdir) end -function Taggers.predict(model::Backends) +function Taggers.predict(model::Backends, file::String; metadata=nothing) isnothing(model.model_instance[]) && throw("no model selected") backend_dir = joinpath(model.location, model.active_backend[]) model_instance = model.model_instance[] isnothing(model_instance) && throw("no model instance selected") - turn_load_animation_on(model.controller) + tagger = Tagger(backend_dir, model_instance) + # + Taggers.push(model, file; metadata=metadata) + # TODO: make the skip_make_dataset option discoverable in the backend + predict(tagger; skip_make_dataset=true) + labelfile = Taggers.pull(model, dirname(file)) + @assert length(labelfile) == 1 + return labelfile[1] +end + +function Taggers.predict(controller::ControllerHub, back) + inputfile = controller[:input][] + isnothing(inputfile) && throw("no loaded files") + @assert ispath(inputfile) + metadata = asdict(Observables.to_value(getmetadatatable(controller))) + turn_load_animation_on(controller) try - # TODO: make the skip_make_dataset option discoverable in the backend - predict(Tagger(backend_dir, model_instance); skip_make_dataset=true) + resultingfile = predict(back, inputfile; metadata=metadata) + tryopenfile(controller, resultingfile; reload=true) catch - turn_load_animation_off(model.controller) + turn_load_animation_off(controller) rethrow() end end - -function Taggers.predict(model::Backends, file::String) - Taggers.push(model, file) - predict(model) - labelfile = Taggers.pull(model, dirname(file)) - @assert length(labelfile) == 1 - tryopenfile(model.controller, labelfile[1]; reload=true) -end diff --git a/src/cli_open.jl b/src/cli_open.jl index a1d46b678e4e4faf1b2790a1202297a6b354ca0e..73210d872b0635b0e4dc2b5eaf3a3db82d05fb62 100644 --- a/src/cli_open.jl +++ b/src/cli_open.jl @@ -54,7 +54,7 @@ function main(args=ARGS; exit_on_error=false) infile = parsed_args["<file-path>"] if isempty(infile) infile = nothing - elseif !isfile(infile) + elseif !(startswith(infile, "http://") || isfile(infile)) if isdir(infile) dataroot = infile infile = nothing diff --git a/src/editor.jl b/src/editor.jl index ad40fb617e22a23c656a06f82595d154bbd3fd28..4891530cfc2c5c955bf8eefcf387aaeb51210f3c 100644 --- a/src/editor.jl +++ b/src/editor.jl @@ -43,6 +43,11 @@ function larvaeditor(path=nothing; enable_delete=false, kwargs...) + if path isa AbstractString && startswith(path, "http://") + backend_directory = path + path = nothing + end + # to (re-)load a file, the app is reloaded with the filepath as sole information # from previous session T = Ref{Union{Nothing, String}} diff --git a/src/larvatagger.css b/src/larvatagger.css index 013b8a4acf974db08bbde37249d883491a754cab..943cd7710e8fa7d6f91366849f2c80e12919287f 100644 --- a/src/larvatagger.css +++ b/src/larvatagger.css @@ -146,7 +146,8 @@ table { } button:disabled { - --text-opacity: 0.5; + /* --text-opacity: 0.5; */ + opacity: 0.5; pointer-events: none; } diff --git a/src/wgl.jl b/src/wgl.jl index fb4640d76c361210a95344674d35ec4f27d736bf..6674dc505cfd75b82be44d16a658101addeb99e2 100644 --- a/src/wgl.jl +++ b/src/wgl.jl @@ -979,33 +979,39 @@ function addtosavequeue!(c::LarvaFilter, id) end struct BackendMenu - backends::Backends + backends + models send2backend::NyxWidgets.Button dom_id end function backendmenu(controller, location) + controller = gethub(controller) backends = getbackends(controller, location) - disabled = map(isempty, backends.model_instances) + models = get_model_instances(backends) + disabled = if isnothing(controller[:input][]) + true + else + map(isempty, models) + end send2backend = NyxWidgets.Button("Autotag"; disabled=disabled) on(send2backend) do _ - predict(backends, controller[:input][]) + predict(controller, backends) end - BackendMenu(backends, send2backend, dom_id()) + BackendMenu(backends, models, send2backend, dom_id()) end function lowerdom(bs::BackendMenu) - models = bs.backends.model_instances - model_instance = bs.backends.model_instance + model_instance = get_model_instance(bs.backends) update_model_instance = js"(evt)=>{ $model_instance.notify(evt.srcElement.value); }" - select_dom = DOM.select(DOM.option(model; value=model) for model in models[]; + select_dom = DOM.select(DOM.option(model; value=model) for model in bs.models[]; onchange=update_model_instance, class=css_button) - active_backend = bs.backends.active_backend + active_backend = get_active_backend(bs.backends) update_active_backend = js"(evt)=>{ $active_backend.notify(evt.srcElement.value); }" return DOM.div(DOM.label("Backend"), DOM.select(DOM.option(backend; value=backend) - for backend in bs.backends.backends; + for backend in get_backend_names(bs.backends); onchange=update_active_backend, class=css_button), DOM.label("Model instance"), @@ -1015,7 +1021,7 @@ function lowerdom(bs::BackendMenu) end function Bonito.jsrender(session::Session, bs::BackendMenu) node = lowerdom(session, bs) - onjs(session, bs.backends.model_instances, js"""(options) => { + onjs(session, bs.models, js"""(options) => { select = document.querySelectorAll($(dom_selector(bs)) + ' > select')[1]; LarvaTagger.updateSelectOptions(select, options); }""") diff --git a/test/rest_client.sh b/test/rest_client.sh new file mode 100755 index 0000000000000000000000000000000000000000..23daccbe2ad9c9a1b30ac5c572791b87b9690f7b --- /dev/null +++ b/test/rest_client.sh @@ -0,0 +1,32 @@ +#!/bin/bash + +set -m + +this_script=$(realpath "${BASH_SOURCE[0]}") +larvatagger_jl_project_root=$(dirname "$(dirname "${this_script}")") +larvatagger_project_root=$(dirname "${larvatagger_jl_project_root}") + +tagging_backend=MaggotUBA +tagging_model=20230311 +lt_backend_port=9285 + +# pre-requisites are similar to rest_server.sh + +# pre-compile +julia +1.10 --project="${larvatagger_jl_project_root}" -q -e "using LarvaTagger" + +# run and background the backend server +JULIA_PROJECT="${larvatagger_project_root}/TaggingBackends" \ + julia +1.10 --project="${larvatagger_jl_project_root}" -i \ + -e "using LarvaTagger.REST.Server; run_backend(\"${larvatagger_project_root}\"; port=${lt_backend_port})" & +lt_backend_pid=$! + +# run the frontend server +JULIA="julia +1.10" ${larvatagger_jl_project_root}/scripts/larvatagger-gui.jl http://localhost:${lt_backend_port} + +# scenarii: +# * click on "Autotag" prior to loading any files (if not disabled, as with a bug in v0.19) +# * load a tracking data file, and then click on "Autotag" + +kill $lt_backend_pid +wait $lt_backend_pid diff --git a/test/rest_server.sh b/test/rest_server.sh new file mode 100755 index 0000000000000000000000000000000000000000..6de171f82297bbda75c4a929589cd40ced7285f5 --- /dev/null +++ b/test/rest_server.sh @@ -0,0 +1,177 @@ +#!/bin/bash + +set -m + +this_script=$(realpath "${BASH_SOURCE[0]}") +larvatagger_jl_project_root=$(dirname "$(dirname "${this_script}")") +larvatagger_project_root=$(dirname "${larvatagger_jl_project_root}") + +tagging_backend=MaggotUBA +tagging_model=20230311 +lt_backend_port=9285 + +# assumed directory structure: +# the MaggotUBA-adapter, TaggingBackends, NyxArtefacts and LarvaTagger.jl projects are siblings in the ${larvatagger_project_root} directory; +# the MaggotUBA-adapter project is available both as directory MaggotUBA-adapter and link MaggotUBA (${tagging_backend}); +# the MaggotUBA-adapter tagging backend contains two trained models (or model instances): 20230311 and 20230311-0; +# the NyxArtefacts project is available as directory Artefacts; +# MaggotUBA is installed with JULIA_PROJECT pointing at the TaggingBackends directory + +[ -d "${larvatagger_project_root}/${tagging_backend}/models/${tagging_model}" ] || exit "tagging backend or model not found" + +if [ -z "`julia +1.10 -v 2>/dev/null`" ]; then + juliaup add 1.10 +fi + +# run and background the backend server +JULIA_PROJECT="${larvatagger_project_root}/TaggingBackends" \ + julia +1.10 --project="${larvatagger_jl_project_root}" -iq \ + -e "using LarvaTagger.REST.Server; run_backend(\"${larvatagger_project_root}\"; port=${lt_backend_port})" & +lt_backend_pid=$! + +# wait for the server to be ready +while [ "`curl -s http://localhost:${lt_backend_port}/status`" != "up" ]; do + sleep 1 +done +echo "status: success" + +# get a session token for a tagging backend +token=`curl -sS http://localhost:${lt_backend_port}/get-token/${tagging_backend}/${tagging_model}` + +if [ "${token:0:3}" = "jl_" ]; then + echo "get-token: success" +else + echo "get-token: failure" + echo "expected: string starting with \"jl_\"" + echo "got: $token" +fi + +# send one text file, one binary file +curl -sS -F "f=@${larvatagger_jl_project_root}/README.md" -F "g=@${larvatagger_project_root}/Artefacts/PlanarLarvae/trx_small.tgz" http://localhost:${lt_backend_port}/push-file/${tagging_backend}/${tagging_model}/${token} 1>/dev/null + +generated_files=(`ls "${larvatagger_project_root}/${tagging_backend}/data/raw/${token}"`) + +expected_files=("README.md" "trx_small.tgz") + +if [ ${#generated_files[@]} -eq ${#expected_files[@]} -a \ + "${generated_files[0]}" = "${expected_files[0]}" -a \ + "${generated_files[1]}" = "${expected_files[1]}" ]; then + echo "push-file: success" +else + echo "push-file: failure" +fi + +# discover the sent files +discovered_files=`curl -sS http://localhost:${lt_backend_port}/list-files/${tagging_backend}/${tagging_model}/${token}/raw` + +expected_files='["README.md", "trx_small.tgz"]' + +if [ "$discovered_files" = "$expected_files" ]; then + echo "list-files: success" +else + echo "list-files: failure" + echo "expected: $expected_files" + echo "got: $discovered_files" +fi + +# make file in processed data dir +test_filename=label.json + +mkdir -p "${larvatagger_project_root}/${tagging_backend}/data/processed/${token}" +cat <<EOF >"${larvatagger_project_root}/${tagging_backend}/data/processed/${token}/${test_filename}" +{ +} +EOF + +# retrieve file from processed data dir +retrieved_content=`curl -sS http://localhost:${lt_backend_port}/pull-file/${tagging_backend}/${tagging_model}/${token}/${test_filename}` + +expected_content="{ +}" + +if [ "$retrieved_content" = "$expected_content" ]; then + echo "pull-file: success" +else + echo "pull-file: failure" + echo "expected file content:" + echo "$expected_content" + echo "got:" + echo "$retrieved_content" +fi + +# request file deletion +curl -sS http://localhost:${lt_backend_port}/reset-data/${tagging_backend}/${tagging_model}/${token}/raw 1>/dev/null + +stored_files=`ls "${larvatagger_project_root}/${tagging_backend}/data/raw/${token}/"` +if [ -z "$stored_files" ]; then + echo "reset-data: success" +else + echo "reset-data: failure" + echo "expected empty list" + echo "got:" + echo "$stored_files" +fi + +# discover the backend(s) and their models +discovered_backends=`curl -sS http://localhost:${lt_backend_port}/list-backends` + +backend_metadata() { + local desc1= + local link1= + local desc2= + local link2= + local desc3= + local link3= + echo "{\"name\":\"$1\",\"description\":\"$desc1\",\"homepage\":\"$link1\",\"models\":[{\"name\":\"20230311\",\"description\":\"$desc2\",\"homepage\":\"$link2\"},{\"name\":\"20230311-0\",\"description\":\"$desc3\",\"homepage\":\"$link3\"}]}" +} +expected_backends="[`backend_metadata $tagging_backend`,`backend_metadata ${tagging_backend}-adapter`]" + +if [ "$discovered_backends" = "$expected_backends" ]; then + echo "list-backends: success" +else + echo "list-backends: failure" + echo "expected:" + echo "$expected_backends" + echo "got:" + echo "$discovered_backends" + # further diagnose + model_instances=(`ls "${larvatagger_project_root}/${tagging_backend}/models"`) + if ! [ ${#model_instances[@]} -eq 2 -a "${model_instances[0]}" = "20230311" -a "${model_instances[1]}" = "20230311-0" ]; then + echo "tagging backend is not adequately set up" + fi +fi + +cp "${larvatagger_jl_project_root}"/20140805_101522@FCF_attP2_1500062@UAS_TNT_2_0003@t5@p_8_45s1x30s0s#p_8_105s10x2s10s#n#n@100.* "${larvatagger_project_root}/${tagging_backend}/data/raw/${token}/" + +# if python does not find TaggingBackends, refresh LT install: +#(cd ../MaggotUBA-adapter && (cd ../TaggingBackends && PYTHON="`poetry env info`/bin/python" julia +1.10 --project=. -e 'using Pkg; Pkg.instantiate()')) +#(cd ../MaggotUBA-adapter && JULIA_PROJECT="`realpath ../TaggingBackends`" poetry install) + +# run the predict function of the tagging backend +curl -sS http://localhost:${lt_backend_port}/predict/${tagging_backend}/${tagging_model}/${token} 1>/dev/null + +generated_files=(`ls "${larvatagger_project_root}/${tagging_backend}/data/processed/${token}/"`) + +if [ ${#generated_files[@]} = 1 -a "${generated_files[0]}" = "predicted.label" ]; then + echo "predict: success" +else + echo "predict: failure" + echo "expected: predicted.label" + echo "got:" + echo "$generated_files" +fi + +# clear session data +curl -sS http://localhost:${lt_backend_port}/close/${tagging_backend}/${tagging_model}/${token} 1>/dev/null + +if ! [ -d "${larvatagger_project_root}/${tagging_backend}/data/raw/${token}" ] && \ + ! [ -d "${larvatagger_project_root}/${tagging_backend}/data/interim/${token}" ] && \ + ! [ -d "${larvatagger_project_root}/${tagging_backend}/data/processed/${token}" ]; then + echo "close: success" +else + echo "close: failure" +fi + +echo "tests complete; terminating the backend (a stack trace will follow)" +kill $lt_backend_pid +wait $lt_backend_pid