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
+15 -22
View File
@@ -23,7 +23,8 @@ wc = EfficientWordCloud(
height=600,
font_path="/path/to/font.ttf",
max_words=200,
min_font_size=8,
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`:最小字体
- `prefer_horizontal`:水平排版概率。
- `use_spiral_search`:是否启用中心优先排序搜索
## 4. 并行优化的使用说明
### 4.1 Python bbox 预取
- 自动启用,无需额外配置。
- 内部使用 `ProcessPoolExecutor`,将未来词语的 bbox 计算并行化
- 运行 `generate` 时会输出预取/等待日志,便于观察并行效果
### 4.2 C++ 并行搜索
-`use_spiral_search=True` 时启用。
- 在 C++ 内部自动进行分块并行搜索,并保持中心优先排序的结果一致性。
- `min_font_size` / `max_font_size`:本次整批布局可使用的硬字号边界
- `prefer_horizontal`:水平排版概率。
- `relative_scaling`:权重对目标字号的影响比例
- `margin`:真实字形之间的最小工作网格间距。
## 4. 放置语义
- 每个词只使用权重映射得到的目标字号
- 放不下时只尝试同字号的另一方向,不会逐词缩字号
- 调用者需要检查 `layout_` 的数量;若不完整,应整批调整字号或扩大画布后创建新实例重排。
- 项目正式流水线使用 C++ `place_glyph_exact()` 做真实字形碰撞;底层兼容类保留矩形搜索 API。
## 5. 常见问题
### 5.1 为什么字体缩小时没有并行
缩小字体后 bbox 依赖当前失败状态,需要同步确认以确保正确性
### 5.2 多进程是否会导致额外内存开销?
是的,但任务仅用于 bbox 预取,且窗口大小有限,避免过度占用。
### 5.3 若没有字体文件怎么办?
会回退到 PIL 默认字体,但测量与渲染效果可能不同。
### 5.1 若没有字体文件怎么办
会回退到 PIL 默认字体,但测量与渲染效果可能不同
@@ -22,8 +22,9 @@
#include <future>
#include <atomic>
#include <random>
#include <functional>
#include <numeric>
#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)
std::vector<std::pair<int, int>> valid_coords;
// 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);
valid_coords.reserve(h * w / 2);
uint64_t free_y_sum = 0;
uint64_t free_x_sum = 0;
// Initialize canvas from mask
ensure_canvas();
@@ -151,28 +157,48 @@ public:
if (is_blocked) {
canvas[i * width + j] = 1;
}
if (!is_blocked) {
valid_coords.push_back({i, 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 (!is_blocked) {
valid_coords.push_back({i, j});
free_y_sum += (uint64_t)i;
free_x_sum += (uint64_t)j;
}
}
}
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 reorder_stratified(int bands) {
std::unique_lock<std::shared_mutex> lock(mutex_);
const size_t len = valid_coords.size();
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,8 +305,7 @@ public:
std::fill(diff.begin(), diff.end(), 0);
recent_rects.clear();
dirty_count = 0;
for (int i = 0; i < height; ++i) {
for (int i = 0; i < height; ++i) {
uint32_t row_sum = 0;
for (int j = 0; j < width; ++j) {
uint32_t val = (raw_pixels[i * width + j] > 0) ? 1 : 0;
@@ -318,10 +343,10 @@ public:
}
// Rebuild integral from the internal canvas (partial from pos_r, pos_c)
void rebuild_from_canvas(int pos_r, int pos_c) {
if (canvas.empty()) return;
rebuild_from_bitmap_partial(canvas.data(), pos_r, pos_c);
}
void rebuild_from_canvas(int pos_r, int pos_c) {
if (canvas.empty()) return;
rebuild_from_bitmap_partial(canvas.data(), pos_r, pos_c);
}
// v4: Partial integral rebuild from position (pos_r, pos_c) downward
// Optimized: use row-sum approach (like rebuild_from_bitmap) for the partial region
@@ -425,8 +450,9 @@ public:
return {-1, -1};
}
DirectResult query_direct(int box_h, int box_w, uint32_t seed) {
// Always flush before scanning
DirectResult query_direct(int box_h, int box_w, uint32_t seed) {
// 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
{
int n_probes = std::min(16, (int)total_positions);
// Random probes avoid an O(H*W) scan during early and middle packing.
// Increase the budget as occupancy rises.
{
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);
uint64_t count = 0;
// 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
@@ -531,10 +559,193 @@ public:
}
cum += chunk_counts[t];
}
return {false, -1, -1};
}
// =========================================================
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
// =========================================================
@@ -575,9 +786,13 @@ public:
return {false, -1, -1, end_idx};
}
std::pair<bool, std::pair<int, int>> find_spot_parallel(int box_h, int box_w, int step) {
{
std::shared_lock<std::shared_mutex> lock(mutex_);
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) {
lock.unlock();
std::unique_lock<std::shared_mutex> write_lock(mutex_);
@@ -679,19 +894,67 @@ static PyObject* Grid_query_reservoir(PyIntegralGrid* self, PyObject* args) {
}
// v3: Direct pixel-grid scan with parallel counting
static PyObject* Grid_query_direct(PyIntegralGrid* self, PyObject* args) {
static PyObject* Grid_query_direct(PyIntegralGrid* self, PyObject* args) {
int box_h, box_w;
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);
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;
}
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
@@ -768,16 +1031,16 @@ static PyObject* Grid_rebuild_from_bitmap_partial(PyIntegralGrid* self, PyObject
Py_RETURN_NONE;
}
// v4: Stamp glyph bitmap onto C++ canvas and rebuild integral
static PyObject* Grid_stamp_and_rebuild(PyIntegralGrid* self, PyObject* args) {
// 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;
if (!PyArg_ParseTuple(args, "Oiiii", &glyph_obj, &gh, &gw, &pos_r, &pos_c)) return NULL;
Py_buffer view;
if (PyObject_GetBuffer(glyph_obj, &view, PyBUF_SIMPLE) < 0) return NULL;
self->grid->stamp_glyph((const unsigned char*)view.buf, gh, gw, pos_r, pos_c);
self->grid->rebuild_from_canvas(pos_r, pos_c);
self->grid->stamp_glyph((const unsigned char*)view.buf, gh, gw, pos_r, pos_c);
self->grid->rebuild_from_canvas(pos_r, pos_c);
PyBuffer_Release(&view);
Py_RETURN_NONE;
@@ -786,8 +1049,10 @@ static PyObject* Grid_stamp_and_rebuild(PyIntegralGrid* self, PyObject* args) {
static PyMethodDef Grid_methods[] = {
{"reorder_stratified", (PyCFunction)Grid_reorder_stratified, METH_VARARGS, "Reorder valid coords with stratified interleaving."},
{"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_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,16 +152,14 @@ class EfficientWordCloud:
background_color="black",
prefer_horizontal=0.9,
mode="RGB",
use_spiral_search=True,
scale=1,
scale=1,
contour_width=0,
contour_color="black",
margin=2,
color_func=None,
colormap=None,
random_state=None,
relative_scaling="auto",
font_step=1,
relative_scaling="auto",
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,36 +335,26 @@ class EfficientWordCloud:
else:
orientation = Image.ROTATE_90
tried_other_orientation = False
while True:
if font_size < self.min_font_size:
break
font = _get_font(font_size)
transposed = ImageFont.TransposedFont(font, orientation=orientation)
bbox = _measure_draw.textbbox((0, 0), word, font=transposed)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
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=candidate_orientation)
bbox = _measure_draw.textbbox((0, 0), word, font=transposed)
tw = bbox[2] - bbox[0]
th = bbox[3] - bbox[1]
qh = th + self.margin
qw = tw + self.margin
pos = _query(qh, qw)
if pos is not None:
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
pos = _query(qh, qw)
if pos is not None:
orientation = candidate_orientation
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()