Skip to content

Commit

Permalink
Merge pull request #107 from BlazingTwist/image2
Browse files Browse the repository at this point in the history
Add support for writing images with alpha / simplify working with images
  • Loading branch information
nick-paul authored Nov 14, 2024
2 parents bae2b35 + 77de808 commit c45ab4a
Show file tree
Hide file tree
Showing 16 changed files with 610 additions and 227 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,17 @@
import aya.ext.graphics.CanvasTable;
import aya.ext.graphics.GraphicsInstruction;
import aya.ext.image.AyaImage;
import aya.obj.symbol.Symbol;
import aya.obj.symbol.SymbolTable;

public class GetPixelsGraphicsInstruction extends GraphicsInstruction {

private static final Symbol DATA = SymbolTable.getSymbol("data");

public GetPixelsGraphicsInstruction(CanvasTable canvas_table) {
super(canvas_table, "get_pixels", "");
_doc = "canvas_id: get pixels ";
}

@Override
protected void doCanvasCommand(Canvas cvs, BlockEvaluator blockEvaluator) {
AyaImage img = AyaImage.fromBufferedImage(cvs.getBuffer());
AyaImage img = new AyaImage( cvs.getBuffer() );
blockEvaluator.push(img.toDict());
}
}
Expand Down
276 changes: 194 additions & 82 deletions src/aya/ext/image/AyaImage.java
Original file line number Diff line number Diff line change
@@ -1,107 +1,219 @@
package aya.ext.image;

import java.awt.Color;
import java.awt.image.BufferedImage;
import java.awt.image.ComponentSampleModel;
import java.awt.image.DataBuffer;
import java.awt.image.DataBufferByte;
import java.awt.image.DataBufferInt;
import java.awt.image.Raster;
import java.awt.image.SampleModel;
import java.awt.image.WritableRaster;

import aya.StaticData;
import aya.exceptions.runtime.ValueError;
import aya.obj.dict.Dict;
import aya.obj.list.List;
import aya.obj.list.numberlist.NumberList;
import aya.obj.number.Num;
import aya.obj.symbol.Symbol;
import aya.obj.symbol.SymbolTable;
import aya.util.DictReader;
import aya.util.Sym;

import java.awt.color.ColorSpace;
import java.awt.image.BufferedImage;
import java.awt.image.ColorModel;
import java.util.EnumMap;
import java.util.Map;
import java.util.stream.Collectors;

