메뉴
HN
Hacker News • 51일 전

가우시안으로 그림 그리기

IMP
6/10
핵심 요약

단순한 기계적 이미지 변환을 넘어, 이미지의 엣지(Edge) 정보를 활용해 붓터치 방향과 길이를 결정하는 디지털 페인팅 알고리즘을 소개합니다. 2D 가우시안 스플래팅(splatting)과 엣지 검출 기술을 결합하여, 기존의 느리고 결과물이 그림처럼 보이지 않던 경사 하강법(Gradient Descent) 방식의 한계를 극복했습니다.

번역된 본문

날짜: 2026년 8월 3일 제목: 가우시안으로 그림 그리기 (Painting with Gaussians)

작년에 저는 이미지를 픽셀 아트로 변환하기 위해, 단순히 고정된 격자를 이미지 위에 덮는 대신 이미지의 윤곽선(엣지)을 따라 격자가 변형되도록 하는 '엣지 인식 픽셀화 도구'를 만든 적이 있습니다. 이렇게 변형된 격자를 바탕으로 픽셀의 색상과 밝기를 결정하도록 한 방식은 윤곽선을 또렷하게 유지하고 디테일을 보존하는 데 효과적이었습니다. 나중에 저는 이러한 윤곽선 정보가 디지털 페인팅에도 똑같이 적용될 수 있음을 깨달았습니다.

페인팅 프로그램은 붓터치를 어디에 놓을지, 크기는 얼마로 할지, 그리고 어느 방향으로 흘러가게 할지 결정해야 합니다. 이러한 정보의 상당 부분은 이미 영역 간의 경계를 나타내는 윤곽선에 인코딩되어 있습니다. 이 윤곽선들은 붓이 따라 그리게 될 객체 영역의 외곽선이며, 윤곽선이 없다는 것은 넓은 붓터치 몇 번만으로 충분한 평평한 영역임을 의미합니다. 그래서 저는 이미지의 구조에서 도출된 형태가 실제 붓터치를 닮은 디지털 페인팅 스타일의 프로그램을 만들 수 있을지 확인해 보기로 했습니다. 제 목표는 사용자가 슬라이더를 드래그하면 그림이 눈앞에서 실시간으로 다시 그려지는 대화형 도구를 만드는 것이었습니다. 또한 이 프로젝트는 'Jolt'를 테스트해보고, 이를 통해 복잡한 프로젝트를 구축할 때 얼마나 잘 작동하는지 확인할 수 있는 좋은 기회였습니다. 이 글에서는 모든 것이 어떻게 구성되었는지 안내해 드릴 것입니다. 어떤 아이디어가 효과가 있었고 어떤 것이 아닌지도 살펴볼 것입니다. 가장 중요한 것은, 최종 결과물이 실제로 그림처럼 보이게 될지 알아보는 것입니다.

왜 가우시안(Gaussians)인가?

계산적 관점에서 고려해야 할 첫 번째 질문은 붓터치가 정확히 무엇인가입니다. 유화나 아크릴 물감의 붓터치는 길쭉한 자국으로, 중심부의 짙은 색상이 가장자리로 갈수록 퍼져나가며, 그 방향은 캔버스 위를 끌고 간 붓의 움직임 결과물입니다. 가장자리는 반투명하며 붓터치가 겹쳐지므로, 화가는 먼저 넓은 색상 블록을 칠한 다음 더 작고 반투명한 자국을 위에 덧칠하여 섬세한 디테일을 추가할 수 있습니다.

이러한 개념은 2D 가우시안 스플래팅(splatting)과 놀라울 정도로 잘 들어맞습니다. 가우시안 스플랫에는 붓터치가 떨어지는 위치를 나타내는 평균(mean), 어떻게 늘어나고 회전하는지를 나타내는 공분산 행렬(covariance matrix), 그리고 색상 및 불투명도가 있습니다. 공분산은 주축(major axis)이 붓터치를 따라 향하고 단축(minor axis)이 가로지르도록 하여 붓의 방향과 길이를 인코딩하는 데 사용될 수 있습니다. 알파(alpha) 값을 이용해 각 스플랫이 뒤에 있는 것을 가리는 방식의 표준 합성(over-compositing)으로 스플랫 필드를 렌더링하면, 자국이 쌓이고 섞이는 자연스러운 페인팅 모델과 유사한 효과를 얻을 수 있습니다. 물론 실제 물감과 똑같은 정확도를 얻을 수는 없으므로, 그 효과는 GIMP나 Krita 같은 도구를 사용한 디지털 페인팅에 더 가깝습니다.

