Photo carousel with Ken Burns effects

Hey info-beam lovers!

I’m a university prof and I’ve been trying for years to have a nice carousel of our laboratory pictures, with more than just fade in/out, and could never get a decent Ken Burns effect on a Raspberry Pi. I ran into info-beamer pi recently and was able to set up a very nice picture frame, with the help of ChatGPT.

Basically my prompt was: “I want to use my Raspberry Pi 5 as a digital photo frame with Ken Burns effect. I have installed info-beamer pi. Help me set up the following slideshow: Pictures in landscape mode should be displayed with the Ken Burns effect from one corner to the other. Picture that are “panoramic” should be slowly panned from left-to right, so that the whole picture is eventually shown. Pictures that are in portrait should be presented with left and right black borders.

The result is great, and I’m attaching both the LUA script and the view of the frame (and our labs!).

gl.setup(NATIVE_WIDTH, NATIVE_HEIGHT)

----------------------------------------------------------------------
-- Configuration
----------------------------------------------------------------------

-- Total time for each photograph, in seconds
local SLIDE_TIME = 60.0

-- Fade to/from black
local FADE_TIME = 4.0

-- A landscape photograph becomes a panorama when:
--
--     image_aspect > screen_aspect * PANORAMA_FACTOR
--
-- On a 16:9 display:
--     1.777 * 1.25 = 2.22
--
local PANORAMA_FACTOR = 1.25

-- Ken Burns zoom range
local KB_ZOOM_MIN = 1.02
local KB_ZOOM_MAX = 1.14

-- Default corporate logo brightness.
-- 0.05 means 5% opacity over black.
local DEFAULT_LOGO_ALPHA = 0.15

-- Safety limit: even if a larger value is requested externally,
-- do not display the logo above this brightness.
local MAX_LOGO_ALPHA = 0.90


----------------------------------------------------------------------
-- Utility functions
----------------------------------------------------------------------

local function clamp(v, lo, hi)
    if v < lo then return lo end
    if v > hi then return hi end
    return v
end

local function lerp(a, b, t)
    return a + (b - a) * t
end

-- Smooth acceleration/deceleration
local function smoothstep(t)
    t = clamp(t, 0, 1)
    return t * t * (3 - 2 * t)
end


----------------------------------------------------------------------
-- Load playlist
----------------------------------------------------------------------

