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:
@@ -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 (0–1).
|
||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -8,3 +8,4 @@ scipy>=1.11.0
|
||||
matplotlib>=3.10.0
|
||||
pandas>=2.0.0
|
||||
openpyxl>=3.1.0
|
||||
fonttools>=4.50.0
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user