UI improvements, added live generation preview via latent linear probe
Browse filesUpdate the moving Canter code on main. Preserve the existing v0001 checkpoint and add the small standalone latent RGB preview projection.
- API.md +28 -0
- README.md +5 -0
- RELEASES.md +7 -0
- canter/__init__.py +4 -1
- canter/inference.py +9 -1
- canter/latent_rgb_preview.safetensors +3 -0
- canter/pipeline.py +70 -1
- canter/preview.py +270 -0
- canter/solvers.py +55 -8
- canter/version.py +1 -1
- canter/webui.py +76 -1815
- canter/webui_app.py +1281 -0
- canter/webui_runtime.py +1171 -0
- release.json +5 -0
- weights_manifest.json +29 -0
API.md
CHANGED
|
@@ -30,6 +30,7 @@ image = output.image
|
|
| 30 |
| `config` | `CanterPipelineConfig()` | Inference and output settings. |
|
| 31 |
| `initial_noise` | `None` | Optional float32 latent noise tensor with the configured batch and spatial shape. |
|
| 32 |
| `progress` | `None` | Optional callback receiving completed and total solver updates. |
|
|
|
|
| 33 |
|
| 34 |
`CanterPipeline` loads the flow-matching denoiser and text tokenizer with the
|
| 35 |
bundled
|
|
@@ -117,6 +118,32 @@ output = pipe("A portrait lit by a large north-facing window", config=config)
|
|
| 117 |
Every output also contains the descending float32 solver schedule in
|
| 118 |
`output.schedule`.
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
## Inference configuration
|
| 121 |
|
| 122 |
### Defaults
|
|
@@ -437,6 +464,7 @@ schedule = output.schedule
|
|
| 437 |
| `config` | `CanterInferenceConfig()` | Latent inference settings. |
|
| 438 |
| `initial_noise` | `None` | Optional float32 noise with shape `[batch, 128, height / 16, width / 16]`. |
|
| 439 |
| `progress` | `None` | Optional callback receiving `(completed_updates, total_updates)`. |
|
|
|
|
| 440 |
|
| 441 |
An empty or whitespace-only prompt uses the learned unconditional token without
|
| 442 |
running the text encoder. Every prompt in a batch must be either blank or
|
|
|
|
| 30 |
| `config` | `CanterPipelineConfig()` | Inference and output settings. |
|
| 31 |
| `initial_noise` | `None` | Optional float32 latent noise tensor with the configured batch and spatial shape. |
|
| 32 |
| `progress` | `None` | Optional callback receiving completed and total solver updates. |
|
| 33 |
+
| `preview` | `None` | Optional callback receiving asynchronous latent-RGB PIL images plus completed and total solver updates. |
|
| 34 |
|
| 35 |
`CanterPipeline` loads the flow-matching denoiser and text tokenizer with the
|
| 36 |
bundled
|
|
|
|
| 118 |
Every output also contains the descending float32 solver schedule in
|
| 119 |
`output.schedule`.
|
| 120 |
|
| 121 |
+
## Live latent previews
|
| 122 |
+
|
| 123 |
+
```python
|
| 124 |
+
def show_preview(images, completed, total):
|
| 125 |
+
images[0].show()
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
output = pipe(
|
| 129 |
+
"A lighthouse in a winter storm",
|
| 130 |
+
preview=show_preview,
|
| 131 |
+
)
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
The bundled float32 linear projection maps the whitened 128-channel DINAC
|
| 135 |
+
latent directly to RGB at one eighth of the requested image size. Every solver
|
| 136 |
+
update is eligible by default. If the previous preview is still transferring,
|
| 137 |
+
converting, or running the callback, the new update is dropped instead of
|
| 138 |
+
blocking sampling. The callback receives this native one-eighth-size image;
|
| 139 |
+
the web UI lets the browser scale it for display.
|
| 140 |
+
|
| 141 |
+
Accepted previews perform only a 1×1 projection and pixel shuffle on CUDA.
|
| 142 |
+
The small RGB result is copied non-blockingly into pinned host memory. CUDA
|
| 143 |
+
event waiting, PIL conversion, and the application callback all run on a
|
| 144 |
+
single background worker. Every step is eligible; the one-worker busy-drop
|
| 145 |
+
policy supplies backpressure without a separate callback cap.
|
| 146 |
+
|
| 147 |
## Inference configuration
|
| 148 |
|
| 149 |
### Defaults
|
|
|
|
| 464 |
| `config` | `CanterInferenceConfig()` | Latent inference settings. |
|
| 465 |
| `initial_noise` | `None` | Optional float32 noise with shape `[batch, 128, height / 16, width / 16]`. |
|
| 466 |
| `progress` | `None` | Optional callback receiving `(completed_updates, total_updates)`. |
|
| 467 |
+
| `state_callback` | `None` | Optional synchronous callback receiving `(state, completed_updates, total_updates)` after every solver update. Prefer pipeline previews when full CUDA latent access is not required. |
|
| 468 |
|
| 469 |
An empty or whitespace-only prompt uses the learned unconditional token without
|
| 470 |
running the text encoder. Every prompt in a batch must be either blank or
|
README.md
CHANGED
|
@@ -99,6 +99,11 @@ python app.py --in-browser
|
|
| 99 |
downloads the latest compatible DINAC-AE-D2 VAE.
|
| 100 |
The interface appears immediately and reports model loading and pytorch dynamo compilation
|
| 101 |
progress.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
Downloaded PNG files contain the prompt, effective per-image settings, Canter
|
| 103 |
code version, and numbered checkpoint release as JSON metadata.
|
| 104 |
|
|
|
|
| 99 |
downloads the latest compatible DINAC-AE-D2 VAE.
|
| 100 |
The interface appears immediately and reports model loading and pytorch dynamo compilation
|
| 101 |
progress.
|
| 102 |
+
During sampling, each output slot receives asynchronous previews from the
|
| 103 |
+
bundled one-eighth-scale latent-RGB projection. Busy preview work is skipped,
|
| 104 |
+
so the sampler never waits for browser publication. The native one-eighth-size
|
| 105 |
+
preview is sent directly and scaled for display by the browser. The UI checkbox
|
| 106 |
+
below the size preset disables previews.
|
| 107 |
Downloaded PNG files contain the prompt, effective per-image settings, Canter
|
| 108 |
code version, and numbered checkpoint release as JSON metadata.
|
| 109 |
|
RELEASES.md
CHANGED
|
@@ -6,6 +6,13 @@
|
|
| 6 |
Checkpoint tags are immutable snapshots. The installed package supplies the
|
| 7 |
inference code, so current code can load any compatible checkpoint tag without
|
| 8 |
executing the Python files stored in that historical snapshot.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
|
| 10 |
| Release | Date | Weight storage | Status |
|
| 11 |
| --- | --- | --- | --- |
|
|
|
|
| 6 |
Checkpoint tags are immutable snapshots. The installed package supplies the
|
| 7 |
inference code, so current code can load any compatible checkpoint tag without
|
| 8 |
executing the Python files stored in that historical snapshot.
|
| 9 |
+
The current package also supplies the small latent-RGB preview projection;
|
| 10 |
+
preview behavior therefore follows the installed code rather than changing an
|
| 11 |
+
older denoiser checkpoint tag.
|
| 12 |
+
|
| 13 |
+
Canter `0.3.0` adds asynchronous native-resolution latent previews and the
|
| 14 |
+
responsive preview grid. The browser scales previews for display; final
|
| 15 |
+
decoded images remain full resolution.
|
| 16 |
|
| 17 |
| Release | Date | Weight storage | Status |
|
| 18 |
| --- | --- | --- | --- |
|
canter/__init__.py
CHANGED
|
@@ -30,8 +30,9 @@ from .pipeline import (
|
|
| 30 |
CanterPipelineMetadata,
|
| 31 |
CanterPipelineOutput,
|
| 32 |
)
|
|
|
|
| 33 |
from .schedules import Schedule
|
| 34 |
-
from .solvers import Solver, SolverProgress
|
| 35 |
from .text_encoder import CanterTextEncoder, TextBackboneOutput
|
| 36 |
from .vae import CanterVae
|
| 37 |
from .version import CANTER_VERSION, __version__
|
|
@@ -54,6 +55,7 @@ __all__ = [
|
|
| 54 |
"CanterPipelineConfig",
|
| 55 |
"CanterPipelineMetadata",
|
| 56 |
"CanterPipelineOutput",
|
|
|
|
| 57 |
"CanterReleaseMetadata",
|
| 58 |
"CanterTextEncoder",
|
| 59 |
"CanterVae",
|
|
@@ -65,6 +67,7 @@ __all__ = [
|
|
| 65 |
"Schedule",
|
| 66 |
"Solver",
|
| 67 |
"SolverProgress",
|
|
|
|
| 68 |
"TextAttentionBackend",
|
| 69 |
"TextBackboneOutput",
|
| 70 |
"WeightDType",
|
|
|
|
| 30 |
CanterPipelineMetadata,
|
| 31 |
CanterPipelineOutput,
|
| 32 |
)
|
| 33 |
+
from .preview import CanterPreviewCallback
|
| 34 |
from .schedules import Schedule
|
| 35 |
+
from .solvers import Solver, SolverProgress, SolverStateCallback
|
| 36 |
from .text_encoder import CanterTextEncoder, TextBackboneOutput
|
| 37 |
from .vae import CanterVae
|
| 38 |
from .version import CANTER_VERSION, __version__
|
|
|
|
| 55 |
"CanterPipelineConfig",
|
| 56 |
"CanterPipelineMetadata",
|
| 57 |
"CanterPipelineOutput",
|
| 58 |
+
"CanterPreviewCallback",
|
| 59 |
"CanterReleaseMetadata",
|
| 60 |
"CanterTextEncoder",
|
| 61 |
"CanterVae",
|
|
|
|
| 67 |
"Schedule",
|
| 68 |
"Solver",
|
| 69 |
"SolverProgress",
|
| 70 |
+
"SolverStateCallback",
|
| 71 |
"TextAttentionBackend",
|
| 72 |
"TextBackboneOutput",
|
| 73 |
"WeightDType",
|
canter/inference.py
CHANGED
|
@@ -15,7 +15,13 @@ from torch.amp import autocast
|
|
| 15 |
from .modeling_canter import CanterPath, PreparedText
|
| 16 |
from .runtime import CANTER_AMP_DTYPE, validate_common_runtime
|
| 17 |
from .schedules import Schedule, build_schedule
|
| 18 |
-
from .solvers import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
if TYPE_CHECKING:
|
| 21 |
from .loading import CanterComponents
|
|
@@ -550,6 +556,7 @@ class CanterInferenceEngine:
|
|
| 550 |
config: CanterInferenceConfig = _DEFAULT_INFERENCE_CONFIG,
|
| 551 |
initial_noise: Tensor | None = None,
|
| 552 |
progress: SolverProgress | None = None,
|
|
|
|
| 553 |
) -> CanterLatentOutput:
|
| 554 |
"""Generate float32 Canter latents for one prompt or prompt batch."""
|
| 555 |
|
|
@@ -632,6 +639,7 @@ class CanterInferenceEngine:
|
|
| 632 |
euler_maruyama_multiplier=config.euler_maruyama_multiplier,
|
| 633 |
er_sde_noise_multiplier=config.er_sde_noise_multiplier,
|
| 634 |
progress=progress,
|
|
|
|
| 635 |
)
|
| 636 |
return CanterLatentOutput(latents=latents, schedule=schedule)
|
| 637 |
|
|
|
|
| 15 |
from .modeling_canter import CanterPath, PreparedText
|
| 16 |
from .runtime import CANTER_AMP_DTYPE, validate_common_runtime
|
| 17 |
from .schedules import Schedule, build_schedule
|
| 18 |
+
from .solvers import (
|
| 19 |
+
LOGSNR_SOLVER_START_EPS,
|
| 20 |
+
Solver,
|
| 21 |
+
SolverProgress,
|
| 22 |
+
SolverStateCallback,
|
| 23 |
+
solve,
|
| 24 |
+
)
|
| 25 |
|
| 26 |
if TYPE_CHECKING:
|
| 27 |
from .loading import CanterComponents
|
|
|
|
| 556 |
config: CanterInferenceConfig = _DEFAULT_INFERENCE_CONFIG,
|
| 557 |
initial_noise: Tensor | None = None,
|
| 558 |
progress: SolverProgress | None = None,
|
| 559 |
+
state_callback: SolverStateCallback | None = None,
|
| 560 |
) -> CanterLatentOutput:
|
| 561 |
"""Generate float32 Canter latents for one prompt or prompt batch."""
|
| 562 |
|
|
|
|
| 639 |
euler_maruyama_multiplier=config.euler_maruyama_multiplier,
|
| 640 |
er_sde_noise_multiplier=config.er_sde_noise_multiplier,
|
| 641 |
progress=progress,
|
| 642 |
+
state_callback=state_callback,
|
| 643 |
)
|
| 644 |
return CanterLatentOutput(latents=latents, schedule=schedule)
|
| 645 |
|
canter/latent_rgb_preview.safetensors
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
version https://git-lfs.github.com/spec/v1
|
| 2 |
+
oid sha256:0760245660c120a79ea536e3c77bb53f5230f22801874623c339ef58fc218866
|
| 3 |
+
size 6832
|
canter/pipeline.py
CHANGED
|
@@ -24,11 +24,16 @@ from .loading import (
|
|
| 24 |
CanterReleaseMetadata,
|
| 25 |
WeightDType,
|
| 26 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
from .vae import CanterVae
|
| 28 |
from .version import CANTER_VERSION
|
| 29 |
|
| 30 |
if TYPE_CHECKING:
|
| 31 |
-
from .solvers import SolverProgress
|
| 32 |
|
| 33 |
|
| 34 |
class CanterOutputType(Enum):
|
|
@@ -100,12 +105,39 @@ class _LatentEngine(Protocol):
|
|
| 100 |
config: CanterInferenceConfig,
|
| 101 |
initial_noise: Tensor | None,
|
| 102 |
progress: SolverProgress | None,
|
|
|
|
| 103 |
) -> CanterLatentOutput:
|
| 104 |
"""Generate deterministic whitened Canter latents."""
|
| 105 |
|
| 106 |
...
|
| 107 |
|
| 108 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
_DEFAULT_PIPELINE_CONFIG = CanterPipelineConfig()
|
| 110 |
|
| 111 |
|
|
@@ -117,6 +149,7 @@ class CanterPipeline:
|
|
| 117 |
engine: _LatentEngine,
|
| 118 |
vae: CanterVae,
|
| 119 |
metadata: CanterPipelineMetadata,
|
|
|
|
| 120 |
) -> None:
|
| 121 |
"""Construct a pipeline from validated inference components."""
|
| 122 |
|
|
@@ -128,6 +161,7 @@ class CanterPipeline:
|
|
| 128 |
raise ValueError("Pipeline metadata and DINAC-AE revision disagree.")
|
| 129 |
self.engine = engine
|
| 130 |
self.vae = vae
|
|
|
|
| 131 |
self.metadata = metadata
|
| 132 |
self.device = engine.device
|
| 133 |
|
|
@@ -177,6 +211,10 @@ class CanterPipeline:
|
|
| 177 |
CanterInferenceEngine(components),
|
| 178 |
resolved_vae,
|
| 179 |
metadata,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
)
|
| 181 |
|
| 182 |
def __call__(
|
|
@@ -187,18 +225,23 @@ class CanterPipeline:
|
|
| 187 |
config: CanterPipelineConfig = _DEFAULT_PIPELINE_CONFIG,
|
| 188 |
initial_noise: Tensor | None = None,
|
| 189 |
progress: SolverProgress | None = None,
|
|
|
|
| 190 |
) -> CanterPipelineOutput:
|
| 191 |
"""Generate the configured output without recording gradients."""
|
| 192 |
|
| 193 |
if not isinstance(config, CanterPipelineConfig):
|
| 194 |
raise TypeError("config must be a CanterPipelineConfig.")
|
|
|
|
|
|
|
| 195 |
latent_output = self.engine.generate(
|
| 196 |
prompts,
|
| 197 |
negative_prompts=negative_prompts,
|
| 198 |
config=config.inference,
|
| 199 |
initial_noise=initial_noise,
|
| 200 |
progress=progress,
|
|
|
|
| 201 |
)
|
|
|
|
| 202 |
match config.output_type:
|
| 203 |
case CanterOutputType.LATENT:
|
| 204 |
return CanterPipelineOutput(
|
|
@@ -235,6 +278,32 @@ class CanterPipeline:
|
|
| 235 |
case _ as unreachable:
|
| 236 |
raise RuntimeError(f"Unsupported output type: {unreachable}")
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
|
| 239 |
def _validate_vae_selection(
|
| 240 |
vae: CanterVae,
|
|
|
|
| 24 |
CanterReleaseMetadata,
|
| 25 |
WeightDType,
|
| 26 |
)
|
| 27 |
+
from .preview import (
|
| 28 |
+
LATENT_RGB_PREVIEW_FILENAME,
|
| 29 |
+
CanterPreviewCallback,
|
| 30 |
+
LatentRgbPreviewer,
|
| 31 |
+
)
|
| 32 |
from .vae import CanterVae
|
| 33 |
from .version import CANTER_VERSION
|
| 34 |
|
| 35 |
if TYPE_CHECKING:
|
| 36 |
+
from .solvers import SolverProgress, SolverStateCallback
|
| 37 |
|
| 38 |
|
| 39 |
class CanterOutputType(Enum):
|
|
|
|
| 105 |
config: CanterInferenceConfig,
|
| 106 |
initial_noise: Tensor | None,
|
| 107 |
progress: SolverProgress | None,
|
| 108 |
+
state_callback: SolverStateCallback | None,
|
| 109 |
) -> CanterLatentOutput:
|
| 110 |
"""Generate deterministic whitened Canter latents."""
|
| 111 |
|
| 112 |
...
|
| 113 |
|
| 114 |
|
| 115 |
+
class _LatentPreviewer(Protocol):
|
| 116 |
+
"""Asynchronous latent preview surface consumed by the pipeline."""
|
| 117 |
+
|
| 118 |
+
def try_submit(
|
| 119 |
+
self,
|
| 120 |
+
latents: Tensor,
|
| 121 |
+
*,
|
| 122 |
+
callback: CanterPreviewCallback,
|
| 123 |
+
completed: int,
|
| 124 |
+
total: int,
|
| 125 |
+
) -> bool:
|
| 126 |
+
"""Submit one preview if the worker is idle."""
|
| 127 |
+
|
| 128 |
+
...
|
| 129 |
+
|
| 130 |
+
def raise_if_failed(self) -> None:
|
| 131 |
+
"""Propagate one completed asynchronous failure."""
|
| 132 |
+
|
| 133 |
+
...
|
| 134 |
+
|
| 135 |
+
def close(self) -> None:
|
| 136 |
+
"""Finish preview work and release worker resources."""
|
| 137 |
+
|
| 138 |
+
...
|
| 139 |
+
|
| 140 |
+
|
| 141 |
_DEFAULT_PIPELINE_CONFIG = CanterPipelineConfig()
|
| 142 |
|
| 143 |
|
|
|
|
| 149 |
engine: _LatentEngine,
|
| 150 |
vae: CanterVae,
|
| 151 |
metadata: CanterPipelineMetadata,
|
| 152 |
+
previewer: _LatentPreviewer,
|
| 153 |
) -> None:
|
| 154 |
"""Construct a pipeline from validated inference components."""
|
| 155 |
|
|
|
|
| 161 |
raise ValueError("Pipeline metadata and DINAC-AE revision disagree.")
|
| 162 |
self.engine = engine
|
| 163 |
self.vae = vae
|
| 164 |
+
self.previewer = previewer
|
| 165 |
self.metadata = metadata
|
| 166 |
self.device = engine.device
|
| 167 |
|
|
|
|
| 211 |
CanterInferenceEngine(components),
|
| 212 |
resolved_vae,
|
| 213 |
metadata,
|
| 214 |
+
LatentRgbPreviewer.from_file(
|
| 215 |
+
Path(__file__).with_name(LATENT_RGB_PREVIEW_FILENAME),
|
| 216 |
+
device=components.model.input_projection.weight.device,
|
| 217 |
+
),
|
| 218 |
)
|
| 219 |
|
| 220 |
def __call__(
|
|
|
|
| 225 |
config: CanterPipelineConfig = _DEFAULT_PIPELINE_CONFIG,
|
| 226 |
initial_noise: Tensor | None = None,
|
| 227 |
progress: SolverProgress | None = None,
|
| 228 |
+
preview: CanterPreviewCallback | None = None,
|
| 229 |
) -> CanterPipelineOutput:
|
| 230 |
"""Generate the configured output without recording gradients."""
|
| 231 |
|
| 232 |
if not isinstance(config, CanterPipelineConfig):
|
| 233 |
raise TypeError("config must be a CanterPipelineConfig.")
|
| 234 |
+
self.previewer.raise_if_failed()
|
| 235 |
+
state_callback = self._state_callback(preview)
|
| 236 |
latent_output = self.engine.generate(
|
| 237 |
prompts,
|
| 238 |
negative_prompts=negative_prompts,
|
| 239 |
config=config.inference,
|
| 240 |
initial_noise=initial_noise,
|
| 241 |
progress=progress,
|
| 242 |
+
state_callback=state_callback,
|
| 243 |
)
|
| 244 |
+
self.previewer.raise_if_failed()
|
| 245 |
match config.output_type:
|
| 246 |
case CanterOutputType.LATENT:
|
| 247 |
return CanterPipelineOutput(
|
|
|
|
| 278 |
case _ as unreachable:
|
| 279 |
raise RuntimeError(f"Unsupported output type: {unreachable}")
|
| 280 |
|
| 281 |
+
def close(self) -> None:
|
| 282 |
+
"""Finish preview work before releasing pipeline resources."""
|
| 283 |
+
|
| 284 |
+
self.previewer.close()
|
| 285 |
+
|
| 286 |
+
def _state_callback(
|
| 287 |
+
self,
|
| 288 |
+
preview: CanterPreviewCallback | None,
|
| 289 |
+
) -> SolverStateCallback | None:
|
| 290 |
+
"""Build a non-blocking solver-state callback for one request."""
|
| 291 |
+
|
| 292 |
+
if preview is None:
|
| 293 |
+
return None
|
| 294 |
+
|
| 295 |
+
def submit(state: Tensor, completed: int, total: int) -> None:
|
| 296 |
+
"""Drop busy updates and enqueue every eligible idle update."""
|
| 297 |
+
|
| 298 |
+
self.previewer.try_submit(
|
| 299 |
+
state,
|
| 300 |
+
callback=preview,
|
| 301 |
+
completed=completed,
|
| 302 |
+
total=total,
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
return submit
|
| 306 |
+
|
| 307 |
|
| 308 |
def _validate_vae_selection(
|
| 309 |
vae: CanterVae,
|
canter/preview.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Asynchronous DINAC latent-to-RGB previews for Canter inference."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import json
|
| 6 |
+
import logging
|
| 7 |
+
import threading
|
| 8 |
+
from concurrent.futures import Future, ThreadPoolExecutor
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
from typing import Protocol
|
| 12 |
+
|
| 13 |
+
import torch
|
| 14 |
+
import torch.nn.functional as F
|
| 15 |
+
from PIL import Image
|
| 16 |
+
from safetensors import safe_open
|
| 17 |
+
from safetensors.torch import load_file
|
| 18 |
+
from torch import Tensor
|
| 19 |
+
|
| 20 |
+
LATENT_RGB_PREVIEW_FILENAME = "latent_rgb_preview.safetensors"
|
| 21 |
+
LATENT_RGB_PREVIEW_FORMAT = "canter-latent-rgb-preview-v1"
|
| 22 |
+
LATENT_RGB_PREVIEW_METADATA_KEY = "canter"
|
| 23 |
+
_WEIGHT_KEY = "projection.weight"
|
| 24 |
+
_BIAS_KEY = "projection.bias"
|
| 25 |
+
_EXPECTED_LATENT_CHANNELS = 128
|
| 26 |
+
_EXPECTED_RGB_CHANNELS = 3
|
| 27 |
+
_EXPECTED_SPATIAL_EXPANSION = 2
|
| 28 |
+
_LOGGER = logging.getLogger(__name__)
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
class CanterPreviewCallback(Protocol):
|
| 32 |
+
"""Consumer of one asynchronously rendered solver preview."""
|
| 33 |
+
|
| 34 |
+
def __call__(
|
| 35 |
+
self,
|
| 36 |
+
images: tuple[Image.Image, ...],
|
| 37 |
+
completed: int,
|
| 38 |
+
total: int,
|
| 39 |
+
) -> None:
|
| 40 |
+
"""Consume preview images for one completed solver update."""
|
| 41 |
+
|
| 42 |
+
...
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@dataclass(frozen=True)
|
| 46 |
+
class _PreviewWork:
|
| 47 |
+
"""Inputs consumed by one asynchronous CPU preview job."""
|
| 48 |
+
|
| 49 |
+
host_rgb: Tensor
|
| 50 |
+
ready: torch.cuda.Event
|
| 51 |
+
callback: CanterPreviewCallback
|
| 52 |
+
completed: int
|
| 53 |
+
total: int
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class LatentRgbPreviewer:
|
| 57 |
+
"""Project solver states on CUDA and finish previews on one CPU worker."""
|
| 58 |
+
|
| 59 |
+
def __init__(
|
| 60 |
+
self,
|
| 61 |
+
weight: Tensor,
|
| 62 |
+
bias: Tensor,
|
| 63 |
+
*,
|
| 64 |
+
device: torch.device,
|
| 65 |
+
) -> None:
|
| 66 |
+
"""Move the tiny projection to CUDA and initialize one-slot dispatch."""
|
| 67 |
+
|
| 68 |
+
if device.type != "cuda":
|
| 69 |
+
raise ValueError("Canter latent previews require a CUDA device.")
|
| 70 |
+
self._weight = weight.to(device=device, dtype=torch.float32)
|
| 71 |
+
self._bias = bias.to(device=device, dtype=torch.float32)
|
| 72 |
+
self.device = self._weight.device
|
| 73 |
+
self._executor = ThreadPoolExecutor(
|
| 74 |
+
max_workers=1,
|
| 75 |
+
thread_name_prefix="canter-preview",
|
| 76 |
+
)
|
| 77 |
+
self._lock = threading.Lock()
|
| 78 |
+
self._future: Future[None] | None = None
|
| 79 |
+
self._host_buffer: Tensor | None = None
|
| 80 |
+
self._closed = False
|
| 81 |
+
|
| 82 |
+
@classmethod
|
| 83 |
+
def from_file(
|
| 84 |
+
cls,
|
| 85 |
+
path: Path,
|
| 86 |
+
*,
|
| 87 |
+
device: torch.device,
|
| 88 |
+
) -> LatentRgbPreviewer:
|
| 89 |
+
"""Load one strict standalone latent-RGB Safetensors artifact."""
|
| 90 |
+
|
| 91 |
+
weight, bias = _load_projection_weights(path)
|
| 92 |
+
return cls(weight, bias, device=device)
|
| 93 |
+
|
| 94 |
+
def try_submit(
|
| 95 |
+
self,
|
| 96 |
+
latents: Tensor,
|
| 97 |
+
*,
|
| 98 |
+
callback: CanterPreviewCallback,
|
| 99 |
+
completed: int,
|
| 100 |
+
total: int,
|
| 101 |
+
) -> bool:
|
| 102 |
+
"""Submit a preview only when the prior CPU job has fully completed."""
|
| 103 |
+
|
| 104 |
+
with self._lock:
|
| 105 |
+
self._raise_completed_error_locked()
|
| 106 |
+
if self._closed:
|
| 107 |
+
raise RuntimeError("Canter latent previewer is closed.")
|
| 108 |
+
if self._future is not None:
|
| 109 |
+
return False
|
| 110 |
+
with (
|
| 111 |
+
torch.inference_mode(),
|
| 112 |
+
torch.autocast(device_type="cuda", enabled=False),
|
| 113 |
+
):
|
| 114 |
+
projected = F.conv2d(latents.float(), self._weight, self._bias)
|
| 115 |
+
small_rgb = F.pixel_shuffle(
|
| 116 |
+
projected,
|
| 117 |
+
upscale_factor=_EXPECTED_SPATIAL_EXPANSION,
|
| 118 |
+
).clamp(-1.0, 1.0)
|
| 119 |
+
host_buffer = self._host_buffer_for(small_rgb)
|
| 120 |
+
host_buffer.copy_(small_rgb, non_blocking=True)
|
| 121 |
+
ready = torch.cuda.Event()
|
| 122 |
+
ready.record(torch.cuda.current_stream(self.device))
|
| 123 |
+
self._future = self._executor.submit(
|
| 124 |
+
_finish_preview,
|
| 125 |
+
_PreviewWork(
|
| 126 |
+
host_rgb=host_buffer,
|
| 127 |
+
ready=ready,
|
| 128 |
+
callback=callback,
|
| 129 |
+
completed=completed,
|
| 130 |
+
total=total,
|
| 131 |
+
),
|
| 132 |
+
)
|
| 133 |
+
self._future.add_done_callback(_log_preview_failure)
|
| 134 |
+
return True
|
| 135 |
+
|
| 136 |
+
def raise_if_failed(self) -> None:
|
| 137 |
+
"""Propagate a completed asynchronous preview failure without waiting."""
|
| 138 |
+
|
| 139 |
+
with self._lock:
|
| 140 |
+
self._raise_completed_error_locked()
|
| 141 |
+
|
| 142 |
+
def close(self) -> None:
|
| 143 |
+
"""Finish accepted preview work and stop the worker."""
|
| 144 |
+
|
| 145 |
+
with self._lock:
|
| 146 |
+
self._closed = True
|
| 147 |
+
self._executor.shutdown(wait=True, cancel_futures=False)
|
| 148 |
+
self.raise_if_failed()
|
| 149 |
+
|
| 150 |
+
def _host_buffer_for(self, small_rgb: Tensor) -> Tensor:
|
| 151 |
+
"""Return a shape-compatible pinned float32 host buffer."""
|
| 152 |
+
|
| 153 |
+
shape = tuple(int(dimension) for dimension in small_rgb.shape)
|
| 154 |
+
current = self._host_buffer
|
| 155 |
+
if current is None or tuple(current.shape) != shape:
|
| 156 |
+
current = torch.empty(
|
| 157 |
+
shape,
|
| 158 |
+
device="cpu",
|
| 159 |
+
dtype=torch.float32,
|
| 160 |
+
pin_memory=True,
|
| 161 |
+
)
|
| 162 |
+
self._host_buffer = current
|
| 163 |
+
return current
|
| 164 |
+
|
| 165 |
+
def _raise_completed_error_locked(self) -> None:
|
| 166 |
+
"""Resolve and clear a completed worker future while holding the lock."""
|
| 167 |
+
|
| 168 |
+
future = self._future
|
| 169 |
+
if future is None or not future.done():
|
| 170 |
+
return
|
| 171 |
+
self._future = None
|
| 172 |
+
future.result()
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def _finish_preview(work: _PreviewWork) -> None:
|
| 176 |
+
"""Wait off-thread, convert the native preview, and publish it."""
|
| 177 |
+
|
| 178 |
+
work.ready.synchronize()
|
| 179 |
+
with torch.inference_mode():
|
| 180 |
+
images = _to_pil_images(work.host_rgb)
|
| 181 |
+
work.callback(images, work.completed, work.total)
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def _to_pil_images(images: Tensor) -> tuple[Image.Image, ...]:
|
| 185 |
+
"""Convert clamped CPU float32 BCHW images into RGB PIL images."""
|
| 186 |
+
|
| 187 |
+
pixels = (
|
| 188 |
+
images.add(1.0)
|
| 189 |
+
.mul(127.5)
|
| 190 |
+
.round()
|
| 191 |
+
.clamp(0.0, 255.0)
|
| 192 |
+
.to(torch.uint8)
|
| 193 |
+
.permute(0, 2, 3, 1)
|
| 194 |
+
.contiguous()
|
| 195 |
+
.numpy()
|
| 196 |
+
)
|
| 197 |
+
return tuple(Image.fromarray(image, mode="RGB") for image in pixels)
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
def _load_projection_weights(path: Path) -> tuple[Tensor, Tensor]:
|
| 201 |
+
"""Validate metadata and tensors from one preview artifact."""
|
| 202 |
+
|
| 203 |
+
metadata = _load_preview_metadata(path)
|
| 204 |
+
expected_metadata = {
|
| 205 |
+
"format": LATENT_RGB_PREVIEW_FORMAT,
|
| 206 |
+
"latent_channels": str(_EXPECTED_LATENT_CHANNELS),
|
| 207 |
+
"rgb_channels": str(_EXPECTED_RGB_CHANNELS),
|
| 208 |
+
"spatial_expansion_factor": str(_EXPECTED_SPATIAL_EXPANSION),
|
| 209 |
+
"target_downsample_factor": "8",
|
| 210 |
+
}
|
| 211 |
+
mismatches = {
|
| 212 |
+
key: (metadata.get(key), expected)
|
| 213 |
+
for key, expected in expected_metadata.items()
|
| 214 |
+
if metadata.get(key) != expected
|
| 215 |
+
}
|
| 216 |
+
if mismatches:
|
| 217 |
+
raise RuntimeError(
|
| 218 |
+
f"Latent preview Safetensors metadata is incompatible: {mismatches}."
|
| 219 |
+
)
|
| 220 |
+
tensors = load_file(path, device="cpu")
|
| 221 |
+
if set(tensors) != {_WEIGHT_KEY, _BIAS_KEY}:
|
| 222 |
+
raise RuntimeError(
|
| 223 |
+
"Latent preview Safetensors must contain only projection.weight "
|
| 224 |
+
"and projection.bias."
|
| 225 |
+
)
|
| 226 |
+
weight = tensors[_WEIGHT_KEY]
|
| 227 |
+
bias = tensors[_BIAS_KEY]
|
| 228 |
+
expected_weight_shape = (
|
| 229 |
+
_EXPECTED_RGB_CHANNELS * _EXPECTED_SPATIAL_EXPANSION**2,
|
| 230 |
+
_EXPECTED_LATENT_CHANNELS,
|
| 231 |
+
1,
|
| 232 |
+
1,
|
| 233 |
+
)
|
| 234 |
+
if tuple(weight.shape) != expected_weight_shape:
|
| 235 |
+
raise RuntimeError(
|
| 236 |
+
"Latent preview projection.weight has an incompatible shape: "
|
| 237 |
+
f"expected {expected_weight_shape}, got {tuple(weight.shape)}."
|
| 238 |
+
)
|
| 239 |
+
if tuple(bias.shape) != (expected_weight_shape[0],):
|
| 240 |
+
raise RuntimeError("Latent preview projection.bias has an incompatible shape.")
|
| 241 |
+
if weight.dtype is not torch.float32 or bias.dtype is not torch.float32:
|
| 242 |
+
raise RuntimeError("Latent preview projection weights must use float32.")
|
| 243 |
+
return weight.contiguous(), bias.contiguous()
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _load_preview_metadata(path: Path) -> dict[str, str]:
|
| 247 |
+
"""Load deterministic JSON metadata from one preview artifact."""
|
| 248 |
+
|
| 249 |
+
with safe_open(path, framework="pt", device="cpu") as file:
|
| 250 |
+
header = file.metadata()
|
| 251 |
+
if header is None or set(header) != {LATENT_RGB_PREVIEW_METADATA_KEY}:
|
| 252 |
+
raise RuntimeError("Latent preview Safetensors metadata is missing.")
|
| 253 |
+
value = json.loads(header[LATENT_RGB_PREVIEW_METADATA_KEY])
|
| 254 |
+
if not isinstance(value, dict) or any(
|
| 255 |
+
not isinstance(key, str) or not isinstance(item, str)
|
| 256 |
+
for key, item in value.items()
|
| 257 |
+
):
|
| 258 |
+
raise TypeError("Latent preview metadata must map strings to strings.")
|
| 259 |
+
return value
|
| 260 |
+
|
| 261 |
+
|
| 262 |
+
def _log_preview_failure(future: Future[None]) -> None:
|
| 263 |
+
"""Log asynchronous callback failures immediately for console diagnosis."""
|
| 264 |
+
|
| 265 |
+
error = future.exception()
|
| 266 |
+
if error is not None:
|
| 267 |
+
_LOGGER.error(
|
| 268 |
+
"Asynchronous Canter preview failed",
|
| 269 |
+
exc_info=(type(error), error, error.__traceback__),
|
| 270 |
+
)
|
canter/solvers.py
CHANGED
|
@@ -46,12 +46,40 @@ class SolverProgress(Protocol):
|
|
| 46 |
...
|
| 47 |
|
| 48 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
def _ignore_progress(completed: int, total: int) -> None:
|
| 50 |
"""Discard solver progress for non-interactive inference."""
|
| 51 |
|
| 52 |
del completed, total
|
| 53 |
|
| 54 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
def _time_batch(time: Tensor, batch: int, device: torch.device) -> Tensor:
|
| 56 |
"""Expand one scalar schedule value to a float32 batch vector."""
|
| 57 |
|
|
@@ -80,6 +108,7 @@ def _euler(
|
|
| 80 |
state: Tensor,
|
| 81 |
schedule: Tensor,
|
| 82 |
progress: SolverProgress,
|
|
|
|
| 83 |
) -> Tensor:
|
| 84 |
"""Integrate one first-order Euler update."""
|
| 85 |
|
|
@@ -89,7 +118,7 @@ def _euler(
|
|
| 89 |
time = _time_batch(schedule[index], batch, state.device)
|
| 90 |
step = schedule[index + 1] - schedule[index]
|
| 91 |
state = state + step * velocity(state, time, index)
|
| 92 |
-
|
| 93 |
return state
|
| 94 |
|
| 95 |
|
|
@@ -110,6 +139,7 @@ def _euler_maruyama(
|
|
| 110 |
generator: torch.Generator,
|
| 111 |
multiplier: float,
|
| 112 |
progress: SolverProgress,
|
|
|
|
| 113 |
) -> Tensor:
|
| 114 |
"""Integrate the reverse SDE with an Euler-Maruyama update."""
|
| 115 |
|
|
@@ -129,7 +159,7 @@ def _euler_maruyama(
|
|
| 129 |
terminal = index == intervals - 1 and next_value == 0.0
|
| 130 |
if terminal:
|
| 131 |
state = state - time_value * predicted
|
| 132 |
-
|
| 133 |
continue
|
| 134 |
drift = predicted - time.view((batch, 1, 1, 1)) * _score(
|
| 135 |
state,
|
|
@@ -145,7 +175,7 @@ def _euler_maruyama(
|
|
| 145 |
diffusion = 2.0 * time_value
|
| 146 |
noise_scale = float(multiplier) * math.sqrt(diffusion) * math.sqrt(abs(step))
|
| 147 |
state = state + step * drift + noise_scale * noise
|
| 148 |
-
|
| 149 |
return state
|
| 150 |
|
| 151 |
|
|
@@ -200,6 +230,7 @@ def _dpmpp_2m(
|
|
| 200 |
state: Tensor,
|
| 201 |
schedule: Tensor,
|
| 202 |
progress: SolverProgress,
|
|
|
|
| 203 |
) -> Tensor:
|
| 204 |
"""Integrate with the flow-matching DPM++ 2M formulation."""
|
| 205 |
|
|
@@ -230,7 +261,7 @@ def _dpmpp_2m(
|
|
| 230 |
)
|
| 231 |
previous_denoised = denoised
|
| 232 |
previous_lambda = lambdas[index]
|
| 233 |
-
|
| 234 |
return state
|
| 235 |
|
| 236 |
|
|
@@ -347,6 +378,7 @@ def _er_sde(
|
|
| 347 |
generator: torch.Generator,
|
| 348 |
noise_multiplier: float,
|
| 349 |
progress: SolverProgress,
|
|
|
|
| 350 |
) -> Tensor:
|
| 351 |
"""Integrate with third-stage VP ER-SDE and Gauss-Legendre quadrature."""
|
| 352 |
|
|
@@ -383,7 +415,7 @@ def _er_sde(
|
|
| 383 |
noise_multiplier=float(noise_multiplier),
|
| 384 |
)
|
| 385 |
old_denoised = denoised
|
| 386 |
-
|
| 387 |
return state
|
| 388 |
|
| 389 |
|
|
@@ -407,6 +439,7 @@ def _abm2(
|
|
| 407 |
state: Tensor,
|
| 408 |
schedule: Tensor,
|
| 409 |
progress: SolverProgress,
|
|
|
|
| 410 |
) -> Tensor:
|
| 411 |
"""Integrate with ABM2, including corrected-state reevaluation."""
|
| 412 |
|
|
@@ -424,7 +457,7 @@ def _abm2(
|
|
| 424 |
_time_batch(schedule[1], batch, state.device),
|
| 425 |
1,
|
| 426 |
)
|
| 427 |
-
|
| 428 |
for index in range(1, intervals):
|
| 429 |
previous_step = schedule[index] - schedule[index - 1]
|
| 430 |
current_step = schedule[index + 1] - schedule[index]
|
|
@@ -450,7 +483,7 @@ def _abm2(
|
|
| 450 |
_time_batch(schedule[index + 1], batch, state.device),
|
| 451 |
index + 1,
|
| 452 |
)
|
| 453 |
-
|
| 454 |
return state
|
| 455 |
|
| 456 |
|
|
@@ -464,6 +497,7 @@ def solve(
|
|
| 464 |
euler_maruyama_multiplier: float,
|
| 465 |
er_sde_noise_multiplier: float = 1.0,
|
| 466 |
progress: SolverProgress | None = None,
|
|
|
|
| 467 |
) -> Tensor:
|
| 468 |
"""Integrate Canter velocity predictions over one validated schedule."""
|
| 469 |
|
|
@@ -477,9 +511,18 @@ def solve(
|
|
| 477 |
if not math.isfinite(er_multiplier) or er_multiplier < 0.0:
|
| 478 |
raise ValueError("er_sde_noise_multiplier must be finite and non-negative.")
|
| 479 |
resolved_progress = _ignore_progress if progress is None else progress
|
|
|
|
|
|
|
|
|
|
| 480 |
match solver:
|
| 481 |
case Solver.EULER:
|
| 482 |
-
return _euler(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
case Solver.EULER_MARUYAMA:
|
| 484 |
return _euler_maruyama(
|
| 485 |
velocity,
|
|
@@ -488,6 +531,7 @@ def solve(
|
|
| 488 |
generator=generator,
|
| 489 |
multiplier=em_multiplier,
|
| 490 |
progress=resolved_progress,
|
|
|
|
| 491 |
)
|
| 492 |
case Solver.ER_SDE:
|
| 493 |
return _er_sde(
|
|
@@ -497,6 +541,7 @@ def solve(
|
|
| 497 |
generator=generator,
|
| 498 |
noise_multiplier=er_multiplier,
|
| 499 |
progress=resolved_progress,
|
|
|
|
| 500 |
)
|
| 501 |
case Solver.DPMPP_2M:
|
| 502 |
return _dpmpp_2m(
|
|
@@ -504,6 +549,7 @@ def solve(
|
|
| 504 |
initial_state,
|
| 505 |
schedule,
|
| 506 |
resolved_progress,
|
|
|
|
| 507 |
)
|
| 508 |
case Solver.ABM2:
|
| 509 |
return _abm2(
|
|
@@ -511,6 +557,7 @@ def solve(
|
|
| 511 |
initial_state,
|
| 512 |
schedule,
|
| 513 |
resolved_progress,
|
|
|
|
| 514 |
)
|
| 515 |
case _ as unreachable:
|
| 516 |
raise RuntimeError(f"Unsupported Canter solver: {unreachable}")
|
|
|
|
| 46 |
...
|
| 47 |
|
| 48 |
|
| 49 |
+
class SolverStateCallback(Protocol):
|
| 50 |
+
"""Callback receiving the state after one completed solver update."""
|
| 51 |
+
|
| 52 |
+
def __call__(self, state: Tensor, completed: int, total: int) -> None:
|
| 53 |
+
"""Observe one completed state update."""
|
| 54 |
+
|
| 55 |
+
...
|
| 56 |
+
|
| 57 |
+
|
| 58 |
def _ignore_progress(completed: int, total: int) -> None:
|
| 59 |
"""Discard solver progress for non-interactive inference."""
|
| 60 |
|
| 61 |
del completed, total
|
| 62 |
|
| 63 |
|
| 64 |
+
def _ignore_state(state: Tensor, completed: int, total: int) -> None:
|
| 65 |
+
"""Discard solver states when no observer is configured."""
|
| 66 |
+
|
| 67 |
+
del state, completed, total
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
def _report_update(
|
| 71 |
+
state: Tensor,
|
| 72 |
+
completed: int,
|
| 73 |
+
total: int,
|
| 74 |
+
progress: SolverProgress,
|
| 75 |
+
state_callback: SolverStateCallback,
|
| 76 |
+
) -> None:
|
| 77 |
+
"""Report one completed update before exposing its resulting state."""
|
| 78 |
+
|
| 79 |
+
progress(completed, total)
|
| 80 |
+
state_callback(state, completed, total)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
def _time_batch(time: Tensor, batch: int, device: torch.device) -> Tensor:
|
| 84 |
"""Expand one scalar schedule value to a float32 batch vector."""
|
| 85 |
|
|
|
|
| 108 |
state: Tensor,
|
| 109 |
schedule: Tensor,
|
| 110 |
progress: SolverProgress,
|
| 111 |
+
state_callback: SolverStateCallback,
|
| 112 |
) -> Tensor:
|
| 113 |
"""Integrate one first-order Euler update."""
|
| 114 |
|
|
|
|
| 118 |
time = _time_batch(schedule[index], batch, state.device)
|
| 119 |
step = schedule[index + 1] - schedule[index]
|
| 120 |
state = state + step * velocity(state, time, index)
|
| 121 |
+
_report_update(state, index + 1, intervals, progress, state_callback)
|
| 122 |
return state
|
| 123 |
|
| 124 |
|
|
|
|
| 139 |
generator: torch.Generator,
|
| 140 |
multiplier: float,
|
| 141 |
progress: SolverProgress,
|
| 142 |
+
state_callback: SolverStateCallback,
|
| 143 |
) -> Tensor:
|
| 144 |
"""Integrate the reverse SDE with an Euler-Maruyama update."""
|
| 145 |
|
|
|
|
| 159 |
terminal = index == intervals - 1 and next_value == 0.0
|
| 160 |
if terminal:
|
| 161 |
state = state - time_value * predicted
|
| 162 |
+
_report_update(state, index + 1, intervals, progress, state_callback)
|
| 163 |
continue
|
| 164 |
drift = predicted - time.view((batch, 1, 1, 1)) * _score(
|
| 165 |
state,
|
|
|
|
| 175 |
diffusion = 2.0 * time_value
|
| 176 |
noise_scale = float(multiplier) * math.sqrt(diffusion) * math.sqrt(abs(step))
|
| 177 |
state = state + step * drift + noise_scale * noise
|
| 178 |
+
_report_update(state, index + 1, intervals, progress, state_callback)
|
| 179 |
return state
|
| 180 |
|
| 181 |
|
|
|
|
| 230 |
state: Tensor,
|
| 231 |
schedule: Tensor,
|
| 232 |
progress: SolverProgress,
|
| 233 |
+
state_callback: SolverStateCallback,
|
| 234 |
) -> Tensor:
|
| 235 |
"""Integrate with the flow-matching DPM++ 2M formulation."""
|
| 236 |
|
|
|
|
| 261 |
)
|
| 262 |
previous_denoised = denoised
|
| 263 |
previous_lambda = lambdas[index]
|
| 264 |
+
_report_update(state, index + 1, intervals, progress, state_callback)
|
| 265 |
return state
|
| 266 |
|
| 267 |
|
|
|
|
| 378 |
generator: torch.Generator,
|
| 379 |
noise_multiplier: float,
|
| 380 |
progress: SolverProgress,
|
| 381 |
+
state_callback: SolverStateCallback,
|
| 382 |
) -> Tensor:
|
| 383 |
"""Integrate with third-stage VP ER-SDE and Gauss-Legendre quadrature."""
|
| 384 |
|
|
|
|
| 415 |
noise_multiplier=float(noise_multiplier),
|
| 416 |
)
|
| 417 |
old_denoised = denoised
|
| 418 |
+
_report_update(state, index + 1, intervals, progress, state_callback)
|
| 419 |
return state
|
| 420 |
|
| 421 |
|
|
|
|
| 439 |
state: Tensor,
|
| 440 |
schedule: Tensor,
|
| 441 |
progress: SolverProgress,
|
| 442 |
+
state_callback: SolverStateCallback,
|
| 443 |
) -> Tensor:
|
| 444 |
"""Integrate with ABM2, including corrected-state reevaluation."""
|
| 445 |
|
|
|
|
| 457 |
_time_batch(schedule[1], batch, state.device),
|
| 458 |
1,
|
| 459 |
)
|
| 460 |
+
_report_update(state, 1, intervals, progress, state_callback)
|
| 461 |
for index in range(1, intervals):
|
| 462 |
previous_step = schedule[index] - schedule[index - 1]
|
| 463 |
current_step = schedule[index + 1] - schedule[index]
|
|
|
|
| 483 |
_time_batch(schedule[index + 1], batch, state.device),
|
| 484 |
index + 1,
|
| 485 |
)
|
| 486 |
+
_report_update(state, index + 1, intervals, progress, state_callback)
|
| 487 |
return state
|
| 488 |
|
| 489 |
|
|
|
|
| 497 |
euler_maruyama_multiplier: float,
|
| 498 |
er_sde_noise_multiplier: float = 1.0,
|
| 499 |
progress: SolverProgress | None = None,
|
| 500 |
+
state_callback: SolverStateCallback | None = None,
|
| 501 |
) -> Tensor:
|
| 502 |
"""Integrate Canter velocity predictions over one validated schedule."""
|
| 503 |
|
|
|
|
| 511 |
if not math.isfinite(er_multiplier) or er_multiplier < 0.0:
|
| 512 |
raise ValueError("er_sde_noise_multiplier must be finite and non-negative.")
|
| 513 |
resolved_progress = _ignore_progress if progress is None else progress
|
| 514 |
+
resolved_state_callback = (
|
| 515 |
+
_ignore_state if state_callback is None else state_callback
|
| 516 |
+
)
|
| 517 |
match solver:
|
| 518 |
case Solver.EULER:
|
| 519 |
+
return _euler(
|
| 520 |
+
velocity,
|
| 521 |
+
initial_state,
|
| 522 |
+
schedule,
|
| 523 |
+
resolved_progress,
|
| 524 |
+
resolved_state_callback,
|
| 525 |
+
)
|
| 526 |
case Solver.EULER_MARUYAMA:
|
| 527 |
return _euler_maruyama(
|
| 528 |
velocity,
|
|
|
|
| 531 |
generator=generator,
|
| 532 |
multiplier=em_multiplier,
|
| 533 |
progress=resolved_progress,
|
| 534 |
+
state_callback=resolved_state_callback,
|
| 535 |
)
|
| 536 |
case Solver.ER_SDE:
|
| 537 |
return _er_sde(
|
|
|
|
| 541 |
generator=generator,
|
| 542 |
noise_multiplier=er_multiplier,
|
| 543 |
progress=resolved_progress,
|
| 544 |
+
state_callback=resolved_state_callback,
|
| 545 |
)
|
| 546 |
case Solver.DPMPP_2M:
|
| 547 |
return _dpmpp_2m(
|
|
|
|
| 549 |
initial_state,
|
| 550 |
schedule,
|
| 551 |
resolved_progress,
|
| 552 |
+
resolved_state_callback,
|
| 553 |
)
|
| 554 |
case Solver.ABM2:
|
| 555 |
return _abm2(
|
|
|
|
| 557 |
initial_state,
|
| 558 |
schedule,
|
| 559 |
resolved_progress,
|
| 560 |
+
resolved_state_callback,
|
| 561 |
)
|
| 562 |
case _ as unreachable:
|
| 563 |
raise RuntimeError(f"Unsupported Canter solver: {unreachable}")
|
canter/version.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
"""Single source of truth for the installable Canter code version."""
|
| 2 |
|
| 3 |
-
__version__ = "0.
|
| 4 |
CANTER_VERSION = __version__
|
|
|
|
| 1 |
"""Single source of truth for the installable Canter code version."""
|
| 2 |
|
| 3 |
+
__version__ = "0.3.0"
|
| 4 |
CANTER_VERSION = __version__
|
canter/webui.py
CHANGED
|
@@ -1,1822 +1,68 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import argparse
|
| 6 |
-
import
|
| 7 |
-
import
|
| 8 |
-
import
|
| 9 |
-
import
|
| 10 |
-
import
|
| 11 |
-
from collections.abc import Callable, Generator, Mapping, Sequence
|
| 12 |
-
from dataclasses import dataclass, replace
|
| 13 |
-
from enum import Enum
|
| 14 |
-
from hashlib import sha256
|
| 15 |
from pathlib import Path
|
| 16 |
-
from typing import Protocol, TypeAlias, TypeVar
|
| 17 |
-
|
| 18 |
-
import gradio as gr
|
| 19 |
-
from PIL import Image
|
| 20 |
|
| 21 |
from .blocks import TextAttentionBackend
|
| 22 |
-
from .inference import (
|
| 23 |
-
CanterInferenceConfig,
|
| 24 |
-
CfgGuidance,
|
| 25 |
-
PdgCurve,
|
| 26 |
-
PdgGuidance,
|
| 27 |
-
PdgMode,
|
| 28 |
-
)
|
| 29 |
from .loading import WeightDType
|
| 30 |
-
from .
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
)
|
| 37 |
-
from .schedules import Schedule
|
| 38 |
-
from .solvers import Solver, SolverProgress
|
| 39 |
|
| 40 |
_DEFAULT_MODEL = "data-archetype/canter"
|
| 41 |
_DEFAULT_SERVER_NAME = "0.0.0.0"
|
| 42 |
_DEFAULT_SERVER_PORT = 7860
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
_MAX_SEED = 2**63 - 1
|
| 47 |
-
_GPU_CONCURRENCY_ID = "canter-gpu"
|
| 48 |
-
_IMAGE_SIZE_MULTIPLE = 32
|
| 49 |
-
_PNG_METADATA_KEY = "canter"
|
| 50 |
-
|
| 51 |
-
_DEFAULT_INFERENCE = CanterInferenceConfig()
|
| 52 |
-
_SOLVERS = {value.value: value for value in Solver}
|
| 53 |
-
_SCHEDULES = {value.value: value for value in Schedule}
|
| 54 |
-
_PDG_MODES = {value.value: value for value in PdgMode if value is not PdgMode.NONE}
|
| 55 |
-
_PDG_CURVES = {value.value: value for value in PdgCurve}
|
| 56 |
_WEIGHT_DTYPES = {value.value: value for value in WeightDType}
|
| 57 |
_TEXT_BACKENDS = {value.value: value for value in TextAttentionBackend}
|
| 58 |
|
| 59 |
-
_SOLVER_CHOICES = (
|
| 60 |
-
("ABM2", Solver.ABM2.value),
|
| 61 |
-
("DPM++ 2M", Solver.DPMPP_2M.value),
|
| 62 |
-
("Euler-Maruyama", Solver.EULER_MARUYAMA.value),
|
| 63 |
-
("ER-SDE", Solver.ER_SDE.value),
|
| 64 |
-
("Euler", Solver.EULER.value),
|
| 65 |
-
)
|
| 66 |
-
_SCHEDULE_CHOICES = (
|
| 67 |
-
("Beta (0.6, 0.6)", Schedule.BETA.value),
|
| 68 |
-
("Linear", Schedule.LINEAR.value),
|
| 69 |
-
)
|
| 70 |
-
_PDG_MODE_CHOICES = (
|
| 71 |
-
("Full path drop", PdgMode.FULL.value),
|
| 72 |
-
("Three-quarter path", PdgMode.THREE_QUARTER.value),
|
| 73 |
-
("Alternate: PDG first", PdgMode.ALTERNATE_PDG_FIRST.value),
|
| 74 |
-
("Alternate: CFG first", PdgMode.ALTERNATE_CFG_FIRST.value),
|
| 75 |
-
("Combined CFG + PDG", PdgMode.COMBINED_CFG_PDG.value),
|
| 76 |
-
("PDG with alternating CFG", PdgMode.PDG_WITH_ALTERNATING_CFG.value),
|
| 77 |
-
("CFG then PDG", PdgMode.CFG_TO_PDG.value),
|
| 78 |
-
)
|
| 79 |
-
_PDG_CURVE_CHOICES = (
|
| 80 |
-
("Constant", PdgCurve.CONSTANT.value),
|
| 81 |
-
("Linear", PdgCurve.LINEAR.value),
|
| 82 |
-
("Power", PdgCurve.POWER.value),
|
| 83 |
-
)
|
| 84 |
-
_DEFAULT_PROMPT = (
|
| 85 |
-
"Wide angle photo of a weathered wooden boardwalk descending steeply from "
|
| 86 |
-
"rocky cliffs toward a rugged coastline. The boardwalk, constructed from "
|
| 87 |
-
"aged planks with visible cracks and moss growth, is bordered by rustic "
|
| 88 |
-
"wooden railings on both sides. Surrounding terrain features uneven, eroded "
|
| 89 |
-
"rock formations covered in patches of green vegetation and low-lying "
|
| 90 |
-
"shrubs. Below, a crescent-shaped sandy beach meets crashing waves of a "
|
| 91 |
-
"deep blue ocean under a stormy, overcast sky with dark clouds and hints "
|
| 92 |
-
"of distant sunlight breaking through. The horizon shows turbulent waves "
|
| 93 |
-
"colliding with rocky outcrops, creating white foam."
|
| 94 |
-
)
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
class SizePreset(Enum):
|
| 98 |
-
"""UI presets that jointly select image shape and attention gain."""
|
| 99 |
-
|
| 100 |
-
DEFAULT_PORTRAIT = "default_portrait"
|
| 101 |
-
WIDE = "wide"
|
| 102 |
-
TALL_PORTRAIT = "tall_portrait"
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
@dataclass(frozen=True)
|
| 106 |
-
class _SizePresetValues:
|
| 107 |
-
"""Concrete image controls selected by one size preset."""
|
| 108 |
-
|
| 109 |
-
width: int
|
| 110 |
-
height: int
|
| 111 |
-
self_attention_gain: float
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
_SIZE_PRESETS = {value.value: value for value in SizePreset}
|
| 115 |
-
_SIZE_PRESET_CHOICES = (
|
| 116 |
-
("832 × 1216 · gain −0.03", SizePreset.DEFAULT_PORTRAIT.value),
|
| 117 |
-
("1536 × 832 · gain −0.02", SizePreset.WIDE.value),
|
| 118 |
-
("1024 × 1536 · gain −0.02", SizePreset.TALL_PORTRAIT.value),
|
| 119 |
-
)
|
| 120 |
-
_SIZE_PRESET_VALUES = {
|
| 121 |
-
SizePreset.DEFAULT_PORTRAIT: _SizePresetValues(832, 1216, -0.03),
|
| 122 |
-
SizePreset.WIDE: _SizePresetValues(1536, 832, -0.02),
|
| 123 |
-
SizePreset.TALL_PORTRAIT: _SizePresetValues(1024, 1536, -0.02),
|
| 124 |
-
}
|
| 125 |
-
|
| 126 |
-
_DARK_HEAD = """
|
| 127 |
-
<script>
|
| 128 |
-
localStorage.setItem("theme", "dark");
|
| 129 |
-
document.documentElement.classList.add("dark");
|
| 130 |
-
document.documentElement.style.colorScheme = "dark";
|
| 131 |
-
</script>
|
| 132 |
-
"""
|
| 133 |
-
|
| 134 |
-
_CSS = """
|
| 135 |
-
:root, body {
|
| 136 |
-
background: #09090b;
|
| 137 |
-
}
|
| 138 |
-
.gradio-container {
|
| 139 |
-
max-width: none !important;
|
| 140 |
-
width: 100% !important;
|
| 141 |
-
padding: 1rem !important;
|
| 142 |
-
}
|
| 143 |
-
#canter-title h1 {
|
| 144 |
-
font-size: 2.1rem;
|
| 145 |
-
letter-spacing: -0.04em;
|
| 146 |
-
margin-bottom: 0.15rem;
|
| 147 |
-
}
|
| 148 |
-
#canter-workspace {
|
| 149 |
-
align-items: flex-start !important;
|
| 150 |
-
}
|
| 151 |
-
#canter-controls {
|
| 152 |
-
max-width: 440px;
|
| 153 |
-
min-width: 360px;
|
| 154 |
-
}
|
| 155 |
-
#canter-results {
|
| 156 |
-
min-width: 0;
|
| 157 |
-
}
|
| 158 |
-
#canter-image-grid {
|
| 159 |
-
display: grid !important;
|
| 160 |
-
grid-template-columns: repeat(auto-fit, minmax(min(100%, 832px), 1fr));
|
| 161 |
-
align-items: start !important;
|
| 162 |
-
gap: 1rem !important;
|
| 163 |
-
overflow: visible !important;
|
| 164 |
-
}
|
| 165 |
-
#canter-image-grid > div {
|
| 166 |
-
min-width: 0 !important;
|
| 167 |
-
}
|
| 168 |
-
.canter-image-cell {
|
| 169 |
-
position: relative;
|
| 170 |
-
}
|
| 171 |
-
.canter-total-progress {
|
| 172 |
-
margin-bottom: 0.75rem;
|
| 173 |
-
}
|
| 174 |
-
.canter-total-progress:not(:has(.canter-progress-panel)) {
|
| 175 |
-
display: none !important;
|
| 176 |
-
}
|
| 177 |
-
.canter-image-progress {
|
| 178 |
-
position: absolute !important;
|
| 179 |
-
top: 0.75rem;
|
| 180 |
-
left: 0.75rem;
|
| 181 |
-
right: 0.75rem;
|
| 182 |
-
z-index: 10;
|
| 183 |
-
pointer-events: none;
|
| 184 |
-
}
|
| 185 |
-
.canter-image-progress:not(:has(.canter-progress-panel)) {
|
| 186 |
-
display: none !important;
|
| 187 |
-
}
|
| 188 |
-
.canter-progress-panel {
|
| 189 |
-
padding: 0.65rem 0.75rem;
|
| 190 |
-
color: #f4f4f5;
|
| 191 |
-
background: rgb(9 9 11 / 88%);
|
| 192 |
-
border: 1px solid #3f3f46;
|
| 193 |
-
border-radius: 0.35rem;
|
| 194 |
-
box-shadow: 0 0.25rem 1rem rgb(0 0 0 / 35%);
|
| 195 |
-
backdrop-filter: blur(0.35rem);
|
| 196 |
-
}
|
| 197 |
-
.canter-progress-label {
|
| 198 |
-
margin-bottom: 0.4rem;
|
| 199 |
-
font-size: 0.85rem;
|
| 200 |
-
font-weight: 600;
|
| 201 |
-
}
|
| 202 |
-
.canter-progress-row {
|
| 203 |
-
display: grid;
|
| 204 |
-
grid-template-columns: 2.5rem minmax(0, 1fr) 2.5rem;
|
| 205 |
-
align-items: center;
|
| 206 |
-
gap: 0.45rem;
|
| 207 |
-
font-size: 0.72rem;
|
| 208 |
-
color: #d4d4d8;
|
| 209 |
-
}
|
| 210 |
-
.canter-progress-track {
|
| 211 |
-
height: 0.35rem;
|
| 212 |
-
overflow: hidden;
|
| 213 |
-
background: #27272a;
|
| 214 |
-
border-radius: 999px;
|
| 215 |
-
}
|
| 216 |
-
.canter-progress-track > div {
|
| 217 |
-
height: 100%;
|
| 218 |
-
background: var(--primary-500);
|
| 219 |
-
border-radius: inherit;
|
| 220 |
-
transition: width 120ms linear;
|
| 221 |
-
}
|
| 222 |
-
.canter-output-image,
|
| 223 |
-
.canter-output-image > div {
|
| 224 |
-
overflow: visible !important;
|
| 225 |
-
}
|
| 226 |
-
.canter-output-image img {
|
| 227 |
-
display: block !important;
|
| 228 |
-
width: auto !important;
|
| 229 |
-
max-width: 100% !important;
|
| 230 |
-
height: auto !important;
|
| 231 |
-
max-height: none !important;
|
| 232 |
-
object-fit: contain !important;
|
| 233 |
-
margin: 0 auto !important;
|
| 234 |
-
}
|
| 235 |
-
#canter-status {
|
| 236 |
-
color: #a1a1aa;
|
| 237 |
-
}
|
| 238 |
-
@media (max-width: 900px) {
|
| 239 |
-
#canter-controls {
|
| 240 |
-
max-width: none;
|
| 241 |
-
min-width: 0;
|
| 242 |
-
}
|
| 243 |
-
}
|
| 244 |
-
"""
|
| 245 |
-
|
| 246 |
-
_Choice = TypeVar("_Choice")
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
class _ImagePipeline(Protocol):
|
| 250 |
-
"""Typed Canter surface used by the web controller and its tests."""
|
| 251 |
-
|
| 252 |
-
metadata: CanterPipelineMetadata
|
| 253 |
-
|
| 254 |
-
def __call__(
|
| 255 |
-
self,
|
| 256 |
-
prompts: str | Sequence[str],
|
| 257 |
-
*,
|
| 258 |
-
config: CanterPipelineConfig,
|
| 259 |
-
initial_noise: None,
|
| 260 |
-
progress: SolverProgress | None,
|
| 261 |
-
) -> CanterPipelineOutput:
|
| 262 |
-
"""Generate decoded images for one web request."""
|
| 263 |
-
|
| 264 |
-
...
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
@dataclass(frozen=True)
|
| 268 |
-
class CanterWebRequest:
|
| 269 |
-
"""Validated sequential image request and frozen base configuration."""
|
| 270 |
-
|
| 271 |
-
prompt: str
|
| 272 |
-
image_count: int
|
| 273 |
-
config: CanterPipelineConfig
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
@dataclass(frozen=True)
|
| 277 |
-
class CanterWebLaunchConfig:
|
| 278 |
-
"""Process-level model and server configuration for the bundled application."""
|
| 279 |
-
|
| 280 |
-
model: str
|
| 281 |
-
revision: str | None
|
| 282 |
-
dtype: WeightDType
|
| 283 |
-
text_backend: TextAttentionBackend
|
| 284 |
-
device: str
|
| 285 |
-
cache_dir: Path | None
|
| 286 |
-
server_name: str
|
| 287 |
-
server_port: int
|
| 288 |
-
share: bool
|
| 289 |
-
in_browser: bool
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
@dataclass(frozen=True)
|
| 293 |
-
class _BasicInputs:
|
| 294 |
-
"""Prompt and basic generation controls rendered above advanced settings."""
|
| 295 |
-
|
| 296 |
-
prompt: gr.Textbox
|
| 297 |
-
image_count: gr.Slider
|
| 298 |
-
size_preset: gr.Dropdown
|
| 299 |
-
width: gr.Number
|
| 300 |
-
height: gr.Number
|
| 301 |
-
seed: gr.Number
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
@dataclass(frozen=True)
|
| 305 |
-
class _WebInputs:
|
| 306 |
-
"""Gradio components in the exact callback argument order."""
|
| 307 |
-
|
| 308 |
-
prompt: gr.Textbox
|
| 309 |
-
image_count: gr.Slider
|
| 310 |
-
width: gr.Number
|
| 311 |
-
height: gr.Number
|
| 312 |
-
seed: gr.Number
|
| 313 |
-
steps: gr.Slider
|
| 314 |
-
solver: gr.Dropdown
|
| 315 |
-
schedule: gr.Dropdown
|
| 316 |
-
log_snr_shift: gr.Slider
|
| 317 |
-
self_attention_gain: gr.Slider
|
| 318 |
-
euler_maruyama_multiplier: gr.Slider
|
| 319 |
-
cfg_enabled: gr.Checkbox
|
| 320 |
-
cfg_scale: gr.Slider
|
| 321 |
-
cfg_start_step: gr.Number
|
| 322 |
-
cfg_stop_step: gr.Textbox
|
| 323 |
-
pdg_enabled: gr.Checkbox
|
| 324 |
-
pdg_mode: gr.Dropdown
|
| 325 |
-
pdg_curve: gr.Dropdown
|
| 326 |
-
pdg_noisy_scale: gr.Slider
|
| 327 |
-
pdg_clean_scale: gr.Slider
|
| 328 |
-
pdg_power: gr.Slider
|
| 329 |
-
pdg_start_step: gr.Number
|
| 330 |
-
pdg_stop_step: gr.Textbox
|
| 331 |
-
|
| 332 |
-
def ordered(self) -> tuple[gr.Component, ...]:
|
| 333 |
-
"""Return components in the order accepted by ``generate``."""
|
| 334 |
-
|
| 335 |
-
return (
|
| 336 |
-
self.prompt,
|
| 337 |
-
self.image_count,
|
| 338 |
-
self.width,
|
| 339 |
-
self.height,
|
| 340 |
-
self.seed,
|
| 341 |
-
self.steps,
|
| 342 |
-
self.solver,
|
| 343 |
-
self.schedule,
|
| 344 |
-
self.log_snr_shift,
|
| 345 |
-
self.self_attention_gain,
|
| 346 |
-
self.euler_maruyama_multiplier,
|
| 347 |
-
self.cfg_enabled,
|
| 348 |
-
self.cfg_scale,
|
| 349 |
-
self.cfg_start_step,
|
| 350 |
-
self.cfg_stop_step,
|
| 351 |
-
self.pdg_enabled,
|
| 352 |
-
self.pdg_mode,
|
| 353 |
-
self.pdg_curve,
|
| 354 |
-
self.pdg_noisy_scale,
|
| 355 |
-
self.pdg_clean_scale,
|
| 356 |
-
self.pdg_power,
|
| 357 |
-
self.pdg_start_step,
|
| 358 |
-
self.pdg_stop_step,
|
| 359 |
-
)
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
@dataclass(frozen=True)
|
| 363 |
-
class _WebOutputs:
|
| 364 |
-
"""Image slots and status component in callback output order."""
|
| 365 |
-
|
| 366 |
-
images: tuple[gr.Image, gr.Image, gr.Image, gr.Image]
|
| 367 |
-
status: gr.Markdown
|
| 368 |
-
progress_timer: gr.Timer
|
| 369 |
-
|
| 370 |
-
def ordered(self) -> tuple[gr.Component, ...]:
|
| 371 |
-
"""Return image, status, and progress-lifecycle callback outputs."""
|
| 372 |
-
|
| 373 |
-
return (*self.images, self.status, self.progress_timer)
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
_ImageSlotUpdate: TypeAlias = Image.Image | dict[str, str] | None
|
| 377 |
-
_GenerationUpdate: TypeAlias = tuple[
|
| 378 |
-
_ImageSlotUpdate,
|
| 379 |
-
_ImageSlotUpdate,
|
| 380 |
-
_ImageSlotUpdate,
|
| 381 |
-
_ImageSlotUpdate,
|
| 382 |
-
str,
|
| 383 |
-
dict[str, object],
|
| 384 |
-
]
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
class _GenerationStoppedError(RuntimeError):
|
| 388 |
-
"""Cooperative-cancellation signal for one web generation."""
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
@dataclass(frozen=True)
|
| 392 |
-
class _WebProgressState:
|
| 393 |
-
"""Current solver position for one browser generation request."""
|
| 394 |
-
|
| 395 |
-
image_index: int
|
| 396 |
-
image_count: int
|
| 397 |
-
completed_updates: int
|
| 398 |
-
total_updates: int
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
@dataclass(frozen=True)
|
| 402 |
-
class _WebGenerationState:
|
| 403 |
-
"""Cancellation and progress state owned by one browser generation."""
|
| 404 |
-
|
| 405 |
-
stop_requested: threading.Event
|
| 406 |
-
progress: _WebProgressState
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
class CanterWebController:
|
| 410 |
-
"""Translate Gradio values into one deterministic pipeline invocation."""
|
| 411 |
-
|
| 412 |
-
def __init__(
|
| 413 |
-
self,
|
| 414 |
-
pipeline: _ImagePipeline | None,
|
| 415 |
-
*,
|
| 416 |
-
load_config: CanterWebLaunchConfig | None = None,
|
| 417 |
-
) -> None:
|
| 418 |
-
"""Retain a pipeline or the exact configuration needed to load it."""
|
| 419 |
-
|
| 420 |
-
if pipeline is None and load_config is None:
|
| 421 |
-
raise ValueError("A lazy Canter web controller requires load_config.")
|
| 422 |
-
if pipeline is not None and load_config is not None:
|
| 423 |
-
raise ValueError("Provide either pipeline or load_config, not both.")
|
| 424 |
-
|
| 425 |
-
self.pipeline = pipeline
|
| 426 |
-
self._load_config = load_config
|
| 427 |
-
self._load_lock = threading.Lock()
|
| 428 |
-
self._session_lock = threading.Lock()
|
| 429 |
-
self._generation_by_session: dict[str, _WebGenerationState] = {}
|
| 430 |
-
|
| 431 |
-
def load(self) -> tuple[str, str, dict[str, object], dict[str, object]]:
|
| 432 |
-
"""Load once, then return ready-state UI updates for every browser session."""
|
| 433 |
-
|
| 434 |
-
pipeline = self._ensure_loaded()
|
| 435 |
-
return (
|
| 436 |
-
_model_summary(pipeline.metadata),
|
| 437 |
-
"Ready. The first request includes compiled graph warm-up.",
|
| 438 |
-
gr.update(interactive=True),
|
| 439 |
-
gr.update(interactive=True),
|
| 440 |
-
)
|
| 441 |
-
|
| 442 |
-
def request_stop(self, request: gr.Request | None = None) -> str:
|
| 443 |
-
"""Cancel only the active generation owned by the requesting browser."""
|
| 444 |
-
|
| 445 |
-
session_hash = _session_hash(request)
|
| 446 |
-
with self._session_lock:
|
| 447 |
-
state = self._generation_by_session.get(session_hash)
|
| 448 |
-
if state is None:
|
| 449 |
-
return "No generation is currently active in this browser session."
|
| 450 |
-
state.stop_requested.set()
|
| 451 |
-
return "Stopping after the current solver step…"
|
| 452 |
-
|
| 453 |
-
def generate( # noqa: PLR0917 - Gradio supplies the explicit form fields.
|
| 454 |
-
self,
|
| 455 |
-
prompt: str,
|
| 456 |
-
image_count: int | float,
|
| 457 |
-
width: int | float,
|
| 458 |
-
height: int | float,
|
| 459 |
-
seed: int | float,
|
| 460 |
-
steps: int | float,
|
| 461 |
-
solver: str,
|
| 462 |
-
schedule: str,
|
| 463 |
-
log_snr_shift: int | float,
|
| 464 |
-
self_attention_gain: int | float,
|
| 465 |
-
euler_maruyama_multiplier: int | float,
|
| 466 |
-
cfg_enabled: bool,
|
| 467 |
-
cfg_scale: int | float,
|
| 468 |
-
cfg_start_step: int | float,
|
| 469 |
-
cfg_stop_step: int | float | str | None,
|
| 470 |
-
pdg_enabled: bool,
|
| 471 |
-
pdg_mode: str,
|
| 472 |
-
pdg_curve: str,
|
| 473 |
-
pdg_noisy_scale: int | float,
|
| 474 |
-
pdg_clean_scale: int | float,
|
| 475 |
-
pdg_power: int | float,
|
| 476 |
-
pdg_start_step: int | float,
|
| 477 |
-
pdg_stop_step: int | float | str | None,
|
| 478 |
-
browser_request: gr.Request | None = None,
|
| 479 |
-
) -> Generator[_GenerationUpdate, None, None]:
|
| 480 |
-
"""Yield each batch-one image as soon as its decoding completes."""
|
| 481 |
-
|
| 482 |
-
session_hash = _session_hash(browser_request)
|
| 483 |
-
pipeline = self._ensure_loaded()
|
| 484 |
-
request = build_web_request(
|
| 485 |
-
prompt=prompt,
|
| 486 |
-
image_count=image_count,
|
| 487 |
-
width=width,
|
| 488 |
-
height=height,
|
| 489 |
-
seed=seed,
|
| 490 |
-
steps=steps,
|
| 491 |
-
solver=solver,
|
| 492 |
-
schedule=schedule,
|
| 493 |
-
log_snr_shift=log_snr_shift,
|
| 494 |
-
self_attention_gain=self_attention_gain,
|
| 495 |
-
euler_maruyama_multiplier=euler_maruyama_multiplier,
|
| 496 |
-
cfg_enabled=cfg_enabled,
|
| 497 |
-
cfg_scale=cfg_scale,
|
| 498 |
-
cfg_start_step=cfg_start_step,
|
| 499 |
-
cfg_stop_step=cfg_stop_step,
|
| 500 |
-
pdg_enabled=pdg_enabled,
|
| 501 |
-
pdg_mode=pdg_mode,
|
| 502 |
-
pdg_curve=pdg_curve,
|
| 503 |
-
pdg_noisy_scale=pdg_noisy_scale,
|
| 504 |
-
pdg_clean_scale=pdg_clean_scale,
|
| 505 |
-
pdg_power=pdg_power,
|
| 506 |
-
pdg_start_step=pdg_start_step,
|
| 507 |
-
pdg_stop_step=pdg_stop_step,
|
| 508 |
-
)
|
| 509 |
-
_log_generation_request(request)
|
| 510 |
-
started = time.perf_counter()
|
| 511 |
-
guidance = _pdg_status(request.config.inference)
|
| 512 |
-
stop_requested = self._begin_generation(
|
| 513 |
-
session_hash,
|
| 514 |
-
image_index=0,
|
| 515 |
-
image_count=request.image_count,
|
| 516 |
-
completed_updates=0,
|
| 517 |
-
total_updates=request.config.inference.steps,
|
| 518 |
-
)
|
| 519 |
-
try:
|
| 520 |
-
yield _generation_update(
|
| 521 |
-
_cleared_image_slots(),
|
| 522 |
-
f"Generating **{request.image_count} images** with batch size `1` · "
|
| 523 |
-
f"{guidance}…",
|
| 524 |
-
progress_active=True,
|
| 525 |
-
)
|
| 526 |
-
for image_index in range(request.image_count):
|
| 527 |
-
self._raise_if_stopped(stop_requested)
|
| 528 |
-
image_number = image_index + 1
|
| 529 |
-
self._set_progress(
|
| 530 |
-
session_hash,
|
| 531 |
-
image_index=image_index,
|
| 532 |
-
image_count=request.image_count,
|
| 533 |
-
completed_updates=0,
|
| 534 |
-
total_updates=request.config.inference.steps,
|
| 535 |
-
)
|
| 536 |
-
image_config = _image_config(request, image_index)
|
| 537 |
-
output = pipeline(
|
| 538 |
-
request.prompt,
|
| 539 |
-
config=image_config,
|
| 540 |
-
initial_noise=None,
|
| 541 |
-
progress=_step_progress(
|
| 542 |
-
stopped=stop_requested.is_set,
|
| 543 |
-
update_web=lambda completed, total, index=image_index: (
|
| 544 |
-
self._set_progress(
|
| 545 |
-
session_hash,
|
| 546 |
-
image_index=index,
|
| 547 |
-
image_count=request.image_count,
|
| 548 |
-
completed_updates=completed,
|
| 549 |
-
total_updates=total,
|
| 550 |
-
)
|
| 551 |
-
),
|
| 552 |
-
),
|
| 553 |
-
)
|
| 554 |
-
self._raise_if_stopped(stop_requested)
|
| 555 |
-
if output.images is None or len(output.images) != 1:
|
| 556 |
-
raise RuntimeError(
|
| 557 |
-
"Each batch-one Canter web invocation must return one PIL image."
|
| 558 |
-
)
|
| 559 |
-
image = _attach_png_metadata(
|
| 560 |
-
output.images[0],
|
| 561 |
-
prompt=request.prompt,
|
| 562 |
-
config=image_config,
|
| 563 |
-
metadata=pipeline.metadata,
|
| 564 |
-
)
|
| 565 |
-
elapsed = time.perf_counter() - started
|
| 566 |
-
status = _generation_status(
|
| 567 |
-
pipeline.metadata,
|
| 568 |
-
request=request,
|
| 569 |
-
elapsed=elapsed,
|
| 570 |
-
completed=image_number,
|
| 571 |
-
)
|
| 572 |
-
yield _generation_update(
|
| 573 |
-
_completed_image_slots(
|
| 574 |
-
image=image,
|
| 575 |
-
image_index=image_index,
|
| 576 |
-
image_count=request.image_count,
|
| 577 |
-
),
|
| 578 |
-
status,
|
| 579 |
-
progress_active=None,
|
| 580 |
-
)
|
| 581 |
-
except _GenerationStoppedError:
|
| 582 |
-
yield _generation_update(
|
| 583 |
-
_preserved_image_slots(),
|
| 584 |
-
"Stopped. Ready for another request.",
|
| 585 |
-
progress_active=None,
|
| 586 |
-
)
|
| 587 |
-
finally:
|
| 588 |
-
self._finish_generation(session_hash, stop_requested)
|
| 589 |
-
|
| 590 |
-
def poll_progress(
|
| 591 |
-
self,
|
| 592 |
-
request: gr.Request | None = None,
|
| 593 |
-
) -> tuple[str, str, str, str, str, dict[str, object]]:
|
| 594 |
-
"""Return progress HTML and whether this session still needs polling."""
|
| 595 |
-
|
| 596 |
-
session_hash = _session_hash(request)
|
| 597 |
-
with self._session_lock:
|
| 598 |
-
generation = self._generation_by_session.get(session_hash)
|
| 599 |
-
state = None if generation is None else generation.progress
|
| 600 |
-
return (*_progress_html(state), gr.update(active=generation is not None))
|
| 601 |
-
|
| 602 |
-
def _raise_if_stopped(self, stop_requested: threading.Event) -> None:
|
| 603 |
-
"""Abort at the next safe boundary after a web stop request."""
|
| 604 |
-
|
| 605 |
-
if stop_requested.is_set():
|
| 606 |
-
raise _GenerationStoppedError
|
| 607 |
-
|
| 608 |
-
def _begin_generation(
|
| 609 |
-
self,
|
| 610 |
-
session_hash: str,
|
| 611 |
-
*,
|
| 612 |
-
image_index: int,
|
| 613 |
-
image_count: int,
|
| 614 |
-
completed_updates: int,
|
| 615 |
-
total_updates: int,
|
| 616 |
-
) -> threading.Event:
|
| 617 |
-
"""Register and return cancellation state for a new browser generation."""
|
| 618 |
-
|
| 619 |
-
progress = _WebProgressState(
|
| 620 |
-
image_index=image_index,
|
| 621 |
-
image_count=image_count,
|
| 622 |
-
completed_updates=completed_updates,
|
| 623 |
-
total_updates=total_updates,
|
| 624 |
-
)
|
| 625 |
-
stop_requested = threading.Event()
|
| 626 |
-
state = _WebGenerationState(
|
| 627 |
-
stop_requested=stop_requested,
|
| 628 |
-
progress=progress,
|
| 629 |
-
)
|
| 630 |
-
with self._session_lock:
|
| 631 |
-
if session_hash in self._generation_by_session:
|
| 632 |
-
raise RuntimeError(
|
| 633 |
-
"A browser session cannot run multiple Canter generations."
|
| 634 |
-
)
|
| 635 |
-
self._generation_by_session[session_hash] = state
|
| 636 |
-
return stop_requested
|
| 637 |
-
|
| 638 |
-
def _set_progress(
|
| 639 |
-
self,
|
| 640 |
-
session_hash: str,
|
| 641 |
-
*,
|
| 642 |
-
image_index: int,
|
| 643 |
-
image_count: int,
|
| 644 |
-
completed_updates: int,
|
| 645 |
-
total_updates: int,
|
| 646 |
-
) -> None:
|
| 647 |
-
"""Publish one solver position for the requesting browser session."""
|
| 648 |
-
|
| 649 |
-
progress = _WebProgressState(
|
| 650 |
-
image_index=image_index,
|
| 651 |
-
image_count=image_count,
|
| 652 |
-
completed_updates=completed_updates,
|
| 653 |
-
total_updates=total_updates,
|
| 654 |
-
)
|
| 655 |
-
with self._session_lock:
|
| 656 |
-
state = self._generation_by_session.get(session_hash)
|
| 657 |
-
if state is None:
|
| 658 |
-
raise RuntimeError(
|
| 659 |
-
"Cannot update progress without an active generation."
|
| 660 |
-
)
|
| 661 |
-
self._generation_by_session[session_hash] = replace(
|
| 662 |
-
state,
|
| 663 |
-
progress=progress,
|
| 664 |
-
)
|
| 665 |
-
|
| 666 |
-
def _finish_generation(
|
| 667 |
-
self,
|
| 668 |
-
session_hash: str,
|
| 669 |
-
stop_requested: threading.Event,
|
| 670 |
-
) -> None:
|
| 671 |
-
"""Remove exactly the browser generation that has finished."""
|
| 672 |
-
|
| 673 |
-
with self._session_lock:
|
| 674 |
-
state = self._generation_by_session.get(session_hash)
|
| 675 |
-
if state is None or state.stop_requested is not stop_requested:
|
| 676 |
-
raise RuntimeError(
|
| 677 |
-
"Canter browser generation state became inconsistent."
|
| 678 |
-
)
|
| 679 |
-
del self._generation_by_session[session_hash]
|
| 680 |
-
|
| 681 |
-
def _ensure_loaded(self) -> _ImagePipeline:
|
| 682 |
-
"""Return the process pipeline, loading it exactly once when necessary."""
|
| 683 |
-
|
| 684 |
-
with self._load_lock:
|
| 685 |
-
if self.pipeline is None:
|
| 686 |
-
config = self._load_config
|
| 687 |
-
if config is None:
|
| 688 |
-
raise RuntimeError(
|
| 689 |
-
"Lazy Canter loading is missing its launch configuration."
|
| 690 |
-
)
|
| 691 |
-
self.pipeline = load_web_pipeline(config)
|
| 692 |
-
return self.pipeline
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
def _preserved_image_slots() -> tuple[
|
| 696 |
-
dict[str, str],
|
| 697 |
-
dict[str, str],
|
| 698 |
-
dict[str, str],
|
| 699 |
-
dict[str, str],
|
| 700 |
-
]:
|
| 701 |
-
"""Return four no-op updates that keep displayed images visible."""
|
| 702 |
-
|
| 703 |
-
return (gr.skip(), gr.skip(), gr.skip(), gr.skip())
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
def _cleared_image_slots() -> tuple[None, None, None, None]:
|
| 707 |
-
"""Clear all prior results before starting a new generation."""
|
| 708 |
-
|
| 709 |
-
return (None, None, None, None)
|
| 710 |
-
|
| 711 |
-
|
| 712 |
-
def _completed_image_slots(
|
| 713 |
-
*,
|
| 714 |
-
image: Image.Image,
|
| 715 |
-
image_index: int,
|
| 716 |
-
image_count: int,
|
| 717 |
-
) -> tuple[_ImageSlotUpdate, _ImageSlotUpdate, _ImageSlotUpdate, _ImageSlotUpdate]:
|
| 718 |
-
"""Update only the new image and clear unused slots after the final image."""
|
| 719 |
-
|
| 720 |
-
updates: list[_ImageSlotUpdate] = list(_preserved_image_slots())
|
| 721 |
-
updates[image_index] = image
|
| 722 |
-
if image_index + 1 == image_count:
|
| 723 |
-
for unused_index in range(image_count, _MAX_IMAGE_COUNT):
|
| 724 |
-
updates[unused_index] = None
|
| 725 |
-
return (updates[0], updates[1], updates[2], updates[3])
|
| 726 |
-
|
| 727 |
-
|
| 728 |
-
def _generation_update(
|
| 729 |
-
image_slots: Sequence[_ImageSlotUpdate],
|
| 730 |
-
status: str,
|
| 731 |
-
*,
|
| 732 |
-
progress_active: bool | None,
|
| 733 |
-
) -> _GenerationUpdate:
|
| 734 |
-
"""Return image, status, and timer updates expected by Gradio."""
|
| 735 |
-
|
| 736 |
-
if len(image_slots) != _MAX_IMAGE_COUNT:
|
| 737 |
-
raise ValueError(
|
| 738 |
-
f"Expected {_MAX_IMAGE_COUNT} web image slots, got {len(image_slots)}."
|
| 739 |
-
)
|
| 740 |
-
return (
|
| 741 |
-
image_slots[0],
|
| 742 |
-
image_slots[1],
|
| 743 |
-
image_slots[2],
|
| 744 |
-
image_slots[3],
|
| 745 |
-
status,
|
| 746 |
-
(gr.skip() if progress_active is None else gr.update(active=progress_active)),
|
| 747 |
-
)
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
def _step_progress(
|
| 751 |
-
*,
|
| 752 |
-
stopped: Callable[[], bool],
|
| 753 |
-
update_web: Callable[[int, int], None],
|
| 754 |
-
) -> SolverProgress:
|
| 755 |
-
"""Create an exact solver-update reporter for one sequential image."""
|
| 756 |
-
|
| 757 |
-
def report(completed: int, total: int) -> None:
|
| 758 |
-
"""Update the current image bar while retaining the total image bar."""
|
| 759 |
-
|
| 760 |
-
if stopped():
|
| 761 |
-
raise _GenerationStoppedError
|
| 762 |
-
update_web(completed, total)
|
| 763 |
-
|
| 764 |
-
return report
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
def _session_hash(request: gr.Request | None) -> str:
|
| 768 |
-
"""Return the required Gradio browser-session identifier."""
|
| 769 |
-
|
| 770 |
-
if request is None or request.session_hash is None or not request.session_hash:
|
| 771 |
-
raise ValueError("Web generation requires a Gradio browser session hash.")
|
| 772 |
-
return request.session_hash
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
def _progress_html(
|
| 776 |
-
state: _WebProgressState | None,
|
| 777 |
-
) -> tuple[str, str, str, str, str]:
|
| 778 |
-
"""Render the total bar and the active image's solver overlay."""
|
| 779 |
-
|
| 780 |
-
slots = ["", "", "", ""]
|
| 781 |
-
if state is None:
|
| 782 |
-
return ("", slots[0], slots[1], slots[2], slots[3])
|
| 783 |
-
image_fraction = state.completed_updates / state.total_updates
|
| 784 |
-
total_fraction = (state.image_index + image_fraction) / state.image_count
|
| 785 |
-
image_percent = 100.0 * image_fraction
|
| 786 |
-
total_percent = 100.0 * total_fraction
|
| 787 |
-
total_html = f"""
|
| 788 |
-
<div class="canter-progress-panel">
|
| 789 |
-
<div class="canter-progress-label">
|
| 790 |
-
Total progress · Image {state.image_index + 1}/{state.image_count}
|
| 791 |
-
</div>
|
| 792 |
-
<div class="canter-progress-row">
|
| 793 |
-
<span>Total</span>
|
| 794 |
-
<div class="canter-progress-track">
|
| 795 |
-
<div style="width: {total_percent:.2f}%"></div>
|
| 796 |
-
</div>
|
| 797 |
-
<span>{total_percent:.0f}%</span>
|
| 798 |
-
</div>
|
| 799 |
-
</div>
|
| 800 |
-
"""
|
| 801 |
-
slots[state.image_index] = f"""
|
| 802 |
-
<div class="canter-progress-panel">
|
| 803 |
-
<div class="canter-progress-label">
|
| 804 |
-
Image {state.image_index + 1}/{state.image_count}
|
| 805 |
-
· {state.completed_updates}/{state.total_updates} updates
|
| 806 |
-
</div>
|
| 807 |
-
<div class="canter-progress-row">
|
| 808 |
-
<span>Image</span>
|
| 809 |
-
<div class="canter-progress-track">
|
| 810 |
-
<div style="width: {image_percent:.2f}%"></div>
|
| 811 |
-
</div>
|
| 812 |
-
<span>{image_percent:.0f}%</span>
|
| 813 |
-
</div>
|
| 814 |
-
</div>
|
| 815 |
-
"""
|
| 816 |
-
return (total_html, slots[0], slots[1], slots[2], slots[3])
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
def _choice(
|
| 820 |
-
value: str | None,
|
| 821 |
-
choices: Mapping[str, _Choice],
|
| 822 |
-
name: str,
|
| 823 |
-
) -> _Choice:
|
| 824 |
-
"""Resolve one exact enum-backed form choice or fail with its field name."""
|
| 825 |
-
|
| 826 |
-
if value is None:
|
| 827 |
-
raise ValueError(f"{name} is required.")
|
| 828 |
-
selected = choices.get(value)
|
| 829 |
-
if selected is None:
|
| 830 |
-
raise ValueError(f"Unsupported {name}: {value!r}.")
|
| 831 |
-
return selected
|
| 832 |
-
|
| 833 |
-
|
| 834 |
-
def _integer(
|
| 835 |
-
value: int | float | None,
|
| 836 |
-
name: str,
|
| 837 |
-
*,
|
| 838 |
-
minimum: int,
|
| 839 |
-
maximum: int | None,
|
| 840 |
-
) -> int:
|
| 841 |
-
"""Convert one integral form number and enforce its explicit bounds."""
|
| 842 |
-
|
| 843 |
-
if value is None or isinstance(value, bool):
|
| 844 |
-
raise TypeError(f"{name} must be an integer.")
|
| 845 |
-
number = float(value)
|
| 846 |
-
if not math.isfinite(number) or not number.is_integer():
|
| 847 |
-
raise ValueError(f"{name} must be a finite integer.")
|
| 848 |
-
result = int(number)
|
| 849 |
-
if result < minimum:
|
| 850 |
-
raise ValueError(f"{name} must be at least {minimum}.")
|
| 851 |
-
if maximum is not None and result > maximum:
|
| 852 |
-
raise ValueError(f"{name} must not exceed {maximum}.")
|
| 853 |
-
return result
|
| 854 |
-
|
| 855 |
-
|
| 856 |
-
def _optional_integer(
|
| 857 |
-
value: int | float | str | None,
|
| 858 |
-
name: str,
|
| 859 |
-
) -> int | None:
|
| 860 |
-
"""Convert a blank solver stop field to ``None`` or validate an index."""
|
| 861 |
-
|
| 862 |
-
if value is None:
|
| 863 |
-
return None
|
| 864 |
-
if isinstance(value, str):
|
| 865 |
-
stripped = value.strip()
|
| 866 |
-
if not stripped:
|
| 867 |
-
return None
|
| 868 |
-
try:
|
| 869 |
-
numeric = float(stripped)
|
| 870 |
-
except ValueError as exc:
|
| 871 |
-
raise ValueError(f"{name} must be blank or a finite integer.") from exc
|
| 872 |
-
return _integer(numeric, name, minimum=0, maximum=None)
|
| 873 |
-
return _integer(value, name, minimum=0, maximum=None)
|
| 874 |
-
|
| 875 |
-
|
| 876 |
-
def _number(value: int | float, name: str) -> float:
|
| 877 |
-
"""Require one finite floating-point form value."""
|
| 878 |
-
|
| 879 |
-
if isinstance(value, bool):
|
| 880 |
-
raise TypeError(f"{name} must be a number.")
|
| 881 |
-
result = float(value)
|
| 882 |
-
if not math.isfinite(result):
|
| 883 |
-
raise ValueError(f"{name} must be finite.")
|
| 884 |
-
return result
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
def _image_dimension(value: int | float | None, name: str) -> int:
|
| 888 |
-
"""Snap one finite image dimension to the supported 32-pixel lattice."""
|
| 889 |
-
|
| 890 |
-
if value is None:
|
| 891 |
-
raise ValueError(f"{name} is required.")
|
| 892 |
-
number = _number(value, name)
|
| 893 |
-
if number < _IMAGE_SIZE_MULTIPLE:
|
| 894 |
-
raise ValueError(f"{name} must be at least {_IMAGE_SIZE_MULTIPLE}.")
|
| 895 |
-
return snap_image_dimension(number)
|
| 896 |
-
|
| 897 |
-
|
| 898 |
-
def snap_image_dimension(value: int | float | None) -> int:
|
| 899 |
-
"""Snap one browser image dimension to its nearest valid multiple."""
|
| 900 |
-
|
| 901 |
-
if value is None:
|
| 902 |
-
raise ValueError("image size is required.")
|
| 903 |
-
number = _number(value, "image size")
|
| 904 |
-
snapped = math.floor(
|
| 905 |
-
(max(number, _IMAGE_SIZE_MULTIPLE) + _IMAGE_SIZE_MULTIPLE / 2)
|
| 906 |
-
/ _IMAGE_SIZE_MULTIPLE
|
| 907 |
-
)
|
| 908 |
-
return snapped * _IMAGE_SIZE_MULTIPLE
|
| 909 |
-
|
| 910 |
-
|
| 911 |
-
def _validated_prompt(prompt: str) -> str:
|
| 912 |
-
"""Return a string prompt without changing the user's text."""
|
| 913 |
-
|
| 914 |
-
if not isinstance(prompt, str):
|
| 915 |
-
raise TypeError("prompt must be a string.")
|
| 916 |
-
return prompt
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
def _pdg_mode_uses_cfg(mode: PdgMode) -> bool:
|
| 920 |
-
"""Return whether one public PDG mode consumes the CFG scale."""
|
| 921 |
-
|
| 922 |
-
match mode:
|
| 923 |
-
case (
|
| 924 |
-
PdgMode.ALTERNATE_PDG_FIRST
|
| 925 |
-
| PdgMode.ALTERNATE_CFG_FIRST
|
| 926 |
-
| PdgMode.COMBINED_CFG_PDG
|
| 927 |
-
| PdgMode.PDG_WITH_ALTERNATING_CFG
|
| 928 |
-
| PdgMode.CFG_TO_PDG
|
| 929 |
-
):
|
| 930 |
-
return True
|
| 931 |
-
case PdgMode.FULL | PdgMode.THREE_QUARTER:
|
| 932 |
-
return False
|
| 933 |
-
case PdgMode.NONE:
|
| 934 |
-
raise ValueError("The enabled web PDG selection cannot use mode none.")
|
| 935 |
-
case _ as unreachable:
|
| 936 |
-
raise RuntimeError(f"Unsupported PDG mode: {unreachable}")
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
def update_pdg_clean_scale(
|
| 940 |
-
curve: str,
|
| 941 |
-
noisy_scale: int | float,
|
| 942 |
-
) -> dict[str, object]:
|
| 943 |
-
"""Disable and synchronize the unused clean endpoint for constant PDG."""
|
| 944 |
-
|
| 945 |
-
resolved_curve = _choice(curve, _PDG_CURVES, "PDG curve")
|
| 946 |
-
match resolved_curve:
|
| 947 |
-
case PdgCurve.CONSTANT:
|
| 948 |
-
return gr.update(
|
| 949 |
-
value=_number(noisy_scale, "PDG noisy scale"),
|
| 950 |
-
interactive=False,
|
| 951 |
-
)
|
| 952 |
-
case PdgCurve.LINEAR | PdgCurve.POWER:
|
| 953 |
-
return gr.update(interactive=True)
|
| 954 |
-
case _ as unreachable:
|
| 955 |
-
raise RuntimeError(f"Unsupported PDG curve: {unreachable}")
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
def build_web_request(
|
| 959 |
-
*,
|
| 960 |
-
prompt: str,
|
| 961 |
-
image_count: int | float,
|
| 962 |
-
width: int | float,
|
| 963 |
-
height: int | float,
|
| 964 |
-
seed: int | float,
|
| 965 |
-
steps: int | float,
|
| 966 |
-
solver: str,
|
| 967 |
-
schedule: str,
|
| 968 |
-
log_snr_shift: int | float,
|
| 969 |
-
self_attention_gain: int | float,
|
| 970 |
-
euler_maruyama_multiplier: int | float,
|
| 971 |
-
cfg_enabled: bool,
|
| 972 |
-
cfg_scale: int | float,
|
| 973 |
-
cfg_start_step: int | float,
|
| 974 |
-
cfg_stop_step: int | float | str | None,
|
| 975 |
-
pdg_enabled: bool,
|
| 976 |
-
pdg_mode: str,
|
| 977 |
-
pdg_curve: str,
|
| 978 |
-
pdg_noisy_scale: int | float,
|
| 979 |
-
pdg_clean_scale: int | float,
|
| 980 |
-
pdg_power: int | float,
|
| 981 |
-
pdg_start_step: int | float,
|
| 982 |
-
pdg_stop_step: int | float | str | None,
|
| 983 |
-
) -> CanterWebRequest:
|
| 984 |
-
"""Validate raw Gradio values and construct the frozen Canter dataclasses."""
|
| 985 |
-
|
| 986 |
-
resolved_pdg_mode = _choice(pdg_mode, _PDG_MODES, "PDG mode")
|
| 987 |
-
active_pdg_mode = resolved_pdg_mode if pdg_enabled else PdgMode.NONE
|
| 988 |
-
uses_cfg_scale = pdg_enabled and _pdg_mode_uses_cfg(resolved_pdg_mode)
|
| 989 |
-
resolved_cfg_scale = (
|
| 990 |
-
_number(cfg_scale, "CFG scale") if cfg_enabled or uses_cfg_scale else None
|
| 991 |
-
)
|
| 992 |
-
cfg = CfgGuidance(
|
| 993 |
-
enabled=cfg_enabled,
|
| 994 |
-
scale=resolved_cfg_scale,
|
| 995 |
-
start_step=_integer(
|
| 996 |
-
cfg_start_step,
|
| 997 |
-
"CFG start step",
|
| 998 |
-
minimum=0,
|
| 999 |
-
maximum=None,
|
| 1000 |
-
),
|
| 1001 |
-
stop_step=_optional_integer(cfg_stop_step, "CFG stop step"),
|
| 1002 |
-
)
|
| 1003 |
-
resolved_pdg_curve = _choice(pdg_curve, _PDG_CURVES, "PDG curve")
|
| 1004 |
-
resolved_pdg_noisy_scale = _number(pdg_noisy_scale, "PDG noisy scale")
|
| 1005 |
-
resolved_pdg_clean_scale = (
|
| 1006 |
-
resolved_pdg_noisy_scale
|
| 1007 |
-
if resolved_pdg_curve is PdgCurve.CONSTANT
|
| 1008 |
-
else _number(pdg_clean_scale, "PDG clean scale")
|
| 1009 |
-
)
|
| 1010 |
-
pdg = PdgGuidance(
|
| 1011 |
-
enabled=pdg_enabled,
|
| 1012 |
-
mode=active_pdg_mode,
|
| 1013 |
-
curve=resolved_pdg_curve,
|
| 1014 |
-
noisy_scale=resolved_pdg_noisy_scale,
|
| 1015 |
-
clean_scale=resolved_pdg_clean_scale,
|
| 1016 |
-
power=_number(pdg_power, "PDG power"),
|
| 1017 |
-
start_step=_integer(
|
| 1018 |
-
pdg_start_step,
|
| 1019 |
-
"PDG start step",
|
| 1020 |
-
minimum=0,
|
| 1021 |
-
maximum=None,
|
| 1022 |
-
),
|
| 1023 |
-
stop_step=_optional_integer(pdg_stop_step, "PDG stop step"),
|
| 1024 |
-
)
|
| 1025 |
-
resolved_image_count = _integer(
|
| 1026 |
-
image_count,
|
| 1027 |
-
"image count",
|
| 1028 |
-
minimum=1,
|
| 1029 |
-
maximum=_MAX_IMAGE_COUNT,
|
| 1030 |
-
)
|
| 1031 |
-
resolved_seed = _integer(
|
| 1032 |
-
seed,
|
| 1033 |
-
"seed",
|
| 1034 |
-
minimum=0,
|
| 1035 |
-
maximum=_MAX_SEED - resolved_image_count + 1,
|
| 1036 |
-
)
|
| 1037 |
-
inference = CanterInferenceConfig(
|
| 1038 |
-
height=_image_dimension(height, "height"),
|
| 1039 |
-
width=_image_dimension(width, "width"),
|
| 1040 |
-
steps=_integer(steps, "steps", minimum=1, maximum=None),
|
| 1041 |
-
solver=_choice(solver, _SOLVERS, "solver"),
|
| 1042 |
-
schedule=_choice(schedule, _SCHEDULES, "schedule"),
|
| 1043 |
-
log_snr_shift=_number(log_snr_shift, "log-SNR shift"),
|
| 1044 |
-
cfg=cfg,
|
| 1045 |
-
pdg=pdg,
|
| 1046 |
-
self_attention_gain=_number(
|
| 1047 |
-
self_attention_gain,
|
| 1048 |
-
"self-attention gain",
|
| 1049 |
-
),
|
| 1050 |
-
euler_maruyama_multiplier=_number(
|
| 1051 |
-
euler_maruyama_multiplier,
|
| 1052 |
-
"SDE noise multiplier",
|
| 1053 |
-
),
|
| 1054 |
-
er_sde_noise_multiplier=_number(
|
| 1055 |
-
euler_maruyama_multiplier,
|
| 1056 |
-
"SDE noise multiplier",
|
| 1057 |
-
),
|
| 1058 |
-
seed=resolved_seed,
|
| 1059 |
-
generator=None,
|
| 1060 |
-
)
|
| 1061 |
-
return CanterWebRequest(
|
| 1062 |
-
prompt=_validated_prompt(prompt),
|
| 1063 |
-
image_count=resolved_image_count,
|
| 1064 |
-
config=CanterPipelineConfig(
|
| 1065 |
-
inference=inference,
|
| 1066 |
-
output_type=CanterOutputType.PIL,
|
| 1067 |
-
),
|
| 1068 |
-
)
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
def _image_config(
|
| 1072 |
-
request: CanterWebRequest,
|
| 1073 |
-
image_index: int,
|
| 1074 |
-
) -> CanterPipelineConfig:
|
| 1075 |
-
"""Return a batch-one config using the next deterministic image seed."""
|
| 1076 |
-
|
| 1077 |
-
seed = request.config.inference.seed
|
| 1078 |
-
if seed is None:
|
| 1079 |
-
raise RuntimeError("A validated Canter web request must contain a seed.")
|
| 1080 |
-
inference = replace(
|
| 1081 |
-
request.config.inference,
|
| 1082 |
-
seed=seed + image_index,
|
| 1083 |
-
generator=None,
|
| 1084 |
-
)
|
| 1085 |
-
return replace(request.config, inference=inference)
|
| 1086 |
-
|
| 1087 |
-
|
| 1088 |
-
def _pdg_metadata(pdg: PdgGuidance) -> dict[str, object]:
|
| 1089 |
-
"""Return PDG fields in public API order."""
|
| 1090 |
-
|
| 1091 |
-
return {
|
| 1092 |
-
"enabled": pdg.enabled,
|
| 1093 |
-
"mode": pdg.mode.value,
|
| 1094 |
-
"curve": pdg.curve.value,
|
| 1095 |
-
"noisy_scale": pdg.noisy_scale,
|
| 1096 |
-
"clean_scale": pdg.clean_scale,
|
| 1097 |
-
"power": pdg.power,
|
| 1098 |
-
"start_step": pdg.start_step,
|
| 1099 |
-
"stop_step": pdg.stop_step,
|
| 1100 |
-
}
|
| 1101 |
-
|
| 1102 |
-
|
| 1103 |
-
def _cfg_metadata(cfg: CfgGuidance) -> dict[str, object]:
|
| 1104 |
-
"""Return CFG fields in public API order."""
|
| 1105 |
-
|
| 1106 |
-
return {
|
| 1107 |
-
"enabled": cfg.enabled,
|
| 1108 |
-
"scale": cfg.scale,
|
| 1109 |
-
"start_step": cfg.start_step,
|
| 1110 |
-
"stop_step": cfg.stop_step,
|
| 1111 |
-
}
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
def _png_metadata_json(
|
| 1115 |
-
*,
|
| 1116 |
-
prompt: str,
|
| 1117 |
-
config: CanterPipelineConfig,
|
| 1118 |
-
metadata: CanterPipelineMetadata,
|
| 1119 |
-
) -> str:
|
| 1120 |
-
"""Serialize reproducible UI settings with primary inputs first."""
|
| 1121 |
-
|
| 1122 |
-
inference = config.inference
|
| 1123 |
-
if inference.seed is None:
|
| 1124 |
-
raise RuntimeError("PNG metadata requires the resolved per-image seed.")
|
| 1125 |
-
payload: dict[str, object] = {
|
| 1126 |
-
"prompt": prompt,
|
| 1127 |
-
"seed": inference.seed,
|
| 1128 |
-
"width": inference.width,
|
| 1129 |
-
"height": inference.height,
|
| 1130 |
-
"steps": inference.steps,
|
| 1131 |
-
"solver": inference.solver.value,
|
| 1132 |
-
"schedule": inference.schedule.value,
|
| 1133 |
-
"pdg": _pdg_metadata(inference.pdg),
|
| 1134 |
-
"cfg": _cfg_metadata(inference.cfg),
|
| 1135 |
-
"self_attention_gain": inference.self_attention_gain,
|
| 1136 |
-
"log_snr_shift": inference.log_snr_shift,
|
| 1137 |
-
"euler_maruyama_multiplier": inference.euler_maruyama_multiplier,
|
| 1138 |
-
"er_sde_noise_multiplier": inference.er_sde_noise_multiplier,
|
| 1139 |
-
"code_version": metadata.code_version,
|
| 1140 |
-
"release": metadata.canter.release,
|
| 1141 |
-
"weight_dtype": metadata.canter.weight_dtype.value,
|
| 1142 |
-
}
|
| 1143 |
-
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
| 1144 |
-
|
| 1145 |
-
|
| 1146 |
-
def _attach_png_metadata(
|
| 1147 |
-
image: Image.Image,
|
| 1148 |
-
*,
|
| 1149 |
-
prompt: str,
|
| 1150 |
-
config: CanterPipelineConfig,
|
| 1151 |
-
metadata: CanterPipelineMetadata,
|
| 1152 |
-
) -> Image.Image:
|
| 1153 |
-
"""Attach the ordered API query for Gradio's PNG encoder."""
|
| 1154 |
-
|
| 1155 |
-
image.info[_PNG_METADATA_KEY] = _png_metadata_json(
|
| 1156 |
-
prompt=prompt,
|
| 1157 |
-
config=config,
|
| 1158 |
-
metadata=metadata,
|
| 1159 |
-
)
|
| 1160 |
-
return image
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
-
def random_seed() -> int:
|
| 1164 |
-
"""Return a non-negative seed accepted by the Canter web form."""
|
| 1165 |
-
|
| 1166 |
-
return secrets.randbelow(_MAX_SEED + 1)
|
| 1167 |
-
|
| 1168 |
-
|
| 1169 |
-
def _model_summary(metadata: CanterPipelineMetadata) -> str:
|
| 1170 |
-
"""Render immutable model and decoder provenance above the controls."""
|
| 1171 |
-
|
| 1172 |
-
canter = metadata.canter
|
| 1173 |
-
return (
|
| 1174 |
-
f"**Canter checkpoint {canter.release}** · "
|
| 1175 |
-
f"code `{metadata.code_version}` · "
|
| 1176 |
-
f"{canter.weight_dtype.value} EMA weights \n"
|
| 1177 |
-
f"VAE `{metadata.vae_repository}` @ `{metadata.vae_revision[:12]}`"
|
| 1178 |
-
)
|
| 1179 |
-
|
| 1180 |
-
|
| 1181 |
-
def _log_generation_request(request: CanterWebRequest) -> None:
|
| 1182 |
-
"""Print the exact effective controls consumed by one generation request."""
|
| 1183 |
-
|
| 1184 |
-
inference = request.config.inference
|
| 1185 |
-
prompt_digest = sha256(request.prompt.encode("utf-8")).hexdigest()[:12]
|
| 1186 |
-
print(
|
| 1187 |
-
"Canter request | "
|
| 1188 |
-
f"prompt_sha256={prompt_digest} | images={request.image_count} | "
|
| 1189 |
-
f"size={inference.width}x{inference.height} | seed={inference.seed} | "
|
| 1190 |
-
f"steps={inference.steps} | solver={inference.solver.value} | "
|
| 1191 |
-
f"schedule={inference.schedule.value} | "
|
| 1192 |
-
f"log_snr_shift={inference.log_snr_shift:g} | "
|
| 1193 |
-
f"self_attention_gain={inference.self_attention_gain:g} | "
|
| 1194 |
-
f"{_pdg_status(inference)} | cfg_enabled={inference.cfg.enabled} | "
|
| 1195 |
-
f"cfg_scale={inference.cfg.scale}",
|
| 1196 |
-
flush=True,
|
| 1197 |
-
)
|
| 1198 |
-
|
| 1199 |
-
|
| 1200 |
-
def _pdg_status(inference: CanterInferenceConfig) -> str:
|
| 1201 |
-
"""Render the effective PDG request consumed by latent inference."""
|
| 1202 |
-
|
| 1203 |
-
pdg = inference.pdg
|
| 1204 |
-
if not pdg.enabled:
|
| 1205 |
-
return "PDG off"
|
| 1206 |
-
stop = inference.steps - 1 if pdg.stop_step is None else pdg.stop_step
|
| 1207 |
-
match pdg.curve:
|
| 1208 |
-
case PdgCurve.CONSTANT:
|
| 1209 |
-
scale = f"{float(pdg.noisy_scale):g}"
|
| 1210 |
-
case PdgCurve.LINEAR:
|
| 1211 |
-
scale = f"linear {float(pdg.noisy_scale):g}→{float(pdg.clean_scale):g}"
|
| 1212 |
-
case PdgCurve.POWER:
|
| 1213 |
-
scale = (
|
| 1214 |
-
f"power {float(pdg.noisy_scale):g}→"
|
| 1215 |
-
f"{float(pdg.clean_scale):g} (p={float(pdg.power):g})"
|
| 1216 |
-
)
|
| 1217 |
-
case _ as unreachable:
|
| 1218 |
-
raise RuntimeError(f"Unsupported PDG curve: {unreachable}")
|
| 1219 |
-
return f"PDG `{pdg.mode.value}` {scale}, updates {pdg.start_step}–{stop}"
|
| 1220 |
-
|
| 1221 |
-
|
| 1222 |
-
def _generation_status(
|
| 1223 |
-
metadata: CanterPipelineMetadata,
|
| 1224 |
-
*,
|
| 1225 |
-
request: CanterWebRequest,
|
| 1226 |
-
elapsed: float,
|
| 1227 |
-
completed: int,
|
| 1228 |
-
) -> str:
|
| 1229 |
-
"""Render incremental timing and deterministic request provenance."""
|
| 1230 |
-
|
| 1231 |
-
inference = request.config.inference
|
| 1232 |
-
image_word = "image" if completed == 1 else "images"
|
| 1233 |
-
if inference.seed is None:
|
| 1234 |
-
raise RuntimeError("A validated Canter web request must contain a seed.")
|
| 1235 |
-
final_seed = inference.seed + completed - 1
|
| 1236 |
-
seed_text = (
|
| 1237 |
-
str(inference.seed) if completed == 1 else f"{inference.seed}–{final_seed}"
|
| 1238 |
-
)
|
| 1239 |
-
progress_text = (
|
| 1240 |
-
f"Generated **{completed}/{request.image_count} {image_word}**"
|
| 1241 |
-
if completed < request.image_count
|
| 1242 |
-
else f"Generated **{completed} {image_word}**"
|
| 1243 |
-
)
|
| 1244 |
-
return (
|
| 1245 |
-
f"{progress_text} in **{elapsed:.2f}s** · "
|
| 1246 |
-
f"seeds `{seed_text}` · batch size `1` · "
|
| 1247 |
-
f"{inference.width}×{inference.height} · "
|
| 1248 |
-
f"{inference.steps} {inference.solver.value} updates · "
|
| 1249 |
-
f"{_pdg_status(inference)} · "
|
| 1250 |
-
f"Canter code `{metadata.code_version}` · "
|
| 1251 |
-
f"checkpoint `{metadata.canter.release}`"
|
| 1252 |
-
)
|
| 1253 |
-
|
| 1254 |
-
|
| 1255 |
-
def _prompt_input() -> gr.Textbox:
|
| 1256 |
-
"""Create the prompt control without rendering it yet."""
|
| 1257 |
-
|
| 1258 |
-
return gr.Textbox(
|
| 1259 |
-
label="Prompt",
|
| 1260 |
-
value=_DEFAULT_PROMPT,
|
| 1261 |
-
lines=3,
|
| 1262 |
-
placeholder="Describe the image you want to generate.",
|
| 1263 |
-
render=False,
|
| 1264 |
-
)
|
| 1265 |
-
|
| 1266 |
-
|
| 1267 |
-
def _basic_inputs(prompt: gr.Textbox) -> _BasicInputs:
|
| 1268 |
-
"""Render prompt, image-count, preset, shape, and seed controls."""
|
| 1269 |
-
|
| 1270 |
-
prompt.render()
|
| 1271 |
-
image_count = gr.Slider(
|
| 1272 |
-
minimum=1,
|
| 1273 |
-
maximum=_MAX_IMAGE_COUNT,
|
| 1274 |
-
value=_DEFAULT_IMAGE_COUNT,
|
| 1275 |
-
step=1,
|
| 1276 |
-
precision=0,
|
| 1277 |
-
label="Images",
|
| 1278 |
-
info="Generated sequentially with batch size 1.",
|
| 1279 |
-
)
|
| 1280 |
-
size_preset = gr.Dropdown(
|
| 1281 |
-
choices=_SIZE_PRESET_CHOICES,
|
| 1282 |
-
value=SizePreset.DEFAULT_PORTRAIT.value,
|
| 1283 |
-
label="Size preset",
|
| 1284 |
-
allow_custom_value=False,
|
| 1285 |
-
)
|
| 1286 |
-
with gr.Row():
|
| 1287 |
-
width = gr.Number(
|
| 1288 |
-
value=_DEFAULT_INFERENCE.width,
|
| 1289 |
-
label="Width",
|
| 1290 |
-
precision=0,
|
| 1291 |
-
minimum=_IMAGE_SIZE_MULTIPLE,
|
| 1292 |
-
step=_IMAGE_SIZE_MULTIPLE,
|
| 1293 |
-
)
|
| 1294 |
-
height = gr.Number(
|
| 1295 |
-
value=_DEFAULT_INFERENCE.height,
|
| 1296 |
-
label="Height",
|
| 1297 |
-
precision=0,
|
| 1298 |
-
minimum=_IMAGE_SIZE_MULTIPLE,
|
| 1299 |
-
step=_IMAGE_SIZE_MULTIPLE,
|
| 1300 |
-
)
|
| 1301 |
-
seed = gr.Number(
|
| 1302 |
-
value=_DEFAULT_INFERENCE.seed,
|
| 1303 |
-
label="Seed",
|
| 1304 |
-
precision=0,
|
| 1305 |
-
minimum=0,
|
| 1306 |
-
maximum=_MAX_SEED,
|
| 1307 |
-
)
|
| 1308 |
-
return _BasicInputs(
|
| 1309 |
-
prompt=prompt,
|
| 1310 |
-
image_count=image_count,
|
| 1311 |
-
size_preset=size_preset,
|
| 1312 |
-
width=width,
|
| 1313 |
-
height=height,
|
| 1314 |
-
seed=seed,
|
| 1315 |
-
)
|
| 1316 |
-
|
| 1317 |
-
|
| 1318 |
-
def apply_size_preset(value: str | None) -> tuple[int, int, float]:
|
| 1319 |
-
"""Return width, height, and attention gain for one exact UI preset."""
|
| 1320 |
-
|
| 1321 |
-
preset = _choice(value, _SIZE_PRESETS, "size preset")
|
| 1322 |
-
selected = _SIZE_PRESET_VALUES[preset]
|
| 1323 |
-
return selected.width, selected.height, selected.self_attention_gain
|
| 1324 |
-
|
| 1325 |
-
|
| 1326 |
-
def schedule_log_snr_shift(value: str | None) -> float:
|
| 1327 |
-
"""Return the UI log-SNR preset associated with one schedule."""
|
| 1328 |
-
|
| 1329 |
-
schedule = _choice(value, _SCHEDULES, "schedule")
|
| 1330 |
-
match schedule:
|
| 1331 |
-
case Schedule.BETA:
|
| 1332 |
-
return 0.0
|
| 1333 |
-
case Schedule.LINEAR:
|
| 1334 |
-
return -2.3
|
| 1335 |
-
case _ as unreachable:
|
| 1336 |
-
raise RuntimeError(f"Unsupported Canter schedule: {unreachable}")
|
| 1337 |
-
|
| 1338 |
-
|
| 1339 |
-
def _solver_inputs() -> tuple[
|
| 1340 |
-
gr.Slider,
|
| 1341 |
-
gr.Dropdown,
|
| 1342 |
-
gr.Dropdown,
|
| 1343 |
-
gr.Slider,
|
| 1344 |
-
gr.Slider,
|
| 1345 |
-
gr.Slider,
|
| 1346 |
-
]:
|
| 1347 |
-
"""Create solver, schedule, and model-gain controls."""
|
| 1348 |
-
|
| 1349 |
-
steps = gr.Slider(
|
| 1350 |
-
minimum=1,
|
| 1351 |
-
maximum=100,
|
| 1352 |
-
value=_DEFAULT_INFERENCE.steps,
|
| 1353 |
-
step=1,
|
| 1354 |
-
precision=0,
|
| 1355 |
-
label="Solver updates",
|
| 1356 |
-
)
|
| 1357 |
-
with gr.Row():
|
| 1358 |
-
solver = gr.Dropdown(
|
| 1359 |
-
choices=_SOLVER_CHOICES,
|
| 1360 |
-
value=_DEFAULT_INFERENCE.solver.value,
|
| 1361 |
-
label="Solver",
|
| 1362 |
-
allow_custom_value=False,
|
| 1363 |
-
)
|
| 1364 |
-
schedule = gr.Dropdown(
|
| 1365 |
-
choices=_SCHEDULE_CHOICES,
|
| 1366 |
-
value=_DEFAULT_INFERENCE.schedule.value,
|
| 1367 |
-
label="Schedule",
|
| 1368 |
-
allow_custom_value=False,
|
| 1369 |
-
)
|
| 1370 |
-
log_snr_shift = gr.Slider(
|
| 1371 |
-
minimum=-4.0,
|
| 1372 |
-
maximum=4.0,
|
| 1373 |
-
value=_DEFAULT_INFERENCE.log_snr_shift,
|
| 1374 |
-
step=0.05,
|
| 1375 |
-
label="Log-SNR shift",
|
| 1376 |
-
)
|
| 1377 |
-
self_attention_gain = gr.Slider(
|
| 1378 |
-
minimum=-0.05,
|
| 1379 |
-
maximum=0.02,
|
| 1380 |
-
value=_DEFAULT_INFERENCE.self_attention_gain,
|
| 1381 |
-
step=0.005,
|
| 1382 |
-
label="Main self-attention gain",
|
| 1383 |
-
)
|
| 1384 |
-
euler_maruyama_multiplier = gr.Slider(
|
| 1385 |
-
minimum=0.0,
|
| 1386 |
-
maximum=2.0,
|
| 1387 |
-
value=_DEFAULT_INFERENCE.euler_maruyama_multiplier,
|
| 1388 |
-
step=0.05,
|
| 1389 |
-
label="SDE noise multiplier",
|
| 1390 |
-
)
|
| 1391 |
-
return (
|
| 1392 |
-
steps,
|
| 1393 |
-
solver,
|
| 1394 |
-
schedule,
|
| 1395 |
-
log_snr_shift,
|
| 1396 |
-
self_attention_gain,
|
| 1397 |
-
euler_maruyama_multiplier,
|
| 1398 |
-
)
|
| 1399 |
-
|
| 1400 |
-
|
| 1401 |
-
def _cfg_inputs() -> tuple[
|
| 1402 |
-
gr.Checkbox,
|
| 1403 |
-
gr.Slider,
|
| 1404 |
-
gr.Number,
|
| 1405 |
-
gr.Textbox,
|
| 1406 |
-
]:
|
| 1407 |
-
"""Create classifier-free guidance controls."""
|
| 1408 |
-
|
| 1409 |
-
enabled = gr.Checkbox(
|
| 1410 |
-
value=_DEFAULT_INFERENCE.cfg.enabled,
|
| 1411 |
-
label="Enable CFG",
|
| 1412 |
-
)
|
| 1413 |
-
scale = gr.Slider(
|
| 1414 |
-
minimum=0.0,
|
| 1415 |
-
maximum=10.0,
|
| 1416 |
-
value=3.0,
|
| 1417 |
-
step=0.05,
|
| 1418 |
-
label="CFG scale",
|
| 1419 |
-
info="Also used by CFG-dependent PDG modes.",
|
| 1420 |
-
)
|
| 1421 |
-
with gr.Row():
|
| 1422 |
-
start = gr.Number(
|
| 1423 |
-
value=_DEFAULT_INFERENCE.cfg.start_step,
|
| 1424 |
-
label="CFG start",
|
| 1425 |
-
precision=0,
|
| 1426 |
-
minimum=0,
|
| 1427 |
-
)
|
| 1428 |
-
stop = gr.Textbox(
|
| 1429 |
-
value="",
|
| 1430 |
-
label="CFG stop",
|
| 1431 |
-
info="Blank means the final solver update.",
|
| 1432 |
-
placeholder="Final",
|
| 1433 |
-
lines=1,
|
| 1434 |
-
)
|
| 1435 |
-
return enabled, scale, start, stop
|
| 1436 |
-
|
| 1437 |
-
|
| 1438 |
-
def _pdg_inputs() -> tuple[
|
| 1439 |
-
gr.Checkbox,
|
| 1440 |
-
gr.Dropdown,
|
| 1441 |
-
gr.Dropdown,
|
| 1442 |
-
gr.Slider,
|
| 1443 |
-
gr.Slider,
|
| 1444 |
-
gr.Slider,
|
| 1445 |
-
gr.Number,
|
| 1446 |
-
gr.Textbox,
|
| 1447 |
-
]:
|
| 1448 |
-
"""Create path-drop guidance and curve controls."""
|
| 1449 |
-
|
| 1450 |
-
enabled = gr.Checkbox(
|
| 1451 |
-
value=_DEFAULT_INFERENCE.pdg.enabled,
|
| 1452 |
-
label="Enable PDG",
|
| 1453 |
-
)
|
| 1454 |
-
with gr.Row():
|
| 1455 |
-
mode = gr.Dropdown(
|
| 1456 |
-
choices=_PDG_MODE_CHOICES,
|
| 1457 |
-
value=_DEFAULT_INFERENCE.pdg.mode.value,
|
| 1458 |
-
label="PDG mode",
|
| 1459 |
-
allow_custom_value=False,
|
| 1460 |
-
)
|
| 1461 |
-
curve = gr.Dropdown(
|
| 1462 |
-
choices=_PDG_CURVE_CHOICES,
|
| 1463 |
-
value=_DEFAULT_INFERENCE.pdg.curve.value,
|
| 1464 |
-
label="PDG curve",
|
| 1465 |
-
allow_custom_value=False,
|
| 1466 |
-
)
|
| 1467 |
-
noisy_scale = gr.Slider(
|
| 1468 |
-
minimum=0.0,
|
| 1469 |
-
maximum=10.0,
|
| 1470 |
-
value=_DEFAULT_INFERENCE.pdg.noisy_scale,
|
| 1471 |
-
step=0.05,
|
| 1472 |
-
label="PDG scale / noisy endpoint",
|
| 1473 |
-
)
|
| 1474 |
-
clean_scale = gr.Slider(
|
| 1475 |
-
minimum=0.0,
|
| 1476 |
-
maximum=10.0,
|
| 1477 |
-
value=_DEFAULT_INFERENCE.pdg.clean_scale,
|
| 1478 |
-
step=0.05,
|
| 1479 |
-
label="PDG clean endpoint",
|
| 1480 |
-
info="Endpoint used by linear and power curves.",
|
| 1481 |
-
interactive=_DEFAULT_INFERENCE.pdg.curve is not PdgCurve.CONSTANT,
|
| 1482 |
-
)
|
| 1483 |
-
power = gr.Slider(
|
| 1484 |
-
minimum=0.05,
|
| 1485 |
-
maximum=10.0,
|
| 1486 |
-
value=_DEFAULT_INFERENCE.pdg.power,
|
| 1487 |
-
step=0.05,
|
| 1488 |
-
label="PDG power",
|
| 1489 |
-
)
|
| 1490 |
-
with gr.Row():
|
| 1491 |
-
start = gr.Number(
|
| 1492 |
-
value=_DEFAULT_INFERENCE.pdg.start_step,
|
| 1493 |
-
label="PDG start",
|
| 1494 |
-
precision=0,
|
| 1495 |
-
minimum=0,
|
| 1496 |
-
)
|
| 1497 |
-
stop = gr.Textbox(
|
| 1498 |
-
value="",
|
| 1499 |
-
label="PDG stop",
|
| 1500 |
-
info="Blank means the final solver update.",
|
| 1501 |
-
placeholder="Final",
|
| 1502 |
-
lines=1,
|
| 1503 |
-
)
|
| 1504 |
-
return enabled, mode, curve, noisy_scale, clean_scale, power, start, stop
|
| 1505 |
-
|
| 1506 |
-
|
| 1507 |
-
def _create_inputs(
|
| 1508 |
-
basic: _BasicInputs,
|
| 1509 |
-
) -> _WebInputs:
|
| 1510 |
-
"""Create advanced controls around an existing basic control group."""
|
| 1511 |
-
|
| 1512 |
-
gr.Markdown("### Solver")
|
| 1513 |
-
(
|
| 1514 |
-
steps,
|
| 1515 |
-
solver,
|
| 1516 |
-
schedule,
|
| 1517 |
-
log_snr_shift,
|
| 1518 |
-
self_attention_gain,
|
| 1519 |
-
euler_maruyama_multiplier,
|
| 1520 |
-
) = _solver_inputs()
|
| 1521 |
-
gr.Markdown("### Path-drop guidance")
|
| 1522 |
-
(
|
| 1523 |
-
pdg_enabled,
|
| 1524 |
-
pdg_mode,
|
| 1525 |
-
pdg_curve,
|
| 1526 |
-
pdg_noisy_scale,
|
| 1527 |
-
pdg_clean_scale,
|
| 1528 |
-
pdg_power,
|
| 1529 |
-
pdg_start,
|
| 1530 |
-
pdg_stop,
|
| 1531 |
-
) = _pdg_inputs()
|
| 1532 |
-
gr.Markdown("### Classifier-free guidance")
|
| 1533 |
-
cfg_enabled, cfg_scale, cfg_start, cfg_stop = _cfg_inputs()
|
| 1534 |
-
return _WebInputs(
|
| 1535 |
-
prompt=basic.prompt,
|
| 1536 |
-
image_count=basic.image_count,
|
| 1537 |
-
width=basic.width,
|
| 1538 |
-
height=basic.height,
|
| 1539 |
-
seed=basic.seed,
|
| 1540 |
-
steps=steps,
|
| 1541 |
-
solver=solver,
|
| 1542 |
-
schedule=schedule,
|
| 1543 |
-
log_snr_shift=log_snr_shift,
|
| 1544 |
-
self_attention_gain=self_attention_gain,
|
| 1545 |
-
euler_maruyama_multiplier=euler_maruyama_multiplier,
|
| 1546 |
-
cfg_enabled=cfg_enabled,
|
| 1547 |
-
cfg_scale=cfg_scale,
|
| 1548 |
-
cfg_start_step=cfg_start,
|
| 1549 |
-
cfg_stop_step=cfg_stop,
|
| 1550 |
-
pdg_enabled=pdg_enabled,
|
| 1551 |
-
pdg_mode=pdg_mode,
|
| 1552 |
-
pdg_curve=pdg_curve,
|
| 1553 |
-
pdg_noisy_scale=pdg_noisy_scale,
|
| 1554 |
-
pdg_clean_scale=pdg_clean_scale,
|
| 1555 |
-
pdg_power=pdg_power,
|
| 1556 |
-
pdg_start_step=pdg_start,
|
| 1557 |
-
pdg_stop_step=pdg_stop,
|
| 1558 |
-
)
|
| 1559 |
-
|
| 1560 |
-
|
| 1561 |
-
def _connect_generation(
|
| 1562 |
-
controller: CanterWebController,
|
| 1563 |
-
inputs: _WebInputs,
|
| 1564 |
-
generate: gr.Button,
|
| 1565 |
-
stop: gr.Button,
|
| 1566 |
-
outputs: _WebOutputs,
|
| 1567 |
-
) -> None:
|
| 1568 |
-
"""Connect button and prompt submission to the serial GPU queue."""
|
| 1569 |
-
|
| 1570 |
-
generate.click(
|
| 1571 |
-
fn=controller.generate,
|
| 1572 |
-
inputs=inputs.ordered(),
|
| 1573 |
-
outputs=outputs.ordered(),
|
| 1574 |
-
api_name="generate",
|
| 1575 |
-
api_description="Generate images with the loaded Canter release.",
|
| 1576 |
-
show_progress="hidden",
|
| 1577 |
-
concurrency_limit=1,
|
| 1578 |
-
concurrency_id=_GPU_CONCURRENCY_ID,
|
| 1579 |
-
)
|
| 1580 |
-
inputs.prompt.submit(
|
| 1581 |
-
fn=controller.generate,
|
| 1582 |
-
inputs=inputs.ordered(),
|
| 1583 |
-
outputs=outputs.ordered(),
|
| 1584 |
-
api_name=None,
|
| 1585 |
-
show_progress="hidden",
|
| 1586 |
-
concurrency_limit=1,
|
| 1587 |
-
concurrency_id=_GPU_CONCURRENCY_ID,
|
| 1588 |
-
api_visibility="private",
|
| 1589 |
-
)
|
| 1590 |
-
stop.click(
|
| 1591 |
-
fn=controller.request_stop,
|
| 1592 |
-
inputs=None,
|
| 1593 |
-
outputs=outputs.status,
|
| 1594 |
-
queue=False,
|
| 1595 |
-
api_name=None,
|
| 1596 |
-
api_visibility="private",
|
| 1597 |
-
)
|
| 1598 |
-
|
| 1599 |
-
|
| 1600 |
-
def _create_web_app(
|
| 1601 |
-
controller: CanterWebController,
|
| 1602 |
-
*,
|
| 1603 |
-
model_status_text: str,
|
| 1604 |
-
ready: bool,
|
| 1605 |
-
load_config: CanterWebLaunchConfig | None,
|
| 1606 |
-
) -> gr.Blocks:
|
| 1607 |
-
"""Build the UI for either an already-loaded or lazy process pipeline."""
|
| 1608 |
-
|
| 1609 |
-
with gr.Blocks(title="Canter", fill_width=True) as application:
|
| 1610 |
-
gr.Markdown("# Canter", elem_id="canter-title")
|
| 1611 |
-
model_status = gr.Markdown(model_status_text, elem_id="canter-model-status")
|
| 1612 |
-
with gr.Row(elem_id="canter-workspace"):
|
| 1613 |
-
with gr.Column(scale=3, min_width=360, elem_id="canter-controls"):
|
| 1614 |
-
prompt = _prompt_input()
|
| 1615 |
-
basic = _basic_inputs(prompt)
|
| 1616 |
-
with gr.Row():
|
| 1617 |
-
generate = gr.Button(
|
| 1618 |
-
"Generate",
|
| 1619 |
-
variant="primary",
|
| 1620 |
-
scale=3,
|
| 1621 |
-
interactive=ready,
|
| 1622 |
-
)
|
| 1623 |
-
stop = gr.Button(
|
| 1624 |
-
"Stop",
|
| 1625 |
-
variant="stop",
|
| 1626 |
-
scale=1,
|
| 1627 |
-
interactive=ready,
|
| 1628 |
-
)
|
| 1629 |
-
randomize = gr.Button("Random seed", variant="secondary", scale=1)
|
| 1630 |
-
inputs = _create_inputs(basic)
|
| 1631 |
-
with gr.Column(scale=8, min_width=640, elem_id="canter-results"):
|
| 1632 |
-
status = gr.Markdown(
|
| 1633 |
-
(
|
| 1634 |
-
"Ready. The first request includes compiled graph warm-up."
|
| 1635 |
-
if ready
|
| 1636 |
-
else "Loading Canter weights and compiling the selected "
|
| 1637 |
-
"backend…"
|
| 1638 |
-
),
|
| 1639 |
-
elem_id="canter-status",
|
| 1640 |
-
)
|
| 1641 |
-
total_progress = gr.HTML(
|
| 1642 |
-
"",
|
| 1643 |
-
container=False,
|
| 1644 |
-
elem_classes="canter-total-progress",
|
| 1645 |
-
)
|
| 1646 |
-
progress_outputs: list[gr.HTML] = []
|
| 1647 |
-
image_outputs: list[gr.Image] = []
|
| 1648 |
-
with gr.Row(elem_id="canter-image-grid"):
|
| 1649 |
-
for index in range(_MAX_IMAGE_COUNT):
|
| 1650 |
-
with gr.Column(
|
| 1651 |
-
min_width=0,
|
| 1652 |
-
elem_classes="canter-image-cell",
|
| 1653 |
-
):
|
| 1654 |
-
progress_outputs.append(
|
| 1655 |
-
gr.HTML(
|
| 1656 |
-
"",
|
| 1657 |
-
container=False,
|
| 1658 |
-
elem_classes="canter-image-progress",
|
| 1659 |
-
)
|
| 1660 |
-
)
|
| 1661 |
-
image_outputs.append(
|
| 1662 |
-
gr.Image(
|
| 1663 |
-
label=f"Image {index + 1}",
|
| 1664 |
-
format="png",
|
| 1665 |
-
height=None,
|
| 1666 |
-
width=None,
|
| 1667 |
-
type="pil",
|
| 1668 |
-
show_label=False,
|
| 1669 |
-
buttons=["download", "fullscreen"],
|
| 1670 |
-
container=False,
|
| 1671 |
-
interactive=False,
|
| 1672 |
-
elem_classes="canter-output-image",
|
| 1673 |
-
)
|
| 1674 |
-
)
|
| 1675 |
-
progress_timer = gr.Timer(value=0.2, active=False)
|
| 1676 |
-
outputs = _WebOutputs(
|
| 1677 |
-
images=(
|
| 1678 |
-
image_outputs[0],
|
| 1679 |
-
image_outputs[1],
|
| 1680 |
-
image_outputs[2],
|
| 1681 |
-
image_outputs[3],
|
| 1682 |
-
),
|
| 1683 |
-
status=status,
|
| 1684 |
-
progress_timer=progress_timer,
|
| 1685 |
-
)
|
| 1686 |
-
progress_timer.tick(
|
| 1687 |
-
fn=controller.poll_progress,
|
| 1688 |
-
inputs=None,
|
| 1689 |
-
outputs=(
|
| 1690 |
-
total_progress,
|
| 1691 |
-
progress_outputs[0],
|
| 1692 |
-
progress_outputs[1],
|
| 1693 |
-
progress_outputs[2],
|
| 1694 |
-
progress_outputs[3],
|
| 1695 |
-
progress_timer,
|
| 1696 |
-
),
|
| 1697 |
-
queue=False,
|
| 1698 |
-
show_progress="hidden",
|
| 1699 |
-
api_name=None,
|
| 1700 |
-
api_visibility="private",
|
| 1701 |
-
)
|
| 1702 |
-
randomize.click(
|
| 1703 |
-
fn=random_seed,
|
| 1704 |
-
inputs=None,
|
| 1705 |
-
outputs=inputs.seed,
|
| 1706 |
-
queue=False,
|
| 1707 |
-
api_name=None,
|
| 1708 |
-
api_visibility="private",
|
| 1709 |
-
)
|
| 1710 |
-
basic.size_preset.change(
|
| 1711 |
-
fn=apply_size_preset,
|
| 1712 |
-
inputs=basic.size_preset,
|
| 1713 |
-
outputs=(
|
| 1714 |
-
basic.width,
|
| 1715 |
-
basic.height,
|
| 1716 |
-
inputs.self_attention_gain,
|
| 1717 |
-
),
|
| 1718 |
-
queue=False,
|
| 1719 |
-
api_name=None,
|
| 1720 |
-
api_visibility="private",
|
| 1721 |
-
)
|
| 1722 |
-
inputs.schedule.change(
|
| 1723 |
-
fn=schedule_log_snr_shift,
|
| 1724 |
-
inputs=inputs.schedule,
|
| 1725 |
-
outputs=inputs.log_snr_shift,
|
| 1726 |
-
queue=False,
|
| 1727 |
-
api_name=None,
|
| 1728 |
-
api_visibility="private",
|
| 1729 |
-
)
|
| 1730 |
-
for dimension in (basic.width, basic.height):
|
| 1731 |
-
dimension.blur(
|
| 1732 |
-
fn=snap_image_dimension,
|
| 1733 |
-
inputs=dimension,
|
| 1734 |
-
outputs=dimension,
|
| 1735 |
-
queue=False,
|
| 1736 |
-
api_name=None,
|
| 1737 |
-
api_visibility="private",
|
| 1738 |
-
)
|
| 1739 |
-
inputs.pdg_curve.change(
|
| 1740 |
-
fn=update_pdg_clean_scale,
|
| 1741 |
-
inputs=(inputs.pdg_curve, inputs.pdg_noisy_scale),
|
| 1742 |
-
outputs=inputs.pdg_clean_scale,
|
| 1743 |
-
queue=False,
|
| 1744 |
-
api_name=None,
|
| 1745 |
-
api_visibility="private",
|
| 1746 |
-
)
|
| 1747 |
-
_connect_generation(controller, inputs, generate, stop, outputs)
|
| 1748 |
-
if load_config is not None:
|
| 1749 |
-
application.load(
|
| 1750 |
-
fn=controller.load,
|
| 1751 |
-
inputs=None,
|
| 1752 |
-
outputs=(model_status, status, generate, stop),
|
| 1753 |
-
show_progress="minimal",
|
| 1754 |
-
show_progress_on=status,
|
| 1755 |
-
concurrency_limit=1,
|
| 1756 |
-
concurrency_id=_GPU_CONCURRENCY_ID,
|
| 1757 |
-
api_name=None,
|
| 1758 |
-
api_visibility="private",
|
| 1759 |
-
)
|
| 1760 |
-
application.queue(
|
| 1761 |
-
max_size=_MAX_QUEUE_SIZE,
|
| 1762 |
-
default_concurrency_limit=1,
|
| 1763 |
-
api_open=False,
|
| 1764 |
-
)
|
| 1765 |
-
return application
|
| 1766 |
-
|
| 1767 |
-
|
| 1768 |
-
def create_web_app(pipeline: _ImagePipeline) -> gr.Blocks:
|
| 1769 |
-
"""Build the bundled application around one already-loaded pipeline."""
|
| 1770 |
-
|
| 1771 |
-
return _create_web_app(
|
| 1772 |
-
CanterWebController(pipeline),
|
| 1773 |
-
model_status_text=_model_summary(pipeline.metadata),
|
| 1774 |
-
ready=True,
|
| 1775 |
-
load_config=None,
|
| 1776 |
-
)
|
| 1777 |
-
|
| 1778 |
-
|
| 1779 |
-
def create_loading_web_app(config: CanterWebLaunchConfig) -> gr.Blocks:
|
| 1780 |
-
"""Build an immediately visible UI that loads its process pipeline once."""
|
| 1781 |
-
|
| 1782 |
-
return _create_web_app(
|
| 1783 |
-
CanterWebController(None, load_config=config),
|
| 1784 |
-
model_status_text=(
|
| 1785 |
-
f"Loading Canter `{config.dtype.value}` weights and compiling the "
|
| 1786 |
-
f"`{config.text_backend.value}` backend…"
|
| 1787 |
-
),
|
| 1788 |
-
ready=False,
|
| 1789 |
-
load_config=config,
|
| 1790 |
-
)
|
| 1791 |
-
|
| 1792 |
-
|
| 1793 |
-
def _theme() -> gr.themes.Base:
|
| 1794 |
-
"""Return Canter's compact charcoal-and-sky-blue dark theme."""
|
| 1795 |
-
|
| 1796 |
-
return gr.themes.Base(
|
| 1797 |
-
primary_hue="sky",
|
| 1798 |
-
secondary_hue="blue",
|
| 1799 |
-
neutral_hue="zinc",
|
| 1800 |
-
spacing_size="sm",
|
| 1801 |
-
radius_size="sm",
|
| 1802 |
-
text_size="md",
|
| 1803 |
-
font=[
|
| 1804 |
-
gr.themes.Font("Inter"),
|
| 1805 |
-
gr.themes.Font("Segoe UI"),
|
| 1806 |
-
gr.themes.Font("sans-serif"),
|
| 1807 |
-
],
|
| 1808 |
-
font_mono=[
|
| 1809 |
-
gr.themes.Font("IBM Plex Mono"),
|
| 1810 |
-
gr.themes.Font("Consolas"),
|
| 1811 |
-
gr.themes.Font("monospace"),
|
| 1812 |
-
],
|
| 1813 |
-
).set(
|
| 1814 |
-
body_background_fill_dark="#09090b",
|
| 1815 |
-
block_background_fill_dark="#111113",
|
| 1816 |
-
block_border_color_dark="#27272a",
|
| 1817 |
-
input_background_fill_dark="#18181b",
|
| 1818 |
-
)
|
| 1819 |
-
|
| 1820 |
|
| 1821 |
def _argument_parser() -> argparse.ArgumentParser:
|
| 1822 |
"""Create the command-line surface with the installed-package model default."""
|
|
@@ -1881,19 +127,28 @@ def parse_launch_config(
|
|
| 1881 |
)
|
| 1882 |
|
| 1883 |
|
| 1884 |
-
def
|
| 1885 |
-
|
|
|
|
|
|
|
|
|
|
| 1886 |
|
| 1887 |
-
|
| 1888 |
-
|
| 1889 |
-
|
| 1890 |
-
|
| 1891 |
-
|
| 1892 |
-
|
| 1893 |
-
|
| 1894 |
-
|
| 1895 |
-
|
| 1896 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1897 |
|
| 1898 |
|
| 1899 |
def launch_web_app(
|
|
@@ -1901,6 +156,12 @@ def launch_web_app(
|
|
| 1901 |
) -> None:
|
| 1902 |
"""Serve the UI immediately and load Canter from its browser load event."""
|
| 1903 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1904 |
application = create_loading_web_app(config)
|
| 1905 |
application.launch(
|
| 1906 |
server_name=config.server_name,
|
|
|
|
| 1 |
+
"""Public entry point for the standalone Canter web application."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
| 5 |
import argparse
|
| 6 |
+
import logging
|
| 7 |
+
import os
|
| 8 |
+
import shutil
|
| 9 |
+
import tempfile
|
| 10 |
+
from collections.abc import Sequence
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
from pathlib import Path
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
from .blocks import TextAttentionBackend
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
from .loading import WeightDType
|
| 15 |
+
from .webui_app import (
|
| 16 |
+
_CSS,
|
| 17 |
+
_DARK_HEAD,
|
| 18 |
+
SizePreset,
|
| 19 |
+
_theme,
|
| 20 |
+
apply_size_preset,
|
| 21 |
+
create_loading_web_app,
|
| 22 |
+
create_web_app,
|
| 23 |
+
image_grid_style,
|
| 24 |
+
image_grid_visibility,
|
| 25 |
+
prepare_image_grid,
|
| 26 |
+
schedule_log_snr_shift,
|
| 27 |
+
)
|
| 28 |
+
from .webui_runtime import (
|
| 29 |
+
CanterWebController,
|
| 30 |
+
CanterWebLaunchConfig,
|
| 31 |
+
_choice,
|
| 32 |
+
_integer,
|
| 33 |
+
build_web_request,
|
| 34 |
+
snap_image_dimension,
|
| 35 |
+
update_pdg_clean_scale,
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
__all__ = (
|
| 39 |
+
"CanterWebController",
|
| 40 |
+
"CanterWebLaunchConfig",
|
| 41 |
+
"SizePreset",
|
| 42 |
+
"apply_size_preset",
|
| 43 |
+
"build_web_request",
|
| 44 |
+
"create_loading_web_app",
|
| 45 |
+
"create_web_app",
|
| 46 |
+
"image_grid_style",
|
| 47 |
+
"image_grid_visibility",
|
| 48 |
+
"launch_web_app",
|
| 49 |
+
"main",
|
| 50 |
+
"parse_launch_config",
|
| 51 |
+
"prepare_image_grid",
|
| 52 |
+
"schedule_log_snr_shift",
|
| 53 |
+
"snap_image_dimension",
|
| 54 |
+
"update_pdg_clean_scale",
|
| 55 |
)
|
|
|
|
|
|
|
| 56 |
|
| 57 |
_DEFAULT_MODEL = "data-archetype/canter"
|
| 58 |
_DEFAULT_SERVER_NAME = "0.0.0.0"
|
| 59 |
_DEFAULT_SERVER_PORT = 7860
|
| 60 |
+
_TEMP_DIRECTORY_PREFIX = "canter-gradio"
|
| 61 |
+
_TEMP_DIRECTORY_MARKER = ".canter-owned-temp-directory"
|
| 62 |
+
_LOG_FORMAT = "%(asctime)s %(levelname)s %(name)s: %(message)s"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
_WEIGHT_DTYPES = {value.value: value for value in WeightDType}
|
| 64 |
_TEXT_BACKENDS = {value.value: value for value in TextAttentionBackend}
|
| 65 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
|
| 67 |
def _argument_parser() -> argparse.ArgumentParser:
|
| 68 |
"""Create the command-line surface with the installed-package model default."""
|
|
|
|
| 127 |
)
|
| 128 |
|
| 129 |
|
| 130 |
+
def _prepare_canter_temp_directory(
|
| 131 |
+
base_directory: Path,
|
| 132 |
+
server_port: int,
|
| 133 |
+
) -> Path:
|
| 134 |
+
"""Replace one marked Canter cache directory and select it for Gradio."""
|
| 135 |
|
| 136 |
+
directory = base_directory.resolve() / f"{_TEMP_DIRECTORY_PREFIX}-{server_port}"
|
| 137 |
+
marker = directory / _TEMP_DIRECTORY_MARKER
|
| 138 |
+
if directory.exists():
|
| 139 |
+
if not directory.is_dir():
|
| 140 |
+
raise NotADirectoryError(
|
| 141 |
+
f"Canter temporary path is not a directory: {directory}"
|
| 142 |
+
)
|
| 143 |
+
if not marker.is_file():
|
| 144 |
+
raise RuntimeError(
|
| 145 |
+
f"Refusing to clear an unmarked temporary directory: {directory}"
|
| 146 |
+
)
|
| 147 |
+
shutil.rmtree(directory)
|
| 148 |
+
directory.mkdir()
|
| 149 |
+
marker.write_text("Owned by the Canter web UI.\n", encoding="utf-8")
|
| 150 |
+
os.environ["GRADIO_TEMP_DIR"] = str(directory)
|
| 151 |
+
return directory
|
| 152 |
|
| 153 |
|
| 154 |
def launch_web_app(
|
|
|
|
| 156 |
) -> None:
|
| 157 |
"""Serve the UI immediately and load Canter from its browser load event."""
|
| 158 |
|
| 159 |
+
logging.basicConfig(format=_LOG_FORMAT)
|
| 160 |
+
logging.getLogger(__package__).setLevel(logging.INFO)
|
| 161 |
+
_prepare_canter_temp_directory(
|
| 162 |
+
Path(tempfile.gettempdir()),
|
| 163 |
+
config.server_port,
|
| 164 |
+
)
|
| 165 |
application = create_loading_web_app(config)
|
| 166 |
application.launch(
|
| 167 |
server_name=config.server_name,
|
canter/webui_app.py
ADDED
|
@@ -0,0 +1,1281 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Gradio component tree and callbacks for the bundled Canter web UI."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from dataclasses import dataclass
|
| 6 |
+
from enum import Enum
|
| 7 |
+
|
| 8 |
+
import gradio as gr
|
| 9 |
+
|
| 10 |
+
from .inference import CanterInferenceConfig, PdgCurve, PdgMode
|
| 11 |
+
from .schedules import Schedule
|
| 12 |
+
from .solvers import Solver
|
| 13 |
+
from .webui_runtime import (
|
| 14 |
+
_IMAGE_SIZE_MULTIPLE,
|
| 15 |
+
_MAX_IMAGE_COUNT,
|
| 16 |
+
_MAX_SEED,
|
| 17 |
+
_SCHEDULES,
|
| 18 |
+
CanterWebController,
|
| 19 |
+
CanterWebLaunchConfig,
|
| 20 |
+
_choice,
|
| 21 |
+
_cleared_image_slots,
|
| 22 |
+
_GenerationOutputUpdate,
|
| 23 |
+
_image_dimension,
|
| 24 |
+
_ImagePipeline,
|
| 25 |
+
_integer,
|
| 26 |
+
_model_summary,
|
| 27 |
+
_number,
|
| 28 |
+
_unloaded_model_status,
|
| 29 |
+
random_seed,
|
| 30 |
+
snap_image_dimension,
|
| 31 |
+
update_pdg_clean_scale,
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
_MAX_QUEUE_SIZE = 8
|
| 35 |
+
_DEFAULT_IMAGE_COUNT = 4
|
| 36 |
+
_GPU_CONCURRENCY_ID = "canter-gpu"
|
| 37 |
+
|
| 38 |
+
_DEFAULT_INFERENCE = CanterInferenceConfig()
|
| 39 |
+
_SOLVER_CHOICES = (
|
| 40 |
+
("ABM2", Solver.ABM2.value),
|
| 41 |
+
("DPM++ 2M", Solver.DPMPP_2M.value),
|
| 42 |
+
("Euler-Maruyama", Solver.EULER_MARUYAMA.value),
|
| 43 |
+
("ER-SDE", Solver.ER_SDE.value),
|
| 44 |
+
("Euler", Solver.EULER.value),
|
| 45 |
+
)
|
| 46 |
+
_SCHEDULE_CHOICES = (
|
| 47 |
+
("Beta (0.6, 0.6)", Schedule.BETA.value),
|
| 48 |
+
("Linear", Schedule.LINEAR.value),
|
| 49 |
+
)
|
| 50 |
+
_PDG_MODE_CHOICES = (
|
| 51 |
+
("Full path drop", PdgMode.FULL.value),
|
| 52 |
+
("Three-quarter path", PdgMode.THREE_QUARTER.value),
|
| 53 |
+
("Alternate: PDG first", PdgMode.ALTERNATE_PDG_FIRST.value),
|
| 54 |
+
("Alternate: CFG first", PdgMode.ALTERNATE_CFG_FIRST.value),
|
| 55 |
+
("Combined CFG + PDG", PdgMode.COMBINED_CFG_PDG.value),
|
| 56 |
+
("PDG with alternating CFG", PdgMode.PDG_WITH_ALTERNATING_CFG.value),
|
| 57 |
+
("CFG then PDG", PdgMode.CFG_TO_PDG.value),
|
| 58 |
+
)
|
| 59 |
+
_PDG_CURVE_CHOICES = (
|
| 60 |
+
("Constant", PdgCurve.CONSTANT.value),
|
| 61 |
+
("Linear", PdgCurve.LINEAR.value),
|
| 62 |
+
("Power", PdgCurve.POWER.value),
|
| 63 |
+
)
|
| 64 |
+
_DEFAULT_PROMPT = (
|
| 65 |
+
"Wide angle photo of a weathered wooden boardwalk descending steeply from "
|
| 66 |
+
"rocky cliffs toward a rugged coastline. The boardwalk, constructed from "
|
| 67 |
+
"aged planks with visible cracks and moss growth, is bordered by rustic "
|
| 68 |
+
"wooden railings on both sides. Surrounding terrain features uneven, eroded "
|
| 69 |
+
"rock formations covered in patches of green vegetation and low-lying "
|
| 70 |
+
"shrubs. Below, a crescent-shaped sandy beach meets crashing waves of a "
|
| 71 |
+
"deep blue ocean under a stormy, overcast sky with dark clouds and hints "
|
| 72 |
+
"of distant sunlight breaking through. The horizon shows turbulent waves "
|
| 73 |
+
"colliding with rocky outcrops, creating white foam."
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
class SizePreset(Enum):
|
| 78 |
+
"""UI presets that jointly select image shape and attention gain."""
|
| 79 |
+
|
| 80 |
+
DEFAULT_PORTRAIT = "default_portrait"
|
| 81 |
+
WIDE = "wide"
|
| 82 |
+
TALL_PORTRAIT = "tall_portrait"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@dataclass(frozen=True)
|
| 86 |
+
class _SizePresetValues:
|
| 87 |
+
"""Concrete image controls selected by one size preset."""
|
| 88 |
+
|
| 89 |
+
width: int
|
| 90 |
+
height: int
|
| 91 |
+
self_attention_gain: float
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
_SIZE_PRESETS = {value.value: value for value in SizePreset}
|
| 95 |
+
_SIZE_PRESET_CHOICES = (
|
| 96 |
+
("832 × 1216 · gain −0.03", SizePreset.DEFAULT_PORTRAIT.value),
|
| 97 |
+
("1536 × 832 · gain −0.02", SizePreset.WIDE.value),
|
| 98 |
+
("1024 × 1536 · gain −0.02", SizePreset.TALL_PORTRAIT.value),
|
| 99 |
+
)
|
| 100 |
+
_SIZE_PRESET_VALUES = {
|
| 101 |
+
SizePreset.DEFAULT_PORTRAIT: _SizePresetValues(832, 1216, -0.03),
|
| 102 |
+
SizePreset.WIDE: _SizePresetValues(1536, 832, -0.02),
|
| 103 |
+
SizePreset.TALL_PORTRAIT: _SizePresetValues(1024, 1536, -0.02),
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
_DARK_HEAD = """
|
| 107 |
+
<script>
|
| 108 |
+
localStorage.setItem("theme", "dark");
|
| 109 |
+
document.documentElement.classList.add("dark");
|
| 110 |
+
document.documentElement.style.colorScheme = "dark";
|
| 111 |
+
const resizeCanterImages = () => {
|
| 112 |
+
const grid = document.querySelector("#canter-image-grid");
|
| 113 |
+
if (grid === null) {
|
| 114 |
+
return;
|
| 115 |
+
}
|
| 116 |
+
const margin = 16;
|
| 117 |
+
const documentTop = grid.getBoundingClientRect().top + window.scrollY;
|
| 118 |
+
const available = documentTop < window.innerHeight
|
| 119 |
+
? window.innerHeight - documentTop - margin
|
| 120 |
+
: window.innerHeight - 2 * margin;
|
| 121 |
+
grid.style.setProperty(
|
| 122 |
+
"--canter-image-max-height",
|
| 123 |
+
`${Math.max(available, 1)}px`
|
| 124 |
+
);
|
| 125 |
+
};
|
| 126 |
+
new MutationObserver(() => requestAnimationFrame(resizeCanterImages)).observe(
|
| 127 |
+
document.documentElement,
|
| 128 |
+
{childList: true, subtree: true}
|
| 129 |
+
);
|
| 130 |
+
window.addEventListener("resize", resizeCanterImages);
|
| 131 |
+
requestAnimationFrame(resizeCanterImages);
|
| 132 |
+
document.addEventListener("click", (event) => {
|
| 133 |
+
const target = event.target;
|
| 134 |
+
if (!(target instanceof Element)) {
|
| 135 |
+
return;
|
| 136 |
+
}
|
| 137 |
+
const image = target.closest(".canter-output-image img");
|
| 138 |
+
if (image === null) {
|
| 139 |
+
return;
|
| 140 |
+
}
|
| 141 |
+
const component = image.closest(".canter-output-image");
|
| 142 |
+
if (component === null) {
|
| 143 |
+
throw new Error("Canter image component is missing.");
|
| 144 |
+
}
|
| 145 |
+
const button = component.querySelector(
|
| 146 |
+
'button[aria-label="Fullscreen"], '
|
| 147 |
+
+ 'button[aria-label="Exit fullscreen mode"]'
|
| 148 |
+
);
|
| 149 |
+
if (button === null) {
|
| 150 |
+
throw new Error("Canter image fullscreen control is missing.");
|
| 151 |
+
}
|
| 152 |
+
event.preventDefault();
|
| 153 |
+
event.stopImmediatePropagation();
|
| 154 |
+
button.click();
|
| 155 |
+
});
|
| 156 |
+
</script>
|
| 157 |
+
"""
|
| 158 |
+
|
| 159 |
+
_CSS = """
|
| 160 |
+
:root, body {
|
| 161 |
+
background: #09090b;
|
| 162 |
+
}
|
| 163 |
+
.gradio-container {
|
| 164 |
+
max-width: none !important;
|
| 165 |
+
width: 100% !important;
|
| 166 |
+
padding: 1rem !important;
|
| 167 |
+
}
|
| 168 |
+
#canter-title h1 {
|
| 169 |
+
font-size: 2.1rem;
|
| 170 |
+
letter-spacing: -0.04em;
|
| 171 |
+
line-height: 1.05;
|
| 172 |
+
margin: 0;
|
| 173 |
+
}
|
| 174 |
+
#canter-header {
|
| 175 |
+
flex-wrap: nowrap !important;
|
| 176 |
+
align-items: center !important;
|
| 177 |
+
gap: 0.5rem !important;
|
| 178 |
+
}
|
| 179 |
+
#canter-title {
|
| 180 |
+
flex: 1 1 0 !important;
|
| 181 |
+
min-width: 0 !important;
|
| 182 |
+
overflow: visible !important;
|
| 183 |
+
}
|
| 184 |
+
.canter-header-action {
|
| 185 |
+
flex: 0 0 auto !important;
|
| 186 |
+
width: auto !important;
|
| 187 |
+
overflow: visible !important;
|
| 188 |
+
}
|
| 189 |
+
#canter-api-button {
|
| 190 |
+
margin-top: 0.75rem;
|
| 191 |
+
}
|
| 192 |
+
footer .show-api {
|
| 193 |
+
display: none !important;
|
| 194 |
+
}
|
| 195 |
+
#canter-workspace {
|
| 196 |
+
align-items: flex-start !important;
|
| 197 |
+
}
|
| 198 |
+
#canter-controls {
|
| 199 |
+
max-width: 440px;
|
| 200 |
+
min-width: 360px;
|
| 201 |
+
}
|
| 202 |
+
#canter-results {
|
| 203 |
+
min-width: 0;
|
| 204 |
+
}
|
| 205 |
+
#canter-image-grid {
|
| 206 |
+
display: grid !important;
|
| 207 |
+
grid-template-columns: repeat(
|
| 208 |
+
auto-fit,
|
| 209 |
+
minmax(min(100%, var(--canter-image-min-width, 640px)), 1fr)
|
| 210 |
+
);
|
| 211 |
+
align-items: start !important;
|
| 212 |
+
gap: 1rem !important;
|
| 213 |
+
height: auto !important;
|
| 214 |
+
max-height: none !important;
|
| 215 |
+
overflow: visible !important;
|
| 216 |
+
}
|
| 217 |
+
#canter-image-grid > div {
|
| 218 |
+
min-width: 0 !important;
|
| 219 |
+
}
|
| 220 |
+
.canter-image-cell {
|
| 221 |
+
position: relative;
|
| 222 |
+
}
|
| 223 |
+
.canter-total-progress {
|
| 224 |
+
margin-bottom: 0.75rem;
|
| 225 |
+
}
|
| 226 |
+
.canter-total-progress:not(:has(.canter-progress-panel)) {
|
| 227 |
+
display: none !important;
|
| 228 |
+
}
|
| 229 |
+
.canter-image-progress {
|
| 230 |
+
position: absolute !important;
|
| 231 |
+
top: 0.75rem;
|
| 232 |
+
left: 0.75rem;
|
| 233 |
+
right: 0.75rem;
|
| 234 |
+
z-index: 10;
|
| 235 |
+
pointer-events: none;
|
| 236 |
+
}
|
| 237 |
+
.canter-image-progress:not(:has(.canter-progress-panel)) {
|
| 238 |
+
display: none !important;
|
| 239 |
+
}
|
| 240 |
+
.canter-progress-panel {
|
| 241 |
+
padding: 0.65rem 0.75rem;
|
| 242 |
+
color: #f4f4f5;
|
| 243 |
+
background: rgb(9 9 11 / 88%);
|
| 244 |
+
border: 1px solid #3f3f46;
|
| 245 |
+
border-radius: 0.35rem;
|
| 246 |
+
box-shadow: 0 0.25rem 1rem rgb(0 0 0 / 35%);
|
| 247 |
+
backdrop-filter: blur(0.35rem);
|
| 248 |
+
}
|
| 249 |
+
.canter-progress-label {
|
| 250 |
+
margin-bottom: 0.4rem;
|
| 251 |
+
font-size: 0.85rem;
|
| 252 |
+
font-weight: 600;
|
| 253 |
+
}
|
| 254 |
+
.canter-progress-row {
|
| 255 |
+
display: grid;
|
| 256 |
+
grid-template-columns: 2.5rem minmax(0, 1fr) 2.5rem;
|
| 257 |
+
align-items: center;
|
| 258 |
+
gap: 0.45rem;
|
| 259 |
+
font-size: 0.72rem;
|
| 260 |
+
color: #d4d4d8;
|
| 261 |
+
}
|
| 262 |
+
.canter-progress-track {
|
| 263 |
+
height: 0.35rem;
|
| 264 |
+
overflow: hidden;
|
| 265 |
+
background: #27272a;
|
| 266 |
+
border-radius: 999px;
|
| 267 |
+
}
|
| 268 |
+
.canter-progress-track > div {
|
| 269 |
+
height: 100%;
|
| 270 |
+
background: var(--primary-500);
|
| 271 |
+
border-radius: inherit;
|
| 272 |
+
transition: width 120ms linear;
|
| 273 |
+
}
|
| 274 |
+
.canter-output-image,
|
| 275 |
+
.canter-output-image > div {
|
| 276 |
+
height: auto !important;
|
| 277 |
+
max-height: none !important;
|
| 278 |
+
overflow: visible !important;
|
| 279 |
+
}
|
| 280 |
+
.canter-output-image img {
|
| 281 |
+
display: block !important;
|
| 282 |
+
width: 100% !important;
|
| 283 |
+
max-width: 100% !important;
|
| 284 |
+
height: auto !important;
|
| 285 |
+
max-height: none !important;
|
| 286 |
+
object-fit: contain !important;
|
| 287 |
+
margin: 0 auto !important;
|
| 288 |
+
cursor: zoom-in;
|
| 289 |
+
}
|
| 290 |
+
.canter-output-image:not(
|
| 291 |
+
:has(button[aria-label="Exit fullscreen mode"])
|
| 292 |
+
) img {
|
| 293 |
+
max-height: var(
|
| 294 |
+
--canter-image-max-height,
|
| 295 |
+
calc(100dvh - 2rem)
|
| 296 |
+
) !important;
|
| 297 |
+
}
|
| 298 |
+
.canter-output-image:not(
|
| 299 |
+
:has(button[aria-label="Exit fullscreen mode"])
|
| 300 |
+
) > div > button > div {
|
| 301 |
+
width: min(
|
| 302 |
+
100%,
|
| 303 |
+
var(--canter-image-min-width, 640px)
|
| 304 |
+
) !important;
|
| 305 |
+
}
|
| 306 |
+
.canter-output-image:has(button[aria-label="Exit fullscreen mode"]) img {
|
| 307 |
+
cursor: zoom-out;
|
| 308 |
+
}
|
| 309 |
+
.canter-output-image:has(button[aria-label="Exit fullscreen mode"]) {
|
| 310 |
+
box-sizing: border-box !important;
|
| 311 |
+
height: 100dvh !important;
|
| 312 |
+
max-height: 100dvh !important;
|
| 313 |
+
overflow: auto !important;
|
| 314 |
+
padding: 8px !important;
|
| 315 |
+
}
|
| 316 |
+
#canter-status {
|
| 317 |
+
color: #a1a1aa;
|
| 318 |
+
}
|
| 319 |
+
@media (max-width: 900px) {
|
| 320 |
+
#canter-controls {
|
| 321 |
+
max-width: none;
|
| 322 |
+
min-width: 0;
|
| 323 |
+
}
|
| 324 |
+
}
|
| 325 |
+
"""
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
@dataclass(frozen=True)
|
| 329 |
+
class _BasicInputs:
|
| 330 |
+
"""Prompt and basic generation controls rendered above advanced settings."""
|
| 331 |
+
|
| 332 |
+
prompt: gr.Textbox
|
| 333 |
+
image_count: gr.Number
|
| 334 |
+
size_preset: gr.Dropdown
|
| 335 |
+
preview_enabled: gr.Checkbox
|
| 336 |
+
width: gr.Number
|
| 337 |
+
height: gr.Number
|
| 338 |
+
seed: gr.Number
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
@dataclass(frozen=True)
|
| 342 |
+
class _WebInputs:
|
| 343 |
+
"""Gradio components in the exact callback argument order."""
|
| 344 |
+
|
| 345 |
+
prompt: gr.Textbox
|
| 346 |
+
image_count: gr.Number
|
| 347 |
+
size_preset: gr.Dropdown
|
| 348 |
+
preview_enabled: gr.Checkbox
|
| 349 |
+
width: gr.Number
|
| 350 |
+
height: gr.Number
|
| 351 |
+
seed: gr.Number
|
| 352 |
+
steps: gr.Slider
|
| 353 |
+
solver: gr.Dropdown
|
| 354 |
+
schedule: gr.Dropdown
|
| 355 |
+
log_snr_shift: gr.Slider
|
| 356 |
+
self_attention_gain: gr.Slider
|
| 357 |
+
euler_maruyama_multiplier: gr.Slider
|
| 358 |
+
cfg_enabled: gr.Checkbox
|
| 359 |
+
cfg_scale: gr.Slider
|
| 360 |
+
cfg_start_step: gr.Number
|
| 361 |
+
cfg_stop_step: gr.Textbox
|
| 362 |
+
pdg_enabled: gr.Checkbox
|
| 363 |
+
pdg_mode: gr.Dropdown
|
| 364 |
+
pdg_curve: gr.Dropdown
|
| 365 |
+
pdg_noisy_scale: gr.Slider
|
| 366 |
+
pdg_clean_scale: gr.Slider
|
| 367 |
+
pdg_power: gr.Slider
|
| 368 |
+
pdg_start_step: gr.Number
|
| 369 |
+
pdg_stop_step: gr.Textbox
|
| 370 |
+
|
| 371 |
+
def ordered(self) -> tuple[gr.Component, ...]:
|
| 372 |
+
"""Return components in the order accepted by ``generate``."""
|
| 373 |
+
|
| 374 |
+
return (
|
| 375 |
+
self.prompt,
|
| 376 |
+
self.image_count,
|
| 377 |
+
self.width,
|
| 378 |
+
self.height,
|
| 379 |
+
self.seed,
|
| 380 |
+
self.steps,
|
| 381 |
+
self.solver,
|
| 382 |
+
self.schedule,
|
| 383 |
+
self.log_snr_shift,
|
| 384 |
+
self.self_attention_gain,
|
| 385 |
+
self.euler_maruyama_multiplier,
|
| 386 |
+
self.cfg_enabled,
|
| 387 |
+
self.cfg_scale,
|
| 388 |
+
self.cfg_start_step,
|
| 389 |
+
self.cfg_stop_step,
|
| 390 |
+
self.pdg_enabled,
|
| 391 |
+
self.pdg_mode,
|
| 392 |
+
self.pdg_curve,
|
| 393 |
+
self.pdg_noisy_scale,
|
| 394 |
+
self.pdg_clean_scale,
|
| 395 |
+
self.pdg_power,
|
| 396 |
+
self.pdg_start_step,
|
| 397 |
+
self.pdg_stop_step,
|
| 398 |
+
self.preview_enabled,
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
|
| 402 |
+
@dataclass(frozen=True)
|
| 403 |
+
class _WebOutputs:
|
| 404 |
+
"""Image slots and status components in callback output order."""
|
| 405 |
+
|
| 406 |
+
image_containers: tuple[gr.Column, ...]
|
| 407 |
+
images: tuple[gr.Image, ...]
|
| 408 |
+
grid_style: gr.HTML
|
| 409 |
+
model_status: gr.Markdown
|
| 410 |
+
status: gr.Markdown
|
| 411 |
+
progress_timer: gr.Timer
|
| 412 |
+
|
| 413 |
+
def ordered(self) -> tuple[gr.Component, ...]:
|
| 414 |
+
"""Return images, status, and progress-lifecycle callback outputs."""
|
| 415 |
+
|
| 416 |
+
return (*self.images, self.status, self.progress_timer)
|
| 417 |
+
|
| 418 |
+
|
| 419 |
+
@dataclass(frozen=True)
|
| 420 |
+
class _GenerationControls:
|
| 421 |
+
"""Buttons controlling generation, cancellation, randomization, and API help."""
|
| 422 |
+
|
| 423 |
+
generate: gr.Button
|
| 424 |
+
stop: gr.Button
|
| 425 |
+
randomize: gr.Button
|
| 426 |
+
api: gr.Button
|
| 427 |
+
|
| 428 |
+
|
| 429 |
+
def _prompt_input() -> gr.Textbox:
|
| 430 |
+
"""Create the prompt control without rendering it yet."""
|
| 431 |
+
|
| 432 |
+
return gr.Textbox(
|
| 433 |
+
label="Prompt",
|
| 434 |
+
value=_DEFAULT_PROMPT,
|
| 435 |
+
lines=3,
|
| 436 |
+
placeholder="Describe the image you want to generate.",
|
| 437 |
+
render=False,
|
| 438 |
+
)
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def _basic_inputs(prompt: gr.Textbox) -> _BasicInputs:
|
| 442 |
+
"""Render prompt, image-count, preset, shape, and seed controls."""
|
| 443 |
+
|
| 444 |
+
prompt.render()
|
| 445 |
+
image_count = gr.Number(
|
| 446 |
+
minimum=1,
|
| 447 |
+
maximum=_MAX_IMAGE_COUNT,
|
| 448 |
+
value=_DEFAULT_IMAGE_COUNT,
|
| 449 |
+
step=1,
|
| 450 |
+
precision=0,
|
| 451 |
+
label="Images",
|
| 452 |
+
info="Generated sequentially with batch size 1.",
|
| 453 |
+
)
|
| 454 |
+
size_preset = gr.Dropdown(
|
| 455 |
+
choices=_SIZE_PRESET_CHOICES,
|
| 456 |
+
value=SizePreset.DEFAULT_PORTRAIT.value,
|
| 457 |
+
label="Size preset",
|
| 458 |
+
allow_custom_value=False,
|
| 459 |
+
)
|
| 460 |
+
preview_enabled = gr.Checkbox(
|
| 461 |
+
value=True,
|
| 462 |
+
label="Preview generation",
|
| 463 |
+
)
|
| 464 |
+
with gr.Row():
|
| 465 |
+
width = gr.Number(
|
| 466 |
+
value=_DEFAULT_INFERENCE.width,
|
| 467 |
+
label="Width",
|
| 468 |
+
precision=0,
|
| 469 |
+
minimum=_IMAGE_SIZE_MULTIPLE,
|
| 470 |
+
step=_IMAGE_SIZE_MULTIPLE,
|
| 471 |
+
)
|
| 472 |
+
height = gr.Number(
|
| 473 |
+
value=_DEFAULT_INFERENCE.height,
|
| 474 |
+
label="Height",
|
| 475 |
+
precision=0,
|
| 476 |
+
minimum=_IMAGE_SIZE_MULTIPLE,
|
| 477 |
+
step=_IMAGE_SIZE_MULTIPLE,
|
| 478 |
+
)
|
| 479 |
+
seed = gr.Number(
|
| 480 |
+
value=_DEFAULT_INFERENCE.seed,
|
| 481 |
+
label="Seed",
|
| 482 |
+
precision=0,
|
| 483 |
+
minimum=0,
|
| 484 |
+
maximum=_MAX_SEED,
|
| 485 |
+
)
|
| 486 |
+
return _BasicInputs(
|
| 487 |
+
prompt=prompt,
|
| 488 |
+
image_count=image_count,
|
| 489 |
+
size_preset=size_preset,
|
| 490 |
+
preview_enabled=preview_enabled,
|
| 491 |
+
width=width,
|
| 492 |
+
height=height,
|
| 493 |
+
seed=seed,
|
| 494 |
+
)
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
def image_grid_visibility(
|
| 498 |
+
image_count: int | float,
|
| 499 |
+
) -> tuple[dict[str, object], ...]:
|
| 500 |
+
"""Show exactly the requested number of fixed image-grid containers."""
|
| 501 |
+
|
| 502 |
+
resolved_count = _integer(
|
| 503 |
+
image_count,
|
| 504 |
+
"image count",
|
| 505 |
+
minimum=1,
|
| 506 |
+
maximum=_MAX_IMAGE_COUNT,
|
| 507 |
+
)
|
| 508 |
+
return tuple(
|
| 509 |
+
gr.update(visible=index < resolved_count) for index in range(_MAX_IMAGE_COUNT)
|
| 510 |
+
)
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
def image_grid_style(width: int | float, height: int | float) -> str:
|
| 514 |
+
"""Return responsive grid CSS using the requested image width."""
|
| 515 |
+
|
| 516 |
+
resolved_width = _image_dimension(width, "width")
|
| 517 |
+
_image_dimension(height, "height")
|
| 518 |
+
return (
|
| 519 |
+
"<style>"
|
| 520 |
+
"#canter-image-grid {"
|
| 521 |
+
f"--canter-image-min-width: {resolved_width}px;"
|
| 522 |
+
"}"
|
| 523 |
+
"</style>"
|
| 524 |
+
)
|
| 525 |
+
|
| 526 |
+
|
| 527 |
+
def prepare_image_grid(
|
| 528 |
+
image_count: int | float,
|
| 529 |
+
width: int | float,
|
| 530 |
+
height: int | float,
|
| 531 |
+
) -> tuple[dict[str, object] | str, ...]:
|
| 532 |
+
"""Configure slot visibility and aspect-aware sizing before generation."""
|
| 533 |
+
|
| 534 |
+
return (
|
| 535 |
+
*image_grid_visibility(image_count),
|
| 536 |
+
image_grid_style(width, height),
|
| 537 |
+
)
|
| 538 |
+
|
| 539 |
+
|
| 540 |
+
def apply_size_preset(value: str | None) -> tuple[int, int, float]:
|
| 541 |
+
"""Return width, height, and attention gain for one exact UI preset."""
|
| 542 |
+
|
| 543 |
+
preset = _choice(value, _SIZE_PRESETS, "size preset")
|
| 544 |
+
selected = _SIZE_PRESET_VALUES[preset]
|
| 545 |
+
return selected.width, selected.height, selected.self_attention_gain
|
| 546 |
+
|
| 547 |
+
|
| 548 |
+
def schedule_log_snr_shift(value: str | None) -> float:
|
| 549 |
+
"""Return the UI log-SNR preset associated with one schedule."""
|
| 550 |
+
|
| 551 |
+
schedule = _choice(value, _SCHEDULES, "schedule")
|
| 552 |
+
match schedule:
|
| 553 |
+
case Schedule.BETA:
|
| 554 |
+
return 0.0
|
| 555 |
+
case Schedule.LINEAR:
|
| 556 |
+
return -2.3
|
| 557 |
+
case _ as unreachable:
|
| 558 |
+
raise RuntimeError(f"Unsupported Canter schedule: {unreachable}")
|
| 559 |
+
|
| 560 |
+
|
| 561 |
+
def _solver_inputs() -> tuple[
|
| 562 |
+
gr.Slider,
|
| 563 |
+
gr.Dropdown,
|
| 564 |
+
gr.Dropdown,
|
| 565 |
+
gr.Slider,
|
| 566 |
+
gr.Slider,
|
| 567 |
+
gr.Slider,
|
| 568 |
+
]:
|
| 569 |
+
"""Create solver, schedule, and model-gain controls."""
|
| 570 |
+
|
| 571 |
+
steps = gr.Slider(
|
| 572 |
+
minimum=1,
|
| 573 |
+
maximum=100,
|
| 574 |
+
value=_DEFAULT_INFERENCE.steps,
|
| 575 |
+
step=1,
|
| 576 |
+
precision=0,
|
| 577 |
+
label="Solver updates",
|
| 578 |
+
)
|
| 579 |
+
with gr.Row():
|
| 580 |
+
solver = gr.Dropdown(
|
| 581 |
+
choices=_SOLVER_CHOICES,
|
| 582 |
+
value=_DEFAULT_INFERENCE.solver.value,
|
| 583 |
+
label="Solver",
|
| 584 |
+
allow_custom_value=False,
|
| 585 |
+
)
|
| 586 |
+
schedule = gr.Dropdown(
|
| 587 |
+
choices=_SCHEDULE_CHOICES,
|
| 588 |
+
value=_DEFAULT_INFERENCE.schedule.value,
|
| 589 |
+
label="Schedule",
|
| 590 |
+
allow_custom_value=False,
|
| 591 |
+
)
|
| 592 |
+
log_snr_shift = gr.Slider(
|
| 593 |
+
minimum=-4.0,
|
| 594 |
+
maximum=4.0,
|
| 595 |
+
value=_DEFAULT_INFERENCE.log_snr_shift,
|
| 596 |
+
step=0.05,
|
| 597 |
+
label="Log-SNR shift",
|
| 598 |
+
)
|
| 599 |
+
self_attention_gain = gr.Slider(
|
| 600 |
+
minimum=-0.05,
|
| 601 |
+
maximum=0.02,
|
| 602 |
+
value=_DEFAULT_INFERENCE.self_attention_gain,
|
| 603 |
+
step=0.005,
|
| 604 |
+
label="Main self-attention gain",
|
| 605 |
+
)
|
| 606 |
+
euler_maruyama_multiplier = gr.Slider(
|
| 607 |
+
minimum=0.0,
|
| 608 |
+
maximum=2.0,
|
| 609 |
+
value=_DEFAULT_INFERENCE.euler_maruyama_multiplier,
|
| 610 |
+
step=0.05,
|
| 611 |
+
label="SDE noise multiplier",
|
| 612 |
+
)
|
| 613 |
+
return (
|
| 614 |
+
steps,
|
| 615 |
+
solver,
|
| 616 |
+
schedule,
|
| 617 |
+
log_snr_shift,
|
| 618 |
+
self_attention_gain,
|
| 619 |
+
euler_maruyama_multiplier,
|
| 620 |
+
)
|
| 621 |
+
|
| 622 |
+
|
| 623 |
+
def _cfg_inputs() -> tuple[
|
| 624 |
+
gr.Checkbox,
|
| 625 |
+
gr.Slider,
|
| 626 |
+
gr.Number,
|
| 627 |
+
gr.Textbox,
|
| 628 |
+
]:
|
| 629 |
+
"""Create classifier-free guidance controls."""
|
| 630 |
+
|
| 631 |
+
enabled = gr.Checkbox(
|
| 632 |
+
value=_DEFAULT_INFERENCE.cfg.enabled,
|
| 633 |
+
label="Enable CFG",
|
| 634 |
+
)
|
| 635 |
+
scale = gr.Slider(
|
| 636 |
+
minimum=0.0,
|
| 637 |
+
maximum=10.0,
|
| 638 |
+
value=3.0,
|
| 639 |
+
step=0.05,
|
| 640 |
+
label="CFG scale",
|
| 641 |
+
info="Also used by CFG-dependent PDG modes.",
|
| 642 |
+
)
|
| 643 |
+
with gr.Row():
|
| 644 |
+
start = gr.Number(
|
| 645 |
+
value=_DEFAULT_INFERENCE.cfg.start_step,
|
| 646 |
+
label="CFG start",
|
| 647 |
+
precision=0,
|
| 648 |
+
minimum=0,
|
| 649 |
+
)
|
| 650 |
+
stop = gr.Textbox(
|
| 651 |
+
value="",
|
| 652 |
+
label="CFG stop",
|
| 653 |
+
info="Blank means the final solver update.",
|
| 654 |
+
placeholder="Final",
|
| 655 |
+
lines=1,
|
| 656 |
+
)
|
| 657 |
+
return enabled, scale, start, stop
|
| 658 |
+
|
| 659 |
+
|
| 660 |
+
def _pdg_inputs() -> tuple[
|
| 661 |
+
gr.Checkbox,
|
| 662 |
+
gr.Dropdown,
|
| 663 |
+
gr.Dropdown,
|
| 664 |
+
gr.Slider,
|
| 665 |
+
gr.Slider,
|
| 666 |
+
gr.Slider,
|
| 667 |
+
gr.Number,
|
| 668 |
+
gr.Textbox,
|
| 669 |
+
]:
|
| 670 |
+
"""Create path-drop guidance and curve controls."""
|
| 671 |
+
|
| 672 |
+
enabled = gr.Checkbox(
|
| 673 |
+
value=_DEFAULT_INFERENCE.pdg.enabled,
|
| 674 |
+
label="Enable PDG",
|
| 675 |
+
)
|
| 676 |
+
with gr.Row():
|
| 677 |
+
mode = gr.Dropdown(
|
| 678 |
+
choices=_PDG_MODE_CHOICES,
|
| 679 |
+
value=_DEFAULT_INFERENCE.pdg.mode.value,
|
| 680 |
+
label="PDG mode",
|
| 681 |
+
allow_custom_value=False,
|
| 682 |
+
)
|
| 683 |
+
curve = gr.Dropdown(
|
| 684 |
+
choices=_PDG_CURVE_CHOICES,
|
| 685 |
+
value=_DEFAULT_INFERENCE.pdg.curve.value,
|
| 686 |
+
label="PDG curve",
|
| 687 |
+
allow_custom_value=False,
|
| 688 |
+
)
|
| 689 |
+
noisy_scale = gr.Slider(
|
| 690 |
+
minimum=0.0,
|
| 691 |
+
maximum=10.0,
|
| 692 |
+
value=_DEFAULT_INFERENCE.pdg.noisy_scale,
|
| 693 |
+
step=0.05,
|
| 694 |
+
label="PDG scale / noisy endpoint",
|
| 695 |
+
)
|
| 696 |
+
clean_scale = gr.Slider(
|
| 697 |
+
minimum=0.0,
|
| 698 |
+
maximum=10.0,
|
| 699 |
+
value=_DEFAULT_INFERENCE.pdg.clean_scale,
|
| 700 |
+
step=0.05,
|
| 701 |
+
label="PDG clean endpoint",
|
| 702 |
+
info="Endpoint used by linear and power curves.",
|
| 703 |
+
interactive=_DEFAULT_INFERENCE.pdg.curve is not PdgCurve.CONSTANT,
|
| 704 |
+
)
|
| 705 |
+
power = gr.Slider(
|
| 706 |
+
minimum=0.05,
|
| 707 |
+
maximum=10.0,
|
| 708 |
+
value=_DEFAULT_INFERENCE.pdg.power,
|
| 709 |
+
step=0.05,
|
| 710 |
+
label="PDG power",
|
| 711 |
+
)
|
| 712 |
+
with gr.Row():
|
| 713 |
+
start = gr.Number(
|
| 714 |
+
value=_DEFAULT_INFERENCE.pdg.start_step,
|
| 715 |
+
label="PDG start",
|
| 716 |
+
precision=0,
|
| 717 |
+
minimum=0,
|
| 718 |
+
)
|
| 719 |
+
stop = gr.Textbox(
|
| 720 |
+
value="",
|
| 721 |
+
label="PDG stop",
|
| 722 |
+
info="Blank means the final solver update.",
|
| 723 |
+
placeholder="Final",
|
| 724 |
+
lines=1,
|
| 725 |
+
)
|
| 726 |
+
return enabled, mode, curve, noisy_scale, clean_scale, power, start, stop
|
| 727 |
+
|
| 728 |
+
|
| 729 |
+
def _create_inputs(
|
| 730 |
+
basic: _BasicInputs,
|
| 731 |
+
) -> _WebInputs:
|
| 732 |
+
"""Create advanced controls around an existing basic control group."""
|
| 733 |
+
|
| 734 |
+
gr.Markdown("### Solver")
|
| 735 |
+
(
|
| 736 |
+
steps,
|
| 737 |
+
solver,
|
| 738 |
+
schedule,
|
| 739 |
+
log_snr_shift,
|
| 740 |
+
self_attention_gain,
|
| 741 |
+
euler_maruyama_multiplier,
|
| 742 |
+
) = _solver_inputs()
|
| 743 |
+
gr.Markdown("### Path-drop guidance")
|
| 744 |
+
(
|
| 745 |
+
pdg_enabled,
|
| 746 |
+
pdg_mode,
|
| 747 |
+
pdg_curve,
|
| 748 |
+
pdg_noisy_scale,
|
| 749 |
+
pdg_clean_scale,
|
| 750 |
+
pdg_power,
|
| 751 |
+
pdg_start,
|
| 752 |
+
pdg_stop,
|
| 753 |
+
) = _pdg_inputs()
|
| 754 |
+
gr.Markdown("### Classifier-free guidance")
|
| 755 |
+
cfg_enabled, cfg_scale, cfg_start, cfg_stop = _cfg_inputs()
|
| 756 |
+
return _WebInputs(
|
| 757 |
+
prompt=basic.prompt,
|
| 758 |
+
image_count=basic.image_count,
|
| 759 |
+
size_preset=basic.size_preset,
|
| 760 |
+
preview_enabled=basic.preview_enabled,
|
| 761 |
+
width=basic.width,
|
| 762 |
+
height=basic.height,
|
| 763 |
+
seed=basic.seed,
|
| 764 |
+
steps=steps,
|
| 765 |
+
solver=solver,
|
| 766 |
+
schedule=schedule,
|
| 767 |
+
log_snr_shift=log_snr_shift,
|
| 768 |
+
self_attention_gain=self_attention_gain,
|
| 769 |
+
euler_maruyama_multiplier=euler_maruyama_multiplier,
|
| 770 |
+
cfg_enabled=cfg_enabled,
|
| 771 |
+
cfg_scale=cfg_scale,
|
| 772 |
+
cfg_start_step=cfg_start,
|
| 773 |
+
cfg_stop_step=cfg_stop,
|
| 774 |
+
pdg_enabled=pdg_enabled,
|
| 775 |
+
pdg_mode=pdg_mode,
|
| 776 |
+
pdg_curve=pdg_curve,
|
| 777 |
+
pdg_noisy_scale=pdg_noisy_scale,
|
| 778 |
+
pdg_clean_scale=pdg_clean_scale,
|
| 779 |
+
pdg_power=pdg_power,
|
| 780 |
+
pdg_start_step=pdg_start,
|
| 781 |
+
pdg_stop_step=pdg_stop,
|
| 782 |
+
)
|
| 783 |
+
|
| 784 |
+
|
| 785 |
+
def _connect_backend_unload(
|
| 786 |
+
controller: CanterWebController,
|
| 787 |
+
unload: gr.Button,
|
| 788 |
+
outputs: _WebOutputs,
|
| 789 |
+
) -> None:
|
| 790 |
+
"""Connect one-click global cancellation and backend cleanup."""
|
| 791 |
+
|
| 792 |
+
def begin_unload() -> tuple[_GenerationOutputUpdate, ...]:
|
| 793 |
+
"""Stop active work and clear browser image slots immediately."""
|
| 794 |
+
|
| 795 |
+
status = controller.request_backend_unload()
|
| 796 |
+
return (
|
| 797 |
+
*_cleared_image_slots(),
|
| 798 |
+
status,
|
| 799 |
+
gr.update(interactive=False),
|
| 800 |
+
)
|
| 801 |
+
|
| 802 |
+
def finish_unload() -> tuple[str, str, dict[str, object]]:
|
| 803 |
+
"""Release model resources while retaining generated temporary files."""
|
| 804 |
+
|
| 805 |
+
status = controller.unload_backend()
|
| 806 |
+
return (
|
| 807 |
+
status,
|
| 808 |
+
controller.model_status(),
|
| 809 |
+
gr.update(value="Unload model", interactive=True),
|
| 810 |
+
)
|
| 811 |
+
|
| 812 |
+
def unload_failed() -> tuple[str, dict[str, object]]:
|
| 813 |
+
"""Keep generation blocked while allowing explicit cleanup retry."""
|
| 814 |
+
|
| 815 |
+
return (
|
| 816 |
+
"Unload failed. Retry cleanup or restart the server; generation "
|
| 817 |
+
"remains blocked.",
|
| 818 |
+
gr.update(value="Retry unload", interactive=True),
|
| 819 |
+
)
|
| 820 |
+
|
| 821 |
+
begin_event = unload.click(
|
| 822 |
+
fn=begin_unload,
|
| 823 |
+
inputs=None,
|
| 824 |
+
outputs=(
|
| 825 |
+
*outputs.images,
|
| 826 |
+
outputs.status,
|
| 827 |
+
unload,
|
| 828 |
+
),
|
| 829 |
+
queue=False,
|
| 830 |
+
api_name=None,
|
| 831 |
+
api_visibility="private",
|
| 832 |
+
)
|
| 833 |
+
finish_event = begin_event.then(
|
| 834 |
+
fn=finish_unload,
|
| 835 |
+
inputs=None,
|
| 836 |
+
outputs=(
|
| 837 |
+
outputs.status,
|
| 838 |
+
outputs.model_status,
|
| 839 |
+
unload,
|
| 840 |
+
),
|
| 841 |
+
concurrency_limit=1,
|
| 842 |
+
concurrency_id=_GPU_CONCURRENCY_ID,
|
| 843 |
+
api_name=None,
|
| 844 |
+
api_visibility="private",
|
| 845 |
+
)
|
| 846 |
+
finish_event.failure(
|
| 847 |
+
fn=unload_failed,
|
| 848 |
+
inputs=None,
|
| 849 |
+
outputs=(outputs.status, unload),
|
| 850 |
+
queue=False,
|
| 851 |
+
api_name=None,
|
| 852 |
+
api_visibility="private",
|
| 853 |
+
)
|
| 854 |
+
|
| 855 |
+
|
| 856 |
+
def _connect_generation(
|
| 857 |
+
controller: CanterWebController,
|
| 858 |
+
inputs: _WebInputs,
|
| 859 |
+
generate: gr.Button,
|
| 860 |
+
stop: gr.Button,
|
| 861 |
+
outputs: _WebOutputs,
|
| 862 |
+
) -> None:
|
| 863 |
+
"""Connect button and prompt submission to the serial GPU queue."""
|
| 864 |
+
|
| 865 |
+
generate_event = generate.click(
|
| 866 |
+
fn=prepare_image_grid,
|
| 867 |
+
inputs=(inputs.image_count, inputs.width, inputs.height),
|
| 868 |
+
outputs=(*outputs.image_containers, outputs.grid_style),
|
| 869 |
+
queue=False,
|
| 870 |
+
api_name=None,
|
| 871 |
+
api_visibility="private",
|
| 872 |
+
)
|
| 873 |
+
generation = generate_event.then(
|
| 874 |
+
fn=controller.generate,
|
| 875 |
+
inputs=inputs.ordered(),
|
| 876 |
+
outputs=outputs.ordered(),
|
| 877 |
+
api_name="generate",
|
| 878 |
+
api_description="Generate images with the loaded Canter release.",
|
| 879 |
+
show_progress="hidden",
|
| 880 |
+
concurrency_limit=1,
|
| 881 |
+
concurrency_id=_GPU_CONCURRENCY_ID,
|
| 882 |
+
)
|
| 883 |
+
generation.then(
|
| 884 |
+
fn=controller.model_status,
|
| 885 |
+
inputs=None,
|
| 886 |
+
outputs=outputs.model_status,
|
| 887 |
+
queue=False,
|
| 888 |
+
api_name=None,
|
| 889 |
+
api_visibility="private",
|
| 890 |
+
)
|
| 891 |
+
prompt_event = inputs.prompt.submit(
|
| 892 |
+
fn=prepare_image_grid,
|
| 893 |
+
inputs=(inputs.image_count, inputs.width, inputs.height),
|
| 894 |
+
outputs=(*outputs.image_containers, outputs.grid_style),
|
| 895 |
+
queue=False,
|
| 896 |
+
api_name=None,
|
| 897 |
+
api_visibility="private",
|
| 898 |
+
)
|
| 899 |
+
prompt_generation = prompt_event.then(
|
| 900 |
+
fn=controller.generate,
|
| 901 |
+
inputs=inputs.ordered(),
|
| 902 |
+
outputs=outputs.ordered(),
|
| 903 |
+
api_name=None,
|
| 904 |
+
show_progress="hidden",
|
| 905 |
+
concurrency_limit=1,
|
| 906 |
+
concurrency_id=_GPU_CONCURRENCY_ID,
|
| 907 |
+
api_visibility="private",
|
| 908 |
+
)
|
| 909 |
+
prompt_generation.then(
|
| 910 |
+
fn=controller.model_status,
|
| 911 |
+
inputs=None,
|
| 912 |
+
outputs=outputs.model_status,
|
| 913 |
+
queue=False,
|
| 914 |
+
api_name=None,
|
| 915 |
+
api_visibility="private",
|
| 916 |
+
)
|
| 917 |
+
stop.click(
|
| 918 |
+
fn=controller.request_stop,
|
| 919 |
+
inputs=None,
|
| 920 |
+
outputs=outputs.status,
|
| 921 |
+
queue=False,
|
| 922 |
+
api_name=None,
|
| 923 |
+
api_visibility="private",
|
| 924 |
+
)
|
| 925 |
+
|
| 926 |
+
|
| 927 |
+
def _create_header(
|
| 928 |
+
*,
|
| 929 |
+
model_status_text: str,
|
| 930 |
+
unload_supported: bool,
|
| 931 |
+
ready: bool,
|
| 932 |
+
) -> tuple[gr.Button, gr.Markdown]:
|
| 933 |
+
"""Create the title, one-click unload control, and model status."""
|
| 934 |
+
|
| 935 |
+
with gr.Row(elem_id="canter-header"):
|
| 936 |
+
gr.Markdown(
|
| 937 |
+
"# Canter",
|
| 938 |
+
elem_id="canter-title",
|
| 939 |
+
scale=1,
|
| 940 |
+
min_width=200,
|
| 941 |
+
)
|
| 942 |
+
unload = gr.Button(
|
| 943 |
+
"Unload model",
|
| 944 |
+
variant="secondary",
|
| 945 |
+
size="sm",
|
| 946 |
+
scale=0,
|
| 947 |
+
min_width=104,
|
| 948 |
+
visible=unload_supported,
|
| 949 |
+
interactive=ready,
|
| 950 |
+
elem_classes="canter-header-action",
|
| 951 |
+
)
|
| 952 |
+
model_status = gr.Markdown(model_status_text, elem_id="canter-model-status")
|
| 953 |
+
return unload, model_status
|
| 954 |
+
|
| 955 |
+
|
| 956 |
+
def _create_result_outputs(
|
| 957 |
+
controller: CanterWebController,
|
| 958 |
+
model_status: gr.Markdown,
|
| 959 |
+
*,
|
| 960 |
+
ready: bool,
|
| 961 |
+
) -> _WebOutputs:
|
| 962 |
+
"""Create responsive image slots and their per-session progress polling."""
|
| 963 |
+
|
| 964 |
+
status = gr.Markdown(
|
| 965 |
+
(
|
| 966 |
+
"Ready. The first request includes compiled graph warm-up."
|
| 967 |
+
if ready
|
| 968 |
+
else "Loading Canter weights and compiling the selected backend…"
|
| 969 |
+
),
|
| 970 |
+
elem_id="canter-status",
|
| 971 |
+
)
|
| 972 |
+
total_progress = gr.HTML(
|
| 973 |
+
"",
|
| 974 |
+
container=False,
|
| 975 |
+
elem_classes="canter-total-progress",
|
| 976 |
+
)
|
| 977 |
+
grid_style = gr.HTML(
|
| 978 |
+
image_grid_style(
|
| 979 |
+
_DEFAULT_INFERENCE.width,
|
| 980 |
+
_DEFAULT_INFERENCE.height,
|
| 981 |
+
),
|
| 982 |
+
container=False,
|
| 983 |
+
)
|
| 984 |
+
progress_outputs: list[gr.HTML] = []
|
| 985 |
+
image_outputs: list[gr.Image] = []
|
| 986 |
+
image_containers: list[gr.Column] = []
|
| 987 |
+
with gr.Row(elem_id="canter-image-grid"):
|
| 988 |
+
for index in range(_MAX_IMAGE_COUNT):
|
| 989 |
+
with gr.Column(
|
| 990 |
+
min_width=0,
|
| 991 |
+
visible=index < _DEFAULT_IMAGE_COUNT,
|
| 992 |
+
elem_classes="canter-image-cell",
|
| 993 |
+
) as image_container:
|
| 994 |
+
image_containers.append(image_container)
|
| 995 |
+
progress_outputs.append(
|
| 996 |
+
gr.HTML(
|
| 997 |
+
"",
|
| 998 |
+
container=False,
|
| 999 |
+
elem_classes="canter-image-progress",
|
| 1000 |
+
)
|
| 1001 |
+
)
|
| 1002 |
+
image_outputs.append(
|
| 1003 |
+
gr.Image(
|
| 1004 |
+
label=f"Image {index + 1}",
|
| 1005 |
+
format="png",
|
| 1006 |
+
height=None,
|
| 1007 |
+
width=None,
|
| 1008 |
+
type="pil",
|
| 1009 |
+
show_label=False,
|
| 1010 |
+
buttons=["download", "fullscreen"],
|
| 1011 |
+
container=False,
|
| 1012 |
+
interactive=False,
|
| 1013 |
+
elem_classes="canter-output-image",
|
| 1014 |
+
)
|
| 1015 |
+
)
|
| 1016 |
+
progress_timer = gr.Timer(value=0.2, active=False)
|
| 1017 |
+
outputs = _WebOutputs(
|
| 1018 |
+
image_containers=tuple(image_containers),
|
| 1019 |
+
images=tuple(image_outputs),
|
| 1020 |
+
grid_style=grid_style,
|
| 1021 |
+
model_status=model_status,
|
| 1022 |
+
status=status,
|
| 1023 |
+
progress_timer=progress_timer,
|
| 1024 |
+
)
|
| 1025 |
+
progress_timer.tick(
|
| 1026 |
+
fn=controller.poll_progress,
|
| 1027 |
+
inputs=None,
|
| 1028 |
+
outputs=(
|
| 1029 |
+
*image_outputs,
|
| 1030 |
+
total_progress,
|
| 1031 |
+
*progress_outputs,
|
| 1032 |
+
progress_timer,
|
| 1033 |
+
),
|
| 1034 |
+
queue=False,
|
| 1035 |
+
show_progress="hidden",
|
| 1036 |
+
api_name=None,
|
| 1037 |
+
api_visibility="private",
|
| 1038 |
+
)
|
| 1039 |
+
return outputs
|
| 1040 |
+
|
| 1041 |
+
|
| 1042 |
+
def _create_workspace(
|
| 1043 |
+
controller: CanterWebController,
|
| 1044 |
+
model_status: gr.Markdown,
|
| 1045 |
+
*,
|
| 1046 |
+
ready: bool,
|
| 1047 |
+
) -> tuple[_WebInputs, _GenerationControls, _WebOutputs]:
|
| 1048 |
+
"""Create the controls column and responsive results column."""
|
| 1049 |
+
|
| 1050 |
+
with gr.Row(elem_id="canter-workspace"):
|
| 1051 |
+
with gr.Column(scale=3, min_width=360, elem_id="canter-controls"):
|
| 1052 |
+
prompt = _prompt_input()
|
| 1053 |
+
basic = _basic_inputs(prompt)
|
| 1054 |
+
with gr.Row():
|
| 1055 |
+
generate = gr.Button(
|
| 1056 |
+
"Generate",
|
| 1057 |
+
variant="primary",
|
| 1058 |
+
scale=3,
|
| 1059 |
+
interactive=ready,
|
| 1060 |
+
)
|
| 1061 |
+
stop = gr.Button(
|
| 1062 |
+
"Stop",
|
| 1063 |
+
variant="stop",
|
| 1064 |
+
scale=1,
|
| 1065 |
+
interactive=ready,
|
| 1066 |
+
)
|
| 1067 |
+
randomize = gr.Button("Random seed", variant="secondary", scale=1)
|
| 1068 |
+
inputs = _create_inputs(basic)
|
| 1069 |
+
api = gr.Button(
|
| 1070 |
+
"Use via API",
|
| 1071 |
+
variant="secondary",
|
| 1072 |
+
elem_id="canter-api-button",
|
| 1073 |
+
)
|
| 1074 |
+
with gr.Column(scale=8, min_width=640, elem_id="canter-results"):
|
| 1075 |
+
outputs = _create_result_outputs(
|
| 1076 |
+
controller,
|
| 1077 |
+
model_status,
|
| 1078 |
+
ready=ready,
|
| 1079 |
+
)
|
| 1080 |
+
controls = _GenerationControls(
|
| 1081 |
+
generate=generate,
|
| 1082 |
+
stop=stop,
|
| 1083 |
+
randomize=randomize,
|
| 1084 |
+
api=api,
|
| 1085 |
+
)
|
| 1086 |
+
return inputs, controls, outputs
|
| 1087 |
+
|
| 1088 |
+
|
| 1089 |
+
def _connect_control_callbacks(
|
| 1090 |
+
inputs: _WebInputs,
|
| 1091 |
+
controls: _GenerationControls,
|
| 1092 |
+
outputs: _WebOutputs,
|
| 1093 |
+
) -> None:
|
| 1094 |
+
"""Connect form-only callbacks that do not invoke the model."""
|
| 1095 |
+
|
| 1096 |
+
controls.randomize.click(
|
| 1097 |
+
fn=random_seed,
|
| 1098 |
+
inputs=None,
|
| 1099 |
+
outputs=inputs.seed,
|
| 1100 |
+
queue=False,
|
| 1101 |
+
api_name=None,
|
| 1102 |
+
api_visibility="private",
|
| 1103 |
+
)
|
| 1104 |
+
size_preset_event = inputs.size_preset.change(
|
| 1105 |
+
fn=apply_size_preset,
|
| 1106 |
+
inputs=inputs.size_preset,
|
| 1107 |
+
outputs=(
|
| 1108 |
+
inputs.width,
|
| 1109 |
+
inputs.height,
|
| 1110 |
+
inputs.self_attention_gain,
|
| 1111 |
+
),
|
| 1112 |
+
queue=False,
|
| 1113 |
+
api_name=None,
|
| 1114 |
+
api_visibility="private",
|
| 1115 |
+
)
|
| 1116 |
+
size_preset_event.then(
|
| 1117 |
+
fn=image_grid_style,
|
| 1118 |
+
inputs=(inputs.width, inputs.height),
|
| 1119 |
+
outputs=outputs.grid_style,
|
| 1120 |
+
queue=False,
|
| 1121 |
+
api_name=None,
|
| 1122 |
+
api_visibility="private",
|
| 1123 |
+
)
|
| 1124 |
+
inputs.schedule.change(
|
| 1125 |
+
fn=schedule_log_snr_shift,
|
| 1126 |
+
inputs=inputs.schedule,
|
| 1127 |
+
outputs=inputs.log_snr_shift,
|
| 1128 |
+
queue=False,
|
| 1129 |
+
api_name=None,
|
| 1130 |
+
api_visibility="private",
|
| 1131 |
+
)
|
| 1132 |
+
for dimension in (inputs.width, inputs.height):
|
| 1133 |
+
dimension_event = dimension.blur(
|
| 1134 |
+
fn=snap_image_dimension,
|
| 1135 |
+
inputs=dimension,
|
| 1136 |
+
outputs=dimension,
|
| 1137 |
+
queue=False,
|
| 1138 |
+
api_name=None,
|
| 1139 |
+
api_visibility="private",
|
| 1140 |
+
)
|
| 1141 |
+
dimension_event.then(
|
| 1142 |
+
fn=image_grid_style,
|
| 1143 |
+
inputs=(inputs.width, inputs.height),
|
| 1144 |
+
outputs=outputs.grid_style,
|
| 1145 |
+
queue=False,
|
| 1146 |
+
api_name=None,
|
| 1147 |
+
api_visibility="private",
|
| 1148 |
+
)
|
| 1149 |
+
inputs.pdg_curve.change(
|
| 1150 |
+
fn=update_pdg_clean_scale,
|
| 1151 |
+
inputs=(inputs.pdg_curve, inputs.pdg_noisy_scale),
|
| 1152 |
+
outputs=inputs.pdg_clean_scale,
|
| 1153 |
+
queue=False,
|
| 1154 |
+
api_name=None,
|
| 1155 |
+
api_visibility="private",
|
| 1156 |
+
)
|
| 1157 |
+
controls.api.click(
|
| 1158 |
+
fn=None,
|
| 1159 |
+
inputs=None,
|
| 1160 |
+
outputs=None,
|
| 1161 |
+
queue=False,
|
| 1162 |
+
js=(
|
| 1163 |
+
"() => {"
|
| 1164 |
+
"const button = document.querySelector('footer button.show-api');"
|
| 1165 |
+
"if (button === null) {"
|
| 1166 |
+
"throw new Error('Gradio API footer control is missing.');"
|
| 1167 |
+
"}"
|
| 1168 |
+
"button.click();"
|
| 1169 |
+
"}"
|
| 1170 |
+
),
|
| 1171 |
+
api_name=None,
|
| 1172 |
+
api_visibility="private",
|
| 1173 |
+
)
|
| 1174 |
+
|
| 1175 |
+
|
| 1176 |
+
def _create_web_app(
|
| 1177 |
+
controller: CanterWebController,
|
| 1178 |
+
*,
|
| 1179 |
+
model_status_text: str,
|
| 1180 |
+
ready: bool,
|
| 1181 |
+
load_config: CanterWebLaunchConfig | None,
|
| 1182 |
+
) -> gr.Blocks:
|
| 1183 |
+
"""Build the UI for either an already-loaded or lazy process pipeline."""
|
| 1184 |
+
|
| 1185 |
+
with gr.Blocks(title="Canter", fill_width=True) as application:
|
| 1186 |
+
unload, model_status = _create_header(
|
| 1187 |
+
model_status_text=model_status_text,
|
| 1188 |
+
unload_supported=load_config is not None,
|
| 1189 |
+
ready=ready,
|
| 1190 |
+
)
|
| 1191 |
+
inputs, controls, outputs = _create_workspace(
|
| 1192 |
+
controller,
|
| 1193 |
+
model_status,
|
| 1194 |
+
ready=ready,
|
| 1195 |
+
)
|
| 1196 |
+
_connect_control_callbacks(inputs, controls, outputs)
|
| 1197 |
+
_connect_generation(
|
| 1198 |
+
controller,
|
| 1199 |
+
inputs,
|
| 1200 |
+
controls.generate,
|
| 1201 |
+
controls.stop,
|
| 1202 |
+
outputs,
|
| 1203 |
+
)
|
| 1204 |
+
if load_config is not None:
|
| 1205 |
+
_connect_backend_unload(controller, unload, outputs)
|
| 1206 |
+
application.load(
|
| 1207 |
+
fn=controller.load,
|
| 1208 |
+
inputs=None,
|
| 1209 |
+
outputs=(
|
| 1210 |
+
model_status,
|
| 1211 |
+
outputs.status,
|
| 1212 |
+
controls.generate,
|
| 1213 |
+
controls.stop,
|
| 1214 |
+
unload,
|
| 1215 |
+
),
|
| 1216 |
+
show_progress="minimal",
|
| 1217 |
+
show_progress_on=outputs.status,
|
| 1218 |
+
concurrency_limit=1,
|
| 1219 |
+
concurrency_id=_GPU_CONCURRENCY_ID,
|
| 1220 |
+
api_name=None,
|
| 1221 |
+
api_visibility="private",
|
| 1222 |
+
)
|
| 1223 |
+
application.queue(
|
| 1224 |
+
max_size=_MAX_QUEUE_SIZE,
|
| 1225 |
+
default_concurrency_limit=1,
|
| 1226 |
+
api_open=False,
|
| 1227 |
+
)
|
| 1228 |
+
return application
|
| 1229 |
+
|
| 1230 |
+
|
| 1231 |
+
def create_web_app(pipeline: _ImagePipeline) -> gr.Blocks:
|
| 1232 |
+
"""Build the bundled application around one already-loaded pipeline."""
|
| 1233 |
+
|
| 1234 |
+
return _create_web_app(
|
| 1235 |
+
CanterWebController(pipeline),
|
| 1236 |
+
model_status_text=_model_summary(pipeline.metadata),
|
| 1237 |
+
ready=True,
|
| 1238 |
+
load_config=None,
|
| 1239 |
+
)
|
| 1240 |
+
|
| 1241 |
+
|
| 1242 |
+
def create_loading_web_app(config: CanterWebLaunchConfig) -> gr.Blocks:
|
| 1243 |
+
"""Build an immediately visible UI that loads its process pipeline once."""
|
| 1244 |
+
|
| 1245 |
+
return _create_web_app(
|
| 1246 |
+
CanterWebController(None, load_config=config),
|
| 1247 |
+
model_status_text=(
|
| 1248 |
+
f"Loading Canter `{config.dtype.value}` weights and compiling the "
|
| 1249 |
+
f"`{config.text_backend.value}` backend…"
|
| 1250 |
+
),
|
| 1251 |
+
ready=False,
|
| 1252 |
+
load_config=config,
|
| 1253 |
+
)
|
| 1254 |
+
|
| 1255 |
+
|
| 1256 |
+
def _theme() -> gr.themes.Base:
|
| 1257 |
+
"""Return Canter's compact charcoal-and-sky-blue dark theme."""
|
| 1258 |
+
|
| 1259 |
+
return gr.themes.Base(
|
| 1260 |
+
primary_hue="sky",
|
| 1261 |
+
secondary_hue="blue",
|
| 1262 |
+
neutral_hue="zinc",
|
| 1263 |
+
spacing_size="sm",
|
| 1264 |
+
radius_size="sm",
|
| 1265 |
+
text_size="md",
|
| 1266 |
+
font=[
|
| 1267 |
+
gr.themes.Font("Inter"),
|
| 1268 |
+
gr.themes.Font("Segoe UI"),
|
| 1269 |
+
gr.themes.Font("sans-serif"),
|
| 1270 |
+
],
|
| 1271 |
+
font_mono=[
|
| 1272 |
+
gr.themes.Font("IBM Plex Mono"),
|
| 1273 |
+
gr.themes.Font("Consolas"),
|
| 1274 |
+
gr.themes.Font("monospace"),
|
| 1275 |
+
],
|
| 1276 |
+
).set(
|
| 1277 |
+
body_background_fill_dark="#09090b",
|
| 1278 |
+
block_background_fill_dark="#111113",
|
| 1279 |
+
block_border_color_dark="#27272a",
|
| 1280 |
+
input_background_fill_dark="#18181b",
|
| 1281 |
+
)
|
canter/webui_runtime.py
ADDED
|
@@ -0,0 +1,1171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generation runtime and model lifecycle for the bundled Canter web UI."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import gc
|
| 6 |
+
import json
|
| 7 |
+
import logging
|
| 8 |
+
import math
|
| 9 |
+
import secrets
|
| 10 |
+
import threading
|
| 11 |
+
import time
|
| 12 |
+
from collections.abc import Callable, Generator, Mapping, Sequence
|
| 13 |
+
from dataclasses import dataclass, replace
|
| 14 |
+
from hashlib import sha256
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
from typing import TYPE_CHECKING, Protocol, TypeAlias, TypeVar
|
| 17 |
+
|
| 18 |
+
import gradio as gr
|
| 19 |
+
import torch
|
| 20 |
+
from PIL import Image
|
| 21 |
+
|
| 22 |
+
from .inference import (
|
| 23 |
+
CanterInferenceConfig,
|
| 24 |
+
CfgGuidance,
|
| 25 |
+
PdgCurve,
|
| 26 |
+
PdgGuidance,
|
| 27 |
+
PdgMode,
|
| 28 |
+
)
|
| 29 |
+
from .pipeline import (
|
| 30 |
+
CanterOutputType,
|
| 31 |
+
CanterPipeline,
|
| 32 |
+
CanterPipelineConfig,
|
| 33 |
+
CanterPipelineMetadata,
|
| 34 |
+
CanterPipelineOutput,
|
| 35 |
+
)
|
| 36 |
+
from .schedules import Schedule
|
| 37 |
+
from .solvers import Solver, SolverProgress
|
| 38 |
+
|
| 39 |
+
if TYPE_CHECKING:
|
| 40 |
+
from .blocks import TextAttentionBackend
|
| 41 |
+
from .loading import WeightDType
|
| 42 |
+
from .preview import CanterPreviewCallback
|
| 43 |
+
|
| 44 |
+
_MAX_IMAGE_COUNT = 50
|
| 45 |
+
_MAX_SEED = 2**63 - 1
|
| 46 |
+
_IMAGE_SIZE_MULTIPLE = 32
|
| 47 |
+
_PNG_METADATA_KEY = "canter"
|
| 48 |
+
_LOGGER = logging.getLogger(__name__)
|
| 49 |
+
|
| 50 |
+
_SOLVERS = {value.value: value for value in Solver}
|
| 51 |
+
_SCHEDULES = {value.value: value for value in Schedule}
|
| 52 |
+
_PDG_MODES = {value.value: value for value in PdgMode if value is not PdgMode.NONE}
|
| 53 |
+
_PDG_CURVES = {value.value: value for value in PdgCurve}
|
| 54 |
+
|
| 55 |
+
_Choice = TypeVar("_Choice")
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class _ImagePipeline(Protocol):
|
| 59 |
+
"""Typed Canter surface used by the web controller and its tests."""
|
| 60 |
+
|
| 61 |
+
metadata: CanterPipelineMetadata
|
| 62 |
+
|
| 63 |
+
def __call__(
|
| 64 |
+
self,
|
| 65 |
+
prompts: str | Sequence[str],
|
| 66 |
+
*,
|
| 67 |
+
config: CanterPipelineConfig,
|
| 68 |
+
initial_noise: None,
|
| 69 |
+
progress: SolverProgress | None,
|
| 70 |
+
preview: CanterPreviewCallback | None,
|
| 71 |
+
) -> CanterPipelineOutput:
|
| 72 |
+
"""Generate decoded images for one web request."""
|
| 73 |
+
|
| 74 |
+
...
|
| 75 |
+
|
| 76 |
+
def close(self) -> None:
|
| 77 |
+
"""Finish background work before backend unload."""
|
| 78 |
+
|
| 79 |
+
...
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
@dataclass(frozen=True)
|
| 83 |
+
class CanterWebRequest:
|
| 84 |
+
"""Validated sequential image request and frozen base configuration."""
|
| 85 |
+
|
| 86 |
+
prompt: str
|
| 87 |
+
image_count: int
|
| 88 |
+
config: CanterPipelineConfig
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
@dataclass(frozen=True)
|
| 92 |
+
class CanterWebLaunchConfig:
|
| 93 |
+
"""Process-level model and server configuration for the bundled application."""
|
| 94 |
+
|
| 95 |
+
model: str
|
| 96 |
+
revision: str | None
|
| 97 |
+
dtype: WeightDType
|
| 98 |
+
text_backend: TextAttentionBackend
|
| 99 |
+
device: str
|
| 100 |
+
cache_dir: Path | None
|
| 101 |
+
server_name: str
|
| 102 |
+
server_port: int
|
| 103 |
+
share: bool
|
| 104 |
+
in_browser: bool
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
_ImageSlotUpdate: TypeAlias = Image.Image | dict[str, str] | None
|
| 108 |
+
_GenerationOutputUpdate: TypeAlias = _ImageSlotUpdate | str | dict[str, object]
|
| 109 |
+
_GenerationUpdate: TypeAlias = tuple[_GenerationOutputUpdate, ...]
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
class _GenerationStoppedError(RuntimeError):
|
| 113 |
+
"""Cooperative-cancellation signal for one web generation."""
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@dataclass(frozen=True)
|
| 117 |
+
class _WebProgressState:
|
| 118 |
+
"""Current solver position for one browser generation request."""
|
| 119 |
+
|
| 120 |
+
image_index: int
|
| 121 |
+
image_count: int
|
| 122 |
+
completed_updates: int
|
| 123 |
+
total_updates: int
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@dataclass(frozen=True)
|
| 127 |
+
class _WebPreview:
|
| 128 |
+
"""Newest unconsumed preview for one output image slot."""
|
| 129 |
+
|
| 130 |
+
image_index: int
|
| 131 |
+
image: Image.Image
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
@dataclass(frozen=True)
|
| 135 |
+
class _WebGenerationState:
|
| 136 |
+
"""Cancellation and progress state owned by one browser generation."""
|
| 137 |
+
|
| 138 |
+
stop_requested: threading.Event
|
| 139 |
+
progress: _WebProgressState
|
| 140 |
+
preview_image_index: int | None
|
| 141 |
+
pending_preview: _WebPreview | None
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
class CanterWebController:
|
| 145 |
+
"""Translate Gradio values into one deterministic pipeline invocation."""
|
| 146 |
+
|
| 147 |
+
def __init__(
|
| 148 |
+
self,
|
| 149 |
+
pipeline: _ImagePipeline | None,
|
| 150 |
+
*,
|
| 151 |
+
load_config: CanterWebLaunchConfig | None = None,
|
| 152 |
+
) -> None:
|
| 153 |
+
"""Retain a pipeline or the exact configuration needed to load it."""
|
| 154 |
+
|
| 155 |
+
if pipeline is None and load_config is None:
|
| 156 |
+
raise ValueError("A lazy Canter web controller requires load_config.")
|
| 157 |
+
if pipeline is not None and load_config is not None:
|
| 158 |
+
raise ValueError("Provide either pipeline or load_config, not both.")
|
| 159 |
+
|
| 160 |
+
self.pipeline = pipeline
|
| 161 |
+
self._load_config = load_config
|
| 162 |
+
self._load_lock = threading.Lock()
|
| 163 |
+
self._session_lock = threading.Lock()
|
| 164 |
+
self._generation_by_session: dict[str, _WebGenerationState] = {}
|
| 165 |
+
self._explicitly_unloaded = False
|
| 166 |
+
self._unload_pending = False
|
| 167 |
+
|
| 168 |
+
def load(
|
| 169 |
+
self,
|
| 170 |
+
) -> tuple[str, str, dict[str, object], dict[str, object], dict[str, object]]:
|
| 171 |
+
"""Load initially without undoing an explicit backend unload."""
|
| 172 |
+
|
| 173 |
+
with self._load_lock:
|
| 174 |
+
if self._explicitly_unloaded:
|
| 175 |
+
unloaded = _unloaded_model_status()
|
| 176 |
+
return (
|
| 177 |
+
unloaded,
|
| 178 |
+
unloaded,
|
| 179 |
+
gr.update(interactive=True),
|
| 180 |
+
gr.update(interactive=True),
|
| 181 |
+
gr.update(interactive=True),
|
| 182 |
+
)
|
| 183 |
+
pipeline = self._load_pipeline_locked()
|
| 184 |
+
return (
|
| 185 |
+
_model_summary(pipeline.metadata),
|
| 186 |
+
"Ready. The first request includes compiled graph warm-up.",
|
| 187 |
+
gr.update(interactive=True),
|
| 188 |
+
gr.update(interactive=True),
|
| 189 |
+
gr.update(interactive=True),
|
| 190 |
+
)
|
| 191 |
+
|
| 192 |
+
def request_stop(self, request: gr.Request | None = None) -> str:
|
| 193 |
+
"""Cancel only the active generation owned by the requesting browser."""
|
| 194 |
+
|
| 195 |
+
session_hash = _session_hash(request)
|
| 196 |
+
with self._session_lock:
|
| 197 |
+
state = self._generation_by_session.get(session_hash)
|
| 198 |
+
if state is None:
|
| 199 |
+
return "No generation is currently active in this browser session."
|
| 200 |
+
state.stop_requested.set()
|
| 201 |
+
return "Stopping after the current solver step…"
|
| 202 |
+
|
| 203 |
+
def request_backend_unload(self) -> str:
|
| 204 |
+
"""Stop every active request and block new loads until cleanup completes."""
|
| 205 |
+
|
| 206 |
+
with self._load_lock:
|
| 207 |
+
self._unload_pending = True
|
| 208 |
+
with self._session_lock:
|
| 209 |
+
active_states = tuple(self._generation_by_session.values())
|
| 210 |
+
for state in active_states:
|
| 211 |
+
state.stop_requested.set()
|
| 212 |
+
active_count = len(active_states)
|
| 213 |
+
_LOGGER.info(
|
| 214 |
+
"Canter backend unload requested | active_requests=%d",
|
| 215 |
+
active_count,
|
| 216 |
+
)
|
| 217 |
+
if active_count == 0:
|
| 218 |
+
return "Unloading Canter and clearing generated images…"
|
| 219 |
+
request_word = "request" if active_count == 1 else "requests"
|
| 220 |
+
return (
|
| 221 |
+
f"Stopping **{active_count} active {request_word}** at the next solver "
|
| 222 |
+
"boundary, then unloading Canter…"
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
def unload_backend(self) -> str:
|
| 226 |
+
"""Release the reloadable pipeline and flush compiler and CUDA caches."""
|
| 227 |
+
|
| 228 |
+
with self._load_lock:
|
| 229 |
+
with self._session_lock:
|
| 230 |
+
active_count = len(self._generation_by_session)
|
| 231 |
+
if active_count != 0:
|
| 232 |
+
raise RuntimeError(
|
| 233 |
+
"Cannot unload Canter while generation state remains active."
|
| 234 |
+
)
|
| 235 |
+
if self._load_config is None:
|
| 236 |
+
raise RuntimeError(
|
| 237 |
+
"This embedded Canter pipeline has no reload configuration."
|
| 238 |
+
)
|
| 239 |
+
pipeline = self.pipeline
|
| 240 |
+
self.pipeline = None
|
| 241 |
+
self._explicitly_unloaded = True
|
| 242 |
+
if pipeline is not None:
|
| 243 |
+
pipeline.close()
|
| 244 |
+
del pipeline
|
| 245 |
+
try:
|
| 246 |
+
torch.compiler.reset()
|
| 247 |
+
gc.collect()
|
| 248 |
+
torch.cuda.empty_cache()
|
| 249 |
+
except (OSError, RuntimeError):
|
| 250 |
+
_LOGGER.exception("Canter backend unload cleanup failed")
|
| 251 |
+
raise
|
| 252 |
+
self._unload_pending = False
|
| 253 |
+
_LOGGER.info("Canter backend unloaded")
|
| 254 |
+
return "Canter is unloaded. The next generation will reload and compile it."
|
| 255 |
+
|
| 256 |
+
def model_status(self) -> str:
|
| 257 |
+
"""Return the current process-wide model residency state."""
|
| 258 |
+
|
| 259 |
+
with self._load_lock:
|
| 260 |
+
if self.pipeline is None:
|
| 261 |
+
return _unloaded_model_status()
|
| 262 |
+
return _model_summary(self.pipeline.metadata)
|
| 263 |
+
|
| 264 |
+
def generate( # noqa: PLR0917 - Gradio supplies the explicit form fields.
|
| 265 |
+
self,
|
| 266 |
+
prompt: str,
|
| 267 |
+
image_count: int | float,
|
| 268 |
+
width: int | float,
|
| 269 |
+
height: int | float,
|
| 270 |
+
seed: int | float,
|
| 271 |
+
steps: int | float,
|
| 272 |
+
solver: str,
|
| 273 |
+
schedule: str,
|
| 274 |
+
log_snr_shift: int | float,
|
| 275 |
+
self_attention_gain: int | float,
|
| 276 |
+
euler_maruyama_multiplier: int | float,
|
| 277 |
+
cfg_enabled: bool,
|
| 278 |
+
cfg_scale: int | float,
|
| 279 |
+
cfg_start_step: int | float,
|
| 280 |
+
cfg_stop_step: int | float | str | None,
|
| 281 |
+
pdg_enabled: bool,
|
| 282 |
+
pdg_mode: str,
|
| 283 |
+
pdg_curve: str,
|
| 284 |
+
pdg_noisy_scale: int | float,
|
| 285 |
+
pdg_clean_scale: int | float,
|
| 286 |
+
pdg_power: int | float,
|
| 287 |
+
pdg_start_step: int | float,
|
| 288 |
+
pdg_stop_step: int | float | str | None,
|
| 289 |
+
preview_enabled: bool,
|
| 290 |
+
browser_request: gr.Request | None = None,
|
| 291 |
+
) -> Generator[_GenerationUpdate, None, None]:
|
| 292 |
+
"""Yield each batch-one image as soon as its decoding completes."""
|
| 293 |
+
|
| 294 |
+
session_hash = _session_hash(browser_request)
|
| 295 |
+
request = build_web_request(
|
| 296 |
+
prompt=prompt,
|
| 297 |
+
image_count=image_count,
|
| 298 |
+
width=width,
|
| 299 |
+
height=height,
|
| 300 |
+
seed=seed,
|
| 301 |
+
steps=steps,
|
| 302 |
+
solver=solver,
|
| 303 |
+
schedule=schedule,
|
| 304 |
+
log_snr_shift=log_snr_shift,
|
| 305 |
+
self_attention_gain=self_attention_gain,
|
| 306 |
+
euler_maruyama_multiplier=euler_maruyama_multiplier,
|
| 307 |
+
cfg_enabled=cfg_enabled,
|
| 308 |
+
cfg_scale=cfg_scale,
|
| 309 |
+
cfg_start_step=cfg_start_step,
|
| 310 |
+
cfg_stop_step=cfg_stop_step,
|
| 311 |
+
pdg_enabled=pdg_enabled,
|
| 312 |
+
pdg_mode=pdg_mode,
|
| 313 |
+
pdg_curve=pdg_curve,
|
| 314 |
+
pdg_noisy_scale=pdg_noisy_scale,
|
| 315 |
+
pdg_clean_scale=pdg_clean_scale,
|
| 316 |
+
pdg_power=pdg_power,
|
| 317 |
+
pdg_start_step=pdg_start_step,
|
| 318 |
+
pdg_stop_step=pdg_stop_step,
|
| 319 |
+
)
|
| 320 |
+
_log_generation_request(request)
|
| 321 |
+
pipeline = self._ensure_loaded()
|
| 322 |
+
started = time.perf_counter()
|
| 323 |
+
guidance = _pdg_status(request.config.inference)
|
| 324 |
+
stop_requested = self._begin_generation(
|
| 325 |
+
session_hash,
|
| 326 |
+
image_index=0,
|
| 327 |
+
image_count=request.image_count,
|
| 328 |
+
completed_updates=0,
|
| 329 |
+
total_updates=request.config.inference.steps,
|
| 330 |
+
)
|
| 331 |
+
try:
|
| 332 |
+
yield _generation_update(
|
| 333 |
+
_cleared_image_slots(),
|
| 334 |
+
f"Generating **{request.image_count} images** with batch size `1` · "
|
| 335 |
+
f"{guidance}…",
|
| 336 |
+
progress_active=True,
|
| 337 |
+
)
|
| 338 |
+
for image_index in range(request.image_count):
|
| 339 |
+
self._raise_if_stopped(stop_requested)
|
| 340 |
+
image_number = image_index + 1
|
| 341 |
+
self._set_preview_target(
|
| 342 |
+
session_hash,
|
| 343 |
+
stop_requested=stop_requested,
|
| 344 |
+
target=image_index if preview_enabled else None,
|
| 345 |
+
)
|
| 346 |
+
self._set_progress(
|
| 347 |
+
session_hash,
|
| 348 |
+
image_index=image_index,
|
| 349 |
+
image_count=request.image_count,
|
| 350 |
+
completed_updates=0,
|
| 351 |
+
total_updates=request.config.inference.steps,
|
| 352 |
+
)
|
| 353 |
+
image_config = _image_config(request, image_index)
|
| 354 |
+
output = pipeline(
|
| 355 |
+
request.prompt,
|
| 356 |
+
config=image_config,
|
| 357 |
+
initial_noise=None,
|
| 358 |
+
progress=_step_progress(
|
| 359 |
+
stopped=stop_requested.is_set,
|
| 360 |
+
update_web=lambda completed, total, index=image_index: (
|
| 361 |
+
self._set_progress(
|
| 362 |
+
session_hash,
|
| 363 |
+
image_index=index,
|
| 364 |
+
image_count=request.image_count,
|
| 365 |
+
completed_updates=completed,
|
| 366 |
+
total_updates=total,
|
| 367 |
+
)
|
| 368 |
+
),
|
| 369 |
+
),
|
| 370 |
+
preview=(
|
| 371 |
+
(
|
| 372 |
+
lambda images, completed, total, index=image_index: (
|
| 373 |
+
self._publish_preview(
|
| 374 |
+
session_hash,
|
| 375 |
+
stop_requested=stop_requested,
|
| 376 |
+
image_index=index,
|
| 377 |
+
images=images,
|
| 378 |
+
)
|
| 379 |
+
)
|
| 380 |
+
)
|
| 381 |
+
if preview_enabled
|
| 382 |
+
else None
|
| 383 |
+
),
|
| 384 |
+
)
|
| 385 |
+
self._set_preview_target(
|
| 386 |
+
session_hash,
|
| 387 |
+
stop_requested=stop_requested,
|
| 388 |
+
target=None,
|
| 389 |
+
)
|
| 390 |
+
self._raise_if_stopped(stop_requested)
|
| 391 |
+
if output.images is None or len(output.images) != 1:
|
| 392 |
+
raise RuntimeError(
|
| 393 |
+
"Each batch-one Canter web invocation must return one PIL image."
|
| 394 |
+
)
|
| 395 |
+
image = _attach_png_metadata(
|
| 396 |
+
output.images[0],
|
| 397 |
+
prompt=request.prompt,
|
| 398 |
+
config=image_config,
|
| 399 |
+
metadata=pipeline.metadata,
|
| 400 |
+
)
|
| 401 |
+
elapsed = time.perf_counter() - started
|
| 402 |
+
status = _generation_status(
|
| 403 |
+
pipeline.metadata,
|
| 404 |
+
request=request,
|
| 405 |
+
elapsed=elapsed,
|
| 406 |
+
completed=image_number,
|
| 407 |
+
)
|
| 408 |
+
yield _generation_update(
|
| 409 |
+
_completed_image_slots(
|
| 410 |
+
image=image,
|
| 411 |
+
image_index=image_index,
|
| 412 |
+
),
|
| 413 |
+
status,
|
| 414 |
+
progress_active=None,
|
| 415 |
+
)
|
| 416 |
+
except _GenerationStoppedError:
|
| 417 |
+
yield _generation_update(
|
| 418 |
+
_preserved_image_slots(),
|
| 419 |
+
"Stopped. Ready for another request.",
|
| 420 |
+
progress_active=None,
|
| 421 |
+
)
|
| 422 |
+
finally:
|
| 423 |
+
self._finish_generation(session_hash, stop_requested)
|
| 424 |
+
|
| 425 |
+
def poll_progress(
|
| 426 |
+
self,
|
| 427 |
+
request: gr.Request | None = None,
|
| 428 |
+
) -> tuple[_GenerationOutputUpdate, ...]:
|
| 429 |
+
"""Consume one newest preview, progress HTML, and polling lifecycle."""
|
| 430 |
+
|
| 431 |
+
session_hash = _session_hash(request)
|
| 432 |
+
with self._session_lock:
|
| 433 |
+
generation = self._generation_by_session.get(session_hash)
|
| 434 |
+
if generation is not None and generation.pending_preview is not None:
|
| 435 |
+
pending = generation.pending_preview
|
| 436 |
+
self._generation_by_session[session_hash] = replace(
|
| 437 |
+
generation,
|
| 438 |
+
pending_preview=None,
|
| 439 |
+
)
|
| 440 |
+
else:
|
| 441 |
+
pending = None
|
| 442 |
+
state = None if generation is None else generation.progress
|
| 443 |
+
image_updates = (
|
| 444 |
+
_preserved_image_slots()
|
| 445 |
+
if pending is None
|
| 446 |
+
else _completed_image_slots(
|
| 447 |
+
image=pending.image,
|
| 448 |
+
image_index=pending.image_index,
|
| 449 |
+
)
|
| 450 |
+
)
|
| 451 |
+
return (
|
| 452 |
+
*image_updates,
|
| 453 |
+
*_progress_html(state),
|
| 454 |
+
gr.update(active=generation is not None),
|
| 455 |
+
)
|
| 456 |
+
|
| 457 |
+
def _raise_if_stopped(self, stop_requested: threading.Event) -> None:
|
| 458 |
+
"""Abort at the next safe boundary after a web stop request."""
|
| 459 |
+
|
| 460 |
+
if stop_requested.is_set():
|
| 461 |
+
raise _GenerationStoppedError
|
| 462 |
+
|
| 463 |
+
def _begin_generation(
|
| 464 |
+
self,
|
| 465 |
+
session_hash: str,
|
| 466 |
+
*,
|
| 467 |
+
image_index: int,
|
| 468 |
+
image_count: int,
|
| 469 |
+
completed_updates: int,
|
| 470 |
+
total_updates: int,
|
| 471 |
+
) -> threading.Event:
|
| 472 |
+
"""Register and return cancellation state for a new browser generation."""
|
| 473 |
+
|
| 474 |
+
progress = _WebProgressState(
|
| 475 |
+
image_index=image_index,
|
| 476 |
+
image_count=image_count,
|
| 477 |
+
completed_updates=completed_updates,
|
| 478 |
+
total_updates=total_updates,
|
| 479 |
+
)
|
| 480 |
+
stop_requested = threading.Event()
|
| 481 |
+
state = _WebGenerationState(
|
| 482 |
+
stop_requested=stop_requested,
|
| 483 |
+
progress=progress,
|
| 484 |
+
preview_image_index=image_index,
|
| 485 |
+
pending_preview=None,
|
| 486 |
+
)
|
| 487 |
+
with self._session_lock:
|
| 488 |
+
if session_hash in self._generation_by_session:
|
| 489 |
+
raise RuntimeError(
|
| 490 |
+
"A browser session cannot run multiple Canter generations."
|
| 491 |
+
)
|
| 492 |
+
self._generation_by_session[session_hash] = state
|
| 493 |
+
return stop_requested
|
| 494 |
+
|
| 495 |
+
def _set_progress(
|
| 496 |
+
self,
|
| 497 |
+
session_hash: str,
|
| 498 |
+
*,
|
| 499 |
+
image_index: int,
|
| 500 |
+
image_count: int,
|
| 501 |
+
completed_updates: int,
|
| 502 |
+
total_updates: int,
|
| 503 |
+
) -> None:
|
| 504 |
+
"""Publish one solver position for the requesting browser session."""
|
| 505 |
+
|
| 506 |
+
progress = _WebProgressState(
|
| 507 |
+
image_index=image_index,
|
| 508 |
+
image_count=image_count,
|
| 509 |
+
completed_updates=completed_updates,
|
| 510 |
+
total_updates=total_updates,
|
| 511 |
+
)
|
| 512 |
+
with self._session_lock:
|
| 513 |
+
state = self._generation_by_session.get(session_hash)
|
| 514 |
+
if state is None:
|
| 515 |
+
raise RuntimeError(
|
| 516 |
+
"Cannot update progress without an active generation."
|
| 517 |
+
)
|
| 518 |
+
self._generation_by_session[session_hash] = replace(
|
| 519 |
+
state,
|
| 520 |
+
progress=progress,
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
def _set_preview_target(
|
| 524 |
+
self,
|
| 525 |
+
session_hash: str,
|
| 526 |
+
*,
|
| 527 |
+
stop_requested: threading.Event,
|
| 528 |
+
target: int | None,
|
| 529 |
+
) -> None:
|
| 530 |
+
"""Select one image for previews, or reject late previews with None."""
|
| 531 |
+
|
| 532 |
+
with self._session_lock:
|
| 533 |
+
state = self._generation_by_session.get(session_hash)
|
| 534 |
+
if state is None or state.stop_requested is not stop_requested:
|
| 535 |
+
raise RuntimeError("Cannot update preview for an inactive generation.")
|
| 536 |
+
self._generation_by_session[session_hash] = replace(
|
| 537 |
+
state,
|
| 538 |
+
preview_image_index=target,
|
| 539 |
+
pending_preview=None,
|
| 540 |
+
)
|
| 541 |
+
|
| 542 |
+
def _publish_preview(
|
| 543 |
+
self,
|
| 544 |
+
session_hash: str,
|
| 545 |
+
*,
|
| 546 |
+
stop_requested: threading.Event,
|
| 547 |
+
image_index: int,
|
| 548 |
+
images: tuple[Image.Image, ...],
|
| 549 |
+
) -> None:
|
| 550 |
+
"""Publish only the newest preview for the still-active image."""
|
| 551 |
+
|
| 552 |
+
pending = _WebPreview(image_index=image_index, image=images[0])
|
| 553 |
+
with self._session_lock:
|
| 554 |
+
state = self._generation_by_session.get(session_hash)
|
| 555 |
+
if (
|
| 556 |
+
state is None
|
| 557 |
+
or state.stop_requested is not stop_requested
|
| 558 |
+
or state.preview_image_index != image_index
|
| 559 |
+
):
|
| 560 |
+
return
|
| 561 |
+
self._generation_by_session[session_hash] = replace(
|
| 562 |
+
state,
|
| 563 |
+
pending_preview=pending,
|
| 564 |
+
)
|
| 565 |
+
|
| 566 |
+
def _finish_generation(
|
| 567 |
+
self,
|
| 568 |
+
session_hash: str,
|
| 569 |
+
stop_requested: threading.Event,
|
| 570 |
+
) -> None:
|
| 571 |
+
"""Remove exactly the browser generation that has finished."""
|
| 572 |
+
|
| 573 |
+
with self._session_lock:
|
| 574 |
+
state = self._generation_by_session.get(session_hash)
|
| 575 |
+
if state is None or state.stop_requested is not stop_requested:
|
| 576 |
+
raise RuntimeError(
|
| 577 |
+
"Canter browser generation state became inconsistent."
|
| 578 |
+
)
|
| 579 |
+
del self._generation_by_session[session_hash]
|
| 580 |
+
|
| 581 |
+
def _ensure_loaded(self) -> _ImagePipeline:
|
| 582 |
+
"""Return the process pipeline, loading it exactly once when necessary."""
|
| 583 |
+
|
| 584 |
+
with self._load_lock:
|
| 585 |
+
if self._unload_pending:
|
| 586 |
+
raise RuntimeError("Canter backend unload is still in progress.")
|
| 587 |
+
return self._load_pipeline_locked()
|
| 588 |
+
|
| 589 |
+
def _load_pipeline_locked(self) -> _ImagePipeline:
|
| 590 |
+
"""Load under the lifecycle lock and return the resident pipeline."""
|
| 591 |
+
|
| 592 |
+
if self.pipeline is None:
|
| 593 |
+
config = self._load_config
|
| 594 |
+
if config is None:
|
| 595 |
+
raise RuntimeError(
|
| 596 |
+
"Lazy Canter loading is missing its launch configuration."
|
| 597 |
+
)
|
| 598 |
+
self.pipeline = load_web_pipeline(config)
|
| 599 |
+
self._explicitly_unloaded = False
|
| 600 |
+
return self.pipeline
|
| 601 |
+
|
| 602 |
+
|
| 603 |
+
def _preserved_image_slots() -> tuple[dict[str, str], ...]:
|
| 604 |
+
"""Return no-op updates that retain every displayed image."""
|
| 605 |
+
|
| 606 |
+
return tuple(gr.skip() for _ in range(_MAX_IMAGE_COUNT))
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
def _cleared_image_slots() -> tuple[None, ...]:
|
| 610 |
+
"""Clear every image before one new sequential generation."""
|
| 611 |
+
|
| 612 |
+
return (None,) * _MAX_IMAGE_COUNT
|
| 613 |
+
|
| 614 |
+
|
| 615 |
+
def _completed_image_slots(
|
| 616 |
+
*,
|
| 617 |
+
image: Image.Image,
|
| 618 |
+
image_index: int,
|
| 619 |
+
) -> tuple[_ImageSlotUpdate, ...]:
|
| 620 |
+
"""Update exactly one newly completed image in the fixed output boundary."""
|
| 621 |
+
|
| 622 |
+
updates: list[_ImageSlotUpdate] = list(_preserved_image_slots())
|
| 623 |
+
updates[image_index] = image
|
| 624 |
+
return tuple(updates)
|
| 625 |
+
|
| 626 |
+
|
| 627 |
+
def _generation_update(
|
| 628 |
+
images: Sequence[_ImageSlotUpdate],
|
| 629 |
+
status: str,
|
| 630 |
+
*,
|
| 631 |
+
progress_active: bool | None,
|
| 632 |
+
) -> _GenerationUpdate:
|
| 633 |
+
"""Return image, status, and timer updates expected by Gradio."""
|
| 634 |
+
|
| 635 |
+
if len(images) != _MAX_IMAGE_COUNT:
|
| 636 |
+
raise ValueError(
|
| 637 |
+
f"Expected {_MAX_IMAGE_COUNT} web image slots, got {len(images)}."
|
| 638 |
+
)
|
| 639 |
+
return (
|
| 640 |
+
*images,
|
| 641 |
+
status,
|
| 642 |
+
(gr.skip() if progress_active is None else gr.update(active=progress_active)),
|
| 643 |
+
)
|
| 644 |
+
|
| 645 |
+
|
| 646 |
+
def _step_progress(
|
| 647 |
+
*,
|
| 648 |
+
stopped: Callable[[], bool],
|
| 649 |
+
update_web: Callable[[int, int], None],
|
| 650 |
+
) -> SolverProgress:
|
| 651 |
+
"""Create an exact solver-update reporter for one sequential image."""
|
| 652 |
+
|
| 653 |
+
def report(completed: int, total: int) -> None:
|
| 654 |
+
"""Update the current image bar while retaining the total image bar."""
|
| 655 |
+
|
| 656 |
+
if stopped():
|
| 657 |
+
raise _GenerationStoppedError
|
| 658 |
+
update_web(completed, total)
|
| 659 |
+
|
| 660 |
+
return report
|
| 661 |
+
|
| 662 |
+
|
| 663 |
+
def _session_hash(request: gr.Request | None) -> str:
|
| 664 |
+
"""Return the required Gradio browser-session identifier."""
|
| 665 |
+
|
| 666 |
+
if request is None or request.session_hash is None or not request.session_hash:
|
| 667 |
+
raise ValueError("Web generation requires a Gradio browser session hash.")
|
| 668 |
+
return request.session_hash
|
| 669 |
+
|
| 670 |
+
|
| 671 |
+
def _progress_html(
|
| 672 |
+
state: _WebProgressState | None,
|
| 673 |
+
) -> tuple[str, ...]:
|
| 674 |
+
"""Render total progress and the active image's solver overlay."""
|
| 675 |
+
|
| 676 |
+
slots = [""] * _MAX_IMAGE_COUNT
|
| 677 |
+
if state is None:
|
| 678 |
+
return ("", *slots)
|
| 679 |
+
image_fraction = state.completed_updates / state.total_updates
|
| 680 |
+
total_fraction = (state.image_index + image_fraction) / state.image_count
|
| 681 |
+
image_percent = 100.0 * image_fraction
|
| 682 |
+
total_percent = 100.0 * total_fraction
|
| 683 |
+
total_html = f"""
|
| 684 |
+
<div class="canter-progress-panel">
|
| 685 |
+
<div class="canter-progress-label">
|
| 686 |
+
Total progress · Image {state.image_index + 1}/{state.image_count}
|
| 687 |
+
</div>
|
| 688 |
+
<div class="canter-progress-row">
|
| 689 |
+
<span>Total</span>
|
| 690 |
+
<div class="canter-progress-track">
|
| 691 |
+
<div style="width: {total_percent:.2f}%"></div>
|
| 692 |
+
</div>
|
| 693 |
+
<span>{total_percent:.0f}%</span>
|
| 694 |
+
</div>
|
| 695 |
+
</div>
|
| 696 |
+
"""
|
| 697 |
+
slots[state.image_index] = f"""
|
| 698 |
+
<div class="canter-progress-panel">
|
| 699 |
+
<div class="canter-progress-label">
|
| 700 |
+
Image {state.image_index + 1}/{state.image_count}
|
| 701 |
+
· {state.completed_updates}/{state.total_updates} updates
|
| 702 |
+
</div>
|
| 703 |
+
<div class="canter-progress-row">
|
| 704 |
+
<span>Image</span>
|
| 705 |
+
<div class="canter-progress-track">
|
| 706 |
+
<div style="width: {image_percent:.2f}%"></div>
|
| 707 |
+
</div>
|
| 708 |
+
<span>{image_percent:.0f}%</span>
|
| 709 |
+
</div>
|
| 710 |
+
</div>
|
| 711 |
+
"""
|
| 712 |
+
return (total_html, *slots)
|
| 713 |
+
|
| 714 |
+
|
| 715 |
+
def _choice(
|
| 716 |
+
value: str | None,
|
| 717 |
+
choices: Mapping[str, _Choice],
|
| 718 |
+
name: str,
|
| 719 |
+
) -> _Choice:
|
| 720 |
+
"""Resolve one exact enum-backed form choice or fail with its field name."""
|
| 721 |
+
|
| 722 |
+
if value is None:
|
| 723 |
+
raise ValueError(f"{name} is required.")
|
| 724 |
+
selected = choices.get(value)
|
| 725 |
+
if selected is None:
|
| 726 |
+
raise ValueError(f"Unsupported {name}: {value!r}.")
|
| 727 |
+
return selected
|
| 728 |
+
|
| 729 |
+
|
| 730 |
+
def _integer(
|
| 731 |
+
value: int | float | None,
|
| 732 |
+
name: str,
|
| 733 |
+
*,
|
| 734 |
+
minimum: int,
|
| 735 |
+
maximum: int | None,
|
| 736 |
+
) -> int:
|
| 737 |
+
"""Convert one integral form number and enforce its explicit bounds."""
|
| 738 |
+
|
| 739 |
+
if value is None or isinstance(value, bool):
|
| 740 |
+
raise TypeError(f"{name} must be an integer.")
|
| 741 |
+
number = float(value)
|
| 742 |
+
if not math.isfinite(number) or not number.is_integer():
|
| 743 |
+
raise ValueError(f"{name} must be a finite integer.")
|
| 744 |
+
result = int(number)
|
| 745 |
+
if result < minimum:
|
| 746 |
+
raise ValueError(f"{name} must be at least {minimum}.")
|
| 747 |
+
if maximum is not None and result > maximum:
|
| 748 |
+
raise ValueError(f"{name} must not exceed {maximum}.")
|
| 749 |
+
return result
|
| 750 |
+
|
| 751 |
+
|
| 752 |
+
def _optional_integer(
|
| 753 |
+
value: int | float | str | None,
|
| 754 |
+
name: str,
|
| 755 |
+
) -> int | None:
|
| 756 |
+
"""Convert a blank solver stop field to ``None`` or validate an index."""
|
| 757 |
+
|
| 758 |
+
if value is None:
|
| 759 |
+
return None
|
| 760 |
+
if isinstance(value, str):
|
| 761 |
+
stripped = value.strip()
|
| 762 |
+
if not stripped:
|
| 763 |
+
return None
|
| 764 |
+
try:
|
| 765 |
+
numeric = float(stripped)
|
| 766 |
+
except ValueError as exc:
|
| 767 |
+
raise ValueError(f"{name} must be blank or a finite integer.") from exc
|
| 768 |
+
return _integer(numeric, name, minimum=0, maximum=None)
|
| 769 |
+
return _integer(value, name, minimum=0, maximum=None)
|
| 770 |
+
|
| 771 |
+
|
| 772 |
+
def _number(value: int | float, name: str) -> float:
|
| 773 |
+
"""Require one finite floating-point form value."""
|
| 774 |
+
|
| 775 |
+
if isinstance(value, bool):
|
| 776 |
+
raise TypeError(f"{name} must be a number.")
|
| 777 |
+
result = float(value)
|
| 778 |
+
if not math.isfinite(result):
|
| 779 |
+
raise ValueError(f"{name} must be finite.")
|
| 780 |
+
return result
|
| 781 |
+
|
| 782 |
+
|
| 783 |
+
def _image_dimension(value: int | float | None, name: str) -> int:
|
| 784 |
+
"""Snap one finite image dimension to the supported 32-pixel lattice."""
|
| 785 |
+
|
| 786 |
+
if value is None:
|
| 787 |
+
raise ValueError(f"{name} is required.")
|
| 788 |
+
number = _number(value, name)
|
| 789 |
+
if number < _IMAGE_SIZE_MULTIPLE:
|
| 790 |
+
raise ValueError(f"{name} must be at least {_IMAGE_SIZE_MULTIPLE}.")
|
| 791 |
+
return snap_image_dimension(number)
|
| 792 |
+
|
| 793 |
+
|
| 794 |
+
def snap_image_dimension(value: int | float | None) -> int:
|
| 795 |
+
"""Snap one browser image dimension to its nearest valid multiple."""
|
| 796 |
+
|
| 797 |
+
if value is None:
|
| 798 |
+
raise ValueError("image size is required.")
|
| 799 |
+
number = _number(value, "image size")
|
| 800 |
+
snapped = math.floor(
|
| 801 |
+
(max(number, _IMAGE_SIZE_MULTIPLE) + _IMAGE_SIZE_MULTIPLE / 2)
|
| 802 |
+
/ _IMAGE_SIZE_MULTIPLE
|
| 803 |
+
)
|
| 804 |
+
return snapped * _IMAGE_SIZE_MULTIPLE
|
| 805 |
+
|
| 806 |
+
|
| 807 |
+
def _validated_prompt(prompt: str) -> str:
|
| 808 |
+
"""Return a string prompt without changing the user's text."""
|
| 809 |
+
|
| 810 |
+
if not isinstance(prompt, str):
|
| 811 |
+
raise TypeError("prompt must be a string.")
|
| 812 |
+
return prompt
|
| 813 |
+
|
| 814 |
+
|
| 815 |
+
def _pdg_mode_uses_cfg(mode: PdgMode) -> bool:
|
| 816 |
+
"""Return whether one public PDG mode consumes the CFG scale."""
|
| 817 |
+
|
| 818 |
+
match mode:
|
| 819 |
+
case (
|
| 820 |
+
PdgMode.ALTERNATE_PDG_FIRST
|
| 821 |
+
| PdgMode.ALTERNATE_CFG_FIRST
|
| 822 |
+
| PdgMode.COMBINED_CFG_PDG
|
| 823 |
+
| PdgMode.PDG_WITH_ALTERNATING_CFG
|
| 824 |
+
| PdgMode.CFG_TO_PDG
|
| 825 |
+
):
|
| 826 |
+
return True
|
| 827 |
+
case PdgMode.FULL | PdgMode.THREE_QUARTER:
|
| 828 |
+
return False
|
| 829 |
+
case PdgMode.NONE:
|
| 830 |
+
raise ValueError("The enabled web PDG selection cannot use mode none.")
|
| 831 |
+
case _ as unreachable:
|
| 832 |
+
raise RuntimeError(f"Unsupported PDG mode: {unreachable}")
|
| 833 |
+
|
| 834 |
+
|
| 835 |
+
def update_pdg_clean_scale(
|
| 836 |
+
curve: str,
|
| 837 |
+
noisy_scale: int | float,
|
| 838 |
+
) -> dict[str, object]:
|
| 839 |
+
"""Disable and synchronize the unused clean endpoint for constant PDG."""
|
| 840 |
+
|
| 841 |
+
resolved_curve = _choice(curve, _PDG_CURVES, "PDG curve")
|
| 842 |
+
match resolved_curve:
|
| 843 |
+
case PdgCurve.CONSTANT:
|
| 844 |
+
return gr.update(
|
| 845 |
+
value=_number(noisy_scale, "PDG noisy scale"),
|
| 846 |
+
interactive=False,
|
| 847 |
+
)
|
| 848 |
+
case PdgCurve.LINEAR | PdgCurve.POWER:
|
| 849 |
+
return gr.update(interactive=True)
|
| 850 |
+
case _ as unreachable:
|
| 851 |
+
raise RuntimeError(f"Unsupported PDG curve: {unreachable}")
|
| 852 |
+
|
| 853 |
+
|
| 854 |
+
def build_web_request(
|
| 855 |
+
*,
|
| 856 |
+
prompt: str,
|
| 857 |
+
image_count: int | float,
|
| 858 |
+
width: int | float,
|
| 859 |
+
height: int | float,
|
| 860 |
+
seed: int | float,
|
| 861 |
+
steps: int | float,
|
| 862 |
+
solver: str,
|
| 863 |
+
schedule: str,
|
| 864 |
+
log_snr_shift: int | float,
|
| 865 |
+
self_attention_gain: int | float,
|
| 866 |
+
euler_maruyama_multiplier: int | float,
|
| 867 |
+
cfg_enabled: bool,
|
| 868 |
+
cfg_scale: int | float,
|
| 869 |
+
cfg_start_step: int | float,
|
| 870 |
+
cfg_stop_step: int | float | str | None,
|
| 871 |
+
pdg_enabled: bool,
|
| 872 |
+
pdg_mode: str,
|
| 873 |
+
pdg_curve: str,
|
| 874 |
+
pdg_noisy_scale: int | float,
|
| 875 |
+
pdg_clean_scale: int | float,
|
| 876 |
+
pdg_power: int | float,
|
| 877 |
+
pdg_start_step: int | float,
|
| 878 |
+
pdg_stop_step: int | float | str | None,
|
| 879 |
+
) -> CanterWebRequest:
|
| 880 |
+
"""Validate raw Gradio values and construct the frozen Canter dataclasses."""
|
| 881 |
+
|
| 882 |
+
resolved_pdg_mode = _choice(pdg_mode, _PDG_MODES, "PDG mode")
|
| 883 |
+
active_pdg_mode = resolved_pdg_mode if pdg_enabled else PdgMode.NONE
|
| 884 |
+
uses_cfg_scale = pdg_enabled and _pdg_mode_uses_cfg(resolved_pdg_mode)
|
| 885 |
+
resolved_cfg_scale = (
|
| 886 |
+
_number(cfg_scale, "CFG scale") if cfg_enabled or uses_cfg_scale else None
|
| 887 |
+
)
|
| 888 |
+
cfg = CfgGuidance(
|
| 889 |
+
enabled=cfg_enabled,
|
| 890 |
+
scale=resolved_cfg_scale,
|
| 891 |
+
start_step=_integer(
|
| 892 |
+
cfg_start_step,
|
| 893 |
+
"CFG start step",
|
| 894 |
+
minimum=0,
|
| 895 |
+
maximum=None,
|
| 896 |
+
),
|
| 897 |
+
stop_step=_optional_integer(cfg_stop_step, "CFG stop step"),
|
| 898 |
+
)
|
| 899 |
+
resolved_pdg_curve = _choice(pdg_curve, _PDG_CURVES, "PDG curve")
|
| 900 |
+
resolved_pdg_noisy_scale = _number(pdg_noisy_scale, "PDG noisy scale")
|
| 901 |
+
resolved_pdg_clean_scale = (
|
| 902 |
+
resolved_pdg_noisy_scale
|
| 903 |
+
if resolved_pdg_curve is PdgCurve.CONSTANT
|
| 904 |
+
else _number(pdg_clean_scale, "PDG clean scale")
|
| 905 |
+
)
|
| 906 |
+
pdg = PdgGuidance(
|
| 907 |
+
enabled=pdg_enabled,
|
| 908 |
+
mode=active_pdg_mode,
|
| 909 |
+
curve=resolved_pdg_curve,
|
| 910 |
+
noisy_scale=resolved_pdg_noisy_scale,
|
| 911 |
+
clean_scale=resolved_pdg_clean_scale,
|
| 912 |
+
power=_number(pdg_power, "PDG power"),
|
| 913 |
+
start_step=_integer(
|
| 914 |
+
pdg_start_step,
|
| 915 |
+
"PDG start step",
|
| 916 |
+
minimum=0,
|
| 917 |
+
maximum=None,
|
| 918 |
+
),
|
| 919 |
+
stop_step=_optional_integer(pdg_stop_step, "PDG stop step"),
|
| 920 |
+
)
|
| 921 |
+
resolved_image_count = _integer(
|
| 922 |
+
image_count,
|
| 923 |
+
"image count",
|
| 924 |
+
minimum=1,
|
| 925 |
+
maximum=_MAX_IMAGE_COUNT,
|
| 926 |
+
)
|
| 927 |
+
resolved_seed = _integer(
|
| 928 |
+
seed,
|
| 929 |
+
"seed",
|
| 930 |
+
minimum=0,
|
| 931 |
+
maximum=_MAX_SEED - resolved_image_count + 1,
|
| 932 |
+
)
|
| 933 |
+
inference = CanterInferenceConfig(
|
| 934 |
+
height=_image_dimension(height, "height"),
|
| 935 |
+
width=_image_dimension(width, "width"),
|
| 936 |
+
steps=_integer(steps, "steps", minimum=1, maximum=None),
|
| 937 |
+
solver=_choice(solver, _SOLVERS, "solver"),
|
| 938 |
+
schedule=_choice(schedule, _SCHEDULES, "schedule"),
|
| 939 |
+
log_snr_shift=_number(log_snr_shift, "log-SNR shift"),
|
| 940 |
+
cfg=cfg,
|
| 941 |
+
pdg=pdg,
|
| 942 |
+
self_attention_gain=_number(
|
| 943 |
+
self_attention_gain,
|
| 944 |
+
"self-attention gain",
|
| 945 |
+
),
|
| 946 |
+
euler_maruyama_multiplier=_number(
|
| 947 |
+
euler_maruyama_multiplier,
|
| 948 |
+
"SDE noise multiplier",
|
| 949 |
+
),
|
| 950 |
+
er_sde_noise_multiplier=_number(
|
| 951 |
+
euler_maruyama_multiplier,
|
| 952 |
+
"SDE noise multiplier",
|
| 953 |
+
),
|
| 954 |
+
seed=resolved_seed,
|
| 955 |
+
generator=None,
|
| 956 |
+
)
|
| 957 |
+
return CanterWebRequest(
|
| 958 |
+
prompt=_validated_prompt(prompt),
|
| 959 |
+
image_count=resolved_image_count,
|
| 960 |
+
config=CanterPipelineConfig(
|
| 961 |
+
inference=inference,
|
| 962 |
+
output_type=CanterOutputType.PIL,
|
| 963 |
+
),
|
| 964 |
+
)
|
| 965 |
+
|
| 966 |
+
|
| 967 |
+
def _image_config(
|
| 968 |
+
request: CanterWebRequest,
|
| 969 |
+
image_index: int,
|
| 970 |
+
) -> CanterPipelineConfig:
|
| 971 |
+
"""Return a batch-one config using the next deterministic image seed."""
|
| 972 |
+
|
| 973 |
+
seed = request.config.inference.seed
|
| 974 |
+
if seed is None:
|
| 975 |
+
raise RuntimeError("A validated Canter web request must contain a seed.")
|
| 976 |
+
inference = replace(
|
| 977 |
+
request.config.inference,
|
| 978 |
+
seed=seed + image_index,
|
| 979 |
+
generator=None,
|
| 980 |
+
)
|
| 981 |
+
return replace(request.config, inference=inference)
|
| 982 |
+
|
| 983 |
+
|
| 984 |
+
def _pdg_metadata(pdg: PdgGuidance) -> dict[str, object]:
|
| 985 |
+
"""Return PDG fields in public API order."""
|
| 986 |
+
|
| 987 |
+
return {
|
| 988 |
+
"enabled": pdg.enabled,
|
| 989 |
+
"mode": pdg.mode.value,
|
| 990 |
+
"curve": pdg.curve.value,
|
| 991 |
+
"noisy_scale": pdg.noisy_scale,
|
| 992 |
+
"clean_scale": pdg.clean_scale,
|
| 993 |
+
"power": pdg.power,
|
| 994 |
+
"start_step": pdg.start_step,
|
| 995 |
+
"stop_step": pdg.stop_step,
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
+
|
| 999 |
+
def _cfg_metadata(cfg: CfgGuidance) -> dict[str, object]:
|
| 1000 |
+
"""Return CFG fields in public API order."""
|
| 1001 |
+
|
| 1002 |
+
return {
|
| 1003 |
+
"enabled": cfg.enabled,
|
| 1004 |
+
"scale": cfg.scale,
|
| 1005 |
+
"start_step": cfg.start_step,
|
| 1006 |
+
"stop_step": cfg.stop_step,
|
| 1007 |
+
}
|
| 1008 |
+
|
| 1009 |
+
|
| 1010 |
+
def _png_metadata_json(
|
| 1011 |
+
*,
|
| 1012 |
+
prompt: str,
|
| 1013 |
+
config: CanterPipelineConfig,
|
| 1014 |
+
metadata: CanterPipelineMetadata,
|
| 1015 |
+
) -> str:
|
| 1016 |
+
"""Serialize reproducible UI settings with primary inputs first."""
|
| 1017 |
+
|
| 1018 |
+
inference = config.inference
|
| 1019 |
+
if inference.seed is None:
|
| 1020 |
+
raise RuntimeError("PNG metadata requires the resolved per-image seed.")
|
| 1021 |
+
payload: dict[str, object] = {
|
| 1022 |
+
"prompt": prompt,
|
| 1023 |
+
"seed": inference.seed,
|
| 1024 |
+
"width": inference.width,
|
| 1025 |
+
"height": inference.height,
|
| 1026 |
+
"steps": inference.steps,
|
| 1027 |
+
"solver": inference.solver.value,
|
| 1028 |
+
"schedule": inference.schedule.value,
|
| 1029 |
+
"pdg": _pdg_metadata(inference.pdg),
|
| 1030 |
+
"cfg": _cfg_metadata(inference.cfg),
|
| 1031 |
+
"self_attention_gain": inference.self_attention_gain,
|
| 1032 |
+
"log_snr_shift": inference.log_snr_shift,
|
| 1033 |
+
"euler_maruyama_multiplier": inference.euler_maruyama_multiplier,
|
| 1034 |
+
"er_sde_noise_multiplier": inference.er_sde_noise_multiplier,
|
| 1035 |
+
"code_version": metadata.code_version,
|
| 1036 |
+
"release": metadata.canter.release,
|
| 1037 |
+
"weight_dtype": metadata.canter.weight_dtype.value,
|
| 1038 |
+
}
|
| 1039 |
+
return json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
| 1040 |
+
|
| 1041 |
+
|
| 1042 |
+
def _attach_png_metadata(
|
| 1043 |
+
image: Image.Image,
|
| 1044 |
+
*,
|
| 1045 |
+
prompt: str,
|
| 1046 |
+
config: CanterPipelineConfig,
|
| 1047 |
+
metadata: CanterPipelineMetadata,
|
| 1048 |
+
) -> Image.Image:
|
| 1049 |
+
"""Attach the ordered API query for Gradio's PNG encoder."""
|
| 1050 |
+
|
| 1051 |
+
image.info[_PNG_METADATA_KEY] = _png_metadata_json(
|
| 1052 |
+
prompt=prompt,
|
| 1053 |
+
config=config,
|
| 1054 |
+
metadata=metadata,
|
| 1055 |
+
)
|
| 1056 |
+
return image
|
| 1057 |
+
|
| 1058 |
+
|
| 1059 |
+
def random_seed() -> int:
|
| 1060 |
+
"""Return a non-negative seed accepted by the Canter web form."""
|
| 1061 |
+
|
| 1062 |
+
return secrets.randbelow(_MAX_SEED + 1)
|
| 1063 |
+
|
| 1064 |
+
|
| 1065 |
+
def _model_summary(metadata: CanterPipelineMetadata) -> str:
|
| 1066 |
+
"""Render immutable model and decoder provenance above the controls."""
|
| 1067 |
+
|
| 1068 |
+
canter = metadata.canter
|
| 1069 |
+
return (
|
| 1070 |
+
f"**Canter checkpoint {canter.release}** · "
|
| 1071 |
+
f"code `{metadata.code_version}` · "
|
| 1072 |
+
f"{canter.weight_dtype.value} EMA weights \n"
|
| 1073 |
+
f"VAE `{metadata.vae_repository}` @ `{metadata.vae_revision[:12]}`"
|
| 1074 |
+
)
|
| 1075 |
+
|
| 1076 |
+
|
| 1077 |
+
def _unloaded_model_status() -> str:
|
| 1078 |
+
"""Render the explicit lazy-reload state above the controls."""
|
| 1079 |
+
|
| 1080 |
+
return (
|
| 1081 |
+
"**Canter model unloaded** · CUDA and compiler caches cleared \n"
|
| 1082 |
+
"The next generation will reload and compile the selected checkpoint."
|
| 1083 |
+
)
|
| 1084 |
+
|
| 1085 |
+
|
| 1086 |
+
def _log_generation_request(request: CanterWebRequest) -> None:
|
| 1087 |
+
"""Log the exact effective controls consumed by one generation request."""
|
| 1088 |
+
|
| 1089 |
+
inference = request.config.inference
|
| 1090 |
+
prompt_digest = sha256(request.prompt.encode("utf-8")).hexdigest()[:12]
|
| 1091 |
+
_LOGGER.info(
|
| 1092 |
+
"Canter request | "
|
| 1093 |
+
f"prompt_sha256={prompt_digest} | images={request.image_count} | "
|
| 1094 |
+
f"size={inference.width}x{inference.height} | seed={inference.seed} | "
|
| 1095 |
+
f"steps={inference.steps} | solver={inference.solver.value} | "
|
| 1096 |
+
f"schedule={inference.schedule.value} | "
|
| 1097 |
+
f"log_snr_shift={inference.log_snr_shift:g} | "
|
| 1098 |
+
f"self_attention_gain={inference.self_attention_gain:g} | "
|
| 1099 |
+
f"{_pdg_status(inference)} | cfg_enabled={inference.cfg.enabled} | "
|
| 1100 |
+
f"cfg_scale={inference.cfg.scale}",
|
| 1101 |
+
)
|
| 1102 |
+
|
| 1103 |
+
|
| 1104 |
+
def _pdg_status(inference: CanterInferenceConfig) -> str:
|
| 1105 |
+
"""Render the effective PDG request consumed by latent inference."""
|
| 1106 |
+
|
| 1107 |
+
pdg = inference.pdg
|
| 1108 |
+
if not pdg.enabled:
|
| 1109 |
+
return "PDG off"
|
| 1110 |
+
stop = inference.steps - 1 if pdg.stop_step is None else pdg.stop_step
|
| 1111 |
+
match pdg.curve:
|
| 1112 |
+
case PdgCurve.CONSTANT:
|
| 1113 |
+
scale = f"{float(pdg.noisy_scale):g}"
|
| 1114 |
+
case PdgCurve.LINEAR:
|
| 1115 |
+
scale = f"linear {float(pdg.noisy_scale):g}→{float(pdg.clean_scale):g}"
|
| 1116 |
+
case PdgCurve.POWER:
|
| 1117 |
+
scale = (
|
| 1118 |
+
f"power {float(pdg.noisy_scale):g}→"
|
| 1119 |
+
f"{float(pdg.clean_scale):g} (p={float(pdg.power):g})"
|
| 1120 |
+
)
|
| 1121 |
+
case _ as unreachable:
|
| 1122 |
+
raise RuntimeError(f"Unsupported PDG curve: {unreachable}")
|
| 1123 |
+
return f"PDG `{pdg.mode.value}` {scale}, updates {pdg.start_step}–{stop}"
|
| 1124 |
+
|
| 1125 |
+
|
| 1126 |
+
def _generation_status(
|
| 1127 |
+
metadata: CanterPipelineMetadata,
|
| 1128 |
+
*,
|
| 1129 |
+
request: CanterWebRequest,
|
| 1130 |
+
elapsed: float,
|
| 1131 |
+
completed: int,
|
| 1132 |
+
) -> str:
|
| 1133 |
+
"""Render incremental timing and deterministic request provenance."""
|
| 1134 |
+
|
| 1135 |
+
inference = request.config.inference
|
| 1136 |
+
image_word = "image" if completed == 1 else "images"
|
| 1137 |
+
if inference.seed is None:
|
| 1138 |
+
raise RuntimeError("A validated Canter web request must contain a seed.")
|
| 1139 |
+
final_seed = inference.seed + completed - 1
|
| 1140 |
+
seed_text = (
|
| 1141 |
+
str(inference.seed) if completed == 1 else f"{inference.seed}–{final_seed}"
|
| 1142 |
+
)
|
| 1143 |
+
progress_text = (
|
| 1144 |
+
f"Generated **{completed}/{request.image_count} {image_word}**"
|
| 1145 |
+
if completed < request.image_count
|
| 1146 |
+
else f"Generated **{completed} {image_word}**"
|
| 1147 |
+
)
|
| 1148 |
+
return (
|
| 1149 |
+
f"{progress_text} in **{elapsed:.2f}s** · "
|
| 1150 |
+
f"seeds `{seed_text}` · batch size `1` · "
|
| 1151 |
+
f"{inference.width}×{inference.height} · "
|
| 1152 |
+
f"{inference.steps} {inference.solver.value} updates · "
|
| 1153 |
+
f"{_pdg_status(inference)} · "
|
| 1154 |
+
f"Canter code `{metadata.code_version}` · "
|
| 1155 |
+
f"checkpoint `{metadata.canter.release}`"
|
| 1156 |
+
)
|
| 1157 |
+
|
| 1158 |
+
|
| 1159 |
+
def load_web_pipeline(config: CanterWebLaunchConfig) -> CanterPipeline:
|
| 1160 |
+
"""Load and compile exactly one selected Canter backend for the web process."""
|
| 1161 |
+
|
| 1162 |
+
return CanterPipeline.from_pretrained(
|
| 1163 |
+
config.model,
|
| 1164 |
+
dtype=config.dtype,
|
| 1165 |
+
text_backend=config.text_backend,
|
| 1166 |
+
device=config.device,
|
| 1167 |
+
revision=config.revision,
|
| 1168 |
+
cache_dir=config.cache_dir,
|
| 1169 |
+
compile_model=True,
|
| 1170 |
+
vae=None,
|
| 1171 |
+
)
|
release.json
CHANGED
|
@@ -2,6 +2,11 @@
|
|
| 2 |
"dtype": "bfloat16",
|
| 3 |
"exporter_sha256": "df8a6cf46d78b38d8297b0c75a76c1eaaf859bb55ec3375a623dc4804d5571d1",
|
| 4 |
"exporter_source_commit": "e75e83d792f45eb82e60e022500edead452be79a",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
"license": "MG-BY-SA-2.0",
|
| 6 |
"parameter_count": 2061680772,
|
| 7 |
"parity_result_ids": [
|
|
|
|
| 2 |
"dtype": "bfloat16",
|
| 3 |
"exporter_sha256": "df8a6cf46d78b38d8297b0c75a76c1eaaf859bb55ec3375a623dc4804d5571d1",
|
| 4 |
"exporter_source_commit": "e75e83d792f45eb82e60e022500edead452be79a",
|
| 5 |
+
"latent_rgb_preview_source_checkpoint_sha256": "f42fd1af65010acba63582288d1802e054ab1cde4c3226dc02494c882cdea78e",
|
| 6 |
+
"latent_rgb_preview_source_config_sha256": "8aff4041a12fe2165442867299f82e14853c2f71d9c94b6705e67521d6c042fb",
|
| 7 |
+
"latent_rgb_preview_source_run_hash": "553803c53a59848a",
|
| 8 |
+
"latent_rgb_preview_source_run_id": "625",
|
| 9 |
+
"latent_rgb_preview_source_step": 5600,
|
| 10 |
"license": "MG-BY-SA-2.0",
|
| 11 |
"parameter_count": 2061680772,
|
| 12 |
"parity_result_ids": [
|
weights_manifest.json
CHANGED
|
@@ -6207,6 +6207,35 @@
|
|
| 6207 |
],
|
| 6208 |
"total_size": 4132975120
|
| 6209 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6210 |
"text_encoder": {
|
| 6211 |
"parameter_count": 283161600,
|
| 6212 |
"tensor_count": 217,
|
|
|
|
| 6207 |
],
|
| 6208 |
"total_size": 4132975120
|
| 6209 |
},
|
| 6210 |
+
"latent_rgb_preview": {
|
| 6211 |
+
"filename": "canter/latent_rgb_preview.safetensors",
|
| 6212 |
+
"parameter_count": 1548,
|
| 6213 |
+
"tensor_count": 2,
|
| 6214 |
+
"tensors": [
|
| 6215 |
+
{
|
| 6216 |
+
"dtype": "float32",
|
| 6217 |
+
"name": "projection.bias",
|
| 6218 |
+
"nbytes": 48,
|
| 6219 |
+
"numel": 12,
|
| 6220 |
+
"shape": [
|
| 6221 |
+
12
|
| 6222 |
+
]
|
| 6223 |
+
},
|
| 6224 |
+
{
|
| 6225 |
+
"dtype": "float32",
|
| 6226 |
+
"name": "projection.weight",
|
| 6227 |
+
"nbytes": 6144,
|
| 6228 |
+
"numel": 1536,
|
| 6229 |
+
"shape": [
|
| 6230 |
+
12,
|
| 6231 |
+
128,
|
| 6232 |
+
1,
|
| 6233 |
+
1
|
| 6234 |
+
]
|
| 6235 |
+
}
|
| 6236 |
+
],
|
| 6237 |
+
"total_size": 6192
|
| 6238 |
+
},
|
| 6239 |
"text_encoder": {
|
| 6240 |
"parameter_count": 283161600,
|
| 6241 |
"tensor_count": 217,
|