package band.full.test.video.generator; import static band.full.core.Window.screen; import static band.full.video.itu.BT1886.TRUE_BLACK_TRANSFER; import static java.lang.Math.floor; import static java.lang.Math.log10; import static java.lang.Math.max; import static java.lang.Math.min; import static java.lang.Math.sqrt; import static java.lang.String.format; import static javafx.scene.layout.Background.EMPTY; import static javafx.scene.text.Font.font; import static javafx.scene.text.TextAlignment.CENTER; import static javafx.scene.text.TextAlignment.LEFT; import static javafx.scene.text.TextAlignment.RIGHT; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.TestInstance.Lifecycle.PER_CLASS; import band.full.core.Window; import band.full.core.color.CIEXYZ; import band.full.core.color.CIExy; import band.full.core.color.CIExyY; import band.full.test.video.encoder.DecoderY4M; import band.full.test.video.encoder.EncoderParameters; import band.full.test.video.encoder.EncoderY4M; import band.full.test.video.executor.FrameVerifier; import band.full.test.video.executor.FxImage; import band.full.test.video.generator.CalibratePatchesBase.Args; import band.full.video.buffer.FrameBuffer; import band.full.video.itu.ICtCp; import org.junit.jupiter.api.TestInstance; import java.io.File; import java.io.IOException; import java.text.DecimalFormat; import java.util.ArrayList; import java.util.List; import java.util.function.DoubleUnaryOperator; import javafx.geometry.Insets; import javafx.scene.Parent; import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; import javafx.scene.paint.Color; import javafx.scene.text.Text; import javafx.scene.text.TextAlignment; import javafx.scene.text.TextFlow; /** * Base class for creating single-color patches in the middle of the screen with * specified area percentage. * * @author Igor Malinin * @see CalibrateGrayscaleBase * @see CalibrateColorPatchesBase */ @TestInstance(PER_CLASS) public abstract class CalibratePatchesBase extends GeneratorBase<Args> { public static class Args { public final String file; public final String sequence; public final String set; public final String label; public final int window; // Use 0 for 100% screen fill! public final int[] yuv; public Args(String file, String sequence, String set, String label, int window, int[] yuv) { this.file = file; this.sequence = sequence; this.set = set; this.label = label; this.window = window; this.yuv = yuv; } public Args(String file, String sequence, String set, String label, int window, int y, int u, int v) { this(file, sequence, set, label, window, new int[] {y, u, v}); } @Override public String toString() { return format("Win%02d %s %03d:%03d:%03d", window, sequence, yuv[0], yuv[1], yuv[2]); } } public CalibratePatchesBase(GeneratorFactory factory, EncoderParameters params, String folder, String group) { super(factory, params, folder, x -> x.file, group); } public CalibratePatchesBase(GeneratorFactory factory, EncoderParameters params, NalUnitPostProcessor<Args> processor, MuxerFactory muxer, String folder, String group) { super(factory, params, processor, muxer, folder, x -> x.file, group); } protected String formatCIE(CIExyY xyY) { double l = xyY.Y * transfer.getNominalDisplayPeakLuminance(); String luminance = formatLuminance(l); return format("CIE(x=%.5f, y=%.5f) %s", xyY.x, xyY.y, luminance); } /** * Limit fractional part in rage of 0 to 6 digits while trying to keep * precision at 5 digits. */ protected String formatLuminance(double l) { int d = max(4 - (int) floor(max(-2.0, log10(l))), 0); return format("%." + d + "f cd/m²", l); } protected String getTopLeftText(Args args) { if (transfer.code() == 18) return "Hybrid Log-Gamma"; if (transfer.isDefinedByEOTF()) return "Encoded with EOTF"; CIExyY xyY = getColor(args); return format("OETF: %s %s", transfer, formatCIE(xyY)); } protected String getBottomLeftText(Args args) { boolean useMatrixTransfer = transfer.code() == 18 || transfer.isDefinedByEOTF(); String name = useMatrixTransfer ? transfer.toString() : "BT.1886"; DoubleUnaryOperator eotf = useMatrixTransfer ? transfer::toLinear : TRUE_BLACK_TRANSFER::eotf; CIExyY xyY = getColor(args, eotf); return format("EOTF: %s %s", name, formatCIE(xyY)); } protected String getTopCenterText(Args args) { return args.label; } protected String getBottomCenterText(Args args) { int[] yuv = args.yuv; if (matrix.isAchromatic(yuv)) return "Code " + yuv[0]; String fmt = matrix instanceof ICtCp ? "ITP" : "YUV"; double[] buf = matrix.fromCodes(yuv, new double[3]); matrix.toRGBCodes(matrix.toRGB(buf, buf), buf); var df = new DecimalFormat("#.#"); return format("%s %d:%d:%d RGB %s:%s:%s", fmt, yuv[0], yuv[1], yuv[2], df.format(buf[0]), df.format(buf[1]), df.format(buf[2])); } protected String getTopRightText(Args args) { return args.set; } protected String getBottomRightText(Args args) { return format("%dx%dp", width, height); } @Override public List<String> encode(File dir, Args args) throws IOException, InterruptedException { var all = new ArrayList<String>(PATTERN_SECONDS); all.addAll(encode(dir, args, INTRO, INTRO_SECONDS)); all.addAll(encode(dir, args, BODY, BODY_SECONDS)); return all; } @Override protected void encode(EncoderY4M e, Args args, String phase) { var fb = e.newFrameBuffer(); fb.fillRect(getWindow(args.window), args.yuv); if (phase != null) { FxImage.overlay(overlay(args), fb); } e.render(gop, () -> fb); } public void generate(FrameBuffer fb, Args args) { fb.fillRect(getWindow(args.window), args.yuv); FxImage.overlay(overlay(args), fb); } @Override protected void verify(File dir, String mp4, Args args) { verify(dir, mp4, INTRO_SECONDS - 1, 2, args); } @Override protected void verify(DecoderY4M d, Args args) { d.read(fb -> verify(fb, args)); } protected void verify(FrameBuffer fb, Args args) { Window win = getVerifyWindow(args.window); FrameVerifier.verifyRect(args.yuv, fb, win, 1, max(width, height), max(width + 1 >> 1, height + 1 >> 1)); } private Window getWindow(int window) { if (window == 0) return screen(resolution); // assume strong alignment assertEquals(0, width % 8); assertEquals(0, height % 8); return window(window / 100.0); } private Window getVerifyWindow(int window) { // remove patch edges if (window > 0) return getWindow(window).shrink(8); // remove areas with labels return center(width, height - height / 10); } public Window window(double area) { double target = width * height * area; return area < 0.5 ? square(target) : proportional(target, area); } protected Window square(double target) { int h = align(sqrt(target), height); int w = align(target / h, width); return center(w, h); } protected Window proportional(double target, double area) { int w = align(width * sqrt(area), width); int h = align(target / w, height); return center(w, h); } protected Window center(int w, int h) { return new Window((width - w) >> 1, (height - h) >> 1, w, h); } /** * Assume screen sides are divisible by 8. * <p> * Make patch size divisible by 8 or 16 so it will be aligned to 8 pixels by * centering, only reducing size. */ protected int align(double suggestion, int side) { int mask = side % 16 == 0 ? ~0xF : ~0x7; int masked = (int) suggestion & mask; if (side % 16 != 0 && masked % 16 == 0) { masked -= 8; // adjust for odd (side / 8) value } return masked; } protected Parent overlay(Args args) { Color fill = getTextFill(args); TextFlow topLeft = text(fill, getTopLeftText(args), LEFT); TextFlow topCenter = text(fill, getTopCenterText(args), CENTER); TextFlow topRight = text(fill, getTopRightText(args), RIGHT); TextFlow bottomLeft = text(fill, getBottomLeftText(args), LEFT); TextFlow bottomCenter = text(fill, getBottomCenterText(args), CENTER); TextFlow bottomRight = text(fill, getBottomRightText(args), RIGHT); StackPane top = new StackPane(topLeft, topCenter, topRight); StackPane bottom = new StackPane(bottomLeft, bottomCenter, bottomRight); BorderPane.setMargin(top, new Insets(20)); BorderPane.setMargin(bottom, new Insets(20)); BorderPane layout = new BorderPane(); layout.setBackground(EMPTY); layout.setTop(top); layout.setBottom(bottom); return layout; } protected Color getTextFill(Args args) { double ye = matrix.fromLumaCode(args.yuv[0]); boolean useMatrixTransfer = transfer.code() == 18 || transfer.isDefinedByEOTF(); DoubleUnaryOperator eotfi = useMatrixTransfer ? transfer::fromLinear : TRUE_BLACK_TRANSFER::eotfi; double peak = transfer.getNominalDisplayPeakLuminance(); double minY = eotfi.applyAsDouble(1.0 / peak); if (args.window == 0) return ye > minY ? Color.BLACK : Color.gray(ye + minY); double maxY = eotfi.applyAsDouble(20.0 / peak); return Color.gray(min(max(ye, minY), maxY)); } protected TextFlow text(Color fill, String text, TextAlignment alignment) { Text label = new Text(text); label.setFont(font(height / 54)); label.setFill(fill); TextFlow flow = new TextFlow(label); flow.setTextAlignment(alignment); return flow; } protected CIExyY getColor(Args args) { return getColor(args, transfer::toLinear); } protected CIExyY getColor(Args args, DoubleUnaryOperator eotf) { if (args.yuv[0] <= matrix.YMIN) { // fake color value for pure black CIExy white = primaries.white; return new CIExyY(white.x, white.y, 0); } double[] buf = matrix.fromCodes(args.yuv, new double[3]); matrix.toLinearRGB(eotf, buf, buf); matrix.RGBtoXYZ.multiply(buf, buf); return new CIEXYZ(buf).CIExyY(); } }