Skip to content

Commit 8e420f2

Browse files
committed
improve tile ID rendering within the BoundingBoxRenderer so that tile IDs are easier to read
1 parent 289f281 commit 8e420f2

2 files changed

Lines changed: 152 additions & 56 deletions

File tree

render-app/src/main/java/org/janelia/alignment/BoundingBoxRenderer.java

Lines changed: 87 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
import java.awt.FontMetrics;
77
import java.awt.Graphics2D;
88
import java.awt.Rectangle;
9+
import java.awt.RenderingHints;
910
import java.awt.Stroke;
1011
import java.awt.image.BufferedImage;
12+
import java.util.ArrayList;
1113
import java.util.List;
1214

1315
import org.janelia.alignment.spec.TileSpec;
@@ -18,10 +20,25 @@
1820
* Simple utility to render bounding boxes for tiles.
1921
* If there is enough area to display them, tile identifiers are also rendered inside each box.
2022
*
23+
* <p>The font size is fixed at {@value #TILE_ID_FONT_SIZE} points regardless of render scale.
24+
* For each tile the number of characters that fit on one line is computed from the actual
25+
* scaled box width and the font metrics, so labels wrap correctly at any scale. If the box
26+
* is too narrow to display even one character the label is omitted entirely for that tile.</p>
27+
*
2128
* @author Eric Trautman
2229
*/
2330
public class BoundingBoxRenderer {
2431

32+
/** Point size of the font used to render tile identifiers. */
33+
public static final int TILE_ID_FONT_SIZE = 12;
34+
35+
/**
36+
* Fraction of each box dimension reserved as padding margin when deciding whether tile-id
37+
* text fits. A value of 1.3 means the text block must fit within ~77% of the box width
38+
* and height (i.e. {@code usable = boxDimension / TILE_ID_BOX_MARGIN}).
39+
*/
40+
public static final double TILE_ID_BOX_MARGIN = 1.3;
41+
2542
private final RenderParameters renderParameters;
2643
private final double xOffset;
2744
private final double yOffset;
@@ -38,12 +55,10 @@ public BoundingBoxRenderer(final RenderParameters renderParameters,
3855
public BoundingBoxRenderer(final RenderParameters renderParameters,
3956
final Color foregroundColor,
4057
final float lineWidth) {
41-
4258
this.renderParameters = renderParameters;
4359
this.xOffset = renderParameters.getX();
4460
this.yOffset = renderParameters.getY();
4561
this.scale = renderParameters.getScale();
46-
4762
this.foregroundColor = foregroundColor;
4863

4964
if (renderParameters.getBackgroundRGBColor() == null) {
@@ -60,6 +75,11 @@ public void render(final BufferedImage targetImage)
6075

6176
final Graphics2D targetGraphics = targetImage.createGraphics();
6277

78+
targetGraphics.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING,
79+
RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
80+
targetGraphics.setRenderingHint(RenderingHints.KEY_RENDERING,
81+
RenderingHints.VALUE_RENDER_QUALITY);
82+
6383
targetGraphics.setColor(foregroundColor);
6484
targetGraphics.setStroke(stroke);
6585

@@ -69,52 +89,66 @@ public void render(final BufferedImage targetImage)
6989
}
7090

7191
final List<TileSpec> tileSpecs = renderParameters.getTileSpecs();
72-
final int maxCharactersPerLine = 12;
7392

74-
int lineWidth = 0;
93+
FontMetrics metrics = null;
7594
int lineHeight = 0;
76-
int minBoxWidthForTileIdRendering = 0;
77-
if (tileSpecs.size() > 0) {
95+
int charWidth = 0; // width of a single monospaced character
96+
97+
if (! tileSpecs.isEmpty()) {
7898
targetGraphics.setFont(TILE_ID_FONT);
79-
final FontMetrics metrics = targetGraphics.getFontMetrics();
80-
lineWidth = metrics.stringWidth("A") * maxCharactersPerLine;
99+
metrics = targetGraphics.getFontMetrics();
81100
lineHeight = metrics.getHeight();
82-
83-
// add margin that should be good enough for 'typical' overlap
84-
minBoxWidthForTileIdRendering = (int) (lineWidth * 1.3) + 1;
101+
// MONOSPACED: all characters have the same advance width.
102+
charWidth = metrics.charWidth('A');
85103
}
86104

87-
Rectangle box;
88-
String tileId;
89-
int x;
90-
int y;
91-
int start;
92105
for (final TileSpec tileSpec : tileSpecs) {
93106

94-
box = getScaledBox(tileSpec);
107+
final Rectangle box = getScaledBox(tileSpec);
95108
targetGraphics.draw(box);
96109

97-
if (box.width > minBoxWidthForTileIdRendering) {
110+
final String tileId = tileSpec.getTileId();
111+
if (tileId == null || tileId.isEmpty()) {
112+
continue;
113+
}
98114

99-
tileId = tileSpec.getTileId();
115+
// Derive how many margin pixels to leave on each side (same fraction as before).
116+
final int usableWidth = (int) (box.width / TILE_ID_BOX_MARGIN);
100117

101-
if (tileId != null) {
102-
x = box.x + ((box.width - lineWidth) / 2); // center tileId horizontally
103-
y = box.y + (box.height / 4); // shift tileId down from top to avoid 'typical' overlap
118+
// How many characters fit on one line inside this box?
119+
final int charsPerLine = usableWidth / charWidth;
104120

105-
start = 0;
106-
for (int stop = maxCharactersPerLine; stop < tileId.length(); stop += maxCharactersPerLine) {
107-
targetGraphics.drawString(tileId.substring(start, stop), x, y);
108-
y = y + lineHeight;
109-
start = stop;
110-
}
111-
if (start < tileId.length()) {
112-
targetGraphics.drawString(tileId.substring(start), x, y);
113-
}
114-
}
121+
// If not even one character fits, skip the label for this tile.
122+
if (charsPerLine < 1) {
123+
continue;
124+
}
125+
126+
// How many lines does this tile id need, and do they fit vertically?
127+
final List<String> lines = wrapText(tileId, charsPerLine);
128+
final int totalTextHeight = lines.size() * lineHeight;
115129

130+
// Only draw if the wrapped text fits inside the usable box height (same margin as width).
131+
final int usableHeight = (int) (box.height / TILE_ID_BOX_MARGIN);
132+
if (totalTextHeight > usableHeight) {
133+
continue;
134+
}
135+
136+
// Measure the widest line so we can centre the block horizontally.
137+
int maxLineWidth = 0;
138+
for (final String line : lines) {
139+
final int w = metrics.stringWidth(line);
140+
if (w > maxLineWidth) {
141+
maxLineWidth = w;
142+
}
116143
}
117144

145+
final int x = box.x + ((box.width - maxLineWidth) / 2); // centre horizontally
146+
int y = box.y + ((box.height - totalTextHeight) / 2) + metrics.getAscent(); // centre vertically
147+
148+
for (final String line : lines) {
149+
targetGraphics.drawString(line, x, y);
150+
y += lineHeight;
151+
}
118152
}
119153

120154
if (renderParameters.isAddWarpFieldDebugOverlay()) {
@@ -129,6 +163,25 @@ public void render(final BufferedImage targetImage)
129163
LOG.debug("render: exit, boxes for {} tiles rendered", tileSpecs.size());
130164
}
131165

166+
/**
167+
* Wraps {@code text} into lines of at most {@code maxChars} characters each,
168+
* breaking only at character boundaries (no word-wrap, matching the original behaviour).
169+
*
170+
* @param text the string to wrap.
171+
* @param maxChars maximum number of characters per line (must be &gt;= 1).
172+
* @return list of lines, never empty.
173+
*/
174+
static List<String> wrapText(final String text, final int maxChars) {
175+
final List<String> lines = new ArrayList<>();
176+
int start = 0;
177+
while (start < text.length()) {
178+
final int end = Math.min(start + maxChars, text.length());
179+
lines.add(text.substring(start, end));
180+
start = end;
181+
}
182+
return lines;
183+
}
184+
132185
private Rectangle getScaledBox(final TileSpec tileSpec) {
133186
final double x = (tileSpec.getMinX() - xOffset) * scale;
134187
final double y = (tileSpec.getMinY() - yOffset) * scale;
@@ -139,5 +192,5 @@ private Rectangle getScaledBox(final TileSpec tileSpec) {
139192

140193
private static final Logger LOG = LoggerFactory.getLogger(BoundingBoxRenderer.class);
141194

142-
private static final Font TILE_ID_FONT = new Font(Font.MONOSPACED, Font.PLAIN, 12);
143-
}
195+
private static final Font TILE_ID_FONT = new Font(Font.MONOSPACED, Font.BOLD, TILE_ID_FONT_SIZE);
196+
}

render-app/src/test/java/org/janelia/alignment/BoundingBoxRendererTest.java

Lines changed: 65 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,11 @@
22

33
import java.awt.Color;
44
import java.awt.image.BufferedImage;
5+
import java.io.File;
56
import java.util.Arrays;
67

8+
import javax.imageio.ImageIO;
9+
710
import org.junit.Assert;
811
import org.junit.Test;
912

@@ -17,28 +20,7 @@ public class BoundingBoxRendererTest {
1720

1821
@Test
1922
public void testRender() {
20-
21-
final String json =
22-
"{\n" +
23-
" \"x\" : 0.0, \"y\" : 0.0, \"width\" : 400, \"height\" : 100, \"scale\" : 1.0,\n" +
24-
" \"tileSpecs\" : [ {\n" +
25-
" \"tileId\" : \"tile_a.1.0\",\n" +
26-
" \"z\" : 1.0, \"minX\" : 0.0, \"minY\" : 0.0, \"maxX\" : 199.0, \"maxY\" : 99.0, \"width\" : 200.0, \"height\" : 100.0,\n" +
27-
" \"mipmapLevels\" : { \"0\" : { \"imageUrl\" : \"src/test/resources/stitch-test/col0075_row0021_cam1.png\" } },\n" +
28-
" \"transforms\" : { \"type\" : \"list\", \"specList\" : [ \n" +
29-
" { \"className\" : \"mpicbg.trakem2.transform.AffineModel2D\", \"dataString\" : \"1 0 0 1 0 0\" } ]\n" +
30-
" }\n" +
31-
" }, {\n" +
32-
" \"tileId\" : \"tile_b.1.0\",\n" +
33-
" \"z\" : 1.0, \"minX\" : 190.0, \"minY\" : 0.0, \"maxX\" : 389.0, \"maxY\" : 99.0, \"width\" : 200.0, \"height\" : 100.0,\n" +
34-
" \"mipmapLevels\" : { \"0\" : { \"imageUrl\" : \"src/test/resources/stitch-test/col0076_row0021_cam0.png\" } },\n" +
35-
" \"transforms\" : { \"type\" : \"list\", \"specList\" : [ \n" +
36-
" { \"className\" : \"mpicbg.trakem2.transform.AffineModel2D\", \"dataString\" : \"1 0 0 1 190 0\" } ]\n" +
37-
" }\n" +
38-
" } ]\n" +
39-
"}";
40-
41-
final RenderParameters renderParameters = RenderParameters.parseJson(json);
23+
final RenderParameters renderParameters = RenderParameters.parseJson(TEST_JSON);
4224

4325
final BufferedImage bufferedImage = renderParameters.openTargetImage();
4426

@@ -65,4 +47,65 @@ public void testRender() {
6547
}
6648

6749
}
50+
51+
private static final String TEST_JSON =
52+
"{\n" +
53+
" \"x\" : 0.0, \"y\" : 0.0, \"width\" : 400, \"height\" : 100, \"scale\" : 1.0,\n" +
54+
" \"tileSpecs\" : [ {\n" +
55+
" \"tileId\" : \"tile_a.1.0\",\n" +
56+
" \"z\" : 1.0, \"minX\" : 0.0, \"minY\" : 0.0, \"maxX\" : 199.0, \"maxY\" : 99.0, \"width\" : 200.0, \"height\" : 100.0,\n" +
57+
" \"mipmapLevels\" : { \"0\" : { \"imageUrl\" : \"src/test/resources/stitch-test/col0075_row0021_cam1.png\" } },\n" +
58+
" \"transforms\" : { \"type\" : \"list\", \"specList\" : [ \n" +
59+
" { \"className\" : \"mpicbg.trakem2.transform.AffineModel2D\", \"dataString\" : \"1 0 0 1 0 0\" } ]\n" +
60+
" }\n" +
61+
" }, {\n" +
62+
" \"tileId\" : \"tile_b.1.0\",\n" +
63+
" \"z\" : 1.0, \"minX\" : 190.0, \"minY\" : 0.0, \"maxX\" : 389.0, \"maxY\" : 99.0, \"width\" : 200.0, \"height\" : 100.0,\n" +
64+
" \"mipmapLevels\" : { \"0\" : { \"imageUrl\" : \"src/test/resources/stitch-test/col0076_row0021_cam0.png\" } },\n" +
65+
" \"transforms\" : { \"type\" : \"list\", \"specList\" : [ \n" +
66+
" { \"className\" : \"mpicbg.trakem2.transform.AffineModel2D\", \"dataString\" : \"1 0 0 1 190 0\" } ]\n" +
67+
" }\n" +
68+
" } ]\n" +
69+
"}";
70+
71+
/**
72+
* Generates PNG files showing bounding-box renders at several scales so that tile-id
73+
* label readability can be compared visually.
74+
*
75+
* <p>Output files are written to the system temp directory. The path of each generated
76+
* file is printed to stdout.</p>
77+
*
78+
* <p>Usage: run as a standard Java application (no arguments required).</p>
79+
*/
80+
public static void main(final String[] args) throws Exception {
81+
82+
// Scales to render — covering a wide range so readability can be compared easily.
83+
final double[] scales = {1.0, 0.8, 0.6, 0.4 };
84+
85+
for (final double scale : scales) {
86+
// Load fresh parameters for each scale so that canvas dimensions are recomputed.
87+
final RenderParameters renderParameters = RenderParameters.parseJson(TEST_JSON);
88+
89+
// Override the scale.
90+
renderParameters.setScale(scale);
91+
renderParameters.initializeDerivedValues();
92+
93+
final BufferedImage targetImage = renderParameters.openTargetImage();
94+
95+
final BoundingBoxRenderer renderer = new BoundingBoxRenderer(renderParameters, Color.GREEN);
96+
renderer.render(targetImage);
97+
98+
final File outputFile = new File("/Users/trautmane/Desktop",
99+
String.format("bounding_box_scale_%f.png", scale));
100+
ImageIO.write(targetImage, "PNG", outputFile);
101+
102+
System.out.printf("scale=%-5s image size=%4dx%-4d file=%s%n",
103+
scale,
104+
targetImage.getWidth(),
105+
targetImage.getHeight(),
106+
outputFile.getAbsolutePath());
107+
}
108+
109+
}
110+
68111
}

0 commit comments

Comments
 (0)