diff --git a/src/main/java/TerminalImageViewer.java b/src/main/java/TerminalImageViewer.java new file mode 100644 index 0000000..918ed00 --- /dev/null +++ b/src/main/java/TerminalImageViewer.java @@ -0,0 +1,301 @@ +import java.awt.Graphics2D; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.util.Arrays; + +import javax.imageio.ImageIO; + +public class TerminalImageViewer { + + /** + * Main method, handles command line arguments and loads and scales images. + */ + public static void main(String[] args) throws IOException { + if (args.length == 0) { + System.out.println("Image file name required. Use -w to set the width in characters (default: 80)."); + return; + } + + int w = 80 * 4; + + for (int i = 0; i < args.length; i++) { + String name = args[i]; + if (name.equals("-w")) { + w = 4 * Integer.parseInt(args[++i]); + continue; + } + + BufferedImage original; + if (name.startsWith("http://") || name.startsWith("https://")) { + URL url = new URL(name); + original = ImageIO.read(url); + } else { + original = ImageIO.read(new File(args[0])); + } + + int ow = original.getWidth(); + int oh = original.getHeight(); + int h = oh * w / ow; + + BufferedImage image = original; + if (w != ow) { + image = new BufferedImage(w, h, BufferedImage.TYPE_INT_RGB); + Graphics2D graphics = image.createGraphics(); + graphics.drawImage(original, 0, 0, w, h, null); + } + + ImageData imageData = new ImageData(w, h); + byte[] data = imageData.data; + int[] rgbArray = new int[w]; + for (int y = 0; y < image.getHeight(); y++) { + image.getRGB(0, y, image.getWidth(), 1, rgbArray, 0, w); + int pos = y * w * 4; + for (int x = 0; x < w; x++) { + int rgb = rgbArray[x]; + data[pos++] = (byte) (rgb >> 16); + data[pos++] = (byte) (rgb >> 8); + data[pos++] = (byte) rgb; + pos++; + } + } + System.out.println(imageData.dump()); + } + } + + /** + * ANSI control code helpers + */ + static class Ansi { + public static final String RESET = "\u001b[0m"; + + public static String fgColor(int r, int g, int b) { + return "\u001b[38;2;" + (r & 255) + ";" + (g & 255) + ";" + (b & 255) + "m"; + } + + public static String bgColor(int r, int g, int b) { + return "\u001b[48;2;" + (r & 255) + ";" + (g & 255) + ";" + (b & 255) + "m"; + } + } + + /** + * Converts 4x8 RGB pixel to a unicode character and a foreground and background character. + * Uses a variation of the median cut algorithm to determine a two-color palette for the + * character, then creates a corresponding bitmap for the partial image covered by the + * character and finds the best match in the character bitmap table. + */ + static class BlockChar { + + /** + * Assumed bitmaps of the supported characters + */ + static int[] BITMAPS = new int[] { + 0x00000000, ' ', + + 0x000ff000, '\u2501', + 0x000cc000, '\u2578', + 0x00033000, '\u257a', + + 0xffff0000, '\u2580', // upper 1/2 + + 0x0000000f, '\u2581', // lower 1/8 + 0x000000ff, '\u2582', // lower 1/4 + 0x00000fff, '\u2583', + 0x0000ffff, '\u2584', // lower 1/2 + 0x000fffff, '\u2585', + 0x00ffffff, '\u2586', // lower 3/4 + 0x0fffffff, '\u2587', + 0xffffffff, '\u2588', // full + + 0xeeeeeeee, '\u258a', // left 3/4 + 0xcccccccc, '\u258c', // left 1/2 + 0x88888888, '\u258e', // left 1/4 + + 0x0000cccc, '\u2596', // quadrant lower left + 0x00003333, '\u2597', // quadrant lower right + 0xcccc0000, '\u2598', // quadrant upper left + 0xccccffff, '\u2599', // ... + 0xcccc3333, '\u259a', + 0xffffcccc, '\u259b', + 0xffff3333, '\u259c', + 0x33330000, '\u259d', + 0x3333cccc, '\u259e', + 0x3333ffff, '\u259f', + + 0x0006ff60, '\u25cf', // Black circle + + 0x000137f0, '\u25e2', // Triangles + 0x0008cef0, '\u25e3', + 0x000fec80, '\u25e4', + 0x000f7310, '\u25e5' + }; + + /** Minimum value for each color channel. */ + int[] min = new int[3]; + + /** Maximum value for each color channel. */ + int[] max = new int[3]; + + /** Red, green and blue components of the selected background color. */ + int[] bgColor = new int[3]; + + /** Red, green and blue components of the selected background color. */ + int[] fgColor = new int[3]; + + /** The selected character. */ + char character; + + /** + * Converts a set of pixels to a unicode character and a background and foreground color. + * data contains the rgba values, p0 is the start point in data and scanWidth the number + * of bytes in each row of data. + */ + void load(byte[] data, int p0, int scanWidth) { + Arrays.fill(min, 255); + Arrays.fill(max, 0); + Arrays.fill(bgColor, 0); + Arrays.fill(fgColor, 0); + + // Determine the minimum and maximum value for each color channel + int pos = p0; + for (int y = 0; y < 8; y++) { + for (int x = 0; x < 4; x++) { + for (int i = 0; i < 3; i++) { + int d = data[pos++] & 255; + min[i] = Math.min(min[i], d); + max[i] = Math.max(max[i], d); + } + pos++; // Alpha + } + pos += scanWidth - 16; + } + + // Determine the color channel with the greatest range. + int splitIndex = 0; + int bestSplit = 0; + for (int i = 0; i < 3; i++) { + if (max[i] - min[i] > bestSplit) { + bestSplit = max[i] - min[i]; + splitIndex = i; + } + } + // We just split at the middle of the interval instead of computing the median. + int splitValue = min[splitIndex] + bestSplit / 2; + + // Compute a bitmap using the given split and sum the color values for both buckets. + int bits = 0; + int fgCount = 0; + int bgCount = 0; + + pos = p0; + for (int y = 0; y < 8; y++) { + for (int x = 0; x < 4; x++) { + bits = bits << 1; + int[] avg; + if ((data[pos + splitIndex] & 255) > splitValue) { + avg = fgColor; + bits |= 1; + fgCount++; + } else { + avg = bgColor; + bgCount++; + } + for (int i = 0; i < 3; i++) { + avg[i] += data[pos++] & 255; + } + pos++; // Alpha + } + pos += scanWidth - 16; + } + + // Calculate the average color value for each bucket + for (int i = 0; i < 3; i++) { + if (bgCount != 0) { + bgColor[i] /= bgCount; + } + if (fgCount != 0) { + fgColor[i] /= fgCount; + } + } + + // Find the best bitmap match by counting the bits that don't match, including + // the inverted bitmaps. + int bestDiff = Integer.MAX_VALUE; + boolean invert = false; + for (int i = 0; i < BITMAPS.length; i += 2) { + int diff = Integer.bitCount(BITMAPS[i] ^ bits); + if (diff < bestDiff) { + character = (char) BITMAPS[i + 1]; + bestDiff = diff; + invert = false; + } + diff = Integer.bitCount((~BITMAPS[i]) ^ bits); + if (diff < bestDiff) { + character = (char) BITMAPS[i + 1]; + bestDiff = diff; + invert = true; + } + } + + // If the match is quite bad, use a shade image instead. + if (bestDiff > 12) { + invert = false; + character = " \u2591\u2592\u2593\u2588".charAt(Math.min(4, fgCount * 5 / 32)); + } + + // If we use an inverted character, we need to swap the colors. + if (invert) { + int[] tmp = bgColor; + bgColor = fgColor; + fgColor = tmp; + } + } + } + + /** + * Roughly modeled after the corresponding HTML 5 class. + */ + static class ImageData { + public final int width; + public final int height; + public final byte[] data; + + public ImageData(int width, int height) { + this.width = width; + this.height = height; + this.data = new byte[width * height * 4]; + } + + /** + * Convert the image to an Ansi control character string setting the colors + */ + public String dump() { + StringBuilder sb = new StringBuilder(); + BlockChar blockChar = new BlockChar(); + + for (int y = 0; y < height - 7; y += 8) { + int pos = y * width * 4; + String lastFg = ""; + String lastBg = ""; + for (int x = 0; x < width - 3; x += 4) { + blockChar.load(data, pos, width * 4); + String fg = Ansi.fgColor(blockChar.fgColor[0], blockChar.fgColor[1], blockChar.fgColor[2]); + String bg = Ansi.bgColor(blockChar.bgColor[0], blockChar.bgColor[1], blockChar.bgColor[2]); + if (!fg.equals(lastFg)) { + sb.append(fg); + lastFg = fg; + } + if (!bg.equals(lastBg)) { + sb.append(bg); + lastBg = bg; + } + sb.append(blockChar.character); + pos += 16; + } + sb.append(Ansi.RESET).append("\n"); + } + return sb.toString(); + } + } +}