module PlotPx
using Libdl
import Pkg.Artifacts: artifact_hash, artifact_path, ensure_artifact_installed
export write_png_bytes, write_png_file, write_plot_png_bytes, write_plot_png_file,
SPECTRUM_STYLE_SOLID, SPECTRUM_STYLE_GRADIENT, SPECTRUM_STYLE_SEGMENTED
const lib_handle = Ref{Ptr{Cvoid}}(C_NULL)
const lib_path = Ref{String}("")
const ARTIFACT_NAME = "plotpx"
const ARTIFACTS_TOML = normpath(joinpath(@__DIR__, "..", "Artifacts.toml"))
function artifact_library_path()
isfile(ARTIFACTS_TOML) || return nothing
try
ensure_artifact_installed(ARTIFACT_NAME, ARTIFACTS_TOML)
hash = artifact_hash(ARTIFACT_NAME, ARTIFACTS_TOML)
hash === nothing && return nothing
dir = artifact_path(hash)
return maybe_find_shared_library(dir)
catch
return nothing
end
end
function maybe_find_shared_library(dir::AbstractString)
libnames = Sys.iswindows() ? ["plotpx.dll"] : Sys.isapple() ? ["libplotpx.dylib"] : ["libplotpx.so"]
for name in libnames
for subdir in ("", "lib", "bin")
path = subdir == "" ? joinpath(dir, name) : joinpath(dir, subdir, name)
if isfile(path)
return normpath(path)
end
end
end
return nothing
end
struct PlotpxBuffer
data::Ptr{UInt8}
len::Csize_t
end
struct PlotpxHeatmapPoint
x::UInt32
y::UInt32
weight::Cfloat
end
struct PlotpxSpectrumConfig
width::UInt32
height::UInt32
style::UInt32
show_peaks::Bool
peak_decay::Cfloat
bar_width_factor::Cfloat
background::NTuple{4, UInt8}
end
const SPECTRUM_STYLE_SOLID = UInt32(0)
const SPECTRUM_STYLE_GRADIENT = UInt32(1)
const SPECTRUM_STYLE_SEGMENTED = UInt32(2)
function default_library_path()
artifact_path = artifact_library_path()
artifact_path !== nothing && return artifact_path
libname = Sys.iswindows() ? "plotpx.dll" : Sys.isapple() ? "libplotpx.dylib" : "libplotpx.so"
return normpath(joinpath(@__DIR__, "..", "..", "..", "target", "release", libname))
end
function load_plotpx(; path::AbstractString = default_library_path())
if !isfile(path)
error("plotpx shared library not found at $(path). Build it with `cargo build --release` or provide `path=` explicitly.")
end
lib_handle[] = Libdl.dlopen(path)
lib_path[] = path
return nothing
end
function ensure_loaded()
if lib_handle[] == C_NULL
load_plotpx()
end
end
function last_error()
ensure_loaded()
ptr = ccall((:plotpx_last_error_message, lib_path[]), Ptr{Cchar}, ())
return ptr == C_NULL ? nothing : unsafe_string(ptr)
end
function free_buffer(buffer::PlotpxBuffer)
ensure_loaded()
ccall((:plotpx_free_buffer, lib_path[]), Cvoid, (PlotpxBuffer,), buffer)
return nothing
end
function as_row_major(data::AbstractMatrix)
height, width = size(data)
buf = Vector{Float32}(undef, width * height)
idx = 1
@inbounds for y in 1:height
for x in 1:width
buf[idx] = Float32(data[y, x])
idx += 1
end
end
return buf, UInt32(width), UInt32(height)
end
function prepare_colors(colors::AbstractVector{<:Integer})
v = Vector{UInt8}(colors)
if length(v) % 4 != 0
error("Color tables must be a multiple of 4 bytes (RGBA)")
end
return v
end
function copy_to_float32(data::AbstractVector{<:Real})
values = Vector{Float32}(undef, length(data))
@inbounds for i in eachindex(values)
values[i] = Float32(data[i])
end
return values
end
function normalize_heatmap_points(points::AbstractVector)
result = Vector{PlotpxHeatmapPoint}(undef, length(points))
@inbounds for (i, point) in pairs(points)
if point isa PlotpxHeatmapPoint
result[i] = point
elseif point isa Tuple
if length(point) == 2
x, y = point
result[i] = PlotpxHeatmapPoint(UInt32(x), UInt32(y), Cfloat(1))
elseif length(point) == 3
x, y, w = point
result[i] = PlotpxHeatmapPoint(UInt32(x), UInt32(y), Cfloat(w))
else
error("Heatmap tuples must have 2 or 3 elements")
end
elseif point isa NamedTuple
haskey(point, :x) || error("Heatmap NamedTuple missing :x")
haskey(point, :y) || error("Heatmap NamedTuple missing :y")
weight = get(point, :weight, 1)
result[i] = PlotpxHeatmapPoint(UInt32(point[:x]), UInt32(point[:y]), Cfloat(weight))
else
error("Unsupported heatmap point type $(typeof(point))")
end
end
return result
end
function to_spectrum_style(style)
style isa UInt32 && return style
style_sym = Symbol(style)
if style_sym === :solid
return SPECTRUM_STYLE_SOLID
elseif style_sym === :gradient
return SPECTRUM_STYLE_GRADIENT
elseif style_sym === :segmented
return SPECTRUM_STYLE_SEGMENTED
else
error("Unsupported spectrum style $(style)")
end
end
function normalize_rgba(background)
length(background) == 4 || error("background must have four components (RGBA)")
return (
UInt8(background[1]),
UInt8(background[2]),
UInt8(background[3]),
UInt8(background[4]),
)
end
function sanitize_saturation(saturation)
sat = Float32(saturation)
if !(sat > 0)
error("saturation must be positive")
end
return Cfloat(sat)
end
function colors_ptr_and_len(colors)
if colors === nothing
return Ptr{UInt8}(C_NULL), Csize_t(0), UInt8[]
end
vec = prepare_colors(colors)
return pointer(vec), Csize_t(length(vec)), vec
end
# Magnitude plots
function _write_magnitude_png_bytes(data::AbstractMatrix{<:Real}; saturation::Union{Nothing,Real}=nothing, colors::Union{Nothing,AbstractVector{<:Integer}}=nothing)
buf, width, height = as_row_major(data)
return _write_magnitude_png_bytes(buf; width=width, height=height, saturation=saturation, colors=colors)
end
function _write_magnitude_png_bytes(data::AbstractVector{<:Real}, width::Integer, height::Integer; kwargs...)
return _write_magnitude_png_bytes(data; width=width, height=height, kwargs...)
end
function _write_magnitude_png_bytes(data::AbstractVector{<:Real}; width::Integer, height::Integer, saturation::Union{Nothing,Real}=nothing, colors::Union{Nothing,AbstractVector{<:Integer}}=nothing)
ensure_loaded()
width > 0 || error("width must be positive")
height > 0 || error("height must be positive")
width_u = UInt32(width)
height_u = UInt32(height)
expected = Int(width_u) * Int(height_u)
length(data) == expected || error("Data length $(length(data)) does not match width×height ($expected)")
values = copy_to_float32(data)
color_ptr, color_len, colors_vec = colors_ptr_and_len(colors)
buf_ref = Ref(PlotpxBuffer(Ptr{UInt8}(C_NULL), 0))
sat = saturation === nothing ? Cfloat(0) : sanitize_saturation(saturation)
code = GC.@preserve values colors_vec begin
ccall((:plotpx_write_magnitude_png_buffer, lib_path[]), Cint,
(Ptr{Cfloat}, Csize_t, UInt32, UInt32, Cfloat, Ptr{UInt8}, Csize_t, Ref{PlotpxBuffer}),
pointer(values), Csize_t(length(values)), width_u, height_u, sat, color_ptr, color_len, buf_ref)
end
if code != 0
msg = last_error()
throw(ErrorException(msg === nothing ? "plotpx_write_magnitude_png_buffer failed" : String(msg)))
end
buf = buf_ref[]
buf.data == Ptr{UInt8}(C_NULL) && return UInt8[]
result = unsafe_wrap(Vector{UInt8}, buf.data, buf.len)
copy_result = copy(result)
free_buffer(buf)
return copy_result
end
function _write_magnitude_png_file(path::AbstractString, data::AbstractMatrix{<:Real}; saturation::Union{Nothing,Real}=nothing, colors::Union{Nothing,AbstractVector{<:Integer}}=nothing)
buf, width, height = as_row_major(data)
_write_magnitude_png_file(path, buf; width=width, height=height, saturation=saturation, colors=colors)
return path
end
function _write_magnitude_png_file(path::AbstractString, data::AbstractVector{<:Real}, width::Integer, height::Integer; kwargs...)
return _write_magnitude_png_file(path, data; width=width, height=height, kwargs...)
end
function _write_magnitude_png_file(path::AbstractString, data::AbstractVector{<:Real}; width::Integer, height::Integer, saturation::Union{Nothing,Real}=nothing, colors::Union{Nothing,AbstractVector{<:Integer}}=nothing)
ensure_loaded()
width > 0 || error("width must be positive")
height > 0 || error("height must be positive")
width_u = UInt32(width)
height_u = UInt32(height)
expected = Int(width_u) * Int(height_u)
length(data) == expected || error("Data length $(length(data)) does not match width×height ($expected)")
values = copy_to_float32(data)
color_ptr, color_len, colors_vec = colors_ptr_and_len(colors)
sat = saturation === nothing ? Cfloat(0) : sanitize_saturation(saturation)
path_c = Base.cconvert(Cstring, path)
path_ptr = Base.unsafe_convert(Cstring, path_c)
code = GC.@preserve values colors_vec begin
ccall((:plotpx_write_magnitude_png_file, lib_path[]), Cint,
(Cstring, Ptr{Cfloat}, Csize_t, UInt32, UInt32, Cfloat, Ptr{UInt8}, Csize_t),
path_ptr, pointer(values), Csize_t(length(values)), width_u, height_u, sat, color_ptr, color_len)
end
if code != 0
msg = last_error()
throw(ErrorException(msg === nothing ? "plotpx_write_magnitude_png_file failed" : String(msg)))
end
return path
end
# Magnitude mapped plots
function _write_magnitude_mapped_png_bytes(data::AbstractMatrix{<:Real}; image_width::Integer, image_height::Integer, saturation::Union{Nothing,Real}=nothing, colors::Union{Nothing,AbstractVector{<:Integer}}=nothing)
buf, input_width, input_height = as_row_major(data)
return _write_magnitude_mapped_png_bytes(buf; input_width=input_width, input_height=input_height, image_width=image_width, image_height=image_height, saturation=saturation, colors=colors)
end
function _write_magnitude_mapped_png_bytes(data::AbstractVector{<:Real}, input_width::Integer, input_height::Integer, image_width::Integer, image_height::Integer; kwargs...)
return _write_magnitude_mapped_png_bytes(data; input_width=input_width, input_height=input_height, image_width=image_width, image_height=image_height, kwargs...)
end
function _write_magnitude_mapped_png_bytes(data::AbstractVector{<:Real}; input_width::Integer, input_height::Integer, image_width::Integer, image_height::Integer, saturation::Union{Nothing,Real}=nothing, colors::Union{Nothing,AbstractVector{<:Integer}}=nothing)
ensure_loaded()
input_width > 0 || error("input_width must be positive")
input_height > 0 || error("input_height must be positive")
image_width > 0 || error("image_width must be positive")
image_height > 0 || error("image_height must be positive")
input_width_u = UInt32(input_width)
input_height_u = UInt32(input_height)
image_width_u = UInt32(image_width)
image_height_u = UInt32(image_height)
expected = Int(input_width_u) * Int(input_height_u)
length(data) == expected || error("Data length $(length(data)) does not match input grid size ($expected)")
values = copy_to_float32(data)
color_ptr, color_len, colors_vec = colors_ptr_and_len(colors)
buf_ref = Ref(PlotpxBuffer(Ptr{UInt8}(C_NULL), 0))
sat = saturation === nothing ? Cfloat(0) : sanitize_saturation(saturation)
code = GC.@preserve values colors_vec begin
ccall((:plotpx_write_magnitude_mapped_png_buffer, lib_path[]), Cint,
(Ptr{Cfloat}, Csize_t, UInt32, UInt32, UInt32, UInt32, Cfloat, Ptr{UInt8}, Csize_t, Ref{PlotpxBuffer}),
pointer(values), Csize_t(length(values)), input_width_u, input_height_u, image_width_u, image_height_u, sat, color_ptr, color_len, buf_ref)
end
if code != 0
msg = last_error()
throw(ErrorException(msg === nothing ? "plotpx_write_magnitude_mapped_png_buffer failed" : String(msg)))
end
buf = buf_ref[]
buf.data == Ptr{UInt8}(C_NULL) && return UInt8[]
result = unsafe_wrap(Vector{UInt8}, buf.data, buf.len)
copy_result = copy(result)
free_buffer(buf)
return copy_result
end
function _write_magnitude_mapped_png_file(path::AbstractString, data::AbstractMatrix{<:Real}; image_width::Integer, image_height::Integer, saturation::Union{Nothing,Real}=nothing, colors::Union{Nothing,AbstractVector{<:Integer}}=nothing)
buf, input_width, input_height = as_row_major(data)
_write_magnitude_mapped_png_file(path, buf; input_width=input_width, input_height=input_height, image_width=image_width, image_height=image_height, saturation=saturation, colors=colors)
return path
end
function _write_magnitude_mapped_png_file(path::AbstractString, data::AbstractVector{<:Real}, input_width::Integer, input_height::Integer, image_width::Integer, image_height::Integer; kwargs...)
return _write_magnitude_mapped_png_file(path, data; input_width=input_width, input_height=input_height, image_width=image_width, image_height=image_height, kwargs...)
end
function _write_magnitude_mapped_png_file(path::AbstractString, data::AbstractVector{<:Real}; input_width::Integer, input_height::Integer, image_width::Integer, image_height::Integer, saturation::Union{Nothing,Real}=nothing, colors::Union{Nothing,AbstractVector{<:Integer}}=nothing)
ensure_loaded()
input_width > 0 || error("input_width must be positive")
input_height > 0 || error("input_height must be positive")
image_width > 0 || error("image_width must be positive")
image_height > 0 || error("image_height must be positive")
input_width_u = UInt32(input_width)
input_height_u = UInt32(input_height)
image_width_u = UInt32(image_width)
image_height_u = UInt32(image_height)
expected = Int(input_width_u) * Int(input_height_u)
length(data) == expected || error("Data length $(length(data)) does not match input grid size ($expected)")
values = copy_to_float32(data)
color_ptr, color_len, colors_vec = colors_ptr_and_len(colors)
sat = saturation === nothing ? Cfloat(0) : sanitize_saturation(saturation)
path_c = Base.cconvert(Cstring, path)
path_ptr = Base.unsafe_convert(Cstring, path_c)
code = GC.@preserve values colors_vec begin
ccall((:plotpx_write_magnitude_mapped_png_file, lib_path[]), Cint,
(Cstring, Ptr{Cfloat}, Csize_t, UInt32, UInt32, UInt32, UInt32, Cfloat, Ptr{UInt8}, Csize_t),
path_ptr, pointer(values), Csize_t(length(values)), input_width_u, input_height_u, image_width_u, image_height_u, sat, color_ptr, color_len)
end
if code != 0
msg = last_error()
throw(ErrorException(msg === nothing ? "plotpx_write_magnitude_mapped_png_file failed" : String(msg)))
end
return path
end
# Heatmap plots
function _write_heatmap_png_bytes(points::AbstractVector; width::Integer, height::Integer, saturation::Union{Nothing,Real}=nothing, colors::Union{Nothing,AbstractVector{<:Integer}}=nothing)
ensure_loaded()
width > 0 || error("width must be positive")
height > 0 || error("height must be positive")
width_u = UInt32(width)
height_u = UInt32(height)
points_vec = normalize_heatmap_points(points)
point_ptr = isempty(points_vec) ? Ptr{PlotpxHeatmapPoint}(C_NULL) : pointer(points_vec)
color_ptr, color_len, colors_vec = colors_ptr_and_len(colors)
buf_ref = Ref(PlotpxBuffer(Ptr{UInt8}(C_NULL), 0))
sat = saturation === nothing ? Cfloat(0) : sanitize_saturation(saturation)
code = GC.@preserve points_vec colors_vec begin
ccall((:plotpx_write_heatmap_png_buffer, lib_path[]), Cint,
(Ptr{PlotpxHeatmapPoint}, Csize_t, UInt32, UInt32, Cfloat, Ptr{UInt8}, Csize_t, Ref{PlotpxBuffer}),
point_ptr, Csize_t(length(points_vec)), width_u, height_u, sat, color_ptr, color_len, buf_ref)
end
if code != 0
msg = last_error()
throw(ErrorException(msg === nothing ? "plotpx_write_heatmap_png_buffer failed" : String(msg)))
end
buf = buf_ref[]
buf.data == Ptr{UInt8}(C_NULL) && return UInt8[]
result = unsafe_wrap(Vector{UInt8}, buf.data, buf.len)
copy_result = copy(result)
free_buffer(buf)
return copy_result
end
function _write_heatmap_png_file(path::AbstractString, points::AbstractVector; width::Integer, height::Integer, saturation::Union{Nothing,Real}=nothing, colors::Union{Nothing,AbstractVector{<:Integer}}=nothing)
ensure_loaded()
width > 0 || error("width must be positive")
height > 0 || error("height must be positive")
width_u = UInt32(width)
height_u = UInt32(height)
points_vec = normalize_heatmap_points(points)
point_ptr = isempty(points_vec) ? Ptr{PlotpxHeatmapPoint}(C_NULL) : pointer(points_vec)
color_ptr, color_len, colors_vec = colors_ptr_and_len(colors)
sat = saturation === nothing ? Cfloat(0) : sanitize_saturation(saturation)
path_c = Base.cconvert(Cstring, path)
path_ptr = Base.unsafe_convert(Cstring, path_c)
code = GC.@preserve points_vec colors_vec begin
ccall((:plotpx_write_heatmap_png_file, lib_path[]), Cint,
(Cstring, Ptr{PlotpxHeatmapPoint}, Csize_t, UInt32, UInt32, Cfloat, Ptr{UInt8}, Csize_t),
path_ptr, point_ptr, Csize_t(length(points_vec)), width_u, height_u, sat, color_ptr, color_len)
end
if code != 0
msg = last_error()
throw(ErrorException(msg === nothing ? "plotpx_write_heatmap_png_file failed" : String(msg)))
end
return path
end
# Spectrum plots
function _write_spectrum_png_bytes(data::AbstractVector{<:Real}; width::Integer, height::Integer, saturation::Union{Nothing,Real}=nothing, colors::Union{Nothing,AbstractVector{<:Integer}}=nothing, style::Union{Symbol,UInt32}=:solid, show_peaks::Bool=false, peak_decay::Real=0.0, bar_width_factor::Real=0.8, background=(0x00, 0x00, 0x00, 0x00))
ensure_loaded()
length(data) > 0 || error("Spectrum data must contain at least one bin")
width > 0 || error("width must be positive")
height > 0 || error("height must be positive")
peak_decay >= 0 || error("peak_decay must be non-negative")
isfinite(peak_decay) || error("peak_decay must be finite")
bar_width_factor > 0 || error("bar_width_factor must be positive")
isfinite(bar_width_factor) || error("bar_width_factor must be finite")
width_u = UInt32(width)
height_u = UInt32(height)
values = copy_to_float32(data)
cfg = PlotpxSpectrumConfig(
width_u,
height_u,
to_spectrum_style(style),
show_peaks,
Cfloat(peak_decay),
Cfloat(bar_width_factor),
normalize_rgba(background),
)
cfg_ref = Ref(cfg)
color_ptr, color_len, colors_vec = colors_ptr_and_len(colors)
buf_ref = Ref(PlotpxBuffer(Ptr{UInt8}(C_NULL), 0))
sat = saturation === nothing ? Cfloat(0) : sanitize_saturation(saturation)
code = GC.@preserve values colors_vec cfg_ref begin
ccall((:plotpx_write_spectrum_png_buffer, lib_path[]), Cint,
(Ptr{Cfloat}, Csize_t, Ptr{PlotpxSpectrumConfig}, Cfloat, Ptr{UInt8}, Csize_t, Ref{PlotpxBuffer}),
pointer(values), Csize_t(length(values)), cfg_ref, sat, color_ptr, color_len, buf_ref)
end
if code != 0
msg = last_error()
throw(ErrorException(msg === nothing ? "plotpx_write_spectrum_png_buffer failed" : String(msg)))
end
buf = buf_ref[]
buf.data == Ptr{UInt8}(C_NULL) && return UInt8[]
result = unsafe_wrap(Vector{UInt8}, buf.data, buf.len)
copy_result = copy(result)
free_buffer(buf)
return copy_result
end
function _write_spectrum_png_file(path::AbstractString, data::AbstractVector{<:Real}; width::Integer, height::Integer, saturation::Union{Nothing,Real}=nothing, colors::Union{Nothing,AbstractVector{<:Integer}}=nothing, style::Union{Symbol,UInt32}=:solid, show_peaks::Bool=false, peak_decay::Real=0.0, bar_width_factor::Real=0.8, background=(0x00, 0x00, 0x00, 0x00))
ensure_loaded()
length(data) > 0 || error("Spectrum data must contain at least one bin")
width > 0 || error("width must be positive")
height > 0 || error("height must be positive")
peak_decay >= 0 || error("peak_decay must be non-negative")
isfinite(peak_decay) || error("peak_decay must be finite")
bar_width_factor > 0 || error("bar_width_factor must be positive")
isfinite(bar_width_factor) || error("bar_width_factor must be finite")
width_u = UInt32(width)
height_u = UInt32(height)
values = copy_to_float32(data)
cfg = PlotpxSpectrumConfig(
width_u,
height_u,
to_spectrum_style(style),
show_peaks,
Cfloat(peak_decay),
Cfloat(bar_width_factor),
normalize_rgba(background),
)
cfg_ref = Ref(cfg)
color_ptr, color_len, colors_vec = colors_ptr_and_len(colors)
sat = saturation === nothing ? Cfloat(0) : sanitize_saturation(saturation)
path_c = Base.cconvert(Cstring, path)
path_ptr = Base.unsafe_convert(Cstring, path_c)
code = GC.@preserve values colors_vec cfg_ref begin
ccall((:plotpx_write_spectrum_png_file, lib_path[]), Cint,
(Cstring, Ptr{Cfloat}, Csize_t, Ptr{PlotpxSpectrumConfig}, Cfloat, Ptr{UInt8}, Csize_t),
path_ptr, pointer(values), Csize_t(length(values)), cfg_ref, sat, color_ptr, color_len)
end
if code != 0
msg = last_error()
throw(ErrorException(msg === nothing ? "plotpx_write_spectrum_png_file failed" : String(msg)))
end
return path
end
# Plot selection helpers
function write_plot_png_bytes(plot::Symbol, data; kwargs...)
plot_sym = Symbol(plot)
if plot_sym === :magnitude
return _write_magnitude_png_bytes(data; kwargs...)
elseif plot_sym === :magnitude_mapped
return _write_magnitude_mapped_png_bytes(data; kwargs...)
elseif plot_sym === :heatmap
return _write_heatmap_png_bytes(data; kwargs...)
elseif plot_sym === :spectrum
return _write_spectrum_png_bytes(data; kwargs...)
else
error("Unsupported plot type $(plot)")
end
end
write_png_bytes(data; kwargs...) = write_plot_png_bytes(:magnitude, data; kwargs...)
write_png_bytes(plot::Symbol, data; kwargs...) = write_plot_png_bytes(plot, data; kwargs...)
function write_plot_png_file(path::AbstractString, plot::Symbol, data; kwargs...)
plot_sym = Symbol(plot)
if plot_sym === :magnitude
return _write_magnitude_png_file(path, data; kwargs...)
elseif plot_sym === :magnitude_mapped
return _write_magnitude_mapped_png_file(path, data; kwargs...)
elseif plot_sym === :heatmap
return _write_heatmap_png_file(path, data; kwargs...)
elseif plot_sym === :spectrum
return _write_spectrum_png_file(path, data; kwargs...)
else
error("Unsupported plot type $(plot)")
end
end
write_png_file(path::AbstractString, data; kwargs...) = write_plot_png_file(path, :magnitude, data; kwargs...)
write_png_file(path::AbstractString, plot::Symbol, data; kwargs...) = write_plot_png_file(path, plot, data; kwargs...)
function __init__()
try
load_plotpx()
catch err
Base.@debug "plotpx shared library not preloaded" err=err
end
end
end # module