HologramController.java
package org.fresnel.backend.api;
import jakarta.validation.Valid;
import org.fresnel.optics.HologramParameters;
import org.fresnel.optics.HologramSynthesizer;
import org.fresnel.optics.PngExporter;
import org.fresnel.optics.RenderResult;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import javax.imageio.ImageIO;
import java.awt.AlphaComposite;
import java.awt.Color;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.util.Base64;
/**
* Endpoints for Use Case D — hologram synthesis from a target image.
*
* <p>The target image is supplied as a base64-encoded PNG/JPEG in the JSON request
* body, decoded server-side to a square greyscale power-of-two image of the
* requested side length, then fed to the Gerchberg–Saxton synthesiser.
*/
@RestController
@RequestMapping("/api/holograms")
public class HologramController {
/** Max side length for synchronous synthesis (1024 = ~1 M FFTs per iteration). */
public static final int MAX_SIDE = 1024;
/**
* Hard cap on the base64-encoded target image. 8 MB of base64 ≈ 6 MB of decoded
* bytes, which already covers any 1024×1024 RGB PNG by a wide margin. Larger
* payloads are rejected without ever calling {@link Base64#decode(String)} or
* {@link ImageIO#read(java.io.InputStream)} so we cannot be DoS'd by giant
* attachments.
*/
public static final int MAX_BASE64_BYTES = 8 * 1024 * 1024;
@PostMapping(value = "/synthesize.png",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.IMAGE_PNG_VALUE)
public ResponseEntity<byte[]> synthesize(@Valid @RequestBody HologramRequest req) throws IOException {
HologramParameters p = decode(req);
RenderResult r = HologramSynthesizer.synthesize(p);
byte[] png = PngExporter.toPngBytes(r, p.dpi());
return png(png, "fresnel-hologram.png", "attachment");
}
@PostMapping(value = "/reconstruct.png",
consumes = MediaType.APPLICATION_JSON_VALUE,
produces = MediaType.IMAGE_PNG_VALUE)
public ResponseEntity<byte[]> reconstruct(@Valid @RequestBody HologramRequest req,
@RequestParam(value = "previewOnly", defaultValue = "false")
boolean previewOnly) throws IOException {
HologramParameters p = decode(req);
RenderResult mask = HologramSynthesizer.synthesize(p);
BufferedImage recon = HologramSynthesizer.reconstruct(mask.image(), p.outputType());
BufferedImage out = previewOnly ? recon : sideBySide(mask.image(), recon);
java.io.ByteArrayOutputStream baos = new java.io.ByteArrayOutputStream();
ImageIO.write(out, "png", baos);
return png(baos.toByteArray(), "fresnel-hologram-reconstruction.png", "inline");
}
static HologramParameters decode(HologramRequest req) throws IOException {
if (req.sidePx() > MAX_SIDE)
throw new IllegalArgumentException("sidePx > " + MAX_SIDE + " requires async render-job");
if ((req.sidePx() & (req.sidePx() - 1)) != 0)
throw new IllegalArgumentException("sidePx must be a power of two");
String b64 = stripDataUrlPrefix(req.targetImageBase64());
if (b64.length() > MAX_BASE64_BYTES)
throw new IllegalArgumentException("targetImageBase64 too large (>"
+ MAX_BASE64_BYTES + " bytes); resize before upload");
byte[] raw = Base64.getDecoder().decode(b64);
BufferedImage src = ImageIO.read(new ByteArrayInputStream(raw));
if (src == null) throw new IllegalArgumentException("could not decode targetImageBase64 as image");
BufferedImage normalised = toSquareGreyscale(src, req.sidePx());
HologramParameters.OutputType type = req.outputType() == null
? HologramParameters.OutputType.GREYSCALE_PHASE
: req.outputType();
return new HologramParameters(normalised, req.iterations(), type, req.dpi());
}
private static String stripDataUrlPrefix(String s) {
int comma = s.indexOf(',');
if (s.startsWith("data:") && comma > 0) return s.substring(comma + 1);
return s;
}
/** Centre-fit then resize to {@code n × n} greyscale. */
static BufferedImage toSquareGreyscale(BufferedImage src, int n) {
BufferedImage square = new BufferedImage(n, n, BufferedImage.TYPE_BYTE_GRAY);
Graphics2D g = square.createGraphics();
try {
g.setComposite(AlphaComposite.Src);
g.setColor(Color.BLACK);
g.fillRect(0, 0, n, n);
g.setRenderingHint(RenderingHints.KEY_INTERPOLATION,
RenderingHints.VALUE_INTERPOLATION_BILINEAR);
int sw = src.getWidth();
int sh = src.getHeight();
int s = Math.min(sw, sh);
int sx = (sw - s) / 2;
int sy = (sh - s) / 2;
g.drawImage(src, 0, 0, n, n, sx, sy, sx + s, sy + s, null);
} finally {
g.dispose();
}
return square;
}
private static BufferedImage sideBySide(BufferedImage a, BufferedImage b) {
int w = a.getWidth() + b.getWidth();
int h = Math.max(a.getHeight(), b.getHeight());
BufferedImage out = new BufferedImage(w, h, BufferedImage.TYPE_BYTE_GRAY);
Graphics2D g = out.createGraphics();
try {
g.drawImage(a, 0, 0, null);
g.drawImage(b, a.getWidth(), 0, null);
} finally {
g.dispose();
}
return out;
}
private static ResponseEntity<byte[]> png(byte[] body, String filename, String disposition) {
HttpHeaders h = new HttpHeaders();
h.setContentType(MediaType.IMAGE_PNG);
h.setContentDisposition("inline".equalsIgnoreCase(disposition)
? org.springframework.http.ContentDisposition.inline().filename(filename).build()
: org.springframework.http.ContentDisposition.attachment().filename(filename).build());
return new ResponseEntity<>(body, h, 200);
}
}