From 331d557db796529f0fc79ce720e298db2ec0136c Mon Sep 17 00:00:00 2001 From: Alexander Serdyukov Date: Wed, 22 Apr 2026 02:14:13 +0400 Subject: [PATCH 1/2] Cosmetic/Usability fixes --- .../ctlab/hict/hict_server/MainVerticle.java | 357 +++++++++--------- .../ctlab/hict/hict_server/WebUIVerticle.java | 126 +++---- version.txt | 2 +- 3 files changed, 236 insertions(+), 249 deletions(-) diff --git a/src/main/java/ru/itmo/ctlab/hict/hict_server/MainVerticle.java b/src/main/java/ru/itmo/ctlab/hict/hict_server/MainVerticle.java index b08cc4a..cb38659 100644 --- a/src/main/java/ru/itmo/ctlab/hict/hict_server/MainVerticle.java +++ b/src/main/java/ru/itmo/ctlab/hict/hict_server/MainVerticle.java @@ -68,8 +68,6 @@ import java.util.EnumMap; import java.util.List; import java.util.Map; -import java.util.concurrent.BrokenBarrierException; -import java.util.concurrent.CyclicBarrier; @Slf4j(topic = "MainVerticle") public class MainVerticle extends AbstractVerticle { @@ -119,155 +117,205 @@ public void start(final Promise startPromise) throws Exception { .add("HICT_WORKERS_EXPORT_MIN") .add("HICT_WORKERS_EXPORT_MAX"))); final ConfigRetrieverOptions myOptions = new ConfigRetrieverOptions().addStore(jsonEnvConfig); - final ConfigRetriever myConfigRetriver = ConfigRetriever.create(vertx, myOptions); - myConfigRetriver.getConfig(asyncResults -> System.out.println(asyncResults.result().encodePrettily())); - final CyclicBarrier barrier = new CyclicBarrier(1); - - myConfigRetriver.getConfig(event -> { - final var dataDirectoryString = event.result().getString("DATA_DIR", "."); - final var dataDirectory = Path.of(dataDirectoryString).normalize().toAbsolutePath().normalize(); - final var processedDirectoryString = event.result().getString("PROCESSED_DIR", dataDirectory.resolve("processed").toString()); - final var processedDirectory = Path.of(processedDirectoryString).normalize().toAbsolutePath().normalize(); - final var tileSize = getIntegerSetting(event.result(), "TILE_SIZE", 256); - final var minDSPool = getIntegerSetting(event.result(), "MIN_DS_POOL", 4); - final var maxDSPool = getIntegerSetting(event.result(), "MAX_DS_POOL", 16); - final var port = getIntegerSetting(event.result(), "VXPORT", 5000); - final int cores = Math.max(2, Runtime.getRuntime().availableProcessors()); - final int totalWorkersDefault = Math.max(10, cores * 2); - final int totalWorkers = getIntegerSetting(event.result(), "HICT_WORKERS_TOTAL_MAX", totalWorkersDefault); - final int queueCapacity = getIntegerSetting(event.result(), "HICT_WORKERS_QUEUE_CAPACITY", 32); - final int keepAliveSeconds = getIntegerSetting(event.result(), "HICT_WORKERS_KEEPALIVE_SECONDS", 30); - final int defaultPoolMax = Math.max(2, Math.min(totalWorkers, cores)); - final var perPrioritySizing = new EnumMap( - RequestTaskScheduler.RequestPriority.class - ); - perPrioritySizing.put( - RequestTaskScheduler.RequestPriority.UI_UX, - new RequestTaskScheduler.PoolSizing( - getIntegerSetting(event.result(), "HICT_WORKERS_UI_MIN", 4), - getIntegerSetting(event.result(), "HICT_WORKERS_UI_MAX", defaultPoolMax) - ) - ); - perPrioritySizing.put( - RequestTaskScheduler.RequestPriority.ASSEMBLY, - new RequestTaskScheduler.PoolSizing( - getIntegerSetting(event.result(), "HICT_WORKERS_ASSEMBLY_MIN", 4), - getIntegerSetting(event.result(), "HICT_WORKERS_ASSEMBLY_MAX", defaultPoolMax) - ) - ); - perPrioritySizing.put( - RequestTaskScheduler.RequestPriority.TILE, - new RequestTaskScheduler.PoolSizing( - getIntegerSetting(event.result(), "HICT_WORKERS_TILE_MIN", 8), - getIntegerSetting(event.result(), "HICT_WORKERS_TILE_MAX", defaultPoolMax) - ) - ); - perPrioritySizing.put( - RequestTaskScheduler.RequestPriority.TRACK, - new RequestTaskScheduler.PoolSizing( - getIntegerSetting(event.result(), "HICT_WORKERS_TRACK_MIN", 4), - getIntegerSetting(event.result(), "HICT_WORKERS_TRACK_MAX", defaultPoolMax) - ) - ); - perPrioritySizing.put( - RequestTaskScheduler.RequestPriority.EXPORT, - new RequestTaskScheduler.PoolSizing( - getIntegerSetting(event.result(), "HICT_WORKERS_EXPORT_MIN", 2), - getIntegerSetting(event.result(), "HICT_WORKERS_EXPORT_MAX", defaultPoolMax) - ) - ); + final ConfigRetriever configRetriever = ConfigRetriever.create(vertx, myOptions); + configRetriever.getConfig(event -> { + if (event.failed()) { + log.error("Failed to load server configuration", event.cause()); + startPromise.fail(event.cause()); + return; + } + final int port; try { - log.info("Trying to write configuration to local map"); - final @NotNull @NonNull LocalMap map = vertx.sharedData().getLocalMap("hict_server"); - map.put("dataDirectory", new ShareableWrappers.PathWrapper(dataDirectory)); - map.put("processedDirectory", new ShareableWrappers.PathWrapper(processedDirectory)); - map.put("tileSize", tileSize); - map.put("VXPORT", port); - map.put("MIN_DS_POOL", minDSPool); - map.put("MAX_DS_POOL", maxDSPool); - this.requestTaskScheduler = new RequestTaskScheduler( - vertx, - new RequestTaskScheduler.SchedulerConfig( - totalWorkers, - queueCapacity, - keepAliveSeconds, - perPrioritySizing - ) - ); - map.put( - RequestTaskScheduler.LOCAL_MAP_KEY, - new ShareableWrappers.RequestTaskSchedulerWrapper(this.requestTaskScheduler) - ); + port = configureServerState(event.result()); + } catch (final Exception ex) { + log.error("Failed to initialize server state", ex); + startPromise.fail(ex); + return; + } - final var defaultVisualizationOptions = new SimpleVisualizationOptions(10.0, 0.0, false, false, false, - new SimpleLinearGradient( - 32, - new Color(255, 255, 255, 0), - new Color(0, 96, 0, 255), - 0.0d, - 1.0d)); + final HttpServerOptions serverOptions = new HttpServerOptions(); + serverOptions.setCompressionSupported(true); + final var server = vertx.createHttpServer(serverOptions); + final var router = createRouter(); - map.put("visualizationOptions", - new ShareableWrappers.SimpleVisualizationOptionsWrapper(defaultVisualizationOptions)); - map.put( - RenderPipelineConfig.LOCAL_MAP_KEY, - new ShareableWrappers.RenderPipelineConfigWrapper(RenderPipelineConfig.disabled()) - ); + log.info("Starting server on port {}", port); + server.requestHandler(router).listen(port, ar -> { + if (ar.succeeded()) { + log.info("Server started on port {}", ar.result().actualPort()); + deployWebUiVerticle(); + startPromise.complete(); + } else { + log.error("Failed to start server on port {}", port, ar.cause()); + startPromise.fail(ar.cause()); + } + }); + }); + } - log.info("Added to local map"); - } finally { - log.info("Finished configuration write to maps"); - } + @Override + public void stop(final Promise stopPromise) { + if (this.requestTaskScheduler != null) { + this.requestTaskScheduler.close(); + this.requestTaskScheduler = null; + } + stopPromise.complete(); + } - log.info("Using " + dataDirectory + " as data directory"); - log.info("Using " + processedDirectory + " as processed directory"); - log.info("Using tile size " + tileSize); - log.info("Server will start on port " + port); + private static int getIntegerSetting(final @NotNull JsonObject config, + final @NotNull String key, + final int defaultValue) { + final Object raw = config.getValue(key); + if (raw instanceof Number number) { + return number.intValue(); + } + if (raw instanceof String value && !value.isBlank()) { try { - barrier.await(); - } catch (final InterruptedException | BrokenBarrierException e) { - throw new RuntimeException(e); + return Integer.parseInt(value.trim()); + } catch (final NumberFormatException ignored) { + // Fall through to system property/default. } - }); + } + final String systemPropertyValue = System.getProperty(key); + if (systemPropertyValue != null && !systemPropertyValue.isBlank()) { + try { + return Integer.parseInt(systemPropertyValue.trim()); + } catch (final NumberFormatException ignored) { + // Fall through to default. + } + } + return defaultValue; + } - final HttpServerOptions serverOptions = new HttpServerOptions(); - serverOptions.setCompressionSupported(true); - final var server = vertx.createHttpServer(serverOptions); - final var router = Router.router(vertx); + private int configureServerState(final @NotNull JsonObject config) { + final var dataDirectoryString = config.getString("DATA_DIR", "."); + final var dataDirectory = Path.of(dataDirectoryString).normalize().toAbsolutePath().normalize(); + final var processedDirectoryString = config.getString( + "PROCESSED_DIR", + dataDirectory.resolve("processed").toString() + ); + final var processedDirectory = Path.of(processedDirectoryString).normalize().toAbsolutePath().normalize(); + final var tileSize = getIntegerSetting(config, "TILE_SIZE", 256); + final var minDSPool = getIntegerSetting(config, "MIN_DS_POOL", 4); + final var maxDSPool = getIntegerSetting(config, "MAX_DS_POOL", 16); + final var port = getIntegerSetting(config, "VXPORT", 5000); + final int cores = Math.max(2, Runtime.getRuntime().availableProcessors()); + final int totalWorkersDefault = Math.max(10, cores * 2); + final int totalWorkers = getIntegerSetting(config, "HICT_WORKERS_TOTAL_MAX", totalWorkersDefault); + final int queueCapacity = getIntegerSetting(config, "HICT_WORKERS_QUEUE_CAPACITY", 32); + final int keepAliveSeconds = getIntegerSetting(config, "HICT_WORKERS_KEEPALIVE_SECONDS", 30); + final int defaultPoolMax = Math.max(2, Math.min(totalWorkers, cores)); + final var perPrioritySizing = new EnumMap( + RequestTaskScheduler.RequestPriority.class + ); + perPrioritySizing.put( + RequestTaskScheduler.RequestPriority.UI_UX, + new RequestTaskScheduler.PoolSizing( + getIntegerSetting(config, "HICT_WORKERS_UI_MIN", 4), + getIntegerSetting(config, "HICT_WORKERS_UI_MAX", defaultPoolMax) + ) + ); + perPrioritySizing.put( + RequestTaskScheduler.RequestPriority.ASSEMBLY, + new RequestTaskScheduler.PoolSizing( + getIntegerSetting(config, "HICT_WORKERS_ASSEMBLY_MIN", 4), + getIntegerSetting(config, "HICT_WORKERS_ASSEMBLY_MAX", defaultPoolMax) + ) + ); + perPrioritySizing.put( + RequestTaskScheduler.RequestPriority.TILE, + new RequestTaskScheduler.PoolSizing( + getIntegerSetting(config, "HICT_WORKERS_TILE_MIN", 8), + getIntegerSetting(config, "HICT_WORKERS_TILE_MAX", defaultPoolMax) + ) + ); + perPrioritySizing.put( + RequestTaskScheduler.RequestPriority.TRACK, + new RequestTaskScheduler.PoolSizing( + getIntegerSetting(config, "HICT_WORKERS_TRACK_MIN", 4), + getIntegerSetting(config, "HICT_WORKERS_TRACK_MAX", defaultPoolMax) + ) + ); + perPrioritySizing.put( + RequestTaskScheduler.RequestPriority.EXPORT, + new RequestTaskScheduler.PoolSizing( + getIntegerSetting(config, "HICT_WORKERS_EXPORT_MIN", 2), + getIntegerSetting(config, "HICT_WORKERS_EXPORT_MAX", defaultPoolMax) + ) + ); + + log.info("Writing server configuration to local shared state"); + final @NotNull @NonNull LocalMap map = vertx.sharedData().getLocalMap("hict_server"); + map.put("dataDirectory", new ShareableWrappers.PathWrapper(dataDirectory)); + map.put("processedDirectory", new ShareableWrappers.PathWrapper(processedDirectory)); + map.put("tileSize", tileSize); + map.put("VXPORT", port); + map.put("MIN_DS_POOL", minDSPool); + map.put("MAX_DS_POOL", maxDSPool); + this.requestTaskScheduler = new RequestTaskScheduler( + vertx, + new RequestTaskScheduler.SchedulerConfig( + totalWorkers, + queueCapacity, + keepAliveSeconds, + perPrioritySizing + ) + ); + map.put( + RequestTaskScheduler.LOCAL_MAP_KEY, + new ShareableWrappers.RequestTaskSchedulerWrapper(this.requestTaskScheduler) + ); + + final var defaultVisualizationOptions = new SimpleVisualizationOptions( + 10.0, + 0.0, + false, + false, + false, + new SimpleLinearGradient( + 32, + new Color(255, 255, 255, 0), + new Color(0, 96, 0, 255), + 0.0d, + 1.0d + ) + ); + + map.put( + "visualizationOptions", + new ShareableWrappers.SimpleVisualizationOptionsWrapper(defaultVisualizationOptions) + ); + map.put( + RenderPipelineConfig.LOCAL_MAP_KEY, + new ShareableWrappers.RenderPipelineConfigWrapper(RenderPipelineConfig.disabled()) + ); + + log.info("Using {} as data directory", dataDirectory); + log.info("Using {} as processed directory", processedDirectory); + log.info("Using tile size {}", tileSize); + return port; + } + private @NotNull Router createRouter() { + final var router = Router.router(vertx); router.route().handler(CorsHandler.create() - .allowedMethod(io.vertx.core.http.HttpMethod.GET) - .allowedMethod(io.vertx.core.http.HttpMethod.POST) - .allowedMethod(io.vertx.core.http.HttpMethod.OPTIONS) - .allowedHeader("Access-Control-Request-Method") - .allowedHeader("Access-Control-Allow-Credentials") - .allowedHeader("Access-Control-Allow-Origin") - .allowedHeader("Access-Control-Allow-Headers") - .allowedHeader("Content-Type")); + .allowedMethod(io.vertx.core.http.HttpMethod.GET) + .allowedMethod(io.vertx.core.http.HttpMethod.POST) + .allowedMethod(io.vertx.core.http.HttpMethod.OPTIONS) + .allowedHeader("Access-Control-Request-Method") + .allowedHeader("Access-Control-Allow-Credentials") + .allowedHeader("Access-Control-Allow-Origin") + .allowedHeader("Access-Control-Allow-Headers") + .allowedHeader("Content-Type")); router.route().handler(BodyHandler.create().setUploadsDirectory("/tmp").setBodyLimit(2L * 1024 * 1024 * 1024)); - // router.route().handler(ErrorHandler.create(Vertx.vertx())); + vertx.exceptionHandler(event -> { log.error("An exception was caught at the top level", event); log.debug(event.getMessage()); }); - log.info("Awaiting configuration to be written into the local map"); - barrier.await(); - final int port; - try { - final @NotNull @NonNull LocalMap map = vertx.sharedData().getLocalMap("hict_server"); - port = (int) map.get("VXPORT"); - } finally { - log.info("Finished maps"); - } - - getVertx().exceptionHandler(err -> { - log.error("An exception was caught at VertX top-level", err); - Vertx.currentContext().exceptionHandler().handle(err); - }); + getVertx().exceptionHandler(err -> log.error("An exception was caught at VertX top-level", err)); - log.info("Initializing handlers"); final List handlersHolders = new ArrayList<>(); handlersHolders.add(new FSHandlersHolder(vertx)); handlersHolders.add(new TileHandlersHolder(vertx)); @@ -284,18 +332,19 @@ public void start(final Promise startPromise) throws Exception { final var message = ctx.failure() != null && ctx.failure().getMessage() != null ? ctx.failure().getMessage() : "Request failed"; + final int statusCode = ctx.statusCode() > 0 ? ctx.statusCode() : 500; ctx.response() .putHeader("content-type", "application/json") + .setStatusCode(statusCode) .end(Json.encode(Map.of("error", message))); }); log.info("Configuring router"); handlersHolders.forEach(handlersHolder -> handlersHolder.addHandlersToRouter(router)); + return router; + } - log.info("Starting server on port " + port); - server.requestHandler(router).listen(port); - log.info("Server started"); - + private void deployWebUiVerticle() { log.info("Deploying WebUI Verticle"); vertx.deployVerticle(new WebUIVerticle(), ar -> { if (ar.succeeded()) { @@ -305,38 +354,4 @@ public void start(final Promise startPromise) throws Exception { } }); } - - @Override - public void stop(final Promise stopPromise) { - if (this.requestTaskScheduler != null) { - this.requestTaskScheduler.close(); - this.requestTaskScheduler = null; - } - stopPromise.complete(); - } - - private static int getIntegerSetting(final @NotNull JsonObject config, - final @NotNull String key, - final int defaultValue) { - final Object raw = config.getValue(key); - if (raw instanceof Number number) { - return number.intValue(); - } - if (raw instanceof String value && !value.isBlank()) { - try { - return Integer.parseInt(value.trim()); - } catch (final NumberFormatException ignored) { - // Fall through to system property/default. - } - } - final String systemPropertyValue = System.getProperty(key); - if (systemPropertyValue != null && !systemPropertyValue.isBlank()) { - try { - return Integer.parseInt(systemPropertyValue.trim()); - } catch (final NumberFormatException ignored) { - // Fall through to default. - } - } - return defaultValue; - } } diff --git a/src/main/java/ru/itmo/ctlab/hict/hict_server/WebUIVerticle.java b/src/main/java/ru/itmo/ctlab/hict/hict_server/WebUIVerticle.java index 11fe147..1fd8501 100644 --- a/src/main/java/ru/itmo/ctlab/hict/hict_server/WebUIVerticle.java +++ b/src/main/java/ru/itmo/ctlab/hict/hict_server/WebUIVerticle.java @@ -44,8 +44,6 @@ import java.nio.file.Files; import java.nio.file.Path; -import java.util.concurrent.BrokenBarrierException; -import java.util.concurrent.CyclicBarrier; @Slf4j public class WebUIVerticle extends AbstractVerticle { @@ -65,86 +63,60 @@ public void start(final Promise startPromise) throws Exception { final ConfigStoreOptions jsonEnvConfig = new ConfigStoreOptions().setType("env") .setConfig(new JsonObject().put("keys", new JsonArray().add("SERVE_WEBUI").add("WEBUI_PORT"))); final ConfigRetrieverOptions myOptions = new ConfigRetrieverOptions().addStore(jsonEnvConfig); - final ConfigRetriever myConfigRetriver = ConfigRetriever.create(vertx, myOptions); - myConfigRetriver.getConfig(asyncResults -> System.out.println(asyncResults.result().encodePrettily())); - final CyclicBarrier barrier = new CyclicBarrier(1); - - myConfigRetriver.getConfig(event -> { - final var serveWebUI = resolveServeWebUI(event.result()); - final var webuiPort = resolveWebuiPort(event.result()); - + final ConfigRetriever configRetriever = ConfigRetriever.create(vertx, myOptions); + configRetriever.getConfig(event -> { + if (event.failed()) { + log.error("Failed to load WebUI configuration", event.cause()); + startPromise.fail(event.cause()); + return; + } try { - log.info("Trying to write WebUI configuration to local map"); + final var serveWebUI = resolveServeWebUI(event.result()); + final var webuiPort = resolveWebuiPort(event.result()); + + log.info("Writing WebUI configuration to local shared state"); final @NotNull @NonNull LocalMap map = vertx.sharedData().getLocalMap("webui_server"); map.put("WEBUI_PORT", webuiPort); map.put("SERVE_WEBUI", serveWebUI); - log.info("Added to local map"); - } finally { - log.info("Finished configuration write to maps"); - } - log.info("WebUI HTTP Server will start on port " + webuiPort); - try { - log.debug("Waiting for WebUI HTTP server to start"); - barrier.await(); - log.debug("Configuration barrier passed"); - } catch (final InterruptedException | BrokenBarrierException e) { - log.error("Configuration barrier error", e); - throw new RuntimeException(e); - } - }); - - - final HttpServerOptions webuiServerOptions = new HttpServerOptions(); - webuiServerOptions.setCompressionSupported(true); - final var webuiServer = vertx.createHttpServer(webuiServerOptions); - final var webuiRouter = Router.router(vertx); - - webuiRouter.route().handler(CorsHandler.create() - .allowedMethod(io.vertx.core.http.HttpMethod.GET) - .allowedMethod(io.vertx.core.http.HttpMethod.POST) - .allowedMethod(io.vertx.core.http.HttpMethod.OPTIONS) - .allowedHeader("Access-Control-Request-Method") - .allowedHeader("Access-Control-Allow-Credentials") - .allowedHeader("Access-Control-Allow-Origin") - .allowedHeader("Access-Control-Allow-Headers") - .allowedHeader("Content-Type")); - log.debug("Awaiting WebUI configuration to be written into the local map"); - barrier.await(); - log.debug("Passed configuration barrier in WebUI main"); - - - final int webuiPort; - final boolean serve; - try { - final @NotNull @NonNull LocalMap map = vertx.sharedData().getLocalMap("webui_server"); - serve = (boolean) map.get("SERVE_WEBUI"); - webuiPort = (int) map.get("WEBUI_PORT"); - } finally { - log.debug("Read configuration of WebUI HTTP Server"); - } - - if (!serve) { - log.info("Not serving WebUI due to SERVE_WEBUI environment variable set to false"); - startPromise.complete(); - return; - } - - log.info("WebUI Server will start on port " + webuiPort); - - - final var webuiStaticHandler = createWebuiStaticHandler(); - webuiRouter.route("/").handler(ctx -> ctx.reroute("/index.html")); - webuiRouter.route("/*").handler(webuiStaticHandler); - log.info("WebUI router configured, binding HTTP server"); - log.info("Starting WebUI server on port " + webuiPort); - webuiServer.requestHandler(webuiRouter).listen(webuiPort, "0.0.0.0", ar -> { - if (ar.succeeded()) { - log.info("WebUI Server started on 0.0.0.0:{}", webuiServer.actualPort()); - startPromise.complete(); - } else { - log.error("Failed to start WebUI server on port " + webuiPort, ar.cause()); - startPromise.fail(ar.cause()); + if (!serveWebUI) { + log.info("Not serving WebUI because SERVE_WEBUI=false"); + startPromise.complete(); + return; + } + + final HttpServerOptions webuiServerOptions = new HttpServerOptions(); + webuiServerOptions.setCompressionSupported(true); + final var webuiServer = vertx.createHttpServer(webuiServerOptions); + final var webuiRouter = Router.router(vertx); + + webuiRouter.route().handler(CorsHandler.create() + .allowedMethod(io.vertx.core.http.HttpMethod.GET) + .allowedMethod(io.vertx.core.http.HttpMethod.POST) + .allowedMethod(io.vertx.core.http.HttpMethod.OPTIONS) + .allowedHeader("Access-Control-Request-Method") + .allowedHeader("Access-Control-Allow-Credentials") + .allowedHeader("Access-Control-Allow-Origin") + .allowedHeader("Access-Control-Allow-Headers") + .allowedHeader("Content-Type")); + + final var webuiStaticHandler = createWebuiStaticHandler(); + webuiRouter.route("/").handler(ctx -> ctx.reroute("/index.html")); + webuiRouter.route("/*").handler(webuiStaticHandler); + + log.info("Starting WebUI server on port {}", webuiPort); + webuiServer.requestHandler(webuiRouter).listen(webuiPort, "0.0.0.0", ar -> { + if (ar.succeeded()) { + log.info("WebUI Server started on 0.0.0.0:{}", webuiServer.actualPort()); + startPromise.complete(); + } else { + log.error("Failed to start WebUI server on port {}", webuiPort, ar.cause()); + startPromise.fail(ar.cause()); + } + }); + } catch (final Throwable t) { + log.error("WebUI verticle start failed", t); + startPromise.fail(t); } }); } catch (Throwable t) { diff --git a/version.txt b/version.txt index 4fececb..a701293 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.0.124-bbee3e5-webui_92b388b \ No newline at end of file +1.0.127-3bc2c2c-webui_92b388b \ No newline at end of file From bb20b7781d72bf3a98461aefb0cec56b50bf725c Mon Sep 17 00:00:00 2001 From: Alexander Serdyukov Date: Wed, 22 Apr 2026 03:53:31 +0400 Subject: [PATCH 2/2] Added pixel overlay capabilities --- .../handlers/tiles/RenderPipelineConfig.java | 207 +++++++++++++++++- .../RenderPipelineConfigPixelBlendTest.java | 128 +++++++++++ version.txt | 2 +- 3 files changed, 331 insertions(+), 6 deletions(-) create mode 100644 src/test/java/ru/itmo/ctlab/hict/hict_server/handlers/tiles/RenderPipelineConfigPixelBlendTest.java diff --git a/src/main/java/ru/itmo/ctlab/hict/hict_server/handlers/tiles/RenderPipelineConfig.java b/src/main/java/ru/itmo/ctlab/hict/hict_server/handlers/tiles/RenderPipelineConfig.java index d0937bd..7a26855 100644 --- a/src/main/java/ru/itmo/ctlab/hict/hict_server/handlers/tiles/RenderPipelineConfig.java +++ b/src/main/java/ru/itmo/ctlab/hict/hict_server/handlers/tiles/RenderPipelineConfig.java @@ -320,10 +320,8 @@ public int evaluateArgb(final boolean upperTriangle, final var nodeType = node.getString("type", "source").trim().toUpperCase(Locale.ROOT); if (isColorNode(nodeType)) { final var colorExpression = compileColorExpression(node, requiredTrackBindings); - final CompiledNumericExpression fallbackSignalExpression = - "COLORMAP".equals(nodeType) - ? compileImplicitColormapSignalExpression(node, requiredTrackBindings) - : context -> 0.0d; + final var fallbackSignalExpression = + compileRepresentativeSignalExpressionForColorNode(node, requiredTrackBindings); return new CompiledRootExpression(true, fallbackSignalExpression, colorExpression); } final var signalExpression = compileNumericExpression(node, requiredTrackBindings); @@ -332,11 +330,33 @@ public int evaluateArgb(final boolean upperTriangle, private static boolean isColorNode(final @NotNull String nodeType) { return switch (nodeType) { - case "COLORMAP", "RGB", "HSL", "HSV" -> true; + case "COLORMAP", "RGB", "HSL", "HSV", "PIXEL_BLEND" -> true; default -> false; }; } + private static @NotNull CompiledNumericExpression compileRepresentativeSignalExpressionForColorNode( + final @NotNull JsonObject node, + final @NotNull Set requiredTrackBindings + ) { + final var nodeType = node.getString("type", "colormap").trim().toUpperCase(Locale.ROOT); + return switch (nodeType) { + case "COLORMAP" -> compileImplicitColormapSignalExpression(node, requiredTrackBindings); + case "PIXEL_BLEND" -> { + final var topNode = firstChildObject(node, "top", "foreground", "upper"); + if (topNode != null) { + yield compileRootExpression(topNode, requiredTrackBindings).signalExpression(); + } + final var bottomNode = firstChildObject(node, "bottom", "background", "lower"); + if (bottomNode != null) { + yield compileRootExpression(bottomNode, requiredTrackBindings).signalExpression(); + } + yield context -> 0.0d; + } + default -> context -> 0.0d; + }; + } + private static @NotNull CompiledNumericExpression compileNumericExpression(final @NotNull JsonObject node, final @NotNull Set requiredTrackBindings) { final var nodeType = node.getString("type", "source").trim().toUpperCase(Locale.ROOT); @@ -566,6 +586,31 @@ private static boolean isBuiltinCoolerWeightsTrackId(final @NotNull String track return context -> fallbackValue; } + private static JsonObject firstChildObject(final @NotNull JsonObject node, + final @NotNull String... keys) { + for (final var key : keys) { + final var value = node.getValue(key); + if (value instanceof JsonObject objectNode) { + return objectNode; + } + } + return null; + } + + private static @NotNull CompiledColorExpression compileColorChildExpression(final @NotNull JsonObject node, + final @NotNull String[] keys, + final int fallbackArgb, + final @NotNull Set requiredTrackBindings) { + final var childNode = firstChildObject(node, keys); + if (childNode != null) { + final var childType = childNode.getString("type", "colormap").trim().toUpperCase(Locale.ROOT); + if (isColorNode(childType)) { + return compileColorExpression(childNode, requiredTrackBindings); + } + } + return context -> fallbackArgb; + } + private static @NotNull CompiledColorExpression compileColorExpression(final @NotNull JsonObject node, final @NotNull Set requiredTrackBindings) { final var nodeType = node.getString("type", "colormap").trim().toUpperCase(Locale.ROOT); @@ -710,6 +755,42 @@ private static boolean isBuiltinCoolerWeightsTrackId(final @NotNull String track return toArgb(red, green, blue, clampAlphaChannel(aExpression.eval(context))); }; } + case "PIXEL_BLEND" -> { + final var topExpression = compileColorChildExpression( + node, + new String[]{"top", "foreground", "upper"}, + 0x00000000, + requiredTrackBindings + ); + final var bottomExpression = compileColorChildExpression( + node, + new String[]{"bottom", "background", "lower"}, + 0x00000000, + requiredTrackBindings + ); + final var topOpacityExpression = compileNumericChildExpressionMulti( + node, + new String[]{"topOpacity", "topAlpha"}, + new String[]{"topOpacityValue", "topAlphaValue"}, + 1.0d, + requiredTrackBindings + ); + final var bottomOpacityExpression = compileNumericChildExpressionMulti( + node, + new String[]{"bottomOpacity", "bottomAlpha"}, + new String[]{"bottomOpacityValue", "bottomAlphaValue"}, + 1.0d, + requiredTrackBindings + ); + final var blendMode = parsePixelBlendMode(node.getString("mode", "OVER")); + yield context -> blendArgb( + topExpression.evalArgb(context), + bottomExpression.evalArgb(context), + topOpacityExpression.eval(context), + bottomOpacityExpression.eval(context), + blendMode + ); + } default -> throw new IllegalArgumentException("Unsupported color expression type: " + nodeType); }; } @@ -876,6 +957,110 @@ private static int toArgb(final int red, | (blue & 0xFF); } + private static @NotNull PixelBlendMode parsePixelBlendMode(final String rawMode) { + if (rawMode == null || rawMode.isBlank()) { + return PixelBlendMode.OVER; + } + try { + return PixelBlendMode.valueOf(rawMode.trim().toUpperCase(Locale.ROOT)); + } catch (final IllegalArgumentException ignored) { + return PixelBlendMode.OVER; + } + } + + private static double normalizeOpacity(final double rawOpacity) { + final var safeOpacity = Double.isFinite(rawOpacity) ? rawOpacity : 1.0d; + if (safeOpacity <= 0.0d) { + return 0.0d; + } + if (safeOpacity <= 1.0d) { + return safeOpacity; + } + if (safeOpacity <= 100.0d) { + return safeOpacity / 100.0d; + } + if (safeOpacity <= 255.0d) { + return safeOpacity / 255.0d; + } + return 1.0d; + } + + private static int blendArgb(final int topArgb, + final int bottomArgb, + final double rawTopOpacity, + final double rawBottomOpacity, + final @NotNull PixelBlendMode mode) { + final var top = toNormalizedRgba(topArgb, normalizeOpacity(rawTopOpacity)); + final var bottom = toNormalizedRgba(bottomArgb, normalizeOpacity(rawBottomOpacity)); + final var overlapAlpha = top[3] * bottom[3]; + final var topOnlyAlpha = top[3] * (1.0d - bottom[3]); + final var bottomOnlyAlpha = bottom[3] * (1.0d - top[3]); + final var outAlpha = overlapAlpha + topOnlyAlpha + bottomOnlyAlpha; + if (outAlpha <= 1e-12d) { + return 0x00000000; + } + + final var blendedRed = applyBlendMode(top[0], bottom[0], mode); + final var blendedGreen = applyBlendMode(top[1], bottom[1], mode); + final var blendedBlue = applyBlendMode(top[2], bottom[2], mode); + + final var outRed = ( + blendedRed * overlapAlpha + + top[0] * topOnlyAlpha + + bottom[0] * bottomOnlyAlpha + ) / outAlpha; + final var outGreen = ( + blendedGreen * overlapAlpha + + top[1] * topOnlyAlpha + + bottom[1] * bottomOnlyAlpha + ) / outAlpha; + final var outBlue = ( + blendedBlue * overlapAlpha + + top[2] * topOnlyAlpha + + bottom[2] * bottomOnlyAlpha + ) / outAlpha; + + return toArgb( + clampColorChannel(outRed * 255.0d), + clampColorChannel(outGreen * 255.0d), + clampColorChannel(outBlue * 255.0d), + clampAlphaChannel(outAlpha * 255.0d) + ); + } + + private static double applyBlendMode(final double top, + final double bottom, + final @NotNull PixelBlendMode mode) { + final var safeTop = Math.max(0.0d, Math.min(1.0d, top)); + final var safeBottom = Math.max(0.0d, Math.min(1.0d, bottom)); + return switch (mode) { + case OVER -> safeTop; + case ADD -> Math.min(1.0d, safeTop + safeBottom); + case SUBTRACT -> Math.max(0.0d, safeTop - safeBottom); + case MULTIPLY -> safeTop * safeBottom; + case SCREEN -> 1.0d - ((1.0d - safeTop) * (1.0d - safeBottom)); + case DIFFERENCE -> Math.abs(safeTop - safeBottom); + case LIGHTEN -> Math.max(safeTop, safeBottom); + case DARKEN -> Math.min(safeTop, safeBottom); + case XOR -> { + final var topByte = clampColorChannel(safeTop * 255.0d); + final var bottomByte = clampColorChannel(safeBottom * 255.0d); + yield (topByte ^ bottomByte) / 255.0d; + } + }; + } + + private static double[] toNormalizedRgba(final int argb, + final double layerOpacity) { + final var alpha = (((argb >> 24) & 0xFF) / 255.0d) * Math.max(0.0d, Math.min(1.0d, layerOpacity)); + return new double[]{ + ((argb >> 16) & 0xFF) / 255.0d, + ((argb >> 8) & 0xFF) / 255.0d, + (argb & 0xFF) / 255.0d, + alpha + }; + } + @FunctionalInterface private interface CompiledNumericExpression { double eval(@NotNull MutablePixelContext context); @@ -910,6 +1095,18 @@ public enum TrackAxis { COL } + private enum PixelBlendMode { + OVER, + ADD, + SUBTRACT, + MULTIPLY, + SCREEN, + DIFFERENCE, + LIGHTEN, + DARKEN, + XOR + } + public record TrackBinding(@NotNull String trackId, @NotNull TrackAxis axis) { } diff --git a/src/test/java/ru/itmo/ctlab/hict/hict_server/handlers/tiles/RenderPipelineConfigPixelBlendTest.java b/src/test/java/ru/itmo/ctlab/hict/hict_server/handlers/tiles/RenderPipelineConfigPixelBlendTest.java new file mode 100644 index 0000000..5c90c8d --- /dev/null +++ b/src/test/java/ru/itmo/ctlab/hict/hict_server/handlers/tiles/RenderPipelineConfigPixelBlendTest.java @@ -0,0 +1,128 @@ +/* + * MIT License + * + * Copyright (c) 2021-2026. Aleksandr Serdiukov, Anton Zamyatin, Aleksandr Sinitsyn, Vitalii Dravgelis and Computer Technologies Laboratory ITMO University team. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package ru.itmo.ctlab.hict.hict_server.handlers.tiles; + +import io.vertx.core.json.JsonObject; +import org.junit.jupiter.api.Test; +import ru.itmo.ctlab.hict.hict_library.visualization.SimpleVisualizationOptions; +import ru.itmo.ctlab.hict.hict_library.visualization.colormap.gradient.SimpleLinearGradient; + +import java.awt.*; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class RenderPipelineConfigPixelBlendTest { + private static SimpleVisualizationOptions dummyOptions() { + return new SimpleVisualizationOptions( + -1.0d, + -1.0d, + false, + false, + false, + new SimpleLinearGradient( + 32, + new Color(0, 0, 0, 0), + new Color(255, 255, 255, 255), + 0.0d, + 1.0d + ) + ); + } + + @Test + void pixelBlendNode_addModeCombinesOpaqueColorLayers() { + final var blend = new JsonObject() + .put("type", "pixel_blend") + .put("mode", "ADD") + .put("topOpacity", 1.0d) + .put("bottomOpacity", 1.0d) + .put( + "top", + new JsonObject() + .put("type", "rgb") + .put("c1", new JsonObject().put("type", "constant").put("value", 120)) + .put("c2", new JsonObject().put("type", "constant").put("value", 30)) + .put("c3", new JsonObject().put("type", "constant").put("value", 0)) + .put("alpha", new JsonObject().put("type", "constant").put("value", 255)) + ) + .put( + "bottom", + new JsonObject() + .put("type", "rgb") + .put("c1", new JsonObject().put("type", "constant").put("value", 20)) + .put("c2", new JsonObject().put("type", "constant").put("value", 40)) + .put("c3", new JsonObject().put("type", "constant").put("value", 80)) + .put("alpha", new JsonObject().put("type", "constant").put("value", 255)) + ); + + final var config = RenderPipelineConfig.fromJson( + new JsonObject() + .put("enabled", true) + .put("upperExpression", blend) + .put("lowerExpression", blend.copy()) + ); + + final int argb = config.evaluateArgb(true, new RenderPipelineConfig.MutablePixelContext(), dummyOptions()); + final int expectedArgb = (255 << 24) | (140 << 16) | (70 << 8) | 80; + assertEquals(expectedArgb, argb); + } + + @Test + void pixelBlendNode_usesTopColorSignalAsRepresentativePipelineSignal() { + final var blend = new JsonObject() + .put("type", "pixel_blend") + .put( + "top", + new JsonObject() + .put("type", "colormap") + .put("input", new JsonObject().put("type", "source").put("source", "SECONDARY")) + .put("startColor", "#00000000") + .put("endColor", "#00ff00ff") + .put("minSignal", 1.0d) + .put("maxSignal", 5.0d) + ) + .put( + "bottom", + new JsonObject() + .put("type", "rgb") + .put("c1", new JsonObject().put("type", "constant").put("value", 0)) + .put("c2", new JsonObject().put("type", "constant").put("value", 0)) + .put("c3", new JsonObject().put("type", "constant").put("value", 0)) + .put("alpha", new JsonObject().put("type", "constant").put("value", 0)) + ); + + final var config = RenderPipelineConfig.fromJson( + new JsonObject() + .put("enabled", true) + .put("upperExpression", blend) + .put("lowerExpression", blend.copy()) + ); + + final var ctx = new RenderPipelineConfig.MutablePixelContext(); + ctx.secondaryValue = 7.5d; + final double evaluatedSignal = config.evaluate(true, ctx); + assertEquals(5.0d, evaluatedSignal, 1e-12); + } +} diff --git a/version.txt b/version.txt index a701293..c4fa63e 100644 --- a/version.txt +++ b/version.txt @@ -1 +1 @@ -1.0.127-3bc2c2c-webui_92b388b \ No newline at end of file +1.0.130-331d557-webui_13d9b83 \ No newline at end of file