brush-paint: walker tracing, optimizer rewrite, distributed worker
Walker now records per-step traces (WalkStep / WalkCandidate) for the debug viz to scrub through. The optimizer is reorganised around a continuous-space, multi-start, golden-section line-search descent in a new module (`brush_paint_opt`), with a worker binary (`paint_opt_worker`) and an SSH-distribution orchestrator (`scripts/optimize_distributed.sh`). Frontend `PaintDebugView` rebuilt as a full step-by-step debugger: walk picker, step scrubber, candidate-direction overlay with score breakdown, live stats + score-component panel, optimizer-run button streaming `optimizer-progress` events, and a hardcoded test-letter picker so debugging works without an image loaded. Defaults retuned by the optimizer. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
370
BRUSH_PAINT_ALGORITHM.md
Normal file
370
BRUSH_PAINT_ALGORITHM.md
Normal file
@@ -0,0 +1,370 @@
|
||||
# Brush-Paint Algorithm
|
||||
|
||||
A walkthrough of `src/brush_paint.rs` as it stands today. This file describes
|
||||
*what the code does*, in the order it does it. No proposed changes — read
|
||||
this first, then we can talk about what to keep, replace, or rip out.
|
||||
|
||||
---
|
||||
|
||||
## Mental model: reverse Microsoft Paint
|
||||
|
||||
Inputs are filled-pixel polygons (`Hull`s) extracted from a rasterized
|
||||
glyph. The plotter has a fixed-radius pen. We need to produce a sequence
|
||||
of pen strokes (`Vec<(f32, f32)>`) such that sweeping the pen along each
|
||||
stroke covers the polygon's ink while minimizing pen lifts and bg paint.
|
||||
|
||||
Think of it as an *inverse* of MS Paint:
|
||||
- MS Paint takes a stroke and produces a painted region (forward).
|
||||
- We take a painted region and produce strokes (inverse).
|
||||
|
||||
A stroke can pass over already-painted pixels — that's repainting, and
|
||||
it's a normal part of one continuous pen-down. A pen lift starts a new
|
||||
stroke. The algorithm trades these against each other.
|
||||
|
||||
---
|
||||
|
||||
## Top-level flow (`paint_fill_with`)
|
||||
|
||||
```
|
||||
input: hull (pixel set), PaintParams
|
||||
output: FillResult { strokes: Vec<Vec<(f32, f32)>> }
|
||||
|
||||
1. compute SDF of the hull (chamfer 3-4)
|
||||
2. brush_radius = brush_radius_factor * sdf_percentile(...) + brush_radius_offset_px
|
||||
3. build Grid (per-pixel state: was_ink, unpainted, sdf, skel_endpoints)
|
||||
4. for stroke_idx in 0..max_strokes:
|
||||
if no unpainted ink remains → break
|
||||
start = pick_next_component(...) // writing-order seed
|
||||
path = trace_stroke(start, ...) // bidirectional walk + polish
|
||||
simplified = rdp_simplify_f32(path, output_rdp_eps)
|
||||
emit simplified
|
||||
5. return strokes
|
||||
```
|
||||
|
||||
Per stroke, the pipeline is:
|
||||
|
||||
```
|
||||
pick_next_component → trace_stroke → rdp_simplify_f32
|
||||
(start) (waypoints) (decimate)
|
||||
```
|
||||
|
||||
`trace_stroke` itself decomposes into:
|
||||
|
||||
```
|
||||
walk_brush(forward) → walk_brush(backward) → polish_path
|
||||
↑ ↑ ↓
|
||||
└─ score / step / repaint-search loop ─┘ relax + shorten
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Data structures
|
||||
|
||||
### `Grid` (per-glyph state)
|
||||
- `bx, by, width, height` — bbox of the hull in source-image coords
|
||||
- `was_ink: Vec<bool>` — original ink mask (immutable)
|
||||
- `unpainted: Vec<bool>` — ink not yet covered by any disk this run
|
||||
- `sdf: Vec<f32>` — chamfer-3-4 distance / 3 (≈ Euclidean px from polygon edge)
|
||||
- `skel_endpoints: Vec<(i32, i32)>` — degree-1 nodes of the Zhang-Suen
|
||||
skeleton after spur pruning. The "legs" — natural pen-down anchors.
|
||||
- `ink_total`, `ink_remaining` — counters
|
||||
|
||||
### `PaintParams` (knobs)
|
||||
Every parameter is described in the table at the bottom.
|
||||
|
||||
---
|
||||
|
||||
## Stage 1: brush sizing
|
||||
|
||||
```rust
|
||||
effective_sdf = sdf_percentile(dist, brush_radius_percentile).max(0.5);
|
||||
brush_radius = brush_radius_factor * effective_sdf + brush_radius_offset_px;
|
||||
```
|
||||
|
||||
`sdf_percentile` sorts all per-pixel SDF values and returns the qth.
|
||||
At q=0.99 (default) it ignores the top 1% of pixels — this clips the
|
||||
spike at junctions where two strokes cross (medial-axis SDF goes well
|
||||
past stroke half-width there). Without that clipping, the brush ends
|
||||
up sized for the junction blob and is too fat for the rest of the
|
||||
stroke.
|
||||
|
||||
Brush radius is a **single value per hull**, fixed for the whole run.
|
||||
|
||||
---
|
||||
|
||||
## Stage 2: pick_next_component (start picking)
|
||||
|
||||
```
|
||||
1. Flood-fill 4-connected components of the unpainted ink.
|
||||
2. For each component below min_component_factor * brush_area pixels:
|
||||
paint each pixel once and forget about it (sub-threshold leftover).
|
||||
3. Among the surviving components, pick the topmost-leftmost.
|
||||
4. Within that component, prefer a skeleton endpoint that lies in
|
||||
its still-unpainted ink (writing-order: topmost-leftmost endpoint).
|
||||
Fall back to topmost-leftmost ink pixel if no endpoint qualifies
|
||||
(closed shapes like O, or after partial fills).
|
||||
5. Snap the chosen pixel to the local ridge (gradient-ascent on SDF
|
||||
in a 5×5 window, up to 16 steps).
|
||||
```
|
||||
|
||||
Output: `(f32, f32)` — the start point on the medial axis.
|
||||
|
||||
---
|
||||
|
||||
## Stage 3: trace_stroke (one stroke)
|
||||
|
||||
```
|
||||
1. Snapshot grid state (so polish can re-evaluate against pre-stroke ink).
|
||||
2. forward = walk_brush(start, init_dir=(0,1)) // bias downward at first step
|
||||
3. If forward.len() < 2 → return forward.
|
||||
4. back_init = -unit(forward[1] - forward[0])
|
||||
5. backward = walk_brush(start, Some(back_init))
|
||||
6. combined = reverse(backward) ++ forward[1..]
|
||||
7. Restore unpainted mask to pre-stroke state.
|
||||
8. polished = polish_path(combined) // relax + shorten
|
||||
9. Re-paint disks along polished path into grid.
|
||||
10. return polished
|
||||
```
|
||||
|
||||
The bidirectional walk guarantees we cover both sides of the start, even
|
||||
when the start landed mid-stroke. The init_dir on the forward walk
|
||||
biases the very first step downward (writing-order); the backward walk
|
||||
then handles whatever sticks up above the start.
|
||||
|
||||
---
|
||||
|
||||
## Stage 4: walk_brush (the stepping loop)
|
||||
|
||||
This is the core of the algorithm. Per iteration:
|
||||
|
||||
```
|
||||
1. For each candidate direction:
|
||||
a. skip if dot(dir, prev_dir) < -0.7 (no immediate flip-back)
|
||||
b. probe = p + dir * step_size; skip if probe pixel isn't ink
|
||||
c. compute lookahead_score; add momentum bonus
|
||||
keep best (dir, score)
|
||||
2. Compute new_p = p + dir * step_size.
|
||||
3. would_be_stuck = (new_p disk has 0 unpainted) AND (p disk has 0 unpainted)
|
||||
4. Decide:
|
||||
IF score >= min_score AND NOT would_be_stuck:
|
||||
chosen_dir = dir (normal step)
|
||||
ELSE:
|
||||
chosen_dir = nearest_unpainted_through_ink(...)
|
||||
IF that returns nothing OR cost > pen_lift_penalty: BREAK
|
||||
5. Step: p ← p + chosen_dir * step_size
|
||||
6. Paint the disk at the new p.
|
||||
```
|
||||
|
||||
Both the normal score path (step 1b) and the repaint Dijkstra reject
|
||||
any candidate whose centre falls off-ink, so every committed waypoint
|
||||
lies on an originally-ink pixel.
|
||||
|
||||
Two ways the stroke ends:
|
||||
- no candidate direction passed the filters (back-direction, ink probe), or
|
||||
- repaint search returns no reachable target within budget.
|
||||
|
||||
---
|
||||
|
||||
### Stage 4a: lookahead_score (direction scoring)
|
||||
|
||||
```rust
|
||||
score = Σₖ₌₁..lookahead_steps (1/k) × [ new(k) − overpaint*repaint(k) − walk_bg*bg(k) ]
|
||||
+ (has_momentum ? momentum_weight × max(0, dot(dir, prev_dir)) × brush_area : 0)
|
||||
```
|
||||
|
||||
Where `new(k)`, `repaint(k)`, `bg(k)` are pixel counts under the disk
|
||||
centered at `p + dir * step_size * k`:
|
||||
- `new` — unpainted ink (ink we want, weighted +1)
|
||||
- `repaint` — ink already painted this run (ignorable cost)
|
||||
- `bg` — never-was-ink pixels (off-glyph paint)
|
||||
|
||||
Note that `bg` here means *under-disk pixels that are background*, not
|
||||
*pixels we'd freshly paint* — if the prior stroke already swept some
|
||||
of these bg pixels, they still count toward the penalty here.
|
||||
|
||||
The 1/k weighting means farther-away ink contributes less. With
|
||||
`lookahead_steps=4` and `step_size=0.5*r`, the horizon is 2r ahead
|
||||
and 90% of the score comes from the first 1-2 steps.
|
||||
|
||||
The `min_score` gate is `min_score_factor * brush_area` — i.e. "the
|
||||
best direction must add at least 5% of a fresh disk worth of new
|
||||
coverage in its lookahead horizon".
|
||||
|
||||
---
|
||||
|
||||
### Stage 4b: nearest_unpainted_through_ink (Dijkstra repaint)
|
||||
|
||||
When the walker is stuck or its score is too low, this is the fallback.
|
||||
|
||||
```
|
||||
SDF-weighted Dijkstra on integer pixels:
|
||||
- start: current walker position
|
||||
- graph nodes: every ink pixel (painted OR unpainted) within
|
||||
pen_lift_reach * brush_radius euclidean distance
|
||||
- graph edges: 8-connected neighbors that are also ink
|
||||
- edge cost: euclidean_length × (1 + 1.5 / (sdf + 0.5))
|
||||
// ridges (high sdf) are cheap, near-edge pixels expensive
|
||||
- target: first popped pixel that is_unpainted
|
||||
|
||||
Return: (unit-vector from start to that target, total cost).
|
||||
Break the walker if cost > pen_lift_penalty.
|
||||
```
|
||||
|
||||
This is what lets one stroke double back through M's painted apex to
|
||||
reach the second diagonal: starting from the apex, the cheapest path
|
||||
through painted ink is along the centerline, which leads up the
|
||||
diagonal that's still unpainted.
|
||||
|
||||
The walker takes ONE step in that direction. Next iteration, the
|
||||
search runs again from the new position. The walker keeps doubling
|
||||
back until either the regular score becomes positive again (we
|
||||
reached unpainted ink) or the Dijkstra fails (no unpainted ink in
|
||||
budget).
|
||||
|
||||
---
|
||||
|
||||
## Stage 5: polish_path (post-walk relaxation)
|
||||
|
||||
Tick-tock for `polish_iters` rounds:
|
||||
|
||||
### relax_step
|
||||
For each waypoint:
|
||||
1. Find nearest uncovered ink pixel within `polish_search_factor * brush_radius`.
|
||||
2. Compute proposed shift = 0.7 × distance to that pixel, capped at 0.6 × brush_radius.
|
||||
3. Reject if the proposed center isn't on ink.
|
||||
4. Score: `evaluate_perturbation` = `ink_gain - ink_loss - outside_penalty * bg_delta`
|
||||
where the deltas are computed against the running coverage count
|
||||
(how many waypoint disks currently cover each pixel).
|
||||
5. Accept if score > 0.
|
||||
|
||||
`outside_penalty` (default 2.0) is heavy: 1 new bg pixel under brush
|
||||
costs as much as 2 ink pixels gained. This is the strict version of
|
||||
the walker's `walk_bg_penalty`.
|
||||
|
||||
### shorten_step
|
||||
For each interior waypoint, drop it if every unpainted ink pixel
|
||||
under its disk is also covered by some *other* waypoint's disk
|
||||
(redundant). Reduces output gcode size without losing coverage.
|
||||
|
||||
---
|
||||
|
||||
## Stage 6: rdp_simplify_f32 (output decimation)
|
||||
|
||||
Drop waypoints whose perpendicular distance to the chord between their
|
||||
neighbors is under `output_rdp_eps` (px). Reduces gcode size without
|
||||
moving any retained waypoint.
|
||||
|
||||
---
|
||||
|
||||
## Where each penalty lives
|
||||
|
||||
| Stage | Function | What it computes | Penalties used |
|
||||
|------|----------|------------------|----------------|
|
||||
| Step direction | `lookahead_score` | per-direction goodness | `overpaint_penalty`, `momentum_weight` |
|
||||
| Step gate | `min_score = min_score_factor × brush_area` | "is best dir good enough" | `min_score_factor` |
|
||||
| Repaint Dijkstra | `nearest_unpainted_through_ink` | path cost through ink | `pen_lift_penalty`, `pen_lift_reach` |
|
||||
| Polish perturbation | `evaluate_perturbation` | net-coverage change of a 1-waypoint move | `bg_penalty` |
|
||||
|
||||
The walker enforces ink containment as a hard constraint (probe step
|
||||
must land on ink) and so doesn't need a soft bg term. Polish is the
|
||||
only stage that weights bg pixels — it pulls waypoints onto the ridge.
|
||||
|
||||
---
|
||||
|
||||
## Parameters reference
|
||||
|
||||
### Brush sizing
|
||||
| Param | Default | Effect |
|
||||
|------|---------|--------|
|
||||
| `brush_radius_factor` | 1.0 | × effective_sdf |
|
||||
| `brush_radius_offset_px` | 0.5 | added after the multiplier |
|
||||
| `brush_radius_percentile` | 0.99 | which SDF percentile defines "the typical stroke half-width" |
|
||||
|
||||
### Walker stepping
|
||||
| Param | Default | Effect |
|
||||
|------|---------|--------|
|
||||
| `step_size_factor` | 0.5 | step size as × brush radius |
|
||||
| `n_directions` | 24 | candidates evaluated per step |
|
||||
| `lookahead_steps` | 4 | how many disks ahead to score |
|
||||
| `momentum_weight` | 0.4 | bonus for keeping current heading |
|
||||
|
||||
### Walker scoring
|
||||
| Param | Default | Effect |
|
||||
|------|---------|--------|
|
||||
| `overpaint_penalty` | 0.05 | per-pixel cost of repainting ink (in lookahead) |
|
||||
| `min_score_factor` | 0.05 | stroke ends when best score < this × brush_area |
|
||||
|
||||
### Polish (post-walk relaxation)
|
||||
| Param | Default | Effect |
|
||||
|------|---------|--------|
|
||||
| `polish_iters` | 4 | relax↔shorten rounds |
|
||||
| `polish_search_factor` | 0.5 | relax search radius (× brush radius) |
|
||||
| `bg_penalty` | 2.0 | per-pixel cost of bg under disk during a perturbation |
|
||||
|
||||
### Components / strokes
|
||||
| Param | Default | Effect |
|
||||
|------|---------|--------|
|
||||
| `min_component_factor` | 0.6 | smallest unpainted component worth a new stroke (× brush area) |
|
||||
| `pen_lift_penalty` | 30.0 | Dijkstra path-cost budget for double-backs |
|
||||
| `pen_lift_reach` | 6.0 | Dijkstra search radius (× brush radius) |
|
||||
| `max_steps_per_stroke` | 4000 | safety cap |
|
||||
| `max_strokes` | 12 | safety cap per hull |
|
||||
|
||||
### Output smoothing
|
||||
| Param | Default | Effect |
|
||||
|------|---------|--------|
|
||||
| `output_rdp_eps` | 0.5 | RDP simplification tolerance (px); 0 disables |
|
||||
|
||||
---
|
||||
|
||||
## Optimizer
|
||||
|
||||
`paint_fill_with` is a deterministic transform `(hull, params) → strokes`.
|
||||
The sweep wraps it: try a list of `PaintParams` variants, score each
|
||||
result, return the best. The inner pipeline is unchanged.
|
||||
|
||||
```
|
||||
PaintMetrics // strokes, total_length, bg_painted, ink_unpainted, brush_radius
|
||||
metrics_for // run paint_fill_debug, build PaintMetrics
|
||||
ScoreWeights // tunable weights: stroke, length, bg, unpainted
|
||||
score_weighted // PaintMetrics → f32 (lower = better)
|
||||
default_score // ScoreWeights::default() applied
|
||||
paint_fill_sweep // generic: try N variants, pick best
|
||||
paint_fill_sweep_radius // convenience: sweep absolute brush radii
|
||||
```
|
||||
|
||||
Default scoring weights (`ScoreWeights::default`):
|
||||
|
||||
| Weight | Default | Meaning |
|
||||
|------|---------|---------|
|
||||
| `stroke` | 800.0 | one pen-lift = 800 score units |
|
||||
| `length` | 1.0 | 1 px of stroke = 1 unit |
|
||||
| `bg` | 5.0 | 1 bg pixel painted = 5 units |
|
||||
| `unpainted` | 1000.0 | 1 ink pixel uncovered = 1000 (effectively a hard constraint) |
|
||||
|
||||
A new stroke is only worth ≈160 saved bg pixels. Coverage shortfall
|
||||
dominates everything.
|
||||
|
||||
## Known properties
|
||||
|
||||
- All 8 unit tests in `brush_paint::tests` pass: full coverage, ≤4
|
||||
strokes/char, every waypoint lies on ink, off-glyph % under 0.42 floor.
|
||||
- Brush size is a single value per hull.
|
||||
- Every emitted waypoint is guaranteed on-ink (walker rejects off-ink
|
||||
candidates; repaint Dijkstra graph is restricted to ink).
|
||||
- The Dijkstra repaint and the lookahead scoring are independent
|
||||
systems — they don't share a cost vocabulary.
|
||||
- `pen_lift_penalty` is in Dijkstra step units (euclidean × ridge-aversion).
|
||||
|
||||
---
|
||||
|
||||
## Files
|
||||
|
||||
- `src/brush_paint.rs` — everything described above
|
||||
- `src/fill.rs` — `chamfer_distance`, `zhang_suen_thin`, `prune_skeleton_spurs`,
|
||||
`zs_neighbors`, `rdp_simplify_f32`
|
||||
- `src-frontend/src/components/PaintDebugView.jsx` — the live slider UI
|
||||
- `src-frontend/src/hooks/useTauri.js` — the JS-side `DEFAULT_PAINT_PARAMS`
|
||||
(must match Rust `PaintParams::default()`)
|
||||
- `target/paint_report/REPORT.md` + `*.png` — the alphabet diagnostic
|
||||
output (regenerate via `cargo test --release --lib paint_alphabet_report -- --ignored --nocapture`)
|
||||
@@ -44,3 +44,7 @@ path = "src/gen_test_assets.rs"
|
||||
[[bin]]
|
||||
name = "pipeline_bench"
|
||||
path = "src/pipeline_bench.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "paint_opt_worker"
|
||||
path = "src/bin/paint_opt_worker.rs"
|
||||
|
||||
115
scripts/optimize_distributed.sh
Executable file
115
scripts/optimize_distributed.sh
Executable file
@@ -0,0 +1,115 @@
|
||||
#!/usr/bin/env bash
|
||||
# Distributed brush-paint optimizer.
|
||||
#
|
||||
# Splits N optimizer starts between THIS machine and a remote SSH host,
|
||||
# runs each as a `paint_opt_worker N` invocation, and prints the best
|
||||
# result by score. Each "start" runs ONE refinement pass (golden-section
|
||||
# coordinate descent from one randomized seed); they're embarrassingly
|
||||
# parallel — no synchronisation, no shared state.
|
||||
#
|
||||
# Usage:
|
||||
# scripts/optimize_distributed.sh [N] [REMOTE]
|
||||
# N = total number of starts (default 32). 0..3 are seeded,
|
||||
# 4..N-1 are random.
|
||||
# REMOTE = "user@host:/path/to/Trac3r-rust" (default: env $REMOTE_TRAC3R).
|
||||
#
|
||||
# Examples:
|
||||
# scripts/optimize_distributed.sh
|
||||
# scripts/optimize_distributed.sh 64 me@beefy:~/source/Trac3r-rust
|
||||
#
|
||||
# Defaults: 32 starts, ~split half/half. Adjust LOCAL_FRAC for unequal
|
||||
# machines. With your beefy=24t, mac=8t-ish, LOCAL_FRAC=0.25 puts 75%
|
||||
# of work on the desktop.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
N="${1:-32}"
|
||||
REMOTE="${2:-${REMOTE_TRAC3R:-}}"
|
||||
LOCAL_FRAC="${LOCAL_FRAC:-0.25}" # 25% local, 75% remote (24t desktop)
|
||||
|
||||
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
|
||||
TMPDIR="$(mktemp -d -t opt-distrib.XXXXXX)"
|
||||
trap 'rm -rf "$TMPDIR"' EXIT
|
||||
|
||||
echo "[orch] $N starts, root=$ROOT, tmp=$TMPDIR" >&2
|
||||
|
||||
# How many starts go local vs remote.
|
||||
LOCAL_N=$(awk -v n="$N" -v f="$LOCAL_FRAC" 'BEGIN{ printf "%d", int(n*f + 0.5) }')
|
||||
REMOTE_N=$((N - LOCAL_N))
|
||||
|
||||
echo "[orch] split: $LOCAL_N local, $REMOTE_N remote" >&2
|
||||
|
||||
# Build local binary first (release mode).
|
||||
echo "[orch] cargo build --release --bin paint_opt_worker (local)…" >&2
|
||||
( cd "$ROOT" && cargo build --release --bin paint_opt_worker ) >&2
|
||||
|
||||
# Build remote binary in parallel if a remote is configured.
|
||||
REMOTE_BUILD_PID=""
|
||||
if [[ -n "$REMOTE" ]]; then
|
||||
HOST="${REMOTE%%:*}"
|
||||
RPATH="${REMOTE#*:}"
|
||||
echo "[orch] cargo build --release on remote ($HOST:$RPATH)…" >&2
|
||||
( ssh "$HOST" "cd '$RPATH' && cargo build --release --bin paint_opt_worker" >&2 ) &
|
||||
REMOTE_BUILD_PID=$!
|
||||
fi
|
||||
|
||||
# Wait for local build to be ready, then dispatch local work in
|
||||
# background. xargs -P0 = max parallelism (each worker uses rayon for
|
||||
# its own internal evaluations, but they're CPU-bound so we want the
|
||||
# OS to schedule).
|
||||
LOCAL_OUT="$TMPDIR/local.json"
|
||||
: > "$LOCAL_OUT"
|
||||
if (( LOCAL_N > 0 )); then
|
||||
echo "[orch] launching $LOCAL_N local workers…" >&2
|
||||
seq 0 $((LOCAL_N - 1)) | \
|
||||
xargs -n1 -P"$LOCAL_N" -I{} \
|
||||
"$ROOT/target/release/paint_opt_worker" {} \
|
||||
>> "$LOCAL_OUT" &
|
||||
LOCAL_PID=$!
|
||||
else
|
||||
LOCAL_PID=""
|
||||
fi
|
||||
|
||||
# Wait for remote build, then dispatch remote work.
|
||||
REMOTE_OUT="$TMPDIR/remote.json"
|
||||
: > "$REMOTE_OUT"
|
||||
REMOTE_PID=""
|
||||
if [[ -n "$REMOTE" && "$REMOTE_N" -gt 0 ]]; then
|
||||
if [[ -n "$REMOTE_BUILD_PID" ]]; then
|
||||
wait "$REMOTE_BUILD_PID"
|
||||
fi
|
||||
HOST="${REMOTE%%:*}"
|
||||
RPATH="${REMOTE#*:}"
|
||||
echo "[orch] launching $REMOTE_N remote workers on $HOST…" >&2
|
||||
# Generate the index list local-side and stream to xargs over ssh.
|
||||
seq "$LOCAL_N" $((N - 1)) | \
|
||||
ssh "$HOST" "cd '$RPATH' && xargs -n1 -P$REMOTE_N -I{} ./target/release/paint_opt_worker {}" \
|
||||
>> "$REMOTE_OUT" &
|
||||
REMOTE_PID=$!
|
||||
fi
|
||||
|
||||
# Wait for both pools.
|
||||
[[ -n "$LOCAL_PID" ]] && wait "$LOCAL_PID"
|
||||
[[ -n "$REMOTE_PID" ]] && wait "$REMOTE_PID"
|
||||
|
||||
# Collect + pick the best by lowest .score field. One JSON per line.
|
||||
ALL="$TMPDIR/all.json"
|
||||
cat "$LOCAL_OUT" "$REMOTE_OUT" > "$ALL"
|
||||
|
||||
LINES=$(wc -l < "$ALL" | tr -d ' ')
|
||||
echo "[orch] collected $LINES results" >&2
|
||||
if [[ "$LINES" -ne "$N" ]]; then
|
||||
echo "[orch] WARNING: expected $N, got $LINES" >&2
|
||||
fi
|
||||
|
||||
# Sort by score (ascending), print top 5 to stderr, full best to stdout.
|
||||
echo "" >&2
|
||||
echo "[orch] top 5 by score:" >&2
|
||||
sort -t : -k 1.1,1.1 "$ALL" \
|
||||
| jq -s 'sort_by(.score) | .[0:5] | .[] | "\(.start_idx)\t\(.score)"' -r >&2 \
|
||||
|| awk -F'"score":' '{ split($2, a, ","); printf "%s\t%s\n", $0, a[1] }' "$ALL" \
|
||||
| sort -k2 -n | head -5 >&2
|
||||
|
||||
echo "" >&2
|
||||
echo "[orch] best result (full JSON on stdout):" >&2
|
||||
jq -s 'sort_by(.score) | .[0]' "$ALL"
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import * as tauri from '../hooks/useTauri.js'
|
||||
import { DEFAULT_PAINT_PARAMS } from '../hooks/useTauri.js'
|
||||
|
||||
@@ -16,8 +17,67 @@ const LAYERS = [
|
||||
{ key: 'strokes', label: '6. Smoothed strokes', on: true },
|
||||
]
|
||||
|
||||
// Step-viz layers — toggled independently from the "final result" layers above.
|
||||
const STEP_LAYERS = [
|
||||
{ key: 'paintedSoFar', label: 'a. Painted-so-far', on: true },
|
||||
{ key: 'pathSoFar', label: 'b. Path up to step', on: true },
|
||||
{ key: 'futurePath', label: 'c. Future path (ghost)', on: true },
|
||||
{ key: 'brushHere', label: 'd. Brush footprint', on: true },
|
||||
{ key: 'momentum', label: 'e. Momentum arrow', on: true },
|
||||
{ key: 'candidates', label: 'f. Candidates', on: true },
|
||||
{ key: 'skeleton', label: 'g. Skeleton (n/a)', on: false },
|
||||
]
|
||||
|
||||
// Score weights — must match Rust's ScoreWeights::default().
|
||||
const SCORE_WEIGHTS = {
|
||||
stroke: 500,
|
||||
length: 5,
|
||||
bg: 50,
|
||||
repaint: 30,
|
||||
unpainted: 200,
|
||||
length_excess: 300,
|
||||
curvature: 500,
|
||||
brush_size: 2000,
|
||||
}
|
||||
const LENGTH_BUDGET_FACTOR = 1.5
|
||||
|
||||
const PLAY_SPEEDS = [
|
||||
{ label: 'slow', ms: 500 },
|
||||
{ label: 'normal', ms: 250 },
|
||||
{ label: 'fast', ms: 100 },
|
||||
]
|
||||
|
||||
const strokeHue = (i) => `hsl(${((i * 137.508) % 360).toFixed(1)}, 80%, 55%)`
|
||||
|
||||
// HSL gradient red(0)→green(120) keyed on rank in [0,1].
|
||||
const rankHue = (rank01) => `hsl(${(rank01 * 120).toFixed(1)}, 90%, 55%)`
|
||||
|
||||
const polylineLength = (pts) => {
|
||||
let L = 0
|
||||
for (let i = 1; i < pts.length; i++) {
|
||||
const dx = pts[i][0] - pts[i - 1][0]
|
||||
const dy = pts[i][1] - pts[i - 1][1]
|
||||
L += Math.hypot(dx, dy)
|
||||
}
|
||||
return L
|
||||
}
|
||||
|
||||
// Sum of |angle changes| across a polyline (in radians).
|
||||
const polylineCurvature = (pts) => {
|
||||
let c = 0
|
||||
for (let i = 1; i < pts.length - 1; i++) {
|
||||
const ax = pts[i][0] - pts[i - 1][0], ay = pts[i][1] - pts[i - 1][1]
|
||||
const bx = pts[i + 1][0] - pts[i][0], by = pts[i + 1][1] - pts[i][1]
|
||||
const la = Math.hypot(ax, ay), lb = Math.hypot(bx, by)
|
||||
if (la < 1e-9 || lb < 1e-9) continue
|
||||
let cosT = (ax * bx + ay * by) / (la * lb)
|
||||
if (cosT > 1) cosT = 1
|
||||
if (cosT < -1) cosT = -1
|
||||
c += Math.abs(Math.acos(cosT))
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
export default function PaintDebugView({ passIdx = 0 }) {
|
||||
const [hulls, setHulls] = useState([])
|
||||
const [hullIdx, setHullIdx] = useState(0)
|
||||
@@ -30,6 +90,9 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
||||
const [enabled, setEnabled] = useState(
|
||||
Object.fromEntries(LAYERS.map(l => [l.key, l.on])),
|
||||
)
|
||||
const [stepEnabled, setStepEnabled] = useState(
|
||||
Object.fromEntries(STEP_LAYERS.map(l => [l.key, l.on])),
|
||||
)
|
||||
const [view, setView] = useState({ zoom: 1, panX: 0, panY: 0 })
|
||||
const containerRef = useRef(null)
|
||||
const svgRef = useRef(null)
|
||||
@@ -38,6 +101,20 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
||||
const [selBox, setSelBox] = useState(null)
|
||||
const [toast, setToast] = useState(null)
|
||||
|
||||
// Walker scrubber state
|
||||
const [walkIdx, setWalkIdx] = useState(0)
|
||||
const [stepIdx, setStepIdx] = useState(0)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [playSpeedMs, setPlaySpeedMs] = useState(250)
|
||||
const [candHover, setCandHover] = useState(null)
|
||||
// last mouse pos in screen coords for tooltip placement
|
||||
const cursorRef = useRef({ x: 0, y: 0 })
|
||||
// Bump on every successful test-letter load so the debug fetch effect
|
||||
// re-fires even when (passIdx, hullIdx, params, hulls.length) all stay
|
||||
// the same (common: any letter with 1 hull replaces another with 1 hull
|
||||
// and hullIdx defaults to 0 both times).
|
||||
const [reloadKey, setReloadKey] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true
|
||||
tauri.listHulls(passIdx).then(list => {
|
||||
@@ -50,19 +127,50 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
||||
}, [passIdx])
|
||||
|
||||
useEffect(() => {
|
||||
if (hulls.length === 0) return
|
||||
if (hulls.length === 0) { setDebug(null); return }
|
||||
let alive = true
|
||||
tauri.getPaintDebug(passIdx, hullIdx, params).then(d => {
|
||||
if (!alive) return
|
||||
setDebug(d)
|
||||
}).catch(() => {})
|
||||
return () => { alive = false }
|
||||
}, [passIdx, hullIdx, params, hulls.length])
|
||||
}, [passIdx, hullIdx, params, hulls.length, reloadKey])
|
||||
|
||||
useEffect(() => {
|
||||
setView({ zoom: 1, panX: 0, panY: 0 })
|
||||
}, [hullIdx])
|
||||
|
||||
// When new debug data arrives, clamp scrubber selection.
|
||||
useEffect(() => {
|
||||
if (!debug || !debug.walks || debug.walks.length === 0) return
|
||||
setWalkIdx(w => Math.max(0, Math.min(w, debug.walks.length - 1)))
|
||||
}, [debug])
|
||||
|
||||
const walks = debug?.walks ?? []
|
||||
const walk = walks[walkIdx] ?? null
|
||||
const stepCount = walk?.steps?.length ?? 0
|
||||
|
||||
useEffect(() => {
|
||||
setStepIdx(s => Math.max(0, Math.min(s, Math.max(0, stepCount - 1))))
|
||||
}, [walkIdx, stepCount])
|
||||
|
||||
const step = walk?.steps?.[stepIdx] ?? null
|
||||
|
||||
// Play loop
|
||||
useEffect(() => {
|
||||
if (!playing || !walk) return
|
||||
const id = setInterval(() => {
|
||||
setStepIdx(s => {
|
||||
if (s >= stepCount - 1) {
|
||||
setPlaying(false)
|
||||
return s
|
||||
}
|
||||
return s + 1
|
||||
})
|
||||
}, playSpeedMs)
|
||||
return () => clearInterval(id)
|
||||
}, [playing, playSpeedMs, walk, stepCount])
|
||||
|
||||
const viewBox = useMemo(() => {
|
||||
if (!debug) return '0 0 100 100'
|
||||
const [x0, y0, x1, y1] = debug.bounds
|
||||
@@ -187,26 +295,129 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
||||
}
|
||||
|
||||
const onMouseMoveSvg = (e) => {
|
||||
cursorRef.current = { x: e.clientX, y: e.clientY }
|
||||
const ip = clientToImage(e.clientX, e.clientY)
|
||||
if (ip) setHover({ x: ip.x, y: ip.y })
|
||||
}
|
||||
|
||||
const toggleLayer = (key) => setEnabled(en => ({ ...en, [key]: !en[key] }))
|
||||
const toggleStepLayer = (key) => setStepEnabled(en => ({ ...en, [key]: !en[key] }))
|
||||
|
||||
// ── Step viz: painted-so-far waypoints ───────────────────────────────────────
|
||||
// Memoised — rebuilds only when (walkIdx, stepIdx, brush_radius) change.
|
||||
const paintedWaypoints = useMemo(() => {
|
||||
if (!walk || !step) return []
|
||||
const path = walk.path ?? []
|
||||
// path is the full walker path; we want index 0..stepIdx INCLUSIVE.
|
||||
// path is typically (steps.length + 1) long: start + one per step.
|
||||
const k = Math.min(stepIdx + 1, path.length)
|
||||
return path.slice(0, k)
|
||||
}, [walkIdx, stepIdx, walk, step])
|
||||
|
||||
// ── Score breakdown for the whole hull ───────────────────────────────────────
|
||||
const scoreBreakdown = useMemo(() => {
|
||||
if (!debug) return null
|
||||
const totalLen = (debug.strokes ?? []).reduce((a, s) => a + polylineLength(s), 0)
|
||||
const totalCurv = (debug.strokes ?? []).reduce((a, s) => a + polylineCurvature(s), 0)
|
||||
const numStrokes = debug.strokes?.length ?? 0
|
||||
const skel = debug.skeleton_length ?? 0
|
||||
const budget = LENGTH_BUDGET_FACTOR * skel
|
||||
const lengthExcess = Math.max(0, totalLen - budget)
|
||||
const brushR = debug.brush_radius ?? 0
|
||||
|
||||
const rows = [
|
||||
{ name: 'stroke', raw: numStrokes, unit: 'count', weight: SCORE_WEIGHTS.stroke },
|
||||
{ name: 'length', raw: totalLen, unit: 'px', weight: SCORE_WEIGHTS.length },
|
||||
{ name: 'bg', raw: debug.bg_painted ?? 0, unit: 'px', weight: SCORE_WEIGHTS.bg },
|
||||
{ name: 'repaint', raw: debug.repaint ?? 0, unit: 'px', weight: SCORE_WEIGHTS.repaint },
|
||||
{ name: 'unpainted', raw: debug.ink_unpainted ?? 0, unit: 'px', weight: SCORE_WEIGHTS.unpainted },
|
||||
{ name: 'length_excess', raw: lengthExcess, unit: 'px', weight: SCORE_WEIGHTS.length_excess },
|
||||
{ name: 'curvature', raw: totalCurv, unit: 'rad', weight: SCORE_WEIGHTS.curvature },
|
||||
{ name: 'brush_size', raw: brushR, unit: 'px', weight: SCORE_WEIGHTS.brush_size },
|
||||
]
|
||||
const total = rows.reduce((a, r) => a + r.raw * r.weight, 0)
|
||||
return { rows, total, totalLen, totalCurv, budget, lengthExcess }
|
||||
}, [debug])
|
||||
|
||||
// ── Hull metrics ─────────────────────────────────────────────────────────────
|
||||
const hullMetrics = useMemo(() => {
|
||||
if (!debug) return null
|
||||
const inkTotal = debug.ink_total ?? 0
|
||||
const inkUnpainted = debug.ink_unpainted ?? 0
|
||||
const totalSwept = debug.total_swept ?? 0
|
||||
const bgPainted = debug.bg_painted ?? 0
|
||||
const skel = debug.skeleton_length ?? 0
|
||||
const totalLen = (debug.strokes ?? []).reduce((a, s) => a + polylineLength(s), 0)
|
||||
const coverage = inkTotal > 0 ? (inkTotal - inkUnpainted) / inkTotal * 100 : 0
|
||||
const offGlyph = totalSwept > 0 ? bgPainted / totalSwept * 100 : 0
|
||||
const lenRatio = skel > 0 ? totalLen / skel : 0
|
||||
return {
|
||||
coverage, offGlyph, lenRatio, totalLen,
|
||||
coverageOk: coverage >= 95,
|
||||
offGlyphOk: offGlyph <= 5,
|
||||
lenRatioOk: lenRatio <= 2.0,
|
||||
}
|
||||
}, [debug])
|
||||
|
||||
// ── Keyboard shortcuts (when SVG focused) ────────────────────────────────────
|
||||
const onSvgKeyDown = useCallback((e) => {
|
||||
if (!walk) return
|
||||
if (e.key === 'ArrowLeft') { setStepIdx(s => Math.max(0, s - 1)); e.preventDefault() }
|
||||
else if (e.key === 'ArrowRight') { setStepIdx(s => Math.min(stepCount - 1, s + 1)); e.preventDefault() }
|
||||
else if (e.key === ' ') { setPlaying(p => !p); e.preventDefault() }
|
||||
else if (e.key === 'Home') { setStepIdx(0); e.preventDefault() }
|
||||
else if (e.key === 'End') { setStepIdx(Math.max(0, stepCount - 1)); e.preventDefault() }
|
||||
}, [walk, stepCount])
|
||||
|
||||
if (!debug) {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-neutral-900 text-neutral-500">
|
||||
<div className="text-center space-y-2">
|
||||
<p>Paint debug</p>
|
||||
<p className="text-xs">No hulls available — run the pipeline first (Source → Kernel → Hull).</p>
|
||||
<div className="absolute inset-0 bg-neutral-900 flex">
|
||||
<div className="w-72 shrink-0 border-r border-neutral-800 p-3 overflow-y-auto text-xs text-neutral-300 space-y-3">
|
||||
<TestLetterPicker passIdx={passIdx} onLoaded={(list) => {
|
||||
const sorted = [...list].sort((a, b) => b.area - a.area)
|
||||
setHulls(sorted)
|
||||
if (sorted.length > 0) setHullIdx(sorted[0].index)
|
||||
setReloadKey(k => k + 1)
|
||||
}} />
|
||||
<div className="text-neutral-500 leading-relaxed pt-2">
|
||||
<p>Pick a test letter above to start, or load an image and run
|
||||
the pipeline (Source → Kernel → Hull) to debug a real glyph.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center text-neutral-600 text-sm">
|
||||
No hull loaded
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Compute candidate ranking (by score, ignoring rejected ones).
|
||||
const candidateRanks = (() => {
|
||||
if (!step) return null
|
||||
const cands = step.candidates ?? []
|
||||
const live = cands
|
||||
.map((c, i) => ({ c, i }))
|
||||
.filter(({ c }) => !c.rejected_back && !c.rejected_off_ink)
|
||||
const sorted = [...live].sort((a, b) => a.c.score - b.c.score)
|
||||
const rank = new Map() // candidate index → rank01
|
||||
sorted.forEach(({ i }, k) => {
|
||||
rank.set(i, sorted.length > 1 ? k / (sorted.length - 1) : 1)
|
||||
})
|
||||
return rank
|
||||
})()
|
||||
|
||||
const brushR = debug.brush_radius ?? 1
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="absolute inset-0 bg-neutral-900 flex">
|
||||
<div className="w-64 shrink-0 border-r border-neutral-800 p-3 overflow-y-auto text-xs text-neutral-300 space-y-3">
|
||||
<div className="w-72 shrink-0 border-r border-neutral-800 p-3 overflow-y-auto text-xs text-neutral-300 space-y-3">
|
||||
<TestLetterPicker passIdx={passIdx} onLoaded={(list) => {
|
||||
const sorted = [...list].sort((a, b) => b.area - a.area)
|
||||
setHulls(sorted)
|
||||
if (sorted.length > 0) setHullIdx(sorted[0].index)
|
||||
setReloadKey(k => k + 1)
|
||||
}} />
|
||||
|
||||
<div>
|
||||
<label className="block text-neutral-500 mb-1">Hull (largest first)</label>
|
||||
<select value={hullIdx}
|
||||
@@ -235,6 +446,45 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-1 border-t border-neutral-800">
|
||||
<div className="text-neutral-500 mb-1">Step viz</div>
|
||||
<div className="space-y-1">
|
||||
{STEP_LAYERS.map(l => (
|
||||
<label key={l.key} className="flex items-center gap-2 cursor-pointer">
|
||||
<input type="checkbox"
|
||||
checked={!!stepEnabled[l.key]}
|
||||
onChange={() => toggleStepLayer(l.key)}
|
||||
disabled={l.key === 'skeleton'} />
|
||||
<span className={l.key === 'skeleton' ? 'text-neutral-600' : ''}>{l.label}</span>
|
||||
</label>
|
||||
))}
|
||||
{stepEnabled.skeleton && (
|
||||
<div className="text-[10px] text-neutral-500 pl-5">skeleton not exposed yet</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ScrubberPanel
|
||||
walks={walks}
|
||||
walkIdx={walkIdx}
|
||||
setWalkIdx={setWalkIdx}
|
||||
stepIdx={stepIdx}
|
||||
setStepIdx={setStepIdx}
|
||||
stepCount={stepCount}
|
||||
playing={playing}
|
||||
setPlaying={setPlaying}
|
||||
playSpeedMs={playSpeedMs}
|
||||
setPlaySpeedMs={setPlaySpeedMs}
|
||||
/>
|
||||
|
||||
<HullMetricsPanel m={hullMetrics} debug={debug} />
|
||||
|
||||
<ScoreBreakdownPanel sb={scoreBreakdown} />
|
||||
|
||||
<SelectedStepPanel walk={walk} step={step} stepIdx={stepIdx} />
|
||||
|
||||
<OptimizerPanel passIdx={passIdx} hullIdx={hullIdx} params={params} setParams={setParams} />
|
||||
|
||||
<div className="space-y-2 pt-1 border-t border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-neutral-500">Brush</span>
|
||||
@@ -275,6 +525,9 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
||||
<ParamSlider label="Overpaint penalty" value={params.overpaint_penalty} min={0} max={0.5} step={0.01}
|
||||
onChange={v => setParam('overpaint_penalty', v)}
|
||||
hint="Per-pixel cost for painting over already-painted pixels." />
|
||||
<ParamSlider label="Walk BG penalty" value={params.walk_bg_penalty} min={0} max={20} step={0.5}
|
||||
onChange={v => setParam('walk_bg_penalty', v)}
|
||||
hint="Per-bg-pixel cost in the walker's lookahead. Higher = stricter centerline-following at corners (rejects corner-cut shortcuts)." />
|
||||
<ParamSlider label="Min score factor" value={params.min_score_factor} min={0} max={0.5} step={0.01}
|
||||
onChange={v => setParam('min_score_factor', v)}
|
||||
hint="Stroke ends when best direction's score < this × brush area." />
|
||||
@@ -288,9 +541,9 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
||||
<ParamSlider label="Polish search ×r" value={params.polish_search_factor} min={0.5} max={6} step={0.25}
|
||||
onChange={v => setParam('polish_search_factor', v)}
|
||||
hint="How far (in brush radii) to search for unpainted ink near each waypoint." />
|
||||
<ParamSlider label="Outside penalty" value={params.outside_penalty} min={0} max={10} step={0.25}
|
||||
onChange={v => setParam('outside_penalty', v)}
|
||||
hint="Cost per background-pixel under brush. Reject moves that drift the path off the glyph." />
|
||||
<ParamSlider label="BG penalty" value={params.bg_penalty} min={0} max={10} step={0.25}
|
||||
onChange={v => setParam('bg_penalty', v)}
|
||||
hint="Per-bg-pixel cost in the polish/relax pass. Higher = stricter centerline pull." />
|
||||
<ParamSlider label="Min component" value={params.min_component_factor} min={0} max={2} step={0.1}
|
||||
onChange={v => setParam('min_component_factor', v)}
|
||||
hint="Smallest unpainted-ink connected component that warrants a new stroke, as a multiple of brush area. Smaller components get a single disk stamp instead." />
|
||||
@@ -309,9 +562,7 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
||||
<ParamSlider label="Max strokes" value={params.max_strokes} min={1} max={30} step={1}
|
||||
onChange={v => setParam('max_strokes', v)} hint="Safety cap on strokes per hull." />
|
||||
<ParamSlider label="Output RDP" value={params.output_rdp_eps} min={0} max={2} step={0.1}
|
||||
onChange={v => setParam('output_rdp_eps', v)} hint="Final stroke RDP epsilon." />
|
||||
<ParamSlider label="Output Chaikin" value={params.output_chaikin} min={0} max={6} step={1}
|
||||
onChange={v => setParam('output_chaikin', v)} hint="Final stroke Chaikin smoothing passes." />
|
||||
onChange={v => setParam('output_rdp_eps', v)} hint="Final stroke RDP simplification epsilon (px). 0 disables." />
|
||||
</div>
|
||||
|
||||
<div className="pt-2 border-t border-neutral-800 space-y-2">
|
||||
@@ -343,6 +594,7 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
||||
|
||||
<div className="pt-2 border-t border-neutral-800 text-neutral-500 leading-relaxed">
|
||||
<p>Wheel: zoom · Drag: pan · Shift+drag: copy region</p>
|
||||
<p className="mt-1">SVG keys: ←/→ step · Space play · Home/End</p>
|
||||
<button onClick={() => setView({ zoom: 1, panX: 0, panY: 0 })}
|
||||
className="mt-1 text-xs px-2 py-0.5 bg-neutral-800 rounded">Fit</button>
|
||||
<div className="mt-2 space-y-0.5">
|
||||
@@ -350,6 +602,7 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
||||
<div>· {debug.start_points.length} start points</div>
|
||||
<div>· {debug.trajectories.length} raw trajectories</div>
|
||||
<div>· {debug.strokes.length} smoothed strokes</div>
|
||||
<div>· {walks.length} walks recorded</div>
|
||||
</div>
|
||||
{hover && (
|
||||
<div className="mt-2 font-mono">
|
||||
@@ -362,11 +615,14 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
||||
<div className="flex-1 relative overflow-hidden" onWheel={onWheel} onMouseDown={onMouseDown}>
|
||||
<svg
|
||||
ref={svgRef}
|
||||
tabIndex={0}
|
||||
width="100%" height="100%"
|
||||
viewBox={viewBox}
|
||||
preserveAspectRatio="xMidYMid meet"
|
||||
onMouseMove={onMouseMoveSvg}
|
||||
style={{ cursor: 'grab', background: '#0f0f10' }}>
|
||||
onMouseLeave={() => setCandHover(null)}
|
||||
onKeyDown={onSvgKeyDown}
|
||||
style={{ cursor: 'grab', background: '#0f0f10', outline: 'none' }}>
|
||||
|
||||
{enabled.source && debug.source_b64 && (
|
||||
<image
|
||||
@@ -443,6 +699,111 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
||||
</g>
|
||||
))}
|
||||
|
||||
{/* ── Step viz ──────────────────────────────────────────────────── */}
|
||||
|
||||
{/* Painted-so-far disks. Stamped at each waypoint up to current step. */}
|
||||
{stepEnabled.paintedSoFar && walk && paintedWaypoints.map((p, i) => (
|
||||
<circle key={`pa${i}`}
|
||||
cx={p[0]} cy={p[1]} r={brushR}
|
||||
fill="lightgray" fillOpacity={0.25} stroke="none" />
|
||||
))}
|
||||
|
||||
{/* Path-up-to-step polyline */}
|
||||
{stepEnabled.pathSoFar && walk && paintedWaypoints.length > 1 && (
|
||||
<polyline
|
||||
points={paintedWaypoints.map(p => `${p[0]},${p[1]}`).join(' ')}
|
||||
fill="none" stroke="#fb923c" strokeWidth={1.5}
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke" />
|
||||
)}
|
||||
|
||||
{/* Future path (ghost) */}
|
||||
{stepEnabled.futurePath && walk && step && (() => {
|
||||
const path = walk.path ?? []
|
||||
const k = Math.min(stepIdx + 1, path.length)
|
||||
const future = path.slice(Math.max(0, k - 1))
|
||||
if (future.length < 2) return null
|
||||
return (
|
||||
<polyline
|
||||
points={future.map(p => `${p[0]},${p[1]}`).join(' ')}
|
||||
fill="none" stroke="#94a3b8" strokeWidth={1}
|
||||
strokeOpacity={0.55} strokeDasharray="2 1.5"
|
||||
strokeLinecap="round" strokeLinejoin="round"
|
||||
vectorEffect="non-scaling-stroke" />
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Brush footprint at current step */}
|
||||
{stepEnabled.brushHere && walk && step && (
|
||||
<circle cx={step.p[0]} cy={step.p[1]} r={brushR}
|
||||
fill="#fde047" fillOpacity={0.4}
|
||||
stroke="#fde047" strokeOpacity={0.9} strokeWidth={0.6}
|
||||
vectorEffect="non-scaling-stroke" />
|
||||
)}
|
||||
|
||||
{/* Momentum arrow */}
|
||||
{stepEnabled.momentum && walk && step && step.prev_dir && (() => {
|
||||
const [dx, dy] = step.prev_dir
|
||||
const x2 = step.p[0] + dx * brushR
|
||||
const y2 = step.p[1] + dy * brushR
|
||||
return (
|
||||
<g>
|
||||
<line x1={step.p[0]} y1={step.p[1]} x2={x2} y2={y2}
|
||||
stroke="#22d3ee" strokeWidth={1.2}
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke" />
|
||||
<circle cx={x2} cy={y2} r={brushR * 0.12}
|
||||
fill="#22d3ee" stroke="none" />
|
||||
</g>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* Candidates */}
|
||||
{stepEnabled.candidates && step && step.candidates && step.candidates.map((c, i) => {
|
||||
const rejected = c.rejected_back || c.rejected_off_ink
|
||||
const isChosen = step.chosen === i
|
||||
const r = brushR * 0.5
|
||||
if (rejected) {
|
||||
const s = brushR * 0.35
|
||||
return (
|
||||
<g key={`cd${i}`}
|
||||
onMouseEnter={() => setCandHover({ idx: i, c })}
|
||||
onMouseLeave={() => setCandHover(h => h?.idx === i ? null : h)}
|
||||
style={{ cursor: 'crosshair' }}>
|
||||
<line x1={c.probe[0] - s} y1={c.probe[1] - s}
|
||||
x2={c.probe[0] + s} y2={c.probe[1] + s}
|
||||
stroke="#525252" strokeOpacity={0.5} strokeWidth={0.6}
|
||||
vectorEffect="non-scaling-stroke" />
|
||||
<line x1={c.probe[0] - s} y1={c.probe[1] + s}
|
||||
x2={c.probe[0] + s} y2={c.probe[1] - s}
|
||||
stroke="#525252" strokeOpacity={0.5} strokeWidth={0.6}
|
||||
vectorEffect="non-scaling-stroke" />
|
||||
</g>
|
||||
)
|
||||
}
|
||||
const rank01 = candidateRanks?.get(i) ?? 0.5
|
||||
const fill = rankHue(rank01)
|
||||
return (
|
||||
<g key={`cd${i}`}
|
||||
onMouseEnter={() => setCandHover({ idx: i, c })}
|
||||
onMouseLeave={() => setCandHover(h => h?.idx === i ? null : h)}
|
||||
style={{ cursor: 'crosshair' }}>
|
||||
<circle cx={c.probe[0]} cy={c.probe[1]} r={r}
|
||||
fill={fill} fillOpacity={0.55}
|
||||
stroke={isChosen ? '#ffffff' : fill}
|
||||
strokeOpacity={isChosen ? 1 : 0.9}
|
||||
strokeWidth={isChosen ? 1.4 : 0.4}
|
||||
vectorEffect="non-scaling-stroke" />
|
||||
{isChosen && (
|
||||
<line x1={step.p[0]} y1={step.p[1]} x2={c.probe[0]} y2={c.probe[1]}
|
||||
stroke="#ffffff" strokeWidth={1.2}
|
||||
strokeLinecap="round"
|
||||
vectorEffect="non-scaling-stroke" />
|
||||
)}
|
||||
</g>
|
||||
)
|
||||
})}
|
||||
|
||||
{selBox && (
|
||||
<rect
|
||||
x={Math.min(selBox.x0, selBox.x1)}
|
||||
@@ -457,7 +818,7 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
||||
</svg>
|
||||
|
||||
<div className="absolute bottom-2 left-3 text-[10px] text-neutral-500 pointer-events-none">
|
||||
Shift+drag to copy region data to clipboard
|
||||
Shift+drag to copy region data to clipboard · Click SVG, then ←/→/Space/Home/End
|
||||
</div>
|
||||
|
||||
{toast && (
|
||||
@@ -467,6 +828,438 @@ export default function PaintDebugView({ passIdx = 0 }) {
|
||||
{toast}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Candidate tooltip */}
|
||||
{candHover && (() => {
|
||||
const cur = cursorRef.current
|
||||
const pad = 12
|
||||
const rect = containerRef.current?.getBoundingClientRect()
|
||||
const left = (cur.x - (rect?.left ?? 0)) + pad
|
||||
const top = (cur.y - (rect?.top ?? 0)) + pad
|
||||
const c = candHover.c
|
||||
const fmt = (n) => Number.isFinite(n) ? n.toFixed(3) : String(n)
|
||||
return (
|
||||
<div className="absolute pointer-events-none z-10 px-2 py-1.5 rounded
|
||||
bg-neutral-950/95 border border-neutral-700 text-[10px] text-neutral-200
|
||||
font-mono shadow-lg whitespace-nowrap"
|
||||
style={{ left, top }}>
|
||||
<div className="text-neutral-400">cand #{candHover.idx} · θ={fmt(c.theta)}</div>
|
||||
<div>new_ink: {fmt(c.new_ink)}</div>
|
||||
<div>repaint: {fmt(c.repaint)}</div>
|
||||
<div>bg: {fmt(c.bg)}</div>
|
||||
<div>momentum_bonus: {fmt(c.momentum_bonus)}</div>
|
||||
<div className="text-amber-300">score: {fmt(c.score)}</div>
|
||||
<div className={c.rejected_back ? 'text-rose-400' : 'text-neutral-500'}>
|
||||
rejected_back: {String(c.rejected_back)}
|
||||
</div>
|
||||
<div className={c.rejected_off_ink ? 'text-rose-400' : 'text-neutral-500'}>
|
||||
rejected_off_ink: {String(c.rejected_off_ink)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Sub-components ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ScrubberPanel({
|
||||
walks, walkIdx, setWalkIdx, stepIdx, setStepIdx, stepCount,
|
||||
playing, setPlaying, playSpeedMs, setPlaySpeedMs,
|
||||
}) {
|
||||
const walk = walks[walkIdx]
|
||||
return (
|
||||
<div className="space-y-2 pt-1 border-t border-neutral-800">
|
||||
<div className="text-neutral-500">Walker scrubber</div>
|
||||
<div>
|
||||
<label className="block text-[10px] text-neutral-500 mb-0.5">Walk</label>
|
||||
<select value={walkIdx}
|
||||
onChange={e => setWalkIdx(parseInt(e.target.value, 10))}
|
||||
disabled={walks.length === 0}
|
||||
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-xs">
|
||||
{walks.length === 0 && <option>(no walks)</option>}
|
||||
{walks.map((w, i) => (
|
||||
<option key={i} value={i}>
|
||||
stroke {w.stroke_idx} {w.kind} · {w.steps.length}st · {w.exit_reason}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-[10px]">
|
||||
<span className="text-neutral-400">Step</span>
|
||||
<span className="text-neutral-300 font-mono">
|
||||
{stepCount === 0 ? '—' : `${stepIdx + 1} / ${stepCount}`}
|
||||
</span>
|
||||
</div>
|
||||
<input type="range"
|
||||
min={0}
|
||||
max={Math.max(0, stepCount - 1)}
|
||||
step={1}
|
||||
value={stepIdx}
|
||||
disabled={stepCount === 0}
|
||||
onChange={e => setStepIdx(parseInt(e.target.value, 10))}
|
||||
className="w-full" />
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<button onClick={() => setStepIdx(0)}
|
||||
disabled={stepCount === 0}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 disabled:opacity-40"
|
||||
title="Restart">⏮</button>
|
||||
<button onClick={() => setStepIdx(s => Math.max(0, s - 1))}
|
||||
disabled={stepCount === 0}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 disabled:opacity-40"
|
||||
title="Prev (←)">◀</button>
|
||||
<button onClick={() => setPlaying(p => !p)}
|
||||
disabled={stepCount === 0}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-indigo-600/30 border border-indigo-500/60 hover:bg-indigo-600/50 text-indigo-200 disabled:opacity-40"
|
||||
title="Play/Pause (Space)">{playing ? '❚❚' : '▶'}</button>
|
||||
<button onClick={() => setStepIdx(s => Math.min(stepCount - 1, s + 1))}
|
||||
disabled={stepCount === 0}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 disabled:opacity-40"
|
||||
title="Next (→)">▶</button>
|
||||
<button onClick={() => setStepIdx(Math.max(0, stepCount - 1))}
|
||||
disabled={stepCount === 0}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-neutral-800 hover:bg-neutral-700 disabled:opacity-40"
|
||||
title="End">⏭</button>
|
||||
<select
|
||||
value={playSpeedMs}
|
||||
onChange={e => setPlaySpeedMs(parseInt(e.target.value, 10))}
|
||||
className="ml-auto bg-neutral-800 border border-neutral-700 rounded px-1 py-0.5 text-[10px]">
|
||||
{PLAY_SPEEDS.map(s => (
|
||||
<option key={s.label} value={s.ms}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{walk && (
|
||||
<div className="text-[10px] text-neutral-500 leading-snug">
|
||||
start: ({walk.start[0].toFixed(1)}, {walk.start[1].toFixed(1)}) ·
|
||||
step={walk.step_size?.toFixed(2)} ·
|
||||
r={walk.brush_radius?.toFixed(2)} ·
|
||||
min_score={walk.min_score?.toFixed(2)}
|
||||
<br />
|
||||
init_dir: {walk.init_dir
|
||||
? `(${walk.init_dir[0].toFixed(2)}, ${walk.init_dir[1].toFixed(2)})`
|
||||
: 'none'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function HullMetricsPanel({ m, debug }) {
|
||||
if (!m || !debug) return null
|
||||
const Badge = ({ ok, children }) => (
|
||||
<span className={`ml-2 px-1 py-0.5 rounded text-[9px] font-mono ${
|
||||
ok ? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40'
|
||||
: 'bg-rose-500/20 text-rose-300 border border-rose-500/40'
|
||||
}`}>{children}</span>
|
||||
)
|
||||
const Row = ({ k, v }) => (
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-neutral-500">{k}</span>
|
||||
<span className="font-mono">{v}</span>
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div className="space-y-1 pt-1 border-t border-neutral-800">
|
||||
<div className="text-neutral-500">Hull metrics</div>
|
||||
<Row k="brush_radius" v={(debug.brush_radius ?? 0).toFixed(2)} />
|
||||
<Row k="ink_total" v={debug.ink_total ?? 0} />
|
||||
<Row k="ink_unpainted" v={debug.ink_unpainted ?? 0} />
|
||||
<Row k="bg_painted" v={debug.bg_painted ?? 0} />
|
||||
<Row k="total_swept" v={debug.total_swept ?? 0} />
|
||||
<Row k="repaint" v={debug.repaint ?? 0} />
|
||||
<Row k="skeleton_length" v={(debug.skeleton_length ?? 0).toFixed(2)} />
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-neutral-500">coverage</span>
|
||||
<span>
|
||||
<span className="font-mono">{m.coverage.toFixed(1)}%</span>
|
||||
<Badge ok={m.coverageOk}>{m.coverageOk ? 'ok' : '<95%'}</Badge>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-neutral-500">off-glyph</span>
|
||||
<span>
|
||||
<span className="font-mono">{m.offGlyph.toFixed(1)}%</span>
|
||||
<Badge ok={m.offGlyphOk}>{m.offGlyphOk ? 'ok' : '>5%'}</Badge>
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-neutral-500">len/skel</span>
|
||||
<span>
|
||||
<span className="font-mono">{m.lenRatio.toFixed(2)}×</span>
|
||||
<Badge ok={m.lenRatioOk}>{m.lenRatioOk ? 'ok' : '>2.0'}</Badge>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ScoreBreakdownPanel({ sb }) {
|
||||
if (!sb) return null
|
||||
const fmt = (n) => {
|
||||
const a = Math.abs(n)
|
||||
if (a >= 1000) return n.toFixed(0)
|
||||
if (a >= 1) return n.toFixed(2)
|
||||
return n.toFixed(3)
|
||||
}
|
||||
const sign = (n) => (n >= 0 ? '+' : '−')
|
||||
return (
|
||||
<div className="space-y-1 pt-1 border-t border-neutral-800">
|
||||
<div className="text-neutral-500">Score breakdown</div>
|
||||
<div className="text-[10px] font-mono space-y-0.5">
|
||||
<div className="grid grid-cols-[1fr_auto_auto_auto] gap-x-2 text-neutral-500">
|
||||
<span>term</span>
|
||||
<span className="text-right">raw</span>
|
||||
<span className="text-right">w</span>
|
||||
<span className="text-right">contrib</span>
|
||||
</div>
|
||||
{sb.rows.map(r => {
|
||||
const contrib = r.raw * r.weight
|
||||
return (
|
||||
<div key={r.name} className="grid grid-cols-[1fr_auto_auto_auto] gap-x-2">
|
||||
<span className="text-neutral-300">{r.name}</span>
|
||||
<span className="text-right">{fmt(r.raw)}</span>
|
||||
<span className="text-right text-neutral-500">×{r.weight}</span>
|
||||
<span className="text-right text-amber-300">
|
||||
{sign(contrib)}{fmt(Math.abs(contrib))}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
<div className="grid grid-cols-[1fr_auto] gap-x-2 pt-1 border-t border-neutral-800">
|
||||
<span className="text-neutral-300">total</span>
|
||||
<span className="text-right text-amber-300 font-bold">{fmt(sb.total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-neutral-500 leading-snug">
|
||||
budget = 1.5 × skel = {sb.budget.toFixed(1)} px ·
|
||||
excess = {sb.lengthExcess.toFixed(1)} ·
|
||||
Σ|Δθ| = {sb.totalCurv.toFixed(2)} rad
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectedStepPanel({ walk, step, stepIdx }) {
|
||||
if (!walk) return null
|
||||
const Row = ({ k, v }) => (
|
||||
<div className="flex justify-between text-[10px]">
|
||||
<span className="text-neutral-500">{k}</span>
|
||||
<span className="font-mono">{v}</span>
|
||||
</div>
|
||||
)
|
||||
if (!step) {
|
||||
return (
|
||||
<div className="space-y-1 pt-1 border-t border-neutral-800">
|
||||
<div className="text-neutral-500">Selected step</div>
|
||||
<div className="text-[10px] text-neutral-500">no step (walk has 0 iters · exit: {walk.exit_reason})</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const cands = step.candidates ?? []
|
||||
const rejBack = cands.filter(c => c.rejected_back).length
|
||||
const rejOff = cands.filter(c => c.rejected_off_ink).length
|
||||
const accepted = cands.length - rejBack - rejOff
|
||||
const mom = step.prev_dir ? Math.hypot(step.prev_dir[0], step.prev_dir[1]) : 0
|
||||
const chosen = step.chosen != null ? cands[step.chosen] : null
|
||||
const fmt = (n) => Number.isFinite(n) ? n.toFixed(3) : String(n)
|
||||
return (
|
||||
<div className="space-y-1 pt-1 border-t border-neutral-800">
|
||||
<div className="text-neutral-500">Selected step</div>
|
||||
<Row k="idx" v={step.idx} />
|
||||
<Row k="p" v={`(${step.p[0].toFixed(2)}, ${step.p[1].toFixed(2)})`} />
|
||||
<Row k="prev_dir" v={step.prev_dir
|
||||
? `(${step.prev_dir[0].toFixed(2)}, ${step.prev_dir[1].toFixed(2)})`
|
||||
: 'none'} />
|
||||
<Row k="|momentum|" v={mom.toFixed(3)} />
|
||||
<Row k="cands" v={`${accepted} ok · ${rejBack} back · ${rejOff} off`} />
|
||||
{chosen ? (
|
||||
<div className="mt-1 text-[10px] font-mono bg-neutral-950/60 border border-neutral-800 rounded p-1.5 space-y-0.5">
|
||||
<div className="text-emerald-300">chosen #{step.chosen}</div>
|
||||
<div>θ {fmt(chosen.theta)}</div>
|
||||
<div>new_ink {fmt(chosen.new_ink)}</div>
|
||||
<div>repaint {fmt(chosen.repaint)}</div>
|
||||
<div>bg {fmt(chosen.bg)}</div>
|
||||
<div>momentum_bonus {fmt(chosen.momentum_bonus)}</div>
|
||||
<div className="text-amber-300">score {fmt(chosen.score)}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-1 text-[10px] font-mono bg-rose-950/40 border border-rose-800/60 rounded p-1.5 text-rose-200">
|
||||
exited: {walk.exit_reason}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OptimizerPanel({ passIdx, hullIdx, params, setParams }) {
|
||||
const [running, setRunning] = useState(false)
|
||||
const [log, setLog] = useState([]) // Vec<{step, axis, value, score, delta}>
|
||||
const [best, setBest] = useState(null) // PaintParams from final result
|
||||
const logEndRef = useRef(null)
|
||||
|
||||
// Auto-scroll to bottom of log on new entries.
|
||||
useEffect(() => {
|
||||
if (logEndRef.current) logEndRef.current.scrollTop = logEndRef.current.scrollHeight
|
||||
}, [log])
|
||||
|
||||
const run = async () => {
|
||||
setRunning(true)
|
||||
setLog([{ axis: '<initial>', value: 0, score: NaN, delta: 0 }])
|
||||
setBest(null)
|
||||
let unlisten = null
|
||||
try {
|
||||
unlisten = await listen('optimizer-progress', (event) => {
|
||||
const p = event.payload
|
||||
setLog(L => [...L, {
|
||||
step: p.step, axis: p.axis, value: p.value,
|
||||
score: p.score, delta: p.delta,
|
||||
}])
|
||||
})
|
||||
const result = await tauri.optimizePaintParams(passIdx, hullIdx, params)
|
||||
setBest(result)
|
||||
} catch (err) {
|
||||
setLog(L => [...L, { axis: 'ERROR', value: 0, score: NaN, delta: 0, err: String(err) }])
|
||||
} finally {
|
||||
if (unlisten) unlisten()
|
||||
setRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const applyBest = () => {
|
||||
if (best) setParams({ ...best })
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1 pt-1 border-t border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-neutral-500">Optimizer</span>
|
||||
{running && <span className="text-[9px] text-amber-400 animate-pulse">running…</span>}
|
||||
</div>
|
||||
<div className="flex gap-1">
|
||||
<button
|
||||
onClick={run}
|
||||
disabled={running}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-emerald-700/40 border border-emerald-600/60 hover:bg-emerald-700/60 text-emerald-100 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
{running ? 'Running…' : 'Run'}
|
||||
</button>
|
||||
<button
|
||||
onClick={applyBest}
|
||||
disabled={!best || running}
|
||||
title={best ? 'Copy the optimizer result into current params' : 'Run first'}
|
||||
className="text-[10px] px-2 py-0.5 rounded bg-indigo-700/40 border border-indigo-600/60 hover:bg-indigo-700/60 text-indigo-100 disabled:opacity-50 disabled:cursor-not-allowed">
|
||||
Apply best
|
||||
</button>
|
||||
</div>
|
||||
<div ref={logEndRef}
|
||||
className="h-32 rounded bg-neutral-950/60 border border-neutral-800 text-[10px] text-neutral-300 px-2 py-1 font-mono overflow-y-auto">
|
||||
{log.length === 0
|
||||
? <span className="text-neutral-600">(idle)</span>
|
||||
: log.map((e, i) => (
|
||||
<div key={i} className={e.axis === 'ERROR' ? 'text-red-400' : ''}>
|
||||
{e.axis === '<initial>' && (
|
||||
<span className="text-neutral-500">starting…</span>
|
||||
)}
|
||||
{e.axis === 'ERROR' && (
|
||||
<span>error: {e.err}</span>
|
||||
)}
|
||||
{e.axis !== '<initial>' && e.axis !== 'ERROR' && (
|
||||
<span>
|
||||
<span className="text-neutral-500">{String(e.step).padStart(2, ' ')}</span>{' '}
|
||||
{e.axis.padEnd(22, ' ')} = {Number(e.value).toFixed(2)}
|
||||
{' → '}
|
||||
<span className="text-amber-300">{Math.round(e.score)}</span>
|
||||
{' '}
|
||||
<span className="text-emerald-400">(Δ {Math.round(e.delta)})</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{best && !running && (
|
||||
<div className="text-[10px] text-neutral-500">
|
||||
best score: <span className="text-emerald-400 font-mono">
|
||||
{Math.round(log[log.length - 1]?.score ?? 0)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Hardcoded test characters + scales. Click any to rasterize that letter
|
||||
// at that scale into pass `passIdx`, replacing the current hulls. Lets the
|
||||
// user jump straight into debugging a specific glyph without running the
|
||||
// full image-load pipeline.
|
||||
const TEST_CHARS = 'ACGIJLMNOSUVWXZBDEFHKPQRTYabcdefghijklmnopqrstuvwxyz0123456789'.split('')
|
||||
const TEST_SCALES = [
|
||||
{ label: '3mm/150dpi/3px', font_mm: 3.0, dpi: 150, thick: 3 },
|
||||
{ label: '5mm/200dpi/4px', font_mm: 5.0, dpi: 200, thick: 4 },
|
||||
{ label: '8mm/200dpi/4px', font_mm: 8.0, dpi: 200, thick: 4 },
|
||||
{ label: '5mm/425dpi/9px', font_mm: 5.0, dpi: 425, thick: 9 },
|
||||
{ label: '8mm/425dpi/9px', font_mm: 8.0, dpi: 425, thick: 9 },
|
||||
]
|
||||
|
||||
function TestLetterPicker({ passIdx, onLoaded }) {
|
||||
const [scaleIdx, setScaleIdx] = useState(3) // default 5mm/425dpi
|
||||
const [loadedCh, setLoadedCh] = useState(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const load = async (ch) => {
|
||||
setBusy(true)
|
||||
try {
|
||||
const list = await tauri.loadTestLetter(
|
||||
passIdx, ch,
|
||||
TEST_SCALES[scaleIdx].font_mm,
|
||||
TEST_SCALES[scaleIdx].dpi,
|
||||
TEST_SCALES[scaleIdx].thick,
|
||||
)
|
||||
setLoadedCh(ch)
|
||||
onLoaded(list)
|
||||
} catch (err) {
|
||||
console.error('loadTestLetter failed', err)
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5 pb-2 border-b border-neutral-800">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-neutral-500">Test letter</span>
|
||||
{loadedCh && (
|
||||
<span className="text-[10px] text-neutral-400 font-mono">
|
||||
loaded: <span className="text-emerald-400">{loadedCh}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<select value={scaleIdx}
|
||||
onChange={e => setScaleIdx(parseInt(e.target.value, 10))}
|
||||
className="w-full bg-neutral-800 border border-neutral-700 rounded px-2 py-1 text-[10px]">
|
||||
{TEST_SCALES.map((s, i) => (
|
||||
<option key={i} value={i}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="grid grid-cols-10 gap-0.5">
|
||||
{TEST_CHARS.map(ch => (
|
||||
<button key={ch}
|
||||
onClick={() => load(ch)}
|
||||
disabled={busy}
|
||||
className={`text-[11px] py-0.5 rounded font-mono
|
||||
${loadedCh === ch
|
||||
? 'bg-emerald-700/40 border border-emerald-500/60 text-emerald-100'
|
||||
: 'bg-neutral-800 hover:bg-neutral-700 text-neutral-300 border border-neutral-700'}
|
||||
disabled:opacity-50 disabled:cursor-wait`}>
|
||||
{ch}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -26,6 +26,15 @@ export async function listHulls(passIdx = 0) {
|
||||
return tracedInvoke('list_hulls', { passIdx })
|
||||
}
|
||||
|
||||
// Replace the hulls of a pass with a freshly-rasterized test letter. Used
|
||||
// by the paint debug viewer's "Test letters" picker so any character/scale
|
||||
// is one click away — no full pipeline run required.
|
||||
export async function loadTestLetter(passIdx, ch, fontMm, dpi, thicknessPx) {
|
||||
return tracedInvoke('load_test_letter', {
|
||||
passIdx, ch, fontMm, dpi, thicknessPx,
|
||||
})
|
||||
}
|
||||
|
||||
// Default StreamlineParams must match Rust's `impl Default for StreamlineParams`.
|
||||
// Values from streamline_optimize coordinate descent over 62-glyph alphabet.
|
||||
export const DEFAULT_STREAMLINE_PARAMS = {
|
||||
@@ -54,32 +63,40 @@ export async function getStreamlineDebug(passIdx, hullIdx, params = DEFAULT_STRE
|
||||
|
||||
// Default PaintParams must match Rust's `impl Default for PaintParams`.
|
||||
export const DEFAULT_PAINT_PARAMS = {
|
||||
brush_radius_factor: 1.0,
|
||||
brush_radius_offset_px: 0.5,
|
||||
brush_radius_percentile: 0.99,
|
||||
step_size_factor: 0.5,
|
||||
n_directions: 24,
|
||||
lookahead_steps: 4,
|
||||
momentum_weight: 0.4,
|
||||
overpaint_penalty: 0.05,
|
||||
walk_bg_penalty: 0.3,
|
||||
min_score_factor: 0.05,
|
||||
polish_iters: 4,
|
||||
brush_radius_factor: 1.15,
|
||||
brush_radius_offset_px: 0.25,
|
||||
brush_radius_percentile: 0.85,
|
||||
step_size_factor: 0.40,
|
||||
n_directions: 48,
|
||||
lookahead_steps: 3,
|
||||
momentum_weight: 0.20,
|
||||
overpaint_penalty: 0.10,
|
||||
walk_bg_penalty: 4.0,
|
||||
min_score_factor: 0.20,
|
||||
polish_iters: 1,
|
||||
polish_search_factor: 0.5,
|
||||
outside_penalty: 2.0,
|
||||
min_component_factor: 0.6,
|
||||
pen_lift_penalty: 30.0,
|
||||
pen_lift_reach: 6.0,
|
||||
bg_penalty: 2.0,
|
||||
min_component_factor: 1.20,
|
||||
pen_lift_penalty: 0.0,
|
||||
pen_lift_reach: 3.0,
|
||||
max_steps_per_stroke: 4000,
|
||||
max_strokes: 12,
|
||||
output_rdp_eps: 0.5,
|
||||
output_chaikin: 2,
|
||||
output_rdp_eps: 1.0,
|
||||
}
|
||||
|
||||
export async function getPaintDebug(passIdx, hullIdx, params = DEFAULT_PAINT_PARAMS) {
|
||||
return tracedInvoke('get_paint_debug', { passIdx, hullIdx, params })
|
||||
}
|
||||
|
||||
// Run coordinate-descent optimization on the current hull's paint params.
|
||||
// While it runs the backend emits `optimizer-progress` events; subscribe
|
||||
// via `import { listen } from '@tauri-apps/api/event'` then
|
||||
// `listen('optimizer-progress', e => …)`. Resolves with the final best
|
||||
// PaintParams.
|
||||
export async function optimizePaintParams(passIdx, hullIdx, base = DEFAULT_PAINT_PARAMS) {
|
||||
return tracedInvoke('optimize_paint_params', { passIdx, hullIdx, base })
|
||||
}
|
||||
|
||||
export async function getAllStrokes() {
|
||||
return tracedInvoke('get_all_strokes', {})
|
||||
}
|
||||
|
||||
94
src/bin/paint_opt_worker.rs
Normal file
94
src/bin/paint_opt_worker.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
//! Distributed optimizer worker. Runs ONE refinement starting from the
|
||||
//! N-th start in `brush_paint_opt::build_start_params`, prints a JSON
|
||||
//! `RefineResult` to stdout. Lets the main optimizer be sharded across
|
||||
//! SSH-reachable machines: each machine runs `paint_opt_worker N` for
|
||||
//! its assigned indices in parallel, the orchestrator collects all
|
||||
//! the JSON outputs and picks the best.
|
||||
//!
|
||||
//! Usage:
|
||||
//! paint_opt_worker <start_idx> [--passes N]
|
||||
//!
|
||||
//! Output (stdout):
|
||||
//! { "start_idx": <int>, "score": <f32>, "params": {…}, "log": [...] }
|
||||
//!
|
||||
//! Stderr is for human-readable progress; never parse it.
|
||||
|
||||
use std::env;
|
||||
use std::process::ExitCode;
|
||||
use trac3r_lib::brush_paint::PaintParams;
|
||||
use trac3r_lib::brush_paint_opt::run_one_start;
|
||||
|
||||
fn parse_args() -> Result<(usize, u32), String> {
|
||||
let argv: Vec<String> = env::args().collect();
|
||||
if argv.len() < 2 {
|
||||
return Err(format!(
|
||||
"usage: {} <start_idx> [--passes N]\n\
|
||||
start_idx is the optimizer's start index (0..K-1).\n\
|
||||
passes defaults to 4.",
|
||||
argv.first().cloned().unwrap_or_else(|| "paint_opt_worker".to_string())
|
||||
));
|
||||
}
|
||||
let start_idx: usize = argv[1].parse()
|
||||
.map_err(|e| format!("start_idx must be a non-negative integer: {e}"))?;
|
||||
let mut passes: u32 = 4;
|
||||
let mut i = 2;
|
||||
while i < argv.len() {
|
||||
match argv[i].as_str() {
|
||||
"--passes" => {
|
||||
i += 1;
|
||||
passes = argv.get(i)
|
||||
.ok_or("--passes requires a value")?
|
||||
.parse()
|
||||
.map_err(|e| format!("--passes value invalid: {e}"))?;
|
||||
}
|
||||
other => return Err(format!("unknown arg: {other}")),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
Ok((start_idx, passes))
|
||||
}
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let (start_idx, passes) = match parse_args() {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("{e}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
|
||||
let host = hostname();
|
||||
let cores = std::thread::available_parallelism().map(|n| n.get()).unwrap_or(0);
|
||||
eprintln!("[worker {host}/{cores}t] start_idx={start_idx} passes={passes}");
|
||||
|
||||
let t0 = std::time::Instant::now();
|
||||
let result = run_one_start(start_idx, &PaintParams::default(), passes);
|
||||
let elapsed = t0.elapsed();
|
||||
eprintln!(
|
||||
"[worker {host}] done idx={} score={:.0} elapsed={:.1}s",
|
||||
result.start_idx, result.score, elapsed.as_secs_f64()
|
||||
);
|
||||
|
||||
match serde_json::to_string(&result) {
|
||||
Ok(json) => {
|
||||
println!("{json}");
|
||||
ExitCode::SUCCESS
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("[worker {host}] JSON serialise failed: {e}");
|
||||
ExitCode::from(3)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn hostname() -> String {
|
||||
std::env::var("HOSTNAME")
|
||||
.or_else(|_| std::env::var("HOST"))
|
||||
.unwrap_or_else(|_| {
|
||||
std::process::Command::new("hostname")
|
||||
.output().ok()
|
||||
.and_then(|o| String::from_utf8(o.stdout).ok())
|
||||
.map(|s| s.trim().to_string())
|
||||
.unwrap_or_else(|| "?".to_string())
|
||||
})
|
||||
}
|
||||
1325
src/brush_paint.rs
1325
src/brush_paint.rs
File diff suppressed because it is too large
Load Diff
238
src/brush_paint_opt.rs
Normal file
238
src/brush_paint_opt.rs
Normal file
@@ -0,0 +1,238 @@
|
||||
//! Multi-start, continuous-space, gradient-free optimizer for the
|
||||
//! brush-paint algorithm's `PaintParams`. Each starting point is
|
||||
//! independently refined via best-improvement coordinate descent with
|
||||
//! golden-section line search per axis.
|
||||
//!
|
||||
//! Two consumers:
|
||||
//! - `paint_optimize_global_defaults` test (in `brush_paint::tests`):
|
||||
//! runs all starts in the test process via rayon.
|
||||
//! - `paint_opt_worker` binary: runs ONE start, prints JSON. Lets us
|
||||
//! distribute starts across SSH workers.
|
||||
//!
|
||||
//! Both share `default_axes` / `build_corpus` / `build_start_params` /
|
||||
//! `refine_one` so they search identical landscapes.
|
||||
|
||||
use rayon::prelude::*;
|
||||
use serde::{Serialize, Deserialize};
|
||||
use crate::brush_paint::{
|
||||
PaintParams, score_for_letter, metrics_for, rasterize_test_letter,
|
||||
};
|
||||
use crate::hulls::Hull;
|
||||
|
||||
/// One tunable parameter axis. Continuous range; ints rounded after line
|
||||
/// search.
|
||||
pub struct Axis {
|
||||
pub name: &'static str,
|
||||
pub lo: f32,
|
||||
pub hi: f32,
|
||||
pub is_int: bool,
|
||||
pub set: fn(&mut PaintParams, f32),
|
||||
pub get: fn(&PaintParams) -> f32,
|
||||
}
|
||||
|
||||
pub fn default_axes() -> Vec<Axis> {
|
||||
vec![
|
||||
Axis { name: "brush_radius_factor", lo: 0.40, hi: 1.50, is_int: false,
|
||||
set: |p, v| p.brush_radius_factor = v, get: |p| p.brush_radius_factor },
|
||||
Axis { name: "brush_radius_percentile", lo: 0.70, hi: 1.00, is_int: false,
|
||||
set: |p, v| p.brush_radius_percentile = v, get: |p| p.brush_radius_percentile },
|
||||
Axis { name: "brush_radius_offset_px", lo: 0.0, hi: 1.0, is_int: false,
|
||||
set: |p, v| p.brush_radius_offset_px = v, get: |p| p.brush_radius_offset_px },
|
||||
Axis { name: "polish_iters", lo: 0.0, hi: 12.0, is_int: true,
|
||||
set: |p, v| p.polish_iters = v as u32, get: |p| p.polish_iters as f32 },
|
||||
Axis { name: "polish_search_factor", lo: 0.10, hi: 4.0, is_int: false,
|
||||
set: |p, v| p.polish_search_factor = v, get: |p| p.polish_search_factor },
|
||||
Axis { name: "bg_penalty", lo: 0.0, hi: 20.0, is_int: false,
|
||||
set: |p, v| p.bg_penalty = v, get: |p| p.bg_penalty },
|
||||
Axis { name: "walk_bg_penalty", lo: 0.0, hi: 20.0, is_int: false,
|
||||
set: |p, v| p.walk_bg_penalty = v, get: |p| p.walk_bg_penalty },
|
||||
Axis { name: "overpaint_penalty", lo: 0.0, hi: 0.5, is_int: false,
|
||||
set: |p, v| p.overpaint_penalty = v, get: |p| p.overpaint_penalty },
|
||||
Axis { name: "step_size_factor", lo: 0.20, hi: 0.90, is_int: false,
|
||||
set: |p, v| p.step_size_factor = v, get: |p| p.step_size_factor },
|
||||
Axis { name: "lookahead_steps", lo: 1.0, hi: 12.0, is_int: true,
|
||||
set: |p, v| p.lookahead_steps = v as usize, get: |p| p.lookahead_steps as f32 },
|
||||
Axis { name: "n_directions", lo: 8.0, hi: 64.0, is_int: true,
|
||||
set: |p, v| p.n_directions = v as usize, get: |p| p.n_directions as f32 },
|
||||
Axis { name: "momentum_weight", lo: 0.0, hi: 2.0, is_int: false,
|
||||
set: |p, v| p.momentum_weight = v, get: |p| p.momentum_weight },
|
||||
Axis { name: "min_score_factor", lo: 0.0, hi: 0.30, is_int: false,
|
||||
set: |p, v| p.min_score_factor = v, get: |p| p.min_score_factor },
|
||||
Axis { name: "min_component_factor", lo: 0.10, hi: 1.50, is_int: false,
|
||||
set: |p, v| p.min_component_factor = v, get: |p| p.min_component_factor },
|
||||
Axis { name: "pen_lift_penalty", lo: 0.0, hi: 200.0, is_int: false,
|
||||
set: |p, v| p.pen_lift_penalty = v, get: |p| p.pen_lift_penalty },
|
||||
Axis { name: "pen_lift_reach", lo: 0.5, hi: 16.0, is_int: false,
|
||||
set: |p, v| p.pen_lift_reach = v, get: |p| p.pen_lift_reach },
|
||||
Axis { name: "output_rdp_eps", lo: 0.0, hi: 2.0, is_int: false,
|
||||
set: |p, v| p.output_rdp_eps = v, get: |p| p.output_rdp_eps },
|
||||
]
|
||||
}
|
||||
|
||||
/// Test corpus: every letter in `CORPUS_ALPHABET` rasterised at every
|
||||
/// scale in `CORPUS_CASES`, taking the largest hull from each. Both ends
|
||||
/// of the workload (test + worker) build the same corpus → identical
|
||||
/// score landscape → safe to distribute.
|
||||
pub const CORPUS_CASES: &[(f32, u32, u32)] = &[
|
||||
(5.0, 200, 4),
|
||||
(5.0, 425, 9),
|
||||
];
|
||||
pub const CORPUS_ALPHABET: &str = "ACGIJLMNOSUVWXZabcdefijlmosuvwxz";
|
||||
|
||||
pub fn build_corpus() -> Vec<(char, Hull)> {
|
||||
CORPUS_CASES.iter().flat_map(|&(mm, dpi, t)| {
|
||||
CORPUS_ALPHABET.chars().filter_map(move |ch| {
|
||||
let hulls = rasterize_test_letter(ch, mm, dpi, t);
|
||||
hulls.into_iter().max_by_key(|h| h.area).map(|h| (ch, h))
|
||||
})
|
||||
}).collect()
|
||||
}
|
||||
|
||||
/// Score one config against the whole corpus, parallel over hulls.
|
||||
pub fn evaluate(corpus: &[(char, Hull)], p: &PaintParams) -> f32 {
|
||||
corpus.par_iter().map(|(ch, hull)| {
|
||||
let (_, m) = metrics_for(hull, p);
|
||||
score_for_letter(*ch, &m)
|
||||
}).sum()
|
||||
}
|
||||
|
||||
/// Cheap deterministic PRNG so each start index produces the same point
|
||||
/// on every machine.
|
||||
fn rng_next(state: &mut u64) -> f32 {
|
||||
*state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407);
|
||||
((state.wrapping_shr(33)) as u32 as f32) / (u32::MAX as f32)
|
||||
}
|
||||
|
||||
/// Build the starting params for a given index. Indices 0-3 are
|
||||
/// hand-picked seeds (base, small-brush, fit-brush, big-brush). 4+ are
|
||||
/// random samples in the joint param space, deterministic per-index.
|
||||
pub fn build_start_params(idx: usize, base: &PaintParams, axes: &[Axis]) -> PaintParams {
|
||||
match idx {
|
||||
0 => base.clone(),
|
||||
1 => {
|
||||
let mut s = base.clone();
|
||||
s.brush_radius_factor = 0.55;
|
||||
s.brush_radius_offset_px = 0.25;
|
||||
s.brush_radius_percentile = 0.85;
|
||||
s.min_component_factor = 1.20;
|
||||
s.polish_iters = 4;
|
||||
s
|
||||
}
|
||||
2 => {
|
||||
let mut s = base.clone();
|
||||
s.brush_radius_factor = 1.00;
|
||||
s.brush_radius_offset_px = 0.5;
|
||||
s.brush_radius_percentile = 0.99;
|
||||
s.min_component_factor = 0.20;
|
||||
s.polish_iters = 2;
|
||||
s
|
||||
}
|
||||
3 => {
|
||||
let mut s = base.clone();
|
||||
s.brush_radius_factor = 1.15;
|
||||
s.brush_radius_offset_px = 0.5;
|
||||
s.brush_radius_percentile = 0.99;
|
||||
s.min_component_factor = 0.20;
|
||||
s.polish_iters = 1;
|
||||
s
|
||||
}
|
||||
_ => {
|
||||
let mut state = (idx as u64).wrapping_mul(0x9E3779B97F4A7C15).wrapping_add(0xDEADBEEF);
|
||||
let mut p = base.clone();
|
||||
for axis in axes {
|
||||
let r = rng_next(&mut state);
|
||||
let v = axis.lo + r * (axis.hi - axis.lo);
|
||||
let v = if axis.is_int { v.round() } else { v };
|
||||
(axis.set)(&mut p, v);
|
||||
}
|
||||
p
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Output of one refinement run.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RefineResult {
|
||||
pub start_idx: usize,
|
||||
pub score: f32,
|
||||
pub params: PaintParams,
|
||||
pub log: Vec<String>,
|
||||
}
|
||||
|
||||
/// Best-improvement coordinate descent with golden-section line search
|
||||
/// on each axis. Stops when no axis can drop the score by more than 1.
|
||||
pub fn refine_one(
|
||||
corpus: &[(char, Hull)],
|
||||
axes: &[Axis],
|
||||
start: &PaintParams,
|
||||
max_passes: u32,
|
||||
) -> (PaintParams, f32, Vec<String>) {
|
||||
let try_axis = |params: &PaintParams, axis: &Axis, v: f32| -> f32 {
|
||||
let mut p = params.clone();
|
||||
let v = if axis.is_int { v.round().clamp(axis.lo, axis.hi) }
|
||||
else { v.clamp(axis.lo, axis.hi) };
|
||||
(axis.set)(&mut p, v);
|
||||
evaluate(corpus, &p)
|
||||
};
|
||||
|
||||
let golden_section = |params: &PaintParams, axis: &Axis, iters: u32| -> (f32, f32) {
|
||||
const PHI: f32 = 0.6180339887;
|
||||
let (mut a, mut b) = (axis.lo, axis.hi);
|
||||
let mut x1 = b - PHI * (b - a);
|
||||
let mut x2 = a + PHI * (b - a);
|
||||
let mut f1 = try_axis(params, axis, x1);
|
||||
let mut f2 = try_axis(params, axis, x2);
|
||||
for _ in 0..iters {
|
||||
if f1 < f2 {
|
||||
b = x2; x2 = x1; f2 = f1;
|
||||
x1 = b - PHI * (b - a);
|
||||
f1 = try_axis(params, axis, x1);
|
||||
} else {
|
||||
a = x1; x1 = x2; f1 = f2;
|
||||
x2 = a + PHI * (b - a);
|
||||
f2 = try_axis(params, axis, x2);
|
||||
}
|
||||
if axis.is_int && (b - a) < 1.0 { break; }
|
||||
}
|
||||
if f1 < f2 {
|
||||
let v = if axis.is_int { x1.round() } else { x1 };
|
||||
(v, f1)
|
||||
} else {
|
||||
let v = if axis.is_int { x2.round() } else { x2 };
|
||||
(v, f2)
|
||||
}
|
||||
};
|
||||
|
||||
let mut current = start.clone();
|
||||
let mut current_score = evaluate(corpus, ¤t);
|
||||
let mut log: Vec<String> = vec![format!("start → {:.0}", current_score)];
|
||||
|
||||
for _ in 0..max_passes {
|
||||
let per_axis: Vec<(usize, f32, f32)> = axes.par_iter().enumerate().map(|(ai, axis)| {
|
||||
let (v, s) = golden_section(¤t, axis, 12);
|
||||
(ai, v, s)
|
||||
}).collect();
|
||||
let (best_ai, best_v, best_s) = per_axis.iter()
|
||||
.min_by(|a, b| a.2.partial_cmp(&b.2).unwrap()).cloned().unwrap();
|
||||
if best_s + 1.0 >= current_score { break; }
|
||||
let axis = &axes[best_ai];
|
||||
log.push(format!(
|
||||
" {:25} {:>6.2} → {:>6.2} → {:.0} (Δ {:.0})",
|
||||
axis.name, (axis.get)(¤t), best_v, best_s, current_score - best_s
|
||||
));
|
||||
(axis.set)(&mut current, best_v);
|
||||
current_score = best_s;
|
||||
}
|
||||
|
||||
(current, current_score, log)
|
||||
}
|
||||
|
||||
/// One-call entry point used by the worker binary: build corpus, build
|
||||
/// the indexed start, refine, return.
|
||||
pub fn run_one_start(start_idx: usize, base: &PaintParams, max_passes: u32) -> RefineResult {
|
||||
let axes = default_axes();
|
||||
let corpus = build_corpus();
|
||||
let start = build_start_params(start_idx, base, &axes);
|
||||
let (params, score, log) = refine_one(&corpus, &axes, &start, max_passes);
|
||||
RefineResult { start_idx, score, params, log }
|
||||
}
|
||||
159
src/lib.rs
159
src/lib.rs
@@ -6,6 +6,7 @@ pub mod text;
|
||||
pub mod streamline;
|
||||
pub mod topo_strokes;
|
||||
pub mod brush_paint;
|
||||
pub mod brush_paint_opt;
|
||||
|
||||
use std::time::Instant;
|
||||
|
||||
@@ -1000,6 +1001,34 @@ fn list_hulls(pass_idx: usize, state: State<Mutex<AppState>>) -> Result<Vec<Hull
|
||||
}).collect())
|
||||
}
|
||||
|
||||
/// Replace the hulls in `pass_idx` with a freshly-rasterized test letter.
|
||||
/// Lets the debug viewer load any character at any scale on demand without
|
||||
/// having to push an image through the full pipeline. Returns the updated
|
||||
/// hull list.
|
||||
#[tauri::command]
|
||||
fn load_test_letter(
|
||||
pass_idx: usize,
|
||||
ch: String,
|
||||
font_mm: f32,
|
||||
dpi: u32,
|
||||
thickness_px: u32,
|
||||
state: State<Mutex<AppState>>,
|
||||
) -> Result<Vec<HullSummary>, String> {
|
||||
let c = ch.chars().next().ok_or("empty character")?;
|
||||
let hulls = brush_paint::rasterize_test_letter(c, font_mm, dpi, thickness_px);
|
||||
let mut st = state.lock().unwrap();
|
||||
if pass_idx >= st.passes.len() {
|
||||
st.passes.resize_with(pass_idx + 1, PassState::default);
|
||||
}
|
||||
let ps = &mut st.passes[pass_idx];
|
||||
ps.hulls = hulls;
|
||||
Ok(ps.hulls.iter().enumerate().map(|(i, h)| HullSummary {
|
||||
index: i,
|
||||
area: h.area,
|
||||
bounds: [h.bounds.x_min, h.bounds.y_min, h.bounds.x_max, h.bounds.y_max],
|
||||
}).collect())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_streamline_debug(
|
||||
pass_idx: usize, hull_idx: usize, params: streamline::StreamlineParams,
|
||||
@@ -1026,6 +1055,134 @@ fn get_paint_debug(
|
||||
Ok(brush_paint::paint_fill_debug(h, ¶ms))
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct OptimizerProgress {
|
||||
step: u32,
|
||||
axis: String,
|
||||
value: f32,
|
||||
score: f32,
|
||||
delta: f32,
|
||||
params: brush_paint::PaintParams,
|
||||
}
|
||||
|
||||
/// Run best-improvement coordinate descent on the brush-paint params,
|
||||
/// optimizing against the single hull at `(pass_idx, hull_idx)` using
|
||||
/// `default_score`. Emits an `optimizer-progress` event after every axis
|
||||
/// improvement; the final best params come back as the return value.
|
||||
///
|
||||
/// This is the in-app version of the `paint_optimize_global_defaults`
|
||||
/// test — single-hull, no constraint corpus, so it's fast (< 5s typical)
|
||||
/// and lets the user dial in params for whatever letter they're staring at.
|
||||
///
|
||||
/// Synchronous: Tauri runs sync commands on a thread pool, so the UI
|
||||
/// stays responsive while this runs.
|
||||
#[tauri::command]
|
||||
fn optimize_paint_params(
|
||||
pass_idx: usize, hull_idx: usize, base: brush_paint::PaintParams,
|
||||
app: AppHandle,
|
||||
state: State<Mutex<AppState>>,
|
||||
) -> Result<brush_paint::PaintParams, String> {
|
||||
// Clone the hull so we don't hold the mutex during the descent.
|
||||
let hull = {
|
||||
let st = state.lock().unwrap();
|
||||
let ps = st.passes.get(pass_idx)
|
||||
.ok_or_else(|| format!("pass {pass_idx} out of range"))?;
|
||||
ps.hulls.get(hull_idx)
|
||||
.ok_or_else(|| format!("hull {hull_idx} out of range"))?
|
||||
.clone()
|
||||
};
|
||||
|
||||
let result = (|| -> brush_paint::PaintParams {
|
||||
type Setter = fn(&mut brush_paint::PaintParams, f32);
|
||||
let axes: Vec<(&str, Vec<f32>, Setter)> = vec![
|
||||
("brush_radius_factor", vec![0.55, 0.65, 0.75, 0.85, 0.95, 1.05, 1.15],
|
||||
|p, v| p.brush_radius_factor = v),
|
||||
("brush_radius_percentile", vec![0.85, 0.90, 0.95, 0.99, 1.00],
|
||||
|p, v| p.brush_radius_percentile = v),
|
||||
("brush_radius_offset_px", vec![0.0, 0.25, 0.5],
|
||||
|p, v| p.brush_radius_offset_px = v),
|
||||
("polish_iters", vec![0.0, 1.0, 2.0, 4.0, 6.0, 10.0],
|
||||
|p, v| p.polish_iters = v as u32),
|
||||
("polish_search_factor", vec![0.2, 0.4, 0.7, 1.0, 1.5, 2.5],
|
||||
|p, v| p.polish_search_factor = v),
|
||||
("bg_penalty", vec![0.0, 1.0, 2.0, 4.0, 8.0, 15.0],
|
||||
|p, v| p.bg_penalty = v),
|
||||
("walk_bg_penalty", vec![0.0, 1.0, 2.0, 4.0, 8.0, 15.0],
|
||||
|p, v| p.walk_bg_penalty = v),
|
||||
("overpaint_penalty", vec![0.0, 0.02, 0.05, 0.1, 0.2, 0.4],
|
||||
|p, v| p.overpaint_penalty = v),
|
||||
("step_size_factor", vec![0.25, 0.4, 0.5, 0.65, 0.8],
|
||||
|p, v| p.step_size_factor = v),
|
||||
("lookahead_steps", vec![1.0, 2.0, 3.0, 4.0, 6.0, 8.0],
|
||||
|p, v| p.lookahead_steps = v as usize),
|
||||
("n_directions", vec![8.0, 16.0, 24.0, 32.0, 48.0],
|
||||
|p, v| p.n_directions = v as usize),
|
||||
("momentum_weight", vec![0.0, 0.2, 0.4, 0.6, 0.9, 1.5],
|
||||
|p, v| p.momentum_weight = v),
|
||||
("min_score_factor", vec![0.0, 0.02, 0.05, 0.1, 0.2],
|
||||
|p, v| p.min_score_factor = v),
|
||||
("min_component_factor", vec![0.2, 0.4, 0.6, 0.8, 1.2],
|
||||
|p, v| p.min_component_factor = v),
|
||||
("pen_lift_penalty", vec![0.0, 10.0, 30.0, 60.0, 120.0],
|
||||
|p, v| p.pen_lift_penalty = v),
|
||||
("pen_lift_reach", vec![1.0, 3.0, 6.0, 10.0, 16.0],
|
||||
|p, v| p.pen_lift_reach = v),
|
||||
("output_rdp_eps", vec![0.0, 0.25, 0.5, 1.0],
|
||||
|p, v| p.output_rdp_eps = v),
|
||||
];
|
||||
|
||||
let eval = |p: &brush_paint::PaintParams| -> f32 {
|
||||
let (_, m) = brush_paint::metrics_for(&hull, p);
|
||||
brush_paint::default_score(&m)
|
||||
};
|
||||
|
||||
let mut current = base.clone();
|
||||
let mut current_score = eval(¤t);
|
||||
let _ = app.emit("optimizer-progress", OptimizerProgress {
|
||||
step: 0, axis: "<initial>".into(), value: 0.0,
|
||||
score: current_score, delta: 0.0,
|
||||
params: current.clone(),
|
||||
});
|
||||
|
||||
let max_steps = axes.len() * 6;
|
||||
for step_no in 1..=max_steps {
|
||||
// Best-improvement: try every axis's best candidate, take the
|
||||
// single move with the biggest score drop.
|
||||
let mut best_axis_idx: usize = usize::MAX;
|
||||
let mut best_axis_v: f32 = f32::NAN;
|
||||
let mut best_axis_score: f32 = current_score;
|
||||
for (ai, (_name, candidates, setter)) in axes.iter().enumerate() {
|
||||
for &v in candidates {
|
||||
let mut p = current.clone();
|
||||
setter(&mut p, v);
|
||||
let s = eval(&p);
|
||||
if s + 1e-3 < best_axis_score {
|
||||
best_axis_score = s;
|
||||
best_axis_v = v;
|
||||
best_axis_idx = ai;
|
||||
}
|
||||
}
|
||||
}
|
||||
if best_axis_idx == usize::MAX { break; } // converged
|
||||
let (name, _, setter) = &axes[best_axis_idx];
|
||||
let prev = current_score;
|
||||
setter(&mut current, best_axis_v);
|
||||
current_score = best_axis_score;
|
||||
let _ = app.emit("optimizer-progress", OptimizerProgress {
|
||||
step: step_no as u32,
|
||||
axis: name.to_string(),
|
||||
value: best_axis_v,
|
||||
score: current_score,
|
||||
delta: prev - current_score,
|
||||
params: current.clone(),
|
||||
});
|
||||
}
|
||||
current
|
||||
})();
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn set_pass_count(count: usize, state: State<Mutex<AppState>>) {
|
||||
let mut st = state.lock().unwrap();
|
||||
@@ -2845,8 +3002,10 @@ pub fn run() {
|
||||
get_images_dir,
|
||||
set_pass_count,
|
||||
list_hulls,
|
||||
load_test_letter,
|
||||
get_streamline_debug,
|
||||
get_paint_debug,
|
||||
optimize_paint_params,
|
||||
process_pass,
|
||||
get_all_strokes,
|
||||
get_gcode_viz,
|
||||
|
||||
Reference in New Issue
Block a user