이미 'DrawingWithGaussians'나 '2d-gaussian-splatting-Art'와 같이 이 아이디어를 구현한 사례들이 있습니다. 하지만 이들은 무작위 스플랫을 심어놓고 렌더링된 결과물이 대상 이미지와 같아 보일 때까지 위치, 모양, 색상을 반복적으로 조정하는 경사 하강법(gradient descent) 방식을 사용합니다. 이 방식은 잘 알려져 있지만 속도가 느리고 과정을 직관적으로 파악하기 어렵습니다. 가장 안 좋은 점은 최종 결과물이 어떤 형태의 그림이라기보다는 단순히 입력 이미지의 손실 많은(lossy) 재구축에 불과해진다는 것입니다.

저는 이미 이미지에서 윤곽선 정보를 추출하는 솔루션을 가지고 있었기 때문에, 이미지를 맹목적으로 변형(진화)시킬 필요성을 느끼지 못했습니다. 대신, 추출된 윤곽선은 디테일의 위치와 붓터치의 방향을 알려주므로 페인팅 과정을 안내하는 데 사용될 수 있습니다. 디테일의 밀도와 픽셀 색상만 있다면 경사 하강법에 의존하지 않고도 필요한 모든 정보를 얻을 수 있었습니다. 기본적으로 기존 이미지를 그대로 따라 그리기만 하면 되는 셈입니다. 도대체 얼마나 어렵겠습니까? 그래서 저는 'pixel = background + Σ(intensity × color)' 방식의 더하기(Additive) 혼합을 사용하는 래스터라이저를 구현하는 방향으로 시작했습니다. 결과적으로 이 접근 방식은 최적화기가 겹침을 보상하는 색상을 학습하기 때문에 피팅(fitting) 과정에서 잘 작동하는 것으로 드러났습니다.

