From 5f3f67e97c3c038e41b95c3d11c38697cbe3600d Mon Sep 17 00:00:00 2001 From: Tim Holy Date: Tue, 26 May 2026 07:26:36 -0500 Subject: [PATCH 1/2] Replace `imshow`'s nested `Dict` return with struct types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce `ImageDisplay`, `ImageViewGUI`, and `ImageROI` as the return types of `imshow`, `imshow_gui`, and the low-level `imshow(canvas, ...)` respectively. `ImageDisplay` forwards field access to its nested `gui` and `roi` so users can write `disp.window`, `disp.zoomregion`, `disp.image_roi`, etc. The custom `show` methods print only a compact summary — never the underlying image array — which fixes the long-standing usability problem where forgetting a trailing semicolon at the REPL caused multi-second hangs while Julia's default dict `show` recursively printed the entire image. The concrete types also give downstream packages a dispatch target; extensions that need to support multiple plotting backends can now write methods like `f(::ImageView.ImageDisplay, ...)`. The pre-existing string-key API (`guidict["gui"]["window"]`, `guidict["roi"]["zoomregion"]`, etc.) is preserved through a deprecation shim implemented via `Base.getindex` on each new struct; each call emits a `Base.depwarn`. This commit deliberately leaves `test/` and `README.md` untouched — the existing test suite is the correctness check that the shim faithfully reproduces the old API. Isolates the legacy string-key indexing methods so the next breaking release can remove the shim by deleting one file and the corresponding `include`. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/ImageView.jl | 214 +++++++++++++++++++++++++++++++++++---------- src/annotations.jl | 17 ++-- src/deprecated.jl | 93 ++++++++++++++++++++ src/link.jl | 10 +-- 4 files changed, 273 insertions(+), 61 deletions(-) create mode 100644 src/deprecated.jl diff --git a/src/ImageView.jl b/src/ImageView.jl index 4c721de..f7d9c40 100644 --- a/src/ImageView.jl +++ b/src/ImageView.jl @@ -42,6 +42,133 @@ Base.convert(::Type{CLim{T}}, clim::CLim) where {T} = CLim(convert(T, clim.min), convert(T, clim.max)) Base.eltype(::CLim{T}) where {T} = T +""" + ImageViewGUI + +Holds the GTK widgets that make up an `ImageView` window: the window itself, +its layout containers, frame(s), canvas(es), the status and view labels, and +(for multidimensional images) the player widgets used to scrub through slices. + +In a grid layout, `frame` and `canvas` are `Matrix`es of widgets; for a +single-image window they are individual widgets. + +Returned by [`imshow_gui`](@ref) and accessible as `disp.gui` on an +[`ImageDisplay`](@ref). +""" +mutable struct ImageViewGUI + window + vbox + frame + canvas + status + viewlabel + players # Vector or `nothing` + hoverinfo # Observer handle or `nothing`; set after construction + zoomregion_info # Observer handle or `nothing`; set after construction + extras::Dict{Symbol,Any} +end +ImageViewGUI(window, vbox, frame, canvas, status, viewlabel, players=nothing) = + ImageViewGUI(window, vbox, frame, canvas, status, viewlabel, players, + nothing, nothing, Dict{Symbol,Any}()) + +""" + ImageROI + +Holds the region-of-interest state and pan/zoom interaction signals for a +displayed image: the currently-shown image slice (`image_roi`), the +`zoomregion` observable, signals for rubberband/scroll zooming and panning, +the redraw signal, and the `slicedata` (when the source image has more than +two dimensions). + +Returned by `imshow(::Canvas, ...)` and accessible as `disp.roi` on an +[`ImageDisplay`](@ref). +""" +mutable struct ImageROI + image_roi + zoomregion + zoom_rubberband + zoom_scroll + pan_scroll + pan_drag + redraw + slicedata # `SliceData` or `nothing` +end +ImageROI(image_roi, zoomregion, zoom_rubberband, zoom_scroll, pan_scroll, pan_drag, redraw) = + ImageROI(image_roi, zoomregion, zoom_rubberband, zoom_scroll, pan_scroll, pan_drag, redraw, nothing) + +""" + ImageDisplay + +The handle returned by [`imshow`](@ref). Composes an [`ImageViewGUI`](@ref) +(accessible via `disp.gui` or by hoisted fields like `disp.window`, +`disp.canvas`), an [`ImageROI`](@ref) (via `disp.roi` or hoisted fields like +`disp.zoomregion`, `disp.image_roi`), an optional `clim`, and the +`annotations` observable. + +`ImageDisplay` overloads `Base.getproperty` so any field of the nested `gui` +or `roi` can be reached directly on the top-level handle. Downstream packages +that need to dispatch on "an ImageView display" should dispatch on this type. + +`Base.show` deliberately prints only a compact summary; it never prints the +underlying image array. +""" +struct ImageDisplay + gui::ImageViewGUI + roi::ImageROI + clim # `Observable{<:CLim}` or `nothing` + annotations # `Annotations` (= `Observable{Dict{UInt,Any}}`) +end + +function Base.getproperty(d::ImageDisplay, name::Symbol) + name === :gui && return getfield(d, :gui) + name === :roi && return getfield(d, :roi) + name === :clim && return getfield(d, :clim) + name === :annotations && return getfield(d, :annotations) + g = getfield(d, :gui) + hasfield(ImageViewGUI, name) && return getfield(g, name) + r = getfield(d, :roi) + hasfield(ImageROI, name) && return getfield(r, name) + throw(ArgumentError("ImageDisplay has no property `$name`. Available: $(propertynames(d))")) +end + +Base.propertynames(::ImageDisplay) = + (:gui, :roi, :clim, :annotations, + fieldnames(ImageViewGUI)..., fieldnames(ImageROI)...) + +function Base.show(io::IO, d::ImageDisplay) + img = getfield(d, :roi).image_roi + print(io, "ImageDisplay(", eltype(img[]), ", ", size(img[]), ")") +end +function Base.show(io::IO, ::MIME"text/plain", d::ImageDisplay) + img = getfield(d, :roi).image_roi[] + println(io, "ImageDisplay: ", size(img), " ", eltype(img)) + sd = getfield(d, :roi).slicedata + sd === nothing || isempty(sd) || println(io, " slicedata: ", sd) + cl = getfield(d, :clim) + cl === nothing || println(io, " clim: ", cl[]) + print(io, " fields: ", join(propertynames(d), ", ")) +end + +function Base.show(io::IO, gui::ImageViewGUI) + print(io, "ImageViewGUI(") + f = getfield(gui, :frame) + if f isa AbstractMatrix + print(io, size(f), " grid") + else + print(io, "single canvas") + end + print(io, ")") +end + +function Base.show(io::IO, r::ImageROI) + print(io, "ImageROI(") + print(io, eltype(r.image_roi[]), ", ", size(r.image_roi[])) + r.slicedata === nothing || isempty(r.slicedata) || print(io, ", with slicedata") + print(io, ")") +end + +include("deprecated.jl") + """ closeall() @@ -215,21 +342,20 @@ Compat.@constprop :none function imshow(@nospecialize(img::AbstractArray), clim, if canvassize === nothing canvassize = default_canvas_size(fullsize(zr[]), ps[2]/ps[1]) end - guidict = imshow_gui(canvassize, sd; name=name, aspect=aspect) - guidict["hoverinfo"] = on(guidict["canvas"].mouse.motion) do btn - hoverinfo(guidict["status"], btn, img, sd) + gui = imshow_gui(canvassize, sd; name=name, aspect=aspect) + gui.hoverinfo = on(gui.canvas.mouse.motion) do btn + hoverinfo(gui.status, btn, img, sd) end - guidict["zoomregion_info"]=on(zr; update=true) do z - viewinfo(guidict["viewlabel"], z, sd) + gui.zoomregion_info = on(zr; update=true) do z + viewinfo(gui.viewlabel, z, sd) end - roidict = imshow(guidict["frame"], guidict["canvas"], img, - wrap_signal(clim), zr, sd, anns) + roi_ = imshow(gui.frame, gui.canvas, img, + wrap_signal(clim), zr, sd, anns) - win = guidict["window"] - dct = Dict("gui"=>guidict, "clim"=>clim, "roi"=>roidict, "annotations"=>anns) - GtkObservables.gc_preserve(win, dct) - return dct + disp = ImageDisplay(gui, roi_, clim, anns) + GtkObservables.gc_preserve(gui.window, disp) + return disp end function imshow(frame::Union{Gtk4.GtkFrame,Gtk4.GtkAspectFrame}, canvas::GtkObservables.Canvas, @@ -257,9 +383,9 @@ function imshow(frame::Union{Gtk4.GtkFrame,Gtk4.GtkAspectFrame}, canvas::GtkObse error("got unsupported eltype $(eltype(imgc[])) in preparing the contrast") end - roidict = imshow(frame, canvas, imgc, zr, anns) - roidict["slicedata"] = sd - roidict + roi_ = imshow(frame, canvas, imgc, zr, anns) + roi_.slicedata = sd + roi_ end # For things that are not AbstractArrays, we don't offer the clim @@ -280,14 +406,13 @@ Compat.@constprop :none function imshow(img, v = slice2d(img, zr[], sd) ps = map(abs, pixelspacing(v)) csz = default_canvas_size(fullsize(zr[]), ps[2]/ps[1]) - guidict = imshow_gui(csz, sd; name=name, aspect=aspect) + gui = imshow_gui(csz, sd; name=name, aspect=aspect) - roidict = imshow(guidict["frame"], guidict["canvas"], img, zr, sd, anns) + roi_ = imshow(gui.frame, gui.canvas, img, zr, sd, anns) - win = guidict["window"] - dct = Dict("gui"=>guidict, "roi"=>roidict) - GtkObservables.gc_preserve(win, dct) - return dct + disp = ImageDisplay(gui, roi_, nothing, anns) + GtkObservables.gc_preserve(gui.window, disp) + return disp end function imshow(frame::Union{GtkFrame,GtkAspectFrame}, canvas::GtkObservables.Canvas, @@ -301,10 +426,10 @@ function imshow(frame::Union{GtkFrame,GtkAspectFrame}, canvas::GtkObservables.Ca set_aspect!(frame, imgsig[]) GtkObservables.gc_preserve(frame, imgsig) - roidict = imshow(frame, canvas, imgsig, zr, anns) - roidict["slicedata"] = sd - GtkObservables.gc_preserve(frame, roidict) - roidict + roi_ = imshow(frame, canvas, imgsig, zr, anns) + roi_.slicedata = sd + GtkObservables.gc_preserve(frame, roi_) + roi_ end function close_cb(::Ptr, par, win) @@ -381,21 +506,20 @@ Compat.@constprop :none function imshow_gui(canvassize::Tuple{Int,Int}, Gtk4.ellipsize(viewlabel, Gtk4.Pango.EllipsizeMode_MIDDLE) cbox[:end] = viewlabel - guidict = Dict("window"=>win, "vbox"=>vbox, "frame"=>frames, "status"=>status, - "canvas"=>canvases, "viewlabel"=>viewlabel) + gui = ImageViewGUI(win, vbox, frames, canvases, status, viewlabel) # Add the player controls if !isempty(slicedata) players = [player(slicedata.signals[i], axisvalues(slicedata.axs[i])[1]; id=i) for i = 1:length(slicedata)] - guidict["players"] = players + gui.players = players hbox = GtkBox(:h) for p in players push!(hbox, frame(p)) end - push!(guidict["vbox"], hbox) + push!(gui.vbox, hbox) end - guidict + gui end imshow_gui(canvassize::Tuple{Int,Int}, slicedata::SliceData, args...; kwargs...) = @@ -473,11 +597,9 @@ function imshow(canvas::GtkObservables.Canvas{UserUnit}, pans = init_pan_scroll(canvas, zr) pand = init_pan_drag(canvas, zr) redraw = imshow!(canvas, imgsig, zr, anns) - dct = Dict("image roi"=>imgsig, "zoomregion"=>zr, "zoom_rubberband"=>zoomrb, - "zoom_scroll"=>zooms, "pan_scroll"=>pans, "pan_drag"=>pand, - "redraw"=>redraw) - GtkObservables.gc_preserve(widget(canvas), dct) - dct + roi_ = ImageROI(imgsig, zr, zoomrb, zooms, pans, pand, redraw) + GtkObservables.gc_preserve(widget(canvas), roi_) + roi_ end function imshow(frame::Union{GtkFrame,GtkAspectFrame}, @@ -491,11 +613,9 @@ function imshow(frame::Union{GtkFrame,GtkAspectFrame}, pans = init_pan_scroll(canvas, zr) pand = init_pan_drag(canvas, zr) redraw = imshow!(frame, canvas, imgsig, zr, anns) - dct = Dict("image roi"=>imgsig, "zoomregion"=>zr, "zoom_rubberband"=>zoomrb, - "zoom_scroll"=>zooms, "pan_scroll"=>pans, "pan_drag"=>pand, - "redraw"=>redraw) - GtkObservables.gc_preserve(widget(canvas), dct) - dct + roi_ = ImageROI(imgsig, zr, zoomrb, zooms, pans, pand, redraw) + GtkObservables.gc_preserve(widget(canvas), roi_) + roi_ end """ @@ -507,14 +627,14 @@ value in the status bar. function imshowlabeled(img::AbstractArray, label::AbstractArray; proplist...) @nospecialize axes(img) == axes(label) || throw(DimensionMismatch("axes $(axes(label)) of label array disagree with axes $(axes(img)) of the image")) - guidict = imshow(img; proplist...) - gui = guidict["gui"] - sd = guidict["roi"]["slicedata"] - off(gui["hoverinfo"]) - gui["hoverinfo"] = on(gui["canvas"].mouse.motion) do btn - hoverinfo(gui["status"], btn, label, sd) - end - guidict + disp = imshow(img; proplist...) + gui = disp.gui + sd = disp.roi.slicedata + off(gui.hoverinfo) + gui.hoverinfo = on(gui.canvas.mouse.motion) do btn + hoverinfo(gui.status, btn, label, sd) + end + disp end function hoverinfo(lbl, btn, img, sd::SliceData{transpose}) where transpose diff --git a/src/annotations.jl b/src/annotations.jl index 13c0615..2b5a14d 100644 --- a/src/annotations.jl +++ b/src/annotations.jl @@ -2,14 +2,13 @@ annotations() = Observable(Dict{UInt,Any}()) -function annotate!(guidict::Dict, ann; anchored::Bool=true) - c, roi, anns = guidict["gui"]["canvas"], guidict["roi"], guidict["annotations"] - annotate!(anns, c, roi, ann; anchored=anchored) +function annotate!(disp::ImageDisplay, ann; anchored::Bool=true) + annotate!(disp.annotations, disp.gui.canvas, disp.roi, ann; anchored=anchored) end -function annotate!(anns::Annotations, c::GtkObservables.Canvas, roi::Dict{String}, ann; anchored::Bool=true) +function annotate!(anns::Annotations, c::GtkObservables.Canvas, roi::ImageROI, ann; anchored::Bool=true) if anchored - zr = roi["zoomregion"] + zr = roi.zoomregion annf = AnchoredAnnotation((_)->canvasbb(c), (_)->zoombb(zr), ann) else annf = FloatingAnnotation((_)->canvasbb(c), ann) @@ -34,7 +33,7 @@ function Base.delete!(anns::Annotations, anh::AnnotationHandle) delete!(anns[], anh.hash) notify(anns) end -Base.delete!(guidict::Dict, anh::AnnotationHandle) = delete!(guidict["annotations"], anh) +Base.delete!(disp::ImageDisplay, anh::AnnotationHandle) = delete!(disp.annotations, anh) function draw_annotations(canvas, anns) for (h, ann) in anns @@ -269,10 +268,10 @@ Add a scale bar annotation to the image display controlled by `x` and `y` control the placement of the scalebar, and `color` its rendered color. """ -function scalebar(guidict::Dict, length; x = 0.8, y = 0.1, color = RGB(1,1,1)) - imsl = guidict["roi"]["image roi"] +function scalebar(disp::ImageDisplay, length; x = 0.8, y = 0.1, color = RGB(1,1,1)) + imsl = disp.roi.image_roi ann = AnnotationScalebarFixed(length/1, length/10, imsl, x, y, color) - annotate!(guidict, ann, anchored=false) + annotate!(disp, ann, anchored=false) end ############## diff --git a/src/deprecated.jl b/src/deprecated.jl new file mode 100644 index 0000000..54ba23f --- /dev/null +++ b/src/deprecated.jl @@ -0,0 +1,93 @@ +# Legacy string-key access (deprecation shim). +# Translates the pre-0.14 nested-`Dict` API to the new `ImageDisplay` / +# `ImageViewGUI` / `ImageROI` structs. Slated for removal in the next +# breaking release. Each call emits a `Base.depwarn`. + +function Base.getindex(d::ImageDisplay, key::AbstractString) + Base.depwarn( + "indexing `imshow`'s return value by string is deprecated; use field access. " * + "Replace `result[\"$key\"]` with `result.$(_legacy_to_field(key))`.", + :getindex) + return _legacy_get(d, key) +end +function Base.setindex!(d::ImageDisplay, value, key::AbstractString) + Base.depwarn( + "string-key assignment on `imshow`'s return value is deprecated.", + :setindex!) + _legacy_set!(d, key, value) +end + +function Base.getindex(g::ImageViewGUI, key::AbstractString) + Base.depwarn( + "indexing the `ImageViewGUI` by string is deprecated; use field access. " * + "Replace `gui[\"$key\"]` with `gui.$(_legacy_to_field(key))`.", + :getindex) + return _legacy_get(g, key) +end +function Base.setindex!(g::ImageViewGUI, value, key::AbstractString) + Base.depwarn("string-key assignment on `ImageViewGUI` is deprecated.", :setindex!) + _legacy_set!(g, key, value) +end + +function Base.getindex(r::ImageROI, key::AbstractString) + Base.depwarn( + "indexing the `ImageROI` by string is deprecated; use field access. " * + "Replace `roi[\"$key\"]` with `roi.$(_legacy_to_field(key))`.", + :getindex) + return _legacy_get(r, key) +end +function Base.setindex!(r::ImageROI, value, key::AbstractString) + Base.depwarn("string-key assignment on `ImageROI` is deprecated.", :setindex!) + _legacy_set!(r, key, value) +end + +# Legacy strings → modern field names +_legacy_to_field(key::AbstractString) = replace(key, ' ' => '_') + +function _legacy_get(d::ImageDisplay, key::AbstractString) + key == "gui" && return getfield(d, :gui) + key == "roi" && return getfield(d, :roi) + key == "clim" && return getfield(d, :clim) + key == "annotations" && return getfield(d, :annotations) + throw(KeyError(key)) +end +function _legacy_set!(d::ImageDisplay, key::AbstractString, _) + throw(ArgumentError("ImageDisplay top-level keys are not settable (got \"$key\")")) +end + +function _legacy_get(g::ImageViewGUI, key::AbstractString) + key == "window" && return getfield(g, :window) + key == "vbox" && return getfield(g, :vbox) + key == "frame" && return getfield(g, :frame) + key == "canvas" && return getfield(g, :canvas) + key == "status" && return getfield(g, :status) + key == "viewlabel" && return getfield(g, :viewlabel) + key == "players" && return getfield(g, :players) + key == "hoverinfo" && return getfield(g, :hoverinfo) + key == "zoomregion_info" && return getfield(g, :zoomregion_info) + key == "guidata" && return getfield(g, :extras)[:guidata] + throw(KeyError(key)) +end +function _legacy_set!(g::ImageViewGUI, key::AbstractString, value) + key == "hoverinfo" && (g.hoverinfo = value; return value) + key == "zoomregion_info" && (g.zoomregion_info = value; return value) + key == "players" && (g.players = value; return value) + key == "guidata" && (g.extras[:guidata] = value; return value) + throw(ArgumentError("cannot assign legacy key \"$key\" on ImageViewGUI")) +end + +function _legacy_get(r::ImageROI, key::AbstractString) + key == "image roi" && return getfield(r, :image_roi) + key == "zoomregion" && return getfield(r, :zoomregion) + key == "zoom_rubberband" && return getfield(r, :zoom_rubberband) + key == "zoom_scroll" && return getfield(r, :zoom_scroll) + key == "pan_scroll" && return getfield(r, :pan_scroll) + key == "pan_drag" && return getfield(r, :pan_drag) + key == "redraw" && return getfield(r, :redraw) + key == "slicedata" && return getfield(r, :slicedata) + throw(KeyError(key)) +end +function _legacy_set!(r::ImageROI, key::AbstractString, value) + key == "slicedata" && (r.slicedata = value; return value) + throw(ArgumentError("cannot assign legacy key \"$key\" on ImageROI")) +end diff --git a/src/link.jl b/src/link.jl index efcdc76..f6ed7f7 100644 --- a/src/link.jl +++ b/src/link.jl @@ -6,17 +6,17 @@ axes to shared GUI control(s). """ function imlink(imgs...; gridsize=imlink_grid(imgs), dims=(1,2)) zr, slicedata = roi(first(imgs), dims) - gd = imshow_gui((200, 200), slicedata, gridsize) + gui = imshow_gui((200, 200), slicedata, gridsize) guidata = Vector{Any}(undef, length(imgs)) for (img, g, i) in zip(imgs, CartesianIndices(gridsize), 1:length(imgs)) if isa(img, AbstractArray) - guidata[i] = imshow(gd["frame"][g], gd["canvas"][g], img, nothing, zr, slicedata) + guidata[i] = imshow(gui.frame[g], gui.canvas[g], img, nothing, zr, slicedata) else - guidata[i] = imshow(gd["frame"][g], gd["canvas"][g], img, zr, slicedata) + guidata[i] = imshow(gui.frame[g], gui.canvas[g], img, zr, slicedata) end end - gd["guidata"] = guidata - gd + gui.extras[:guidata] = guidata + gui end imlink_grid(imgs) = (1,length(imgs)) From 27671b5dd8b2a4c2fb4688588b3e6c091be05a51 Mon Sep 17 00:00:00 2001 From: Tim Holy Date: Tue, 26 May 2026 07:43:44 -0500 Subject: [PATCH 2/2] Migrate tests, README, and docstrings to the new field-access API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts every call site that indexes the `imshow` return value by string to the new struct field syntax (e.g. `result["gui"]["window"]` → `result.window`, `result["roi"]["zoomregion"]` → `result.zoomregion`, `result["roi"]["image roi"]` → `result.image_roi`). Updates the README's tutorial and script-usage examples, the `imshow`/`imshow_gui`/ `scalebar` docstrings, and adds a NEWS.md entry describing the change and the deprecation shim. The shim itself is untouched — the legacy string-key API continues to work with a `Base.depwarn`. Co-Authored-By: Claude Opus 4.7 (1M context) --- NEWS.md | 21 +++++++++++++ Project.toml | 2 +- README.md | 74 ++++++++++++++++++++++++--------------------- src/ImageView.jl | 27 +++++++++-------- src/annotations.jl | 4 +-- test/annotations.jl | 2 +- test/contrast.jl | 10 +++--- test/kwargs.jl | 2 +- test/newtests.jl | 50 +++++++++++++++--------------- test/simple.jl | 4 +-- test/tile.jl | 2 +- 11 files changed, 113 insertions(+), 85 deletions(-) diff --git a/NEWS.md b/NEWS.md index 6f3f35a..a76900d 100644 --- a/NEWS.md +++ b/NEWS.md @@ -1,5 +1,26 @@ # New in 0.13 +- `imshow` now returns an `ImageDisplay` struct rather than a nested + `Dict{String,Any}`. The nested GUI and ROI groupings are exposed as + the structs `ImageViewGUI` (returned by `imshow_gui`) and `ImageROI` + (returned by the low-level `imshow(::Canvas, ...)`). Field access + is flattened on `ImageDisplay`, so where you previously wrote + `result["gui"]["window"]`, `result["roi"]["zoomregion"]`, or + `result["roi"]["image roi"]`, you can now write `result.window`, + `result.zoomregion`, `result.image_roi`. Call `propertynames(result)` + to see every accessible field. + + This change fixes a long-standing usability problem: the default + `show` for the old `Dict` recursively printed the underlying image + array, which could hang the REPL for several seconds on a large + image when the trailing semicolon was forgotten. The new types have + compact `show` methods that print only summary information. + + The string-key API (`result["gui"]["window"]` etc.) still works and + routes through a deprecation shim; each call emits a + `Base.depwarn`. The shim will be removed in the next breaking + release. Downstream packages can dispatch on `ImageView.ImageDisplay`, + `ImageView.ImageViewGUI`, or `ImageView.ImageROI` directly. - Julia 1.10 is now required - MultiChannelColors, FileIO support now in extensions diff --git a/Project.toml b/Project.toml index a1cf4ba..67f5dcc 100644 --- a/Project.toml +++ b/Project.toml @@ -1,7 +1,7 @@ name = "ImageView" uuid = "86fae568-95e7-573e-a6b2-d8a6b900c9ef" author = ["Tim Holy "] -version = "0.13.1" +version = "0.13.2" [deps] AxisArrays = "39de3d68-74b9-583c-8d2d-e117c070f3a9" diff --git a/README.md b/README.md index 7236b9c..7095f8b 100644 --- a/README.md +++ b/README.md @@ -112,47 +112,53 @@ You can place multiple images in the same window using `imshow_gui`: ``` using ImageView, TestImages, Gtk4 gui = imshow_gui((300, 300), (2, 1)) # 2 columns, 1 row of images (each initially 300×300) -canvases = gui["canvas"] +canvases = gui.canvas imshow(canvases[1,1], testimage("lighthouse")) imshow(canvases[1,2], testimage("mandrill")) -show(gui["window"]) +show(gui.window) ``` ![canvasgrid snapshot](readme_images/canvasgrid.jpg) -`gui["window"]` returns the window; `gui["canvas"]` either returns a single canvas +`gui.window` returns the window; `gui.canvas` either returns a single canvas (if there is just one), or an array if you've specified a grid of canvases. `Gtk4.show(win)` is sometimes needed when using the lower-level utilities of this package. Generally you should call it after you've finished assembling the entire window, so as to avoid redraws with each subsequent change. -### The dictionary and region-of-interest manipulations +### The display handle and region-of-interest manipulations -`imshow` returns a dictionary containing a wealth of information about -the state of the viewer. Perhaps most interesting is the `"roi"` -entry, which itself is another dictionary containing information about -the current selected region or interest. These are -[Observables signals](https://juliagizmos.github.io/Observables.jl/), and consequently you can even manipulate the -state of the GUI by `push!`ing new values to these signals. +`imshow` returns an `ImageDisplay`, a struct holding the GTK widgets and +Observables that drive the viewer. The most interesting subgroup is the +region-of-interest (accessible as `disp.roi` or directly via hoisted fields +like `disp.zoomregion`, `disp.image_roi`, `disp.slicedata`). The signals are +[Observables](https://juliagizmos.github.io/Observables.jl/), and you can +manipulate the state of the GUI by `push!`ing (or assigning) new values to +them. For example, using the `"mri"` image above, you can select the 5th slice with ```julia -guidict = imshow(img) -guidict["roi"]["slicedata"].signals[1][] = 5 +disp = imshow(img) +disp.slicedata.signals[1][] = 5 ``` `SliceData` objects contain information about which axes are displayed and the current slice indices of those axes perpendicular to the view -plane. Likewise, `"image roi"` contains the actual image data +plane. Likewise, `disp.image_roi` contains the actual image data currently being shown in the window (including all zoom/slice settings). -The `"zoom"`- and `"pan"`-related signals originate from +The `zoom_*` and `pan_*` signals originate from [GtkObservables](https://juliagizmos.github.io/GtkObservables.jl/stable/), and users should see the documentation for that package for more information. +`propertynames(disp)` lists every accessible field. For backwards +compatibility, the older string-key syntax (`disp["gui"]["window"]`, +`disp["roi"]["zoomregion"]`, etc.) still works but emits a deprecation +warning; it will be removed in the next breaking release. + ### Coupling two or more images together `imshow` allows you to pass many more arguments; please use `?imshow` @@ -171,9 +177,9 @@ mriseg[mri .> 0.5] .= colorant"red" Now we display the images: ```julia -guidata = imshow(mri, axes=(1,2)) -zr = guidata["roi"]["zoomregion"] -slicedata = guidata["roi"]["slicedata"] +disp = imshow(mri, axes=(1,2)) +zr = disp.zoomregion +slicedata = disp.slicedata imshow(mriseg, nothing, zr, slicedata) ``` @@ -187,8 +193,8 @@ Alternatively, you can place both displays in a single window: ```julia zr, slicedata = roi(mri, (1,2)) gd = imshow_gui((200, 200), (2, 1); slicedata=slicedata) -imshow(gd["frame"][1,1], gd["canvas"][1,1], mri, nothing, zr, slicedata) -imshow(gd["frame"][1,2], gd["canvas"][1,2], mriseg, nothing, zr, slicedata) +imshow(gd.frame[1,1], gd.canvas[1,1], mri, nothing, zr, slicedata) +imshow(gd.frame[1,2], gd.canvas[1,2], mriseg, nothing, zr, slicedata) ``` You should see something like this: @@ -208,8 +214,8 @@ and "floating" annotations are best for things like scalebars. Here's an example of adding a 30-pixel scale bar to an image: ```julia -guidict = imshow(img) -scalebar(guidict, 30; x = 0.1, y = 0.05) +disp = imshow(img) +scalebar(disp, 30; x = 0.1, y = 0.05) ``` `x` and `y` describe the center of the scale bar in normalized @@ -226,14 +232,14 @@ using Images, ImageView z = ones(10,50); y = 8; x = 2; z[y,x] = 0 -guidict = imshow(z) -idx = annotate!(guidict, AnnotationText(x, y, "x", color=RGB(0,0,1), fontsize=3)) -idx2 = annotate!(guidict, AnnotationPoint(x+10, y, shape='.', size=4, color=RGB(1,0,0))) -idx3 = annotate!(guidict, AnnotationPoint(x+20, y-6, shape='.', size=1, color=RGB(1,0,0), linecolor=RGB(0,0,0), scale=true)) -idx4 = annotate!(guidict, AnnotationLine(x+10, y, x+20, y-6, linewidth=2, color=RGB(0,1,0))) -idx5 = annotate!(guidict, AnnotationBox(x+10, y, x+20, y-6, linewidth=2, color=RGB(0,0,1))) +disp = imshow(z) +idx = annotate!(disp, AnnotationText(x, y, "x", color=RGB(0,0,1), fontsize=3)) +idx2 = annotate!(disp, AnnotationPoint(x+10, y, shape='.', size=4, color=RGB(1,0,0))) +idx3 = annotate!(disp, AnnotationPoint(x+20, y-6, shape='.', size=1, color=RGB(1,0,0), linecolor=RGB(0,0,0), scale=true)) +idx4 = annotate!(disp, AnnotationLine(x+10, y, x+20, y-6, linewidth=2, color=RGB(0,1,0))) +idx5 = annotate!(disp, AnnotationBox(x+10, y, x+20, y-6, linewidth=2, color=RGB(0,0,1))) # This shows that you can remove annotations, too: -delete!(guidict, idx) +delete!(disp, idx) ``` This is what this looks like before `delete!`ing the first annotation: @@ -247,7 +253,7 @@ by calling `annotations()`: using ImageView, Images, Gtk4 # Create the window and a 2x2 grid of canvases, each 300x300 pixels in size gui = imshow_gui((300, 300), (2, 2)) -canvases = gui["canvas"] +canvases = gui.canvas # Create some single-color images (just for testing purposes) makeimage(color) = fill(color, 100, 100) imgs = makeimage.([colorant"red" colorant"green"; @@ -256,10 +262,10 @@ imgs = makeimage.([colorant"red" colorant"green"; anns = [annotations() annotations(); annotations() annotations()] # Draw the images on the canvases. Note that we supply the annotation object for each. -roidict = [imshow(canvases[1,1], imgs[1,1], anns[1,1]) imshow(canvases[1,2], imgs[1,2], anns[1,2]); - imshow(canvases[2,1], imgs[2,1], anns[2,1]) imshow(canvases[2,2], imgs[2,2], anns[2,2])] +rois = [imshow(canvases[1,1], imgs[1,1], anns[1,1]) imshow(canvases[1,2], imgs[1,2], anns[1,2]); + imshow(canvases[2,1], imgs[2,1], anns[2,1]) imshow(canvases[2,2], imgs[2,2], anns[2,2])] # Now we'll add an annotation to the lower-right image -annotate!(anns[2,2], canvases[2,2], roidict[2,2], AnnotationBox(5, 5, 30, 80, linewidth=3, color=RGB(1,1,0))) +annotate!(anns[2,2], canvases[2,2], rois[2,2], AnnotationBox(5, 5, 30, 80, linewidth=3, color=RGB(1,1,0))) ``` ![grid annotations](readme_images/grid_annotations.png) @@ -352,7 +358,7 @@ If you call Julia from a script file, the GLib main loop (which is responsible f using Images, ImageView, TestImages, Gtk4 img = testimage("mandrill") -guidict = imshow(img); +disp = imshow(img); #If we are not in a REPL if (!isinteractive()) @@ -361,7 +367,7 @@ if (!isinteractive()) c = Condition() # Get the window - win = guidict["gui"]["window"] + win = disp.window # Start the GLib main loop @async Gtk4.GLib.glib_main() diff --git a/src/ImageView.jl b/src/ImageView.jl index f7d9c40..66349a5 100644 --- a/src/ImageView.jl +++ b/src/ImageView.jl @@ -284,13 +284,14 @@ function copy_with_restrict!(cnvs, img::AbstractMatrix) end """ - imshow(img; axes=(1,2), name="ImageView") -> guidict - imshow(img, clim; kwargs...) -> guidict - imshow(img, clim, zoomregion, slicedata, annotations; kwargs...) -> guidict - -Display the image `img` in a new window titled with `name`, returning -a dictionary `guidict` containing any Observables signals or GtkObservables -widgets. If the image is 3 or 4 dimensional, GUI controls will be + imshow(img; axes=(1,2), name="ImageView") -> disp::ImageDisplay + imshow(img, clim; kwargs...) -> disp::ImageDisplay + imshow(img, clim, zoomregion, slicedata, annotations; kwargs...) -> disp::ImageDisplay + +Display the image `img` in a new window titled with `name`, returning an +[`ImageDisplay`](@ref) `disp` whose fields expose the Observables and +GtkObservables widgets that drive the GUI (see `propertynames(disp)`). +If the image is 3 or 4 dimensional, GUI controls will be added for slicing along "extra" axes. By default the two-dimensional slice containing axes 1 and 2 are shown, but that can be changed by passing a different setting for `axes`. @@ -455,7 +456,7 @@ function fullscreen_cb(aptr::Ptr, par, win) end """ - guidict = imshow_gui(canvassize, gridsize=(1,1); name="ImageView", aspect=:auto, slicedata=SliceData{false}()) + gui = imshow_gui(canvassize, gridsize=(1,1); name="ImageView", aspect=:auto, slicedata=SliceData{false}()) -> gui::ImageViewGUI Create an image-viewer GUI. By default creates a single canvas, but with custom `gridsize = (nx, ny)` you can create a grid of canvases. @@ -560,9 +561,9 @@ Compat.@constprop :none function frame_canvas(aspect) end """ - imshow(canvas, imgsig::Observable) -> guidict - imshow(canvas, imgsig::Observable, zr::Observable{ZoomRegion}) -> guidict - imshow(frame::Frame, canvas, imgsig::Observable, zr::Observable{ZoomRegion}) -> guidict + imshow(canvas, imgsig::Observable) -> roi::ImageROI + imshow(canvas, imgsig::Observable, zr::Observable{ZoomRegion}) -> roi::ImageROI + imshow(frame::Frame, canvas, imgsig::Observable, zr::Observable{ZoomRegion}) -> roi::ImageROI Display `imgsig` (a `Observable` of an image) in `canvas`, setting up panning and zooming. Optionally include a `frame` for preserving @@ -576,8 +577,8 @@ using ImageView, TestImages, Gtk4 mri = testimage("mri"); # Create a canvas `c`. There are other approaches, like stealing one from a previous call # to `imshow`, or using GtkObservables directly. -guidict = imshow_gui((300, 300)) -c = guidict["canvas"]; +gui = imshow_gui((300, 300)) +c = gui.canvas; # To see anything you have to call `showall` on the window (once) # Create the image Observable imgsig = Observable(mri[:,:,1]); diff --git a/src/annotations.jl b/src/annotations.jl index 2b5a14d..38050f1 100644 --- a/src/annotations.jl +++ b/src/annotations.jl @@ -259,10 +259,10 @@ normalized_lengths(imsl::Observable, width, height) = normalized_lengths(imsl[], width, height) """ - scalebar(guidict::Dict, length; x = 0.8, y = 0.1, color = RGB(1,1,1)) + scalebar(disp::ImageDisplay, length; x = 0.8, y = 0.1, color = RGB(1,1,1)) Add a scale bar annotation to the image display controlled by -`guidict` (returned by [`imshow`](@ref)). If the +`disp` (returned by [`imshow`](@ref)). If the [`pixelspacing`](@ref) of the image is set using Unitful quantities, `length` should also be expressed in physical units. diff --git a/test/annotations.jl b/test/annotations.jl index 34ecad3..b20ce8f 100644 --- a/test/annotations.jl +++ b/test/annotations.jl @@ -23,7 +23,7 @@ scalebar(guidict, 30; x = 0.1, y = 0.05) # Grid of images (issue #202) gd = imshow_gui((300, 300), (2, 2)) -canvases = gd["canvas"] +canvases = gd.canvas anns = [annotations() annotations(); annotations() annotations()] makeimage(color) = fill(color, 100, 100) diff --git a/test/contrast.jl b/test/contrast.jl index a767edf..f2ad58a 100644 --- a/test/contrast.jl +++ b/test/contrast.jl @@ -56,9 +56,9 @@ end Observable(CLim(RGB(0f0,0f0,0f0), RGB(1f0,1f0,1f0))))) == 3 # setup_contrast_popup! registers the contrast_gui action on the canvas - # (gridsize (1,1) default → gd["canvas"] is a single Canvas, not a matrix) + # (gridsize (1,1) default → gd.canvas is a single Canvas, not a matrix) gd = imshow_gui((50, 50)) - canvas = gd["canvas"] + canvas = gd.canvas clim2 = Observable(CLim(0.0f0, 1.0f0)) n_preserved = length(canvas.preserved) ret = setup_contrast_popup!(canvas, clim2) @@ -68,12 +68,12 @@ end # with img kwarg: uses histsignals; action still registered gd2 = imshow_gui((50, 50)) - canvas2 = gd2["canvas"] + canvas2 = gd2.canvas clim3 = Observable(CLim(0.0f0, 1.0f0)) img = Observable(rand(Float32, 10, 10)) setup_contrast_popup!(canvas2, clim3; img=img) @test "contrast_gui" in keys(canvas2.action_group) - Gtk4.destroy(gd["window"]) - Gtk4.destroy(gd2["window"]) + Gtk4.destroy(gd.window) + Gtk4.destroy(gd2.window) end diff --git a/test/kwargs.jl b/test/kwargs.jl index c14bffd..bd411cd 100644 --- a/test/kwargs.jl +++ b/test/kwargs.jl @@ -22,5 +22,5 @@ end @testset "window size with kwargs" begin gd = imshow([1 0; 0 1], canvassize=(500,500)) sleep(2.0) - get(ENV, "CI", nothing) === nothing && @test all(>=(500), size(gd["gui"]["window"])) + get(ENV, "CI", nothing) === nothing && @test all(>=(500), size(gd.window)) end diff --git a/test/newtests.jl b/test/newtests.jl index b92e9ea..01277d9 100644 --- a/test/newtests.jl +++ b/test/newtests.jl @@ -6,16 +6,16 @@ using AxisArrays: AxisArrays, AxisArray, Axis @testset "1d" begin img = rand(N0f8, 5) guidict = imshow_now(img) - win = guidict["gui"]["window"] + win = guidict.window Gtk4.destroy(win) end @testset "Aspect ratio" begin img = rand(N0f8, 20, 20) guidict = imshow_now(img) - win, frame = guidict["gui"]["window"], guidict["gui"]["frame"] + win, frame = guidict.window, guidict.frame @test isa(frame, Gtk4.GtkAspectFrameLeaf) - zr = guidict["roi"]["zoomregion"] + zr = guidict.zoomregion @test get_gtk_property(frame, :ratio, Float32) == 1.0 zr[] = (1:20, 9:10) @@ -29,7 +29,7 @@ end Gtk4.destroy(win) guidict = imshow_now(img, aspect=:none) - win, frame = guidict["gui"]["window"], guidict["gui"]["frame"] + win, frame = guidict.window, guidict.frame @test isa(frame, Gtk4.GtkFrameLeaf) Gtk4.destroy(win) end @@ -38,7 +38,7 @@ end @testset "Image display" begin img_n0f8 = rand(N0f8, 3,3) imsd = imshow_now(img_n0f8; name="N0f8") - @test get_gtk_property(imsd["gui"]["window"], :title, String) == "N0f8" + @test get_gtk_property(imsd.window, :title, String) == "N0f8" img_n0f16 = rand(N0f16, 3,3) imshow_now(img_n0f16; name="N0f16") @@ -73,7 +73,7 @@ end # a large image img = testimage("earth") hbig = imshow_now(img, name="Earth") - win = hbig["gui"]["window"] + win = hbig.window w, h = size(win) ws, hs = screen_size(win) !Sys.iswindows() && @test w <= ws && h <= hs @@ -82,7 +82,7 @@ end img = rand(N0f8, 10000, 15000) hbig = imshow_now(img, name="VeryBig"; canvassize=(500,500)) sleep(1.0) # some extra sleep for this big image - cvs = hbig["gui"]["canvas"]; + cvs = hbig.canvas; # GUI update takes a very long time in CI (sometimes) for MacOS, hence the following i=1 passed=false @@ -98,7 +98,7 @@ end @testset "imshow!" begin img = testimage("mri") guidict = imshow(img[:,:,1]) - c = guidict["gui"]["canvas"] + c = guidict.canvas ImageView.imshow!(c, img[:,:,2]) imgsig = Observable(img[:,:,1]) @@ -109,13 +109,13 @@ end @testset "Orientation" begin img = [1 2; 3 4] guidict = imshow_now(img) - @test parent(guidict["roi"]["image roi"][]) == [1 2; 3 4] + @test parent(guidict.image_roi[]) == [1 2; 3 4] guidict = imshow_now(img, flipy=true) - @test parent(guidict["roi"]["image roi"][]) == [3 4; 1 2] + @test parent(guidict.image_roi[]) == [3 4; 1 2] guidict = imshow_now(img, flipx=true) - @test parent(guidict["roi"]["image roi"][]) == [2 1; 4 3] + @test parent(guidict.image_roi[]) == [2 1; 4 3] guidict = imshow_now(img, flipx=true, flipy=true) - @test parent(guidict["roi"]["image roi"][]) == [4 3; 2 1] + @test parent(guidict.image_roi[]) == [4 3; 2 1] end @testset "Mapping errors" begin @@ -141,44 +141,44 @@ end # Test that we can use positional or named axes with AxisArrays img = AxisArray(rand(3, 5, 2), :x, :y, :z) guin = imshow_now(img; name="AxisArray Named") - @test isa(guin["roi"]["slicedata"].axs[1], Axis{:z}) + @test isa(guin.slicedata.axs[1], Axis{:z}) guip = imshow_now(img; axes=(1,2), name="AxisArray Positional") - @test isa(guip["roi"]["slicedata"].axs[1], Axis{3}) + @test isa(guip.slicedata.axs[1], Axis{3}) guip2 = imshow_now(img; axes=(1,3), name="AxisArray Positional") - @test isa(guip2["roi"]["slicedata"].axs[1], Axis{2}) + @test isa(guip2.slicedata.axs[1], Axis{2}) ## 3d images img = testimage("mri") hmri = imshow_now(img; name="P,R view") - @test isa(hmri["roi"]["slicedata"].axs[1], Axis{:S}) + @test isa(hmri.slicedata.axs[1], Axis{:S}) # Use a custom CLim here because the first slice is not representative of the intensities hmrip = imshow_now(img, Observable(CLim(0.0, 1.0)), axes=(:S, :P), name="S,P view") - @test isa(hmrip["roi"]["slicedata"].axs[1], Axis{:R}) - hmrip["roi"]["slicedata"].signals[1][] = 84 + @test isa(hmrip.slicedata.axs[1], Axis{:R}) + hmrip.slicedata.signals[1][] = 84 ## Two coupled images mriseg = RGB.(img) mriseg[img .> 0.5] .= colorant"red" # version 1 guidata = imshow_now(img, axes=(1,2)) - zr = guidata["roi"]["zoomregion"] - slicedata = guidata["roi"]["slicedata"] + zr = guidata.zoomregion + slicedata = guidata.slicedata guidata2 = imshow_now(mriseg, nothing, zr, slicedata) - @test guidata2["roi"]["zoomregion"] === zr + @test guidata2.zoomregion === zr # version 2 zr, slicedata = roi(img, (1,2)) gd = imshow_gui((200, 200), (1,2); slicedata=slicedata) - guidata1 = imshow(gd["frame"][1,1], gd["canvas"][1,1], img, nothing, zr, slicedata) - guidata2 = imshow(gd["frame"][1,2], gd["canvas"][1,2], mriseg, nothing, zr, slicedata) + guidata1 = imshow(gd.frame[1,1], gd.canvas[1,1], img, nothing, zr, slicedata) + guidata2 = imshow(gd.frame[1,2], gd.canvas[1,2], mriseg, nothing, zr, slicedata) sleep(0.01) - @test guidata1["zoomregion"] === guidata2["zoomregion"] === zr + @test guidata1.zoomregion === guidata2.zoomregion === zr # imlink gd = imlink(img, mriseg) sleep(0.01) - @test gd["guidata"][1]["zoomregion"] === gd["guidata"][2]["zoomregion"] + @test gd.extras[:guidata][1].zoomregion === gd.extras[:guidata][2].zoomregion end @testset "Non-AbstractArrays" begin diff --git a/test/simple.jl b/test/simple.jl index 3da5bae..183c67d 100644 --- a/test/simple.jl +++ b/test/simple.jl @@ -15,7 +15,7 @@ using Test # default contrast setting with a homogenous image imgdict = imshow_now(zeros(3, 3)) - @test imgdict["clim"][] == ImageView.CLim(0.0,1.0) + @test imgdict.clim[] == ImageView.CLim(0.0,1.0) end @testset "Simple RGB" begin @@ -29,7 +29,7 @@ end imgdict = imshow_now(colorview(RGB, A)) # test window actions - win = imgdict["gui"]["window"] + win = imgdict.window Gtk4.G_.activate_action(win, "win.fullscreen", nothing) sleep(0.5) Gtk4.G_.activate_action(win, "win.fullscreen", nothing) diff --git a/test/tile.jl b/test/tile.jl index 060f3f2..a7e9fc7 100644 --- a/test/tile.jl +++ b/test/tile.jl @@ -3,7 +3,7 @@ using ImageCore using TestImages gui = imshow_gui((400, 300), (2, 2); name="canvasgrid") -c = gui["canvas"] +c = gui.canvas imshow(c[1,1], testimage("lighthouse")) imshow(c[1,2], testimage("mountainstream")) imshow(c[2,1], testimage("moonsurface"))