diff --git a/Project.toml b/Project.toml index 67d40f2..a1cf4ba 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" [deps] AxisArrays = "39de3d68-74b9-583c-8d2d-e117c070f3a9" diff --git a/src/ImageView.jl b/src/ImageView.jl index ebe8b07..4c721de 100644 --- a/src/ImageView.jl +++ b/src/ImageView.jl @@ -18,7 +18,7 @@ using Compat # for @constprop :none export AnnotationText, AnnotationPoint, AnnotationPoints, AnnotationLine, AnnotationLines, AnnotationBox export CLim, annotate!, annotations, canvasgrid, imshow, imshow!, imshow_gui, imlink, - roi, scalebar, slice2d + roi, scalebar, setup_contrast_popup!, slice2d const AbstractGray{T} = Color{T,1} const GrayLike = Union{AbstractGray,Number} @@ -733,6 +733,49 @@ function create_contrast_popup(canvas, enabled, hists, clim) end end +function dummy_histsig(clim::Observable{CLim{T}}; floor=nothing) where {T<:GrayLike} + Th = float(T) + cl = clim[] + lo, hi = Th(cl.min), Th(cl.max) + if !(lo < hi) + lo, hi = zero(Th), one(Th) + end + span = hi - lo + rnglo = floor === nothing ? lo - span/2 : max(Th(floor), lo - span/2) + rng = LinRange(rnglo, hi + span/2, 300) + Observable(Histogram(rng, zeros(Int, 299), :right, false)) +end + +dummy_histsigs(clim::Observable{CLim{T}}; floor=nothing) where {T<:GrayLike} = + [dummy_histsig(clim; floor)] + +dummy_histsigs(clim::Observable{CLim{T}}; floor=nothing) where {T<:AbstractRGB} = + [dummy_histsig(map(x->channel_clim(red, x), clim); floor), + dummy_histsig(map(x->channel_clim(green, x), clim); floor), + dummy_histsig(map(x->channel_clim(blue, x), clim); floor)] + +""" + setup_contrast_popup!(canvas, clim; img=nothing) + +Set up a right-click context menu on `canvas` that allows interactive +adjustment of `clim` via a contrast GUI window. If `img` is an +`Observable` wrapping an array compatible with `clim`, a histogram of +pixel intensities is computed and shown whenever the GUI is opened. +Without `img`, the GUI shows sliders only (no histogram). + +This is a lower-level complement to [`imshow`](@ref), useful when +contrast is managed internally by the image type but an interactive +right-click popup is still desired. +""" +function setup_contrast_popup!(canvas, clim::Observable{CLim{T}}; + img::Union{Nothing,Observable}=nothing, + floor=nothing) where T + enabled = Observable(false) + histsigs = img === nothing ? dummy_histsigs(clim; floor) : histsignals(enabled, img, clim) + push!(canvas.preserved, create_contrast_popup(canvas, enabled, histsigs, clim)) + return canvas +end + function map_image_roi(@nospecialize(img), zr::Observable{ZoomRegion{T}}, slices...) where T map(zr, slices...) do r, s... cv = r.currentview diff --git a/test/contrast.jl b/test/contrast.jl index f182853..a767edf 100644 --- a/test/contrast.jl +++ b/test/contrast.jl @@ -1,5 +1,6 @@ using ImageView, ImageCore, ImageView.Observables, MultiChannelColors using Gtk4: Gtk4 +using GtkObservables: GtkObservables using Test @testset "contrast GUI" begin @@ -32,3 +33,47 @@ using Test @test_broken sum(h.weights) > 0 end end + +@testset "setup_contrast_popup!" begin + # dummy_histsig: range is [lo-span/2, hi+span/2] with zero counts + clim = Observable(CLim(0.2f0, 0.8f0)) + hsig = ImageView.dummy_histsig(clim) + h = hsig[] + @test all(iszero, h.weights) + @test length(h.weights) == 299 # 300-point LinRange → 299 bins + @test first(h.edges[1]) ≈ -0.1f0 # 0.2 - 0.6/2 + @test last(h.edges[1]) ≈ 1.1f0 # 0.8 + 0.6/2 + + # degenerate CLim (min == max) falls back to [0,1]-based range + hsig_degen = ImageView.dummy_histsig(Observable(CLim(0.5f0, 0.5f0))) + h_degen = hsig_degen[] + @test first(h_degen.edges[1]) ≈ -0.5f0 # 0 - 1/2 + @test last(h_degen.edges[1]) ≈ 1.5f0 # 1 + 1/2 + + # dummy_histsigs: 1 signal for GrayLike, 3 for AbstractRGB + @test length(ImageView.dummy_histsigs(Observable(CLim(0.0f0, 1.0f0)))) == 1 + @test length(ImageView.dummy_histsigs( + 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) + gd = imshow_gui((50, 50)) + canvas = gd["canvas"] + clim2 = Observable(CLim(0.0f0, 1.0f0)) + n_preserved = length(canvas.preserved) + ret = setup_contrast_popup!(canvas, clim2) + @test ret === canvas # returns the canvas + @test "contrast_gui" in keys(canvas.action_group) + @test length(canvas.preserved) > n_preserved # callback was preserved + + # with img kwarg: uses histsignals; action still registered + gd2 = imshow_gui((50, 50)) + 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"]) +end