원문 보기
원문 보기 (영어)
August 3, 2026 Painting with Gaussians Last year I built an edge-aware pixelation tool to turn images into pixel art by deforming a grid so that it follows image edges instead of naively using a fixed grid over the picture. Using an edge adapter grid bent to inform the color and brightness of the pixels worked well for keeping edges crisp and preserving details. I later realized the same edge information could also be applied in a context of digital painting. A painting program has to figure out where to put brush strokes, how big should they be, and which direction should they flow in. Much of that is already encoded in the edges since they mark the boundaries between regions. These are the contours around areas of objects that a brush would trace, and their absence indicates generally flat areas where a few broad strokes should suffice. And so, I set off to see if I could make a program that paints in the style of a digital painting where marks derived from the image structure would resemble brush strokes. My goal was to make an interactive tool where you drag sliders around and the painting reforms itself in front of you. The project was also a great opportunity for me to test drive Jolt and see how well it works for building a non-trivial project. In this post, I'll walk you through how it all came together. We'll see what ideas worked and which ones didn't. Most importantly, we'll find out whether the end result actually ends up resembling anything like a painting. Why Gaussians? The first question we need to consider is what a brush stroke is exactly in computational terms. A stroke of oil or acrylic is an elongated mark which has a center of color that fades toward its edges, and its orientation is the product of a brush being dragged across a canvas. It's translucent at the edges, and strokes overlap, allowing a painter to lay down broad blocks of color first, then build detail on top with smaller and more translucent marks to add finer detail. It turns out that a 2D Gaussian splat maps onto this idea surprisingly well. It has a mean which is where the stroke lands, a covariance matrix representing how it's stretched and rotated, along with a color and opacity. The covariance can be used to encode the brush direction and its elongation with the major axis pointing along the stroke, and the minor axis across it. Rendering a field of splats with standard over-compositing where each one is occluding what's behind it by its alpha gives you a similar effect to a natural painting model that allows marks to layer and blend together. Of course, you don't get the same fidelity of actual paint, so the effect is closer to digital painting using a tool like GIMP or Krita. There are already some implementations of this idea such as DrawingWithGaussians and 2d-gaussian-splatting-Art . However, both of them use a gradient descent approach where they seed random splats, and then iteratively nudge their positions, shapes, and colors until the rendered field has the appearance of a target image. That's the well known approach which is both slow and opaque. The worst part is that the end result ends up being simply a lossy reconstruction of the input image rather than looking like any sort of a painting. Since I already had the solution for extracting edge information from the image, I didn't see the point of evolving the image blindly. Instead, the extracted edges can be used to guide the painting process because they tell us where the details are along with the orientation of the strokes. Between detail density and pixel colors I had all the information that I'd need without having to resort to gradient descent. I'd basically just need to trace the existing image. How hard could it be really? So, I started following the reference rasterizer which uses additive blending where: pixel = background + Σ(intensity × color). Turns out, this approach works in the fitting regime because the optimizer learns colors that compensate for overlap. Unfortunately, seeding thousands of splats directly from pixel colors and rendering them additively creates a lot more overlap. With 1,200 splats on a 64×64 image, the sum hit 22.06 in some pixels, creating pure white blobs all over the image. Luckily, the problem can be solved by using the standard over-operator from alpha compositing to make each splat occlude what's behind it by its alpha so that the summed color never exceeds 1.0. Another benefit of this approach is that it cleanly separates color sampled from the image and opacity. Edges Tell You Where to Paint Every image in this section is the same photo run through the same pipeline, with one idea switched off at a time — same source, same stroke budget, same base size — so each step shows exactly what that one idea buys. I started using the following source photo, and recorded the progress as I continued to improve the app to illustrate what each idea buys. Let's see how the painting evolves as new tricks are added to the mix. I got a rather sad output which looked like a uniform mosaic with my initial renderer. Every splat had the same size, aspect ratio, and rotation, producing a regular grid of identical blobs. Not really looking like much of a painting so far. An actual painter would vary their strokes using a few broad strokes for flat regions such as the sky or a smooth surface. Then, along edges and in textured areas like eyes or fabric, a smaller brush gets used to make numerous finer strokes that follow the contours of the objects. One way to emulate this is by using a structure tensor to compute the image gradient, encoding how much and in which direction the color changes for each pixel. A 2×2 tensor is formed from the gradient outer product, and blurred over a neighborhood. Importantly, the tensor's eigenvectors will tell you three key things. The major eigenvector points across the contour, providing the direction of the strongest gradient. The minor eigenvector points along the edge, giving the direction of the brush stroke. And coherence, which is the ratio of eigenvalues, tells you whether the edge is a crisp contour or isotropic mush. Each splat gets its own covariance from this tensor at its position, and gets elongated along the edge, with elongation being proportional to coherence. Flat areas stay round while the edges become thin, directional strokes that trace the contours of the objects in the scene. This is the classic painterly rendering trick from Litwinowicz and Hertzmann. With it in place, the rendering started to resemble something that looks like brushwork if you squint a bit. Here, the fur and the hat brim pick up direction, and the whiskers start to appear. But here, I hit another problem because the structure tensor uses luminance gradients which are grayscale, making it blind to isoluminant color edges such as red lips against pale skin or a blue sign on a grey wall. Luckily, the Di Zenzo color tensor can be used to compute Sobel gradients per RGB channel. Their outer products can then be summed into one tensor, giving a chroma edge that drives orientation as strongly as a luma edge. Edges Aren't Enough While the structure tensor solves the problem of figuring out orientation, it tells you nothing about the density of the region. And without knowing that, it's not possible to figure out how many strokes need to go in that region and how small should they be. You might be thinking that you could just use edge strength to figure this out, and decide on the number of strokes to use based on that. But doing so ends up missing the texture of the objects entirely. For example, a gravel path has little coherent edge structure but lots of high-frequency detail that deserves its own fine marks. A smooth cheek, on the other hand, has a single contour edge while its interior should stay broad. Conversely, a faint-but-real edge, such as subtle fabric folds or distant tree branches, has low absolute gradient magnitude but still needs to be rendered