Nota sobre expressões matemáticas: Este artigo contém fórmulas matemáticas que são renderizadas dinamicamente no seu navegador usando MathJax. Se as equações não aparecerem corretamente, por favor, atualize a página ou verifique se JavaScript está habilitado.

Laços de Luz: Curvas Animadas a Partir de Imagens com gosketch

Imagine uma tela onde linhas ganham vida, dançando ao ritmo secreto de uma imagem estática. Curvas fluidas se desenrolam sobre fotografias, revelando padrões ocultos em luz e sombra. Nos últimos dias, mergulhei em um experimento de criatividade computacional que une código e arte: usei algoritmos simples para gerar curvas animadas baseadas em imagens, explorando como arte generativa pode emergir da interação entre instruções matemáticas e a complexidade visual de uma foto.

Este é um relato técnico e poético desse processo – um diário de projeto em que linhas de código traçam poesia visual.

🎨 Inspiração: A Arte Digital de Schmrgl e a Centelha do Projeto

A inspiração para este projeto nasceu ao contemplar as obras digitais do artista Schmrgl. Suas criações apresentam traços curvilíneos e orgânicos que parecem orbitar formas e cores, como se fossem correntes de vento invisível soprando através de imagens. Fiquei fascinado com a forma como linhas fluidas podem reinterpretar uma imagem – destacando contornos, evocando movimento e dando nova vida a fotos estáticas.

Como entusiasta de arte generativa, me perguntei: seria possível recriar um efeito semelhante via código? Quis capturar aquela estética de curvas que abraçam os contornos da imagem e trazê-la para o meu próprio experimento.

Essa centelha de inspiração motivou o plano: usar uma fotografia como ponto de partida e, com algoritmos, gerar linhas que se curvam e dançam guiadas pelos segredos internos da imagem. Seria uma junção perfeita de projeto artístico com exploração técnica, algo muito alinhado com o espírito deste blog.

🛠️ gosketch: Pintando com Código em Go

Para transformar essa ideia em realidade, utilizei o gosketch – um framework de creative coding em Go que desenvolvi inspirando-me no Processing/p5.js. O gosketch atua como tela e pincel digital, fornecendo uma API simples para desenhar formas, atualizar frames e interagir em tempo real.

Com ele, pude me concentrar na lógica criativa do projeto, enquanto tarefas como abrir uma janela gráfica, renderizar curvas e controlar a animação ficaram a cargo da ferramenta.

A decisão de usar o gosketch não foi apenas por familiaridade (afinal, fui eu mesmo que o criei 😄), mas também pela leveza e rapidez da linguagem Go. Desenhar centenas de curvas animadas requer desempenho, e Go se mostrou eficiente nesse aspecto.

Além disso, trabalhar com gosketch tornou a experiência mais fluida: em vez de lutar contra detalhes de baixo nível, escrevi o código do sketch quase como se escrevesse um pequeno poema – definindo a cena no Setup() e dando vida às curvas no Draw().

🔍 Análise da Imagem: Brilho e Gradiente

O primeiro passo do algoritmo foi entender a imagem de entrada não como um conjunto de pixels coloridos, mas como um mapa de intensidades de luz. Converti a foto para escalas de cinza e calculei o brilho de cada ponto – essencialmente um valor de 0 (preto) a 255 (branco) representando a luminosidade.

// brightness retorna [0,1] do pixel em x,y
func brightness(x, y int) float64 {
    c := img.GetPixel(x, y)
    r, g, b, _ := c.RGBA()
    // convertendo para 0–255
    R := float64(r >> 8)
    G := float64(g >> 8)
    B := float64(b >> 8)
    return (R + G + B) / (3 * 255)
}

Essa simplificação revelou a "geografia" básica da imagem: regiões claras e escuras, contraste, transições suaves ou bruscas.

Com o mapa de brilho em mãos, extraí o gradiente da imagem. O gradiente, em processamento de imagens, é um vetor que aponta na direção de maior mudança de brilho e cuja magnitude indica o quão abrupta é essa mudança.