local function load_playlist()
    local list = {}
    local data = resource.load_file("photos.txt")

    for line in data:gmatch("[^\r\n]+") do
        -- Ignore blank lines and comments
        if line:match("%S") and line:sub(1,1) ~= "#" then
            list[#list + 1] = line
        end
    end

    return list
end

local photos = load_playlist()

if #photos == 0 then
    error("photos.txt contains no photographs")
end

print(string.format(
    "Loaded playlist containing %d photographs",
    #photos
))


----------------------------------------------------------------------
-- Slideshow state
----------------------------------------------------------------------

local current = nil
local pending = nil
local slide_started = sys.now()


----------------------------------------------------------------------
-- Corporate logo
----------------------------------------------------------------------

-- logo.png must be located in the node directory.
local logo = resource.load_image("logoETS.png")

local logo_override = false
local logo_alpha = DEFAULT_LOGO_ALPHA

-- Give this node a predictable address on info-beamer's
-- UDP control interface.
node.alias("photo-frame")


----------------------------------------------------------------------
-- Corporate logo control
--
-- Accepted messages:
--
--     photo-frame:logo:on
--     photo-frame:logo:on:0.03
--     photo-frame:logo:off
--
----------------------------------------------------------------------

node.event("data", function(data, suffix)

    --------------------------------------------------------------
    -- Enable logo
    --------------------------------------------------------------

    if data == "logo:on" then

        logo_alpha = DEFAULT_LOGO_ALPHA
        logo_override = true

        print(string.format(
            "Corporate logo enabled at brightness %.3f",
            logo_alpha
        ))

        return
    end


    --------------------------------------------------------------
    -- Enable logo with explicit brightness
    --------------------------------------------------------------

    local requested_alpha =
        data:match("^logo:on:([0-9]*%.?[0-9]+)$")

    if requested_alpha then

        local a = tonumber(requested_alpha)

        if a then
            logo_alpha = clamp(
                a,
                0.01,
                MAX_LOGO_ALPHA
            )

            logo_override = true

            print(string.format(
                "Corporate logo enabled at brightness %.3f",
                logo_alpha
            ))
        end

        return
    end


    --------------------------------------------------------------
    -- Disable logo
    --------------------------------------------------------------

    if data == "logo:off" then

        if logo_override then
            logo_override = false

            -- Start the current photograph again from the beginning
            -- of its Ken Burns/pan sequence.
            slide_started = sys.now()

            print("Corporate logo disabled")
        end

        return
    end

end)


----------------------------------------------------------------------
-- Image loading
----------------------------------------------------------------------

local function next_index(idx)
    return (idx % #photos) + 1
end


local function load_photo(idx)

    local path = photos[idx]

    print(string.format(
        "Loading %d/%d: %s",
        idx,
        #photos,
        path
    ))

    local img = resource.load_image {
        file = path;
        mipmap = true;
    }

    return {
        image = img;
        path = path;
        idx = idx;
        ready = false;
        failed = false;
    }
end


local function poll_load(job)

    if not job or job.ready or job.failed then
        return
    end

    local state, a, b = job.image:state()

    if state == "loaded" then

        job.ready = true
        job.w = a
        job.h = b

        print(string.format(
            "Loaded %s (%dx%d, aspect %.3f)",
            job.path,
            job.w,
            job.h,
            job.w / job.h
        ))

    elseif state == "error" then

        print(string.format(
            "ERROR loading %s: %s",
            job.path,
            tostring(a)
        ))

        job.image:dispose()
        job.failed = true
    end
end


local function queue_after(idx)
    pending = load_photo(next_index(idx))
end


local function promote(job, now)

    if current then
        current.image:dispose()
    end

    current = job
    pending = nil
    slide_started = now

    --------------------------------------------------------------
    -- Select one of four diagonal Ken Burns paths.
    --------------------------------------------------------------

    current.corner_path = (current.idx - 1) % 4

    --------------------------------------------------------------
    -- Alternate zoom-in and zoom-out.
    --------------------------------------------------------------

    current.zoom_in = (current.idx % 2 == 1)

    --------------------------------------------------------------
    -- Preload next photograph.
    --------------------------------------------------------------

    queue_after(current.idx)
end


----------------------------------------------------------------------
-- Portrait rendering
----------------------------------------------------------------------

local function draw_portrait(job, alpha)

    -- gl.clear() has already made the entire screen black.
    --
    -- util.draw_correct fits the complete image while preserving
    -- aspect ratio. A portrait photograph therefore naturally
    -- receives black borders on its left and right sides.

    util.draw_correct(
        job.image,
        0, 0,
        WIDTH, HEIGHT,
        alpha
    )
end


----------------------------------------------------------------------
-- Panorama rendering
----------------------------------------------------------------------

local function draw_panorama(job, t, alpha)

    local image_aspect = job.w / job.h
    local screen_aspect = WIDTH / HEIGHT

    --------------------------------------------------------------
    -- Display the photograph at full screen height.
    --
    -- Determine what fraction of the source width corresponds
    -- to one complete screen.
    --------------------------------------------------------------

    local crop_w = screen_aspect / image_aspect
    crop_w = clamp(crop_w, 0, 1)

    --------------------------------------------------------------
    -- Smooth left -> right movement.
    --------------------------------------------------------------

    local p = smoothstep(t)

    local x1 = (1.0 - crop_w) * p
    local x2 = x1 + crop_w

    --------------------------------------------------------------
    -- At t=0:
    --
    --     [0 ........ crop_w]
    --
    -- At t=1:
    --
    --     [1-crop_w ........ 1]
    --
    -- Every horizontal part of the panoramic photograph is
    -- therefore eventually shown.
    --------------------------------------------------------------

    job.image:draw(
        0, 0,
        WIDTH, HEIGHT,
        alpha,
        x1, 0,
        x2, 1
    )
end


----------------------------------------------------------------------
-- Landscape Ken Burns rendering
----------------------------------------------------------------------

local function draw_landscape(job, t, alpha)

    local image_aspect = job.w / job.h
    local screen_aspect = WIDTH / HEIGHT

    --------------------------------------------------------------
    -- Determine the largest source rectangle that exactly matches
    -- the display aspect ratio.
    --------------------------------------------------------------

    local base_w
    local base_h

    if image_aspect >= screen_aspect then

        -- Source is wider than the display.
        base_h = 1.0
        base_w = screen_aspect / image_aspect

    else

        -- Source is taller than the display.
        base_w = 1.0
        base_h = image_aspect / screen_aspect
    end


    --------------------------------------------------------------
    -- Position within the animation.
    --------------------------------------------------------------

    local p = smoothstep(t)


    --------------------------------------------------------------
    -- Zoom
    --------------------------------------------------------------

    local zoom

    if job.zoom_in then

        zoom = lerp(
            KB_ZOOM_MIN,
            KB_ZOOM_MAX,
            p
        )

    else

        zoom = lerp(
            KB_ZOOM_MAX,
            KB_ZOOM_MIN,
            p
        )
    end


    local crop_w = base_w / zoom
    local crop_h = base_h / zoom


    --------------------------------------------------------------
    -- Corner-to-corner motion
    --
    -- 0 = upper-left  -> lower-right
    -- 1 = upper-right -> lower-left
    -- 2 = lower-left  -> upper-right
    -- 3 = lower-right -> upper-left
    --------------------------------------------------------------

    local sx, sy
    local ex, ey

    if job.corner_path == 0 then

        sx, sy = 0, 0
        ex, ey = 1, 1

    elseif job.corner_path == 1 then

        sx, sy = 1, 0
        ex, ey = 0, 1

    elseif job.corner_path == 2 then

        sx, sy = 0, 1
        ex, ey = 1, 0

    else

        sx, sy = 1, 1
        ex, ey = 0, 0
    end


    --------------------------------------------------------------
    -- Interpolate the source crop.
    --------------------------------------------------------------

    local x1 = lerp(
        sx * (1.0 - crop_w),
        ex * (1.0 - crop_w),
        p
    )

    local y1 = lerp(
        sy * (1.0 - crop_h),
        ey * (1.0 - crop_h),
        p
    )

    local x2 = x1 + crop_w
    local y2 = y1 + crop_h


    --------------------------------------------------------------
    -- Draw the selected source rectangle over the entire display.
    --------------------------------------------------------------

    job.image:draw(
        0, 0,
        WIDTH, HEIGHT,
        alpha,
        x1, y1,
        x2, y2
    )
end


----------------------------------------------------------------------
-- Corporate logo rendering
----------------------------------------------------------------------

local function draw_logo()

    local state = logo:state()

    if state ~= "loaded" then
        return
    end

    --------------------------------------------------------------
    -- The entire screen has already been cleared to black.
    --
    -- Leave a substantial margin around the logo and preserve
    -- its aspect ratio.
    --------------------------------------------------------------

    util.draw_correct(
        logo,

        WIDTH  * 0.15,
        HEIGHT * 0.15,

        WIDTH  * 0.85,
        HEIGHT * 0.85,

        logo_alpha
    )
end


----------------------------------------------------------------------
-- Render current photograph
----------------------------------------------------------------------

local function draw_current(now)

    if not current then
        return
    end

    local elapsed = now - slide_started

    local t = clamp(
        elapsed / SLIDE_TIME,
        0,
        1
    )


    --------------------------------------------------------------
    -- Fade
    --------------------------------------------------------------

    local alpha = 1.0

    -- Fade in
    if elapsed < FADE_TIME then
        alpha = elapsed / FADE_TIME
    end

    -- Fade out only if the next photograph is already available.
    if pending and pending.ready then

        if elapsed > SLIDE_TIME - FADE_TIME then

            alpha = math.min(
                alpha,
                (SLIDE_TIME - elapsed) / FADE_TIME
            )
        end
    end

    alpha = clamp(alpha, 0, 1)


    --------------------------------------------------------------
    -- Classify photograph
    --------------------------------------------------------------

    local image_aspect = current.w / current.h
    local screen_aspect = WIDTH / HEIGHT


    if current.w < current.h then

        ----------------------------------------------------------
        -- Portrait
        ----------------------------------------------------------

        draw_portrait(
            current,
            alpha
        )


    elseif image_aspect >
           screen_aspect * PANORAMA_FACTOR then

        ----------------------------------------------------------
        -- Panorama
        ----------------------------------------------------------

        draw_panorama(
            current,
            t,
            alpha
        )


    else

        ----------------------------------------------------------
        -- Ordinary landscape
        ----------------------------------------------------------

        draw_landscape(
            current,
            t,
            alpha
        )

    end
end


----------------------------------------------------------------------
-- Start loading first photograph
----------------------------------------------------------------------

pending = load_photo(1)


----------------------------------------------------------------------
-- Main render loop
----------------------------------------------------------------------

function node.render()

    local now = sys.now()


    --------------------------------------------------------------
    -- Always start with a pure black display.
    --------------------------------------------------------------

    gl.clear(0, 0, 0, 1)


    --------------------------------------------------------------
    -- Poll asynchronous photo loader.
    --------------------------------------------------------------

    poll_load(pending)


    --------------------------------------------------------------
    -- Skip unreadable photographs.
    --------------------------------------------------------------

    if pending and pending.failed then

        local bad_idx = pending.idx

        pending = load_photo(
            next_index(bad_idx)
        )

        poll_load(pending)
    end


    --------------------------------------------------------------
    -- Establish first photograph.
    --------------------------------------------------------------

    if not current then

        if pending and pending.ready then
            promote(pending, now)
        end
    end


    --------------------------------------------------------------
    -- CORPORATE LOGO OVERRIDE
    --
    -- Important: this occurs BEFORE advancing the slideshow.
    -- Consequently the current photograph remains selected while
    -- the logo is visible.
    --------------------------------------------------------------

    if logo_override then

        draw_logo()
        return
    end


    --------------------------------------------------------------
    -- Advance to the following photograph once its display time
    -- has expired, but only if that photograph is ready.
    --------------------------------------------------------------

    if current and
       pending and
       pending.ready and
       now - slide_started >= SLIDE_TIME then

        promote(
            pending,
            now
        )
    end


    --------------------------------------------------------------
    -- Normal slideshow
    --------------------------------------------------------------

    draw_current(now)

end

1 Like