/**
* Implements the data-type for Image instructions:
* <pre>{@code
* {,
* .# meta information about the image.
* .# :{image.read} provides this information
* .# :{image.write} infers these values unless specified
* {,
* <bool> :gray
* <bool> :alpha
* <bool> :premultiplied
* <num> :java_image_type
* } :meta
*
* <num> :width
* <num> :height
*
* .# each channel is backed by a byte[] internally
* 256.R :r
* 256.R :g
* 256.R :b
* 256.R :a
* }}</pre>
*/
public class AyaImage
{
private static final Symbol SYM_META = Sym.sym("meta");
private static final Symbol SYM_WIDTH = Sym.sym("width");
private static final Symbol SYM_HEIGHT = Sym.sym("height");

public static String getDocString(String padLeft) {
return ("image\n"
+ padLeft + "meta::dict : " + ImageMeta.getDocString(padLeft + " ")
+ padLeft + "width::num : width in pixels\n"
+ padLeft + "height::num : height in pixels\n"
+ padLeft + "<hint: all channels are optional>\n"
+ padLeft + "<hint: channels are iterated in row-major order>\n"
+ padLeft + "<hint: all channel values are scaled to range [0 255]>\n"
+ padLeft + Channel.red.symbol.name() + "::list : red channel\n"
+ padLeft + Channel.green.symbol.name() + "::list : green channel\n"
+ padLeft + Channel.blue.symbol.name() + "::list : blue channel\n"
+ padLeft + Channel.alpha.symbol.name() + "::list : alpha channel\n"
);
}

public final ImageMeta imageMeta;
public final int width;
public final int height;
public final Map<Channel, byte[]> channels = new EnumMap<>(Channel.class);

public AyaImage(DictReader d) {
imageMeta = new ImageMeta(d.getDictReader(SYM_META), d.hasKey(Channel.alpha.symbol));

public class AyaImage {
/** Utility class for loading, storing and writing image in Aya */
for (Channel channel : Channel.values()) {
if (!d.hasKey(channel.symbol))
continue;

private static final Symbol DATA = SymbolTable.getSymbol("data");
private static final Symbol WIDTH = SymbolTable.getSymbol("width");
private static final Symbol HEIGHT = SymbolTable.getSymbol("height");
NumberList valueList = d.getNumberListEx(channel.symbol);
channels.put(channel, valueList.toByteArray());
}
if (channels.isEmpty()) {
throw new ValueError(d.get_err_name() + ": must have at least one channel (::r, ::g, ::b, ::a)");
}
// verify that all channels have the same length (number of pixels)
int[] channelLengths = channels.values().stream().mapToInt(c -> c.length).distinct().toArray();
if (channelLengths.length != 1) {
// construct a String that contains the lengths of each channel. example: 'r=100, g=100, b=100, a=101'
String lenStr = channels.entrySet().stream().map(e -> e.getKey().symbol.name() + "=" + e.getValue().length).collect(Collectors.joining(", "));
throw new ValueError(d.get_err_name() + ": inconsistent channel lengths, found: " + lenStr);
}
int numPixels = channelLengths[0];

private NumberList bytes;
private int width;
private int height;
boolean hasWidth = d.hasKey(SYM_WIDTH);
boolean hasHeight = d.hasKey(SYM_HEIGHT);
if (!hasWidth && !hasHeight) {
throw new ValueError(d.get_err_name() + ": must have at either ::width, ::height or both");
}

public AyaImage(NumberList bytes, int width, int height) {
this.bytes = bytes;
this.width = width;
this.height = height;
// verify that the width*height matches the number of pixels.
if (hasWidth && hasHeight) {
this.width = d.getIntEx(SYM_WIDTH);
this.height = d.getIntEx(SYM_HEIGHT);
if ((width * height) != numPixels) {
throw new ValueError(d.get_err_name() + ": number of pixels (" + numPixels + ") does not match width*height (" + width + "x" + height + ")");
}
} else if (hasWidth) {
this.width = d.getIntEx(SYM_WIDTH);
if (numPixels % width != 0) {
throw new ValueError(d.get_err_name() + ": if ::height is omitted, the number of pixels (" + numPixels + ") must be a multiple of the width (" + width + ")");
}
this.height = numPixels / width;
} else {
this.height = d.getIntEx(SYM_HEIGHT);
if (numPixels % height != 0) {
throw new ValueError(d.get_err_name() + ": if ::width is omitted, the number of pixels (" + numPixels + ") must be a multiple of the height (" + height + ")");
}
this.width = numPixels / height;
}
}

public static AyaImage fromDict(DictReader d) {
return new AyaImage(
d.getNumberListEx(DATA),
d.getIntEx(WIDTH),
d.getIntEx(HEIGHT));

public AyaImage(BufferedImage image) {
ColorModel model = image.getColorModel();
ColorSpace colorSpace = model.getColorSpace();

if (colorSpace.getType() != ColorSpace.TYPE_RGB
&& colorSpace.getType() != ColorSpace.TYPE_GRAY
&& colorSpace.getType() != ColorSpace.TYPE_3CLR
&& colorSpace.getType() != ColorSpace.TYPE_4CLR
) {
StaticData.IO.err().println("Info: ColorSpace '" + ImageHelper.getColorSpaceName(colorSpace) + "' will be converted to RGB");
}

/* One of the downsides of guaranteeing all color values to be integers in range [0, 255]
* is that higher bit depths are not supported (e.g. 16 bit grayscale).
* I think this is an acceptable limitation, since java.awt.Color also only supports 8-bit colors.
*/
int[] bitWidths = model.getComponentSize();
for (int i = 0; i < bitWidths.length; i++) {
if (bitWidths[i] > 8) {
StaticData.IO.err().println("Warning: Color component '" + colorSpace.getName(i) + "' is " + bitWidths[i] + " bits wide. Maximum supported is 8. The data will be truncated.");
}
}

this.imageMeta = new ImageMeta(image);
this.width = image.getWidth();
this.height = image.getHeight();

int numColorChannels = model.getNumColorComponents();
if (numColorChannels <= 0) {
throw new ValueError("Image has no color channels"); // I don't think this is possible in practice. See java.awt.color.ICC_Profile#getNumComponents
}

byte[] red = new byte[width * height];
byte[] green = imageMeta.isGray ? null : new byte[width * height];
byte[] blue = imageMeta.isGray ? null : new byte[width * height];
byte[] alpha = new byte[width * height];
boolean hasAlpha = model.hasAlpha();

int pixelIdx = 0;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
// getRGB takes care of all abstractions (such as premultiplied alpha, transfer types (byte/short/int), colorModel conversions, ...)
// despite the name, it also provides alpha information
int argb = image.getRGB(x, y);

red[pixelIdx] = (byte) (argb >> 16);
if (green != null) green[pixelIdx] = (byte) (argb >> 8);
if (blue != null) blue[pixelIdx] = (byte) argb;
alpha[pixelIdx] = hasAlpha ? ((byte) (argb >> 24)) : ((byte) 255); // ComponentColorModel and DirectColorModel already behave like this, but IndexedColorModel does not.

pixelIdx++;
}
}

this.channels.put(Channel.red, red);
this.channels.put(Channel.alpha, alpha);

if (imageMeta.isGray) {
this.channels.put(Channel.green, red);
this.channels.put(Channel.blue, red);
} else {
this.channels.put(Channel.green, green);
this.channels.put(Channel.blue, blue);
}
}

public Dict toDict() {
Dict d = new Dict();
d.set(DATA, new List(bytes));
d.set(WIDTH, Num.fromInt(width));
d.set(HEIGHT, Num.fromInt(height));
d.set(SYM_META, imageMeta.toDict());
d.set(SYM_WIDTH, Num.fromInt(width));
d.set(SYM_HEIGHT, Num.fromInt(height));
for (Map.Entry<Channel, byte[]> entry : channels.entrySet()) {
d.set(entry.getKey().symbol, new List(NumberList.fromUBytes(entry.getValue())));
}
return d;
}

public BufferedImage toBufferedImage() {
if (bytes.length() != (width * height * 3)) {
throw new ValueError("Error when reading image data. Data is invalid length. Must be width*height*3");
}

byte[] raw = bytes.toByteArray();

DataBuffer buffer = new DataBufferByte(raw, raw.length);
SampleModel sampleModel = new ComponentSampleModel(DataBuffer.TYPE_BYTE, width, height, 3, width*3, new int[]{2,1,0});
Raster raster = Raster.createRaster(sampleModel, buffer, null);
BufferedImage image = new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR);
image.setData(raster);

return image;
}

public static AyaImage fromBufferedImage(BufferedImage buf) {
// get DataBufferBytes from Raster
WritableRaster raster = buf.getRaster();
DataBuffer databuf = raster.getDataBuffer();
int type = databuf.getDataType();

if (type == DataBuffer.TYPE_BYTE) {
DataBufferByte data = (DataBufferByte) raster.getDataBuffer();

return new AyaImage(
NumberList.fromBytes(data.getData()),
buf.getWidth(),
buf.getHeight());
} else if (type == DataBuffer.TYPE_INT) {
DataBufferInt data = (DataBufferInt) raster.getDataBuffer();

int[] pixels = data.getData();
byte[] bytes = new byte[data.getSize() * 3];

for (int i = 0; i < data.getSize(); i++) {
int byte_index = i * 3;
final Color c = new Color(pixels[i]);
bytes[byte_index + 0] = (byte)(c.getRed());
bytes[byte_index + 1] = (byte)(c.getBlue());
bytes[byte_index + 2] = (byte)(c.getGreen());
BufferedImage image = ImageHelper.createCompatibleImage(width, height, imageMeta);

byte[] red = channels.get(Channel.red);
byte[] green = channels.get(imageMeta.isGray ? Channel.red : Channel.green);
byte[] blue = channels.get(imageMeta.isGray ? Channel.red : Channel.blue);
byte[] alpha = channels.get(Channel.alpha);

int i = 0;
for (int y = 0; y < height; y++) {
for (int x = 0; x < width; x++) {
int rgb = (alpha == null ? 0 : ((alpha[i] & 0xff) << 24))
| (red == null ? 0 : ((red[i] & 0xff) << 16))
| (green == null ? 0 : ((green[i] & 0xff) << 8))
| (blue == null ? 0 : (blue[i] & 0xff));
image.setRGB(x, y, rgb);
i++;
}

return new AyaImage(
NumberList.fromBytes(bytes),
buf.getWidth(),
buf.getHeight());
} else {
throw new ValueError("Image buffer type not supported");
}
return image;
}


}
17 changes: 17 additions & 0 deletions src/aya/ext/image/Channel.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package aya.ext.image;

import aya.obj.symbol.Symbol;
import aya.util.Sym;

public enum Channel {
red(Sym.sym("r")),
green(Sym.sym("g")),
blue(Sym.sym("b")),
alpha(Sym.sym("a"));

public final Symbol symbol;

Channel(Symbol symbol) {
this.symbol = symbol;
}
}
Loading

0 comments on commit c45ab4a

Please sign in to comment.