Monochromatic mode

This commit is contained in:
Bane Stojkovic
2025-12-28 23:07:50 +01:00
parent 3ad9c49f53
commit 443f76f3f9
7 changed files with 198 additions and 27 deletions
@@ -68,5 +68,9 @@ public enum DrawOption {
/**
* Whether locked components should be rendered as transparent or not.
*/
LOCKED_ALPHA;
LOCKED_ALPHA,
/**
* When applied, all colors will be converted to blackest black or whitest white
*/
MONOCHROMATIC;
}
@@ -18,6 +18,7 @@
*/
package org.diylc.presenter;
import java.awt.Color;
import java.awt.geom.Point2D;
import java.util.List;
@@ -71,4 +72,52 @@ public class CalcUtils {
return false;
return true;
}
/**
* Calculates the relative luminance (perceived brightness) of a color.
* Uses the standard formula: L = 0.299*R + 0.587*G + 0.114*B
*
* @param color the color to calculate luminance for
* @return the luminance value between 0 and 255
*/
public static double calculateLuminance(Color color) {
if (color == null) {
return 0.0;
}
return 0.299 * color.getRed() + 0.587 * color.getGreen() + 0.114 * color.getBlue();
}
/**
* Converts a color to monochrome (black or white) based on its luminance.
* Colors with luminance below the threshold (128) are converted to black,
* otherwise to white. The alpha channel is preserved if present.
*
* @param color the color to convert
* @param threshold the luminance threshold (default 128 for middle gray)
* @return black or white color, preserving alpha channel
*/
public static Color convertToMonochrome(Color color, double threshold) {
if (color == null) {
return null;
}
double luminance = calculateLuminance(color);
Color monochromeColor = (luminance < threshold) ? Color.BLACK : Color.WHITE;
// Preserve alpha channel if present
if (color.getAlpha() < 255) {
monochromeColor = new Color(monochromeColor.getRed(), monochromeColor.getGreen(),
monochromeColor.getBlue(), color.getAlpha());
}
return monochromeColor;
}
/**
* Converts a color to monochrome (black or white) based on its luminance.
* Uses the default threshold of 128 (middle gray).
*
* @param color the color to convert
* @return black or white color, preserving alpha channel
*/
public static Color convertToMonochrome(Color color) {
return convertToMonochrome(color, 128.0);
}
}
@@ -26,10 +26,12 @@ import java.lang.ref.SoftReference;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.apache.log4j.Logger;
import org.diylc.common.ComponentType;
import org.diylc.common.DrawOption;
import org.diylc.core.ComponentForRender;
import org.diylc.core.ComponentState;
import org.diylc.core.IDIYComponent;
@@ -67,14 +69,14 @@ public class DrawingCache {
*/
@SuppressWarnings("unchecked")
public void bulkPrepare(Collection<ComponentForRender> components, G2DWrapper g2d, boolean outlineMode, Project project,
double zoom, double scaleFactor) {
double zoom, double scaleFactor, Set<DrawOption> drawOptions) {
// run all renders in parallel to utilize all CPU cores
components.stream().parallel().forEach(x -> {
ComponentType type = ComponentProcessor.getInstance()
.extractComponentTypeFrom((Class<? extends IDIYComponent<?>>) x.getComponent().getClass());
if (type != null && type.getEnableCache()) {
renderAndCache(x.getComponent(), g2d, x.getState(), outlineMode, project, zoom, scaleFactor, x.getZOrder());
renderAndCache(x.getComponent(), g2d, x.getState(), outlineMode, project, zoom, scaleFactor, x.getZOrder(), drawOptions);
}
});
}
@@ -92,7 +94,7 @@ public class DrawingCache {
*/
@SuppressWarnings("unchecked")
public void draw(IDIYComponent<?> component, G2DWrapper g2d, ComponentState componentState,
boolean outlineMode, Project project, double zoom, double scaleFactor, int zOrder, Rectangle2D visibleRect) {
boolean outlineMode, Project project, double zoom, double scaleFactor, int zOrder, Rectangle2D visibleRect, Set<DrawOption> drawOptions) {
ComponentType type = ComponentProcessor.getInstance()
.extractComponentTypeFrom((Class<? extends IDIYComponent<?>>) component.getClass());
@@ -100,7 +102,7 @@ public class DrawingCache {
// if we need to apply caching
Point2D firstPoint = component.getControlPoint(0);
CacheValue value = renderAndCache(component, g2d, componentState, outlineMode, project, zoom, scaleFactor, zOrder);
CacheValue value = renderAndCache(component, g2d, componentState, outlineMode, project, zoom, scaleFactor, zOrder, drawOptions);
if (value == null) {
return;
@@ -180,7 +182,7 @@ public class DrawingCache {
}
private CacheValue renderAndCache(IDIYComponent<?> component, G2DWrapper g2d, ComponentState componentState,
boolean outlineMode, Project project, double zoom, double scaleFactor, int zOrder) {
boolean outlineMode, Project project, double zoom, double scaleFactor, int zOrder, Set<DrawOption> drawOptions) {
if (!g2d.getClipBounds().intersects(component.getCachingBounds())) {
return null;
@@ -199,9 +201,11 @@ public class DrawingCache {
}
// only honor the cache if the component hasn't changed in the meantime
// Also check if drawOptions changed (e.g., MONOCHROMATIC affects rendering)
boolean drawOptionsChanged = value == null || !value.getDrawOptions().equals(drawOptions);
if (value == null || !value.getComponent().equalsTo(component)
|| value.getState() != componentState || value.getZoom() != zoom || value.getOutlineMode() != outlineMode
|| value.getScaleFactor() != scaleFactor) {
|| value.getScaleFactor() != scaleFactor || drawOptionsChanged) {
// figure out size and placement of buffer image
Rectangle2D rect = component.getCachingBounds();
int width = (int)Math.round(rect.getWidth() * zoom);
@@ -233,7 +237,7 @@ public class DrawingCache {
cg2d.translate(-firstPoint.getX() + dx, -firstPoint.getY() + dy);
// wrap the graphics so we can track
G2DWrapper wrapper = new G2DWrapper(cg2d, zoom);
G2DWrapper wrapper = new G2DWrapper(cg2d, zoom, drawOptions);
// initialize wrapper
wrapper.startedDrawingComponent(zOrder);
@@ -275,7 +279,7 @@ public class DrawingCache {
// add to the cache
value = new CacheValue(component, image, wrapper.getCurrentArea(), wrapper.getContinuityPositiveAreas(),
wrapper.getContinuityNegativeAreas(), componentState, outlineMode, zoom, scaleFactor, dx, dy, rect);
wrapper.getContinuityNegativeAreas(), componentState, outlineMode, zoom, scaleFactor, dx, dy, rect, drawOptions);
imageCache.put(component, new SoftReference<DrawingCache.CacheValue>(value));
}
@@ -295,11 +299,12 @@ public class DrawingCache {
int dx;
int dy;
Rectangle2D cacheBounds;
Set<DrawOption> drawOptions;
public CacheValue(IDIYComponent<?> component, BufferedImage image, Area currentArea,
Map<String, Area> continuityPositiveAreas, Map<String, Area> continuityNegativeAreas,
ComponentState state, boolean outlineMode, double zoom, double scaleFactor, int dx, int dy,
Rectangle2D cacheBounds) {
Rectangle2D cacheBounds, Set<DrawOption> drawOptions) {
super();
this.cacheBounds = cacheBounds;
// take a copy of the component, so we can check if it changed in the meantime
@@ -317,6 +322,7 @@ public class DrawingCache {
this.dx = dx;
this.dy = dy;
this.scaleFactor = scaleFactor;
this.drawOptions = drawOptions;
}
public IDIYComponent<?> getComponent() {
@@ -366,6 +372,10 @@ public class DrawingCache {
public Rectangle2D getCacheBounds() {
return cacheBounds;
}
public Set<DrawOption> getDrawOptions() {
return drawOptions;
}
@Override
public String toString() {
@@ -184,7 +184,7 @@ public class DrawingManager {
if (externalZoom != null)
zoom *= externalZoom;
G2DWrapper g2dWrapper = new G2DWrapper(g2d, zoom);
G2DWrapper g2dWrapper = new G2DWrapper(g2d, zoom, drawOptions);
configureRenderingHints(g2d, drawOptions);
@@ -268,7 +268,7 @@ public class DrawingManager {
// give the cache a chance to prepare all the components in multiple threads
if (drawOptions.contains(DrawOption.ENABLE_CACHING)) {
DrawingCache.Instance.bulkPrepare(componentForRenderList, g2dWrapper, outlineMode, project, zoom, scaleFactor);
DrawingCache.Instance.bulkPrepare(componentForRenderList, g2dWrapper, outlineMode, project, zoom, scaleFactor, drawOptions);
}
// componentAreaMap.clear();
@@ -562,10 +562,12 @@ public class DrawingManager {
}
}
private static void drawComponent(Project project, Set<DrawOption> drawOptions, Double scaleFactor, Rectangle2D visibleRect, IDIYComponent<?> component, G2DWrapper g2dWrapper, ComponentState state, double zoom, int i, boolean outlineMode) {
private static void drawComponent(Project project, Set<DrawOption> drawOptions, Double scaleFactor,
Rectangle2D visibleRect, IDIYComponent<?> component, G2DWrapper g2dWrapper, ComponentState state,
double zoom, int i, boolean outlineMode) {
if (drawOptions.contains(DrawOption.ENABLE_CACHING)) // go through the DrawingCache
DrawingCache.Instance.draw(component, g2dWrapper, state,
drawOptions.contains(DrawOption.OUTLINE_MODE), project, zoom, scaleFactor, i, visibleRect);
drawOptions.contains(DrawOption.OUTLINE_MODE), project, zoom, scaleFactor, i, visibleRect, drawOptions);
else // go straight to the wrapper
component.draw(g2dWrapper, state, outlineMode, project, g2dWrapper);
}
@@ -53,17 +53,11 @@ import java.awt.image.renderable.RenderableImage;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.text.AttributedCharacterIterator;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import java.util.*;
import java.util.Map.Entry;
import org.apache.log4j.Logger;
import org.diylc.common.DrawOption;
import org.diylc.common.ObjectCache;
import org.diylc.common.ZoomableStroke;
import org.diylc.core.IDrawingObserver;
@@ -96,7 +90,9 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
private int uniqueCounter = 0;
private Graphics2D canvasGraphics;
private final Graphics2D canvasGraphics;
private final Set<DrawOption> drawOptions;
private Stroke originalStroke;
private Color originalColor;
private Composite originalComposite;
@@ -115,12 +111,14 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
/**
* Creates a wrapper around specified {@link Graphics2D} object.
*
*
* @param canvasGraphics
* @param drawOptions
*/
public G2DWrapper(Graphics2D canvasGraphics, double zoom) {
public G2DWrapper(Graphics2D canvasGraphics, double zoom, Set<DrawOption> drawOptions) {
super();
this.canvasGraphics = canvasGraphics;
this.drawOptions = drawOptions;
String debugAreaPerformanceStr = System.getProperty(DEBUG_AREA_PERFORMANCE);
debugAreaPerformance = "true".equalsIgnoreCase(debugAreaPerformanceStr);
this.zoom = zoom;
@@ -758,7 +756,11 @@ class G2DWrapper extends Graphics2D implements IDrawingObserver {
@Override
public void setColor(Color c) {
canvasGraphics.setColor(c);
if (drawOptions.contains(DrawOption.MONOCHROMATIC) && c != null) {
canvasGraphics.setColor(CalcUtils.convertToMonochrome(c));
} else {
canvasGraphics.setColor(c);
}
}
@Override
@@ -3,6 +3,7 @@ package org.diylc.presenter;
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
import java.awt.Color;
import java.awt.geom.Point2D;
import java.util.Arrays;
import java.util.List;
@@ -142,4 +143,107 @@ public class CalcUtilsTest {
point2.setLocation(10.0, 21.0);
assertFalse(CalcUtils.pointsMatch(point1, point2, 0.5));
}
@Test
public void testCalculateLuminance() {
// Test pure black
assertEquals(0.0, CalcUtils.calculateLuminance(Color.BLACK), 0.001);
// Test pure white
assertEquals(255.0, CalcUtils.calculateLuminance(Color.WHITE), 0.001);
// Test pure red
double redLuminance = CalcUtils.calculateLuminance(Color.RED);
assertEquals(76.245, redLuminance, 0.001); // 0.299 * 255
// Test pure green
double greenLuminance = CalcUtils.calculateLuminance(Color.GREEN);
assertEquals(149.685, greenLuminance, 0.001); // 0.587 * 255
// Test pure blue
double blueLuminance = CalcUtils.calculateLuminance(Color.BLUE);
assertEquals(29.07, blueLuminance, 0.001); // 0.114 * 255
// Test middle gray (128, 128, 128)
Color gray = new Color(128, 128, 128);
double grayLuminance = CalcUtils.calculateLuminance(gray);
assertEquals(128.0, grayLuminance, 0.001);
// Test null color
assertEquals(0.0, CalcUtils.calculateLuminance(null), 0.001);
// Test custom color
Color custom = new Color(100, 150, 200);
double customLuminance = CalcUtils.calculateLuminance(custom);
double expected = 0.299 * 100 + 0.587 * 150 + 0.114 * 200;
assertEquals(expected, customLuminance, 0.001);
}
@Test
public void testConvertToMonochrome() {
// Test pure black -> should stay black
Color result = CalcUtils.convertToMonochrome(Color.BLACK);
assertEquals(Color.BLACK, result);
// Test pure white -> should stay white
result = CalcUtils.convertToMonochrome(Color.WHITE);
assertEquals(Color.WHITE, result);
// Test dark color -> should become black
Color darkBlue = new Color(0, 0, 50);
result = CalcUtils.convertToMonochrome(darkBlue);
assertEquals(Color.BLACK, result);
// Test light color -> should become white
Color lightYellow = new Color(255, 255, 200);
result = CalcUtils.convertToMonochrome(lightYellow);
assertEquals(Color.WHITE, result);
// Test middle gray -> should become white (threshold is 128, gray is exactly 128)
Color gray = new Color(128, 128, 128);
result = CalcUtils.convertToMonochrome(gray);
assertEquals(Color.WHITE, result);
// Test color just below threshold -> should become black
Color darkGray = new Color(127, 127, 127);
result = CalcUtils.convertToMonochrome(darkGray);
assertEquals(Color.BLACK, result);
// Test color just above threshold -> should become white
Color lightGray = new Color(129, 129, 129);
result = CalcUtils.convertToMonochrome(lightGray);
assertEquals(Color.WHITE, result);
// Test with custom threshold
Color mediumColor = new Color(100, 100, 100);
result = CalcUtils.convertToMonochrome(mediumColor, 50.0);
assertEquals(Color.WHITE, result); // Luminance ~100, threshold 50, so white
result = CalcUtils.convertToMonochrome(mediumColor, 150.0);
assertEquals(Color.BLACK, result); // Luminance ~100, threshold 150, so black
// Test null color
assertNull(CalcUtils.convertToMonochrome(null));
}
@Test
public void testConvertToMonochromeWithAlpha() {
// Test color with alpha channel - alpha should be preserved
Color transparentRed = new Color(255, 0, 0, 128);
Color result = CalcUtils.convertToMonochrome(transparentRed);
assertEquals(Color.WHITE.getRed(), result.getRed());
assertEquals(Color.WHITE.getGreen(), result.getGreen());
assertEquals(Color.WHITE.getBlue(), result.getBlue());
assertEquals(128, result.getAlpha());
// Test fully transparent color
Color transparent = new Color(100, 150, 200, 0);
result = CalcUtils.convertToMonochrome(transparent);
assertEquals(0, result.getAlpha());
// Test opaque color - alpha should remain 255
Color opaque = new Color(50, 50, 50);
result = CalcUtils.convertToMonochrome(opaque);
assertEquals(255, result.getAlpha());
}
}
@@ -52,7 +52,7 @@ public class SilkScreenDrawingProvider implements IDrawingProvider {
@Override
public void draw(int page, Graphics g, double zoomFactor) {
plugInPort.draw((Graphics2D) g, EnumSet.of(DrawOption.ANTIALIASING, DrawOption.OUTLINE_MODE),
plugInPort.draw((Graphics2D) g, EnumSet.of(DrawOption.ANTIALIASING, DrawOption.OUTLINE_MODE, DrawOption.MONOCHROMATIC),
new SilkScreenFilter(), zoomFactor, null, null);
}