Rework layout engine around exact-glyph collision, add tests and docs sync

Replace the old bbox/heuristic placement (scale search rounds, large-font
capping, stratified sampling, fill-retry ladders) with an area-model font
sizing pass feeding a C++ exact-glyph collision engine (centroid-biased
spiral + random probing, HD clearance refinement, density/hole
optimization). Simplify the frontend advanced-params panel and JobParams
type to match the surviving config surface, add a layout-constraints test
suite and a repeatable benchmark tool, and bring docs/*.md back in sync
with current code (plus new TESTING.md and DEPLOYMENT.md).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 18:32:25 +08:00
co-authored by Claude Sonnet 5
parent bf2b138007
commit 1d17b5e20d
24 changed files with 2600 additions and 1283 deletions
+1
View File
@@ -38,6 +38,7 @@ backend/service_projects/
backend/service_fonts/
backend/output/
backend/output_cli_test/
backend/benchmark_outputs/
backend/ref/
backend/.runtime/
+10 -17
View File
@@ -24,6 +24,7 @@ wc = EfficientWordCloud(
font_path="/path/to/font.ttf",
max_words=200,
min_font_size=8,
max_font_size=32,
prefer_horizontal=0.9
)
@@ -36,26 +37,18 @@ img.show()
- `width` / `height`:画布尺寸。
- `font_path`:字体路径。
- `max_words`:最大词数。
- `min_font_size`:最小字体
- `min_font_size` / `max_font_size`:本次整批布局可使用的硬字号边界
- `prefer_horizontal`:水平排版概率。
- `use_spiral_search`:是否启用中心优先排序搜索
- `relative_scaling`:权重对目标字号的影响比例
- `margin`:真实字形之间的最小工作网格间距。
## 4. 并行优化的使用说明
### 4.1 Python bbox 预取
- 自动启用,无需额外配置。
- 内部使用 `ProcessPoolExecutor`,将未来词语的 bbox 计算并行化。
- 运行 `generate` 时会输出预取/等待日志,便于观察并行效果。
## 4. 放置语义
### 4.2 C++ 并行搜索
- `use_spiral_search=True` 时启用
- 在 C++ 内部自动进行分块并行搜索,并保持中心优先排序的结果一致性
- 每个词只使用权重映射得到的目标字号。
- 放不下时只尝试同字号的另一方向,不会逐词缩字号
- 调用者需要检查 `layout_` 的数量;若不完整,应整批调整字号或扩大画布后创建新实例重排
- 项目正式流水线使用 C++ `place_glyph_exact()` 做真实字形碰撞;底层兼容类保留矩形搜索 API。
## 5. 常见问题
### 5.1 为什么字体缩小时没有并行
缩小字体后 bbox 依赖当前失败状态,需要同步确认以确保正确性。
### 5.2 多进程是否会导致额外内存开销?
是的,但任务仅用于 bbox 预取,且窗口大小有限,避免过度占用。
### 5.3 若没有字体文件怎么办?
### 5.1 若没有字体文件怎么办
会回退到 PIL 默认字体,但测量与渲染效果可能不同。
@@ -24,6 +24,7 @@
#include <random>
#include <functional>
#include <numeric>
#include <limits>
// ==========================================
// Thread Pool (avoid per-query thread creation)
@@ -114,8 +115,13 @@ public:
int w;
};
// valid coordinates sorted by distance to center (for sorted/spiral search)
// Original mask-free coordinates. Sorting is lazy because the active
// query_direct/query_near_center paths do not need the O(A log A) order.
std::vector<std::pair<int, int>> valid_coords;
bool valid_coords_center_sorted = false;
double free_center_y = 0.0;
double free_center_x = 0.0;
int spiral_cursor = 1;
// Lazy update buffers
std::vector<int32_t> diff;
@@ -133,9 +139,9 @@ public:
}
void init_from_buffer(unsigned char* raw_mask, int h, int w) {
int center_y = h / 2;
int center_x = w / 2;
valid_coords.reserve(h * w / 2);
uint64_t free_y_sum = 0;
uint64_t free_x_sum = 0;
// Initialize canvas from mask
ensure_canvas();
@@ -153,25 +159,45 @@ public:
}
if (!is_blocked) {
valid_coords.push_back({i, j});
free_y_sum += (uint64_t)i;
free_x_sum += (uint64_t)j;
}
}
}
std::sort(valid_coords.begin(), valid_coords.end(),
[center_y, center_x](const std::pair<int, int>& a, const std::pair<int, int>& b) {
long da = (long)(a.first - center_y)*(a.first - center_y) + (long)(a.second - center_x)*(a.second - center_x);
long db = (long)(b.first - center_y)*(b.first - center_y) + (long)(b.second - center_x)*(b.second - center_x);
return da < db;
}
);
if (!valid_coords.empty()) {
free_center_y = (double)free_y_sum / (double)valid_coords.size();
free_center_x = (double)free_x_sum / (double)valid_coords.size();
} else {
free_center_y = (double)h * 0.5;
free_center_x = (double)w * 0.5;
}
valid_coords_center_sorted = false;
spiral_cursor = 1;
std::fill(diff.begin(), diff.end(), 0);
recent_rects.clear();
dirty_count = 0;
}
void ensure_valid_coords_center_sorted() {
if (valid_coords_center_sorted) return;
const double center_y = free_center_y;
const double center_x = free_center_x;
std::sort(valid_coords.begin(), valid_coords.end(),
[center_y, center_x](const std::pair<int, int>& a, const std::pair<int, int>& b) {
double ay = (double)a.first - center_y;
double ax = (double)a.second - center_x;
double by = (double)b.first - center_y;
double bx = (double)b.second - center_x;
return ay * ay + ax * ax < by * by + bx * bx;
}
);
valid_coords_center_sorted = true;
}
// Legacy coordinate ordering remains available to compatibility callers.
void reorder_stratified(int bands) {
std::unique_lock<std::shared_mutex> lock(mutex_);
ensure_valid_coords_center_sorted();
const size_t len = valid_coords.size();
if (bands <= 1 || len == 0) return;
if ((size_t)bands > len) bands = (int)len;
@@ -279,7 +305,6 @@ public:
std::fill(diff.begin(), diff.end(), 0);
recent_rects.clear();
dirty_count = 0;
for (int i = 0; i < height; ++i) {
uint32_t row_sum = 0;
for (int j = 0; j < width; ++j) {
@@ -426,7 +451,8 @@ public:
}
DirectResult query_direct(int box_h, int box_w, uint32_t seed) {
// Always flush before scanning
// Apply pending rectangle updates before reading the integral image.
// Exact-glyph placement uses its own canvas-only path.
flush();
int max_row = height - box_h;
@@ -447,11 +473,12 @@ public:
return {true, y, x};
}
// Quick random probe: try a few random positions first
// If the canvas is mostly empty, one of these will hit quickly
// This avoids the full O(H*W) scan for early words
// Random probes avoid an O(H*W) scan during early and middle packing.
// Increase the budget as occupancy rises.
{
int n_probes = std::min(16, (int)total_positions);
const double occ_ratio = (double)total_occupied / (double)(width * height);
const int probe_budget = (occ_ratio < 0.15) ? 32 : (occ_ratio < 0.40) ? 96 : 192;
const int n_probes = (int)std::min<int64_t>(probe_budget, total_positions);
for (int p = 0; p < n_probes; ++p) {
int y = std::uniform_int_distribution<int>(0, max_row)(rng);
int x = std::uniform_int_distribution<int>(0, max_col)(rng);
@@ -469,27 +496,28 @@ public:
}
if (nt <= 1) {
// Single-thread: two-pass (count then pick) is faster than
// reservoir sampling because it avoids per-position RNG calls
uint64_t total_valid = 0;
for (int i = 0; i <= max_row; ++i) {
for (int j = 0; j <= max_col; ++j) {
if (get_area_sum_fast(i, j, box_h, box_w) == 0)
++total_valid;
}
}
if (total_valid == 0) return {false, -1, -1};
uint64_t target = std::uniform_int_distribution<uint64_t>(0, total_valid - 1)(rng);
// Single-pass reservoir halves the work of count-then-pick. A
// small LCG avoids invoking mt19937 for every valid position.
uint64_t count = 0;
int best_y = -1, best_x = -1;
uint32_t state = seed ? seed : 1u;
for (int i = 0; i <= max_row; ++i) {
for (int j = 0; j <= max_col; ++j) {
if (get_area_sum_fast(i, j, box_h, box_w) == 0) {
if (count == target) return {true, i, j};
++count;
// Replace the current result with probability 1/count.
state = state * 1664525u + 1013904223u;
const bool replace = (state % count) == 0;
if (replace) {
best_y = i;
best_x = j;
}
}
}
}
return {false, -1, -1};
return count == 0
? DirectResult{false, -1, -1}
: DirectResult{true, best_y, best_x};
}
// Multi-thread path: parallel count then pick
@@ -534,6 +562,189 @@ public:
return {false, -1, -1};
}
// Sample legal positions and prefer the one whose box center is closest
// to the free-mask centroid. This gives the visually important large
// words a stable focal region without paying for a full spiral scan.
DirectResult query_near_center(int box_h, int box_w, uint32_t seed, int probes) {
flush();
int max_row = height - box_h;
int max_col = width - box_w;
if (max_row < 0 || max_col < 0) return {false, -1, -1};
std::mt19937 rng(seed);
probes = std::max(16, std::min(probes, 1024));
int best_y = -1;
int best_x = -1;
double best_score = std::numeric_limits<double>::infinity();
std::uniform_int_distribution<int> row_dist(0, max_row);
std::uniform_int_distribution<int> col_dist(0, max_col);
std::uniform_real_distribution<double> jitter(0.0, 1e-4);
// Test the centroid-aligned position first.
int center_y = std::max(0, std::min(max_row, (int)std::lround(free_center_y - box_h * 0.5)));
int center_x = std::max(0, std::min(max_col, (int)std::lround(free_center_x - box_w * 0.5)));
if (get_area_sum_fast(center_y, center_x, box_h, box_w) == 0) {
return {true, center_y, center_x};
}
const double norm_y = std::max(1.0, (double)height);
const double norm_x = std::max(1.0, (double)width);
for (int p = 0; p < probes; ++p) {
int y = row_dist(rng);
int x = col_dist(rng);
if (get_area_sum_fast(y, x, box_h, box_w) != 0) continue;
double cy = (double)y + box_h * 0.5;
double cx = (double)x + box_w * 0.5;
double dy = (cy - free_center_y) / norm_y;
double dx = (cx - free_center_x) / norm_x;
double score = dy * dy + dx * dx + jitter(rng);
if (score < best_score) {
best_score = score;
best_y = y;
best_x = x;
}
}
if (best_y >= 0) return {true, best_y, best_x};
return query_direct(box_h, box_w, seed ^ 0x9E3779B9u);
}
inline bool glyph_fits_exact(
const unsigned char* glyph, int glyph_h, int glyph_w, int y, int x
) const {
for (int row = 0; row < glyph_h; ++row) {
const unsigned char* glyph_row = glyph + row * glyph_w;
const uint8_t* canvas_row = canvas.data() + (y + row) * width + x;
for (int col = 0; col < glyph_w; ++col) {
if (glyph_row[col] > 0 && canvas_row[col] != 0) return false;
}
}
return true;
}
inline void reserve_glyph_exact(
const unsigned char* glyph, int glyph_h, int glyph_w, int y, int x
) {
stamp_glyph(glyph, glyph_h, glyph_w, y, x);
}
// Find and reserve a position using the actual (optionally dilated) glyph
// bitmap. Unlike rectangle queries, transparent corners and gaps between
// strokes may overlap safely. Search and reservation stay in one C++ call,
// so no integral-image rebuild is needed between words.
DirectResult place_glyph_exact(
const unsigned char* collision_glyph,
const unsigned char* stamp_glyph_data,
int glyph_h,
int glyph_w,
uint32_t seed,
int probes,
int placement_mode
) {
ensure_canvas();
const int max_row = height - glyph_h;
const int max_col = width - glyph_w;
if (max_row < 0 || max_col < 0) return {false, -1, -1};
std::mt19937 rng(seed);
probes = std::max(32, std::min(probes, 2048));
std::uniform_int_distribution<int> row_dist(0, max_row);
std::uniform_int_distribution<int> col_dist(0, max_col);
int best_y = -1;
int best_x = -1;
double best_score = std::numeric_limits<double>::infinity();
if (placement_mode != 0) {
const int center_y = std::max(
0, std::min(max_row, (int)std::lround(free_center_y - glyph_h * 0.5))
);
const int center_x = std::max(
0, std::min(max_col, (int)std::lround(free_center_x - glyph_w * 0.5))
);
if (glyph_fits_exact(collision_glyph, glyph_h, glyph_w, center_y, center_x)) {
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, center_y, center_x);
return {true, center_y, center_x};
}
}
const double norm_y = std::max(1.0, (double)height);
const double norm_x = std::max(1.0, (double)width);
// Mode 1 is a persistent centre-out Fermat spiral. Mode 2 skips the
// spiral and selects a random legal candidate biased toward the centre.
if (placement_mode == 1) {
constexpr double golden_angle = 2.39996322972865332;
constexpr double sample_spacing = 1.25;
const int start_step = spiral_cursor;
const int end_step = std::min(200000, start_step + 60000);
int last_y = std::numeric_limits<int>::min();
int last_x = std::numeric_limits<int>::min();
for (int step = start_step; step <= end_step; ++step) {
const double radius = sample_spacing * std::sqrt((double)step);
const double theta = golden_angle * (double)step;
const int y = (int)std::lround(
free_center_y + radius * std::sin(theta) - glyph_h * 0.5
);
const int x = (int)std::lround(
free_center_x + radius * std::cos(theta) - glyph_w * 0.5
);
if (y == last_y && x == last_x) continue;
last_y = y;
last_x = x;
if (y < 0 || x < 0 || y > max_row || x > max_col) continue;
if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue;
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, y, x);
spiral_cursor = step;
return {true, y, x};
}
}
for (int probe = 0; probe < probes; ++probe) {
const int y = row_dist(rng);
const int x = col_dist(rng);
if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue;
if (placement_mode == 0) {
stamp_glyph(stamp_glyph_data, glyph_h, glyph_w, y, x);
return {true, y, x};
}
const double cy = (double)y + glyph_h * 0.5;
const double cx = (double)x + glyph_w * 0.5;
const double dy = (cy - free_center_y) / norm_y;
const double dx = (cx - free_center_x) / norm_x;
const double score = dy * dy + dx * dx;
if (score < best_score) {
best_score = score;
best_y = y;
best_x = x;
}
}
if (best_y >= 0) {
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, best_y, best_x);
return {true, best_y, best_x};
}
// Dense late-stage fallback. Start at a seeded offset to avoid a
// top-left bias, but visit every possible origin so completeness is
// deterministic whenever a legal position exists.
const int n_rows = max_row + 1;
const int n_cols = max_col + 1;
const int row_start = row_dist(rng);
const int col_start = col_dist(rng);
for (int row_offset = 0; row_offset < n_rows; ++row_offset) {
const int y = (row_start + row_offset) % n_rows;
for (int col_offset = 0; col_offset < n_cols; ++col_offset) {
const int x = (col_start + col_offset) % n_cols;
if (!glyph_fits_exact(collision_glyph, glyph_h, glyph_w, y, x)) continue;
reserve_glyph_exact(stamp_glyph_data, glyph_h, glyph_w, y, x);
return {true, y, x};
}
}
return {false, -1, -1};
}
// =========================================================
// v3: Batch query — process multiple (box_h, box_w) in one call
// Returns vector of {found, y, x} for each query
@@ -576,6 +787,10 @@ public:
}
std::pair<bool, std::pair<int, int>> find_spot_parallel(int box_h, int box_w, int step) {
{
std::unique_lock<std::shared_mutex> lock(mutex_);
ensure_valid_coords_center_sorted();
}
{
std::shared_lock<std::shared_mutex> lock(mutex_);
if (dirty_count > 0 && dirty_count >= rebuild_interval) {
@@ -684,15 +899,63 @@ static PyObject* Grid_query_direct(PyIntegralGrid* self, PyObject* args) {
unsigned int seed = 0;
if (!PyArg_ParseTuple(args, "ii|I", &box_h, &box_w, &seed)) return NULL;
Py_BEGIN_ALLOW_THREADS
// query_direct is GIL-free safe (no Python objects touched)
Py_END_ALLOW_THREADS
auto r = self->grid->query_direct(box_h, box_w, seed);
if (r.found) return Py_BuildValue("ii", r.y, r.x);
Py_RETURN_NONE;
}
static PyObject* Grid_query_near_center(PyIntegralGrid* self, PyObject* args) {
int box_h, box_w;
unsigned int seed = 0;
int probes = 160;
if (!PyArg_ParseTuple(args, "ii|Ii", &box_h, &box_w, &seed, &probes)) return NULL;
auto r = self->grid->query_near_center(box_h, box_w, seed, probes);
if (r.found) return Py_BuildValue("ii", r.y, r.x);
Py_RETURN_NONE;
}
static PyObject* Grid_place_glyph_exact(PyIntegralGrid* self, PyObject* args) {
PyObject* collision_obj;
PyObject* stamp_obj;
int glyph_h, glyph_w;
unsigned int seed = 0;
int probes = 256;
int placement_mode = 0;
if (!PyArg_ParseTuple(
args, "OOiiIii", &collision_obj, &stamp_obj, &glyph_h, &glyph_w, &seed, &probes, &placement_mode
)) return NULL;
Py_buffer collision_view;
Py_buffer stamp_view;
if (PyObject_GetBuffer(collision_obj, &collision_view, PyBUF_SIMPLE) < 0) return NULL;
if (PyObject_GetBuffer(stamp_obj, &stamp_view, PyBUF_SIMPLE) < 0) {
PyBuffer_Release(&collision_view);
return NULL;
}
const Py_ssize_t required = (Py_ssize_t)glyph_h * (Py_ssize_t)glyph_w;
if (glyph_h <= 0 || glyph_w <= 0 || collision_view.len < required || stamp_view.len < required) {
PyBuffer_Release(&collision_view);
PyBuffer_Release(&stamp_view);
PyErr_SetString(PyExc_ValueError, "glyph buffer is smaller than glyph_h * glyph_w");
return NULL;
}
auto result = self->grid->place_glyph_exact(
(const unsigned char*)collision_view.buf,
(const unsigned char*)stamp_view.buf,
glyph_h,
glyph_w,
seed,
probes,
placement_mode
);
PyBuffer_Release(&collision_view);
PyBuffer_Release(&stamp_view);
if (result.found) return Py_BuildValue("ii", result.y, result.x);
Py_RETURN_NONE;
}
// v3: Batch query — process multiple placements in one C++ call
// Input: list of (box_h, box_w, seed) tuples
// Output: list of (y, x) or None for each
@@ -768,7 +1031,7 @@ static PyObject* Grid_rebuild_from_bitmap_partial(PyIntegralGrid* self, PyObject
Py_RETURN_NONE;
}
// v4: Stamp glyph bitmap onto C++ canvas and rebuild integral
// Stamp glyph bitmap onto C++ canvas and rebuild the affected integral region.
static PyObject* Grid_stamp_and_rebuild(PyIntegralGrid* self, PyObject* args) {
PyObject* glyph_obj;
int gh, gw, pos_r, pos_c;
@@ -788,6 +1051,8 @@ static PyMethodDef Grid_methods[] = {
{"query_sorted", (PyCFunction)Grid_query_sorted, METH_VARARGS, "Find position using sorted coordinate list (center-out, parallel)."},
{"query_reservoir", (PyCFunction)Grid_query_reservoir, METH_VARARGS, "Find position using parallel direct-scan reservoir sampling."},
{"query_direct", (PyCFunction)Grid_query_direct, METH_VARARGS, "Direct pixel-grid scan with parallel reservoir sampling."},
{"query_near_center", (PyCFunction)Grid_query_near_center, METH_VARARGS, "Sample legal positions and prefer the free-mask centroid."},
{"place_glyph_exact", (PyCFunction)Grid_place_glyph_exact, METH_VARARGS, "Place and reserve an exact glyph bitmap without changing its size."},
{"batch_query", (PyCFunction)Grid_batch_query, METH_VARARGS, "Batch placement: list of (bh,bw,seed) -> list of (y,x)|None."},
{"update", (PyCFunction)Grid_update, METH_VARARGS, "Update grid with placed rectangle."},
{"flush", (PyCFunction)Grid_flush, METH_NOARGS, "Force rebuild integral image."},
@@ -101,8 +101,6 @@ class EfficientWordCloud:
Probability a word is placed horizontally (01).
mode : str
PIL image mode ('RGB', 'RGBA', …).
use_spiral_search : bool
Use center-out sorted search (True) or reservoir sampling (False).
scale : float
Scaling factor between layout computation and final rendering.
``scale=2`` means the output image is 2× the canvas size in each
@@ -142,8 +140,6 @@ class EfficientWordCloud:
How much word frequency (vs rank) influences font size.
0 = rank only, 1 = fully frequency-driven.
When *repeat* is True, defaults to 0.
font_step : int
Step size when reducing font size to find a fit.
"""
def __init__(self,
@@ -156,7 +152,6 @@ class EfficientWordCloud:
background_color="black",
prefer_horizontal=0.9,
mode="RGB",
use_spiral_search=True,
scale=1,
contour_width=0,
contour_color="black",
@@ -165,7 +160,6 @@ class EfficientWordCloud:
colormap=None,
random_state=None,
relative_scaling="auto",
font_step=1,
repeat=False,
stopwords=None,
regexp=None,
@@ -185,7 +179,6 @@ class EfficientWordCloud:
self.background_color = background_color
self.prefer_horizontal = prefer_horizontal
self.mode = mode
self.use_spiral_search = use_spiral_search
self.scale = scale
self.contour_width = contour_width
self.contour_color = contour_color
@@ -197,7 +190,6 @@ class EfficientWordCloud:
else:
self.relative_scaling = relative_scaling
self.margin = margin
self.font_step = font_step
self.stopwords = stopwords if stopwords is not None else STOPWORDS
self.regexp = regexp
self.collocations = collocations
@@ -319,10 +311,9 @@ class EfficientWordCloud:
def _query(qh, qw):
return self.grid.query_direct(qh, qw, rs.randint(0, 2**31))
# v4: Ref-like linear step-down placement with bitmap occupancy
# After each word placement, stamp glyph bitmap into C++ canvas
# and rebuild integral for pixel-accurate collision detection.
# No PIL image drawn during placement — to_image() renders later.
# Each word is tried at exactly its weight-derived target size. A
# failed word may change orientation, but never receives a private
# fallback size. Whole-cloud scaling belongs to the caller.
# Dummy draw for textbbox measurement
_measure_img = Image.new("L", (1, 1))
@@ -344,14 +335,13 @@ class EfficientWordCloud:
else:
orientation = Image.ROTATE_90
tried_other_orientation = False
while True:
if font_size < self.min_font_size:
break
pos = None
orientations = [orientation]
if self.prefer_horizontal < 1:
orientations.append(Image.ROTATE_90 if orientation is None else None)
for candidate_orientation in orientations:
font = _get_font(font_size)
transposed = ImageFont.TransposedFont(font, orientation=orientation)
transposed = ImageFont.TransposedFont(font, orientation=candidate_orientation)
bbox = _measure_draw.textbbox((0, 0), word, font=transposed)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
@@ -360,20 +350,11 @@ class EfficientWordCloud:
pos = _query(qh, qw)
if pos is not None:
orientation = candidate_orientation
break
# No position found — try alternate orientation, then reduce size
if not tried_other_orientation and self.prefer_horizontal < 1:
orientation = Image.ROTATE_90 if orientation is None else None
tried_other_orientation = True
else:
font_size -= self.font_step
orientation = None
tried_other_orientation = False
if font_size < self.min_font_size:
# Canvas full — no more words can fit
break
if pos is None:
continue
y, x = pos
# Adjust position for margin (like ref: x,y += margin // 2)
+18 -81
View File
@@ -31,8 +31,6 @@ MODE = "IMAGE"
# --- Image Mode ---
MASK_IMAGE_PATH = "7887.png"
IMAGE_CANVAS_MODE = "WIDTH"
EXPAND_FOR_SPIRAL = True # 放大画布使螺旋填充覆盖边角
EXPAND_RATIO = 2.5 # 更大倍率确保覆盖边缘
FILL_CORNERS = False
CORNER_FILL_RATIO = 0.15
@@ -42,11 +40,12 @@ MASK_FONT_PATH = str(PROJECT_DEFAULT_FONT)
MASK_FONT_SIZE = 3000
# --- 自动画幅与清晰度 ---
AUTO_EXPAND_CANVAS = True
BASE_HD_WIDTH = 8000
# 默认 4k 级画布:打印/激光足够清晰,比 8k 渲染快约 4×
BASE_HD_WIDTH = 4000
BASE_HD_HEIGHT = 4000
MIN_READABLE_HEIGHT_PX = 25
WORK_SCALE = 0.25
MIN_READABLE_HEIGHT_PX = 22
# 运算网格缩放:0.18 在速度/质量之间更均衡
WORK_SCALE = 0.18
# --- 阴阳刻 ---
FILL_ON = "BLACK"
@@ -68,53 +67,17 @@ FONT_FALLBACK_PATHS = (
# --- 填充策略 ---
N_REPETITIONS = 1
TARGET_FILL_RATIO = 0.0 # 关闭填充率检测
# 面积模型目标填充率:中文实心笔画像素占比约 0.35–0.55。
# 略偏保守以保证 scale=1.0 首次就能放满,减少多轮重试。
TARGET_FILL_RATIO = 0.45
SIZE_RATIO = 2.0
PACKING_EFFICIENCY = 0.85
# --- 分层采样(边缘覆盖) ---
ENABLE_STRATIFIED_SAMPLING = True
STRATIFIED_BANDS = 3 # Mix Center, Middle, and Edge
# --- 填充率补偿(低填充时略增字号) ---
GROW_FONT_ON_LOW_FILL = False # 关闭
GROW_FONT_STEP = 1.05
# --- 填充率检测 ---
MIN_ACCEPT_FILL_RATIO = 0.75
FILL_RETRY_RELAX_LARGE_CAP = True
FILL_RETRY_MAX_ROUNDS = 3
FILL_RETRY_MAX_SCALE = 1.5
PACKING_EFFICIENCY = 0.9
# --- 智能字号搜索 ---
REQUIRE_ALL_WORDS = True
MIN_FONT_SIZE = int(MIN_READABLE_HEIGHT_PX * WORK_SCALE)
USER_MIN_FONT_SIZE = None
USER_MAX_FONT_SIZE = None
MIN_FONT_FLOOR = 2
FONT_SCALE_MIN = 0.5
FONT_SCALE_MAX = 1.2
SCALE_SEARCH_STEPS = 7
SCALE_SEARCH_ROUNDS = 5
SCALE_DECAY = 0.85
SCALE_FLOOR = 0.25
AUTO_SHRINK_ROUNDS = 4
LOG_WEIGHT_RATIO = 0.72
RANK_WEIGHT_RATIO = 0.28
# --- 大字号智能降级 ---
# 开启后,如果填不满,会自动尝试减少大字号的数量,给小词腾空间
ENABLE_SMART_LARGE_FONT_REDUCTION = True
LIMIT_LARGE_FONTS = True
LARGE_FONT_LIMIT_RATIO = 0.2 # 初始允许 20% 的词是大字
LARGE_FONT_THRESHOLD_RATIO = 0.8 # 超过最大字号 80% 算大字
LARGE_FONT_CAP_RATIO = 0.6 # 被限制时,缩小到阈值的 60%
# --- 点阵补偿 ---
ENABLE_DOT_MATRIX = False
DOT_SPACING = 15
DOT_RADIUS = 0
DOT_SAFETY_BUFFER = 12
# --- 画布重试 ---
CANVAS_RETRY_MAX_ROUNDS = 1
@@ -138,51 +101,36 @@ LIGHT_COLOR_PALETTE = (
FONT_COLOR = "#000000" # 统一字体颜色,None 则使用调色板
# --- 输出 ---
MAX_ATTEMPTS = 5
OUTPUT_DIR = "."
OUTPUT_PREFIX = ""
OUTPUT_PNG = "Efficient_Result_HD_AutoResize.png"
OUTPUT_SVG = "Efficient_Result_HD_AutoResize.svg"
DB_PATH = "wordcloud_hd.db"
METRICS_FILE = "metrics.json"
SAVE_DEBUG_IMAGES = True
SAVE_DEBUG_IMAGES = False
DEBUG_OUTPUT_DIR = "output"
# --- 可复现性 ---
SEED = None
LAYOUT_ORDER_MODE_SORTED = "SORTED"
LAYOUT_ORDER_MODE_INTERLEAVED_RANDOM = "INTERLEAVED_RANDOM"
VALID_LAYOUT_ORDER_MODES = (
LAYOUT_ORDER_MODE_SORTED,
LAYOUT_ORDER_MODE_INTERLEAVED_RANDOM,
)
LAYOUT_ORDER_MODE = LAYOUT_ORDER_MODE_SORTED
LAYOUT_SEED = None
KNOWN_CONFIG_KEYS = {
'MODE', 'MASK_IMAGE_PATH', 'IMAGE_CANVAS_MODE', 'EXPAND_FOR_SPIRAL', 'EXPAND_RATIO', 'FILL_CORNERS',
'CORNER_FILL_RATIO', 'MASK_TEXT', 'MASK_FONT_PATH', 'MASK_FONT_SIZE', 'AUTO_EXPAND_CANVAS',
'MODE', 'MASK_IMAGE_PATH', 'IMAGE_CANVAS_MODE', 'FILL_CORNERS',
'CORNER_FILL_RATIO', 'MASK_TEXT', 'MASK_FONT_PATH', 'MASK_FONT_SIZE',
'BASE_HD_WIDTH', 'BASE_HD_HEIGHT', 'MIN_READABLE_HEIGHT_PX', 'WORK_SCALE', 'FILL_ON', 'EXCEL_PATH',
'DATA_COL_INDEX', 'WEIGHT_COL_INDEX', 'WEIGHT_COL_NAME', 'REMOVE_DUPLICATES', 'ENABLE_STROKE_WEIGHTS',
'WC_FONT_PATH',
'FONT_FALLBACK_PATHS',
'N_REPETITIONS', 'TARGET_FILL_RATIO', 'SIZE_RATIO', 'PACKING_EFFICIENCY', 'ENABLE_STRATIFIED_SAMPLING',
'STRATIFIED_BANDS', 'GROW_FONT_ON_LOW_FILL', 'GROW_FONT_STEP', 'MIN_ACCEPT_FILL_RATIO',
'FILL_RETRY_RELAX_LARGE_CAP', 'FILL_RETRY_MAX_ROUNDS', 'FILL_RETRY_MAX_SCALE', 'REQUIRE_ALL_WORDS',
'MIN_FONT_SIZE', 'USER_MIN_FONT_SIZE', 'USER_MAX_FONT_SIZE', 'MIN_FONT_FLOOR', 'FONT_SCALE_MIN',
'FONT_SCALE_MAX', 'SCALE_SEARCH_STEPS', 'SCALE_SEARCH_ROUNDS', 'SCALE_DECAY', 'SCALE_FLOOR',
'LOG_WEIGHT_RATIO', 'RANK_WEIGHT_RATIO',
'AUTO_SHRINK_ROUNDS', 'ENABLE_SMART_LARGE_FONT_REDUCTION', 'LIMIT_LARGE_FONTS',
'LARGE_FONT_LIMIT_RATIO', 'LARGE_FONT_THRESHOLD_RATIO', 'LARGE_FONT_CAP_RATIO', 'ENABLE_DOT_MATRIX',
'DOT_SPACING', 'DOT_RADIUS', 'DOT_SAFETY_BUFFER', 'CANVAS_RETRY_MAX_ROUNDS', 'CANVAS_RETRY_GROWTH',
'DARK_COLOR_PALETTE', 'LIGHT_COLOR_PALETTE', 'FONT_COLOR', 'MAX_ATTEMPTS', 'OUTPUT_DIR', 'OUTPUT_PREFIX',
'N_REPETITIONS', 'TARGET_FILL_RATIO', 'SIZE_RATIO', 'PACKING_EFFICIENCY',
'USER_MIN_FONT_SIZE', 'USER_MAX_FONT_SIZE',
'CANVAS_RETRY_MAX_ROUNDS', 'CANVAS_RETRY_GROWTH',
'DARK_COLOR_PALETTE', 'LIGHT_COLOR_PALETTE', 'FONT_COLOR', 'OUTPUT_DIR', 'OUTPUT_PREFIX',
'OUTPUT_PNG', 'OUTPUT_SVG', 'DB_PATH', 'METRICS_FILE', 'SAVE_DEBUG_IMAGES', 'DEBUG_OUTPUT_DIR', 'SEED',
'LAYOUT_ORDER_MODE', 'LAYOUT_SEED'
'LAYOUT_SEED'
}
CONFIG_ALIASES = {
'seed': 'SEED',
'layout_order_mode': 'LAYOUT_ORDER_MODE',
'layout_seed': 'LAYOUT_SEED',
'excel_path': 'EXCEL_PATH',
'mask_image_path': 'MASK_IMAGE_PATH',
@@ -207,16 +155,12 @@ CRITICAL_TYPE_CHECKS = {
'FONT_FALLBACK_PATHS': (list, tuple),
'USER_MIN_FONT_SIZE': (int, float, type(None)),
'USER_MAX_FONT_SIZE': (int, float, type(None)),
'MAX_ATTEMPTS': int,
'SAVE_DEBUG_IMAGES': bool,
'REMOVE_DUPLICATES': bool,
'ENABLE_STROKE_WEIGHTS': bool,
'CANVAS_RETRY_MAX_ROUNDS': int,
'CANVAS_RETRY_GROWTH': (int, float),
'LOG_WEIGHT_RATIO': (int, float),
'RANK_WEIGHT_RATIO': (int, float),
'SEED': (int, type(None)),
'LAYOUT_ORDER_MODE': str,
'LAYOUT_SEED': (int, type(None)),
}
@@ -227,7 +171,6 @@ def parse_args():
parser = argparse.ArgumentParser(description="Efficient WordCloud generator")
parser.add_argument("--config", type=str, help="JSON 配置文件路径")
parser.add_argument("--seed", type=int, help="随机种子(可复现)")
parser.add_argument("--layout-order-mode", type=lambda s: s.upper(), choices=VALID_LAYOUT_ORDER_MODES, help="布局顺序模式")
parser.add_argument("--layout-seed", type=int, help="布局顺序随机种子")
parser.add_argument("--excel-path", type=str, help="Excel 输入路径")
parser.add_argument("--mask-image-path", type=str, help="掩膜图片路径(IMAGE 模式)")
@@ -298,7 +241,6 @@ def apply_json_config(config_path):
def apply_cli_overrides(args):
mapping = {
'seed': 'SEED',
'layout_order_mode': 'LAYOUT_ORDER_MODE',
'layout_seed': 'LAYOUT_SEED',
'excel_path': 'EXCEL_PATH',
'mask_image_path': 'MASK_IMAGE_PATH',
@@ -342,7 +284,7 @@ def _resolve_font_path(configured_path, fallback_paths, *, role):
def finalize_runtime_config():
global EXCEL_PATH, MASK_IMAGE_PATH, MASK_FONT_PATH, WC_FONT_PATH
global OUTPUT_DIR, OUTPUT_PNG, OUTPUT_SVG, DB_PATH, METRICS_FILE, DEBUG_OUTPUT_DIR, MIN_FONT_SIZE
global LAYOUT_ORDER_MODE, LAYOUT_SEED
global LAYOUT_SEED
# 运行时派生字段
MIN_FONT_SIZE = int(MIN_READABLE_HEIGHT_PX * WORK_SCALE)
@@ -350,11 +292,6 @@ def finalize_runtime_config():
if LAYOUT_SEED is None:
LAYOUT_SEED = SEED
LAYOUT_ORDER_MODE = str(LAYOUT_ORDER_MODE).upper()
if LAYOUT_ORDER_MODE not in VALID_LAYOUT_ORDER_MODES:
print(f"错误: 不支持的 LAYOUT_ORDER_MODE: {LAYOUT_ORDER_MODE}")
sys.exit(1)
EXCEL_PATH = str(_resolve_path(EXCEL_PATH))
MASK_IMAGE_PATH = str(_resolve_path(MASK_IMAGE_PATH))
+330 -209
View File
@@ -1,16 +1,159 @@
import math
import random
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from matplotlib.path import Path as MplPath
from matplotlib.textpath import TextPath
from matplotlib.transforms import Affine2D
from . import config
from .ewc import EfficientWordCloud
from .fonts import get_cached_font, get_font_properties
# ── Fast SVG path cache (fontTools outlines, unscaled per-char) ──────────────
# font_path -> (glyph_set, cmap, units_per_em)
_FT_FONT_CACHE = {}
# (font_path, char) -> (svg_path_d_in_font_units, advance_width)
_FT_CHAR_PATH_CACHE = {}
# (font_path, word, size, orient) -> (path_d_scaled, tx0, ty0)
_SVG_SHAPE_CACHE = {}
def _load_ft_font(font_path):
cached = _FT_FONT_CACHE.get(font_path)
if cached is not None:
return cached
from fontTools.ttLib import TTFont
# .ttc collections: try face 0 first
try:
tt = TTFont(font_path, fontNumber=0)
except TypeError:
tt = TTFont(font_path)
glyph_set = tt.getGlyphSet()
cmap = tt.getBestCmap() or {}
units = tt["head"].unitsPerEm
cached = (tt, glyph_set, cmap, units)
_FT_FONT_CACHE[font_path] = cached
return cached
def build_svg_text_path_cached(word, size, x, y, font_path, orient):
"""Return (path_d, tx, ty, None). Geometry is cached for identical glyphs.
Path is in y-up font space (same as matplotlib TextPath). Caller applies
translate(tx, ty) scale(1, -1) to place it on the canvas.
"""
key = (font_path, word, int(size), bool(orient))
cached = _SVG_SHAPE_CACHE.get(key)
if cached is None:
try:
cached = _build_shape_fonttools(word, size, font_path, orient)
except Exception:
cached = _build_shape_matplotlib(word, size, font_path, orient)
_SVG_SHAPE_CACHE[key] = cached
if len(_SVG_SHAPE_CACHE) > 20000:
for i, k in enumerate(list(_SVG_SHAPE_CACHE.keys())):
if i % 2 == 0:
_SVG_SHAPE_CACHE.pop(k, None)
path_d, tx0, ty0 = cached
return path_d, tx0 + x, ty0 + y, None
def _build_shape_fonttools(word, size, font_path, orient):
from fontTools.pens.svgPathPen import SVGPathPen
from fontTools.pens.transformPen import TransformPen
from fontTools.misc.transform import Transform
_tt, glyph_set, cmap, units = _load_ft_font(font_path)
scale = float(size) / float(units)
pen = SVGPathPen(glyph_set)
cursor = 0.0
for ch in word:
gname = cmap.get(ord(ch))
if not gname or gname not in glyph_set:
continue
glyph = glyph_set[gname]
if orient:
# Horizontal layout then rotate -90° around origin:
# point (px, py) in string space -> after scale: (s*px, s*py)
# rotate -90: (s*py, -s*px). Compose with glyph origin at cursor:
# glyph local (gx,gy) -> (scale*gx + cursor, scale*gy)
# -> rotate -90: (scale*gy, -(scale*gx + cursor)) = (scale*gy, -scale*gx - cursor)
# matrix: x' = 0*gx + scale*gy + 0; y' = -scale*gx + 0*gy - cursor
# Transform(xx, xy, yx, yy, dx, dy): x' = xx*x + xy*y + dx; y' = yx*x + yy*y + dy
# xx=0, xy=scale, yx=-scale, yy=0, dx=0, dy=-cursor
tp = TransformPen(pen, Transform(0, -scale, scale, 0, 0, -cursor))
else:
tp = TransformPen(pen, Transform(scale, 0, 0, scale, cursor, 0))
glyph.draw(tp)
cursor += float(glyph.width) * scale
path_d = pen.getCommands()
if not path_d:
return "", 0.0, 0.0
xmin, ymin, xmax, ymax = _path_bbox(path_d)
# Offsets placing glyph top-left at (0,0) under translate(tx,ty) scale(1,-1)
tx0 = -xmin
ty0 = ymax
return path_d, tx0, ty0
def _path_bbox(path_d):
import re
nums = [float(n) for n in re.findall(r"[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?", path_d)]
# This is approximate (includes arc radii etc.) but good enough for placement offsets
# Better: parse properly. For font outlines, commands are mostly M/L/Q/C/Z with coords.
xs, ys = [], []
tokens = re.findall(r"[A-Za-z]|[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?", path_d)
i = 0
while i < len(tokens):
t = tokens[i]
if t.isalpha():
cmd = t
i += 1
if cmd in "Zz":
continue
if cmd in "Hh":
while i < len(tokens) and not tokens[i].isalpha():
xs.append(float(tokens[i])); i += 1
elif cmd in "Vv":
while i < len(tokens) and not tokens[i].isalpha():
ys.append(float(tokens[i])); i += 1
elif cmd in "Aa":
while i + 6 < len(tokens) and not tokens[i].isalpha():
xs.append(float(tokens[i + 5])); ys.append(float(tokens[i + 6])); i += 7
else:
while i + 1 < len(tokens) and not tokens[i].isalpha():
xs.append(float(tokens[i])); ys.append(float(tokens[i + 1])); i += 2
else:
i += 1
if not xs or not ys:
return 0.0, 0.0, 0.0, 0.0
return min(xs), min(ys), max(xs), max(ys)
def _build_shape_matplotlib(word, size, font_path, orient):
from matplotlib.textpath import TextPath
path = TextPath((0, 0), word, prop=get_font_properties(font_path, size), size=size)
if orient:
path = path.transformed(Affine2D().rotate_deg(-90))
bbox = path.get_extents()
tx0 = -bbox.xmin
ty0 = bbox.ymax
path_d = mpl_path_to_svg_d(path)
return path_d, tx0, ty0
def build_svg_text_path(word, size, x, y, font_path, orient):
path_d, tx, ty, _ = build_svg_text_path_cached(word, size, x, y, font_path, orient)
return path_d, tx, ty, None
def normalize_relative_scores(values):
if not values:
@@ -18,7 +161,10 @@ def normalize_relative_scores(values):
v_min = min(values)
v_max = max(values)
if math.isclose(v_min, v_max):
return [1.0 for _ in values]
# Equal weights should produce a neutral, equal hierarchy. Returning
# 1.0 made every word request the maximum size and later words were
# arbitrarily shrunk by placement order.
return [0.5 for _ in values]
scale = v_max - v_min
return [(value - v_min) / scale for value in values]
@@ -35,28 +181,17 @@ def build_log_rank_scores(freq_list, *, per_word=False):
word_weights[word] = f
unique_weights = sorted(set(word_weights.values()), reverse=True)
if len(unique_weights) <= 1:
word_scores = {w: 1.0 for w in word_weights}
word_scores = {w: 0.5 for w in word_weights}
else:
log_vals = [math.log1p(w) for w in unique_weights]
normed = normalize_relative_scores(log_vals)
weight_to_score = dict(zip(unique_weights, normed))
word_scores = {w: weight_to_score[weight] for w, weight in word_weights.items()}
return [word_scores.get(w, 1.0) for w, _ in freq_list]
return [word_scores.get(w, 0.5) for w, _ in freq_list]
safe_freqs = [max(float(freq), 1e-6) for _word, freq in freq_list]
log_scores = normalize_relative_scores([math.log1p(freq) for freq in safe_freqs])
rank_scores = [1.0 - (idx / max(1, len(freq_list) - 1)) for idx in range(len(freq_list))]
total_ratio = config.LOG_WEIGHT_RATIO + config.RANK_WEIGHT_RATIO
if total_ratio <= 0:
return log_scores
log_ratio = config.LOG_WEIGHT_RATIO / total_ratio
rank_ratio = config.RANK_WEIGHT_RATIO / total_ratio
return [
max(0.0, min(1.0, log_score * log_ratio + rank_score * rank_ratio))
for log_score, rank_score in zip(log_scores, rank_scores)
]
return log_scores
def pick_palette_color(relative_score):
@@ -69,76 +204,34 @@ def pick_palette_color(relative_score):
return palette[idx]
def _build_layout_sequence(sorted_freq, max_words, layout_order_mode, layout_seed):
def _build_layout_sequence(sorted_freq, max_words, layout_seed):
if max_words <= 0 or not sorted_freq:
return []
expanded_freq = list(sorted_freq)
if len(expanded_freq) < max_words:
base_words = expanded_freq[:]
if not base_words:
return []
while len(expanded_freq) < max_words:
for item in base_words:
if len(expanded_freq) >= max_words:
break
expanded_freq.append(item)
expanded_freq = expanded_freq[:max_words]
if layout_order_mode == config.LAYOUT_ORDER_MODE_SORTED or len(expanded_freq) <= 1:
return expanded_freq
band_count = min(3, len(expanded_freq))
band_size = math.ceil(len(expanded_freq) / band_count)
bands = []
rng = random.Random(layout_seed)
for band_idx in range(band_count):
start = band_idx * band_size
end = min(len(expanded_freq), start + band_size)
band = expanded_freq[start:end]
rng.shuffle(band)
if band:
bands.append(band)
interleave_pattern = [0, 1, 0, 2]
band_positions = [0] * len(bands)
base_words = list(sorted_freq)
sequence = []
while len(sequence) < len(expanded_freq):
appended = False
for pattern_idx in interleave_pattern:
if pattern_idx >= len(bands):
continue
pos = band_positions[pattern_idx]
if pos >= len(bands[pattern_idx]):
continue
sequence.append(bands[pattern_idx][pos])
band_positions[pattern_idx] += 1
appended = True
if len(sequence) >= len(expanded_freq):
break
if appended:
continue
for band_idx, band in enumerate(bands):
pos = band_positions[band_idx]
if pos < len(band):
sequence.append(band[pos])
band_positions[band_idx] += 1
appended = True
if len(sequence) >= len(expanded_freq):
break
if not appended:
break
while len(sequence) < max_words:
round_items = []
start = 0
while start < len(base_words):
end = start + 1
weight = float(base_words[start][1])
while end < len(base_words) and math.isclose(float(base_words[end][1]), weight):
end += 1
# Start every equal-weight group from a canonical order before
# shuffling. A fixed seed must therefore give the same layout
# regardless of the row order in the uploaded workbook.
group = sorted(base_words[start:end], key=lambda item: str(item[0]))
rng.shuffle(group)
round_items.extend(group)
start = end
remaining = max_words - len(sequence)
sequence.extend(round_items[:remaining])
return sequence
class OptimizedEfficientWordCloud(EfficientWordCloud):
def __init__(self, *args, large_font_ratio=config.LARGE_FONT_LIMIT_RATIO, size_scale=1.0, **kwargs):
super().__init__(*args, **kwargs)
self.large_font_ratio = large_font_ratio
self.size_scale = size_scale
def generate_from_frequencies(self, frequencies):
if isinstance(frequencies, dict):
freq_list = list(frequencies.items())
@@ -147,12 +240,12 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
else:
raise ValueError("frequencies 必须是字典或 (word, freq) 列表")
sorted_freq = sorted(freq_list, key=lambda x: x[1], reverse=True)
sorted_freq = sorted(freq_list, key=lambda item: (-float(item[1]), str(item[0])))
layout_seed = getattr(self, "layout_seed", config.LAYOUT_SEED)
layout_sequence = _build_layout_sequence(
sorted_freq,
self.max_words,
config.LAYOUT_ORDER_MODE,
config.LAYOUT_SEED,
layout_seed,
)
if not layout_sequence:
@@ -164,116 +257,120 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
for (w, _f), s in zip(freq_list, per_word_scores):
if w not in word_to_score or s > word_to_score[w]:
word_to_score[w] = s
score_by_index = [word_to_score.get(w, 1.0) for w, _ in layout_sequence]
effective_max_font = max(self.min_font_size + 1, int(self.max_font_size * self.size_scale))
large_threshold = int(effective_max_font * config.LARGE_FONT_THRESHOLD_RATIO) if config.LIMIT_LARGE_FONTS else None
large_limit = int(self.max_words * self.large_font_ratio) if config.LIMIT_LARGE_FONTS else None
large_count = 0
rotation_flags = [np.random.random() > self.prefer_horizontal for _ in layout_sequence]
score_by_index = [word_to_score.get(w, 0.5) for w, _ in layout_sequence]
seed = layout_seed if layout_seed is not None else config.SEED
rng = np.random.default_rng(seed)
rotation_flags = [bool(rng.random() > self.prefer_horizontal) for _ in layout_sequence]
# Dummy draw for textbbox measurement (no actual PIL image needed during placement)
_measure_img = Image.new("L", (1, 1))
_measure_draw = ImageDraw.Draw(_measure_img)
base_span = max(1, self.max_font_size - self.min_font_size)
# (word, size, rotate) -> exact collision and drawing geometry.
# The C++ canvas stores the same tight glyph bitmap that PIL renders;
# bbox bearings are carried separately so HD output cannot drift away
# from the collision map.
glyph_cache = {}
def measure_and_mask(word, size, rotate):
key = (word, size, rotate)
cached = glyph_cache.get(key)
if cached is not None:
return cached
font = get_cached_font(self.font_path, size)
orientation = Image.ROTATE_90 if rotate else None
transposed = ImageFont.TransposedFont(font, orientation=orientation) if orientation else font
bbox = _measure_draw.textbbox((0, 0), word, font=transposed)
glyph_mask = transposed.getmask(word, mode="L")
gw, gh = glyph_mask.size
if gw <= 0 or gh <= 0:
return None
# np.array avoids the intermediate bytes() copy that
# frombuffer(bytes(...)) would incur.
glyph_arr = np.array(glyph_mask, dtype=np.uint8).reshape(gh, gw)
pad = max(0, int(self.margin))
if pad:
padded = np.zeros((gh + 2 * pad, gw + 2 * pad), dtype=np.uint8)
padded[pad:pad + gh, pad:pad + gw] = glyph_arr
# Reserve a true inter-glyph margin while still allowing
# transparent corners and stroke gaps to interlock.
collision_arr = np.asarray(
Image.fromarray(padded).filter(ImageFilter.MaxFilter(2 * pad + 1)),
dtype=np.uint8,
)
stamp_arr = padded
else:
collision_arr = glyph_arr
stamp_arr = glyph_arr
result = (
collision_arr.shape[0],
collision_arr.shape[1],
collision_arr,
stamp_arr,
orientation,
int(bbox[0]),
int(bbox[1]),
pad,
)
glyph_cache[key] = result
return result
min_font = max(config.MIN_FONT_FLOOR, int(self.min_font_size))
max_font = max(min_font, int(self.max_font_size))
base_span = max_font - min_font
target_font_sizes = []
for score in score_by_index:
raw_size = self.min_font_size + base_span * score
f_size = max(config.MIN_FONT_FLOOR, int(round(raw_size * self.size_scale)))
raw_size = min_font + base_span * score
f_size = min(max_font, max(min_font, int(round(raw_size))))
target_font_sizes.append(f_size)
gap_fill_list = [] # 收集未成功放置的词,用于第二轮填充
random_large_prefix = max(1, int(math.ceil(len(layout_sequence) * 0.08)))
for idx, (word, _freq) in enumerate(layout_sequence):
font_size = target_font_sizes[idx]
if config.LIMIT_LARGE_FONTS and large_threshold is not None and large_limit is not None:
if font_size >= large_threshold and large_count >= large_limit:
font_size = max(self.min_font_size, int(large_threshold * config.LARGE_FONT_CAP_RATIO))
current_size = font_size
min_attempt_size = max(self.min_font_size, int(current_size * 0.4))
placed = False
rotate = rotation_flags[idx]
for try_rotate in (rotate, not rotate):
measured = measure_and_mask(word, font_size, try_rotate)
if measured is None:
continue
(
query_h,
query_w,
collision_arr,
stamp_arr,
orientation,
bbox_left,
bbox_top,
pad,
) = measured
query_seed = int(rng.integers(0, 2**31))
large_word = idx < random_large_prefix or score_by_index[idx] >= 0.80
placement_mode = 2 if large_word else 1
pos = self.grid.place_glyph_exact(
collision_arr,
stamp_arr,
query_h,
query_w,
query_seed,
256,
placement_mode,
)
while current_size >= min_attempt_size:
orientation = None
rotate = rotation_flags[idx]
if rotate:
orientation = Image.ROTATE_90
if pos is None:
continue
y, x = pos
ink_y = y + pad
ink_x = x + pad
draw_y = ink_y - bbox_top
draw_x = ink_x - bbox_left
color = pick_palette_color(score_by_index[idx])
self.layout_.append((word, font_size, (draw_y, draw_x), orientation, color))
placed = True
break
font = get_cached_font(self.font_path, current_size)
if orientation:
transposed = ImageFont.TransposedFont(font, orientation=orientation)
else:
transposed = font
bbox = _measure_draw.textbbox((0, 0), word, font=transposed)
w_text = bbox[2] - bbox[0]
h_text = bbox[3] - bbox[1]
query_w = w_text + self.margin
query_h = h_text + self.margin
pos = self.grid.query_direct(query_h, query_w, np.random.randint(0, 2**31))
if pos is not None:
y, x = pos
draw_y = y + self.margin // 2
draw_x = x + self.margin // 2
# Stamp glyph bitmap into C++ canvas for pixel-accurate collision
font = get_cached_font(self.font_path, current_size)
if orientation:
transposed = ImageFont.TransposedFont(font, orientation=orientation)
else:
transposed = font
glyph_mask = transposed.getmask(word, mode="L")
gw, gh = glyph_mask.size
glyph_arr = np.frombuffer(bytes(glyph_mask), dtype=np.uint8).reshape(gh, gw)
self.grid.stamp_and_rebuild(glyph_arr, gh, gw, draw_y, draw_x)
color = pick_palette_color(score_by_index[idx])
self.layout_.append((word, current_size, (draw_y, draw_x), orientation, color))
if config.LIMIT_LARGE_FONTS and large_threshold is not None and current_size >= large_threshold:
large_count += 1
placed = True
break
current_size -= 2
if not placed:
gap_fill_list.append((word, score_by_index[idx]))
# ── Gap-filling pass: 用更小的字号填充剩余空隙 ──────────────
if gap_fill_list:
gap_font_size = max(config.MIN_FONT_FLOOR, int(self.min_font_size * 0.8))
if gap_font_size >= config.MIN_FONT_FLOOR:
placed_gap = 0
for word, score in gap_fill_list:
font = get_cached_font(self.font_path, gap_font_size)
bbox = _measure_draw.textbbox((0, 0), word, font=font)
w_text = bbox[2] - bbox[0]
h_text = bbox[3] - bbox[1]
query_w = w_text + self.margin
query_h = h_text + self.margin
pos = self.grid.query_direct(query_h, query_w, np.random.randint(0, 2**31))
if pos is not None:
y, x = pos
draw_y = y + self.margin // 2
draw_x = x + self.margin // 2
glyph_mask = font.getmask(word, mode="L")
gw, gh = glyph_mask.size
glyph_arr = np.frombuffer(bytes(glyph_mask), dtype=np.uint8).reshape(gh, gw)
self.grid.stamp_and_rebuild(glyph_arr, gh, gw, draw_y, draw_x)
color = pick_palette_color(score)
self.layout_.append((word, gap_font_size, (draw_y, draw_x), None, color))
placed_gap += 1
if placed_gap > 0:
config._warn(f"Gap-filling: 用小字号 {gap_font_size} 额外放置了 {placed_gap}/{len(gap_fill_list)} 个词")
# Deliberately do not shrink an individual word. The pipeline
# treats a short layout as a failed batch and retries every word
# at one uniformly scaled size range.
return self
@@ -287,6 +384,57 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
draw.text((x, y), word, font=font, fill=color)
return img
def _iter_svg_paths(self):
"""Build SVG path data once per layout entry, with glyph-shape cache."""
# Cache by (word, size, orient): path geometry is identical; only translate differs.
shape_cache = {}
for word, size, (y, x), orient, color in self.layout_:
key = (word, int(size), bool(orient))
cached = shape_cache.get(key)
if cached is None:
try:
path_d, origin_tx, origin_ty, bbox = build_svg_text_path_cached(
word, size, 0, 0, self.font_path, orient
)
except Exception as exc:
config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}")
continue
# origin_tx/ty place the glyph so its top-left is at (0,0)
shape_cache[key] = (path_d, origin_tx, origin_ty)
cached = shape_cache[key]
path_d, origin_tx, origin_ty = cached
# Shift from (0,0) origin to actual layout position
tx = origin_tx + x
ty = origin_ty + y
yield path_d, tx, ty, color
def export_svgs(self, fill_filename, stroke_color="#000000", stroke_width=1.0):
"""Write fill + stroke SVG in one pass (path geometry built once)."""
stroke_filename = str(
Path(fill_filename).with_name(Path(fill_filename).stem + "_stroke" + Path(fill_filename).suffix)
)
background = self.background_color
header = (
f'<svg width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" '
f'xmlns="http://www.w3.org/2000/svg">\n'
)
with open(fill_filename, "w", encoding="utf-8") as ff, open(stroke_filename, "w", encoding="utf-8") as sf:
ff.write(header)
sf.write(header)
ff.write(f'<rect width="100%" height="100%" fill="{background}"/>\n')
sf.write('<rect width="100%" height="100%" fill="none"/>\n')
for path_d, tx, ty, color in self._iter_svg_paths():
transform = f'translate({tx:.3f} {ty:.3f}) scale(1 -1)'
ff.write(f'<path d="{path_d}" transform="{transform}" fill="{color}"/>\n')
sf.write(
f'<path d="{path_d}" transform="{transform}" '
f'fill="none" stroke="{stroke_color}" stroke-width="{stroke_width}" '
f'stroke-linejoin="round" stroke-linecap="round"/>\n'
)
ff.write("</svg>\n")
sf.write("</svg>\n")
return stroke_filename
def to_svg(self, filename):
background = self.background_color
with open(filename, "w", encoding="utf-8") as f:
@@ -295,15 +443,8 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
f'xmlns="http://www.w3.org/2000/svg">\n'
)
f.write(f'<rect width="100%" height="100%" fill="{background}"/>\n')
for word, size, (y, x), orient, color in self.layout_:
try:
path, tx, ty, _ = build_svg_text_path(word, size, x, y, self.font_path, orient)
except Exception as exc:
config._warn(f"SVG path 导出失败,跳过词条: {word}, error={exc}")
continue
f.write(f'<path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" fill="{color}"/>\n')
for path_d, tx, ty, color in self._iter_svg_paths():
f.write(f'<path d="{path_d}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" fill="{color}"/>\n')
f.write("</svg>\n")
def to_svg_stroke(self, filename, stroke_color="#000000", stroke_width=1.0):
@@ -313,20 +454,13 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
f'<svg width="{self.width}" height="{self.height}" viewBox="0 0 {self.width} {self.height}" '
f'xmlns="http://www.w3.org/2000/svg">\n'
)
f.write(f'<rect width="100%" height="100%" fill="none"/>\n')
for word, size, (y, x), orient, _color in self.layout_:
try:
path, tx, ty, _ = build_svg_text_path(word, size, x, y, self.font_path, orient)
except Exception as exc:
config._warn(f"SVG stroke path 导出失败,跳过词条: {word}, error={exc}")
continue
f.write('<rect width="100%" height="100%" fill="none"/>\n')
for path_d, tx, ty, _color in self._iter_svg_paths():
f.write(
f'<path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" '
f'<path d="{path_d}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" '
f'fill="none" stroke="{stroke_color}" stroke-width="{stroke_width}" '
f'stroke-linejoin="round" stroke-linecap="round"/>\n'
)
f.write("</svg>\n")
def to_svg_dotfill(self, filename, dot_spacing=10, dot_radius=2, dot_color="#000000"):
@@ -471,18 +605,6 @@ class OptimizedEfficientWordCloud(EfficientWordCloud):
f.write(' </clipPath>\n')
def build_svg_text_path(word, size, x, y, font_path, orient):
path = TextPath((0, 0), word, prop=get_font_properties(font_path, size), size=size)
if orient:
path = path.transformed(Affine2D().rotate_deg(-90))
bbox = path.get_extents()
tx = x - bbox.xmin
ty = y + bbox.ymax
# 返回变换后的 Path(已定位到画布坐标)以及 SVG 用的偏移量
transformed = path.transformed(Affine2D().scale(1, -1).translate(tx, ty))
return mpl_path_to_svg_d(path), tx, ty, transformed
def mpl_path_to_svg_d(path):
parts = []
for vertices, code in path.iter_segments():
@@ -557,4 +679,3 @@ def render_path_occupancy(layout_data, canvas_shape, font_path):
# 最终蒙版:文字笔画=1,外部和字内空洞=0
return (occ_raw & (~outside).astype(np.uint8)).astype(np.uint8)
+11 -7
View File
@@ -40,15 +40,12 @@ def normalize_mask_for_fill(mask):
def calculate_dynamic_dimensions(base_w, base_h, num_words, avg_len=3, mask_stats=None):
if not config.AUTO_EXPAND_CANVAS:
return base_w, base_h
effective_fill = config.TARGET_FILL_RATIO if config.TARGET_FILL_RATIO > 0 else max(config.MIN_ACCEPT_FILL_RATIO, 0.82)
effective_fill = config.TARGET_FILL_RATIO if config.TARGET_FILL_RATIO > 0 else 0.45
mask_fill_ratio = 0.5
if mask_stats is not None:
mask_fill_ratio = max(0.05, mask_stats["free_ratio"])
area_per_word = (config.MIN_READABLE_HEIGHT_PX ** 2) * max(1.0, avg_len) * 1.2
area_per_word = (config.MIN_READABLE_HEIGHT_PX ** 2) * max(1.0, avg_len) * 1.05
required_fillable_area = (num_words * area_per_word * max(1, config.N_REPETITIONS)) / max(effective_fill, 0.1)
required_canvas_area = required_fillable_area / mask_fill_ratio
current_area = base_w * base_h
@@ -56,8 +53,15 @@ def calculate_dynamic_dimensions(base_w, base_h, num_words, avg_len=3, mask_stat
scale_factor = (required_canvas_area / current_area) ** 0.5
new_w = int(base_w * scale_factor)
new_h = int(base_h * scale_factor)
new_w = ((new_w // 100) + 1) * 100
new_h = ((new_h // 100) + 1) * 100
# Cap HD canvas to keep render/export under the speed budget.
# 6000px 边长对激光/打印足够,再大收益很小但 SVG/PNG 成本陡增。
max_edge = 6000
if max(new_w, new_h) > max_edge:
s = max_edge / max(new_w, new_h)
new_w = int(new_w * s)
new_h = int(new_h * s)
new_w = max(100, ((new_w // 100) + 1) * 100)
new_h = max(100, ((new_h // 100) + 1) * 100)
print(f"[Auto-Size] 扩展画布: {base_w}x{base_h} -> {new_w}x{new_h}")
return new_w, new_h
return base_w, base_h
+463 -213
View File
@@ -1,4 +1,5 @@
import logging
import math
import os
import sqlite3
import sys
@@ -13,13 +14,32 @@ from . import config
from .fonts import get_cached_font
from .layout import OptimizedEfficientWordCloud
from .mask import analyze_mask, apply_safe_padding, calculate_dynamic_dimensions, prepare_mask
from .render import apply_dot_matrix, compute_fill_ratio_fast
from .weights import calculate_font_by_area_model, extract_weights_from_df, get_stroke_complexity_batch
from .render import (
compute_fill_ratio_fast,
count_layout_overlap_pixels,
largest_empty_square_size,
refine_layout_with_hd_clearance,
scale_layout_for_hd,
)
from .weights import (
calculate_font_by_area_model,
extract_weights_from_df,
get_stroke_complexity_batch,
merge_weight_maps,
)
log = logging.getLogger("core.pipeline")
def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real_hd_w, real_hd_h):
def run_generation_pass(
names,
frequencies_data,
name_weights_map,
mask_hd,
real_hd_w,
real_hd_h,
_collision_margin=0,
):
log.info("[run_generation_pass] 开始")
log.info(" 输入: %d 词 | HD尺寸: %dx%d", len(names), real_hd_w, real_hd_h)
@@ -42,44 +62,73 @@ def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real
print(f"最终输出: {real_hd_w}x{real_hd_h} | 运算网格: {w_small}x{h_small}")
current_min_font = max(config.MIN_FONT_FLOOR, int(config.MIN_READABLE_HEIGHT_PX * config.WORK_SCALE))
readable_min_font = max(
config.MIN_FONT_FLOOR,
int(math.ceil(config.MIN_READABLE_HEIGHT_PX * config.WORK_SCALE)),
)
total_target = len(names) * config.N_REPETITIONS
current_packing_eff = config.PACKING_EFFICIENCY
grow_step = config.GROW_FONT_STEP if config.GROW_FONT_ON_LOW_FILL else 1.0
final_wc = None
final_scale = 1.0
base_min_font = current_min_font
base_max_font = current_min_font + 1
base_layout_seed = config.LAYOUT_SEED if config.LAYOUT_SEED is not None else config.SEED
final_layout_seed = base_layout_seed
base_min_font, base_max_font = calculate_font_by_area_model(
mask_small,
names,
name_weights_map,
config.TARGET_FILL_RATIO,
config.SIZE_RATIO,
config.PACKING_EFFICIENCY,
config.N_REPETITIONS,
)
def compute_font_bounds(packing_eff):
min_font, max_font = calculate_font_by_area_model(
mask_small, names, name_weights_map, config.TARGET_FILL_RATIO, config.SIZE_RATIO, packing_eff, config.N_REPETITIONS
)
min_font = max(current_min_font, min_font)
explicit_min_font = config.USER_MIN_FONT_SIZE is not None
hard_min_font = readable_min_font
if explicit_min_font:
hard_min_font = max(config.MIN_FONT_FLOOR, int(round(config.USER_MIN_FONT_SIZE)))
if config.USER_MIN_FONT_SIZE is not None:
user_min = int(config.USER_MIN_FONT_SIZE)
if user_min < config.MIN_FONT_FLOOR:
config._warn(f"USER_MIN_FONT_SIZE={config.USER_MIN_FONT_SIZE} 过小,提升到 {config.MIN_FONT_FLOOR}")
user_min = config.MIN_FONT_FLOOR
min_font = user_min
hard_max_font = None
if config.USER_MAX_FONT_SIZE is not None:
hard_max_font = max(config.MIN_FONT_FLOOR, int(round(config.USER_MAX_FONT_SIZE)))
if explicit_min_font and hard_max_font < hard_min_font:
raise ValueError(
f"字号硬约束冲突: USER_MAX_FONT_SIZE={hard_max_font} "
f"小于最小允许字号 {hard_min_font}"
)
if not explicit_min_font:
# A user-specified maximum outranks the automatic readability
# suggestion. It remains an exact ceiling rather than causing an
# artificial conflict with a value the user never requested.
hard_min_font = min(hard_min_font, hard_max_font)
if config.USER_MAX_FONT_SIZE is not None:
user_max = int(config.USER_MAX_FONT_SIZE)
if user_max < config.MIN_FONT_FLOOR:
config._warn(f"USER_MAX_FONT_SIZE={config.USER_MAX_FONT_SIZE} 过小,提升到 {config.MIN_FONT_FLOOR}")
user_max = config.MIN_FONT_FLOOR
max_font = user_max
equal_size_mode = math.isclose(float(config.SIZE_RATIO), 1.0, rel_tol=0.0, abs_tol=1e-9)
base_min_font = max(hard_min_font, int(base_min_font))
base_max_font = max(base_min_font, int(base_max_font))
if hard_max_font is not None:
base_min_font = min(base_min_font, hard_max_font)
base_max_font = min(base_max_font, hard_max_font)
if equal_size_mode:
# Keep one scalar throughout every retry. This is what makes
# SIZE_RATIO=1 exact even after automatic batch scaling.
equal_font = min(base_min_font, base_max_font)
base_min_font = equal_font
base_max_font = equal_font
if max_font <= min_font:
config._warn(f"字号区间无效: min={min_font}, max={max_font},自动修正 max=min+1")
max_font = min_font + 1
def scaled_bounds(scale):
if equal_size_mode:
size = max(hard_min_font, int(round(base_min_font * scale)))
if hard_max_font is not None:
size = min(size, hard_max_font)
return size, size
return min_font, max_font
min_font = max(hard_min_font, int(round(base_min_font * scale)))
max_font = max(min_font, int(round(base_max_font * scale)))
if hard_max_font is not None:
min_font = min(min_font, hard_max_font)
max_font = min(max_font, hard_max_font)
return min_font, max(min_font, max_font)
def try_place(min_font, max_font, large_ratio=config.LARGE_FONT_LIMIT_RATIO, size_scale=1.0):
min_font = max(config.MIN_FONT_FLOOR, int(min_font))
max_font = max(min_font + 1, int(max_font))
def try_place(scale, layout_seed=base_layout_seed):
min_font, max_font = scaled_bounds(scale)
wc = OptimizedEfficientWordCloud(
width=w_small,
height=h_small,
@@ -89,108 +138,77 @@ def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real
min_font_size=min_font,
max_font_size=max_font,
background_color=config.get_output_background(),
use_spiral_search=True,
large_font_ratio=large_ratio,
size_scale=size_scale,
prefer_horizontal=0.82,
# HD clearance is applied after scaling. Keeping the coarse-grid
# margin at zero avoids turning 1 HD pixel into 5-6 output pixels.
margin=_collision_margin,
)
if config.ENABLE_STRATIFIED_SAMPLING:
wc.grid.reorder_stratified(config.STRATIFIED_BANDS)
wc.layout_seed = layout_seed
wc.generate_from_frequencies(frequencies_data)
return wc, len(wc.layout_)
return wc, len(wc.layout_), min_font, max_font
print(f"--- 5. 启动生成 (目标: {total_target} 词) ---")
log.info("--- 5. 启动生成 ---")
log.info(" 目标词数: %d (names=%d * N_REPETITIONS=%d)", total_target, len(names), config.N_REPETITIONS)
log.info(" 当前最小字号: %d, 效率: %.2f", current_min_font, current_packing_eff)
for attempt in range(1, config.MAX_ATTEMPTS + 1):
base_min_font, base_max_font = compute_font_bounds(current_packing_eff)
print(f"尝试 #{attempt}: 基准字号 [{base_min_font}, {base_max_font}], 效率: {current_packing_eff:.2f}")
log.info("[尝试 #%d] 字号区间: [%d, %d], 效率: %.2f, 大字率: %.2f",
attempt, base_min_font, base_max_font, current_packing_eff, config.LARGE_FONT_LIMIT_RATIO)
log.info(
" 基准字号: [%d, %d], 硬边界: [%d, %s], 等字号=%s",
base_min_font,
base_max_font,
hard_min_font,
hard_max_font if hard_max_font is not None else "",
equal_size_mode,
)
best_wc = None
best_count = 0
best_scale = config.FONT_SCALE_MIN
best_success_wc = None
best_success_scale = None
current_large_ratio = config.LARGE_FONT_LIMIT_RATIO
low_scale = max(config.SCALE_FLOOR, config.FONT_SCALE_MIN)
high_scale = max(low_scale + 0.01, config.FONT_SCALE_MAX)
for _ in range(max(1, config.SCALE_SEARCH_ROUNDS)):
mid_scale = ((low_scale + high_scale) / 2) * grow_step
wc, placed_count = try_place(base_min_font, base_max_font, current_large_ratio, mid_scale)
print(f" 尺度 {mid_scale:.3f} (字号 {base_min_font}-{base_max_font}) -> 成功: {placed_count}/{total_target}")
log.info(" 尺度 %.3f -> 放置 %d/%d", mid_scale, placed_count, total_target)
if placed_count > best_count:
best_wc = wc
best_count = placed_count
best_scale = mid_scale
if config.REQUIRE_ALL_WORDS:
if placed_count >= total_target:
best_success_wc = wc
best_success_scale = mid_scale
low_scale = max(low_scale, mid_scale / max(grow_step, 1e-6))
else:
high_scale = min(high_scale, mid_scale / max(grow_step, 1e-6))
else:
if placed_count >= best_count:
low_scale = max(low_scale, mid_scale / max(grow_step, 1e-6))
else:
high_scale = min(high_scale, mid_scale / max(grow_step, 1e-6))
if abs(high_scale - low_scale) < 0.02:
break
if best_success_wc is not None:
final_wc = best_success_wc
final_scale = best_success_scale if best_success_scale is not None else best_scale
# Every attempt is a fresh, whole-cloud layout. No word may silently
# receive a smaller fallback size. Usually the area model succeeds on
# attempt one; two adaptive retries cover fragmentation-heavy masks.
best_wc = None
best_count = 0
best_scale = 1.0
scale = 1.0
tried_layouts = set()
failed_scales = []
for attempt in range(1, 4):
bounds = scaled_bounds(scale)
layout_key = (bounds, base_layout_seed)
if layout_key in tried_layouts:
break
tried_layouts.add(layout_key)
wc, placed_count, min_font, max_font = try_place(scale)
print(
f" 整批布局 #{attempt}: scale={scale:.3f}, "
f"字号=[{min_font}, {max_font}] -> {placed_count}/{total_target}"
)
log.info(
" 整批布局 #%d scale=%.3f 字号=[%d,%d] -> %d/%d",
attempt,
scale,
min_font,
max_font,
placed_count,
total_target,
)
if placed_count > best_count:
best_wc = wc
best_count = placed_count
best_scale = scale
if placed_count >= total_target:
final_wc = wc
final_scale = scale
final_layout_seed = base_layout_seed
break
if best_wc is not None:
shrink_min = current_min_font
for _ in range(config.AUTO_SHRINK_ROUNDS):
shrink_min = max(config.MIN_FONT_FLOOR, int(shrink_min * 0.8))
if shrink_min >= base_min_font:
continue
failed_scales.append(scale)
print(f" [降级:缩小字号] {shrink_min}...")
wc, placed_count = try_place(shrink_min, base_max_font, current_large_ratio, best_scale)
if placed_count > best_count:
best_wc = wc
best_count = placed_count
best_scale = best_scale
if config.REQUIRE_ALL_WORDS and placed_count >= total_target:
final_wc = wc
final_scale = best_scale
break
if final_wc is not None:
break
placed_ratio = placed_count / max(1, total_target)
# Required box area is roughly proportional to size². The extra
# safety margin absorbs fragmentation without wasting a binary search.
shrink = 0.62 if placed_ratio <= 0 else min(0.92, max(0.58, math.sqrt(placed_ratio) * 0.92))
scale *= shrink
if config.ENABLE_SMART_LARGE_FONT_REDUCTION:
print(" [降级:牺牲大字] 仍然放不下,尝试减少大字数量...")
strict_large_ratio = 0.05
retry_min = shrink_min if 'shrink_min' in locals() else current_min_font
wc, placed_count = try_place(retry_min, base_max_font, strict_large_ratio, best_scale)
print(f" [严格模式] 大字率 {strict_large_ratio} -> 成功: {placed_count}/{total_target}")
if placed_count > best_count:
best_wc = wc
best_count = placed_count
if config.REQUIRE_ALL_WORDS and placed_count >= total_target:
final_wc = wc
final_scale = best_scale
break
if attempt == config.MAX_ATTEMPTS:
final_wc = best_wc
final_scale = best_scale
break
shrink_ratio = (best_count / total_target) if best_count else 0.5
current_packing_eff *= min(0.95, max(0.5, shrink_ratio))
if final_wc is None:
final_wc = best_wc
final_scale = best_scale
if final_wc is None:
return {
@@ -203,61 +221,263 @@ def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real
"base_max_font": base_max_font,
"mask_small": mask_small,
"size_scale": final_scale,
"layout_seed": final_layout_seed,
"collision_margin": _collision_margin,
"hd_overlap_pixels": 0,
"hd_layout": [],
"hd_clearance": None,
}
fill_ratio, occ_fast = compute_fill_ratio_fast(final_wc.layout_, mask_small, config.WC_FONT_PATH)
largest_empty_square = largest_empty_square_size(occ_fast, mask_small)
def has_character_sized_hole(wc, hole_size):
if not equal_size_mode or wc is None or not wc.layout_:
return False
font_size = int(wc.layout_[0][1])
return hole_size >= max(2, int(math.ceil(font_size * 1.25)))
complete_candidates = []
if len(final_wc.layout_) >= total_target:
complete_candidates.append(
(final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast)
)
print(f"填充率: {fill_ratio:.3f}")
log.info("[填充率] 初始填充率: %.4f (最低要求: %.4f)", fill_ratio, config.MIN_ACCEPT_FILL_RATIO)
log.info("[填充率] 初始填充率: %.4f", fill_ratio)
log.info(" layout_ 词数: %d", len(final_wc.layout_))
if config.SAVE_DEBUG_IMAGES and occ_fast is not None:
debug_dir = Path(config.DEBUG_OUTPUT_DIR)
debug_dir.mkdir(parents=True, exist_ok=True)
Image.fromarray((occ_fast * 255).astype(np.uint8)).save(str(debug_dir / "occ_fast.png"))
if fill_ratio < config.MIN_ACCEPT_FILL_RATIO:
print(f"[填充率不足] {fill_ratio:.3f} < {config.MIN_ACCEPT_FILL_RATIO:.2f},启动二分放大字号重试...")
low_scale = max(final_scale, 1.0)
high_scale = max(low_scale, config.FILL_RETRY_MAX_SCALE)
retry_round = 0
best_wc = final_wc
best_fill = fill_ratio
# Probe larger whole-cloud layouts and keep the largest complete one. If
# an earlier batch was too large, search the discrete interval between the
# complete and failed scales instead of accepting an over-aggressive
# shrink. Every probe rebuilds the entire cloud with one shared scale.
if (
len(final_wc.layout_) >= total_target
and fill_ratio > 0
and (
fill_ratio < config.TARGET_FILL_RATIO * 0.98
or has_character_sized_hole(final_wc, largest_empty_square)
)
):
upper_scale = min(
(failed for failed in failed_scales if failed > final_scale),
default=None,
)
for density_attempt in range(1, 5):
if upper_scale is not None:
grow_scale = (final_scale + upper_scale) / 2.0
elif equal_size_mode:
current_size, _ = scaled_bounds(final_scale)
grow_scale = (current_size + 1) / max(1, base_min_font)
else:
desired_growth = min(
1.12,
math.sqrt(config.TARGET_FILL_RATIO / fill_ratio) * 0.98,
)
if desired_growth <= 1.005:
break
grow_scale = final_scale * desired_growth
while retry_round < config.FILL_RETRY_MAX_ROUNDS:
mid_scale = (low_scale + high_scale) / 2
retry_large_ratio = 1.0 if config.FILL_RETRY_RELAX_LARGE_CAP else config.LARGE_FONT_LIMIT_RATIO
grow_bounds = scaled_bounds(grow_scale)
if grow_bounds == scaled_bounds(final_scale):
break
grow_min, grow_max = grow_bounds
layout_key = (grow_bounds, base_layout_seed)
attempted_layout = False
if layout_key not in tried_layouts:
tried_layouts.add(layout_key)
wc, placed_count, _, _ = try_place(grow_scale)
attempted_layout = True
print(
f" 密度优化 #{density_attempt}: scale={grow_scale:.3f}, "
f"字号=[{grow_min}, {grow_max}] -> {placed_count}/{total_target}"
)
else:
wc, placed_count = None, -1
wc, _placed_count = try_place(base_min_font, base_max_font, retry_large_ratio, mid_scale)
new_fill, occ_fast = compute_fill_ratio_fast(wc.layout_, mask_small, config.WC_FONT_PATH)
print(f" [二分重试#{retry_round + 1}] scale={mid_scale:.3f} 填充率={new_fill:.3f}")
if new_fill > best_fill:
best_fill = new_fill
best_wc = wc
if new_fill >= config.MIN_ACCEPT_FILL_RATIO:
final_wc = wc
final_scale = mid_scale
fill_ratio = new_fill
if config.SAVE_DEBUG_IMAGES and occ_fast is not None:
debug_dir = Path(config.DEBUG_OUTPUT_DIR)
debug_dir.mkdir(parents=True, exist_ok=True)
Image.fromarray((occ_fast * 255).astype(np.uint8)).save(
str(debug_dir / f"occ_fast_retry_{retry_round + 1}.png")
selected_seed = base_layout_seed
if placed_count < total_target and base_layout_seed is not None:
# The reference library samples a fresh legal position order.
# One bounded whole-cloud re-layout recovers dense solutions
# without per-word shrinking or an unbounded random search.
candidate_seed = (int(base_layout_seed) * 3 + 3) % (2**31 - 1)
retry_key = (grow_bounds, candidate_seed)
if retry_key not in tried_layouts:
tried_layouts.add(retry_key)
retry_wc, retry_count, _, _ = try_place(grow_scale, candidate_seed)
attempted_layout = True
print(
f" 密度优化 #{density_attempt} 整批重排: "
f"seed={candidate_seed}, 字号=[{grow_min}, {grow_max}] -> "
f"{retry_count}/{total_target}"
)
if retry_count > placed_count:
wc = retry_wc
placed_count = retry_count
selected_seed = candidate_seed
if not attempted_layout:
break
if placed_count < total_target:
upper_scale = grow_scale
continue
new_fill, new_occ = compute_fill_ratio_fast(wc.layout_, mask_small, config.WC_FONT_PATH)
if new_fill <= fill_ratio:
break
final_wc = wc
final_scale = grow_scale
final_layout_seed = selected_seed
fill_ratio = new_fill
occ_fast = new_occ
largest_empty_square = largest_empty_square_size(occ_fast, mask_small)
complete_candidates.append(
(final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast)
)
if (
fill_ratio >= config.TARGET_FILL_RATIO * 0.98
and not has_character_sized_hole(final_wc, largest_empty_square)
):
break
if new_fill > fill_ratio:
low_scale = mid_scale
else:
high_scale = mid_scale
# At the largest complete equal-size tier, compare a small deterministic
# set of whole-cloud reorderings and retain the one with the smallest
# character-scale void. This never changes an individual font size.
if (
equal_size_mode
and len(final_wc.layout_) >= total_target
and has_character_sized_hole(final_wc, largest_empty_square)
and base_layout_seed is not None
):
if total_target < 100:
hole_attempt_budget = 3
elif total_target <= 300:
hole_attempt_budget = 2
else:
hole_attempt_budget = 1
modulus = 2**31 - 1
for hole_attempt in range(1, hole_attempt_budget + 1):
candidate_seed = (
int(base_layout_seed) ^ ((0x9E3779B9 * hole_attempt) & 0x7FFFFFFF)
) % modulus
bounds = scaled_bounds(final_scale)
layout_key = (bounds, candidate_seed)
if layout_key in tried_layouts:
continue
tried_layouts.add(layout_key)
candidate_wc, placed_count, _, _ = try_place(final_scale, candidate_seed)
if placed_count < total_target:
continue
candidate_fill, candidate_occ = compute_fill_ratio_fast(
candidate_wc.layout_, mask_small, config.WC_FONT_PATH
)
candidate_hole = largest_empty_square_size(candidate_occ, mask_small)
print(
f" 空洞优化 #{hole_attempt}: seed={candidate_seed}, "
f"最大空白={candidate_hole}px -> {placed_count}/{total_target}"
)
if candidate_hole >= largest_empty_square:
continue
final_wc = candidate_wc
final_layout_seed = candidate_seed
fill_ratio = candidate_fill
occ_fast = candidate_occ
largest_empty_square = candidate_hole
complete_candidates.append(
(final_wc, final_scale, final_layout_seed, fill_ratio, occ_fast)
)
retry_round += 1
hd_layout = None
hd_clearance = None
raw_hd_layout = []
for candidate_index, candidate in enumerate(reversed(complete_candidates), start=1):
candidate_wc, candidate_scale, candidate_seed, candidate_fill, candidate_occ = candidate
raw_hd_layout = scale_layout_for_hd(candidate_wc.layout_, config.WORK_SCALE)
refined_layout = None
clearance_stats = None
clearance_modes = (1, 0)
if total_target >= 100 and candidate_index > 1:
clearance_modes = (0,)
for clearance_px in clearance_modes:
priority_layout = raw_hd_layout
priority_restarts = 0
max_priority_attempts = 2 if clearance_px == 0 or total_target < 100 else 1
for priority_attempt in range(max_priority_attempts):
refined_layout, clearance_stats = refine_layout_with_hd_clearance(
priority_layout,
mask_hd,
config.WC_FONT_PATH,
clearance=clearance_px,
)
if refined_layout is not None:
break
failed_word = clearance_stats["failed_word"]
failed_index = next(
(index for index, item in enumerate(priority_layout) if item[0] == failed_word),
None,
)
if (
failed_index is None
or failed_index == 0
or priority_attempt + 1 >= max_priority_attempts
):
break
failed_item = priority_layout[failed_index]
priority_layout = [failed_item, *priority_layout[:failed_index], *priority_layout[failed_index + 1:]]
priority_restarts += 1
if fill_ratio < config.MIN_ACCEPT_FILL_RATIO:
final_wc = best_wc
fill_ratio = best_fill
if refined_layout is not None:
clearance_stats["clearance_px"] = clearance_px
clearance_stats["priority_restarts"] = priority_restarts
break
clearance_stats["clearance_px"] = clearance_px
clearance_stats["priority_restarts"] = priority_restarts
if refined_layout is None:
log.warning(
"高清候选 #%d 隔离精修失败: word=%s, shifted=%d, max_shift=%d",
candidate_index,
clearance_stats["failed_word"],
clearance_stats["shifted_words"],
clearance_stats["max_shift"],
)
hd_clearance = clearance_stats
continue
print(f"最终填充率: {fill_ratio:.3f}")
final_wc = candidate_wc
final_scale = candidate_scale
final_layout_seed = candidate_seed
fill_ratio = candidate_fill
occ_fast = candidate_occ
largest_empty_square = largest_empty_square_size(occ_fast, mask_small)
hd_layout = refined_layout
hd_clearance = clearance_stats
break
if hd_layout is None:
hd_overlap_pixels = -1
hd_layout = raw_hd_layout
if hd_clearance is None:
hd_clearance = {
"shifted_words": 0,
"max_shift": 0,
"clearance_px": None,
"priority_restarts": 0,
"failed_word": None,
}
else:
hd_overlap_pixels = count_layout_overlap_pixels(
hd_layout,
(real_hd_h, real_hd_w),
config.WC_FONT_PATH,
)
print(
f"最终填充率: {fill_ratio:.3f} | 高清重叠像素: {hd_overlap_pixels} | "
f"精修位移: {hd_clearance['shifted_words']} 词, 最大 {hd_clearance['max_shift']}px | "
f"隔离带: {hd_clearance['clearance_px']}px"
)
return {
"wc": final_wc,
"fill_ratio": fill_ratio,
@@ -268,6 +488,17 @@ def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real
"base_max_font": base_max_font,
"mask_small": mask_small,
"size_scale": final_scale,
"layout_seed": final_layout_seed,
"collision_margin": _collision_margin,
"hd_overlap_pixels": hd_overlap_pixels,
"hd_layout": hd_layout,
"hd_clearance": hd_clearance,
"largest_empty_square_work_px": largest_empty_square,
"largest_empty_square_font_ratio": (
largest_empty_square / max(1, int(final_wc.layout_[0][1]))
if final_wc.layout_ and equal_size_mode
else None
),
}
@@ -317,6 +548,7 @@ def main():
log.info(" 平均名字长度: %.2f 字符", avg_len)
log.info(" BASE_HD: %dx%d", config.BASE_HD_WIDTH, config.BASE_HD_HEIGHT)
# 只生成一次掩膜:先 probe 尺寸,再按需扩展后复用(避免二次 LANCZOS)
probe_mask_hd, (probe_w, probe_h), _ = prepare_mask(config.BASE_HD_WIDTH, config.BASE_HD_HEIGHT)
probe_stats = analyze_mask(probe_mask_hd)
log.info(" Probe mask: %dx%d, free_ratio=%.4f, bbox_fill_ratio=%.4f",
@@ -330,8 +562,12 @@ def main():
print("--- 3. 生成掩膜 (High Quality & Edge Fix) ---")
log.info("--- 阶段3: 生成掩膜 ---")
mask_hd, (real_hd_w, real_hd_h), _ = prepare_mask(hd_w, hd_h)
mask_stats = analyze_mask(mask_hd)
if (hd_w, hd_h) == (probe_w, probe_h):
mask_hd, real_hd_w, real_hd_h = probe_mask_hd, probe_w, probe_h
mask_stats = probe_stats
else:
mask_hd, (real_hd_w, real_hd_h), _ = prepare_mask(hd_w, hd_h)
mask_stats = analyze_mask(mask_hd)
log.info(" mask_hd: %dx%d", real_hd_w, real_hd_h)
log.info(" free_area=%d, free_ratio=%.6f", mask_stats['free_area'], mask_stats['free_ratio'])
log.info(" bbox_fill_ratio=%.6f", mask_stats['bbox_fill_ratio'])
@@ -371,8 +607,11 @@ def main():
print(f"Excel 权重不可用,已回退{fallback}")
log.info(" Excel 权重不可用,已回退%s", fallback)
name_weights_map = dict(stroke_weights_map)
name_weights_map.update(excel_weights_map)
name_weights_map = merge_weight_maps(
names,
stroke_weights_map if config.ENABLE_STROKE_WEIGHTS else {},
excel_weights_map,
)
frequencies_data = name_weights_map if config.REMOVE_DUPLICATES else [(name, name_weights_map.get(name, 10)) for name in names]
canvas_retry_round = 0
@@ -389,21 +628,35 @@ def main():
real_hd_h,
)
if generation_result["wc"] is None:
if canvas_retry_round >= config.CANVAS_RETRY_MAX_ROUNDS:
print("生成失败:未找到合适布局")
log.error("生成失败:未找到合适布局 (已重试 %d 轮)", canvas_retry_round)
wc = generation_result.get("wc")
placed = len(wc.layout_) if wc is not None else 0
target = len(names) * config.N_REPETITIONS
# 名单完整性是硬约束。填充率优化不得以漏掉姓名为代价。
hd_overlap_pixels = int(generation_result.get("hd_overlap_pixels", 0))
placement_ok = wc is not None and placed >= target and hd_overlap_pixels == 0
if placement_ok or canvas_retry_round >= config.CANVAS_RETRY_MAX_ROUNDS:
if not placement_ok:
print(
f"生成失败:放置 {placed}/{target},高清重叠像素 {hd_overlap_pixels}"
"未满足完整名单与零碰撞约束"
)
log.error(
"生成失败:放置 %d/%d, 高清重叠像素=%d (已重试 %d 轮)",
placed,
target,
hd_overlap_pixels,
canvas_retry_round,
)
sys.exit(1)
log.warning(" 本轮生成失败 (wc=None), 将重试")
elif generation_result["fill_ratio"] >= config.MIN_ACCEPT_FILL_RATIO or canvas_retry_round >= config.CANVAS_RETRY_MAX_ROUNDS:
log.info(" 生成成功! fill_ratio=%.4f (要求>=%.4f), 重试轮次=%d",
generation_result['fill_ratio'], config.MIN_ACCEPT_FILL_RATIO, canvas_retry_round)
log.info(" 生成完成 placed=%d/%d fill=%.4f retry=%d",
placed, target, generation_result["fill_ratio"], canvas_retry_round)
break
canvas_retry_round += 1
next_w = int(real_hd_w * config.CANVAS_RETRY_GROWTH)
next_h = int(real_hd_h * config.CANVAS_RETRY_GROWTH)
print(f"[画布重试#{canvas_retry_round}] {real_hd_w}x{real_hd_h} -> {next_w}x{next_h}")
print(f"[画布重试#{canvas_retry_round}] {real_hd_w}x{real_hd_h} -> {next_w}x{next_h} (放置 {placed}/{target})")
log.info("[画布重试#%d] %dx%d -> %dx%d (growth=%.2f)",
canvas_retry_round, real_hd_w, real_hd_h, next_w, next_h, config.CANVAS_RETRY_GROWTH)
mask_hd, (real_hd_w, real_hd_h), _ = prepare_mask(next_w, next_h)
@@ -419,12 +672,7 @@ def main():
print("--- 6. 高清渲染 ---")
log.info("--- 阶段6: 高清渲染 ---")
t_render = time.time()
hd_layout = []
for text, size, (y, x), orient, color in final_wc.layout_:
hd_size = int(size / config.WORK_SCALE)
hd_y = int(y / config.WORK_SCALE)
hd_x = int(x / config.WORK_SCALE)
hd_layout.append((text, hd_size, (hd_y, hd_x), orient, color))
hd_layout = generation_result["hd_layout"]
log.info(" HD layout 词数: %d", len(hd_layout))
log.info(" HD 画布: %dx%d", real_hd_w, real_hd_h)
@@ -439,26 +687,17 @@ def main():
final_wc.height = real_hd_h
base_img = final_wc.to_image().convert("RGB")
if config.ENABLE_DOT_MATRIX:
base_img = apply_dot_matrix(base_img, mask_hd)
base_img.save(config.OUTPUT_PNG)
# 更快的 PNG 写出(压缩等级 1,视觉无损)
base_img.save(config.OUTPUT_PNG, compress_level=1)
print(f"已保存: {config.OUTPUT_PNG}")
log.info(" PNG 已保存: %s (%.2f MB)", config.OUTPUT_PNG,
Path(config.OUTPUT_PNG).stat().st_size / 1024 / 1024 if Path(config.OUTPUT_PNG).exists() else 0)
final_wc.to_svg(config.OUTPUT_SVG)
# 一次构建路径,同时写出 fill / stroke 两份 SVG
stroke_svg = final_wc.export_svgs(config.OUTPUT_SVG)
print(f"已保存: {config.OUTPUT_SVG}")
log.info(" SVG 已保存: %s (%.2f MB)", config.OUTPUT_SVG,
Path(config.OUTPUT_SVG).stat().st_size / 1024 / 1024 if Path(config.OUTPUT_SVG).exists() else 0)
# 描边版 SVG(激光雕刻用)
stroke_svg = str(Path(config.OUTPUT_SVG).with_name(
Path(config.OUTPUT_SVG).stem + "_stroke" + Path(config.OUTPUT_SVG).suffix
))
final_wc.to_svg_stroke(stroke_svg)
print(f"已保存: {stroke_svg}")
log.info(" SVG(stroke) 已保存: %s (%.2f MB)", stroke_svg,
Path(stroke_svg).stat().st_size / 1024 / 1024 if Path(stroke_svg).exists() else 0)
log.info(" SVG 已保存: %s", config.OUTPUT_SVG)
log.info(" SVG(stroke) 已保存: %s", stroke_svg)
log.info(" 渲染耗时: %.2fs", time.time() - t_render)
log.info("--- 阶段7: 写入数据库 ---")
@@ -482,27 +721,35 @@ def main():
box_height INTEGER
)
""")
bbox_canvas = Image.new("L", (1, 1), 0)
bbox_draw = ImageDraw.Draw(bbox_canvas)
# Cache full font bearings as well as dimensions so search highlights
# match the actual PIL-rendered glyph position.
bbox_cache = {}
_measure = ImageDraw.Draw(Image.new("L", (1, 1)))
db_data = []
for name, font_size, (y, x), orient, color in final_wc.layout_:
font = get_cached_font(config.WC_FONT_PATH, max(1, int(font_size)))
orientation = "vertical" if orient else "horizontal"
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
bbox = bbox_draw.textbbox((x, y), name, font=font)
key = (name, int(font_size), bool(orient))
box = bbox_cache.get(key)
if box is None:
font = get_cached_font(config.WC_FONT_PATH, max(1, int(font_size)))
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
bb = _measure.textbbox((0, 0), name, font=font)
box = (bb[0], bb[1], bb[2] - bb[0], bb[3] - bb[1])
bbox_cache[key] = box
bx, by, bw, bh = box
db_data.append(
(
name,
x,
y,
x + bx,
y + by,
font_size,
color,
orientation,
bbox[0],
bbox[1],
bbox[2] - bbox[0],
bbox[3] - bbox[1],
x,
y,
bw,
bh,
)
)
cursor.executemany(
@@ -525,11 +772,18 @@ def main():
placed_count = len(final_wc.layout_)
metrics = {
"seed": config.SEED,
"layout_order_mode": config.LAYOUT_ORDER_MODE,
"layout_seed": config.LAYOUT_SEED,
"layout_seed": generation_result.get("layout_seed", config.LAYOUT_SEED),
"input_count": input_count,
"placed_count": placed_count,
"fill_ratio": fill_ratio,
"largest_empty_square_work_px": int(
generation_result.get("largest_empty_square_work_px", 0)
),
"largest_empty_square_font_ratio": generation_result.get(
"largest_empty_square_font_ratio"
),
"hd_overlap_pixels": int(generation_result.get("hd_overlap_pixels", -1)),
"hd_clearance": generation_result.get("hd_clearance"),
"elapsed_seconds": round(elapsed, 4),
"font_info": {
"layout_font_path": config.WC_FONT_PATH,
@@ -563,12 +817,8 @@ def main():
"output_dir": config.OUTPUT_DIR,
"output_prefix": config.OUTPUT_PREFIX,
"min_font_size": config.MIN_FONT_SIZE,
"max_attempts": config.MAX_ATTEMPTS,
"fill_on": config.FILL_ON,
"min_accept_fill_ratio": config.MIN_ACCEPT_FILL_RATIO,
"require_all_words": config.REQUIRE_ALL_WORDS,
"layout_order_mode": config.LAYOUT_ORDER_MODE,
"layout_seed": config.LAYOUT_SEED,
"layout_seed": generation_result.get("layout_seed", config.LAYOUT_SEED),
}
}
config.write_metrics(metrics)
+172 -18
View File
@@ -1,10 +1,157 @@
import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from . import config
from .fonts import get_cached_font
def scale_layout_for_hd(layout, work_scale):
if work_scale <= 0:
raise ValueError("WORK_SCALE must be greater than zero")
return [
(
text,
max(1, int(size / work_scale)),
(int(y / work_scale), int(x / work_scale)),
orient,
color,
)
for text, size, (y, x), orient, color in layout
]
def count_layout_overlap_pixels(layout, mask_shape, font_path, alpha_threshold=0):
"""Count final rendered pixels occupied by more than one layout entry."""
height, width = mask_shape
occupied = np.zeros((height, width), dtype=bool)
overlaps = np.zeros((height, width), dtype=bool)
measure = ImageDraw.Draw(Image.new("L", (1, 1)))
for word, size, (y, x), orient, _color in layout:
font = get_cached_font(font_path, size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
bbox = measure.textbbox((0, 0), word, font=font)
glyph = font.getmask(word, mode="L")
glyph_width, glyph_height = glyph.size
if glyph_width <= 0 or glyph_height <= 0:
continue
ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width) > alpha_threshold
ink_x = int(x) + int(bbox[0])
ink_y = int(y) + int(bbox[1])
x0 = max(0, ink_x)
y0 = max(0, ink_y)
x1 = min(width, ink_x + glyph_width)
y1 = min(height, ink_y + glyph_height)
if x0 >= x1 or y0 >= y1:
continue
visible_ink = ink[y0 - ink_y:y1 - ink_y, x0 - ink_x:x1 - ink_x]
occupied_region = occupied[y0:y1, x0:x1]
overlaps[y0:y1, x0:x1] |= occupied_region & visible_ink
occupied_region |= visible_ink
return int(overlaps.sum())
def refine_layout_with_hd_clearance(
layout,
mask,
font_path,
clearance=1,
max_shift=24,
):
"""Validate the whole HD batch and minimally move rasterization collisions."""
height, width = mask.shape
blocked = np.asarray(mask) != 0
occupied = np.zeros((height, width), dtype=bool)
measure = ImageDraw.Draw(Image.new("L", (1, 1)))
refined = []
shifted_words = 0
max_applied_shift = 0
offset_rings = [[(0, 0)]]
for radius in range(1, max_shift + 1):
ring = [
(dy, dx)
for dy in range(-radius, radius + 1)
for dx in range(-radius, radius + 1)
if max(abs(dy), abs(dx)) == radius
]
ring.sort(key=lambda item: (item[0] * item[0] + item[1] * item[1], item[0], item[1]))
offset_rings.append(ring)
for word, size, (draw_y, draw_x), orient, color in layout:
font = get_cached_font(font_path, size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
bbox = measure.textbbox((0, 0), word, font=font)
glyph = font.getmask(word, mode="L")
glyph_width, glyph_height = glyph.size
if glyph_width <= 0 or glyph_height <= 0:
refined.append((word, size, (draw_y, draw_x), orient, color))
continue
glyph_ink = np.asarray(glyph, dtype=np.uint8).reshape(glyph_height, glyph_width)
pad = max(0, int(clearance))
padded = np.zeros((glyph_height + 2 * pad, glyph_width + 2 * pad), dtype=np.uint8)
padded[pad:pad + glyph_height, pad:pad + glyph_width] = glyph_ink
if pad:
collision = np.asarray(
Image.fromarray(padded).filter(ImageFilter.MaxFilter(2 * pad + 1)),
dtype=np.uint8,
) > 0
else:
collision = padded > 0
stamp = padded > 0
base_y = int(draw_y) + int(bbox[1]) - pad
base_x = int(draw_x) + int(bbox[0]) - pad
def fits(y0, x0):
y1 = y0 + collision.shape[0]
x1 = x0 + collision.shape[1]
if y0 < 0 or x0 < 0 or y1 > height or x1 > width:
return False
if np.any(blocked[y0:y1, x0:x1] & stamp):
return False
return not np.any(occupied[y0:y1, x0:x1] & collision)
placed_offset = None
for ring in offset_rings:
for dy, dx in ring:
y0 = base_y + dy
x0 = base_x + dx
if not fits(y0, x0):
continue
placed_offset = (dy, dx)
break
if placed_offset is not None:
break
if placed_offset is None:
return None, {
"shifted_words": shifted_words,
"max_shift": max_applied_shift,
"failed_word": word,
}
dy, dx = placed_offset
y0 = base_y + dy
x0 = base_x + dx
occupied[y0:y0 + stamp.shape[0], x0:x0 + stamp.shape[1]] |= stamp
if dy or dx:
shifted_words += 1
max_applied_shift = max(max_applied_shift, abs(dy), abs(dx))
refined.append((word, size, (int(draw_y) + dy, int(draw_x) + dx), orient, color))
return refined, {
"shifted_words": shifted_words,
"max_shift": max_applied_shift,
"failed_word": None,
}
def render_layout_occupancy(layout, mask_shape, font_path):
h, w = mask_shape
canvas = Image.new("L", (w, h), 0)
@@ -28,20 +175,27 @@ def compute_fill_ratio_fast(layout, mask, font_path):
return filled_area / free_area, occ
def apply_dot_matrix(base_img, mask_hd):
text_mask = base_img.convert("L").point(lambda x: 0 if x < 200 else 255)
filter_size = max(3, (config.DOT_SAFETY_BUFFER // 2) * 2 + 1)
safe_zone_mask = text_mask.filter(ImageFilter.MinFilter(size=filter_size))
safe_zone_array = np.array(safe_zone_mask)
unfilled_zone = (mask_hd == 0) & (safe_zone_array > 200)
draw = ImageDraw.Draw(base_img)
h, w = mask_hd.shape
dot_color = "white" if config.FILL_ON == "WHITE" else "black"
for y in range(0, h, config.DOT_SPACING):
for x in range(0, w, config.DOT_SPACING):
if unfilled_zone[y, x]:
if config.DOT_RADIUS > 0:
draw.ellipse([x - config.DOT_RADIUS, y - config.DOT_RADIUS, x + config.DOT_RADIUS, y + config.DOT_RADIUS], fill=dot_color)
else:
draw.point((x, y), fill=dot_color)
return base_img
def largest_empty_square_size(occupancy, mask):
"""Return the largest fully empty square inside the fillable mask."""
blocked = (np.asarray(mask) != 0) | (np.asarray(occupancy) != 0)
if blocked.ndim != 2 or blocked.size == 0:
return 0
integral = np.pad(blocked.astype(np.uint32), ((1, 0), (1, 0)))
integral = integral.cumsum(axis=0).cumsum(axis=1)
low = 0
high = min(blocked.shape)
while low < high:
size = (low + high + 1) // 2
window_sums = (
integral[size:, size:]
- integral[:-size, size:]
- integral[size:, :-size]
+ integral[:-size, :-size]
)
if np.any(window_sums == 0):
low = size
else:
high = size - 1
return int(low)
+53 -5
View File
@@ -73,13 +73,55 @@ def extract_weights_from_df(df, names):
return dict(zip(grouped["name"], grouped["weight"]))
def merge_weight_maps(names, stroke_weights=None, excel_weights=None):
"""Combine optional manual weights with normalized stroke complexity.
Manual Excel weights remain the base signal. When both sources exist,
stroke complexity is normalized around the median and applied as a
multiplicative factor, so an all-ones Excel column still enables visibly
different stroke-driven font sizes while meaningful manual ratios remain.
"""
stroke_weights = stroke_weights or {}
excel_weights = excel_weights or {}
if not stroke_weights:
return dict(excel_weights)
if not excel_weights:
return dict(stroke_weights)
positive_strokes = sorted(
max(float(stroke_weights[name]), 1.0)
for name in names
if name in stroke_weights
)
if not positive_strokes:
return dict(excel_weights)
midpoint = positive_strokes[len(positive_strokes) // 2]
merged = {}
for name in names:
manual = excel_weights.get(name)
stroke = stroke_weights.get(name)
if manual is None:
if stroke is not None:
merged[name] = float(stroke)
continue
if stroke is None:
merged[name] = float(manual)
continue
merged[name] = max(float(manual), 1e-6) * max(float(stroke), 1.0) / midpoint
return merged
def calculate_font_by_area_model(mask, names, weights_map, fill_ratio, size_ratio, packing_efficiency, n_rep):
free_area = int(np.sum(mask == 0))
if free_area <= 0:
free_area = int(mask.size)
effective_fill = fill_ratio if fill_ratio > 0 else max(config.MIN_ACCEPT_FILL_RATIO, 0.82)
target_area = free_area * effective_fill * packing_efficiency
# 中文实心笔画约占字形包围盒 35–55%;目标取 0.42 附近,略保守以便一次放满。
effective_fill = fill_ratio if fill_ratio > 0 else 0.42
# 包围盒面积 vs 实际笔画:面积模型用包围盒估算,需额外 ink_factor 校正
ink_factor = 0.42
target_area = free_area * effective_fill * packing_efficiency / ink_factor
weights = [max(float(weights_map.get(name, 10)), 1.0) for name in names]
if not weights:
@@ -89,13 +131,19 @@ def calculate_font_by_area_model(mask, names, weights_map, fill_ratio, size_rati
char_mass = 0.0
for name, score in zip(names, log_scores):
length = max(1, len(name))
char_mass += length * (0.9 + 0.9 * score)
char_mass += length * (0.85 + 0.7 * score)
char_mass *= max(1, n_rep)
if char_mass <= 0:
return max(config.MIN_FONT_SIZE, 10), max(config.MIN_FONT_SIZE + 4, 20)
nominal_size = math.sqrt(target_area / char_mass)
min_f = max(config.MIN_FONT_SIZE, int(nominal_size * 0.72))
max_f = max(min_f + 1, int(min_f * max(1.4, size_ratio)))
min_f = max(config.MIN_FONT_SIZE, int(round(nominal_size * 0.78)))
ratio = max(1.0, float(size_ratio))
if math.isclose(ratio, 1.0, rel_tol=0.0, abs_tol=1e-9):
# A ratio of exactly one is a hard semantic guarantee: every word
# receives the same target size. Do not inject an artificial span.
max_f = min_f
else:
max_f = max(min_f, int(round(min_f * ratio)))
return min_f, max_f
+1
View File
@@ -8,3 +8,4 @@ scipy>=1.11.0
matplotlib>=3.10.0
pandas>=2.0.0
openpyxl>=3.1.0
fonttools>=4.50.0
+209
View File
@@ -0,0 +1,209 @@
from __future__ import annotations
import sys
import unittest
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from core import config # noqa: E402
from core.ewc import EfficientWordCloud # noqa: E402
from core.fonts import get_cached_font # noqa: E402
from core.pipeline import run_generation_pass # noqa: E402
from core.render import count_layout_overlap_pixels, largest_empty_square_size # noqa: E402
from core.weights import merge_weight_maps # noqa: E402
def chinese_names(count: int) -> list[str]:
surnames = "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜"
given = "子涵宇轩梓萱浩然欣怡雨桐诗涵俊杰思远若曦嘉怡明哲一诺安然沐阳"
return [
surnames[index % len(surnames)]
+ given[(index * 3) % len(given)]
+ given[(index * 7 + 1) % len(given)]
for index in range(count)
]
def circle_mask(size: int) -> np.ndarray:
image = Image.new("L", (size, size), 255)
ImageDraw.Draw(image).ellipse((20, 20, size - 21, size - 21), fill=0)
return np.asarray(image)
class LayoutConstraintTests(unittest.TestCase):
def setUp(self) -> None:
self.saved = {
key: getattr(config, key)
for key in (
"SIZE_RATIO",
"WORK_SCALE",
"MIN_READABLE_HEIGHT_PX",
"MIN_FONT_SIZE",
"USER_MIN_FONT_SIZE",
"USER_MAX_FONT_SIZE",
"N_REPETITIONS",
"LAYOUT_SEED",
"SEED",
"TARGET_FILL_RATIO",
"WC_FONT_PATH",
)
}
config.WC_FONT_PATH = str(config.PROJECT_DEFAULT_FONT)
config.SIZE_RATIO = 1.0
config.WORK_SCALE = 0.18
config.MIN_READABLE_HEIGHT_PX = 22
config.MIN_FONT_SIZE = 3
config.USER_MIN_FONT_SIZE = None
config.USER_MAX_FONT_SIZE = None
config.N_REPETITIONS = 1
config.LAYOUT_SEED = 20260718
config.SEED = 20260718
config.TARGET_FILL_RATIO = 0.42
def tearDown(self) -> None:
for key, value in self.saved.items():
setattr(config, key, value)
def generate(self, count: int, canvas: int = 1600):
names = chinese_names(count)
frequencies = [(name, 10.0) for name in names]
weights = dict(frequencies)
result = run_generation_pass(
names,
frequencies,
weights,
circle_mask(canvas),
canvas,
canvas,
)
return names, result
def test_size_ratio_one_keeps_every_equal_weight_size_identical(self) -> None:
names, result = self.generate(40)
layout = result["wc"].layout_
self.assertEqual(len(layout), len(names))
self.assertEqual(len({font_size for _, font_size, *_ in layout}), 1)
def test_explicit_equal_min_max_is_exact(self) -> None:
config.USER_MIN_FONT_SIZE = 12
config.USER_MAX_FONT_SIZE = 12
names, result = self.generate(20)
layout = result["wc"].layout_
self.assertEqual(len(layout), len(names))
self.assertEqual({font_size for _, font_size, *_ in layout}, {12})
def test_same_weight_groups_receive_the_same_size(self) -> None:
config.SIZE_RATIO = 2.0
names = chinese_names(24)
weights = {
name: (100.0 if index < 8 else 30.0 if index < 16 else 10.0)
for index, name in enumerate(names)
}
frequencies = [(name, weights[name]) for name in names]
result = run_generation_pass(
names,
frequencies,
weights,
circle_mask(1800),
1800,
1800,
)
sizes_by_weight: dict[float, set[int]] = {}
for name, font_size, *_ in result["wc"].layout_:
sizes_by_weight.setdefault(weights[name], set()).add(font_size)
self.assertEqual(len(result["wc"].layout_), len(names))
self.assertTrue(all(len(sizes) == 1 for sizes in sizes_by_weight.values()))
self.assertGreater(
max(sizes_by_weight[100.0]),
max(sizes_by_weight[10.0]),
)
def test_stroke_weight_is_applied_when_excel_weights_are_flat(self) -> None:
merged = merge_weight_maps(
["", "", ""],
{"": 100.0, "": 200.0, "": 300.0},
{"": 1.0, "": 1.0, "": 1.0},
)
self.assertLess(merged[""], merged[""])
self.assertLess(merged[""], merged[""])
def test_largest_empty_square_ignores_space_outside_mask(self) -> None:
mask = np.full((7, 7), 255, dtype=np.uint8)
mask[1:6, 1:6] = 0
occupancy = np.zeros((7, 7), dtype=np.uint8)
occupancy[1:3, 1:6] = 1
self.assertEqual(largest_empty_square_size(occupancy, mask), 3)
def test_conflicting_explicit_font_bounds_fail(self) -> None:
config.USER_MIN_FONT_SIZE = 13
config.USER_MAX_FONT_SIZE = 12
with self.assertRaisesRegex(ValueError, "字号硬约束冲突"):
self.generate(10)
def test_explicit_max_overrides_automatic_readability_floor(self) -> None:
config.USER_MAX_FONT_SIZE = 2
names, result = self.generate(12)
layout = result["wc"].layout_
self.assertEqual(len(layout), len(names))
self.assertEqual({font_size for _, font_size, *_ in layout}, {2})
def test_base_class_never_uses_a_private_fallback_size(self) -> None:
words = {name: 1.0 for name in chinese_names(12)}
wc = EfficientWordCloud(
width=150,
height=150,
font_path=config.WC_FONT_PATH,
max_words=len(words),
min_font_size=5,
max_font_size=30,
prefer_horizontal=1.0,
random_state=7,
margin=1,
)
wc.generate_from_frequencies(words)
self.assertGreater(len(wc.layout_), 0)
self.assertEqual({font_size for _, font_size, *_ in wc.layout_}, {30})
def test_rendered_ink_stays_inside_mask_and_does_not_overlap(self) -> None:
_names, result = self.generate(50)
layout = result["wc"].layout_
mask = result["mask_small"]
height, width = mask.shape
coverage = np.zeros((height, width), dtype=np.uint16)
for word, size, (y, x), orient, _color in layout:
image = Image.new("L", (width, height), 0)
draw = ImageDraw.Draw(image)
font = get_cached_font(config.WC_FONT_PATH, size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
draw.text((x, y), word, font=font, fill=255)
coverage += (np.asarray(image) > 0).astype(np.uint16)
self.assertFalse(np.any((coverage > 0) & (mask != 0)))
self.assertLessEqual(int(coverage.max()), 1)
def test_hd_rendered_ink_does_not_overlap_after_scaling(self) -> None:
names, result = self.generate(50)
self.assertEqual(len(result["wc"].layout_), len(names))
hd_layout = result["hd_layout"]
overlap_pixels = count_layout_overlap_pixels(
hd_layout,
(1600, 1600),
config.WC_FONT_PATH,
)
self.assertEqual(result["hd_overlap_pixels"], 0)
self.assertEqual(overlap_pixels, 0)
self.assertEqual(result["collision_margin"], 0)
self.assertEqual(result["hd_clearance"]["failed_word"], None)
self.assertIn(result["hd_clearance"]["clearance_px"], (0, 1))
if __name__ == "__main__":
unittest.main()
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""可重复的中文词云性能与视觉质量基准。"""
from __future__ import annotations
import argparse
import json
import math
import sys
import time
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
BACKEND_DIR = Path(__file__).resolve().parents[1]
if str(BACKEND_DIR) not in sys.path:
sys.path.insert(0, str(BACKEND_DIR))
from core import config # noqa: E402
from core.fonts import get_cached_font # noqa: E402
from core.pipeline import run_generation_pass # noqa: E402
from core.render import count_layout_overlap_pixels, scale_layout_for_hd # noqa: E402
SURNAMES = "赵钱孙李周吴郑王冯陈褚卫蒋沈韩杨朱秦尤许何吕施张孔曹严华金魏陶姜戚谢邹喻柏水窦章云苏潘葛奚范彭郎鲁韦昌马苗凤花方俞任袁柳唐罗薛雷贺倪汤滕殷罗毕郝邬安常乐于时傅皮卞齐康伍余元卜顾孟平黄和穆萧尹姚邵湛汪祁毛禹狄米贝明臧计伏成戴谈宋茅庞熊纪舒屈项祝董梁杜阮蓝闵席季麻强贾路娄危江童颜郭梅盛林刁钟徐邱骆高夏蔡田樊胡凌霍虞万支柯昝管卢莫经房裘缪干解应宗宣丁贲邓郁单杭洪包诸左石崔吉龚程嵇邢裴陆荣翁荀羊甄魏家封芮羿储靳汲邴糜松井段富巫乌焦巴弓牧隗山谷车侯宓蓬全郗班仰秋仲伊宫宁仇栾暴甘钭厉戎祖武符刘景詹束龙叶幸司韶郜黎蓟薄印宿白怀蒲台从鄂索咸籍赖卓蔺屠蒙池乔阴胥能苍双闻莘党翟谭贡劳逄姬申扶堵冉宰郦雍却璩桑桂濮牛寿通边扈燕冀郏浦尚农温别庄晏柴瞿阎充慕连茹习宦艾鱼容向古易慎戈廖庾终暨居衡步都耿满弘匡国文寇广禄阙东欧殳沃利蔚越夔隆师巩厍聂晁勾敖融冷訾辛阚那简饶空曾毋沙乜养鞠须丰巢关蒯相查后荆红游竺权逯盖益桓公"
GIVEN = "子涵宇轩梓萱浩然欣怡雨桐诗涵俊杰思远若曦嘉怡明哲一诺安然沐阳可馨奕辰语嫣皓轩晨曦梦瑶佳宁天佑书瑶瑞泽景行星辰清越知夏亦航舒雅嘉禾锦程乐言思齐云舟清欢予安望舒嘉树怀瑾景明青禾昭阳念初令仪时安向晚星野云舒允和知许南乔初晴清晏如松修远云起长风映雪听澜"
def make_names(count: int) -> list[str]:
names = []
for index in range(count):
surname = SURNAMES[index % len(SURNAMES)]
a = GIVEN[(index * 7) % len(GIVEN)]
b = GIVEN[(index * 17 + index // len(GIVEN)) % len(GIVEN)]
names.append(surname + a + b)
return names
def make_round_mask(size: int) -> np.ndarray:
image = Image.new("L", (size, size), 255)
draw = ImageDraw.Draw(image)
inset = max(8, size // 50)
draw.ellipse((inset, inset, size - inset - 1, size - inset - 1), fill=0)
return np.array(image)
def render_hd(layout, size: int, work_scale: float, output: Path) -> np.ndarray:
image = Image.new("RGB", (size, size), "white")
draw = ImageDraw.Draw(image)
hd_layout = scale_layout_for_hd(layout, work_scale)
for word, font_size, (y, x), orient, color in hd_layout:
font = get_cached_font(config.WC_FONT_PATH, font_size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
draw.text((x, y), word, font=font, fill=color)
image.save(output, compress_level=1)
return np.any(np.asarray(image) < 245, axis=2)
def visual_metrics(ink: np.ndarray, mask: np.ndarray) -> dict[str, float]:
free = mask == 0
true_ink = ink & free
free_area = int(free.sum())
density = float(true_ink.sum() / free_area) if free_area else 0.0
rows, cols = np.where(true_ink)
free_rows, free_cols = np.where(free)
bbox_coverage = 0.0
if rows.size and free_rows.size:
ink_h = int(rows.max() - rows.min() + 1)
ink_w = int(cols.max() - cols.min() + 1)
free_h = int(free_rows.max() - free_rows.min() + 1)
free_w = int(free_cols.max() - free_cols.min() + 1)
bbox_coverage = (ink_h * ink_w) / max(1, free_h * free_w)
# 轮廓覆盖:掩膜内 8×8 有效区域中,被真实墨迹触达的区域比例。
grid_hit = 0
grid_free = 0
height, width = free.shape
for gy in range(8):
y0, y1 = gy * height // 8, (gy + 1) * height // 8
for gx in range(8):
x0, x1 = gx * width // 8, (gx + 1) * width // 8
cell_free = free[y0:y1, x0:x1]
if not cell_free.any():
continue
grid_free += 1
if true_ink[y0:y1, x0:x1].any():
grid_hit += 1
return {
"hd_true_density": density,
"ink_bbox_coverage": float(bbox_coverage),
"contour_grid_coverage": float(grid_hit / grid_free) if grid_free else 0.0,
}
def run_case(count: int, canvas: int, output_dir: Path, max_growth_rounds: int) -> dict:
names = make_names(count)
weights = {name: 10.0 for name in names}
frequencies = [(name, 10.0) for name in names]
started = time.perf_counter()
current_canvas = canvas
growth_rounds = 0
while True:
mask = make_round_mask(current_canvas)
result = run_generation_pass(
names,
frequencies,
weights,
mask,
current_canvas,
current_canvas,
)
wc = result["wc"]
placed = len(wc.layout_) if wc is not None else 0
collision_ok = int(result.get("hd_overlap_pixels", -1)) == 0
if (placed >= count and collision_ok) or growth_rounds >= max_growth_rounds:
break
growth_rounds += 1
current_canvas = int(math.ceil(current_canvas * config.CANVAS_RETRY_GROWTH))
layout_seconds = time.perf_counter() - started
layout = wc.layout_ if wc is not None else []
png_path = output_dir / f"chinese_{count}.png"
render_started = time.perf_counter()
ink = render_hd(layout, current_canvas, config.WORK_SCALE, png_path)
render_seconds = time.perf_counter() - render_started
font_sizes = [int(item[1]) for item in layout]
hd_layout = result["hd_layout"]
metrics = {
"count": count,
"canvas": current_canvas,
"canvas_growth_rounds": growth_rounds,
"placed": len(layout),
"completeness": len(layout) / count if count else 1.0,
"layout_seconds": layout_seconds,
"render_seconds": render_seconds,
"total_seconds": layout_seconds + render_seconds,
"work_fill_ratio": float(result["fill_ratio"]),
"font_size_min": min(font_sizes) if font_sizes else 0,
"font_size_max": max(font_sizes) if font_sizes else 0,
"equal_weight_font_consistent": len(set(font_sizes)) <= 1,
"collision_margin": int(result["collision_margin"]),
"hd_clearance_shifted_words": int(result["hd_clearance"]["shifted_words"]),
"hd_clearance_max_shift": int(result["hd_clearance"]["max_shift"]),
"hd_clearance_px": int(result["hd_clearance"]["clearance_px"]),
"hd_clearance_priority_restarts": int(result["hd_clearance"]["priority_restarts"]),
"hd_overlap_pixels": count_layout_overlap_pixels(
hd_layout,
(current_canvas, current_canvas),
config.WC_FONT_PATH,
),
"largest_empty_square_work_px": int(result["largest_empty_square_work_px"]),
"largest_empty_square_font_ratio": result["largest_empty_square_font_ratio"],
"png": str(png_path),
}
metrics.update(visual_metrics(ink, mask))
return metrics
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--counts", nargs="+", type=int, default=[80, 800])
parser.add_argument("--canvas", type=int, default=2000)
parser.add_argument("--max-growth-rounds", type=int, default=1)
parser.add_argument("--assert-targets", action="store_true")
parser.add_argument("--output-dir", type=Path, default=BACKEND_DIR / "benchmark_outputs")
args = parser.parse_args()
args.output_dir.mkdir(parents=True, exist_ok=True)
config.WC_FONT_PATH = str(config.PROJECT_DEFAULT_FONT)
config.SIZE_RATIO = 1.0
config.N_REPETITIONS = 1
config.WORK_SCALE = 0.18
config.MIN_READABLE_HEIGHT_PX = 22
config.MIN_FONT_SIZE = max(1, int(config.MIN_READABLE_HEIGHT_PX * config.WORK_SCALE))
config.USER_MIN_FONT_SIZE = None
config.USER_MAX_FONT_SIZE = None
config.LAYOUT_SEED = 20260718
config.SEED = 20260718
config.TARGET_FILL_RATIO = 0.45
config.FONT_COLOR = "#102A43"
results = [
run_case(count, args.canvas, args.output_dir, args.max_growth_rounds)
for count in args.counts
]
if args.assert_targets:
failures = []
for item in results:
if item["completeness"] != 1.0:
failures.append(f'{item["count"]}: completeness={item["completeness"]:.4f}')
if not item["equal_weight_font_consistent"]:
failures.append(f'{item["count"]}: equal-weight font sizes differ')
if item["hd_overlap_pixels"] != 0:
failures.append(f'{item["count"]}: HD overlap pixels={item["hd_overlap_pixels"]}')
if item["contour_grid_coverage"] < 0.80:
failures.append(f'{item["count"]}: contour coverage below 0.80')
if item["hd_true_density"] < 0.10:
failures.append(f'{item["count"]}: HD true density below 0.10')
if item["count"] < 100 and item["total_seconds"] >= 1.0:
failures.append(f'{item["count"]}: total time >= 1.0s')
if item["count"] < 1000 and item["total_seconds"] >= 5.0:
failures.append(f'{item["count"]}: total time >= 5.0s')
if failures:
raise SystemExit("基准门禁失败: " + "; ".join(failures))
report = args.output_dir / "benchmark.json"
report.write_text(json.dumps(results, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(results, ensure_ascii=False, indent=2))
print(f"报告: {report}")
if __name__ == "__main__":
main()
+113 -70
View File
@@ -4,132 +4,175 @@
## 总流程
1. 读取 Excel 名单:`pipeline.main()`
2. 计算自动画幅:`mask.calculate_dynamic_dimensions()`
3. 生成并归一化掩膜:`mask.prepare_mask()`
4. 计算权重:`weights.extract_weights_from_df()``weights.get_stroke_complexity_batch()`
5. 估算字号范围:`weights.calculate_font_by_area_model()`
6. 小画布布局:`layout.OptimizedEfficientWordCloud.generate_from_frequencies()`
7. C++ 找可放位置:`IntegralGrid.query_direct()`
8. C++ 写入字形占用:`IntegralGrid.stamp_and_rebuild()`
9. 计算填充率并必要时重试放大
10. 将小画布 layout 放大到高清画布并输出 PNG/SVG/DB/metrics
```
1. 读取 Excel 名单 → pipeline.main()
2. 智能画幅计算 → mask.calculate_dynamic_dimensions()
3. 生成并归一化掩膜 → mask.prepare_mask()
4. 计算权重 → weights.extract_weights_from_df() + weights.get_stroke_complexity_batch()
5. 估算字号范围 → weights.calculate_font_by_area_model()
6. 小画布布局 → layout.OptimizedEfficientWordCloud.generate_from_frequencies()
7. C++ 按真实字形找位置并原子写入 → IntegralGrid.place_glyph_exact()
8. 整批未完整放入 → 统一缩放字号或扩大画布后重排
9. 密度优化 → 探测更大字号并保留完整率不下降的候选
10. 高清精修 → render.refine_layout_with_hd_clearance() 加入隔离带局部微调
11. 输出 PNG / SVG / DB / metrics
```
## 名单重复填充
## 名单重复填充
`N_REPETITIONS` 决定目标词数:
```text
```
total_target = len(names) * N_REPETITIONS
```
布局序列由 `_build_layout_sequence()` 生成。当前行为是按原名单循环追加
布局序列由 `_build_layout_sequence()` 生成。行为:**按原名单循环追加**,不是把同一个名字所有副本先放完。
```text
```
[A, B, C], N_REPETITIONS=4
=> [A, B, C, A, B, C, A, B, C, A, B, C]
[A, B, C, A, B, C, A, B, C, A, B, C]
```
所以当前队列顺序是“先填一轮名单,再填下一轮”,不是先放完同一个名字所有副本
需要注意:队列顺序公平不等于最终字号完全一致。放置阶段如果某个词以目标字号找不到位置,会单独降字号继续尝试。因此后几轮词语通常比前几轮小。
同名副本在同一批布局中使用相同目标字号。放置失败不会触发逐词缩字号
## 权重逻辑
### Excel 权重
`WEIGHT_COL_NAME` 优先于 `WEIGHT_COL_INDEX`。有效权重必须是可转数字且大于 0。
`REMOVE_DUPLICATES = True` 时,同名权重取最大值`REMOVE_DUPLICATES = False` 时,仍会按名字聚合权重映射,所以同名不同权重不会保留为不同权重实例。
- `WEIGHT_COL_NAME` 优先于 `WEIGHT_COL_INDEX`
- 有效权重必须是可转数字且大于 `0`
- `REMOVE_DUPLICATES=True` 时,同名权重取最大值
- `REMOVE_DUPLICATES=False` 时,仍按名字聚合成权重映射
### 笔画权重
`ENABLE_STROKE_WEIGHTS = True` 时,系统渲染每个字符到 64x64 灰度图,用像素占用量估算复杂度。一个名字的笔画权重取其中最复杂字符的值。
`ENABLE_STROKE_WEIGHTS = False` 时跳过笔画权重。若没有 Excel 权重,所有名字权重默认为 `10`
- `ENABLE_STROKE_WEIGHTS=True` 时,系统渲染每个字符到 `64×64` 灰度图,用像素占用量估算复杂度
- 一个名字的笔画权重取其中最复杂字符的值
- 若同时存在 Excel 权重:Excel 值作为基础权重,笔画复杂度除以全体中位数后作为乘数
- 这样手动权重比例仍保留,且 Excel 权重全为 `1` 时笔画开关也不会失效
- `ENABLE_STROKE_WEIGHTS=False` 且无 Excel 权重时,所有名字权重默认为 `10`
## 字号范围估算
`calculate_font_by_area_model()` 使用可填充面积、目标填充率、packing efficiency、重复次数和名字长度估算 `min_font` / `max_font`
`calculate_font_by_area_model()` 使用以下输入估算 `min_font` / `max_font`
- 可填充面积(掩膜中 `0` 的像素数)
- 目标填充率 `TARGET_FILL_RATIO`
- 打包效率 `PACKING_EFFICIENCY`
- 重复次数 `N_REPETITIONS`
- 名字长度、各名字权重
公式思想:
- 可填区域越大,字号越大
- 名字越多、重复次数越高,字号越小
- 字符越多,总占用质量越高,字号越小
- 字符越多,总字符质量越高,字号越小
- 权重越高,在 `log1p(weight)` 归一化后获得更高面积质量
最终 `max_font` 基于 `min_font * SIZE_RATIO` 计算
最终 `max_font = min_font × SIZE_RATIO`
## 字号打分
当前 `build_log_rank_scores(..., per_word=True)` 按姓名权重计算固定分数,然后映射到展开后的重复序列。
`build_log_rank_scores(..., per_word=True)` 按姓名权重计算固定分数,映射到展开后的重复序列。结果是:
这意味着:
- 同名副本的目标分数相同
- 同名副本的初始目标字号相同
- 同名副本目标分数相同
- 同名副本初始目标字号相同
- 权重相同时,所有姓名初始目标字号相同
但最终放置字号仍可能变小,因为放置失败时会逐词降字号。
`SIZE_RATIO=1` 时,`min_font == max_font`,所有同权重姓名在重试前后都保持完全相同字号。
## 等字号模式
`SIZE_RATIO=1` 时,面积估算阶段直接令 `min_font = max_font`。所有字号重试也只改变单一字号值。
等字号批次的特殊优化:
1. 视作整批同字号布局,使用整批字号打分(`per_word=False`
2. 密度优化阶段在完整候选中比较字符级最大空洞(`largest_empty_square_size`),并尝试不同布局种子降低空洞
3. 高清精修阶段允许优先级回溯(把失败词提前到队列最前)
## 放置策略
每个词的放置流程:
1. 根据目标分数得到目标字号
2. 随机决定横排或竖排
3. 用 PIL 测量文字包围盒
4. 调用 C++ `query_direct(query_h, query_w, seed)` 找位置
5. 如果找不到,字号减 2 后重试,最低到目标字号的 40% 或 `min_font_size`
6. 放置成功后,取真实字形 bitmap 并调用 `stamp_and_rebuild()`
7. 主循环放不下的词进入 gap filling,用更小字号再尝试一次
1. 根据目标分数得到目标字号(线性插值于 `min_font``max_font`
2. 随机决定横排或竖排`prefer_horizontal` 控制概率)
3. 用 PIL 渲染真实字形 bitmap,施加单侧安全边距(margin)生成碰撞 mask
4. 调用 C++ `place_glyph_exact()` 搜索合法位置并原子写入
5. 当前方向找不到时,只尝试同字号的另一方向
6. 任意姓名失败即视为本次整批布局不完整
7. **不逐词缩字号**。Python 统一缩放整批字号范围后重新布局
8. 触及字号下限仍失败时,按 `CANVAS_RETRY_GROWTH` 扩大画布并整批重排
9. 将完整布局映射到高清字号和坐标
10. 高清画布进行逐词隔离带精修,局部无合法位置时扩大画布并整批重排
## C++ 积分图搜索
## C++ 真实字形搜索
C++ `IntegralGrid` 维护两个核心结构:
`IntegralGrid` 维护两个核心结构:
- `canvas`:真实占用像素`1` 表示已占用或掩膜阻挡
- `data``canvas` 的积分图,用于 O(1) 判断矩形区域是否为空
- `canvas`:真实占用像素`1` 表示已占用或掩膜阻挡
- `data`兼容旧矩形查询的积分图;当前主路径不依赖逐词重建
`query_direct()` 行为:
`place_glyph_exact()` 行为:
1. 如果画布完全空,随机返回一个位置
2. 先随机探测最多 16 个位置
3. 如果未命中,扫描所有可能位置
4. 对每个候选位置用积分图判断包围盒是否为空
5. 从所有可放位置中随机选一个
1. 等字号批次前 `70%` 姓名优先选择靠近可填区域质心的合法位置,避免少量姓名接受第一个随机空位而形成大块空洞
2. 多字号批次只对前 `25%` 以及高权重(`score ≥ 0.80`)姓名启用中心偏好
3. 少于 `100` 人的等字号批次中心候选数提高到 `768``100300` 人提高到 `512`;其余保持 `256`
4. 随机探测失败后,从种子决定的偏移开始完整扫描
5. 对每个候选位置逐像素比较碰撞 mask 与 C++ `canvas`
6. 命中后只写入真实字形,占位查询和写入在同一次 C++ 调用中完成
`stamp_and_rebuild()` 的行为:
真实字形搜索允许透明角落和笔画间空隙安全交错,比外接矩形碰撞更密。安全边距只参与候选检查,不会被双侧累计放大。
1. 把真实字形像素写入 C++ `canvas`
2. 从字形左上角开始局部重建积分图
## 高清精修(隔离带)
当前碰撞检测是“矩形找位置 + 字形像素落图”。找位置阶段要求文字包围盒矩形完全空;实际占用阶段只写入字形像素
正式流水线不在低清工作网格增加边距,因为 `1` 个工作像素会被放大成约 `56` 个高清像素并显著损失容量
隔离带优先作用在最终高清画布,宽度为 `1px`,不是用户参数。精修流程:
1. 小画布完整布局放大到高清
2. 逐词在高清画布上验证,加入 `1px` 隔离带
3. 碰撞时只允许最大 `max_shift=24px` 的局部微位移
4. 精修失败时,最多进行 `1` 次确定性整批优先级回溯(把失败词移到队列最前)
5. 若轮廓过窄无法容纳额外隔离带,降为精确零间隙碰撞(`clearance=0`
6. 全部候选无解时,由外层扩大画布后整批重新布局,不进行远距离单词搬移
密度搜索会保留各档完整整批候选。最高密度候选若无法在有限位置修正范围内通过高清隔离验收,则改用上一档完整整批候选;不会对碰撞姓名单独缩字号,也不会接受带重叠的高密度结果。
## 填充率重试
一次布局完成后,`compute_fill_ratio_fast()` 重新渲染 layout 并计算填充率。
如果填充率低于 `MIN_ACCEPT_FILL_RATIO`,管线会尝试二分放大 `size_scale`,并可通过 `FILL_RETRY_RELAX_LARGE_CAP` 放宽大字号限制。
- 面积模型首次完整放入但明显低于 `TARGET_FILL_RATIO` 时,只允许一次整批等比例增字号尝试。新布局必须仍然完整且真实填充率更高才会采用
- 整批未完整放入时,流水线不会输出半成品:先整批等比例调整字号;触及最小字号仍失败时按 `CANVAS_RETRY_GROWTH` 扩大画布并重新生成
如果仍无法达到目标,会保留填充率最好的 layout。
## 字号硬约束
## 已知算法限制
- 不存在逐词缩字号、gap filling 或大字号自动压缩路径
- `USER_MIN_FONT_SIZE``USER_MAX_FONT_SIZE` 是不可越过的边界;冲突时直接报错
- `SIZE_RATIO=1` 在面积估算、整批重试和高清放大阶段始终保持单一字号
- 完整名单不可关闭;放不下时只能整批重排、整批等比例调整或扩大画布
- 当前没有真正的“整轮统一降字号”机制。重复填充虽然按轮展开,但每个词可以独立降字号。
- `LIMIT_LARGE_FONTS` 是全局计数,不区分姓名和轮次。
- `LAYOUT_ORDER_MODE_INTERLEAVED_RANDOM` 只改变展开序列的顺序,不改变 C++ 的空间采样策略。
- `ENABLE_STRATIFIED_SAMPLING` 调用的 `reorder_stratified()``query_direct()` 主路径无效。
- C++ `batch_query()` 会用矩形 `update_rect_add()` 更新,不走真实字形 `stamp_and_rebuild()`;当前 Python 主路径没有使用它。
## 空洞优化(等字号模式)
## 后续公平重复填充建议
等字号模式下,字符级最大空洞(`largest_empty_square_size`)用于判断是否存在"可放字符级空洞":
如果目标是“每一轮名单整体公平变小”,建议新增独立模式,而不是继续微调当前逐词降字号:
- 当前 `largest_empty_square ≥ ceil(font_size * 1.25)` 时视为存在字符级空洞
- 流水线会在同一字号档位尝试最多 `3` 种不同布局种子(按人数递减预算)
- 目标是最小化最大空洞尺寸,不改变任何字号
- `REPEAT_FILL_MODE = "ROUND_ROBIN_FAIR"`
- 以轮为单位生成任务
- 同一轮使用统一字号或统一权重映射
- 某一轮放不下时,整轮降低字号重试
- 失败词统一进入下一档补位队列
- 大字号限制按姓名或轮次计数
## 自动画幅
`calculate_dynamic_dimensions()` 先对 `BASE_HD_WIDTH × BASE_HD_HEIGHT` 做一次 probe 掩膜,计算可填比例 `free_ratio`,再用以下公式扩展:
```
area_per_word = (MIN_READABLE_HEIGHT_PX²) × avg_len × 1.05
required_area = num_words × area_per_word × N_REPETITIONS / TARGET_FILL_RATIO
required_area /= free_ratio
scale_factor = √(required_area / current_area)
new_w = clamp(base_w × scale_factor, max_edge=6000)
```
- 扩展后宽高向上取整到最近的 `100`
- 最大边长限制为 `6000px`,避免 SVG/PNG 过度膨胀
- 掩膜只生成一次,扩展后复用
+55 -22
View File
@@ -7,13 +7,14 @@
- 默认后端地址:`http://localhost:8000`
- 请求体中上传文件使用 `multipart/form-data`
- `params` 字段是 JSON 字符串,顶层必须是对象
- 任务状态存在内存中,服务重启后状态会丢失
- 任务状态存在内存中`JobManager`),服务重启后状态会丢失;文件仍保留在 `service_workspace`
- CORS 已开启,允许所有来源
## Jobs
### GET `/api/health`
返回:
健康检查。
```json
{"ok": true}
@@ -30,15 +31,13 @@
Form 字段:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
|------|------|------|
| `name_list` | 是 | `.xlsx` 名单文件 |
| `mask_image` | IMAGE 模式必填 | `.png` / `.jpg` / `.jpeg` 掩膜 |
| `font_file` | 否 | 临时上传字体,支持后端 `_FONT_EXTENSIONS` 中的格式 |
| `font_file` | 否 | 临时上传字体`.ttf` / `.ttc` / `.otf` |
| `font_id` | 否 | 使用已上传字体 |
| `params` | 否 | JSON 字符串,合并到任务配置 |
字体格式当前支持 `.ttf``.ttc``.otf`
`params` 示例:
```json
@@ -129,10 +128,12 @@ SSE 事件流。事件数据模型:
### GET `/api/jobs/{job_id}/locations`
查询词语位置。查询参数:
查询词语位置。
查询参数:
| 参数 | 说明 |
| --- | --- |
|------|------|
| `name` | 可选;为空返回全部,非空精确匹配 |
返回:
@@ -173,7 +174,7 @@ SSE 事件流。事件数据模型:
查询参数:
| 参数 | 默认 | 说明 |
| --- | --- | --- |
|------|------|------|
| `fill` | `fill` | `fill` / `dot` / `line` / `ring` |
| `stroke` | `0` | 是否描边 |
| `spacing` | `10` | 点阵间距 |
@@ -192,11 +193,11 @@ SSE 事件流。事件数据模型:
返回后端硬编码模板列表:
- `poster_1x2`
- `poster_4x5`
- `poster_1x1`
- `poster_3x4`
- `poster_16x9`
- `poster_1x2`(竖版手机海报,1080×2160
- `poster_4x5`(社交媒体图,1080×1350
- `poster_1x1`(方形封面,1080×1080
- `poster_3x4`(竖版广告,1080×1440
- `poster_16x9`(横版电商,1920×1080
## Assets
@@ -205,7 +206,7 @@ SSE 事件流。事件数据模型:
上传素材。Form 字段:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
|------|------|------|
| `file` | 是 | 素材文件 |
| `name` | 否 | 名称 |
| `type` | 否 | 默认 `upload` |
@@ -215,7 +216,7 @@ SSE 事件流。事件数据模型:
从任务产物导入素材。Form 字段:
| 字段 | 默认 | 说明 |
| --- | --- | --- |
|------|------|------|
| `kind` | `png` | 产物类型 |
| `name` | 空 | 素材名称 |
| `type` | `wordcloud` | 素材类型 |
@@ -243,10 +244,10 @@ SSE 事件流。事件数据模型:
### POST `/api/projects`
Form 字段:
创建工程。Form 字段:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
|------|------|------|
| `name` | 是 | 工程名 |
| `template_id` | 是 | 模板 ID |
| `background_color` | 是 | `#RRGGBB` |
@@ -272,29 +273,61 @@ Form 字段:
### GET `/api/fonts`
返回字体列表,包含默认字体项。
返回字体列表,包含默认字体项`__default__`
### POST `/api/fonts`
上传字体。Form 字段:
| 字段 | 必填 | 说明 |
| --- | --- | --- |
| `file` | 是 | 字体文件 |
|------|------|------|
| `file` | 是 | 字体文件`.ttf` / `.ttc` / `.otf` |
| `name` | 否 | 字体名 |
### DELETE `/api/fonts/{font_id}`
删除已上传字体。默认字体不能删除。
## Line Spacing Analysis
### POST `/api/jobs/{job_id}/analyze-line-spacing`
分析 SVG 词云路径的线距,返回 `LineSpacingAnalysisSummary`
请求体(JSON):
```json
{
"percentile": 3,
"elementWidth": 100.0,
"elementHeight": 100.0,
"sampleStep": 2.0
}
```
响应字段:
| 字段 | 说明 |
|------|------|
| `percentile` | 线距百分位 |
| `spacingPx` | 采样线距(像素) |
| `spacingMm` | 采样线距(毫米) |
| `minSpacingPx` | 最小线距(像素) |
| `minSpacingMm` | 最小线距(毫米) |
| `curveCount` | 曲线数量 |
| `segmentCount` | 线段数量 |
| `sourceWidth` / `sourceHeight` | SVG 原始尺寸 |
| `elementWidth` / `elementHeight` | 目标元素尺寸 |
## 常见错误
| 场景 | 状态码 | detail |
| --- | --- | --- |
|------|--------|--------|
| `params` 不是合法 JSON | 400 | `params must be valid JSON` |
| `params` 不是对象 | 400 | `params must be JSON object` |
| `name_list``.xlsx` | 400 | `name_list must be xlsx` |
| IMAGE 模式缺少掩膜 | 400 | `mask_image is required when MODE=IMAGE` |
| 掩膜文件不存在 | 400 | `mask_image must be png/jpg/jpeg` |
| job 不存在 | 404 | `job not found` |
| 产物未就绪 | 404 | `artifact not ready` |
| 文件类型未知 | 404 | `unknown artifact kind` |
+72 -37
View File
@@ -1,23 +1,30 @@
# 画布与贴纸功能
本文档描述当前前端代码中已实现的画布设计功能。事实来源是 `frontend/src/App.tsx``frontend/src/pages/CanvasStudio.tsx``frontend/src/components/ExportPanel.tsx``frontend/src/lib/stickerLibrary.ts``frontend/src/types.ts`
本文档描述当前前端代码中已实现的画布设计功能。事实来源是 `frontend/src/pages/CanvasStudio.tsx``frontend/src/App.tsx``frontend/src/components/ExportPanel.tsx``frontend/src/lib/stickerLibrary.ts``frontend/src/lib/canvasDocument.ts``frontend/src/types.ts`
## 页面关系
- 应用默认进入画布设计页。
- 画布页顶部的“添加词云”会切换到原有词云生成页。
- 词云生成页顶部的“返回画布”会回到画布设计页。
- 词云生成页的导出面板保留下载 SVG/位图功能,并新增“作为贴纸导入贴纸库”。
```
TemplateHome(首页)
├── CanvasStudio(画布设计页)─ 添加词云 ─→ TestWorkbench(词云生成页)
├── TestWorkbench(词云生成页)─ 返回画布 ─→ CanvasStudio
└── HelpPage(帮助页)
```
- 应用默认进入首页,展示模板列表
- 画布页顶部的"添加词云"会切换到词云生成页
- 词云生成页顶部的"返回画布"会回到画布设计页
- 词云生成页导出面板保留下载 SVG/位图功能,并新增"作为贴纸导入贴纸库"
## 贴纸库
贴纸库是前端本地能力,不依赖后端接口:
- 存储位置:`localStorage``wordcloud-sticker-library`
- 数据类型:`StickerAsset`,当前支持 `svg``image` 两类,已实现入口主要使用 `svg`
- 用户导入 SVG:画布页左侧贴纸面板读取 `.svg` 文件文本,写入贴纸库,并立即插入画布
- 词云作为贴纸:导出面板按当前 SVG 导出参数请求 `/api/jobs/{job_id}/custom.svg`,读取返回的 SVG 文本后写入贴纸库
- 贴纸删除只删除本地贴纸库记录,不会删除已经导出的总图文件
- 存储位置:`localStorage``wordcloud-sticker-library`
- 数据类型:`StickerAsset`,当前支持 `svg``image` 两类,已实现入口主要使用 `svg`
- 用户导入 SVG:画布页左侧"贴纸"面板读取 `.svg` 文件文本,写入贴纸库,并立即插入画布
- 词云作为贴纸:导出面板按当前 SVG 导出参数请求 `/api/jobs/{job_id}/custom.svg`,读取返回的 SVG 文本后写入贴纸库
- 贴纸删除只删除本地贴纸库记录,不会删除已经导出的总图文件
## 画布模型
@@ -25,43 +32,71 @@
当前模型字段:
- `width`:画布宽度,默认 `1600`
- `height`:画布高度,默认 `1000`
- `background`:画布背景色,默认 `#ffffff`
- `elements`:画布元素数组
- `width`:画布宽度,默认 `1600`
- `height`:画布高度,默认 `1000`
- `background`:画布背景色,默认 `#ffffff`,支持透明 `#00000000`
- `elements`:画布元素数组
- `layers`:图层数组(可选,支持可见性、锁定、文件夹分组)
- `layerFolders`:图层文件夹数组(可选)
当前元素类型:
- `sticker`:引用贴纸库中的 SVG 或图片
- `text`:普通文字,支持内容、颜色、字号、字体、字重、位置、尺寸、旋转、透明度
- `rect`:矩形,支持填充、描边、描边宽度、位置、尺寸、旋转、透明度
- `ellipse`:椭圆,支持填充、描边、描边宽度、位置、尺寸、旋转、透明度。
- `line`:线条,支持描边、描边宽度、位置、尺寸、旋转、透明度
- `sticker`:引用贴纸库中的 SVG 或图片`assetId`
- `text`:普通文字内容、颜色、字号、字体、字重、位置、尺寸、旋转、透明度
- `rect`:矩形填充、描边、描边宽度、位置、尺寸、旋转、透明度
- `ellipse`:椭圆(同上)
- `line`:线条描边、描边宽度、位置、尺寸、旋转、透明度
## 编辑行为
- 点击贴纸库中的贴纸会把该贴纸插入画布中央区域
- 画布元素可拖拽移动。
- 选中元素后可通过右下角手柄调整大小。
- 右侧属性面板可以精确编辑位置、尺寸、旋转、透明度和元素特有属性。
- 右侧属性面板提供上移、下移和删除。
- 画布面板支持修改画布宽高、背景色、导出 SVG、清空画布。
- 点击贴纸库中的贴纸会把该贴纸插入画布中央区域
- 画布元素支持:
- **拖拽移动**:鼠标/触摸拖拽
- **大小调整**:选中后通过右下角手柄调整
- **精确编辑**:右侧面板可修改位置、尺寸、旋转、透明度、元素特有属性
- **层级调整**:上移、下移
- **删除**:删除元素
- 右侧面板支持修改画布宽高、背景色
- 支持导出总图 SVG、清空画布
- **图层管理**:支持图层可见性、锁定、文件夹分组
- **吸附对齐**:元素拖拽时自动吸附到附近元素边缘
- **缩放**:编辑视图可缩放(不影响导出尺寸)
## SVG 导出
导出总图 SVG由前端序列化当前画布模型完成:
"导出总图 SVG"由前端序列化当前画布模型完成:
- 导出文件名:`canvas-design.svg`
- 背景输出为一个覆盖全画布的 `<rect>`
- 贴纸输出为 `<image>`SVG 贴纸会以内联 `data:image/svg+xml` 的形式嵌入
- 文字输出为 `<text>`
- 基础形状输出为原生 SVG 的 `<rect>``<ellipse>``<line>`
- 元素的位移和旋转写入 SVG `transform`,透明度写入 `opacity`
- 导出文件名:`canvas-design.svg`
- 背景输出为一个覆盖全画布的 `<rect>`(透明背景时 fill="none"
- 贴纸输出为 `<image>`SVG 贴纸会以内联 `data:image/svg+xml` 的形式嵌入
- 文字输出为 `<text>`
- 基础形状输出为原生 SVG 的 `<rect>``<ellipse>``<line>`
- 元素的位移和旋转写入 SVG `transform`,透明度写入 `opacity`
## ZIP 导出
支持导出含以下内容的 ZIP 包:
- `canvas.svg`:总图 SVG
- `sticker_{id}.svg`:画布中所有独立 SVG 贴纸
- `manifest.json`:元素元数据清单
## 同底图换名单(Replace Session
画布页支持"替换词云名单"功能:
1. 用户右键点击画布上的词云贴纸,选择"同底图换名单"
2. 生成一个 `WordcloudReplaceSession`,包含:
- `mask`:原词云的底图遮罩(SVG/Image)
- `target`:目标元素信息(含宽度、高度、原遮罩元素 ID)
3. 会话传递到词云生成页
4. 词云生成页锁定底图,只替换名单内容,生成后自动按原尺寸贴回画布
## 当前边界
- 贴纸库和画布文档只保存在当前浏览器本地,不会跨浏览器或跨设备同步
- 当前没有服务端素材库、项目文件格式或协作编辑接口
- SVG 导入按用户信任文件处理;编辑器预览使用图片方式加载,不在页面中直接执行 SVG 内容
- 当前缩放只影响编辑视图,不改变导出尺寸
- 当前导出目标是 SVG;没有在画布页实现 PNG/JPG 总图导出
- 贴纸库和画布文档只保存在当前浏览器 `localStorage`,不会跨浏览器或跨设备同步
- 当前没有服务端素材库、项目文件格式或协作编辑接口(Projects 接口存在但服务层级较浅)
- SVG 导入按用户信任文件处理;编辑器预览使用图片方式加载,不在页面中直接执行 SVG 内容
- 当前缩放只影响编辑视图,不改变导出尺寸
- 当前导出目标是 SVG;没有在画布页实现 PNG/JPG 总图导出
- 线距分析结果显示在元素属性面板中,辅助激光加工参数设定
+94 -32
View File
@@ -12,9 +12,9 @@ CLI 入口 `backend/wordcloud_generate_hybrid.py` 的顺序:
4. 调用 `finalize_runtime_config()` 派生路径、字体、输出路径
5. 调用 `set_random_seed()`
服务模式下,`POST /api/jobs` 会生成任务配置写入:
服务模式下,`POST /api/jobs` 收到的 `params` JSON 会由服务层合并到任务配置写入:
```text
```
backend/service_workspace/{job_id}/config.json
```
@@ -25,48 +25,110 @@ backend/service_workspace/{job_id}/config.json
当前前端 `TestWorkbench.tsx` 提交的关键字段:
| 前端字段 | 后端配置 |
| --- | --- |
| `dataColIndex` | `DATA_COL_INDEX` |
|----------|----------|
| `seed` | `SEED` |
| `dataColIndex` | `DATA_COL_INDEX` |
| `weightColIndex` | `WEIGHT_COL_INDEX` |
| `fontColor` | `FONT_COLOR` |
| `nRepetitions` | `N_REPETITIONS` |
| `strokeWeights=false` | `ENABLE_STROKE_WEIGHTS=false` |
| `strokeWeights` | `ENABLE_STROKE_WEIGHTS` |
| `sizeRatio` | `SIZE_RATIO` |
| `packingEfficiency` | `PACKING_EFFICIENCY` |
| `targetFillRatio` | `TARGET_FILL_RATIO` |
| `userMinFontSize` | `USER_MIN_FONT_SIZE` |
| `userMaxFontSize` | `USER_MAX_FONT_SIZE` |
| `minReadableHeightPx` | `MIN_READABLE_HEIGHT_PX` |
| `workScale` | `WORK_SCALE` |
| `fillOn` | `FILL_ON` |
| `canvasRetryMaxRounds` | `CANVAS_RETRY_MAX_ROUNDS` |
| `canvasRetryGrowth` | `CANVAS_RETRY_GROWTH` |
| `layoutSeed` | `LAYOUT_SEED` |
前端只在重复次数大于 1 时传 `N_REPETITIONS`,只在关闭笔画权重时传 `ENABLE_STROKE_WEIGHTS=false`
"名单完整性"不是可关闭参数,始终是硬约束
## 常用配置
## 完整配置清单
### 掩膜与画布
| 键 | 默认值 | 说明 |
| --- | --- | --- |
| `MODE` | `IMAGE` | 掩膜模式,`IMAGE``TEXT` |
| `MASK_IMAGE_PATH` | `7887.png` | IMAGE 模式掩膜路径服务模式会覆盖为上传文件路径 |
| `IMAGE_CANVAS_MODE` | `WIDTH` | 图片掩膜缩放模式 |
| `FILL_ON` | `BLACK` | `BLACK` 表示黑色可填,`WHITE` 表示白色可填 |
| `EXCEL_PATH` | `四个方向汇总录取名单.xlsx` | Excel 路径,服务模式会覆盖为上传文件路径 |
|----|--------|------|
| `MODE` | `IMAGE` | `IMAGE``TEXT` |
| `MASK_IMAGE_PATH` | `7887.png` | IMAGE 模式掩膜路径服务模式会上传文件路径覆盖 |
| `IMAGE_CANVAS_MODE` | `WIDTH` | 图片掩膜缩放模式`WIDTH` / `HEIGHT` / `AUTO` |
| `FILL_ON` | `BLACK` | `BLACK` = 黑色可填,`WHITE` = 白色可填 |
| `FILL_CORNERS` | `False` | 是否自动填充四角区域 |
| `CORNER_FILL_RATIO` | `0.15` | 四角填充面积占画布比例 |
| `BASE_HD_WIDTH` | `4000` | 默认高清画布宽 |
| `BASE_HD_HEIGHT` | `4000` | 默认高清画布高 |
| `MIN_READABLE_HEIGHT_PX` | `22` | 最小可读高度(像素) |
| `WORK_SCALE` | `0.18` | 高清画布到运算网格的缩放比例 |
| `CANVAS_RETRY_MAX_ROUNDS` | `1` | 画布扩大重试轮数 |
| `CANVAS_RETRY_GROWTH` | `1.12` | 完整名单放不下时的整画布边长增长比例 |
### 文本掩膜(TEXT 模式)
| 键 | 默认值 | 说明 |
|----|--------|------|
| `MASK_TEXT` | `A` | 用作掩膜的文本 |
| `MASK_FONT_PATH` | 项目默认字体 | 掩膜字体 |
| `MASK_FONT_SIZE` | `3000` | 掩膜文字字号 |
### 数据与权重
| 键 | 默认值 | 说明 |
|----|--------|------|
| `EXCEL_PATH` | `四个方向汇总录取名单.xlsx` | Excel 路径;服务模式被上传文件覆盖 |
| `DATA_COL_INDEX` | `1` | 名单列,0-based |
| `WEIGHT_COL_INDEX` | `None` | 权重列,0-based |
| `WEIGHT_COL_NAME` | `None` | 权重列名,优先于列索引 |
| `REMOVE_DUPLICATES` | `False` | 是否对名单去重 |
| `ENABLE_STROKE_WEIGHTS` | `True` | 是否在无 Excel 权重时使用笔画复杂度权重 |
| `ENABLE_STROKE_WEIGHTS` | `True` | 是否使用笔画复杂度权重 |
### 填充策略
| 键 | 默认值 | 说明 |
|----|--------|------|
| `N_REPETITIONS` | `1` | 名单重复倍率 |
| `SIZE_RATIO` | `2.0` | `max_font` 相对 `min_font` 的比例 |
| `PACKING_EFFICIENCY` | `0.85` | 面积模型中的打包效率 |
| `MIN_ACCEPT_FILL_RATIO` | `0.75` | 填充率重试阈值 |
| `REQUIRE_ALL_WORDS` | `True` | 搜索阶段是否优先要求达到目标词数 |
| `TARGET_FILL_RATIO` | `0.45` | 面积模型目标笔画填充率 |
| `SIZE_RATIO` | `2.0` | `max_font` 相对 `min_font` 的比例;`1.0` 为等字号模式 |
| `PACKING_EFFICIENCY` | `0.9` | 面积模型中的打包效率 |
### 字号硬约束
| 键 | 默认值 | 说明 |
|----|--------|------|
| `USER_MIN_FONT_SIZE` | `None` | 用户覆盖最小字号 |
| `USER_MAX_FONT_SIZE` | `None` | 用户覆盖最大字号 |
| `FONT_SCALE_MIN` | `0.5` | 二分搜索缩放下限 |
| `FONT_SCALE_MAX` | `1.2` | 二分搜索缩放上限 |
| `LIMIT_LARGE_FONTS` | `True` | 是否限制大字号数量 |
| `LARGE_FONT_LIMIT_RATIO` | `0.2` | 大字号数量上限占比 |
| `LARGE_FONT_THRESHOLD_RATIO` | `0.8` | 超过有效最大字号该比例视为大字号 |
| `LARGE_FONT_CAP_RATIO` | `0.6` | 超过大字号限制后的降级比例 |
| `ENABLE_DOT_MATRIX` | `False` | 是否用点阵补偿空白区域 |
| `CANVAS_RETRY_MAX_ROUNDS` | `1` | 画布扩大重试轮数 |
| `MIN_FONT_FLOOR` | `2` | 绝对字号下限 |
### 字体与配色
| 键 | 默认值 | 说明 |
|----|--------|------|
| `WC_FONT_PATH` | 项目默认字体 | 布局字体 |
| `FONT_FALLBACK_PATHS` | 系统字体列表 | 字体回退路径 |
| `FONT_COLOR` | `#000000` | 固定字体颜色;为空时使用调色板 |
| `DARK_COLOR_PALETTE` | 5 色深色 | `FILL_ON=WHITE` 时使用 |
| `LIGHT_COLOR_PALETTE` | 5 色浅色 | `FILL_ON=BLACK` 时使用 |
### 输出
| 键 | 默认值 | 说明 |
|----|--------|------|
| `OUTPUT_DIR` | `.` | 输出目录 |
| `OUTPUT_PREFIX` | `""` | 输出文件前缀 |
| `OUTPUT_PNG` | `Efficient_Result_HD_AutoResize.png` | PNG 文件名 |
| `OUTPUT_SVG` | `Efficient_Result_HD_AutoResize.svg` | SVG 文件名 |
| `DB_PATH` | `wordcloud_hd.db` | SQLite 数据库文件名 |
| `METRICS_FILE` | `metrics.json` | 指标文件名 |
| `SAVE_DEBUG_IMAGES` | `False` | 是否保存调试图 |
| `DEBUG_OUTPUT_DIR` | `output` | 调试文件目录 |
### 可复现性
| 键 | 默认值 | 说明 |
|----|--------|------|
| `SEED` | `None` | 随机种子 |
| `LAYOUT_ORDER_MODE` | `SORTED` | 展开序列排序模式 |
| `LAYOUT_SEED` | `None` | 布局顺序种子,默认继承 `SEED` |
## JSON 别名
@@ -74,9 +136,8 @@ backend/service_workspace/{job_id}/config.json
`apply_json_config()` 支持部分小写别名:
| 别名 | 正式配置 |
| --- | --- |
|------|----------|
| `seed` | `SEED` |
| `layout_order_mode` | `LAYOUT_ORDER_MODE` |
| `layout_seed` | `LAYOUT_SEED` |
| `excel_path` | `EXCEL_PATH` |
| `mask_image_path` | `MASK_IMAGE_PATH` |
@@ -99,6 +160,7 @@ CLI 覆盖只支持 `parse_args()` 中定义的参数,不支持 `font_color`
## 路径规则
相对路径`backend` 目录作为基准解析。输出路径会在 `finalize_runtime_config()` 中创建。
字体先尝试项目字体,再尝试 `FONT_FALLBACK_PATHS`。字体不可用会直接失败。
- 相对路径以 `backend` 目录作为基准解析
- 输出路径会在 `finalize_runtime_config()` 中自动创建目录
- 字体先尝试项目字体,再尝试 `FONT_FALLBACK_PATHS`
- 字体不可用会直接失败并退出
+132
View File
@@ -0,0 +1,132 @@
# 部署说明
本文档覆盖本地开发、Docker 和 Ubuntu 服务器部署。
## 本地开发
### 系统要求
- Python 3.9+
- Node.js 16+ + npm
- C++ 编译器(macOS: Xcode CLI ToolsLinux: `build-essential`
### 一键前后端联调
```bash
./start-all.sh
```
- 后端:http://localhost:8000
- 前端:http://localhost:3000
- 后端端口可在环境变量 `BACKEND_PORT` 中覆盖(默认 `8000`
### 单独启动后端
```bash
cd backend
./start-dev.sh
```
脚本行为:
1. 探测 Python 3.9+(优先系统 Python,否则创建 `.venv`
2. 检查依赖:`fastapi uvicorn python-multipart pydantic pandas openpyxl pillow numpy matplotlib`
3. 在外部 Python 中复用 `scipy`ABI 匹配时),避免网络安装
4. C++ 扩展 `ewc_core` 源码有更新时自动重新编译
5. 确保运行时目录:`service_workspace``service_assets``service_projects`
6. 自动释放被占用的端口
7.`uvicorn --reload` 启动 FastAPI 服务
### 单独启动前端
```bash
cd frontend
npm install
npm run dev
```
前端通过 Vite 代理访问后端,配置见 `frontend/vite.config.ts`
## Docker 部署
项目根目录包含 `Dockerfile``docker-compose.yml`
### 构建并启动
```bash
docker compose up -d --build
```
- 前端:http://localhost:3000
- 后端:http://localhost:8000
- 后端文档:http://localhost:8000/docs
### 常用命令
```bash
docker compose logs -f # 查看日志
docker compose down # 停止服务
docker compose restart # 重启服务
```
## Ubuntu 服务器部署
使用 `install-ubuntu.sh` 一键部署:
```bash
chmod +x install-ubuntu.sh
./install-ubuntu.sh
```
脚本行为:
1. 检查并安装 DockerUbuntu/Debian 自动安装)
2. 确保 `docker compose` 可用
3. 调用 `docker compose up -d --build`
4. 打印访问地址
可选环境变量:
| 变量 | 说明 |
|------|------|
| `SKIP_DOCKER_INSTALL=1` | 跳过 Docker 安装检测 |
| `NO_BUILD=1` | 不强制 `--build`(沿用已有镜像) |
首次部署会编译 C++ 扩展和前端,可能需要几分钟。
### release 包
`scripts/pack-release.sh` 用于打包 release 包,包含:
- 预编译前端(无 Node 环境也能运行)
- Docker 部署脚本
- 简化版 `install-ubuntu.sh`
## 产物目录
运行时产生的数据和文件保存在以下目录(已加入 `.gitignore`):
| 目录 | 用途 |
|------|------|
| `backend/service_workspace/` | 任务产物(输入/输出/配置) |
| `backend/service_assets/` | 后端素材库 |
| `backend/service_projects/` | 后端工程项目 |
| `backend/service_design_templates/` | 设计模板 |
| `backend/service_fonts/` | 上传字体 |
| `backend/.runtime/` | 运行时日志 |
| `backend/benchmark_outputs/` | 基准测试结果 |
## 环境变量
| 变量 | 影响范围 | 说明 |
|------|----------|------|
| `BACKEND_PORT` | 本地开发 | 后端监听端口,默认 `8000` |
| `SKIP_DOCKER_INSTALL` | Ubuntu 部署 | 跳过 Docker 自动安装 |
| `NO_BUILD` | Ubuntu 部署 | 不强制 Docker 重建 |
| `WORDCLOUD_SCIPY_SITE` | 本地开发 | 外部 SciPy 路径加速启动 |
## 注意事项
- 重启后端服务会导致内存中的任务状态丢失,但 `service_workspace` 中的文件产物不受影响
- 首次启动时 C++ 扩展编译需要系统编译器;如果失败请检查 `build-essential` 或 Xcode CLI Tools
- 前端 `localStorage` 中的贴纸库和画布文档不会自动同步到服务器
+143 -79
View File
@@ -1,45 +1,115 @@
# 项目标准说明
本文档按当前代码整理,覆盖项目边界、运行方式、输入输出和维护约定。最后核对代码时间:2026-06-09
本文档覆盖项目边界、目录结构、运行方式、输入输出和维护约定。核对代码时间:2026-07-25
## 项目目标
本项目生成基于名单和掩膜的词云图。后端负责读取 Excel 名单、处理掩膜、计算权重、布局、渲染和导出;前端提供参数面板、任务提交、结果查看、查找和导出入口。
本项目是一套中文词云生成+画布设计系统,核心能力:
当前项目不是通用设计平台。`Projects``Assets``Templates` 接口存在,但主要服务于当前工作台原型和素材管理,不代表完整生产级工程系统
1. **词云生成**:读取 Excel 名单,按掩膜轮廓填充人名,输出 PNG/SVG/DB/metrics
2. **画布设计**:前端提供图层化画布、贴纸库、基础形状编辑、SVG 总图导出。
3. **同底图换名单**:画布中已有的词云底图,支持只换名单不换位置和字号(保留视觉结构)。
4. **线距分析**:对 SVG 路径进行线距采样,辅助激光加工参数设定。
## 目录结构
| 路径 | 职责 |
| --- | --- |
| `backend/wordcloud_generate_hybrid.py` | CLI 入口,加载配置后调用生成管线 |
| `backend/core/config.py` | 默认配置、JSON 配置合并、CLI 覆盖、路径和字体解析 |
| `backend/core/pipeline.py` | 生成主流程:读数据、画布、掩膜、权重、布局、渲染、DB、metrics |
| `backend/core/layout.py` | Python 布局调度、字号打分、逐词放置、SVG 导出 |
| `backend/core/weights.py` | 笔画复杂度权重、Excel 权重、面积字号模型 |
| `backend/core/mask.py` | 掩膜归一化、自动画布、边界安全 padding |
| `backend/core/render.py` | 填充率计算和点阵补偿 |
| `backend/EfficientWordCloud/` | C++ 扩展及其 Python 包装 |
| `backend/service/` | FastAPI 服务、任务管理、文件存储 |
| `frontend/src/` | React 工作台 |
| `docs/` | 标准文档入口 |
```
wordcloud/
├── docs/ # 标准文档
│ ├── README.md
│ ├── PROJECT_STANDARD.md
│ ├── ALGORITHM.md
│ ├── CONFIG.md
│ ├── API.md
│ ├── CANVAS_STUDIO.md
│ ├── TESTING.md
│ └── DEPLOYMENT.md
├── backend/
│ ├── core/ # 词云核心引擎
│ │ ├── config.py # 配置默认值、别名、类型校验
│ │ ├── pipeline.py # 主流程:数据→掩膜→权重→布局→渲染→导出
│ │ ├── layout.py # Python 布局调度、SVG 路径导出
│ │ ├── weights.py # 笔画权重、Excel 权重、面积字号模型
│ │ ├── mask.py # 掩膜生成、归一化、自动画幅
│ │ ├── render.py # 高清精修、填充率计算、重叠检测
│ │ ├── fonts.py # 字体缓存
│ │ └── ewc.py # 兼容层(基类+Python 稀疏网格)
│ ├── EfficientWordCloud/ # C++ 扩展(integral grid + 精确字形碰撞)
│ │ ├── efficient_wordcloud/
│ │ │ ├── wordcloud.py # Python 包装
│ │ │ └── src/ewc_core.cpp # Cython 扩展
│ │ └── setup.py # C++ 编译入口
│ ├── service/ # FastAPI 服务
│ │ ├── app.py # HTTP 路由
│ │ ├── schemas.py # Pydantic 模型
│ │ ├── runner.py # 子进程任务执行
│ │ ├── job_manager.py # 内存任务状态管理
│ │ ├── storage.py # 任务文件目录管理
│ │ ├── line_spacing.py # SVG 线距分析
│ │ └── log_config.py # 服务日志配置
│ ├── tests/ # 单元测试
│ │ └── test_layout_constraints.py
│ ├── tools/ # 基准工具
│ │ └── benchmark_layout.py
│ ├── service_workspace/ # 运行时任务产物(.gitignore
│ ├── service_assets/ # 后端素材库(.gitignore
│ ├── service_projects/ # 后端工程项目(.gitignore
│ ├── service_design_templates/ # 设计模板(.gitignore
│ └── start-dev.sh # 本地后端启动脚本
├── frontend/
│ ├── src/
│ │ ├── App.tsx # 页面路由:home → canvas / wordcloud / help
│ │ ├── main.tsx
│ │ ├── types.ts # 全项目 TypeScript 类型
│ │ ├── styles.css
│ │ ├── pages/ # 页面级组件
│ │ │ ├── TemplateHome.tsx # 首页:模板选择
│ │ │ ├── CanvasStudio.tsx # 画布设计页
│ │ │ ├── TestWorkbench.tsx # 词云生成页
│ │ │ └── HelpPage.tsx # 帮助页
│ │ ├── components/ # 可复用组件
│ │ │ ├── AdvancedPanel.tsx
│ │ │ ├── CanvasArea.tsx
│ │ │ ├── DockTabBar.tsx
│ │ │ ├── EditPanel.tsx
│ │ │ ├── ExportPanel.tsx
│ │ │ ├── FindPanel.tsx
│ │ │ ├── FloatingPanel.tsx
│ │ │ ├── ImportPanel.tsx
│ │ │ ├── ProgressPanel.tsx
│ │ │ ├── ViewControls.tsx
│ │ │ └── AppSettingsWindow.tsx
│ │ ├── hooks/
│ │ ├── lib/
│ │ │ ├── api.ts # API 工具函数
│ │ │ ├── canvasDocument.ts # 画布模型操作
│ │ │ ├── stickerLibrary.ts # 贴纸库存取
│ │ │ ├── svgExport.ts # SVG/Zip 序列化
│ │ │ └── templateLibrary.ts # 模板库
│ │ └── vite-env.d.ts
│ ├── vite.config.ts
│ ├── package.json
│ └── tsconfig.json
├── start-all.sh # 一键前后端联调
├── install-ubuntu.sh # Ubuntu Docker 一键部署
├── release/
│ └── install-ubuntu.sh
├── scripts/
│ └── pack-release.sh
└── README.md / README_zh.md # 顶层面向用户说明(非标准)
```
## 运行方式
### 一键前后端联调
在项目根目录运行:
### 一键前后端联调(推荐开发用)
```bash
./start-all.sh
```
它会:
- 启动 `backend/start-dev.sh`
- 启动前端 `npm run dev`
- 默认后端端口为 `8000`
- 前端 Vite 端口为 `3000`
- 后端:http://localhost:8000
- 前端:http://localhost:3000
- 两者通过前端 Vite 代理通信(配置见 `frontend/vite.config.ts`
### 单独启动后端
@@ -48,7 +118,11 @@ cd backend
./start-dev.sh
```
`start-dev.sh` 会检查 Python 依赖、必要时创建 `.venv`,并在 C++ 源码更新后重新编译 `ewc_core`
脚本会自动:
- 探测 Python 3.9+(优先系统 Python,否则创建 `.venv`
- 检查并安装 `fastapi uvicorn python-multipart pydantic pandas openpyxl pillow numpy matplotlib`
- 在外部 Python 中复用 `scipy`ABI 匹配时),避免网络安装
- C++ 扩展 `ewc_core` 源码有更新时自动重新编译
### 单独启动前端
@@ -57,8 +131,6 @@ cd frontend
npm run dev
```
前端通过 Vite 代理访问后端。代理配置见 `frontend/vite.config.ts`
### CLI 生成
```bash
@@ -66,81 +138,73 @@ cd backend
python wordcloud_generate_hybrid.py --config /path/to/config.json
```
CLI 配置优先级
1. `backend/core/config.py` 默认值
2. JSON 配置文件
3. CLI 参数覆盖
CLI 配置优先级见 [CONFIG.md](CONFIG.md)。
## 输入要求
### Excel 名单
服务接口接受 `.xlsx`。默认名单列为 `DATA_COL_INDEX = 1`,也就是第 2 列,索引从 0 开始。
- 服务接口接受 `.xlsx`
- `DATA_COL_INDEX` 默认 `1`(第2列,0-based
- `REMOVE_DUPLICATES` 默认 `False`,前端也默认保留重复
- 权重列:优先 `WEIGHT_COL_NAME`,次选 `WEIGHT_COL_INDEX`
- 有效权重必须为正数
`REMOVE_DUPLICATES = False` 时,Excel 中重复姓名会保留。当前前端默认保留重复。
### 权重来源
### 权重
权重来源按优先级合并:
1. Excel 权重列:`WEIGHT_COL_NAME` 优先于 `WEIGHT_COL_INDEX`
2. 笔画复杂度权重:受 `ENABLE_STROKE_WEIGHTS` 控制
3. 默认权重:没有权重时使用 `10`
关闭 `ENABLE_STROKE_WEIGHTS` 且不传 Excel 权重列时,所有姓名进入均等权重。
| 场景 | 行为 |
|------|------|
| 有 Excel 权重,笔画权重开启 | Excel 值作为基础 × 笔画复杂度归一化乘数 |
| 有 Excel 权重,笔画权重关闭 | 仅 Excel 权重 |
| 无 Excel 权重,笔画权重开启 | 仅笔画复杂度权重 |
| 两者均无 | 全部默认权重 `10` |
### 掩膜
`MODE = IMAGE` 时必须提供 PNG/JPG/JPEG 掩膜。后端会将图片转灰度并以阈值 `200` 二值化。
`FILL_ON = BLACK` 时,黑色区域可填充;`FILL_ON = WHITE` 时,白色区域可填充
- `MODE=IMAGE` 时必须上传 PNG/JPG/JPEG 掩膜文件
- 后端转灰度后按阈值 `200` 二值化
- `FILL_ON=BLACK`黑色区域可填充;`FILL_ON=WHITE`白色区域可填充
- `MODE=TEXT`:用指定文字生成文本掩膜
## 输出产物
每次服务任务会创建
服务任务产物位置
```text
```
backend/service_workspace/{job_id}/
input/
mask.png
names.xlsx
output/
Efficient_Result_HD_AutoResize.png
Efficient_Result_HD_AutoResize.svg
Efficient_Result_HD_AutoResize_stroke.svg
wordcloud_hd.db
metrics.json
debug/
mask_src.png
mask_hd.png
mask_small.png
occ_fast.png
Efficient_Result_HD_AutoResize.png # 最终位图
Efficient_Result_HD_AutoResize.svg # 填充 SVG
Efficient_Result_HD_AutoResize_stroke.svg # 描边 SVG(激光雕刻适用)
wordcloud_hd.db # SQLiteword_locations 表
metrics.json # 运行指标
debug/ # 调试图(受 SAVE_DEBUG_IMAGES 控制)
config.json
```
产物说明:
| 文件 | 含义 |
| --- | --- |
| PNG | 最终位图结果 |
| SVG | 填充路径 SVG |
| `_stroke.svg` | 描边 SVG,适合继续加工 |
| SQLite DB | `word_locations` 表,记录词语位置、字号、颜色、方向、包围盒 |
| metrics | 运行指标、画布尺寸、填充率、配置快照 |
| debug | 调试图,受 `SAVE_DEBUG_IMAGES` 控制 |
## 当前限制
- 重复填充当前按名单轮次展开,但单个词在放置失败时会独立降字号;这会导致后几轮整体字号小于前几轮
- `reorder_stratified()` 当前对主路径 `query_direct()` 没有实际影响,因为 `query_direct()` 不使用 `valid_coords`
- C++ `Grid_query_direct` 的 GIL 释放包装没有包住实际扫描调用,性能并发上还有优化空间
- API 中的 Jobs 存储在进程内存,服务重启后历史任务状态会丢失;文件仍保留在 `service_workspace`
- 旧文档中的市场分析、路线图和性能宣传不作为当前能力承诺
- **名单完整性是不可关闭的硬约束。** 放不下的情况下只会整批缩放字号或扩大画布,不会漏词
- 不存在逐词缩字号、gap filling 或大字号自动压缩路径
- `USER_MIN_FONT_SIZE``USER_MAX_FONT_SIZE` 是不可越过的边界;冲突时直接报错
- `SIZE_RATIO=1` 时所有同权重词语保持完全相同字号,面积估算和重试阶段都维持单一字号
- C++ 中保留旧矩形查询 API 供底层兼容性,正式流水线使用 `place_glyph_exact()` 真实字形主路径
- 前端贴纸库和画布文档只保存在当前浏览器 `localStorage`,不跨设备同步。
- Jobs 状态存在内存中,服务重启后历史任务状态丢失;文件仍保留在 `service_workspace`
- 当前 Projects、Assets、Templates 接口主要服务当前工作台原型和素材管理,不代表完整生产级工程系统。
## 维护约定
- 修改算法行为时,同步更新 [ALGORITHM.md](ALGORITHM.md)。
- 新增或删除配置项时,同步更新 [CONFIG.md](CONFIG.md)。
- 改 HTTP 接口或响应模型时,同步更新 [API.md](API.md)。
- 不再新增单次变更记录文档;短期变更应合并进标准文档。
| 变更范围 | 同步更新 |
|----------|----------|
| 算法行为 | ALGORITHM.md |
| 配置项增删 | CONFIG.md |
| HTTP 接口/响应模型 | API.md |
| 前端画布功能 | CANVAS_STUDIO.md |
| 测试/基准 | TESTING.md |
| 部署方式 | DEPLOYMENT.md |
不再新增单次变更记录文档;短期变更合并进标准文档。
+17 -16
View File
@@ -1,27 +1,22 @@
# WordCloud 项目文档入口
# WordCloud 项目文档
本文档目录是当前项目的标准文档入口。除非某个历史文档被明确标注为“标准文档”,否则以这里列出的文档为准
## 文档准则
- 以代码为准。文档只描述当前代码实际行为,不提前承诺未实现能力。
-`backend/core/config.py``backend/core/pipeline.py``backend/core/layout.py``backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp` 为算法事实来源。
-`backend/service/app.py``backend/service/schemas.py` 为 HTTP API 事实来源。
- 变更记录只记录历史,不作为使用说明。
本文档目录是标准文档入口。标准文档直接对应代码实现;历史/规划文档不作为行为依据
## 标准文档
| 文档 | 用途 |
| --- | --- |
| [PROJECT_STANDARD.md](PROJECT_STANDARD.md) | 项目结构、运行方式、输入输出、工程约定 |
| [ALGORITHM.md](ALGORITHM.md) | 词云生成算法、重复填充、权重、C++ 碰撞搜索 |
| [CONFIG.md](CONFIG.md) | 配置项优先级、前端参数到后端配置的映射 |
| [API.md](API.md) | FastAPI 接口、请求格式、响应结构、产物下载 |
|------|------|
| [PROJECT_STANDARD.md](PROJECT_STANDARD.md) | 项目目标、目录结构、运行方式、输入输出、工程约定 |
| [ALGORITHM.md](ALGORITHM.md) | 词云生成算法:面积模型、权重、布局、C++ 碰撞搜索、高清精修 |
| [CONFIG.md](CONFIG.md) | 配置项清单、来源优先级、前端参数映射、类型校验 |
| [API.md](API.md) | FastAPI HTTP 接口、请求格式、响应结构、产物下载 |
| [CANVAS_STUDIO.md](CANVAS_STUDIO.md) | 画布设计、贴纸库、词云作为贴纸、总图 SVG 导出 |
| [TESTING.md](TESTING.md) | 单元测试、基准测试、质量门禁 |
| [DEPLOYMENT.md](DEPLOYMENT.md) | 本地开发、Docker、Ubuntu 服务器部署 |
## 非标准/历史文档
以下文档可能包含历史规划阶段性设想或已经过期的实现描述,不再作为行为依据:
以下文档可能包含过期规划阶段性描述,不再作为行为依据:
- `docs/PPT-EfficientWordCloud-详细大纲-v1.0.md`
- `docs/stroke-weights-optional.md`
@@ -29,4 +24,10 @@
- `backend/README.md`
- `backend/README_zh.md`
需要确认行为时,优先查标准文档;标准文档仍不清楚时,直接查代码。
## 工程约定
- **以代码为准。** 文档描述当前代码的实际行为,不提前承诺未实现能力。
- 算法事实来源:`backend/core/pipeline.py``backend/core/layout.py``backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp`
- HTTP API 事实来源:`backend/service/app.py``backend/service/schemas.py`
- 前端事实来源:`frontend/src/App.tsx``frontend/src/pages/TestWorkbench.tsx``frontend/src/components/AdvancedPanel.tsx``frontend/src/types.ts`
- 变更不单独记文档;短期变更直接合并进标准文档。
+109
View File
@@ -0,0 +1,109 @@
# 测试与基准
本文档覆盖当前代码中的测试工具和基准脚本。
## 单元测试
位置:`backend/tests/test_layout_constraints.py`
运行方式:
```bash
cd backend
python -m pytest tests/test_layout_constraints.py -v
```
测试矩阵(基于 `unittest`,不依赖外部服务):
| 测试 | 目的 |
|------|------|
| `test_size_ratio_one_keeps_every_equal_weight_size_identical` | `SIZE_RATIO=1` 时所有同权重词字号完全相同 |
| `test_explicit_equal_min_max_is_exact` | `USER_MIN=USER_MAX=12` 时所有词精确为 `12px` |
| `test_same_weight_groups_receive_the_same_size` | 相同权重组内字号一致;权重组间字号递增 |
| `test_stroke_weight_is_applied_when_excel_weights_are_flat` | Excel 权重全为 `1` 时,笔画权重仍能产生区分度 |
| `test_largest_empty_square_ignores_space_outside_mask` | 最大空洞算法只计算掩膜内区域 |
| `test_conflicting_explicit_font_bounds_fail` | `MIN > MAX` 时报错而非静默回退 |
| `test_explicit_max_overrides_automatic_readability_floor` | 用户覆盖最大字号时,自动可读性下限让位于用户输入 |
| `test_base_class_never_uses_a_private_fallback_size` | 基类布局不使用隐藏回退字号 |
| `test_rendered_ink_stays_inside_mask_and_does_not_overlap` | 工作网格上:墨迹不超出掩膜、不重叠 |
| `test_hd_rendered_ink_does_not_overlap_after_scaling` | 高清放大后:零重叠像素、零碰撞边距 |
固定种子(`SEED=LAYOUT_SEED=20260718`)确保可复现。
## 基准测试
位置:`backend/tools/benchmark_layout.py`
运行方式:
```bash
cd backend
python tools/benchmark_layout.py --counts 80 800 --canvas 2000 --assert-targets
```
参数:
| 参数 | 说明 |
|------|------|
| `--counts COUNT [COUNT ...]` | 测试名单数量,默认 `80 800` |
| `--canvas CANVAS` | 画布尺寸,默认 `2000` |
| `--max-growth-rounds N` | 最大画布扩展轮数,默认 `1` |
| `--assert-targets` | 启用门禁检查 |
| `--output-dir PATH` | 输出目录,默认 `backend/benchmark_outputs/` |
### 门禁检查项(`--assert-targets`
| 指标 | 阈值 | 含义 |
|------|------|------|
| `completeness` | `= 1.0` | 名单必须全部放入 |
| `equal_weight_font_consistent` | `True` | 等权重时字号一致 |
| `hd_overlap_pixels` | `= 0` | 高清渲染后零重叠 |
| `contour_grid_coverage` | `≥ 0.80` | 轮廓网格覆盖率(避免大块空洞) |
| `hd_true_density` | `≥ 0.10` | 真实笔画密度 |
| `total_seconds` | `< 1.0s` (count<100) / `< 5.0s` (count<1000) | 性能门槛 |
### 输出指标
| 指标 | 说明 |
|------|------|
| `count` | 名单数量 |
| `canvas` | 最终画布尺寸 |
| `canvas_growth_rounds` | 画布扩展轮数 |
| `placed` | 实际放置词数 |
| `completeness` | 完整率 |
| `layout_seconds` / `render_seconds` / `total_seconds` | 各阶段耗时 |
| `work_fill_ratio` | 工作网格填充率 |
| `font_size_min` / `font_size_max` | 字号范围 |
| `equal_weight_font_consistent` | 等权重字号一致性 |
| `collision_margin` | 碰撞边距 |
| `hd_clearance_shifted_words` | 高清精修时位移词数 |
| `hd_clearance_max_shift` | 最大位移像素 |
| `hd_clearance_px` | 隔离带宽度 |
| `hd_clearance_priority_restarts` | 优先级回溯次数 |
| `hd_overlap_pixels` | 高清重叠像素(双重检查) |
| `largest_empty_square_work_px` | 工作网格最大空洞(像素) |
| `largest_empty_square_font_ratio` | 空洞相对字号比例 |
| `hd_true_density` | 高清真实笔画密度 |
| `ink_bbox_coverage` | 墨迹包围盒覆盖率 |
| `contour_grid_coverage` | 轮廓网格覆盖率 |
基准结果保存为 JSON`backend/benchmark_outputs/benchmark.json`
## 扩展基准
基准脚本使用 `run_generation_pass()` 直接调用核心管线,绕过 HTTP 服务和文件 IO。
- 生成数据:`make_names()` 使用中文姓氏库和双字名库组合出不重复姓名
- 掩膜:`make_round_mask()` 生成圆形掩膜
- 固定种子:`SEED=LAYOUT_SEED=20260718`
- 固定配置:`SIZE_RATIO=1.0, N_REPETITIONS=1, WORK_SCALE=0.18, TARGET_FILL_RATIO=0.45`
## CI 建议
```bash
cd backend
python -m pytest tests/test_layout_constraints.py -v
python tools/benchmark_layout.py --counts 80 800 --canvas 2000 --assert-targets
```
两次运行均应在数秒内完成。
+8 -246
View File
@@ -196,7 +196,7 @@ export default function AdvancedPanel({
min={0.1}
max={1}
step={0.01}
onChange={v => onParamsChange({ packingEfficiency: v ?? 0.85 })}
onChange={v => onParamsChange({ packingEfficiency: v ?? 0.9 })}
/>
</div>
<Hint>SIZE_RATIO 2.0</Hint>
@@ -220,13 +220,6 @@ export default function AdvancedPanel({
/>
</div>
<div className="form-row">
<NumberField
label="字号下限 MIN_FONT_FLOOR"
value={params.minFontFloor}
min={1}
step={1}
onChange={v => onParamsChange({ minFontFloor: Math.max(1, Math.round(v ?? 2)) })}
/>
<NumberField
label="可读高度(px)"
value={params.minReadableHeightPx}
@@ -242,7 +235,7 @@ export default function AdvancedPanel({
min={0.05}
max={1}
step={0.01}
onChange={v => onParamsChange({ workScale: v ?? 0.25 })}
onChange={v => onParamsChange({ workScale: v ?? 0.2 })}
/>
<NumberField
label="目标填充率 TARGET_FILL"
@@ -250,10 +243,10 @@ export default function AdvancedPanel({
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ targetFillRatio: v ?? 0 })}
onChange={v => onParamsChange({ targetFillRatio: v ?? 0.45 })}
/>
</div>
<Hint>TARGET_FILL_RATIO=0 </Hint>
<Hint> 0.300.55TARGET_FILL </Hint>
<div className="section-divider" />
<SectionTitle></SectionTitle>
@@ -263,229 +256,9 @@ export default function AdvancedPanel({
onChange={v => onParamsChange({ strokeWeights: v })}
hint="开启后笔画复杂的字更大;关闭则更接近均等字号"
/>
<div className="form-row">
<NumberField
label="对数权重 LOG_WEIGHT_RATIO"
value={params.logWeightRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ logWeightRatio: v ?? 0.72 })}
/>
<NumberField
label="排序权重 RANK_WEIGHT_RATIO"
value={params.rankWeightRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ rankWeightRatio: v ?? 0.28 })}
/>
</div>
<Hint> 1.0 0.72 / 0.28</Hint>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<BoolField
label="智能减少大字数量"
checked={params.enableSmartLargeFontReduction}
onChange={v => onParamsChange({ enableSmartLargeFontReduction: v })}
hint="填不满时自动减少大字,给小词腾空间"
/>
<BoolField
label="限制大字比例 LIMIT_LARGE_FONTS"
checked={params.limitLargeFonts}
onChange={v => onParamsChange({ limitLargeFonts: v })}
/>
<div className="form-row">
<NumberField
label="大字允许占比"
value={params.largeFontLimitRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ largeFontLimitRatio: v ?? 0.2 })}
/>
<NumberField
label="大字判定阈值"
value={params.largeFontThresholdRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ largeFontThresholdRatio: v ?? 0.8 })}
/>
</div>
<NumberField
label="大字压缩系数 LARGE_FONT_CAP_RATIO"
value={params.largeFontCapRatio}
min={0.1}
max={1}
step={0.01}
onChange={v => onParamsChange({ largeFontCapRatio: v ?? 0.6 })}
/>
<Hint> 20% 80% 60%</Hint>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<div className="form-row">
<NumberField
label="缩放下限 FONT_SCALE_MIN"
value={params.fontScaleMin}
min={0.05}
max={2}
step={0.05}
onChange={v => onParamsChange({ fontScaleMin: v ?? 0.5 })}
/>
<NumberField
label="缩放上限 FONT_SCALE_MAX"
value={params.fontScaleMax}
min={0.1}
max={3}
step={0.05}
onChange={v => onParamsChange({ fontScaleMax: v ?? 1.2 })}
/>
</div>
<div className="form-row">
<NumberField
label="搜索步数"
value={params.scaleSearchSteps}
min={1}
step={1}
onChange={v => onParamsChange({ scaleSearchSteps: Math.max(1, Math.round(v ?? 7)) })}
/>
<NumberField
label="搜索轮数"
value={params.scaleSearchRounds}
min={1}
step={1}
onChange={v => onParamsChange({ scaleSearchRounds: Math.max(1, Math.round(v ?? 5)) })}
/>
</div>
<div className="form-row">
<NumberField
label="衰减 SCALE_DECAY"
value={params.scaleDecay}
min={0.1}
max={1}
step={0.01}
onChange={v => onParamsChange({ scaleDecay: v ?? 0.85 })}
/>
<NumberField
label="地板 SCALE_FLOOR"
value={params.scaleFloor}
min={0.05}
max={1}
step={0.01}
onChange={v => onParamsChange({ scaleFloor: v ?? 0.25 })}
/>
</div>
<div className="form-row">
<NumberField
label="自动收缩轮数"
value={params.autoShrinkRounds}
min={0}
step={1}
onChange={v => onParamsChange({ autoShrinkRounds: Math.max(0, Math.round(v ?? 4)) })}
/>
<div className="form-group" style={{ justifyContent: 'center' }}>
<BoolField
label="要求全部词语放入"
checked={params.requireAllWords}
onChange={v => onParamsChange({ requireAllWords: v })}
/>
</div>
</div>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<BoolField
label="分层采样(边缘覆盖)"
checked={params.enableStratifiedSampling}
onChange={v => onParamsChange({ enableStratifiedSampling: v })}
/>
<NumberField
label="分层条带数 STRATIFIED_BANDS"
value={params.stratifiedBands}
min={1}
max={10}
step={1}
onChange={v => onParamsChange({ stratifiedBands: Math.max(1, Math.round(v ?? 3)) })}
/>
<div className="form-row">
<NumberField
label="最低接受填充率"
value={params.minAcceptFillRatio}
min={0}
max={1}
step={0.01}
onChange={v => onParamsChange({ minAcceptFillRatio: v ?? 0.75 })}
/>
<NumberField
label="填充重试轮数"
value={params.fillRetryMaxRounds}
min={0}
step={1}
onChange={v => onParamsChange({ fillRetryMaxRounds: Math.max(0, Math.round(v ?? 3)) })}
/>
</div>
<div className="form-row">
<NumberField
label="重试最大缩放"
value={params.fillRetryMaxScale}
min={1}
max={3}
step={0.05}
onChange={v => onParamsChange({ fillRetryMaxScale: v ?? 1.5 })}
/>
<NumberField
label="低填充增字号步长"
value={params.growFontStep}
min={1}
max={2}
step={0.01}
onChange={v => onParamsChange({ growFontStep: v ?? 1.05 })}
/>
</div>
<BoolField
label="填充重试时放宽大字限制"
checked={params.fillRetryRelaxLargeCap}
onChange={v => onParamsChange({ fillRetryRelaxLargeCap: v })}
/>
<BoolField
label="低填充时略增字号 GROW_FONT_ON_LOW_FILL"
checked={params.growFontOnLowFill}
onChange={v => onParamsChange({ growFontOnLowFill: v })}
hint="后端默认关闭"
/>
<div className="section-divider" />
<SectionTitle></SectionTitle>
<BoolField
label="自动扩展画布 AUTO_EXPAND_CANVAS"
checked={params.autoExpandCanvas}
onChange={v => onParamsChange({ autoExpandCanvas: v })}
/>
<BoolField
label="螺旋填充扩展 EXPAND_FOR_SPIRAL"
checked={params.expandForSpiral}
onChange={v => onParamsChange({ expandForSpiral: v })}
/>
<div className="form-row">
<NumberField
label="扩展倍率 EXPAND_RATIO"
value={params.expandRatio}
min={1}
max={5}
step={0.1}
onChange={v => onParamsChange({ expandRatio: v ?? 2.5 })}
/>
<NumberField
label="最大尝试次数"
value={params.maxAttempts}
min={1}
step={1}
onChange={v => onParamsChange({ maxAttempts: Math.max(1, Math.round(v ?? 5)) })}
/>
</div>
<div className="form-row">
<NumberField
label="画布重试轮数"
@@ -494,6 +267,8 @@ export default function AdvancedPanel({
step={1}
onChange={v => onParamsChange({ canvasRetryMaxRounds: Math.max(0, Math.round(v ?? 1)) })}
/>
</div>
<div className="form-row">
<NumberField
label="画布重试增长"
value={params.canvasRetryGrowth}
@@ -506,19 +281,6 @@ export default function AdvancedPanel({
<div className="section-divider" />
<SectionTitle></SectionTitle>
<div className="form-group">
<label className="form-label"> LAYOUT_ORDER_MODE</label>
<select
className="form-input"
value={params.layoutOrderMode}
onChange={e =>
onParamsChange({ layoutOrderMode: e.target.value as JobParams['layoutOrderMode'] })
}
>
<option value="SORTED">SORTED</option>
<option value="INTERLEAVED_RANDOM">INTERLEAVED_RANDOM</option>
</select>
</div>
<NumberField
label="布局种子 LAYOUT_SEED"
value={params.layoutSeed}
@@ -531,8 +293,8 @@ export default function AdvancedPanel({
<div className="section-divider" />
<p className="text-xs text-muted" style={{ lineHeight: 1.6 }}>
<code style={{ fontSize: 10 }}>backend/core/config.py</code>
<strong>SIZE_RATIO</strong><strong></strong> {' '}
<strong></strong>
<strong>SIZE_RATIO</strong><strong></strong> {' '}
<strong></strong>
</p>
</div>
</>
+4 -72
View File
@@ -48,58 +48,19 @@ const DEFAULT_PARAMS: JobParams = {
// 字号与填充
sizeRatio: 2.0,
packingEfficiency: 0.85,
targetFillRatio: 0.0,
packingEfficiency: 0.9,
targetFillRatio: 0.45,
userMinFontSize: null,
userMaxFontSize: null,
minFontFloor: 2,
minReadableHeightPx: 25,
workScale: 0.25,
// 字号搜索
fontScaleMin: 0.5,
fontScaleMax: 1.2,
scaleSearchSteps: 7,
scaleSearchRounds: 5,
scaleDecay: 0.85,
scaleFloor: 0.25,
autoShrinkRounds: 4,
requireAllWords: true,
// 权重混合
logWeightRatio: 0.72,
rankWeightRatio: 0.28,
// 大字限制
enableSmartLargeFontReduction: true,
limitLargeFonts: true,
largeFontLimitRatio: 0.2,
largeFontThresholdRatio: 0.8,
largeFontCapRatio: 0.6,
// 分层采样
enableStratifiedSampling: true,
stratifiedBands: 3,
// 填充重试
minAcceptFillRatio: 0.75,
fillRetryRelaxLargeCap: true,
fillRetryMaxRounds: 3,
fillRetryMaxScale: 1.5,
growFontOnLowFill: false,
growFontStep: 1.05,
minReadableHeightPx: 22,
workScale: 0.18,
// 画布
autoExpandCanvas: true,
expandForSpiral: true,
expandRatio: 2.5,
fillOn: 'BLACK',
canvasRetryMaxRounds: 1,
canvasRetryGrowth: 1.12,
maxAttempts: 5,
// 布局可复现
layoutOrderMode: 'SORTED',
layoutSeed: null,
};
@@ -469,40 +430,11 @@ export default function TestWorkbench({
SIZE_RATIO: params.sizeRatio,
PACKING_EFFICIENCY: params.packingEfficiency,
TARGET_FILL_RATIO: params.targetFillRatio,
MIN_FONT_FLOOR: params.minFontFloor,
MIN_READABLE_HEIGHT_PX: params.minReadableHeightPx,
WORK_SCALE: params.workScale,
FONT_SCALE_MIN: params.fontScaleMin,
FONT_SCALE_MAX: params.fontScaleMax,
SCALE_SEARCH_STEPS: params.scaleSearchSteps,
SCALE_SEARCH_ROUNDS: params.scaleSearchRounds,
SCALE_DECAY: params.scaleDecay,
SCALE_FLOOR: params.scaleFloor,
AUTO_SHRINK_ROUNDS: params.autoShrinkRounds,
REQUIRE_ALL_WORDS: params.requireAllWords,
LOG_WEIGHT_RATIO: params.logWeightRatio,
RANK_WEIGHT_RATIO: params.rankWeightRatio,
ENABLE_SMART_LARGE_FONT_REDUCTION: params.enableSmartLargeFontReduction,
LIMIT_LARGE_FONTS: params.limitLargeFonts,
LARGE_FONT_LIMIT_RATIO: params.largeFontLimitRatio,
LARGE_FONT_THRESHOLD_RATIO: params.largeFontThresholdRatio,
LARGE_FONT_CAP_RATIO: params.largeFontCapRatio,
ENABLE_STRATIFIED_SAMPLING: params.enableStratifiedSampling,
STRATIFIED_BANDS: params.stratifiedBands,
MIN_ACCEPT_FILL_RATIO: params.minAcceptFillRatio,
FILL_RETRY_RELAX_LARGE_CAP: params.fillRetryRelaxLargeCap,
FILL_RETRY_MAX_ROUNDS: params.fillRetryMaxRounds,
FILL_RETRY_MAX_SCALE: params.fillRetryMaxScale,
GROW_FONT_ON_LOW_FILL: params.growFontOnLowFill,
GROW_FONT_STEP: params.growFontStep,
AUTO_EXPAND_CANVAS: params.autoExpandCanvas,
EXPAND_FOR_SPIRAL: params.expandForSpiral,
EXPAND_RATIO: params.expandRatio,
FILL_ON: params.fillOn,
CANVAS_RETRY_MAX_ROUNDS: params.canvasRetryMaxRounds,
CANVAS_RETRY_GROWTH: params.canvasRetryGrowth,
MAX_ATTEMPTS: params.maxAttempts,
LAYOUT_ORDER_MODE: params.layoutOrderMode,
};
if (params.seed !== null) {
paramsObj.SEED = params.seed;
-39
View File
@@ -25,54 +25,15 @@ export interface JobParams {
targetFillRatio: number;
userMinFontSize: number | null;
userMaxFontSize: number | null;
minFontFloor: number;
minReadableHeightPx: number;
workScale: number;
// 字号搜索
fontScaleMin: number;
fontScaleMax: number;
scaleSearchSteps: number;
scaleSearchRounds: number;
scaleDecay: number;
scaleFloor: number;
autoShrinkRounds: number;
requireAllWords: boolean;
// 权重混合
logWeightRatio: number;
rankWeightRatio: number;
// 大字限制
enableSmartLargeFontReduction: boolean;
limitLargeFonts: boolean;
largeFontLimitRatio: number;
largeFontThresholdRatio: number;
largeFontCapRatio: number;
// 分层采样
enableStratifiedSampling: boolean;
stratifiedBands: number;
// 填充重试
minAcceptFillRatio: number;
fillRetryRelaxLargeCap: boolean;
fillRetryMaxRounds: number;
fillRetryMaxScale: number;
growFontOnLowFill: boolean;
growFontStep: number;
// 画布
autoExpandCanvas: boolean;
expandForSpiral: boolean;
expandRatio: number;
fillOn: 'BLACK' | 'WHITE';
canvasRetryMaxRounds: number;
canvasRetryGrowth: number;
maxAttempts: number;
// 布局可复现
layoutOrderMode: 'SORTED' | 'INTERLEAVED_RANDOM';
layoutSeed: number | null;
}