// computeGradient calcula ângulo e magnitude do gradiente em (x, y)
func computeGradient(x, y int) (angle, magnitude float64) {
    cC := img.GetPixel(x, y)
    rC, gC, bC, _ := cC.RGBA()
    iC := (float64(rC>>8) + float64(gC>>8) + float64(bC>>8)) / 3.0

    xi := clamp(x+1, 0, img.Width()-1)
    cR := img.GetPixel(xi, y)
    rR, gR, bR, _ := cR.RGBA()
    iR := (float64(rR>>8) + float64(gR>>8) + float64(bR>>8)) / 3.0

    yi := clamp(y+1, 0, img.Height()-1)
    cD := img.GetPixel(x, yi)
    rD, gD, bD, _ := cD.RGBA()
    iD := (float64(rD>>8) + float64(gD>>8) + float64(bD>>8)) / 3.0

    dx := iR - iC
    dy := iD - iC

    angle = math.Atan2(dy, dx)
    magnitude = math.Hypot(dx, dy)
    return
}

Em outras palavras, onde a imagem tem um contorno ou borda, o gradiente "aponta" perpendicularmente a esse contorno (do lado escuro para o claro).

Diálogo com a imagem: Nesse estágio, senti que o código "ouvia" a fotografia – percorrendo cada pixel como quem passa a mão por uma escultura de relevo, sentindo suas saliências e depressões. Os valores de brilho e gradiente formaram uma espécie de relevo invisível: colinas de luz e vales de sombra que logo serviriam de guia para nossas curvas.

🌀 Curvas Circulares a partir do Gradiente

Tendo o campo de gradientes, o próximo desafio foi: como desenhar curvas baseadas nesses vetores? A ideia central foi usar o gradiente como base, mas com um twist: em vez de seguir o gradiente em si (o que nos levaria diretamente das sombras para as luzes, atravessando os contornos), decidi seguir uma direção perpendicular ao gradiente.

Essa simples escolha – girar o vetor gradiente em 90 graus – faz toda a diferença: significa que iremos deslizar ao longo dos contornos da imagem, em vez de cortá-los. É como optar por caminhar pela linha da costa em vez de subir morro acima ou descer vale abaixo.

A Matemática das Curvas Tangenciais

Matematicamente, se temos um gradiente $$\nabla I = (g_x, g_y)$$ em um ponto, a direção perpendicular (tangente ao contorno) pode ser obtida por uma rotação de 90°:

$$\text{direção_tangente} = (-g_y, g_x) \text{ ou } (g_y, -g_x)$$

Essas são as curvas circulares de que falo: trajetórias que orbitam regiões claras ou escuras, mantendo-se tangenciais às bordas. Se o gradiente aponta "para fora" de uma área iluminada, a direção perpendicular nos faz contorná-la, desenhando um laço ao seu redor.

// newGradientCurve pré-computa uma curva de gradiente para (x, y)
func newGradientCurve(x, y int) (Curve, bool) {
    // aplica bias de brilho para spawn
    if rand.Float64() > math.Pow(brightness(x, y), cfg.BrightnessBias) {
        return Curve{}, false
    }

    angle, mag := computeGradient(x, y)
    length := math.Min(mag*5.0, cfg.MaxGradientLen)
    if length < 1 {
        return Curve{}, false
    }

    // subdivisão baseada em comprimento e spacing
    segCount := int(math.Ceil(length / float64(cfg.Spacing)))
    if segCount < 2 {
        segCount = 2
    }

    offsetMax := length * rand.Float64() / 3
    pts := make([][2]float64, segCount)
    for j := 0; j < segCount; j++ {
        t := float64(j) / float64(segCount-1)
        bx := float64(x) + math.Cos(angle)*length*t
        by := float64(y) + math.Sin(angle)*length*t

        n := noise(
            float64(x)*0.01+math.Cos(angle)*t*5,
            float64(y)*0.01+math.Sin(angle)*t*5,
            float64(frameCount)*0.01+t*10,
        )
        offset := (n*2 - 1) * offsetMax
        perp := angle + math.Pi/2 + cfg.GradientArtAngle

        px := bx + math.Cos(perp)*offset
        py := by + math.Sin(perp)*offset
        xi := clamp(int(math.Round(px)), 0, gosketch.GetWidth()-1)
        yi := clamp(int(math.Round(py)), 0, gosketch.GetHeight()-1)
        pts[j][0], pts[j][1] = float64(xi), float64(yi)
    }

    return Curve{pts: pts, color: gosketch.ColorFrom(img.GetPixel(x, y))}, true
}

Na prática, cada curva resultante tende a abraçar uma área de brilho semelhante a um anel ou espiral, daí a ideia de circularidade.

