Something went wrong. Try again.
fork of gh:maxwoffard/pic-to-patch
Something went wrong. Try again.
18 kB · 687 lines
Go
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688package 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) }}