diff --git a/Dockerfile.worker b/Dockerfile.worker index 99a6b6d..f396f34 100644 --- a/Dockerfile.worker +++ b/Dockerfile.worker @@ -1,6 +1,17 @@ -# Stage 1: download and strip inkstitch -FROM python:3.11-slim AS builder - +# Stage 1: build Go binary +FROM golang:1.26-alpine AS go-builder +WORKDIR /src +COPY worker/go.mod worker/go.sum ./ +RUN go mod download +COPY worker/*.go ./ +RUN CGO_ENABLED=0 go build -o /worker . + +# Stage 2: build vtracer from Rust source +FROM rust:1-slim AS rust-builder +RUN cargo install vtracer + +# Stage 3: download and strip inkstitch +FROM debian:trixie-slim AS inkstitch-builder RUN apt-get update && apt-get install -y --no-install-recommends \ wget xz-utils ca-certificates \ && rm -rf /var/lib/apt/lists/* @@ -22,21 +33,16 @@ RUN ARCH=$(dpkg --print-architecture) && \ /opt/inkstitch/inkstitch/symbols \ /opt/inkstitch/inkstitch/dbus -# Stage 2: runtime -FROM python:3.11-slim +# Stage 4: runtime (no Python!) +FROM debian:trixie-slim RUN apt-get update && apt-get install -y --no-install-recommends \ inkscape libwayland-cursor0 libdrm2 libgbm1 libxcb-dri3-0 \ && rm -rf /var/lib/apt/lists/* -COPY --from=builder /opt/inkstitch /opt/inkstitch +COPY --from=go-builder /worker /usr/local/bin/worker +COPY --from=rust-builder /usr/local/cargo/bin/vtracer /usr/local/bin/vtracer +COPY --from=inkstitch-builder /opt/inkstitch /opt/inkstitch ENV INKSTITCH_BIN=/opt/inkstitch/inkstitch/bin/inkstitch -WORKDIR /app -COPY worker/requirements.txt . -RUN pip install --no-cache-dir -r requirements.txt - -COPY worker/pipeline/ pipeline/ -COPY worker/worker.py . - -CMD ["python", "worker.py"] +CMD ["worker"] diff --git a/TODO.md b/TODO.md index 4c2e11d..fa1e228 100644 --- a/TODO.md +++ b/TODO.md @@ -1,5 +1,7 @@ - [ ] Option for square/rectangle background to patch vs kiss-cut patch - [ ] Option for background color - [ ] Contour-following border shape (instead of always rounded rectangle) -- [ ] Rewrite in Go or Ruby (integration tests in tests/test_api.py define the contract) +- [x] Rewrite in Go or Ruby (integration tests in tests/test_api.py define the contract) - [ ] Document attempts/ folder with an example output for each approach, then un-gitignore +- [ ] File size options +- [ ] Stitch options (ie. size of patch in relation to stitches) diff --git a/web/main.go b/web/main.go index e22e909..1061f27 100644 --- a/web/main.go +++ b/web/main.go @@ -26,6 +26,10 @@ type QueuePayload struct { BorderColor string `json:"border_color"` ColorPrecision int `json:"color_precision"` Postprocess bool `json:"postprocess"` + BackgroundColor string `json:"background_color,omitempty"` + PatchShape string `json:"patch_shape,omitempty"` + OutputSize int `json:"output_size,omitempty"` + StitchDensity int `json:"stitch_density,omitempty"` } func main() { @@ -88,6 +92,19 @@ func handleCreatePatch(w http.ResponseWriter, r *http.Request) { postprocess = v != "false" && v != "0" && v != "no" } + backgroundColor := r.FormValue("background_color") + patchShape := r.FormValue("patch_shape") + + outputSize := 0 + if v := r.FormValue("output_size"); v != "" { + outputSize, _ = strconv.Atoi(v) + } + + stitchDensity := 0 + if v := r.FormValue("stitch_density"); v != "" { + stitchDensity, _ = strconv.Atoi(v) + } + jobID := uuid.New().String() ctx := context.Background() @@ -101,10 +118,14 @@ func handleCreatePatch(w http.ResponseWriter, r *http.Request) { } payload := QueuePayload{ - JobID: jobID, - BorderColor: borderColor, - ColorPrecision: colorPrecision, - Postprocess: postprocess, + JobID: jobID, + BorderColor: borderColor, + ColorPrecision: colorPrecision, + Postprocess: postprocess, + BackgroundColor: backgroundColor, + PatchShape: patchShape, + OutputSize: outputSize, + StitchDensity: stitchDensity, } payloadJSON, _ := json.Marshal(payload) diff --git a/worker/go.mod b/worker/go.mod new file mode 100644 index 0000000..5e8c0e0 --- /dev/null +++ b/worker/go.mod @@ -0,0 +1,14 @@ +module pic-to-patch-worker + +go 1.25.0 + +require ( + github.com/beevik/etree v1.6.0 + github.com/redis/go-redis/v9 v9.19.0 + golang.org/x/image v0.40.0 +) + +require ( + github.com/cespare/xxhash/v2 v2.3.0 // indirect + go.uber.org/atomic v1.11.0 // indirect +) diff --git a/worker/go.sum b/worker/go.sum new file mode 100644 index 0000000..6c291de --- /dev/null +++ b/worker/go.sum @@ -0,0 +1,26 @@ +github.com/beevik/etree v1.6.0 h1:u8Kwy8pp9D9XeITj2Z0XtA5qqZEmtJtuXZRQi+j03eE= +github.com/beevik/etree v1.6.0/go.mod h1:bh4zJxiIr62SOf9pRzN7UUYaEDa9HEKafK25+sLc0Gc= +github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= +github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c= +github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= +github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE= +github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.19.0 h1:XPVaaPSnG6RhYf7p+rmSa9zZfeVAnWsH5h3lxthOm/k= +github.com/redis/go-redis/v9 v9.19.0/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= +github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/image v0.40.0 h1:Tw4GyDXMo+daZN1znreBRC3VayR1aLFUyUEOLUdW1a8= +golang.org/x/image v0.40.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= diff --git a/worker/imgutil.go b/worker/imgutil.go new file mode 100644 index 0000000..e670690 --- /dev/null +++ b/worker/imgutil.go @@ -0,0 +1,546 @@ +package main + +import ( + "image" + "image/color" + "math" + "math/rand/v2" + "sort" +) + +// GrayF is a floating-point grayscale image. +type GrayF struct { + Pix []float64 + W, H int +} + +func newGrayF(w, h int) *GrayF { + return &GrayF{Pix: make([]float64, w*h), W: w, H: h} +} + +func newGrayFFill(w, h int, v float64) *GrayF { + g := newGrayF(w, h) + for i := range g.Pix { + g.Pix[i] = v + } + return g +} + +func (g *GrayF) at(x, y int) float64 { + if x < 0 || x >= g.W || y < 0 || y >= g.H { + return 0 + } + return g.Pix[y*g.W+x] +} + +func (g *GrayF) set(x, y int, v float64) { + if x >= 0 && x < g.W && y >= 0 && y < g.H { + g.Pix[y*g.W+x] = v + } +} + +func (g *GrayF) clone() *GrayF { + out := newGrayF(g.W, g.H) + copy(out.Pix, g.Pix) + return out +} + +// RGBF is a floating-point RGB image (interleaved R,G,B). +type RGBF struct { + Pix []float64 + W, H int +} + +func newRGBF(w, h int) *RGBF { + return &RGBF{Pix: make([]float64, w*h*3), W: w, H: h} +} + +func (img *RGBF) at(x, y, c int) float64 { + if x < 0 || x >= img.W || y < 0 || y >= img.H { + return 0 + } + return img.Pix[(y*img.W+x)*3+c] +} + +func (img *RGBF) set(x, y, c int, v float64) { + if x >= 0 && x < img.W && y >= 0 && y < img.H { + img.Pix[(y*img.W+x)*3+c] = v + } +} + +func (img *RGBF) clone() *RGBF { + out := newRGBF(img.W, img.H) + copy(out.Pix, img.Pix) + return out +} + +func rgbfFromImage(src image.Image) *RGBF { + b := src.Bounds() + w, h := b.Dx(), b.Dy() + img := newRGBF(w, h) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + r, g, bl, _ := src.At(x+b.Min.X, y+b.Min.Y).RGBA() + idx := (y*w + x) * 3 + img.Pix[idx] = float64(r) / 257.0 + img.Pix[idx+1] = float64(g) / 257.0 + img.Pix[idx+2] = float64(bl) / 257.0 + } + } + return img +} + +func (img *RGBF) toImage() *image.NRGBA { + out := image.NewNRGBA(image.Rect(0, 0, img.W, img.H)) + for y := 0; y < img.H; y++ { + for x := 0; x < img.W; x++ { + idx := (y*img.W + x) * 3 + out.SetNRGBA(x, y, color.NRGBA{ + R: clampByte(img.Pix[idx]), + G: clampByte(img.Pix[idx+1]), + B: clampByte(img.Pix[idx+2]), + A: 255, + }) + } + } + return out +} + +// Channel extraction +func luminance(img *RGBF) *GrayF { + out := newGrayF(img.W, img.H) + for i := 0; i < img.W*img.H; i++ { + out.Pix[i] = 0.299*img.Pix[i*3] + 0.587*img.Pix[i*3+1] + 0.114*img.Pix[i*3+2] + } + return out +} + +func meanRGB(img *RGBF) *GrayF { + out := newGrayF(img.W, img.H) + for i := 0; i < img.W*img.H; i++ { + out.Pix[i] = (img.Pix[i*3] + img.Pix[i*3+1] + img.Pix[i*3+2]) / 3.0 + } + return out +} + +// Gaussian blur (separable, clamp-to-edge) +func gaussianKernel(sigma float64) []float64 { + radius := int(math.Ceil(sigma * 3)) + if radius < 1 { + radius = 1 + } + size := 2*radius + 1 + kernel := make([]float64, size) + sum := 0.0 + for i := 0; i < size; i++ { + x := float64(i - radius) + kernel[i] = math.Exp(-x * x / (2 * sigma * sigma)) + sum += kernel[i] + } + for i := range kernel { + kernel[i] /= sum + } + return kernel +} + +func clampIdx(v, max int) int { + if v < 0 { + return 0 + } + if v >= max { + return max - 1 + } + return v +} + +func gaussBlur(src *GrayF, sigma float64) *GrayF { + if sigma <= 0 { + return src.clone() + } + kernel := gaussianKernel(sigma) + radius := len(kernel) / 2 + w, h := src.W, src.H + + tmp := newGrayF(w, h) + for y := 0; y < h; y++ { + row := y * w + for x := 0; x < w; x++ { + sum := 0.0 + for k := -radius; k <= radius; k++ { + sum += src.Pix[row+clampIdx(x+k, w)] * kernel[k+radius] + } + tmp.Pix[row+x] = sum + } + } + + out := newGrayF(w, h) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + sum := 0.0 + for k := -radius; k <= radius; k++ { + sum += tmp.Pix[clampIdx(y+k, h)*w+x] * kernel[k+radius] + } + out.Pix[y*w+x] = sum + } + } + return out +} + +func gaussBlurRGB(src *RGBF, sigma float64) *RGBF { + if sigma <= 0 { + return src.clone() + } + channels := make([]*GrayF, 3) + for c := 0; c < 3; c++ { + ch := newGrayF(src.W, src.H) + for i := 0; i < src.W*src.H; i++ { + ch.Pix[i] = src.Pix[i*3+c] + } + channels[c] = gaussBlur(ch, sigma) + } + out := newRGBF(src.W, src.H) + for i := 0; i < src.W*src.H; i++ { + out.Pix[i*3] = channels[0].Pix[i] + out.Pix[i*3+1] = channels[1].Pix[i] + out.Pix[i*3+2] = channels[2].Pix[i] + } + return out +} + +// Directional gaussian blur (blur more along one axis) +func gaussBlurDirectional(src *GrayF, sigmaX, sigmaY float64) *GrayF { + if sigmaX <= 0 && sigmaY <= 0 { + return src.clone() + } + w, h := src.W, src.H + + tmp := src.clone() + if sigmaX > 0 { + kx := gaussianKernel(sigmaX) + rx := len(kx) / 2 + t2 := newGrayF(w, h) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + sum := 0.0 + for k := -rx; k <= rx; k++ { + sum += tmp.Pix[y*w+clampIdx(x+k, w)] * kx[k+rx] + } + t2.Pix[y*w+x] = sum + } + } + tmp = t2 + } + if sigmaY > 0 { + ky := gaussianKernel(sigmaY) + ry := len(ky) / 2 + out := newGrayF(w, h) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + sum := 0.0 + for k := -ry; k <= ry; k++ { + sum += tmp.Pix[clampIdx(y+k, h)*w+x] * ky[k+ry] + } + out.Pix[y*w+x] = sum + } + } + return out + } + return tmp +} + +// Sobel filters +func sobelX(src *GrayF) *GrayF { + out := newGrayF(src.W, src.H) + w := src.W + for y := 1; y < src.H-1; y++ { + for x := 1; x < src.W-1; x++ { + v := -src.Pix[(y-1)*w+(x-1)] + src.Pix[(y-1)*w+(x+1)] + + -2*src.Pix[y*w+(x-1)] + 2*src.Pix[y*w+(x+1)] + + -src.Pix[(y+1)*w+(x-1)] + src.Pix[(y+1)*w+(x+1)] + out.Pix[y*w+x] = v + } + } + return out +} + +func sobelY(src *GrayF) *GrayF { + out := newGrayF(src.W, src.H) + w := src.W + for y := 1; y < src.H-1; y++ { + for x := 1; x < src.W-1; x++ { + v := -src.Pix[(y-1)*w+(x-1)] - 2*src.Pix[(y-1)*w+x] - src.Pix[(y-1)*w+(x+1)] + + src.Pix[(y+1)*w+(x-1)] + 2*src.Pix[(y+1)*w+x] + src.Pix[(y+1)*w+(x+1)] + out.Pix[y*w+x] = v + } + } + return out +} + +// Morphological operations +func binaryDilate(src *GrayF, iterations int) *GrayF { + cur := src.clone() + w, h := cur.W, cur.H + for iter := 0; iter < iterations; iter++ { + next := newGrayF(w, h) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + hit := false + for dy := -1; dy <= 1; dy++ { + for dx := -1; dx <= 1; dx++ { + yy, xx := y+dy, x+dx + if yy >= 0 && yy < h && xx >= 0 && xx < w && cur.Pix[yy*w+xx] > 0.5 { + hit = true + } + } + } + if hit { + next.Pix[y*w+x] = 1 + } + } + } + cur = next + } + return cur +} + +func binaryErode(src *GrayF, iterations int) *GrayF { + cur := src.clone() + w, h := cur.W, cur.H + for iter := 0; iter < iterations; iter++ { + next := newGrayF(w, h) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + all := true + for dy := -1; dy <= 1; dy++ { + for dx := -1; dx <= 1; dx++ { + yy, xx := y+dy, x+dx + if yy < 0 || yy >= h || xx < 0 || xx >= w || cur.Pix[yy*w+xx] <= 0.5 { + all = false + } + } + } + if all { + next.Pix[y*w+x] = 1 + } + } + } + cur = next + } + return cur +} + +func binaryClose(src *GrayF, iterations int) *GrayF { + return binaryErode(binaryDilate(src, iterations), iterations) +} + +// Distance transform (Meijster et al. exact EDT) +func distanceTransformEDT(binary *GrayF) *GrayF { + w, h := binary.W, binary.H + inf := float64(w + h) + + g := make([]float64, w*h) + for x := 0; x < w; x++ { + if binary.Pix[x] > 0.5 { + g[x] = 0 + } else { + g[x] = inf + } + for y := 1; y < h; y++ { + if binary.Pix[y*w+x] > 0.5 { + g[y*w+x] = 0 + } else { + g[y*w+x] = g[(y-1)*w+x] + 1 + } + } + for y := h - 2; y >= 0; y-- { + if g[(y+1)*w+x]+1 < g[y*w+x] { + g[y*w+x] = g[(y+1)*w+x] + 1 + } + } + } + + out := newGrayF(w, h) + s := make([]int, w) + t := make([]int, w) + + edtF := func(x, i int, gi float64) float64 { + dx := float64(x - i) + return dx*dx + gi*gi + } + + for y := 0; y < h; y++ { + q := 0 + s[0] = 0 + t[0] = 0 + + for u := 1; u < w; u++ { + for q >= 0 && edtF(t[q], s[q], g[y*w+s[q]]) > edtF(t[q], u, g[y*w+u]) { + q-- + } + if q < 0 { + q = 0 + s[0] = u + } else { + sep := edtSep(s[q], u, g[y*w+s[q]], g[y*w+u], w) + if sep < w { + q++ + s[q] = u + t[q] = sep + } + } + } + + for u := w - 1; u >= 0; u-- { + out.Pix[y*w+u] = math.Sqrt(edtF(u, s[q], g[y*w+s[q]])) + if u == t[q] && q > 0 { + q-- + } + } + } + + return out +} + +func edtSep(i, u int, gi, gu float64, _ int) int { + num := float64(u*u-i*i) + gu*gu - gi*gi + den := 2.0 * float64(u-i) + return int(math.Floor(num/den)) + 1 +} + +// Shift image by (dx, dy) with bilinear interpolation +func shiftImage(src *GrayF, dx, dy float64) *GrayF { + out := newGrayF(src.W, src.H) + for y := 0; y < src.H; y++ { + for x := 0; x < src.W; x++ { + sx := float64(x) - dx + sy := float64(y) - dy + x0 := int(math.Floor(sx)) + y0 := int(math.Floor(sy)) + fx := sx - float64(x0) + fy := sy - float64(y0) + v := src.at(x0, y0)*(1-fx)*(1-fy) + + src.at(x0+1, y0)*fx*(1-fy) + + src.at(x0, y0+1)*(1-fx)*fy + + src.at(x0+1, y0+1)*fx*fy + out.Pix[y*src.W+x] = v + } + } + return out +} + +// Noise generation +func normalNoise(w, h int) *GrayF { + out := newGrayF(w, h) + for i := range out.Pix { + out.Pix[i] = rand.NormFloat64() + } + return out +} + +func normalNoiseRGB(w, h int) *RGBF { + out := newRGBF(w, h) + for i := range out.Pix { + out.Pix[i] = rand.NormFloat64() + } + return out +} + +// Utility +func clampF(v, lo, hi float64) float64 { + if v < lo { + return lo + } + if v > hi { + return hi + } + return v +} + +func clampByte(v float64) uint8 { + if v < 0 { + return 0 + } + if v > 255 { + return 255 + } + return uint8(v + 0.5) +} + +func percentile(data *GrayF, mask *GrayF, p float64) float64 { + var vals []float64 + for i, v := range mask.Pix { + if v > 0.5 { + vals = append(vals, data.Pix[i]) + } + } + if len(vals) == 0 { + return 1.0 + } + sort.Float64s(vals) + idx := p / 100.0 * float64(len(vals)-1) + lo := int(math.Floor(idx)) + hi := int(math.Ceil(idx)) + if hi >= len(vals) { + hi = len(vals) - 1 + } + frac := idx - float64(lo) + return vals[lo]*(1-frac) + vals[hi]*frac +} + +func absMax(g *GrayF) float64 { + m := 0.0 + for _, v := range g.Pix { + if math.Abs(v) > m { + m = math.Abs(v) + } + } + return m +} + +// Upscale a small image by repeating pixels then blurring +func upscaleBlur(src *GrayF, targetW, targetH int, scale int, sigma float64) *GrayF { + // Nearest-neighbor upscale + up := newGrayF(targetW, targetH) + for y := 0; y < targetH; y++ { + sy := y / scale + if sy >= src.H { + sy = src.H - 1 + } + for x := 0; x < targetW; x++ { + sx := x / scale + if sx >= src.W { + sx = src.W - 1 + } + up.Pix[y*targetW+x] = src.Pix[sy*src.W+sx] + } + } + if sigma > 0 { + return gaussBlur(up, sigma) + } + return up +} + +func upscaleBlurRGB(src *RGBF, targetW, targetH int, scale int, sigma float64) *RGBF { + up := newRGBF(targetW, targetH) + for y := 0; y < targetH; y++ { + sy := y / scale + if sy >= src.H { + sy = src.H - 1 + } + for x := 0; x < targetW; x++ { + sx := x / scale + if sx >= src.W { + sx = src.W - 1 + } + di := (y*targetW + x) * 3 + si := (sy*src.W + sx) * 3 + up.Pix[di] = src.Pix[si] + up.Pix[di+1] = src.Pix[si+1] + up.Pix[di+2] = src.Pix[si+2] + } + } + if sigma > 0 { + return gaussBlurRGB(up, sigma) + } + return up +} diff --git a/worker/main.go b/worker/main.go new file mode 100644 index 0000000..a305744 --- /dev/null +++ b/worker/main.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "strconv" + "time" + + "github.com/redis/go-redis/v9" +) + +var ( + rdb *redis.Client + resultTTL time.Duration + ctx = context.Background() +) + +type JobPayload struct { + JobID string `json:"job_id"` + BorderColor string `json:"border_color"` + ColorPrecision int `json:"color_precision"` + Postprocess bool `json:"postprocess"` +} + +func main() { + redisURL := getenv("REDIS_URL", "redis://localhost:6379") + ttlSec, _ := strconv.Atoi(getenv("RESULT_TTL", "3600")) + resultTTL = time.Duration(ttlSec) * time.Second + + opt, err := redis.ParseURL(redisURL) + if err != nil { + log.Fatalf("bad REDIS_URL: %v", err) + } + rdb = redis.NewClient(opt) + + if err := rdb.Ping(ctx).Err(); err != nil { + log.Fatalf("redis not reachable: %v", err) + } + log.Println("Worker ready, listening on queue 'patches'") + + for { + result, err := rdb.BRPop(ctx, 0, "patches").Result() + if err != nil { + log.Printf("brpop error: %v", err) + continue + } + + var job JobPayload + if err := json.Unmarshal([]byte(result[1]), &job); err != nil { + log.Printf("bad payload: %v", err) + continue + } + + log.Printf("Processing %s", job.JobID) + if err := processJob(job); err != nil { + log.Printf(" failed: %v", err) + } else { + log.Println(" done") + } + } +} + +func processJob(job JobPayload) error { + inputBytes, err := rdb.Get(ctx, fmt.Sprintf("job:%s:input", job.JobID)).Bytes() + if err != nil { + return setFailed(job.JobID, "input not found in redis") + } + + ext, err := rdb.Get(ctx, fmt.Sprintf("job:%s:ext", job.JobID)).Result() + if err != nil || ext == "" { + ext = "png" + } + + resultBytes, err := runPipeline(inputBytes, ext, job.BorderColor, job.ColorPrecision, job.Postprocess) + if err != nil { + return setFailed(job.JobID, err.Error()) + } + + pipe := rdb.Pipeline() + pipe.SetEx(ctx, fmt.Sprintf("job:%s:result", job.JobID), resultBytes, resultTTL) + pipe.SetEx(ctx, fmt.Sprintf("job:%s:status", job.JobID), "complete", resultTTL) + pipe.Del(ctx, fmt.Sprintf("job:%s:input", job.JobID)) + _, err = pipe.Exec(ctx) + return err +} + +func setFailed(jobID string, errMsg string) error { + pipe := rdb.Pipeline() + pipe.SetEx(ctx, fmt.Sprintf("job:%s:status", jobID), "failed", resultTTL) + pipe.SetEx(ctx, fmt.Sprintf("job:%s:error", jobID), errMsg, resultTTL) + pipe.Exec(ctx) + return fmt.Errorf("%s", errMsg) +} + +func getenv(key, fallback string) string { + if v := os.Getenv(key); v != "" { + return v + } + return fallback +} diff --git a/worker/pipeline.go b/worker/pipeline.go new file mode 100644 index 0000000..c3dd27d --- /dev/null +++ b/worker/pipeline.go @@ -0,0 +1,163 @@ +package main + +import ( + "bytes" + "fmt" + "image" + _ "image/gif" + _ "image/jpeg" + "image/png" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + + "golang.org/x/image/draw" +) + +const maxImageDim = 500 + +func runPipeline(input []byte, ext string, borderColor string, colorPrecision int, postprocess bool) ([]byte, error) { + tmpDir, err := os.MkdirTemp("", "p2p_") + if err != nil { + return nil, err + } + defer os.RemoveAll(tmpDir) + + inputPath := filepath.Join(tmpDir, "input."+ext) + if err := os.WriteFile(inputPath, input, 0644); err != nil { + return nil, err + } + + isSVG := strings.EqualFold(ext, "svg") + + var embroideryPath string + if isSVG { + embroideryPath = filepath.Join(tmpDir, "embroidery.svg") + if err := addInkstitchParams(inputPath, embroideryPath, borderColor); err != nil { + return nil, fmt.Errorf("inkstitch params: %w", err) + } + } else { + resizedPath := filepath.Join(tmpDir, "resized.png") + if err := resizeImage(inputPath, resizedPath); err != nil { + return nil, fmt.Errorf("resize: %w", err) + } + + vectorizedPath := filepath.Join(tmpDir, "vectorized.svg") + if err := vectorize(resizedPath, vectorizedPath, colorPrecision); err != nil { + return nil, fmt.Errorf("vectorize: %w", err) + } + + embroideryPath = filepath.Join(tmpDir, "embroidery.svg") + if err := addInkstitchParams(vectorizedPath, embroideryPath, borderColor); err != nil { + return nil, fmt.Errorf("inkstitch params: %w", err) + } + } + + renderPath := filepath.Join(tmpDir, "render.png") + if err := renderInkstitch(embroideryPath, renderPath); err != nil { + return nil, err + } + + if !postprocess { + return os.ReadFile(renderPath) + } + + outputPath := filepath.Join(tmpDir, "patch.png") + if err := postprocessPhotorealistic(renderPath, outputPath); err != nil { + return nil, fmt.Errorf("postprocess: %w", err) + } + + return os.ReadFile(outputPath) +} + +func resizeImage(inputPath, outputPath string) error { + f, err := os.Open(inputPath) + if err != nil { + return err + } + defer f.Close() + + src, _, err := image.Decode(f) + if err != nil { + return err + } + + bounds := src.Bounds() + w, h := bounds.Dx(), bounds.Dy() + + maxDim := w + if h > maxDim { + maxDim = h + } + + if maxDim <= maxImageDim { + out, err := os.Create(outputPath) + if err != nil { + return err + } + defer out.Close() + return png.Encode(out, src) + } + + ratio := float64(maxImageDim) / float64(maxDim) + newW := int(float64(w) * ratio) + newH := int(float64(h) * ratio) + + dst := image.NewRGBA(image.Rect(0, 0, newW, newH)) + draw.CatmullRom.Scale(dst, dst.Bounds(), src, bounds, draw.Over, nil) + + out, err := os.Create(outputPath) + if err != nil { + return err + } + defer out.Close() + return png.Encode(out, dst) +} + +func vectorize(inputPath, outputPath string, colorPrecision int) error { + cmd := exec.Command("vtracer", + "--input", inputPath, + "--output", outputPath, + "--colormode", "color", + "--hierarchical", "stacked", + "--filter_speckle", "4", + "--color_precision", strconv.Itoa(colorPrecision), + "--corner_threshold", "60", + ) + output, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("%v: %s", err, output) + } + + if _, err := os.Stat(outputPath); err != nil { + return fmt.Errorf("vtracer produced no output") + } + return nil +} + +func renderInkstitch(svgPath, outputPath string) error { + inkstitchBin := getenv("INKSTITCH_BIN", "/opt/inkstitch/inkstitch/bin/inkstitch") + + var stdout bytes.Buffer + var stderr bytes.Buffer + + cmd := exec.Command(inkstitchBin, "--extension=png_realistic", svgPath) + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + errStr := stderr.String() + if len(errStr) > 300 { + errStr = errStr[:300] + } + return fmt.Errorf("inkstitch render failed: %s", errStr) + } + + if stdout.Len() == 0 { + return fmt.Errorf("inkstitch produced empty output") + } + + return os.WriteFile(outputPath, stdout.Bytes(), 0644) +} diff --git a/worker/pipeline/__init__.py b/worker/pipeline/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/worker/pipeline/convert.py b/worker/pipeline/convert.py deleted file mode 100644 index f5b3158..0000000 --- a/worker/pipeline/convert.py +++ /dev/null @@ -1,116 +0,0 @@ -""" -Core pipeline: image → embroidered patch PNG. - -This is the throwaway python version. The interface is: - convert(input_path, output_path, border_color="#0a0a14", color_precision=8) - -That's it. Everything else is internal. -""" - -import os -import subprocess -import tempfile -from pathlib import Path - -from PIL import Image -import vtracer - -from .svg2patch import add_inkstitch_params, render_inkstitch - -MAX_IMAGE_DIM = int(os.environ.get("MAX_IMAGE_DIM", "500")) - - -def convert(input_path, output_path, border_color="#0a0a14", color_precision=8, postprocess=True): - input_path = Path(input_path) - output_path = Path(output_path) - - with tempfile.TemporaryDirectory(prefix="p2p_") as tmpdir: - tmpdir = Path(tmpdir) - - resized = tmpdir / "resized.png" - _resize(input_path, resized) - - vtracer_svg = tmpdir / "vectorized.svg" - _vectorize(resized, vtracer_svg, color_precision) - - embroidery_svg = tmpdir / "embroidery.svg" - add_inkstitch_params(vtracer_svg, embroidery_svg, border_color=border_color) - - stitch_png = tmpdir / "stitch.png" - if not render_inkstitch(embroidery_svg, stitch_png): - raise RuntimeError("inkstitch render failed") - - if postprocess: - _postprocess(stitch_png, output_path) - else: - import shutil - shutil.copy2(stitch_png, output_path) - - return output_path - - -def convert_svg(input_svg, output_path, border_color="#0a0a14", postprocess=True): - input_svg = Path(input_svg) - output_path = Path(output_path) - - with tempfile.TemporaryDirectory(prefix="p2p_") as tmpdir: - tmpdir = Path(tmpdir) - - embroidery_svg = tmpdir / "embroidery.svg" - add_inkstitch_params(input_svg, embroidery_svg, border_color=border_color) - - stitch_png = tmpdir / "stitch.png" - if not render_inkstitch(embroidery_svg, stitch_png): - raise RuntimeError("inkstitch render failed") - - if postprocess: - _postprocess(stitch_png, output_path) - else: - import shutil - shutil.copy2(stitch_png, output_path) - - return output_path - - -def _resize(input_path, output_path): - img = Image.open(input_path) - if img.mode == "P": - img = img.convert("RGBA") - w, h = img.size - if max(w, h) > MAX_IMAGE_DIM: - ratio = MAX_IMAGE_DIM / max(w, h) - img = img.resize((int(w * ratio), int(h * ratio)), Image.Resampling.LANCZOS) - img.save(str(output_path)) - - -def _vectorize(input_image, output_svg, color_precision=8): - vtracer.convert_image_to_svg_py( - image_path=str(input_image), - out_path=str(output_svg), - colormode="color", - hierarchical="stacked", - filter_speckle=4, - color_precision=color_precision, - corner_threshold=60, - length_threshold=4, - splice_threshold=45, - ) - if not output_svg.exists(): - raise RuntimeError("vtracer produced no output") - - -def _postprocess(stitch_png, output_path): - script = Path(__file__).parent / "photorealistic.py" - if not script.exists(): - import shutil - shutil.copy2(stitch_png, output_path) - return - - import sys - result = subprocess.run( - [sys.executable, str(script), str(stitch_png), str(output_path)], - capture_output=True, text=True, timeout=120, - ) - if result.returncode != 0 or not output_path.exists(): - import shutil - shutil.copy2(stitch_png, output_path) diff --git a/worker/pipeline/photorealistic.py b/worker/pipeline/photorealistic.py deleted file mode 100644 index ab92a19..0000000 --- a/worker/pipeline/photorealistic.py +++ /dev/null @@ -1,764 +0,0 @@ -#!/usr/bin/env python3 -""" -Advanced post-processing pipeline that makes inkstitch renders look like -photographs of real embroidered patches. - -Pipeline: - 1. Extract patch mask from white background - 2. Compute per-pixel normal map from stitch texture gradients - 3. Estimate local thread direction via structure tensor - 4. Apply Blinn-Phong shading with Kajiya-Kay anisotropic specular - 5. Add per-thread micro-highlight variation - 6. Generate felt/twill backing fabric texture - 7. Create realistic drop shadow (contact + cast) - 8. Simulate merrow/overlock edge border - 9. Add patch thickness bevel (embossed raised edge) - 10. Composite everything - 11. Photographic finishing: DOF, color grade, vignette, grain -""" - -import sys -import numpy as np -from pathlib import Path -from PIL import Image, ImageFilter, ImageEnhance, ImageDraw -from scipy import ndimage -from scipy.ndimage import gaussian_filter, uniform_filter - - -# ───────────────────────────────────────────── -# 1. Mask extraction -# ───────────────────────────────────────────── - -def extract_patch_mask(img_arr, threshold=235): - """ - Extract a mask of the embroidered region vs white background. - Returns float mask [0, 1] with anti-aliased soft edges. - """ - is_bg = np.all(img_arr[:, :, :3] > threshold, axis=2) - mask = (~is_bg).astype(np.float32) - - # Close small holes inside the patch - mask = ndimage.binary_closing(mask > 0.5, iterations=3).astype(np.float32) - # Dilate slightly to catch anti-aliased edge pixels - mask = ndimage.binary_dilation(mask > 0.5, iterations=1).astype(np.float32) - - # Soft edge via gaussian blur - mask_soft = gaussian_filter(mask, sigma=1.2) - return np.clip(mask_soft, 0, 1) - - -# ───────────────────────────────────────────── -# 2. Normal map computation -# ───────────────────────────────────────────── - -def compute_normal_map(img_arr, mask, strength=2.0): - """ - Derive per-pixel surface normals from stitch texture luminance gradients. - Multi-scale: fine captures thread ridges, medium captures stitch rows. - """ - lum = (0.299 * img_arr[:, :, 0] + - 0.587 * img_arr[:, :, 1] + - 0.114 * img_arr[:, :, 2]).astype(np.float64) - - # Fine scale - individual thread ridges - gx_fine = ndimage.sobel(lum, axis=1) - gy_fine = ndimage.sobel(lum, axis=0) - - # Medium scale - stitch row structure - lum_med = gaussian_filter(lum, sigma=1.2) - gx_med = ndimage.sobel(lum_med, axis=1) - gy_med = ndimage.sobel(lum_med, axis=0) - - # Coarse scale - broad curvature - lum_coarse = gaussian_filter(lum, sigma=3.0) - gx_coarse = ndimage.sobel(lum_coarse, axis=1) - gy_coarse = ndimage.sobel(lum_coarse, axis=0) - - gx = 0.5 * gx_fine + 0.35 * gx_med + 0.15 * gx_coarse - gy = 0.5 * gy_fine + 0.35 * gy_med + 0.15 * gy_coarse - - gx *= strength - gy *= strength - - h, w = lum.shape - normals = np.zeros((h, w, 3), dtype=np.float64) - normals[:, :, 0] = -gx - normals[:, :, 1] = -gy - normals[:, :, 2] = 255.0 - - length = np.sqrt(np.sum(normals ** 2, axis=2, keepdims=True)) - normals /= np.maximum(length, 1e-8) - normals *= mask[:, :, np.newaxis] - - return normals - - -# ───────────────────────────────────────────── -# 3. Thread direction estimation -# ───────────────────────────────────────────── - -def estimate_thread_direction(img_arr, mask, block_size=12): - """ - Estimate local thread direction via structure tensor analysis. - Returns angle map in radians for anisotropic specular. - """ - lum = (0.299 * img_arr[:, :, 0] + - 0.587 * img_arr[:, :, 1] + - 0.114 * img_arr[:, :, 2]).astype(np.float64) - - gx = ndimage.sobel(lum, axis=1) - gy = ndimage.sobel(lum, axis=0) - - sigma = block_size / 2.0 - Jxx = gaussian_filter(gx * gx, sigma=sigma) - Jxy = gaussian_filter(gx * gy, sigma=sigma) - Jyy = gaussian_filter(gy * gy, sigma=sigma) - - # Dominant gradient direction - angle = 0.5 * np.arctan2(2.0 * Jxy, Jxx - Jyy + 1e-10) - # Thread runs perpendicular to gradient - thread_angle = angle + np.pi / 2.0 - - # Also compute anisotropy strength (how directional the texture is) - trace = Jxx + Jyy + 1e-10 - diff = np.sqrt((Jxx - Jyy) ** 2 + 4 * Jxy ** 2) - anisotropy = diff / trace # 0 = isotropic, 1 = perfectly directional - - return thread_angle, anisotropy - - -# ───────────────────────────────────────────── -# 4. Blinn-Phong + anisotropic shading -# ───────────────────────────────────────────── - -def apply_lighting(img_arr, normals, mask, thread_angle, anisotropy, - light_dir=(0.25, -0.35, 0.90), - ambient=0.55, diffuse_strength=0.35, - specular_strength=0.12, shininess=18.0, - aniso_strength=0.08, aniso_shininess=30.0): - """ - Apply Blinn-Phong shading with Kajiya-Kay anisotropic specular. - - The key insight: we DON'T want to darken the patch too much. Real embroidery - is bright and saturated under normal lighting. The shading should add - subtle variation and highlights, not dramatically change overall brightness. - - We use a "detail lighting" approach: - final = original * (ambient + diffuse) + specular_white - where ambient+diffuse averages close to 1.0 across the image. - """ - result = img_arr.astype(np.float64).copy() - - light = np.array(light_dir, dtype=np.float64) - light /= np.linalg.norm(light) - - view = np.array([0.0, 0.0, 1.0]) - half_vec = light + view - half_vec /= np.linalg.norm(half_vec) - - # --- Diffuse --- - n_dot_l = (normals[:, :, 0] * light[0] + - normals[:, :, 1] * light[1] + - normals[:, :, 2] * light[2]) - n_dot_l = np.clip(n_dot_l, 0, 1) - - # Wrap diffuse to soften shadows (half-lambert) - diffuse = diffuse_strength * (n_dot_l * 0.5 + 0.5) - - # --- Isotropic specular --- - n_dot_h = (normals[:, :, 0] * half_vec[0] + - normals[:, :, 1] * half_vec[1] + - normals[:, :, 2] * half_vec[2]) - n_dot_h = np.clip(n_dot_h, 0, 1) - specular_iso = specular_strength * np.power(n_dot_h, shininess) - - # --- Anisotropic specular (Kajiya-Kay) --- - tangent_x = np.cos(thread_angle) - tangent_y = np.sin(thread_angle) - - t_dot_h = tangent_x * half_vec[0] + tangent_y * half_vec[1] - sin_th = np.sqrt(np.clip(1.0 - t_dot_h ** 2, 0, 1)) - # Modulate by local anisotropy - only apply aniso spec where texture is directional - specular_aniso = aniso_strength * np.power(sin_th, aniso_shininess) * anisotropy - - # --- Secondary broad specular (satin sheen on lighter regions) --- - # Lighter areas (white text, bright embroidery) have more specular - lum = np.mean(result[:, :, :3], axis=2) / 255.0 - brightness_boost = np.clip(lum - 0.4, 0, 0.6) / 0.6 # ramp from 0.4 to 1.0 - spec_broad = 0.06 * np.power(n_dot_h, 8.0) * brightness_boost - - # --- Combine --- - # Multiplicative lighting (affects color) - color_light = ambient + diffuse - # Ensure average is near 1.0 to preserve original brightness - color_light = np.clip(color_light, 0.35, 1.5) - - # Additive specular (white highlight) - spec_total = (specular_iso + specular_aniso + spec_broad) * mask - - # Apply - for c in range(3): - channel = result[:, :, c] - lit = channel * color_light + spec_total * 220.0 - result[:, :, c] = channel * (1.0 - mask) + lit * mask - - return np.clip(result, 0, 255) - - -# ───────────────────────────────────────────── -# 5. Per-thread micro-highlights -# ───────────────────────────────────────────── - -def compute_ambient_occlusion(img_arr, mask, radius=2.0, strength=0.15): - """ - Approximate screen-space ambient occlusion from stitch texture. - Stitch valleys (between thread rows) are slightly darker. - Computed by comparing local brightness to neighborhood average. - """ - lum = np.mean(img_arr[:, :, :3], axis=2) - local_avg = gaussian_filter(lum, sigma=radius) - ao = np.clip((local_avg - lum) / (local_avg + 1e-8), 0, 1) * strength - ao *= mask - return ao - - -def add_thread_microhighlights(img_arr, normals, mask, thread_angle, intensity=0.05): - """ - Individual threads catch light slightly differently. - Creates fine-grained brightness variation aligned with thread direction. - Also adds subtle shimmer for thread luster. - """ - h, w = img_arr.shape[:2] - noise = np.random.normal(0, 1, (h, w)) - - # Directional blur kernels - noise_along = gaussian_filter(noise, sigma=[0.3, 3.0]) - noise_across = gaussian_filter(noise, sigma=[3.0, 0.3]) - - # Blend using thread angle - cos_a = np.abs(np.cos(thread_angle)) - sin_a = np.abs(np.sin(thread_angle)) - total = cos_a + sin_a + 1e-8 - directional = (noise_along * cos_a + noise_across * sin_a) / total - - highlight = directional * intensity * mask - result = img_arr.copy() - for c in range(3): - result[:, :, c] *= (1.0 + highlight) - - # Fine per-pixel shimmer for thread luster - shimmer = np.random.normal(0, 0.015, (h, w)) - shimmer = gaussian_filter(shimmer, sigma=0.3) - for c in range(3): - result[:, :, c] *= (1.0 + shimmer * mask) - - return np.clip(result, 0, 255) - - -# ───────────────────────────────────────────── -# 6. Fabric backing texture -# ───────────────────────────────────────────── - -def generate_fabric_texture(width, height, color=(35, 35, 40), weave_scale=3): - """ - Generate realistic felt/twill fabric backing. - Multi-octave noise with diagonal weave pattern. - """ - arr = np.full((height, width, 3), color, dtype=np.float64) - - y_idx = np.arange(height)[:, None] - x_idx = np.arange(width)[None, :] - - # Twill diagonal weave - twill1 = np.sin(2 * np.pi * (x_idx + y_idx) / weave_scale) * 0.035 - twill2 = np.sin(2 * np.pi * (x_idx - y_idx) / (weave_scale * 1.3)) * 0.02 - twill3 = np.sin(2 * np.pi * (x_idx * 0.7 + y_idx * 1.3) / (weave_scale * 2)) * 0.015 - arr *= (1.0 + twill1 + twill2 + twill3)[:, :, np.newaxis] - - # Fine fiber noise - noise_fine = np.random.normal(0, 2.0, (height, width, 3)) - # Medium texture clumps - noise_med_small = np.random.normal(0, 1.2, (height // 2 + 1, width // 2 + 1, 3)) - noise_med = np.repeat(np.repeat(noise_med_small, 2, axis=0), 2, axis=1)[:height, :width, :] - noise_med = gaussian_filter(noise_med, sigma=1.0) - # Coarse color drift - noise_coarse_small = np.random.normal(0, 0.8, (height // 6 + 1, width // 6 + 1, 3)) - noise_coarse = np.repeat(np.repeat(noise_coarse_small, 6, axis=0), 6, axis=1)[:height, :width, :] - noise_coarse = gaussian_filter(noise_coarse, sigma=3.0) - - arr += noise_fine + noise_med + noise_coarse - - # Subtle large-scale brightness variation (fabric isn't perfectly uniform) - var_small = np.random.normal(0, 0.015, (height // 12 + 1, width // 12 + 1)) - variation = np.repeat(np.repeat(var_small, 12, axis=0), 12, axis=1)[:height, :width] - variation = gaussian_filter(variation, sigma=6.0) - arr *= (1.0 + variation[:, :, np.newaxis]) - - return np.clip(arr, 0, 255).astype(np.uint8) - - -# ───────────────────────────────────────────── -# 7. Shadow -# ───────────────────────────────────────────── - -def create_patch_shadow(mask, offset=(5, 6), blur_radius=10, opacity=0.55): - """ - Realistic two-layer shadow: - - Contact shadow: tight, dark, right at the edge - - Cast shadow: offset, soft, diffuse - """ - h, w = mask.shape - - # Cast shadow: shift the mask by offset and blur - shifted = ndimage.shift(mask, (offset[1], offset[0]), order=1, mode='constant', cval=0) - cast = gaussian_filter(shifted, sigma=blur_radius) * opacity - - # Contact shadow: unshifted tight edge glow - # Expand mask slightly, subtract original, blur tightly - dilated = gaussian_filter(mask, sigma=2.0) - contact_ring = np.clip(dilated - mask * 0.9, 0, 1) - contact = gaussian_filter(contact_ring, sigma=2.5) * 0.4 - - # Combine - shadow = np.maximum(cast, contact) - # Don't darken the patch interior - shadow *= np.clip(1.0 - mask, 0, 1) - - return np.clip(shadow, 0, 1) - - -# ───────────────────────────────────────────── -# 8. Merrow edge -# ───────────────────────────────────────────── - -def create_merrow_edge(mask, thickness=3): - """ - Simulate overlock/merrow stitch border around patch edge. - Returns (edge_mask, edge_normals) for 3D stitched border look. - """ - h, w = mask.shape - - # Get edge band via morphological gradient - hard_mask = (mask > 0.5).astype(np.float32) - dilated = ndimage.binary_dilation(hard_mask > 0.5, iterations=thickness).astype(np.float32) - eroded = ndimage.binary_erosion(hard_mask > 0.5, iterations=max(1, thickness // 2)).astype(np.float32) - edge_band = np.clip(dilated - eroded, 0, 1) - - # Anti-alias the edge band - edge_band = gaussian_filter(edge_band, sigma=0.6) - - # Add stitch texture pattern along the edge - y_idx = np.arange(h)[:, None].astype(np.float64) - x_idx = np.arange(w)[None, :].astype(np.float64) - - # Distance from center for radial stitch direction - cy, cx = h / 2.0, w / 2.0 - dy = y_idx - cy - dx = x_idx - cx - angle = np.arctan2(dy, dx) - - # Stitch pattern follows the edge circumferentially - # Use angle to create perpendicular stitches - dist = np.sqrt(dx ** 2 + dy ** 2) - stitch_freq = dist * 0.15 # scale frequency by radius - stitch_pattern = np.sin(stitch_freq + angle * 25) * 0.12 + 0.88 - stitch_pattern2 = np.cos(stitch_freq * 1.7 + angle * 18) * 0.06 + 0.94 - - edge_textured = edge_band * stitch_pattern * stitch_pattern2 - - return edge_textured, edge_band - - -# ───────────────────────────────────────────── -# 9. Patch thickness bevel -# ───────────────────────────────────────────── - -def create_edge_bevel(mask, bevel_width=6, light_dir=(0.3, -0.4)): - """ - Create a bevel/emboss effect at the patch edge to simulate thickness. - The patch is raised ~1-2mm above the fabric, creating a lit top edge - and shadowed bottom edge. - """ - # Compute distance from edge (inward) - hard_mask = (mask > 0.5).astype(np.float32) - dist = ndimage.distance_transform_edt(hard_mask) - dist_outside = ndimage.distance_transform_edt(1 - hard_mask) - - # Bevel height profile: ramps up at edge, flat in interior - bevel_height = np.clip(dist / bevel_width, 0, 1) - # Also slight ramp outside for the outer edge - bevel_height_out = np.clip(1.0 - dist_outside / (bevel_width * 0.5), 0, 1) * (1 - hard_mask) - - height = bevel_height + bevel_height_out - - # Compute lighting from height map - gx = ndimage.sobel(height, axis=1) - gy = ndimage.sobel(height, axis=0) - - # Directional lighting - lx, ly = light_dir - bevel_light = -(gx * lx + gy * ly) - - # Normalize to [-1, 1] range - max_val = max(np.abs(bevel_light).max(), 1e-8) - bevel_light = bevel_light / max_val - - # Only apply near edges - edge_proximity = np.clip(1.0 - dist / (bevel_width * 1.5), 0, 1) * hard_mask - edge_proximity += np.clip(1.0 - dist_outside / (bevel_width * 0.8), 0, 1) * (1 - hard_mask) - - bevel_light *= edge_proximity - - return bevel_light - - -def create_inner_relief(img_arr, mask, light_dir=(0.3, -0.4), strength=0.08): - """ - Detect color boundaries within the patch (where different stitch sections - meet) and add subtle height relief at those boundaries. In real embroidery, - the NASA text sits slightly above the blue fill, the chevron overlaps, etc. - """ - h, w = img_arr.shape[:2] - - # Detect edges within the patch using color gradient magnitude - # Use all 3 channels for better boundary detection - edges = np.zeros((h, w), dtype=np.float64) - for c in range(3): - gx = ndimage.sobel(img_arr[:, :, c], axis=1) - gy = ndimage.sobel(img_arr[:, :, c], axis=0) - edges += np.sqrt(gx ** 2 + gy ** 2) - edges /= 3.0 - - # Threshold to find significant color boundaries (not just stitch texture) - # Smooth to get section-level boundaries, not individual thread edges - edges_smooth = gaussian_filter(edges, sigma=1.5) - - # Normalize - edge_max = np.percentile(edges_smooth[mask > 0.5], 95) if np.any(mask > 0.5) else 1.0 - edges_norm = np.clip(edges_smooth / (edge_max + 1e-8), 0, 1) - - # Create height map: sections have flat heights, boundaries have transitions - # Use edge magnitude as a proxy for height discontinuity - height = gaussian_filter(edges_norm, sigma=2.0) * mask - - # Compute directional lighting on this height map - gx = ndimage.sobel(height, axis=1) - gy = ndimage.sobel(height, axis=0) - lx, ly = light_dir - relief = -(gx * lx + gy * ly) * strength * mask - - return relief - - -# ───────────────────────────────────────────── -# 10-11. Photographic effects -# ───────────────────────────────────────────── - -def add_vignette(img_arr, strength=0.22, radius=0.65): - """Photographic vignette - darkens corners.""" - h, w = img_arr.shape[:2] - y = np.linspace(-1, 1, h)[:, None] - x = np.linspace(-1, 1, w)[None, :] - dist = np.sqrt(x * x + y * y) - vignette = 1.0 - strength * np.clip((dist - radius) / (1.4 - radius), 0, 1) ** 1.5 - return img_arr * vignette[:, :, np.newaxis] - - -def add_film_grain(img_arr, strength=3.0): - """Photographic film grain with realistic grain size.""" - h, w = img_arr.shape[:2] - # Luminance-dependent grain (stronger in shadows) - lum = np.mean(img_arr[:, :, :3], axis=2) - grain_strength = strength * (1.0 + 0.3 * (1.0 - lum / 255.0)) - - grain = np.random.normal(0, 1, (h, w)) * grain_strength - grain = gaussian_filter(grain, sigma=0.4) - - result = img_arr + grain[:, :, np.newaxis] - return np.clip(result, 0, 255) - - -def add_depth_of_field(img_arr, mask, max_blur=1.3): - """Subtle DOF: patch center sharp, frame edges soft.""" - h, w = img_arr.shape[:2] - - y_coords, x_coords = np.where(mask > 0.5) - if len(y_coords) == 0: - return img_arr - cx, cy = int(np.mean(x_coords)), int(np.mean(y_coords)) - - y = np.arange(h)[:, None] - x = np.arange(w)[None, :] - dist = np.sqrt(((x - cx) / w * 2) ** 2 + ((y - cy) / h * 2) ** 2) - blur_t = np.clip((dist - 0.35) / 0.65, 0, 1) ** 1.5 - - blurred = np.stack([ - gaussian_filter(img_arr[:, :, c], sigma=max_blur) - for c in range(img_arr.shape[2]) - ], axis=2) - - blend = blur_t[:, :, np.newaxis] - return img_arr * (1 - blend) + blurred * blend - - -def color_grade(img_arr, warmth=0.02, contrast=1.05, saturation=1.10): - """Subtle photographic color grading.""" - result = img_arr.copy() - - # Warmth - result[:, :, 0] *= (1.0 + warmth) - result[:, :, 1] *= (1.0 + warmth * 0.2) - result[:, :, 2] *= (1.0 - warmth * 0.4) - - # S-curve contrast (gentle) - mid = 128.0 - result = mid + (result - mid) * contrast - - # Saturation boost (embroidery is vivid) - gray = np.mean(result[:, :, :3], axis=2, keepdims=True) - result[:, :, :3] = gray + (result[:, :, :3] - gray) * saturation - - return np.clip(result, 0, 255) - - -# ───────────────────────────────────────────── -# Main pipeline -# ───────────────────────────────────────────── - -def postprocess_photorealistic(input_path, output_path, - fabric_color=(35, 35, 40), - padding=55, - light_dir=(0.25, -0.35, 0.90)): - """ - Full photorealistic post-processing pipeline. - """ - print(f"Loading {input_path}...") - stitch_img = Image.open(input_path) - stitch_arr = np.array(stitch_img)[:, :, :3].astype(np.float64) - h, w = stitch_arr.shape[:2] - - print(" [1/13] Extracting patch mask...") - mask = extract_patch_mask(stitch_arr) - - print(" [2/13] Computing normal map...") - normals = compute_normal_map(stitch_arr, mask, strength=2.0) - - print(" [3/13] Estimating thread directions...") - thread_angle, anisotropy = estimate_thread_direction(stitch_arr, mask, block_size=12) - - print(" [4/13] Applying Blinn-Phong + anisotropic shading...") - lit = apply_lighting(stitch_arr, normals, mask, thread_angle, anisotropy, - light_dir=light_dir, - ambient=0.55, diffuse_strength=0.35, - specular_strength=0.12, shininess=18.0, - aniso_strength=0.08, aniso_shininess=30.0) - - print(" [5/13] Computing ambient occlusion...") - ao = compute_ambient_occlusion(stitch_arr, mask, radius=2.0, strength=0.12) - # Apply AO: darken stitch valleys - for c in range(3): - lit[:, :, c] *= (1.0 - ao) - lit = np.clip(lit, 0, 255) - - print(" [6/13] Adding per-thread micro-highlights...") - lit = add_thread_microhighlights(lit, normals, mask, thread_angle, intensity=0.05) - - # --- Canvas setup --- - canvas_w = w + padding * 2 - canvas_h = h + padding * 2 - - print(f" [7/13] Generating fabric texture ({canvas_w}x{canvas_h})...") - fabric = generate_fabric_texture(canvas_w, canvas_h, color=fabric_color) - canvas = fabric.astype(np.float64) - - # --- Pad mask to canvas size --- - mask_padded = np.zeros((canvas_h, canvas_w), dtype=np.float64) - mask_padded[padding:padding + h, padding:padding + w] = mask - - # --- Shadow --- - print(" [8/13] Creating drop shadow...") - shadow = create_patch_shadow(mask_padded, offset=(5, 6), blur_radius=10, opacity=0.50) - for c in range(3): - canvas[:, :, c] *= (1.0 - shadow * 0.8) - - # --- Composite lit patch onto canvas --- - print(" [9/13] Compositing patch onto fabric...") - for c in range(3): - patch_channel = lit[:, :, c] - canvas_region = canvas[padding:padding + h, padding:padding + w, c] - canvas[padding:padding + h, padding:padding + w, c] = ( - canvas_region * (1 - mask) + patch_channel * mask - ) - - # --- Edge bevel (thickness illusion) --- - print(" [10/13] Adding edge bevel for 3D thickness...") - bevel = create_edge_bevel(mask_padded, bevel_width=5, - light_dir=(light_dir[0], light_dir[1])) - # Apply bevel as brightness modulation - bevel_intensity = 45.0 # How strong the bevel highlight/shadow is - for c in range(3): - canvas[:, :, c] += bevel * bevel_intensity - canvas = np.clip(canvas, 0, 255) - - # --- Inner relief --- - print(" [11/13] Adding inner relief at section boundaries...") - inner_relief = create_inner_relief(stitch_arr, mask, - light_dir=(light_dir[0], light_dir[1]), - strength=0.07) - relief_padded = np.zeros((canvas_h, canvas_w), dtype=np.float64) - relief_padded[padding:padding + h, padding:padding + w] = inner_relief - for c in range(3): - canvas[:, :, c] *= (1.0 + relief_padded) - canvas = np.clip(canvas, 0, 255) - - print(" [12/13] Adding merrow edge border...") - merrow, merrow_band = create_merrow_edge(mask_padded, thickness=3) - - # Detect dominant edge color from the patch border pixels - # Sample colors from the edge region of the lit patch - edge_sample_mask = (mask > 0.3) & (mask < 0.95) - if np.any(edge_sample_mask): - edge_colors = lit[edge_sample_mask] - avg_edge = np.mean(edge_colors, axis=0) - # Make merrow edge slightly lighter than border - edge_color = np.clip(avg_edge * 1.3 + 30, 0, 255) - else: - edge_color = np.array([160, 160, 165], dtype=np.float64) - - # Apply merrow edge with bump lighting for 3D thread appearance - # Create mini-normal from merrow pattern for lit edge - merrow_gx = ndimage.sobel(merrow, axis=1) - merrow_gy = ndimage.sobel(merrow, axis=0) - merrow_light = -(merrow_gx * light_dir[0] + merrow_gy * light_dir[1]) - merrow_light = merrow_light / (np.abs(merrow_light).max() + 1e-8) * 0.3 - - for c in range(3): - edge_val = edge_color[c] * (1.0 + merrow_light) * merrow - canvas[:, :, c] = canvas[:, :, c] * (1 - merrow_band * 0.65) + edge_val * 0.65 - canvas = np.clip(canvas, 0, 255) - - # --- Photographic finishing --- - print(" [13/13] Photographic finishing (DOF, color, vignette, grain)...") - canvas = add_depth_of_field(canvas, mask_padded, max_blur=1.2) - canvas = color_grade(canvas, warmth=0.02, contrast=1.06, saturation=1.12) - canvas = add_vignette(canvas, strength=0.20, radius=0.60) - canvas = add_film_grain(canvas, strength=3.0) - - # --- Save --- - result = np.clip(canvas, 0, 255).astype(np.uint8) - output_img = Image.fromarray(result, "RGB") - output_img.save(str(output_path), quality=95) - print(f" Saved to {output_path}") - - return output_path - - -def save_debug_stages(input_path, output_dir): - """Save intermediate stages for inspection.""" - output_dir = Path(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - stitch_img = Image.open(input_path) - stitch_arr = np.array(stitch_img)[:, :, :3].astype(np.float64) - h, w = stitch_arr.shape[:2] - - mask = extract_patch_mask(stitch_arr) - normals = compute_normal_map(stitch_arr, mask, strength=2.0) - thread_angle, anisotropy = estimate_thread_direction(stitch_arr, mask, block_size=12) - - # 1. Normal map visualization (standard purple/green/blue encoding) - normal_vis = ((normals + 1.0) * 0.5 * 255).astype(np.uint8) - Image.fromarray(normal_vis, "RGB").save(str(output_dir / "01_normal_map.png")) - - # 2. Mask - mask_vis = (mask * 255).astype(np.uint8) - Image.fromarray(mask_vis, "L").save(str(output_dir / "02_mask.png")) - - # 3. Thread direction + anisotropy - thread_vis = np.zeros((h, w, 3), dtype=np.uint8) - thread_vis[:, :, 0] = ((np.cos(thread_angle) + 1) * 0.5 * 255 * mask).astype(np.uint8) - thread_vis[:, :, 1] = ((np.sin(thread_angle) + 1) * 0.5 * 255 * mask).astype(np.uint8) - thread_vis[:, :, 2] = (anisotropy * 255 * mask).astype(np.uint8) - Image.fromarray(thread_vis, "RGB").save(str(output_dir / "03_thread_direction.png")) - - # 4. Lit patch (after shading, before compositing) - lit = apply_lighting(stitch_arr, normals, mask, thread_angle, anisotropy) - lit = add_thread_microhighlights(lit, normals, mask, thread_angle) - Image.fromarray(np.clip(lit, 0, 255).astype(np.uint8), "RGB").save( - str(output_dir / "04_lit_patch.png")) - - # 5. Edge bevel visualization - mask_padded = np.zeros((h + 100, w + 100), dtype=np.float64) - mask_padded[50:50 + h, 50:50 + w] = mask - bevel = create_edge_bevel(mask_padded, bevel_width=5) - bevel_vis = ((bevel + 1) * 0.5 * 255).astype(np.uint8) - Image.fromarray(bevel_vis, "L").save(str(output_dir / "05_edge_bevel.png")) - - # 6. Shadow - shadow = create_patch_shadow(mask_padded) - shadow_vis = (shadow * 255).astype(np.uint8) - Image.fromarray(shadow_vis, "L").save(str(output_dir / "06_shadow.png")) - - print(f"Debug stages saved to {output_dir}/") - - -def create_comparison(input_path, output_path, comparison_path): - """Create side-by-side comparison image.""" - original = Image.open(input_path) - result = Image.open(output_path) - - # Make them the same height - orig_w, orig_h = original.size - res_w, res_h = result.size - - # Scale original to match result height - scale = res_h / orig_h - orig_scaled = original.resize((int(orig_w * scale), res_h), Image.LANCZOS) - - # Create comparison canvas - gap = 20 - comp_w = orig_scaled.width + res_w + gap - comp = Image.new("RGB", (comp_w, res_h + 40), (30, 30, 30)) - - # Paste images - comp.paste(orig_scaled, (0, 0)) - comp.paste(result, (orig_scaled.width + gap, 0)) - - # Add labels - draw = ImageDraw.Draw(comp) - draw.text((orig_scaled.width // 2 - 30, res_h + 5), "BEFORE", fill=(180, 180, 180)) - draw.text((orig_scaled.width + gap + res_w // 2 - 20, res_h + 5), "AFTER", fill=(180, 180, 180)) - - comp.save(str(comparison_path), quality=95) - print(f"Comparison saved to {comparison_path}") - - -def main(): - if len(sys.argv) < 2: - print(f"Usage: {sys.argv[0]} [output.png] [--debug] [--compare]") - sys.exit(1) - - input_path = Path(sys.argv[1]) - - # Find output path - positional_args = [a for a in sys.argv[2:] if not a.startswith("--")] - output_path = Path(positional_args[0]) if positional_args else \ - input_path.with_name(input_path.stem + "_photorealistic.png") - - debug = "--debug" in sys.argv - compare = "--compare" in sys.argv - - if debug: - save_debug_stages(input_path, output_path.parent / "debug") - - postprocess_photorealistic(input_path, output_path) - - if compare: - comp_path = output_path.with_name(output_path.stem + "_comparison.png") - create_comparison(input_path, output_path, comp_path) - - print("Done!") - - -if __name__ == "__main__": - main() diff --git a/worker/pipeline/svg2patch.py b/worker/pipeline/svg2patch.py deleted file mode 100644 index 4464c00..0000000 --- a/worker/pipeline/svg2patch.py +++ /dev/null @@ -1,235 +0,0 @@ -#!/usr/bin/env python3 -""" -svg2patch: Add inkstitch embroidery parameters to any SVG and render as a patch. - -Takes a clean SVG (from vtracer, manual design, etc.) and: - 1. Adds inkstitch namespace + fill parameters to all paths - 2. Sets document dimensions to mm - 3. Renders with inkstitch for realistic stitch simulation - 4. Optionally runs photorealistic post-processing -""" - -import sys -import os -import subprocess -import argparse -from pathlib import Path -from lxml import etree - -INKSTITCH_NS = "http://inkstitch.org/namespace" -SVG_NS = "http://www.w3.org/2000/svg" -INKSCAPE_NS = "http://www.inkscape.org/namespaces/inkscape" -SODIPODI_NS = "http://sodipodi.sourceforge.net/DTD/sodipodi-0.0.dtd" - -INKSTITCH_BIN = os.environ.get( - "INKSTITCH_BIN", - os.path.expanduser( - "~/Library/Application Support/org.inkscape.Inkscape" - "/config/inkscape/extensions/inkstitch.app/Contents/MacOS/inkstitch" - ), -) - -PATCH_WIDTH_MM = 80.0 - - -def add_inkstitch_params(svg_path, output_svg_path, border_color=None): - """Add inkstitch embroidery parameters to all paths in an SVG.""" - etree.register_namespace("inkstitch", INKSTITCH_NS) - etree.register_namespace("inkscape", INKSCAPE_NS) - etree.register_namespace("sodipodi", SODIPODI_NS) - - tree = etree.parse(str(svg_path)) - root = tree.getroot() - - nsmap = dict(root.nsmap) - nsmap["inkstitch"] = INKSTITCH_NS - nsmap["inkscape"] = INKSCAPE_NS - nsmap["sodipodi"] = SODIPODI_NS - new_root = etree.Element(root.tag, nsmap=nsmap) - new_root.attrib.update(root.attrib) - new_root.text = root.text - new_root.tail = root.tail - for child in root: - new_root.append(child) - root = new_root - - # get original dimensions from viewBox or width/height - viewbox = root.get("viewBox") - if viewbox: - parts = viewbox.split() - vb_w = float(parts[2]) - float(parts[0]) - vb_h = float(parts[3]) - float(parts[1]) - else: - vb_w = float(root.get("width", "100").replace("px", "").replace("mm", "")) - vb_h = float(root.get("height", "100").replace("px", "").replace("mm", "")) - - # set dimensions to mm - scale = PATCH_WIDTH_MM / vb_w - width_mm = PATCH_WIDTH_MM - height_mm = vb_h * scale - root.set("width", f"{width_mm}mm") - root.set("height", f"{height_mm}mm") - - # add namedview - existing_nv = root.find("{%s}namedview" % SODIPODI_NS) - if existing_nv is None: - nv = etree.SubElement(root, "{%s}namedview" % SODIPODI_NS) - nv.set("{%s}document-units" % INKSCAPE_NS, "mm") - - # add version metadata - existing_meta = root.find("{%s}metadata" % SVG_NS) - if existing_meta is None: - existing_meta = root.find("metadata") - if existing_meta is None: - existing_meta = etree.SubElement(root, "metadata") - version_el = existing_meta.find("{%s}inkstitch_svg_version" % INKSTITCH_NS) - if version_el is None: - version_el = etree.SubElement(existing_meta, "{%s}inkstitch_svg_version" % INKSTITCH_NS) - version_el.text = "3" - - # find all paths/shapes and add inkstitch params - all_elements = root.iter() - shape_tags = { - "{%s}path" % SVG_NS, "{%s}circle" % SVG_NS, "{%s}ellipse" % SVG_NS, - "{%s}rect" % SVG_NS, "{%s}polygon" % SVG_NS, - "path", "circle", "ellipse", "rect", "polygon", - } - - element_count = 0 - for el in all_elements: - tag = el.tag - if tag not in shape_tags: - continue - - # get fill color from style or fill attribute - style = el.get("style", "") - fill = el.get("fill", "") - - if "fill:none" in style or fill == "none": - continue - if "display:none" in style: - continue - - # convert fill attribute to style if needed - if fill and "fill:" not in style: - if style: - el.set("style", f"fill:{fill};stroke:none;{style}") - else: - el.set("style", f"fill:{fill};stroke:none") - if el.get("fill"): - del el.attrib["fill"] - elif not fill and "fill:" not in style: - continue - - # ensure stroke:none is in style - current_style = el.get("style", "") - if "stroke:" not in current_style: - el.set("style", current_style.rstrip(";") + ";stroke:none") - - # add inkstitch fill parameters - angle = (30 + element_count * 23) % 180 - el.set("{%s}fill_method" % INKSTITCH_NS, "auto_fill") - el.set("{%s}fill_underlay" % INKSTITCH_NS, "true") - el.set("{%s}fill_underlay_angle" % INKSTITCH_NS, str((angle + 90) % 360)) - el.set("{%s}angle" % INKSTITCH_NS, str(angle)) - el.set("{%s}row_spacing_mm" % INKSTITCH_NS, "0.25") - el.set("{%s}max_stitch_length_mm" % INKSTITCH_NS, "3.0") - el.set("{%s}staggers" % INKSTITCH_NS, "4") - - element_count += 1 - - # add border if requested - if border_color: - if viewbox: - parts = [float(p) for p in viewbox.split()] - origin_x, origin_y = parts[0], parts[1] - else: - origin_x, origin_y = 0.0, 0.0 - - pad = vb_w * 0.06 - border = etree.Element("rect") - border.set("x", str(origin_x - pad)) - border.set("y", str(origin_y - pad)) - border.set("width", str(vb_w + pad * 2)) - border.set("height", str(vb_h + pad * 2)) - border.set("rx", str(pad * 0.8)) - border.set("ry", str(pad * 0.8)) - border.set("style", f"fill:{border_color};stroke:none") - border.set("{%s}fill_method" % INKSTITCH_NS, "auto_fill") - border.set("{%s}fill_underlay" % INKSTITCH_NS, "true") - border.set("{%s}angle" % INKSTITCH_NS, "90") - border.set("{%s}row_spacing_mm" % INKSTITCH_NS, "0.2") - border.set("{%s}max_stitch_length_mm" % INKSTITCH_NS, "2.5") - border.set("{%s}staggers" % INKSTITCH_NS, "4") - root.insert(0, border) - - root.set("viewBox", f"{origin_x-pad} {origin_y-pad} {vb_w+pad*2} {vb_h+pad*2}") - new_scale = PATCH_WIDTH_MM / (vb_w + pad * 2) - root.set("width", f"{(vb_w + pad*2) * new_scale}mm") - root.set("height", f"{(vb_h + pad*2) * new_scale}mm") - element_count += 1 - - etree.ElementTree(root).write(str(output_svg_path), xml_declaration=True, encoding="utf-8", pretty_print=True) - print(f" {element_count} elements parameterized") - return output_svg_path - - -def render_inkstitch(svg_path, output_png): - """Render with inkstitch realistic PNG.""" - with open(output_png, "wb") as f: - result = subprocess.run( - [INKSTITCH_BIN, "--extension=png_realistic", str(svg_path)], - stdout=f, stderr=subprocess.PIPE, timeout=300, - ) - if result.returncode != 0: - print(f" inkstitch error: {result.stderr.decode()[:300]}", file=sys.stderr) - if output_png.exists(): - output_png.unlink() - return False - return output_png.exists() and output_png.stat().st_size > 0 - - -def main(): - parser = argparse.ArgumentParser(description="Add inkstitch params to SVG and render as patch") - parser.add_argument("input", help="Input SVG path") - parser.add_argument("output", nargs="?", help="Output PNG path") - parser.add_argument("-b", "--border-color", default="#0a0a14", help="Border color (default: #0a0a14)") - parser.add_argument("--no-border", action="store_true", help="Skip border") - parser.add_argument("--no-postprocess", action="store_true", help="Skip photorealistic post-processing") - args = parser.parse_args() - - input_path = Path(args.input) - output_path = Path(args.output) if args.output else input_path.with_name(input_path.stem + "_patch.png") - - border = None if args.no_border else args.border_color - - print(f"Adding inkstitch params to {input_path}...") - embroidery_svg = output_path.with_suffix(".svg") - add_inkstitch_params(input_path, embroidery_svg, border_color=border) - - print(f"Rendering with inkstitch...") - if render_inkstitch(embroidery_svg, output_path): - print(f" Stitch render: {output_path} ({output_path.stat().st_size // 1024}KB)") - else: - print(" inkstitch render failed") - return - - if not args.no_postprocess: - postprocess_script = Path(__file__).parent / "photorealistic.py" - if postprocess_script.exists(): - final_path = output_path.with_name(output_path.stem.replace("_patch", "") + "_final.png") - print(f"Post-processing...") - subprocess.run( - [sys.executable, str(postprocess_script), str(output_path), str(final_path)], - timeout=120, - ) - if final_path.exists(): - print(f" Final: {final_path}") - else: - print(" (photorealistic.py not found, skipping post-processing)") - - print("Done!") - - -if __name__ == "__main__": - main() diff --git a/worker/postprocess.go b/worker/postprocess.go new file mode 100644 index 0000000..0558031 --- /dev/null +++ b/worker/postprocess.go @@ -0,0 +1,687 @@ +package main + +import ( + "fmt" + "image" + "image/png" + "math" + "math/rand/v2" + "os" + "time" +) + +const postprocessPadding = 55 + +func postprocessPhotorealistic(inputPath, outputPath string) error { + f, err := os.Open(inputPath) + if err != nil { + return err + } + src, _, err := image.Decode(f) + f.Close() + if err != nil { + return err + } + + img := rgbfFromImage(src) + h, w := img.H, img.W + + stage := func(name string) func() { + t0 := time.Now() + fmt.Printf(" %s...", name) + return func() { fmt.Printf(" %.2fs\n", time.Since(t0).Seconds()) } + } + + done := stage("[1/13] Extracting patch mask") + mask := extractPatchMask(img) + done() + + done = stage("[2/13] Computing normal map") + normals := computeNormalMap(img, mask, 2.0) + done() + + done = stage("[3/13] Estimating thread directions") + threadAngle, anisotropy := estimateThreadDirection(img, mask, 12) + done() + + done = stage("[4/13] Applying Blinn-Phong + anisotropic shading") + lit := applyLighting(img, normals, mask, threadAngle, anisotropy) + done() + + done = stage("[5/13] Computing ambient occlusion") + ao := computeAmbientOcclusion(img, mask, 2.0, 0.12) + for i := 0; i < w*h; i++ { + scale := 1.0 - ao.Pix[i] + lit.Pix[i*3] = clampF(lit.Pix[i*3]*scale, 0, 255) + lit.Pix[i*3+1] = clampF(lit.Pix[i*3+1]*scale, 0, 255) + lit.Pix[i*3+2] = clampF(lit.Pix[i*3+2]*scale, 0, 255) + } + done() + + done = stage("[6/13] Adding per-thread micro-highlights") + lit = addThreadMicrohighlights(lit, mask, threadAngle, 0.05) + done() + + canvasW := w + postprocessPadding*2 + canvasH := h + postprocessPadding*2 + + done = stage(fmt.Sprintf("[7/13] Generating fabric texture (%dx%d)", canvasW, canvasH)) + canvas := generateFabricTexture(canvasW, canvasH) + done() + + maskPadded := newGrayF(canvasW, canvasH) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + maskPadded.Pix[(y+postprocessPadding)*canvasW+(x+postprocessPadding)] = mask.Pix[y*w+x] + } + } + + done = stage("[8/13] Creating drop shadow") + shadow := createPatchShadow(maskPadded, 5, 6, 10, 0.50) + for i := 0; i < canvasW*canvasH; i++ { + scale := 1.0 - shadow.Pix[i]*0.8 + canvas.Pix[i*3] *= scale + canvas.Pix[i*3+1] *= scale + canvas.Pix[i*3+2] *= scale + } + done() + + done = stage("[9/13] Compositing patch onto fabric") + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + m := mask.Pix[y*w+x] + ci := ((y+postprocessPadding)*canvasW + (x + postprocessPadding)) * 3 + li := (y*w + x) * 3 + canvas.Pix[ci] = canvas.Pix[ci]*(1-m) + lit.Pix[li]*m + canvas.Pix[ci+1] = canvas.Pix[ci+1]*(1-m) + lit.Pix[li+1]*m + canvas.Pix[ci+2] = canvas.Pix[ci+2]*(1-m) + lit.Pix[li+2]*m + } + } + done() + + done = stage("[10/13] Adding edge bevel for 3D thickness") + bevel := createEdgeBevel(maskPadded, 5, 0.25, -0.35) + for i := 0; i < canvasW*canvasH; i++ { + b := bevel.Pix[i] * 45.0 + canvas.Pix[i*3] = clampF(canvas.Pix[i*3]+b, 0, 255) + canvas.Pix[i*3+1] = clampF(canvas.Pix[i*3+1]+b, 0, 255) + canvas.Pix[i*3+2] = clampF(canvas.Pix[i*3+2]+b, 0, 255) + } + done() + + done = stage("[11/13] Adding inner relief at section boundaries") + innerRelief := createInnerRelief(img, mask, 0.25, -0.35, 0.07) + reliefPadded := newGrayF(canvasW, canvasH) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + reliefPadded.Pix[(y+postprocessPadding)*canvasW+(x+postprocessPadding)] = innerRelief.Pix[y*w+x] + } + } + for i := 0; i < canvasW*canvasH; i++ { + scale := 1.0 + reliefPadded.Pix[i] + canvas.Pix[i*3] = clampF(canvas.Pix[i*3]*scale, 0, 255) + canvas.Pix[i*3+1] = clampF(canvas.Pix[i*3+1]*scale, 0, 255) + canvas.Pix[i*3+2] = clampF(canvas.Pix[i*3+2]*scale, 0, 255) + } + done() + + done = stage("[12/13] Adding merrow edge border") + applyMerrowEdge(canvas, maskPadded, lit, mask, 3, [3]float64{0.25, -0.35, 0.90}) + done() + + done = stage("[13/13] Photographic finishing") + canvas = addDepthOfField(canvas, maskPadded, 1.2) + colorGrade(canvas, 0.02, 1.06, 1.12) + addVignette(canvas, 0.20, 0.60) + addFilmGrain(canvas, 3.0) + done() + + out, err := os.Create(outputPath) + if err != nil { + return err + } + defer out.Close() + return png.Encode(out, canvas.toImage()) +} + +func extractPatchMask(img *RGBF) *GrayF { + w, h := img.W, img.H + mask := newGrayF(w, h) + for i := 0; i < w*h; i++ { + r, g, b := img.Pix[i*3], img.Pix[i*3+1], img.Pix[i*3+2] + if r <= 235 || g <= 235 || b <= 235 { + mask.Pix[i] = 1 + } + } + mask = binaryClose(mask, 3) + mask = binaryDilate(mask, 1) + mask = gaussBlur(mask, 1.2) + for i := range mask.Pix { + mask.Pix[i] = clampF(mask.Pix[i], 0, 1) + } + return mask +} + +func computeNormalMap(img *RGBF, mask *GrayF, strength float64) *RGBF { + lum := luminance(img) + w, h := img.W, img.H + + gxFine := sobelX(lum) + gyFine := sobelY(lum) + + lumMed := gaussBlur(lum, 1.2) + gxMed := sobelX(lumMed) + gyMed := sobelY(lumMed) + + lumCoarse := gaussBlur(lum, 3.0) + gxCoarse := sobelX(lumCoarse) + gyCoarse := sobelY(lumCoarse) + + normals := newRGBF(w, h) + for i := 0; i < w*h; i++ { + gx := 0.5*gxFine.Pix[i] + 0.35*gxMed.Pix[i] + 0.15*gxCoarse.Pix[i] + gy := 0.5*gyFine.Pix[i] + 0.35*gyMed.Pix[i] + 0.15*gyCoarse.Pix[i] + gx *= strength + gy *= strength + + nx := -gx + ny := -gy + nz := 255.0 + length := math.Sqrt(nx*nx + ny*ny + nz*nz) + if length < 1e-8 { + length = 1e-8 + } + m := mask.Pix[i] + normals.Pix[i*3] = nx / length * m + normals.Pix[i*3+1] = ny / length * m + normals.Pix[i*3+2] = nz / length * m + } + return normals +} + +func estimateThreadDirection(img *RGBF, mask *GrayF, blockSize int) (*GrayF, *GrayF) { + lum := luminance(img) + w, h := img.W, img.H + + gx := sobelX(lum) + gy := sobelY(lum) + + sigma := float64(blockSize) / 2.0 + + jxx := newGrayF(w, h) + jxy := newGrayF(w, h) + jyy := newGrayF(w, h) + for i := 0; i < w*h; i++ { + jxx.Pix[i] = gx.Pix[i] * gx.Pix[i] + jxy.Pix[i] = gx.Pix[i] * gy.Pix[i] + jyy.Pix[i] = gy.Pix[i] * gy.Pix[i] + } + jxx = gaussBlur(jxx, sigma) + jxy = gaussBlur(jxy, sigma) + jyy = gaussBlur(jyy, sigma) + + threadAngle := newGrayF(w, h) + anisotropy := newGrayF(w, h) + for i := 0; i < w*h; i++ { + angle := 0.5 * math.Atan2(2.0*jxy.Pix[i], jxx.Pix[i]-jyy.Pix[i]+1e-10) + threadAngle.Pix[i] = angle + math.Pi/2.0 + + trace := jxx.Pix[i] + jyy.Pix[i] + 1e-10 + diff := math.Sqrt(math.Pow(jxx.Pix[i]-jyy.Pix[i], 2) + 4*jxy.Pix[i]*jxy.Pix[i]) + anisotropy.Pix[i] = diff / trace + } + return threadAngle, anisotropy +} + +func applyLighting(img *RGBF, normals *RGBF, mask, threadAngle, anisotropy *GrayF) *RGBF { + w, h := img.W, img.H + result := img.clone() + + lx, ly, lz := 0.25, -0.35, 0.90 + ll := math.Sqrt(lx*lx + ly*ly + lz*lz) + lx, ly, lz = lx/ll, ly/ll, lz/ll + + hx := lx + hy := ly + hz := lz + 1.0 + hl := math.Sqrt(hx*hx + hy*hy + hz*hz) + hx, hy, hz = hx/hl, hy/hl, hz/hl + + ambient := 0.55 + diffStr := 0.35 + specStr := 0.12 + shininess := 18.0 + anisoStr := 0.08 + anisoShin := 30.0 + + lum := meanRGB(img) + + for i := 0; i < w*h; i++ { + m := mask.Pix[i] + if m < 0.001 { + continue + } + + nx := normals.Pix[i*3] + ny := normals.Pix[i*3+1] + nz := normals.Pix[i*3+2] + + ndl := clampF(nx*lx+ny*ly+nz*lz, 0, 1) + diffuse := diffStr * (ndl*0.5 + 0.5) + + ndh := clampF(nx*hx+ny*hy+nz*hz, 0, 1) + specIso := specStr * math.Pow(ndh, shininess) + + ta := threadAngle.Pix[i] + tx, ty := math.Cos(ta), math.Sin(ta) + tdh := tx*hx + ty*hy + sinTH := math.Sqrt(clampF(1.0-tdh*tdh, 0, 1)) + specAniso := anisoStr * math.Pow(sinTH, anisoShin) * anisotropy.Pix[i] + + brightnessBoost := clampF((lum.Pix[i]/255.0-0.4)/0.6, 0, 1) + specBroad := 0.06 * math.Pow(ndh, 8.0) * brightnessBoost + + colorLight := clampF(ambient+diffuse, 0.35, 1.5) + specTotal := (specIso + specAniso + specBroad) * m + + for c := 0; c < 3; c++ { + ch := result.Pix[i*3+c] + litVal := ch*colorLight + specTotal*220.0 + result.Pix[i*3+c] = ch*(1-m) + litVal*m + } + } + + for i := range result.Pix { + result.Pix[i] = clampF(result.Pix[i], 0, 255) + } + return result +} + +func computeAmbientOcclusion(img *RGBF, mask *GrayF, radius, strength float64) *GrayF { + w, h := img.W, img.H + lum := meanRGB(img) + localAvg := gaussBlur(lum, radius) + + ao := newGrayF(w, h) + for i := 0; i < w*h; i++ { + avg := localAvg.Pix[i] + v := clampF((avg-lum.Pix[i])/(avg+1e-8), 0, 1) * strength + ao.Pix[i] = v * mask.Pix[i] + } + return ao +} + +func addThreadMicrohighlights(img *RGBF, mask, threadAngle *GrayF, intensity float64) *RGBF { + w, h := img.W, img.H + noise := normalNoise(w, h) + + noiseAlong := gaussBlurDirectional(noise, 3.0, 0.3) + noiseAcross := gaussBlurDirectional(noise, 0.3, 3.0) + + result := img.clone() + for i := 0; i < w*h; i++ { + cosA := math.Abs(math.Cos(threadAngle.Pix[i])) + sinA := math.Abs(math.Sin(threadAngle.Pix[i])) + total := cosA + sinA + 1e-8 + directional := (noiseAlong.Pix[i]*cosA + noiseAcross.Pix[i]*sinA) / total + highlight := directional * intensity * mask.Pix[i] + + shimmer := rand.NormFloat64() * 0.015 + m := mask.Pix[i] + + for c := 0; c < 3; c++ { + result.Pix[i*3+c] *= (1.0 + highlight) * (1.0 + shimmer*m) + } + } + for i := range result.Pix { + result.Pix[i] = clampF(result.Pix[i], 0, 255) + } + return result +} + +func generateFabricTexture(w, h int) *RGBF { + canvas := newRGBF(w, h) + baseR, baseG, baseB := 35.0, 35.0, 40.0 + weaveScale := 3.0 + + for y := 0; y < h; y++ { + fy := float64(y) + for x := 0; x < w; x++ { + fx := float64(x) + twill1 := math.Sin(2*math.Pi*(fx+fy)/weaveScale) * 0.035 + twill2 := math.Sin(2*math.Pi*(fx-fy)/(weaveScale*1.3)) * 0.02 + twill3 := math.Sin(2*math.Pi*(fx*0.7+fy*1.3)/(weaveScale*2)) * 0.015 + scale := 1.0 + twill1 + twill2 + twill3 + + idx := (y*w + x) * 3 + canvas.Pix[idx] = baseR * scale + canvas.Pix[idx+1] = baseG * scale + canvas.Pix[idx+2] = baseB * scale + } + } + + noiseFine := normalNoiseRGB(w, h) + for i := range noiseFine.Pix { + noiseFine.Pix[i] *= 2.0 + } + + noiseMedSrc := normalNoiseRGB(w/2+1, h/2+1) + for i := range noiseMedSrc.Pix { + noiseMedSrc.Pix[i] *= 1.2 + } + noiseMed := upscaleBlurRGB(noiseMedSrc, w, h, 2, 1.0) + + noiseCoarseSrc := normalNoiseRGB(w/6+1, h/6+1) + for i := range noiseCoarseSrc.Pix { + noiseCoarseSrc.Pix[i] *= 0.8 + } + noiseCoarse := upscaleBlurRGB(noiseCoarseSrc, w, h, 6, 3.0) + + for i := range canvas.Pix { + canvas.Pix[i] += noiseFine.Pix[i] + noiseMed.Pix[i] + noiseCoarse.Pix[i] + } + + varSrc := normalNoise(w/12+1, h/12+1) + for i := range varSrc.Pix { + varSrc.Pix[i] *= 0.015 + } + variation := upscaleBlur(varSrc, w, h, 12, 6.0) + + for i := 0; i < w*h; i++ { + scale := 1.0 + variation.Pix[i] + canvas.Pix[i*3] = clampF(canvas.Pix[i*3]*scale, 0, 255) + canvas.Pix[i*3+1] = clampF(canvas.Pix[i*3+1]*scale, 0, 255) + canvas.Pix[i*3+2] = clampF(canvas.Pix[i*3+2]*scale, 0, 255) + } + return canvas +} + +func createPatchShadow(mask *GrayF, ox, oy, blurRadius int, opacity float64) *GrayF { + shifted := shiftImage(mask, float64(ox), float64(oy)) + cast := gaussBlur(shifted, float64(blurRadius)) + for i := range cast.Pix { + cast.Pix[i] *= opacity + } + + dilated := gaussBlur(mask, 2.0) + contact := newGrayF(mask.W, mask.H) + for i := range contact.Pix { + contact.Pix[i] = clampF(dilated.Pix[i]-mask.Pix[i]*0.9, 0, 1) + } + contact = gaussBlur(contact, 2.5) + for i := range contact.Pix { + contact.Pix[i] *= 0.4 + } + + shadow := newGrayF(mask.W, mask.H) + for i := range shadow.Pix { + shadow.Pix[i] = math.Max(cast.Pix[i], contact.Pix[i]) + shadow.Pix[i] *= clampF(1.0-mask.Pix[i], 0, 1) + shadow.Pix[i] = clampF(shadow.Pix[i], 0, 1) + } + return shadow +} + +func createEdgeBevel(mask *GrayF, bevelWidth int, lightX, lightY float64) *GrayF { + w, h := mask.W, mask.H + bw := float64(bevelWidth) + + hardMask := newGrayF(w, h) + invMask := newGrayF(w, h) + for i := range mask.Pix { + if mask.Pix[i] > 0.5 { + hardMask.Pix[i] = 1 + } else { + invMask.Pix[i] = 1 + } + } + + dist := distanceTransformEDT(hardMask) + distOutside := distanceTransformEDT(invMask) + + height := newGrayF(w, h) + for i := 0; i < w*h; i++ { + bh := clampF(dist.Pix[i]/bw, 0, 1) + bhOut := clampF(1.0-distOutside.Pix[i]/(bw*0.5), 0, 1) * (1 - hardMask.Pix[i]) + height.Pix[i] = bh + bhOut + } + + gx := sobelX(height) + gy := sobelY(height) + + bevelLight := newGrayF(w, h) + for i := 0; i < w*h; i++ { + bevelLight.Pix[i] = -(gx.Pix[i]*lightX + gy.Pix[i]*lightY) + } + + maxVal := absMax(bevelLight) + if maxVal < 1e-8 { + maxVal = 1e-8 + } + + for i := 0; i < w*h; i++ { + bevelLight.Pix[i] /= maxVal + edgeProx := clampF(1.0-dist.Pix[i]/(bw*1.5), 0, 1) * hardMask.Pix[i] + edgeProx += clampF(1.0-distOutside.Pix[i]/(bw*0.8), 0, 1) * (1 - hardMask.Pix[i]) + bevelLight.Pix[i] *= edgeProx + } + return bevelLight +} + +func createInnerRelief(img *RGBF, mask *GrayF, lightX, lightY, strength float64) *GrayF { + w, h := img.W, img.H + + edges := newGrayF(w, h) + for c := 0; c < 3; c++ { + ch := newGrayF(w, h) + for i := 0; i < w*h; i++ { + ch.Pix[i] = img.Pix[i*3+c] + } + gx := sobelX(ch) + gy := sobelY(ch) + for i := 0; i < w*h; i++ { + edges.Pix[i] += math.Sqrt(gx.Pix[i]*gx.Pix[i] + gy.Pix[i]*gy.Pix[i]) + } + } + for i := range edges.Pix { + edges.Pix[i] /= 3.0 + } + + edgesSmooth := gaussBlur(edges, 1.5) + edgeMax := percentile(edgesSmooth, mask, 95) + if edgeMax < 1e-8 { + edgeMax = 1 + } + + for i := range edgesSmooth.Pix { + edgesSmooth.Pix[i] = clampF(edgesSmooth.Pix[i]/edgeMax, 0, 1) + } + + heightMap := gaussBlur(edgesSmooth, 2.0) + for i := range heightMap.Pix { + heightMap.Pix[i] *= mask.Pix[i] + } + + gx := sobelX(heightMap) + gy := sobelY(heightMap) + + relief := newGrayF(w, h) + for i := 0; i < w*h; i++ { + relief.Pix[i] = -(gx.Pix[i]*lightX + gy.Pix[i]*lightY) * strength * mask.Pix[i] + } + return relief +} + +func applyMerrowEdge(canvas *RGBF, maskPadded *GrayF, litPatch *RGBF, patchMask *GrayF, thickness int, lightDir [3]float64) { + w, h := maskPadded.W, maskPadded.H + + hardMask := newGrayF(w, h) + for i := range maskPadded.Pix { + if maskPadded.Pix[i] > 0.5 { + hardMask.Pix[i] = 1 + } + } + + dilated := binaryDilate(hardMask, thickness) + eroded := binaryErode(hardMask, max(1, thickness/2)) + + edgeBand := newGrayF(w, h) + for i := range edgeBand.Pix { + edgeBand.Pix[i] = clampF(dilated.Pix[i]-eroded.Pix[i], 0, 1) + } + edgeBand = gaussBlur(edgeBand, 0.6) + + cy, cx := float64(h)/2.0, float64(w)/2.0 + + merrow := newGrayF(w, h) + for y := 0; y < h; y++ { + dy := float64(y) - cy + for x := 0; x < w; x++ { + dx := float64(x) - cx + angle := math.Atan2(dy, dx) + dist := math.Sqrt(dx*dx + dy*dy) + stitchFreq := dist * 0.15 + sp := math.Sin(stitchFreq+angle*25)*0.12 + 0.88 + sp2 := math.Cos(stitchFreq*1.7+angle*18)*0.06 + 0.94 + merrow.Pix[y*w+x] = edgeBand.Pix[y*w+x] * sp * sp2 + } + } + + // Sample edge color from patch border + edgeColor := [3]float64{160, 160, 165} + var edgePixels int + var edgeSum [3]float64 + pw, ph := patchMask.W, patchMask.H + for i := 0; i < pw*ph; i++ { + if patchMask.Pix[i] > 0.3 && patchMask.Pix[i] < 0.95 { + edgeSum[0] += litPatch.Pix[i*3] + edgeSum[1] += litPatch.Pix[i*3+1] + edgeSum[2] += litPatch.Pix[i*3+2] + edgePixels++ + } + } + if edgePixels > 0 { + for c := 0; c < 3; c++ { + edgeColor[c] = clampF(edgeSum[c]/float64(edgePixels)*1.3+30, 0, 255) + } + } + + merrowGx := sobelX(merrow) + merrowGy := sobelY(merrow) + merrowLight := newGrayF(w, h) + for i := range merrowLight.Pix { + merrowLight.Pix[i] = -(merrowGx.Pix[i]*lightDir[0] + merrowGy.Pix[i]*lightDir[1]) + } + mlMax := absMax(merrowLight) + if mlMax < 1e-8 { + mlMax = 1e-8 + } + for i := range merrowLight.Pix { + merrowLight.Pix[i] = merrowLight.Pix[i] / mlMax * 0.3 + } + + for i := 0; i < w*h; i++ { + eb := edgeBand.Pix[i] + m := merrow.Pix[i] + for c := 0; c < 3; c++ { + edgeVal := edgeColor[c] * (1.0 + merrowLight.Pix[i]) * m + canvas.Pix[i*3+c] = canvas.Pix[i*3+c]*(1-eb*0.65) + edgeVal*0.65 + } + } + + for i := range canvas.Pix { + canvas.Pix[i] = clampF(canvas.Pix[i], 0, 255) + } +} + +func addDepthOfField(img *RGBF, mask *GrayF, maxBlur float64) *RGBF { + w, h := img.W, img.H + + var sumX, sumY float64 + var count int + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + if mask.Pix[y*w+x] > 0.5 { + sumX += float64(x) + sumY += float64(y) + count++ + } + } + } + if count == 0 { + return img.clone() + } + cx := sumX / float64(count) + cy := sumY / float64(count) + + blurred := gaussBlurRGB(img, maxBlur) + + result := newRGBF(w, h) + fw, fh := float64(w), float64(h) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + dx := (float64(x) - cx) / fw * 2 + dy := (float64(y) - cy) / fh * 2 + dist := math.Sqrt(dx*dx + dy*dy) + t := clampF(math.Pow(math.Max((dist-0.35)/0.65, 0), 1.5), 0, 1) + + idx := (y*w + x) * 3 + for c := 0; c < 3; c++ { + result.Pix[idx+c] = img.Pix[idx+c]*(1-t) + blurred.Pix[idx+c]*t + } + } + } + return result +} + +func colorGrade(img *RGBF, warmth, contrast, saturation float64) { + for i := 0; i < img.W*img.H; i++ { + idx := i * 3 + img.Pix[idx] *= 1.0 + warmth + img.Pix[idx+1] *= 1.0 + warmth*0.2 + img.Pix[idx+2] *= 1.0 - warmth*0.4 + + for c := 0; c < 3; c++ { + img.Pix[idx+c] = 128 + (img.Pix[idx+c]-128)*contrast + } + + gray := (img.Pix[idx] + img.Pix[idx+1] + img.Pix[idx+2]) / 3.0 + for c := 0; c < 3; c++ { + img.Pix[idx+c] = gray + (img.Pix[idx+c]-gray)*saturation + img.Pix[idx+c] = clampF(img.Pix[idx+c], 0, 255) + } + } +} + +func addVignette(img *RGBF, strength, radius float64) { + w, h := img.W, img.H + for y := 0; y < h; y++ { + ny := float64(y)/float64(h-1)*2 - 1 + for x := 0; x < w; x++ { + nx := float64(x)/float64(w-1)*2 - 1 + dist := math.Sqrt(nx*nx + ny*ny) + v := 1.0 - strength*clampF(math.Pow(math.Max((dist-radius)/(1.4-radius), 0), 1.5), 0, 1) + + idx := (y*w + x) * 3 + img.Pix[idx] *= v + img.Pix[idx+1] *= v + img.Pix[idx+2] *= v + } + } +} + +func addFilmGrain(img *RGBF, strength float64) { + w, h := img.W, img.H + lum := meanRGB(img) + + noise := normalNoise(w, h) + noise = gaussBlur(noise, 0.4) + + for i := 0; i < w*h; i++ { + grainStr := strength * (1.0 + 0.3*(1.0-lum.Pix[i]/255.0)) + g := noise.Pix[i] * grainStr + img.Pix[i*3] = clampF(img.Pix[i*3]+g, 0, 255) + img.Pix[i*3+1] = clampF(img.Pix[i*3+1]+g, 0, 255) + img.Pix[i*3+2] = clampF(img.Pix[i*3+2]+g, 0, 255) + } +} diff --git a/worker/requirements.txt b/worker/requirements.txt deleted file mode 100644 index 076b92f..0000000 --- a/worker/requirements.txt +++ /dev/null @@ -1,6 +0,0 @@ -redis==5.0.0 -lxml==5.2.0 -numpy==1.26.0 -scipy==1.13.0 -Pillow==10.4.0 -vtracer==0.6.15 diff --git a/worker/svg2patch.go b/worker/svg2patch.go new file mode 100644 index 0000000..f97374b --- /dev/null +++ b/worker/svg2patch.go @@ -0,0 +1,216 @@ +package main + +import ( + "fmt" + "strconv" + "strings" + + "github.com/beevik/etree" +) + +const ( + inkstitchNS = "http://inkstitch.org/namespace" + svgNS = "http://www.w3.org/2000/svg" + inkscapeNS = "http://www.inkscape.org/namespaces/inkscape" + sodipodiNS = "http://sodipodi.sourceforge.net/DTD/sodipodi-0.0.dtd" + patchWidthMM = 80.0 +) + +var shapeTags = map[string]bool{ + "path": true, "circle": true, "ellipse": true, + "rect": true, "polygon": true, +} + +func addInkstitchParams(svgPath, outputPath, borderColor string) error { + doc := etree.NewDocument() + if err := doc.ReadFromFile(svgPath); err != nil { + return err + } + + root := doc.Root() + if root == nil { + return fmt.Errorf("no root element") + } + + root.CreateAttr("xmlns:inkstitch", inkstitchNS) + root.CreateAttr("xmlns:inkscape", inkscapeNS) + root.CreateAttr("xmlns:sodipodi", sodipodiNS) + + vbW, vbH, originX, originY := getDimensions(root) + if vbW == 0 { + vbW = 100 + } + if vbH == 0 { + vbH = 100 + } + + scale := patchWidthMM / vbW + root.CreateAttr("width", fmt.Sprintf("%gmm", patchWidthMM)) + root.CreateAttr("height", fmt.Sprintf("%gmm", vbH*scale)) + + ensureNamedview(root) + ensureInkstitchVersion(root) + + elementCount := 0 + for _, el := range collectElements(root) { + tag := localName(el.Tag) + if !shapeTags[tag] { + continue + } + + style := el.SelectAttrValue("style", "") + fill := el.SelectAttrValue("fill", "") + + if strings.Contains(style, "fill:none") || fill == "none" { + continue + } + if strings.Contains(style, "display:none") { + continue + } + + if fill != "" && !strings.Contains(style, "fill:") { + if style != "" { + el.CreateAttr("style", fmt.Sprintf("fill:%s;stroke:none;%s", fill, style)) + } else { + el.CreateAttr("style", fmt.Sprintf("fill:%s;stroke:none", fill)) + } + el.RemoveAttr("fill") + } else if fill == "" && !strings.Contains(style, "fill:") { + continue + } + + currentStyle := el.SelectAttrValue("style", "") + if !strings.Contains(currentStyle, "stroke:") { + el.CreateAttr("style", strings.TrimRight(currentStyle, ";")+";stroke:none") + } + + angle := (30 + elementCount*23) % 180 + setInkstitchAttr(el, "fill_method", "auto_fill") + setInkstitchAttr(el, "fill_underlay", "true") + setInkstitchAttr(el, "fill_underlay_angle", strconv.Itoa((angle+90)%360)) + setInkstitchAttr(el, "angle", strconv.Itoa(angle)) + setInkstitchAttr(el, "row_spacing_mm", "0.25") + setInkstitchAttr(el, "max_stitch_length_mm", "3.0") + setInkstitchAttr(el, "staggers", "4") + + elementCount++ + } + + if borderColor != "" { + pad := vbW * 0.06 + + border := etree.NewElement("rect") + border.CreateAttr("x", fmt.Sprintf("%g", originX-pad)) + border.CreateAttr("y", fmt.Sprintf("%g", originY-pad)) + border.CreateAttr("width", fmt.Sprintf("%g", vbW+pad*2)) + border.CreateAttr("height", fmt.Sprintf("%g", vbH+pad*2)) + border.CreateAttr("rx", fmt.Sprintf("%g", pad*0.8)) + border.CreateAttr("ry", fmt.Sprintf("%g", pad*0.8)) + border.CreateAttr("style", fmt.Sprintf("fill:%s;stroke:none", borderColor)) + setInkstitchAttr(border, "fill_method", "auto_fill") + setInkstitchAttr(border, "fill_underlay", "true") + setInkstitchAttr(border, "angle", "90") + setInkstitchAttr(border, "row_spacing_mm", "0.2") + setInkstitchAttr(border, "max_stitch_length_mm", "2.5") + setInkstitchAttr(border, "staggers", "4") + root.InsertChildAt(0, border) + + newVBW := vbW + pad*2 + newVBH := vbH + pad*2 + root.CreateAttr("viewBox", fmt.Sprintf("%g %g %g %g", + originX-pad, originY-pad, newVBW, newVBH)) + newScale := patchWidthMM / newVBW + root.CreateAttr("width", fmt.Sprintf("%gmm", newVBW*newScale)) + root.CreateAttr("height", fmt.Sprintf("%gmm", newVBH*newScale)) + elementCount++ + } + + fmt.Printf(" %d elements parameterized\n", elementCount) + + doc.Indent(2) + return doc.WriteToFile(outputPath) +} + +func getDimensions(root *etree.Element) (w, h, ox, oy float64) { + if vb := root.SelectAttrValue("viewBox", ""); vb != "" { + parts := strings.Fields(vb) + if len(parts) >= 4 { + ox, _ = strconv.ParseFloat(parts[0], 64) + oy, _ = strconv.ParseFloat(parts[1], 64) + p2, _ := strconv.ParseFloat(parts[2], 64) + p3, _ := strconv.ParseFloat(parts[3], 64) + w = p2 - ox + h = p3 - oy + return + } + } + + wStr := strings.TrimSuffix(strings.TrimSuffix( + root.SelectAttrValue("width", "100"), "px"), "mm") + hStr := strings.TrimSuffix(strings.TrimSuffix( + root.SelectAttrValue("height", "100"), "px"), "mm") + w, _ = strconv.ParseFloat(wStr, 64) + h, _ = strconv.ParseFloat(hStr, 64) + return +} + +func ensureNamedview(root *etree.Element) { + for _, child := range root.ChildElements() { + if localName(child.Tag) == "namedview" { + return + } + } + nv := root.CreateElement("sodipodi:namedview") + nv.CreateAttr("inkscape:document-units", "mm") +} + +func ensureInkstitchVersion(root *etree.Element) { + var meta *etree.Element + for _, child := range root.ChildElements() { + if localName(child.Tag) == "metadata" { + meta = child + break + } + } + if meta == nil { + meta = root.CreateElement("metadata") + } + + for _, child := range meta.ChildElements() { + if localName(child.Tag) == "inkstitch_svg_version" { + child.SetText("3") + return + } + } + ver := meta.CreateElement("inkstitch:inkstitch_svg_version") + ver.SetText("3") +} + +func setInkstitchAttr(el *etree.Element, name, value string) { + el.CreateAttr("inkstitch:"+name, value) +} + +func localName(tag string) string { + if i := strings.LastIndex(tag, "}"); i >= 0 { + return tag[i+1:] + } + if i := strings.LastIndex(tag, ":"); i >= 0 { + return tag[i+1:] + } + return tag +} + +func collectElements(root *etree.Element) []*etree.Element { + var result []*etree.Element + var walk func(*etree.Element) + walk = func(el *etree.Element) { + result = append(result, el) + for _, child := range el.ChildElements() { + walk(child) + } + } + for _, child := range root.ChildElements() { + walk(child) + } + return result +} diff --git a/worker/worker.py b/worker/worker.py deleted file mode 100644 index 6dd665b..0000000 --- a/worker/worker.py +++ /dev/null @@ -1,76 +0,0 @@ -import os -import json -import tempfile -from pathlib import Path - -import redis - -from pipeline.convert import convert, convert_svg - -REDIS_URL = os.environ.get("REDIS_URL", "redis://localhost:6379") -RESULT_TTL = int(os.environ.get("RESULT_TTL", "3600")) -QUEUE_NAME = "patches" - - -def main(): - conn = redis.from_url(REDIS_URL) - conn.ping() - print(f"Worker ready, listening on queue '{QUEUE_NAME}'") - - while True: - _, payload_bytes = conn.brpop(QUEUE_NAME) - job = json.loads(payload_bytes) - job_id = job["job_id"] - print(f"Processing {job_id}") - - try: - run_pipeline( - conn, job_id, - border_color=job.get("border_color", "#0a0a14"), - color_precision=job.get("color_precision", 8), - postprocess=job.get("postprocess", True), - ) - print(f" done") - except Exception as e: - print(f" failed: {e}") - - -def run_pipeline(conn, job_id, border_color="#0a0a14", - color_precision=8, postprocess=True): - try: - input_bytes = conn.get(f"job:{job_id}:input") - ext = (conn.get(f"job:{job_id}:ext") or b"png").decode() - if not input_bytes: - raise RuntimeError("input not found in redis") - - is_svg = ext.lower() == "svg" - - with tempfile.TemporaryDirectory(prefix="p2p_") as tmpdir: - tmpdir = Path(tmpdir) - input_path = tmpdir / f"input.{ext}" - input_path.write_bytes(input_bytes) - output_path = tmpdir / "patch.png" - - if is_svg: - convert_svg(str(input_path), str(output_path), - border_color=border_color, postprocess=postprocess) - else: - convert(str(input_path), str(output_path), - border_color=border_color, - color_precision=color_precision, - postprocess=postprocess) - - result_bytes = output_path.read_bytes() - - conn.setex(f"job:{job_id}:result", RESULT_TTL, result_bytes) - conn.setex(f"job:{job_id}:status", RESULT_TTL, "complete") - conn.delete(f"job:{job_id}:input") - - except Exception as e: - conn.setex(f"job:{job_id}:status", RESULT_TTL, "failed") - conn.setex(f"job:{job_id}:error", RESULT_TTL, str(e)) - raise - - -if __name__ == "__main__": - main()