🎲 Ruído Perlin: O Toque de Aleatoriedade Orgânica

Para evitar que as curvas ficassem perfeitas demais (ou seja, estritamente coladas ao contorno da imagem), introduzi uma perturbação aleatória controlada na direção de cada passo da linha. A técnica escolhida foi usar Ruído Perlin – um tipo de ruído suave muito comum em arte generativa, por produzir variações graduais em vez de saltos bruscos.

// noise implementa um Perlin-like simplificado
func noise(x, y, z float64) float64 {
    return math.Sin(x*12.9898+y*78.233+z*37.719)*43758.5453 - 
           math.Floor(math.Sin(x*12.9898+y*78.233+z*37.719)*43758.5453)
}

No contexto do projeto, apliquei um ruído Perlin simplificado: em vez de gerar um campo 2D completo, utilizei uma função de ruído unidimensional ou de baixa resolução para obter um valor aleatório que mudasse lentamente de acordo com a posição (ou com o tempo).

Na prática, isso funciona assim: cada vez que calculo a direção tangente para a curva, somo um pequeno ângulo aleatório obtido do ruído. Esse ângulo extra desvia ligeiramente a linha de seu caminho "ideal". Como o ruído Perlin produz valores contínuos, as mudanças de direção não são abruptas, mas sim suaves e fluídas, dando um caráter orgânico ao traçado.

Esse toque de ruído foi fundamental para tornar o resultado visualmente interessante. As curvas deixaram de ser completamente previsíveis e ganharam personalidade: algumas ondulam levemente como se estivessem explorando o caminho, hesitando; outras fazem pequenas oscilações que lembram a irregularidade de um traço de lápis sobre papel de aquarela.

🎬 Animando o Sketch: Dando Vida às Linhas

Com as curvas geradas por meio do algoritmo acima, eu já tinha uma composição interessante. Mas queria ir além da imagem estática – queria movimento, um fator quase cinematográfico de ver essas linhas sendo desenhadas e evoluindo.

Foi aí que aproveitei o loop de desenho do gosketch para animar o processo em tempo real:

func draw() {
    frameCount++

    // Controle de ciclo de vida das curvas
    if frameCount%cfg.RemovalCount == 0 {
        cfg.MaxCirclesRadius = cfg.MaxCirclesRadius - 1
        cfg.MaxGradientLen = cfg.MaxGradientLen - 1
        if cfg.MaxCirclesRadius <= 0 || cfg.MaxGradientLen <= 0 {
            filename := fmt.Sprintf("1_%s.jpg", cfg.ImagePath)
            gosketch.SaveImage(filename)
            gosketch.NoLoop()
        }
    }

    // Gerar novas curvas respeitando spacing e limites
    attempts := 0
    for added := 0; added < cfg.NewCurvesPerFrame && len(curves) < cfg.MaxCurves && attempts < 200; attempts++ {
        x := rand.Intn(gosketch.GetWidth())
        y := rand.Intn(gosketch.GetHeight())

        // Verificar spacing mínimo entre curvas
        ok := true
        for _, c := range curves {
            x0, y0 := c.pts[0][0], c.pts[0][1]
            if math.Hypot(float64(x)-x0, float64(y)-y0) < float64(cfg.Spacing) {
                ok = false
                break
            }
        }
        if !ok {
            continue
        }

        // Escolher tipo de curva e criar
        if rand.Float64() < cfg.CircularChance {
            if c, ok := newCircularCurve(x, y); ok {
                curves = append(curves, c)
                added++
            }
        } else {
            if c, ok := newGradientCurve(x, y); ok {
                curves = append(curves, c)
                added++
            }
        }
    }

    // Desenhar segmento por segmento
    alive := curves[:0]
    for _, c := range curves {
        if c.currentSeg < len(c.pts)-1 {
            x0, y0 := c.pts[c.currentSeg][0], c.pts[c.currentSeg][1]
            x1, y1 := c.pts[c.currentSeg+1][0], c.pts[c.currentSeg+1][1]
            if math.Hypot(x1-x0, y1-y0) > 1.0 {
                gosketch.StrokeColor(gosketch.ParseColorValue(c.color))
                gosketch.StrokeWeight(2)
                gosketch.NoFill()
                gosketch.Line(x0, y0, x1, y1)
            }
            c.currentSeg++
            alive = append(alive, c)
        }
    }
    curves = alive

    gosketch.RedrawOnce()
}

