diff --git a/java2d/src/main/scala/doodle/java2d/algebra/Algebra.scala b/java2d/src/main/scala/doodle/java2d/algebra/Algebra.scala index 0f4091e5..d07dd141 100644 --- a/java2d/src/main/scala/doodle/java2d/algebra/Algebra.scala +++ b/java2d/src/main/scala/doodle/java2d/algebra/Algebra.scala @@ -33,6 +33,7 @@ final case class Algebra( applyDrawing: Apply[Reification] = Apply.apply[Reification], functorDrawing: Functor[Reification] = Apply.apply[Reification] ) extends Basic + with FilterModule with Java2dFromBufferedImage with Java2dFromBase64 with ReifiedBitmap diff --git a/java2d/src/main/scala/doodle/java2d/algebra/Filter.scala b/java2d/src/main/scala/doodle/java2d/algebra/Filter.scala new file mode 100644 index 00000000..83a1391e --- /dev/null +++ b/java2d/src/main/scala/doodle/java2d/algebra/Filter.scala @@ -0,0 +1,313 @@ +package doodle +package java2d +package algebra + +import cats.Eval +import cats.data.WriterT +import doodle.algebra.{Filter as FilterAlgebra, Kernel} +import doodle.algebra.generic.* +import doodle.core.{BoundingBox, Color, Transform as Tx} +import doodle.java2d.algebra.reified.* +import doodle.java2d.effect.Java2d +import java.awt.image.{BufferedImage, ConvolveOp, Kernel as AwtKernel} +import java.awt.{ Graphics2D, RenderingHints } + +trait FilterModule extends FilterAlgebra { + self: Algebra => + + def gaussianBlur[A](picture: Drawing[A], stdDeviation: Double): Drawing[A] = + transformPicture(picture)(applyGaussianBlur(_, stdDeviation)) + + def boxBlur[A](picture: Drawing[A], radius: Int): Drawing[A] = { + val size = 2 * radius + 1 + val kernel = + Kernel(size, size, IArray.fill(size * size)(1.0 / (size * size))) + convolveMatrix(picture, kernel, None, 0.0) + } + + def detectEdges[A](picture: Drawing[A]): Drawing[A] = + convolveMatrix(picture, FilterAlgebra.edgeDetectionKernel, None, 0.5) + + def sharpen[A](picture: Drawing[A], amount: Double): Drawing[A] = { + val baseKernel = FilterAlgebra.sharpenKernel + val scaledElements = IArray.tabulate(baseKernel.elements.length)(i => + baseKernel.elements(i) * amount + ) + val kernel = baseKernel.copy(elements = scaledElements) + convolveMatrix(picture, kernel, None, 0.0) + } + + def emboss[A](picture: Drawing[A]): Drawing[A] = + convolveMatrix(picture, FilterAlgebra.embossKernel, None, 0.5) + + def dropShadow[A]( + picture: Drawing[A], + offsetX: Double, + offsetY: Double, + blur: Double, + color: Color + ): Drawing[A] = + transformPicture(picture)( + applyDropShadow(_, offsetX.toInt, offsetY.toInt, blur, color) + ) + + def convolveMatrix[A]( + picture: Drawing[A], + kernel: Kernel, + divisor: Option[Double], + bias: Double + ): Drawing[A] = + transformPicture(picture)(applyConvolution(_, kernel, divisor, bias)) + + private def transformPicture[A]( + picture: Drawing[A] + )(transform: BufferedImage => BufferedImage): Drawing[A] = { + picture.flatMap { case (bb, rdr) => + Finalized.leaf { _ => + val width = Math.max(1, Math.ceil(bb.width).toInt + 100) + val height = Math.max(1, Math.ceil(bb.height).toInt + 100) + + val image = + new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + val g2d = image.createGraphics() + setupGraphics(g2d) + + // Transform to center + val centerTx = Tx.translate(width / 2.0, height / 2.0) + + // Get the reification and render it + val (txResult, renderable) = rdr.run(centerTx).value + val (reification, a) = renderable.run.value + + Java2d.render(g2d, reification, txResult) + g2d.dispose() + + val filtered = transform(image) + + // Create new bounding box + val filteredBB = BoundingBox.centered( + filtered.getWidth.toDouble, + filtered.getHeight.toDouble + ) + + val newRenderable: Renderable[Reification, A] = Renderable { tx => + Eval.now( + WriterT + .liftF[Eval, List[Reified], A]( + Eval.now(a) + ) + .tell(List(Reified.bitmap(tx, filtered))) + ) + } + + (filteredBB, newRenderable) + } + } + } + + private def setupGraphics(g2d: Graphics2D): Unit = { + g2d.setRenderingHint( + RenderingHints.KEY_ANTIALIASING, + RenderingHints.VALUE_ANTIALIAS_ON + ) + g2d.setRenderingHint( + RenderingHints.KEY_RENDERING, + RenderingHints.VALUE_RENDER_QUALITY + ) + g2d.setRenderingHint( + RenderingHints.KEY_INTERPOLATION, + RenderingHints.VALUE_INTERPOLATION_BILINEAR + ) + } + + private def applyGaussianBlur( + image: BufferedImage, + stdDev: Double + ): BufferedImage = { + if stdDev < 0.1 then return image + + val radius = Math.min(15, Math.max(1, Math.ceil(stdDev * 2).toInt)) + val size = 2 * radius + 1 + + val kernel = Array.ofDim[Float](size * size) + var sum = 0.0f + val sigma2 = 2.0f * stdDev.toFloat * stdDev.toFloat + + for y <- 0 until size; x <- 0 until size do { + val dx = x - radius + val dy = y - radius + val value = Math.exp(-(dx * dx + dy * dy) / sigma2).toFloat + kernel(y * size + x) = value + sum += value + } + + for i <- kernel.indices do { + kernel(i) /= sum + } + + applyKernel(image, kernel, size, size) + } + + private def applyConvolution( + image: BufferedImage, + kernel: Kernel, + divisor: Option[Double], + bias: Double + ): BufferedImage = { + val kernelArray = + Array.tabulate(kernel.elements.length)(i => kernel.elements(i).toFloat) + val sum = kernelArray.sum + val actualDivisor = divisor + .getOrElse(if Math.abs(sum) < 0.001 then 1.0 else sum.toDouble) + .toFloat + + val normalized = kernelArray.map(_ / actualDivisor) + + val result = applyKernel(image, normalized, kernel.width, kernel.height) + + if Math.abs(bias) > 0.001 then { + applyBias(result, bias) + } else { + result + } + } + + private def applyKernel( + image: BufferedImage, + kernel: Array[Float], + kw: Int, + kh: Int + ): BufferedImage = { + val src = ensureARGB(image) + val dest = new BufferedImage( + src.getWidth, + src.getHeight, + BufferedImage.TYPE_INT_ARGB + ) + + try { + val awtKernel = new AwtKernel(kw, kh, kernel) + val op = new ConvolveOp(awtKernel, ConvolveOp.EDGE_NO_OP, null) + op.filter(src, dest) + } catch { + case _: Exception => + manualConvolve(src, kernel, kw, kh, dest) + } + + dest + } + + private def manualConvolve( + src: BufferedImage, + kernel: Array[Float], + kw: Int, + kh: Int, + dest: BufferedImage + ): Unit = { + val width = src.getWidth + val height = src.getHeight + val kcx = kw / 2 + val kcy = kh / 2 + + for y <- 0 until height; x <- 0 until width do { + var r = 0f + var g = 0f + var b = 0f + var a = 0f + + for ky <- 0 until kh; kx <- 0 until kw do { + val sx = Math.min(width - 1, Math.max(0, x + kx - kcx)) + val sy = Math.min(height - 1, Math.max(0, y + ky - kcy)) + val pixel = src.getRGB(sx, sy) + val weight = kernel(ky * kw + kx) + + a += ((pixel >> 24) & 0xff) * weight + r += ((pixel >> 16) & 0xff) * weight + g += ((pixel >> 8) & 0xff) * weight + b += (pixel & 0xff) * weight + } + + val newPixel = + (Math.min(255, Math.max(0, a.toInt)) << 24) | + (Math.min(255, Math.max(0, r.toInt)) << 16) | + (Math.min(255, Math.max(0, g.toInt)) << 8) | + Math.min(255, Math.max(0, b.toInt)) + + dest.setRGB(x, y, newPixel) + } + } + + private def ensureARGB(image: BufferedImage): BufferedImage = { + if image.getType == BufferedImage.TYPE_INT_ARGB then { + image + } else { + val argb = new BufferedImage( + image.getWidth, + image.getHeight, + BufferedImage.TYPE_INT_ARGB + ) + val g = argb.createGraphics() + g.drawImage(image, 0, 0, null) + g.dispose() + argb + } + } + + private def applyBias(image: BufferedImage, bias: Double): BufferedImage = { + val biasInt = (bias * 127).toInt + for y <- 0 until image.getHeight; x <- 0 until image.getWidth do { + val pixel = image.getRGB(x, y) + val a = (pixel >> 24) & 0xff + val r = Math.min(255, Math.max(0, ((pixel >> 16) & 0xff) + biasInt)) + val g = Math.min(255, Math.max(0, ((pixel >> 8) & 0xff) + biasInt)) + val b = Math.min(255, Math.max(0, (pixel & 0xff) + biasInt)) + image.setRGB(x, y, (a << 24) | (r << 16) | (g << 8) | b) + } + image + } + + private def applyDropShadow( + image: BufferedImage, + offsetX: Int, + offsetY: Int, + blur: Double, + shadowColor: Color + ): BufferedImage = { + val padding = Math.ceil(blur * 3).toInt + 5 + val newWidth = image.getWidth + Math.abs(offsetX) + padding * 2 + val newHeight = image.getHeight + Math.abs(offsetY) + padding * 2 + + val result = + new BufferedImage(newWidth, newHeight, BufferedImage.TYPE_INT_ARGB) + val g2d = result.createGraphics() + setupGraphics(g2d) + + // Create shadow from alpha + val shadow = new BufferedImage( + image.getWidth, + image.getHeight, + BufferedImage.TYPE_INT_ARGB + ) + val rgb = shadowColor.toRgb + + for y <- 0 until image.getHeight; x <- 0 until image.getWidth do { + val pixel = image.getRGB(x, y) + val alpha = ((pixel >> 24) & 0xff) * rgb.a.get + val shadowPixel = + (alpha.toInt << 24) | + ((rgb.r.get * 255).toInt << 16) | + ((rgb.g.get * 255).toInt << 8) | + (rgb.b.get * 255).toInt + shadow.setRGB(x, y, shadowPixel) + } + + val blurredShadow = + if blur > 0.5 then applyGaussianBlur(shadow, blur) else shadow + + g2d.drawImage(blurredShadow, offsetX + padding, offsetY + padding, null) + g2d.drawImage(image, padding, padding, null) + + g2d.dispose() + result + } +} diff --git a/java2d/src/main/scala/doodle/java2d/examples/Filter.scala b/java2d/src/main/scala/doodle/java2d/examples/Filter.scala new file mode 100644 index 00000000..41d970c6 --- /dev/null +++ b/java2d/src/main/scala/doodle/java2d/examples/Filter.scala @@ -0,0 +1,99 @@ +package doodle.java2d.examples + +import cats.effect.* +import cats.effect.unsafe.implicits.global + +import doodle.core.* +import doodle.core.font.Font + +import doodle.java2d.* +import doodle.syntax.all.* +import doodle.algebra.Kernel + +object Filter extends IOApp { + + def basicFilters: Picture[Unit] = { + val circle = Picture.circle(80).fillColor(Color.red) + val square = Picture.square(80).fillColor(Color.blue) + + val row1 = List( + circle.above(Picture.text("Original").margin(10)), + circle.blur(5.0).above(Picture.text("Blur 5.0").margin(10)), + circle.blur(10.0).above(Picture.text("Blur 10.0").margin(10)) + ).map(_.margin(40)).allBeside + + val row2 = List( + square.above(Picture.text("Original").margin(10)), + square.detectEdges.above(Picture.text("Edges").margin(10)), + square.sharpen(2.0).above(Picture.text("Sharpen").margin(10)) + ).map(_.margin(40)).allBeside + + row1.above(row2.margin(40)) + } + + def convolutionExamples: Picture[Unit] = { + val base = Picture + .circle(60) + .fillColor(Color.orange) + .on(Picture.square(60).fillColor(Color.purple)) + + // Edge detection kernel + val edgeKernel = Kernel( + 3, + 3, + IArray( + -1, -1, -1, -1, 8, -1, -1, -1, -1 + ).map(_.toDouble) + ) + + // Emboss + val examples = List( + base.above(Picture.text("Original").margin(10)), + base.emboss.above(Picture.text("Emboss").margin(10)), + base.convolve(edgeKernel).above(Picture.text("Custom Edge").margin(10)) + ).map(_.margin(40)).allBeside + + examples + } + + def dropShadowExample: Picture[Unit] = { + val shape = Picture.star(5, 50, 25).fillColor(Color.green) + + List( + shape.above(Picture.text("Original").margin(10)), + shape + .dropShadow(10, 10, 5, Color.black.alpha(0.5.normalized)) + .above(Picture.text("Drop Shadow").margin(10)) + ).map(_.margin(40)).allBeside + } + + def run(args: List[String]): IO[ExitCode] = { + val fullExample = List( + Picture + .text("Basic Filters") + .font(Font.defaultSansSerif.size(18)) + .margin(20), + basicFilters, + Picture + .text("Convolution Examples") + .font(Font.defaultSansSerif.size(18)) + .margin(20), + convolutionExamples, + Picture + .text("Drop Shadow") + .font(Font.defaultSansSerif.size(18)) + .margin(20), + dropShadowExample + ).allAbove + + val frame = Frame.default + .withSize(1000, 700) + .withBackground(Color.white) + .withTitle("Java2D Filter Examples") + + // Fix: Use drawToIO instead of drawWithFrame + IO { + fullExample.drawWithFrame(frame) + }.flatMap(_ => IO.never) + } +} diff --git a/java2d/src/main/scala/doodle/java2d/examples/FilterDebug.scala b/java2d/src/main/scala/doodle/java2d/examples/FilterDebug.scala new file mode 100644 index 00000000..fbdce411 --- /dev/null +++ b/java2d/src/main/scala/doodle/java2d/examples/FilterDebug.scala @@ -0,0 +1,73 @@ +package doodle.java2d.examples + +import cats.effect.* +import doodle.core.* +import doodle.java2d.* +import doodle.syntax.all.* + +object FilterDebug extends IOApp { + + def testSimpleBlur: Picture[Unit] = { + // Simple test: red circle should blur + val circle = Picture.circle(100).fillColor(Color.red) + + // Place them side by side with labels + List( + circle.above(Picture.text("Original")), + circle.blur(10.0).above(Picture.text("Blur 10.0")) + ).map(_.margin(50)).allBeside + } + + def testEdgeDetection: Picture[Unit] = { + // Blue square with edge detection + val square = Picture.square(100).fillColor(Color.blue) + + List( + square.above(Picture.text("Original")), + square.detectEdges.above(Picture.text("Edge Detection")) + ).map(_.margin(50)).allBeside + } + + def testDropShadow: Picture[Unit] = { + // Green triangle with drop shadow + val triangle = Picture.triangle(100, 100).fillColor(Color.green) + + List( + triangle.above(Picture.text("Original")), + triangle + .dropShadow(15, 15, 5, Color.black.alpha(0.7.normalized)) + .above(Picture.text("Drop Shadow")) + ).map(_.margin(50)).allBeside + } + + def run(args: List[String]): IO[ExitCode] = { + IO.println("Starting Filter Debug...") *> { + val tests = List( + Picture.text("=== BLUR TEST ===").margin(20), + testSimpleBlur.margin(20), + Picture.text("=== EDGE DETECTION TEST ===").margin(20), + testEdgeDetection.margin(20), + Picture.text("=== DROP SHADOW TEST ===").margin(20), + testDropShadow.margin(20) + ).allAbove + + val frame = Frame.default + .withSize(600, 800) + .withBackground(Color.lightGray) + .withTitle("Filter Debug") + + IO { + import cats.effect.unsafe.implicits.global + tests.drawWithFrame(frame) + } + } *> + IO.println("Filter Debug window should be visible") *> + IO.println("Check if filters are applying:") *> + IO.println("1. Blur should make the red circle fuzzy") *> + IO.println("2. Edge detection should show edges of blue square") *> + IO.println( + "3. Drop shadow should show a shadow offset from green triangle" + ) *> + IO.never + } +} diff --git a/java2d/src/main/scala/doodle/java2d/package.scala b/java2d/src/main/scala/doodle/java2d/package.scala index 93d6b3d0..176d8f56 100644 --- a/java2d/src/main/scala/doodle/java2d/package.scala +++ b/java2d/src/main/scala/doodle/java2d/package.scala @@ -38,6 +38,7 @@ package object java2d extends Java2dToPicture { doodle.algebra.Algebra with Basic with Bitmap + with Filter with FromBufferedImage with FromPngBase64 with FromGifBase64 @@ -88,6 +89,7 @@ package object java2d extends Java2dToPicture { object Picture extends BaseConstructor with BitmapConstructor + with FilterConstructor with FromGifBase64Constructor with FromPngBase64Constructor with FromJpgBase64Constructor diff --git a/java2d/src/test/scala/doodle/java2d/Java2dFilterSuite.scala b/java2d/src/test/scala/doodle/java2d/Java2dFilterSuite.scala new file mode 100644 index 00000000..90a2072a --- /dev/null +++ b/java2d/src/test/scala/doodle/java2d/Java2dFilterSuite.scala @@ -0,0 +1,206 @@ +package doodle +package java2d + +import doodle.algebra.Kernel +import doodle.core.* + +import cats.effect.unsafe.implicits.global + +import doodle.java2d.* +import doodle.java2d.effect.Java2d +import doodle.syntax.all.* +import munit.FunSuite +import java.awt.image.BufferedImage + +class Java2dFilterSuite extends FunSuite { + + def renderToImage(picture: Picture[Unit]): BufferedImage = { + + val frame = Frame.default.withSize(200, 200) + + val io = Java2d.renderBufferedImage( + frame.size, + frame.center, + frame.background, + picture + ) { (width: Int, height: Int) => + new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB) + } + + val (image, _) = io.unsafeRunSync() + image + } + + test("gaussianBlur should actually blur the image") { + val sharp = Picture.circle(40).fillColor(Color.red) + val blurred = sharp.blur(5.0) + + val sharpImage = renderToImage(sharp) + val blurredImage = renderToImage(blurred) + + // Check that colors have spread (blur effect) + val centerX = sharpImage.getWidth / 2 + val centerY = sharpImage.getHeight / 2 + + // Check a point near the edge of the circle (should be affected by blur) + val edgeX = centerX + 35 + val edgeY = centerY + + val sharpEdge = sharpImage.getRGB(edgeX, edgeY) + val blurredEdge = blurredImage.getRGB(edgeX, edgeY) + + // In sharp image, edge should be transparent (alpha = 0) + // In blurred image, edge should have some color (alpha > 0) + val sharpAlpha = (sharpEdge >> 24) & 0xff + val blurredAlpha = (blurredEdge >> 24) & 0xff + + // Blur should spread color to previously empty areas + assert(blurredAlpha > sharpAlpha || blurredAlpha > 0) + } + + test("detectEdges should highlight boundaries") { + val solid = Picture.square(60).fillColor(Color.blue) + val edges = solid.detectEdges + + val solidImage = renderToImage(solid) + val edgeImage = renderToImage(edges) + + // Sample a few points to verify edge detection worked + val centerX = solidImage.getWidth / 2 + val centerY = solidImage.getHeight / 2 + + // Center should be different (edges are typically darker in uniform areas) + val originalCenter = solidImage.getRGB(centerX, centerY) + val edgeCenter = edgeImage.getRGB(centerX, centerY) + + // Just verify the filter ran and produced different output + val originalBlue = originalCenter & 0xff + val edgeBlue = edgeCenter & 0xff + + // Edge detection should change the image + assert(originalBlue != edgeBlue || originalCenter != edgeCenter) + } + + test("boxBlur kernel should have correct properties") { + val radius = 2 + val size = 2 * radius + 1 + val expectedKernelSize = size * size + + // Create kernel as in implementation + val kernel = Kernel( + size, + size, + IArray.fill(expectedKernelSize)(1.0 / expectedKernelSize) + ) + + // Verify kernel properties + assertEquals(kernel.width, 5) + assertEquals(kernel.height, 5) + assertEquals(kernel.elements.length, 25) + + // All elements should be equal + val expectedValue = 1.0 / 25.0 + kernel.elements.foreach { value => + assertEquals(value, expectedValue, 0.0001) + } + + // Kernel should sum to approximately 1 (allowing for floating point errors) + val sum = kernel.elements.foldLeft(0.0)(_ + _) + assertEquals(sum, 1.0, 0.01) + } + + test("sharpen kernel should emphasize center") { + val kernel = doodle.algebra.Filter.sharpenKernel + + // Center element should be positive and larger than 1 + val centerIndex = (kernel.height / 2) * kernel.width + (kernel.width / 2) + assert(kernel.elements(centerIndex) > 1.0) + + // Adjacent elements should be negative (for contrast) + assert(kernel.elements(1) < 0) // top + assert(kernel.elements(3) < 0) // left + assert(kernel.elements(5) < 0) // right + assert(kernel.elements(7) < 0) // bottom + } + + test("emboss kernel should be asymmetric") { + val kernel = doodle.algebra.Filter.embossKernel + + // Emboss kernels are typically asymmetric to create 3D effect + val topLeft = kernel.elements(0) + val bottomRight = kernel.elements(kernel.elements.length - 1) + + // These should have opposite signs for emboss effect + assert(topLeft * bottomRight < 0) + } + + test("dropShadow should create offset shadow") { + val circle = Picture.circle(30).fillColor(Color.red) + val withShadow = circle.dropShadow(10, 10, 2, Color.black) + + val originalImage = renderToImage(circle) + val shadowImage = renderToImage(withShadow) + + // Shadow image should be at least as large (often larger due to blur) + assert(shadowImage.getWidth >= originalImage.getWidth) + assert(shadowImage.getHeight >= originalImage.getHeight) + + // Check that we have both the original and shadow + // The center should still be red (original circle) + val centerX = shadowImage.getWidth / 2 + val centerY = shadowImage.getHeight / 2 + val centerPixel = shadowImage.getRGB(centerX, centerY) + val centerRed = (centerPixel >> 16) & 0xff + + // Center should still be reddish + assert(centerRed > 100) + } + + test("convolve with identity kernel should approximately preserve image") { + val identityKernel = Kernel( + 3, + 3, + IArray( + 0, 0, 0, 0, 1, 0, 0, 0, 0 + ).map(_.toDouble) + ) + + val original = Picture.square(40).fillColor(Color.green) + val convolved = original.convolve(identityKernel) + + val originalImage = renderToImage(original) + val convolvedImage = renderToImage(convolved) + + // Sample center pixel (away from edges where convolution might have artifacts) + val centerX = originalImage.getWidth / 2 + val centerY = originalImage.getHeight / 2 + + val origPixel = originalImage.getRGB(centerX, centerY) + val convPixel = convolvedImage.getRGB(centerX, centerY) + + // Extract color components + val origG = (origPixel >> 8) & 0xff + val convG = (convPixel >> 8) & 0xff + + // Should be approximately the same (allowing for rounding errors) + assert(Math.abs(origG - convG) < 10) + } + + test("filter composition should work") { + val base = Picture.circle(40).fillColor(Color.blue) + + // Apply filters in sequence - just verify they don't crash + val filtered1 = base.blur(2.0).sharpen(1.5) + val filtered2 = base.sharpen(1.5).blur(2.0) + + val image1 = renderToImage(filtered1) + val image2 = renderToImage(filtered2) + + // Just verify both produced images + assert(image1.getWidth > 0) + assert(image2.getWidth > 0) + + // They might be different due to non-commutative operations + // but we can't guarantee pixel-perfect differences due to rounding + } +}