From 91c1b5e1f9c47c26b2934c27f7ce57d3103c81a7 Mon Sep 17 00:00:00 2001 From: Johnny Chen Date: Thu, 15 Apr 2021 17:30:29 +0800 Subject: [PATCH] add tests --- src/keyboard.jl | 61 +++++++++++++++++++++++ src/multipage.jl | 120 ++++++++++++++++------------------------------ test/keyboard.jl | 36 ++++++++++++++ test/multipage.jl | 61 +++++++++++++++++++++++ test/runtests.jl | 4 ++ 5 files changed, 204 insertions(+), 78 deletions(-) create mode 100644 src/keyboard.jl create mode 100644 test/keyboard.jl create mode 100644 test/multipage.jl diff --git a/src/keyboard.jl b/src/keyboard.jl new file mode 100644 index 0000000..edf20f2 --- /dev/null +++ b/src/keyboard.jl @@ -0,0 +1,61 @@ +# minimal keyboard event support +""" + read_key() -> control_value + +read control key from keyboard input. + +# Reference table + +| value | control_value | effect | +| ------------------- | ----------------- | ------------------- | +| UP, LEFT, b | :CONTROL_BACKWARD | show previous frame | +| DOWN, RIGHT, f | :CONTROL_FORWARD | show next frame | +| SPACE, p | :CONTROL_PAUSE | pause/resume play | +| CTRL-c, q | :CONTROL_EXIT | exit current play | +| others... | :CONTROL_VOID | no effect | +""" +function read_key(io=stdin) + control_value = :CONTROL_VOID + try + _setraw!(io, true) + keyin = read(io, Char) + if keyin == '\e' + # some special keys are more than one byte, e.g., left key is `\e[D` + # reference: https://en.wikipedia.org/wiki/ANSI_escape_code + keyin = read(io, Char) + if keyin == '[' + keyin = read(io, Char) + if keyin in ['A', 'D'] # up, left + control_value = :CONTROL_BACKWARD + elseif keyin in ['B', 'C'] # down, right + control_value = :CONTROL_FORWARD + end + end + elseif 'A' <= keyin <= 'Z' || 'a' <= keyin <= 'z' + keyin = lowercase(keyin) + if keyin == 'p' + control_value = :CONTROL_PAUSE + elseif keyin == 'q' + control_value = :CONTROL_EXIT + elseif keyin == 'f' + control_value = :CONTROL_FORWARD + elseif keyin == 'b' + control_value = :CONTROL_BACKWARD + end + elseif keyin == ' ' + control_value = :CONTROL_PAUSE + end + catch e + if e isa InterruptException # Ctrl-C + control_value = :CONTROL_EXIT + else + rethrow(e) + end + finally + _setraw!(io, false) + end + return control_value +end + +_setraw!(io::Base.TTY, raw) = ccall(:jl_tty_set_mode, Int32, (Ptr{Cvoid},Int32), io.handle, raw) +_setraw!(::IO, raw) = nothing diff --git a/src/multipage.jl b/src/multipage.jl index 64d51a8..78a78cb 100644 --- a/src/multipage.jl +++ b/src/multipage.jl @@ -1,37 +1,51 @@ +include("keyboard.jl") + ansi_moveup(n::Int) = string("\e[", n, "A") const ansi_movecol1 = "\e[1G" """ play(framestack::AbstractVector{T}; kwargs...) where {T<:AbstractArray} - play(arr::T, dim::Int; kwargs...) + play(arr::T, dim=3; kwargs...) Play a video of a framestack of image arrays, or 3D array along dimension `dim`. Control keys: - `p` or `space-bar`: pause/resume -- `f`, `←`(left arrow), or `↑`(up arrow): step backward -- `b`, `→`(right arrow), or `↓`(down arrow): step forward +- `b`, `←`(left arrow), or `↑`(up arrow): step backward +- `f`, `→`(right arrow), or `↓`(down arrow): step forward - `ctrl-c` or `q`: exit kwargs: -- `fps::Real=30` +- `fps`: frame per second. # Examples ```julia -julia> using TestImages, ImageShow - -julia> img3d = testimage("mri-stack") |> collect; - -julia> ImageShow.play(img3d, 3) +using TestImages, ImageShow -julia> framestack = [img3d[:, :, i] for i in axes(img3d, 3)]; +img3d = RGB.(testimage("mri-stack")) +ImageShow.play(img3d) -julia> ImageShow.play(framestack) +framestack = [img3d[:, :, i] for i in axes(img3d, 3)]; +ImageShow.play(framestack) ``` """ -function play(framestack::AbstractVector{<:AbstractArray}; fps::Real=30, paused=false) +function play(framestack::AbstractVector{<:AbstractMatrix}; fps::Real=min(10, length(framestack)÷2)) + # NOTE: the default fps is chosen purely by experience and may be changed in the future + _play(framestack; fps=fps, paused=false, quit_after_play=true) +end +play(img::AbstractArray{<:Colorant, 3}, dim=3; kwargs...) = play(map(i->selectdim(img, dim, i), axes(img, dim)); kwargs...) + +function _play( + framestack::AbstractVector{<:AbstractMatrix}; + fps, paused, quit_after_play, + # The following keywords are for advanced usages(e.g., test), common users are not expected + # to use them directly. + display_io::Union{Nothing, IO}=nothing, + summary_io::IO=stdout, + keyboard_io::IO=stdin +) nframes = length(framestack) # vars @@ -44,18 +58,24 @@ function play(framestack::AbstractVector{<:AbstractArray}; fps::Real=30, paused= cols, rows = size(frame) if !first_frame - print(ansi_moveup(2), ansi_movecol1) + print(summary_io, ansi_moveup(2), ansi_movecol1) end - println("Frame: $frame_idx/$nframes FPS: $(round(actual_fps, digits=1))", " "^5) - println("exit: ctrl-c. play/pause: space-bar. seek: arrow keys") - - display(frame) + println(summary_io, "Frame: $frame_idx/$nframes FPS: $(round(actual_fps, digits=1))", " "^5) + println(summary_io, "exit: ctrl-c. play/pause: space-bar. seek: arrow keys") + + # When calling `display(MIME"image/png"(), img)`, VSCode/IJulia/Atom will eventually + # create an `IOBuffer` to get the Base64+PNG encoded data, and send the encoded data to + # the outside display pipeline, e.g., as JSON message. + # For test purpose, we could directly show it to our manually created IO. + isnothing(display_io) ? display(frame) : show(display_io, MIME"image/png"(), frame) end + # These codes live in ImageShow and thus MIME"image/png" is always showable + @assert showable(MIME"image/png"(), framestack[frame_idx]) render_frame(frame_idx, actual_fps; first_frame=true) keytask = @async begin while !should_exit - control_value = read_key() + control_value = read_key(keyboard_io) if control_value == :CONTROL_BACKWARD frame_idx = max(frame_idx-1, 1) @@ -86,6 +106,10 @@ function play(framestack::AbstractVector{<:AbstractArray}; fps::Real=30, paused= end last_frame_idx = frame_idx end + if !quit_after_play && frame_idx == nframes + # don't immediately quit the play + paused = true + end paused || (frame_idx += 1) paused && sleep(0.001) end @@ -98,67 +122,7 @@ function play(framestack::AbstractVector{<:AbstractArray}; fps::Real=30, paused= end return nothing end -play(img::AbstractArray{<:Colorant}, dim; kwargs...) = play(map(i->selectdim(img, dim, i), axes(img, dim)); kwargs...) -# minimal keyboard event support -""" - read_key() -> control_value - -read control key from keyboard input. - -# Reference table - -| value | control_value | effect | -| ------------------- | ----------------- | ------------------- | -| UP, LEFT, f, F | :CONTROL_BACKWARD | show previous frame | -| DOWN, RIGHT, b, B | :CONTROL_FORWARD | show next frame | -| SPACE, p, P | :CONTROL_PAUSE | pause/resume play | -| CTRL-c, q, Q | :CONTROL_EXIT | exit current play | -| others... | :CONTROL_VOID | no effect | -""" -function read_key() - setraw!(io, raw) = ccall(:jl_tty_set_mode, Int32, (Ptr{Cvoid},Int32), io.handle, raw) - control_value = :CONTROL_VOID - try - setraw!(stdin, true) - keyin = read(stdin, Char) - if keyin == '\e' - # some special keys are more than one byte, e.g., left key is `\e[D` - # reference: https://en.wikipedia.org/wiki/ANSI_escape_code - keyin = read(stdin, Char) - if keyin == '[' - keyin = read(stdin, Char) - if keyin in ['A', 'D'] # up, left - control_value = :CONTROL_BACKWARD - elseif keyin in ['B', 'C'] # down, right - control_value = :CONTROL_FORWARD - end - end - elseif 'A' <= keyin <= 'Z' || 'a' <= keyin <= 'z' - keyin = lowercase(keyin) - if keyin == 'p' - control_value = :CONTROL_PAUSE - elseif keyin == 'q' - control_value = :CONTROL_EXIT - elseif keyin == 'f' - control_value = :CONTROL_FORWARD - elseif keyin == 'b' - control_value = :CONTROL_BACKWARD - end - elseif keyin == ' ' - control_value = :CONTROL_PAUSE - end - catch e - if e isa InterruptException # Ctrl-C - control_value = :CONTROL_EXIT - else - rethrow(e) - end - finally - setraw!(stdin, false) - end - return control_value -end """ fixed_fps(f::Function, fps::Real) diff --git a/test/keyboard.jl b/test/keyboard.jl new file mode 100644 index 0000000..5de1862 --- /dev/null +++ b/test/keyboard.jl @@ -0,0 +1,36 @@ +using ImageShow +using ImageShow: read_key + +@testset "keyboard" begin + # TODO: Ctrl-C (InterruptException) is not tested + @testset "read_key" begin + inputs = [ + (UInt8['\e', '[', 'A'], :CONTROL_BACKWARD), # UP + (UInt8['\e', '[', 'D'], :CONTROL_BACKWARD), # LEFT + (UInt8['\e', '[', 'B'], :CONTROL_FORWARD), # DOWN + (UInt8['\e', '[', 'C'], :CONTROL_FORWARD), # RIGHT + + (UInt8[' '], :CONTROL_PAUSE), # SPACE + (UInt8['p'], :CONTROL_PAUSE), + (UInt8['P'], :CONTROL_PAUSE), + (UInt8['q'], :CONTROL_EXIT), + (UInt8['Q'], :CONTROL_EXIT), + (UInt8['f'], :CONTROL_FORWARD), + (UInt8['F'], :CONTROL_FORWARD), + (UInt8['b'], :CONTROL_BACKWARD), + (UInt8['B'], :CONTROL_BACKWARD), + + # key events that currenctly has no effect + + # although VIM users might want this :) + (UInt8['j'], :CONTROL_VOID), + (UInt8['k'], :CONTROL_VOID), + (UInt8['h'], :CONTROL_VOID), + (UInt8['l'], :CONTROL_VOID), + ] + for (chs, ref) in inputs + io = IOBuffer(chs) + @test ref == read_key(io) + end + end +end diff --git a/test/multipage.jl b/test/multipage.jl new file mode 100644 index 0000000..1b54471 --- /dev/null +++ b/test/multipage.jl @@ -0,0 +1,61 @@ +using ImageShow, ImageCore +using TestImages, FileIO +using Test + +function check_summary(n, msg) + function generate_summary_regex(n) + summary_regex = raw"Frame: \d+/\d+ FPS: \d+\.\d+\s+\nexit: ctrl-c\. play\/pause: space-bar\. seek: arrow keys\n" + Regex(mapreduce(i->summary_regex, (x,y)->x*raw".*"*y, 1:n)) + end + function _check_summary(n, msg) + return !isnothing(match(generate_summary_regex(n), msg)) + end + + return _check_summary(n, msg) && !_check_summary(n+1, msg) +end + +@testset "multipage" begin + @testset "play" begin + img = RGB.(testimage("mri-stack")) + framestack = [img[:, :, i] for i in 1:size(img, 3)] + + workdir = "tmp" + mktempdir() do workdir + filename = joinpath(workdir, "multipage.png") + fn = open(filename, "w") + summary_output_io = IOBuffer() + + save(filename, framestack[1]) + frame_size = stat(filename).size + + # Case: quit immediately + key_input_io = IOBuffer(UInt8['q']) + ImageShow._play(framestack; fps=1, paused=false, quit_after_play=true, display_io=fn, summary_io=summary_output_io, keyboard_io=key_input_io) + summary_msg = String(take!(summary_output_io)) + @test check_summary(2, summary_msg) # If paused=false, it will print summary twice + @test RGB.(load(filename)) == framestack[1] + @test 1 == stat(filename).size/frame_size + + # Case: quit after all play + fn = open(filename, "w") + summary_output_io = IOBuffer() + # use a small fps to make sure each frame are actually written + ImageShow._play(framestack; fps=15, paused=false, quit_after_play=true, display_io=fn, summary_io=summary_output_io, keyboard_io=stdin) + summary_msg = String(take!(summary_output_io)) + @test check_summary(length(framestack), summary_msg) + # FIXME: show method will append to the given file handler, however, PNG reader will only + # read the first valid png data; all extra data block are discarded. + # This somehow still proves that we're writing more than one image to the display_io + @test RGB.(load(filename)) == framestack[1] + @test 20 < stat(filename).size/frame_size < length(framestack) + end + end + + @testset "utils" begin + # Although it's a no-op, it gets blocked at fps=2, which is about 0.5 second + t = @elapsed ImageShow.fixed_fps(2) do + nothing + end + @test 0.4 < t < 0.6 + end +end diff --git a/test/runtests.jl b/test/runtests.jl index 0d31947..52f608e 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -7,4 +7,8 @@ using Test if VERSION >= v"1.3.0" include("gif.jl") end + + @info "There are some keyboard IO tests. To make sure test passes as expected, please don't press any key until test finishes." + include("keyboard.jl") + include("multipage.jl") end