Estratégias de Animação

  1. Desenho progressivo: Em vez de desenhar cada curva inteira de uma vez, dividi o desenho em etapas. A cada quadro (frame), avanço um passo em cada curva que está sendo traçada e desenho apenas aquele segmento novo.

  2. Agentes ativos: Mantive um conjunto de "agentes" ou pontos ativos percorrendo o campo de gradiente. A cada iteração do Draw(), calculei a direção (tangente + ruído) para cada agente e movi-o um passo adiante.

  3. Ciclo de vida: Para acentuar a sensação de vida, implementei um sistema onde as curvas têm um ciclo: nascem, crescem, e eventualmente desaparecem, dando lugar a novas curvas.

  4. Ruído temporal: Variei levemente o ruído Perlin ao longo do tempo, fazendo com que as curvas já desenhadas tremulassem sutilmente, como se tivessem movimento próprio.

Assistir à animação foi hipnotizante. No início a tela começa em branco. Então pequenos fragmentos de curvas surgem aqui e ali, expandindo-se como trepadeiras digitais. Em poucos segundos, os contornos principais da fotografia original começam a se revelar através dessas linhas vivas.

🎯 Otimizações e Controle de Performance

Para garantir que a animação rodasse suavemente, implementei várias otimizações:

Bias de Brilho

// Favorece spawn em áreas mais claras da imagem
if rand.Float64() > math.Pow(brightness(x, y), cfg.BrightnessBias) {
    return Curve{}, false
}

Controle de Densidade

// Spacing mínimo entre curvas para evitar sobreposição
for _, c := range curves {
    x0, y0 := c.pts[0][0], c.pts[0][1]
    if math.Hypot(float64(x)-x0, float64(y)-y0) < float64(cfg.Spacing) {
        ok = false
        break
    }
}

Limite de Curvas Ativas

// Máximo de curvas simultâneas na tela
for added := 0; added < cfg.NewCurvesPerFrame && len(curves) < cfg.MaxCurves && attempts < 200; attempts++ {
    // ...
}

🎨 Dois Tipos de Curvas: Gradiente e Circulares

O sistema suporta dois tipos distintos de curvas, cada uma com sua personalidade visual:

Curvas de Gradiente

Seguem a direção perpendicular ao gradiente da imagem, criando linhas que contornam formas e objetos. São mais fiéis aos contornos originais da foto.

Curvas Circulares

Criam padrões mais orgânicos e abstratos, com movimentos espiralados que adicionam dinamismo à composição:

func newCircularCurve(x, y int) (Curve, bool) {
    // Circunferência e segCount por spacing
    circ := 2 * math.Pi * cfg.MaxCirclesRadius * float64(cfg.MaxCirclesTurns)
    segCount := int(math.Ceil(circ / float64(cfg.Spacing)))
    
    pts := make([][2]float64, segCount)
    for j := 0; j < segCount; j++ {
        theta := float64(j) / float64(segCount) * 2 * math.Pi * float64(cfg.MaxCirclesTurns)
        n := noise(
            float64(x)+math.Cos(theta)*0.5,
            float64(y)+math.Sin(theta)*0.5,
            float64(frameCount)*0.02,
        )
        r := cfg.MaxCirclesRadius + (n*2-1)*cfg.MaxCirclesRadius*0.3
        px := float64(x) + math.Cos(theta)*r
        py := float64(y) + math.Sin(theta)*r
        // ...
    }
    
    return Curve{pts: pts, color: gosketch.ColorFrom(img.GetPixel(x, y))}, true
}

🔬 Reflexões: Simplicidade Algorítmica e Complexidade Visual

Ao concluir este experimento, fiquei refletindo sobre o poder da arte generativa nascida da interação entre algoritmos simples e imagens complexas. Por um lado, temos algumas regras básicas: seguir o gradiente, rotacionar, adicionar um pouquinho de ruído. Nada disso é terrivelmente complicado – são conceitos que cabem em poucas linhas de código e em ideias matemáticas acessíveis.

Por outro lado, ao aplicarmos essas regras a uma imagem rica em detalhes, obtemos um resultado que surpreende pela complexidade visual. Linhas suaves delineiam formas que o algoritmo nunca "soube" que existiam explicitamente – elas emergem da colaboração entre os dados da imagem e a lógica do código.

A Parceria Humano-Algoritmo

Essa experiência me lembrou por que amo criatividade computacional. Há uma espécie de parceria entre o programador e o algoritmo: eu defino um conjunto de procedimentos (um pequeno ecossistema de regras) e então solto esse mini-universo para interagir com os pixels de uma fotografia.

O que emerge dessa interação não é totalmente controlado por mim, nem totalmente aleatório – é uma forma de co-criação:

  • A imagem guia as curvas através de seus gradientes
  • O algoritmo traz sua interpretação desses gradientes em forma de desenho
  • O ruído garante que haja sempre um elemento único, não repetível, quase espontâneo em cada execução

Arqueologia Visual

Ao ver as curvas animadas contornando os motivos da foto, senti que estava revelando algo oculto – como arqueologia visual, escavando camadas de significado com uma pá algorítmica. Cada execução com uma imagem diferente gera uma obra nova, muitas vezes com um clima distinto (serena, caótica, melancólica, enérgica) dependendo da fonte.

E pequenas variações nos parâmetros (passo do agente, intensidade do ruído, quantidade de curvas) resultam em estilos de traço diversos, do esboço delicado ao arabesco exuberante.

🔧 Configuração e Parâmetros

O sistema é altamente configurável através de uma estrutura de configuração:

type Config struct {
    ImagePath         string  // caminho da imagem
    MaxCanvasWidth    int     // largura máxima do canvas
    Spacing           int     // distância mínima entre curvas
    NewCurvesPerFrame int     // quantas curvas tentamos criar a cada frame
    BrightnessBias    float64 // bias para spawn em áreas claras
    CircularChance    float64 // probabilidade de ser curva circular
    MaxCirclesRadius  float64 // raio máximo de curvas circulares
    MaxGradientLen    float64 // comprimento máximo de curvas de gradiente
    GradientArtAngle  float64 // ângulo artístico extra em gradiente
    MaxCurves         int     // limite total de curvas vivas
}

Esses parâmetros permitem ajustar finamente o comportamento do sistema, desde a densidade das curvas até o estilo visual final.

🎭 O Background Interativo deste Post

Enquanto você lê este artigo, observe o fascinante processo de geração de curvas se desenvolvendo no background! O que você está vendo é uma visualização em tempo real do algoritmo descrito neste post.

As curvas emergem gradualmente, seguindo os contornos invisíveis de uma imagem de fundo. Cada linha carrega consigo a cor extraída diretamente dos pixels da imagem original, criando uma paleta orgânica que evolui naturalmente.

Esta implementação demonstra os princípios fundamentais do projeto:

  • Análise de gradiente: As curvas seguem as direções de maior contraste na imagem
  • Ruído orgânico: Pequenas perturbações criam movimento natural e fluido
  • Ciclo de vida: Curvas nascem, crescem e eventualmente desaparecem
  • Interatividade: Sua presença influencia sutilmente o comportamento do sistema

Interaja com o background:

  • Mova o mouse para influenciar a direção das novas curvas que surgem próximas ao cursor
  • Clique em qualquer lugar para acelerar temporariamente a geração de curvas naquela região
  • Observe o ciclo completo: spawn → crescimento → maturidade → desvanecimento → renovação

Esta visualização captura perfeitamente a essência do projeto: a emergência de beleza através da interação entre matemática simples e complexidade visual, onde algoritmos revelam a poesia oculta nas imagens.

🔚 Conclusão

Em suma, este projeto mostrou que arte generativa pode brotar de ferramentas simples quando as conectamos a fontes ricas. Um algoritmo modesto, quando conversa com uma fotografia, pode pintar com linhas aquilo que os olhos não veem diretamente.

É quase uma poesia entre o cálculo e o pixel – versos traçados em curva, revelando que dentro de cada imagem há um mundo de possibilidades artísticas à espera de um olhar matemático para florescer.

O código completo deste projeto está disponível no meu repositório do GitHub, junto com exemplos e documentação para você experimentar com suas próprias imagens.

Até a próxima aventura criativa! Aqui continuo minha jornada, sempre explorando novos caminhos onde código e imaginação se encontram – porque, no fundo, programar também é poetizar, e cada sketch generativo é um pequeno poema visual escrito em linguagem de máquina e apreciado em linguagem humana.


Dica para artistas digitais: Experimente variar os parâmetros de ruído e bias de brilho para descobrir estilos completamente diferentes. Cada imagem tem sua personalidade única esperando para ser revelada!