Initial project baseline

This commit is contained in:
2026-07-04 02:40:45 +08:00
commit d5d8caef2f
86 changed files with 15590 additions and 0 deletions
+23
View File
@@ -0,0 +1,23 @@
.venv
__pycache__
*.pyc
*.pyo
*.pyd
.Python
.git
.DS_Store
*.log
.claude
.vscode
.idea
service_workspace
service_assets
service_projects
service_design_templates
service_fonts
*.egg-info
build
dist
EfficientWordCloud/build
EfficientWordCloud/*.so
EfficientWordCloud/**/*.so
+65
View File
@@ -0,0 +1,65 @@
# macOS / editors
.DS_Store
.idea/
.claude/
# Python caches and local environments
__pycache__/
*.py[cod]
*.pyc.*
.pytest_cache/
.mypy_cache/
.ruff_cache/
.venv/
# Runtime caches and local state
.runtime/
.matplotlib/
*.log
# Build, packaging, and compiled artifacts
build/
dist/
*.egg
*.egg-info/
*.so
*.dylib
*.dll
*.a
*.o
*.obj
EfficientWordCloud/build/
EfficientWordCloud/dist/
EfficientWordCloud/efficient_wordcloud.egg-info/
EfficientWordCloud/efficient_wordcloud/ewc_core*.so
# Frontend dependencies and build output
web-test/node_modules/
web-test/dist/
# Generated outputs and service workspaces
output/
output_cli_test/
service_workspace/
*.db
metrics.json
Efficient_Result_*.png
Efficient_Result_*.svg
# Local data and ad-hoc attachments in repo root
/*.png
/*.svg
/*.jpg
/*.jpeg
/*.webp
/*.pdf
/*.zip
/*.xlsx
/*.xls
/*.csv
/*.tsv
/*.docx
# Local notes
GIT_PUSH_指南.md
EWC_REF_FEATURE_PARITY_PLAN.md
+36
View File
@@ -0,0 +1,36 @@
# syntax=docker/dockerfile:1
FROM python:3.10-slim
# Install build tools needed for the C++ extension plus curl for healthchecks
RUN apt-get update && apt-get install -y --no-install-recommends \
build-essential \
g++ \
curl \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy and install Python dependencies first (better layer caching)
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Copy backend source code and the C++ extension source
COPY service ./service
COPY core ./core
COPY EfficientWordCloud ./EfficientWordCloud
COPY assets ./assets
COPY start-dev.sh ./
COPY wordcloud_generate_hybrid.py ./
# Build the C++ extension in-place
RUN cd EfficientWordCloud && python setup.py build_ext --inplace
# Runtime data directories (will be mounted as volumes)
RUN mkdir -p service_workspace service_assets service_projects service_design_templates service_fonts
EXPOSE 8000
ENV PYTHONPATH=/app/EfficientWordCloud
CMD ["uvicorn", "service.app:app", "--host", "0.0.0.0", "--port", "8000"]
+66
View File
@@ -0,0 +1,66 @@
# EfficientWordCloud Core / 核心引擎
(English below)
## 🇨🇳 中文说明
**EfficientWordCloud** 是本项目的高性能 C++ 核心扩展库。它负责处理最耗时的碰撞检测和空间搜索任务,是生成 4K/8K 超高清词云的基础。
### 核心技术
1. **积分图 (Integral Image)**
使用 O(1) 时间复杂度计算任意矩形区域的像素和。这意味着无论单词多大,碰撞检测的耗时都是恒定的,实现了瞬时检测。
2. **螺旋搜索 (Spiral Search)**
替代传统的随机尝试算法,采用“从中心向外”的螺旋扫描策略。这不仅大幅提高了填充率,还将大图生成速度提升了 **50-100 倍**
3. **空间索引 (Spatial Indexing)**
内部维护一个层级网格,快速剔除无效区域。
### 依赖说明
- 核心扩展包依赖:`numpy``pillow``matplotlib`
- 主脚本额外依赖:`pandas`(用于 `wordcloud_generate_hybrid.py` 读取 Excel
安装示例:
```bash
pip install numpy pillow matplotlib
# 如果你要运行仓库根目录主脚本,再额外安装:
pip install pandas
```
### 编译安装
在当前目录下运行以下命令,将在 `efficient_wordcloud` 文件夹中生成编译好的扩展文件(`.so``.pyd`):
```bash
python3 setup.py build_ext --inplace
```
### 性能对比
| 分辨率 | 原版 wordcloud | EfficientWordCloud (Spiral) | 提速 |
| :--- | :--- | :--- | :--- |
| 1920x1080 | ~2.5s | ~0.05s | **50x** |
| 8000x4000 | ~60s+ | ~0.5s | **120x+** |
---
## 🇺🇸 English Description
**EfficientWordCloud** is the high-performance C++ backend for this project. It handles the computationally expensive collision detection and spatial queries, enabling the generation of 4K/8K ultra-HD word clouds.
### Key Technologies
1. **Integral Images**:
Calculates the sum of pixels in any rectangular area in O(1) time. This allows for instantaneous collision checks regardless of the word size.
2. **Spiral Search**:
Replaces the brute-force random sampling with a "Center-Out" spiral heuristic. This significantly improves packing density and boosts performance by **50-100x** on large canvases.
3. **Spatial Indexing**:
Maintains an internal hierarchical grid to quickly cull invalid regions.
### Build & Install
Run the following command in this directory to build the extension in-place (generates `.so` or `.pyd` inside `efficient_wordcloud` folder):
```bash
python3 setup.py build_ext --inplace
```
### Performance Comparison
| Resolution | Original Library | EfficientWordCloud (Spiral) | Speedup |
| :--- | :--- | :--- | :--- |
| 1920x1080 | ~2.5s | ~0.05s | **50x** |
| 8000x4000 | ~60s+ | ~0.5s | **120x+** |
+84
View File
@@ -0,0 +1,84 @@
# EfficientWordCloud 并行优化设计文稿
## 1. 目标与背景
本设计针对 EfficientWordCloud 的两个性能瓶颈进行优化:
- **Python 侧**:文本渲染与 bbox 计算属于 CPU 密集型,原流程在主循环中串行执行。
- **C++ 侧**:可用位置搜索为线性扫描,且非线程安全,限制多核利用。
目标:
- 在 Python 侧引入并行 bbox 预取,降低主循环阻塞。
- 在 C++ 侧实现线程安全与分块并行搜索,提升多核性能。
## 2. 总体架构
### 2.1 并行策略
- **Python 侧**Producer-Consumer 预取模式。
- 使用 `ProcessPoolExecutor` 异步计算未来词语的 bbox。
- 主循环消费预取结果;若字体缩小,则回退为同步计算。
- **C++ 侧**Chunked Parallel Search。
- 将候选坐标分块,每块由一个异步任务扫描。
- 收集所有任务结果,选择最小索引对应的位置,以保持“中心优先”的排序策略。
### 2.2 线程安全模型
- `IntegralGrid` 内部引入 `std::shared_mutex`
- **读**`get_area_sum`)在并行搜索期间不再加锁,假设搜索阶段只读且在生成坐标与积分图后保持快照。
- **写**`update_rect_add`)当前实现默认由单线程 Python 调用,移除了锁以减少开销;如需多线程更新需恢复写锁保护。
- 重建积分图(`rebuild_integral`)只在写路径触发。
## 3. C++ 核心设计
### 3.1 数据结构扩展
-`IntegralGrid` 内部新增:
```cpp
mutable std::shared_mutex mutex_;
```
### 3.2 并行搜索流程
1. 计算线程数(`hardware_concurrency`),确定分块大小。
2. 对 `valid_coords` 分块后并行扫描,每块返回一个 `SearchResult`。
3. 主线程汇总所有结果,选取最小索引(全局最佳位置)。
4. 在搜索期间释放 GIL
```cpp
Py_BEGIN_ALLOW_THREADS
...
Py_END_ALLOW_THREADS
```
### 3.3 关键函数
- `search_range(start, end, box_h, box_w, step)`:扫描单块坐标。
- `find_spot_parallel(box_h, box_w, step)`:组织并行任务并聚合结果。
- `Grid_query_sorted`:绑定层调用 `find_spot_parallel`。
## 4. Python 侧设计
### 4.1 可序列化的测量函数
模块级函数保证可被 `ProcessPoolExecutor` 调度:
```python
def measure_text(text, font_path, size, rotate) -> (int, int)
```
### 4.2 ParallelBBoxFetcher
职责:
- 维护任务队列。
- 提交预取任务。
- 主循环中拉取结果(若无缓存则同步测量)。
接口:
- `prefetch(items)`
- `get(key, text, size, rotate)`
### 4.3 主流程集成
- 预先生成 `rotation_flags`,确保预取与实际一致。
- 预取前 `N` 个词的 bbox。
- 逐词处理时,提前预取下一批的 bbox。
- 字体缩小后回退同步,保证正确性。
## 5. 构建配置
- 编译标准升级为 C++17 以使用 `std::shared_mutex`。
## 6. 影响与兼容性
- 对外 API 保持不变。
- 性能提升取决于多核环境及词数量。
- 并行搜索仍保证排序一致性。
## 7. 风险与对策
- **多进程 bbox 测量开销**:采用预取窗口控制并发规模。
- **锁开销**:只在必要时加锁,读操作使用共享锁。
- **一致性**:聚合结果按最小索引保证行为与原排序一致。
+61
View File
@@ -0,0 +1,61 @@
# EfficientWordCloud 使用文档
## 1. 构建与安装
在项目根目录执行:
```bash
python setup.py build
python setup.py install
```
## 2. 基本使用示例
```python
from efficient_wordcloud.wordcloud import EfficientWordCloud
freq = {
"hello": 100,
"world": 80,
"efficient": 60,
"wordcloud": 40
}
wc = EfficientWordCloud(
width=800,
height=600,
font_path="/path/to/font.ttf",
max_words=200,
min_font_size=8,
prefer_horizontal=0.9
)
wc.generate(freq)
img = wc.to_image()
img.show()
```
## 3. 关键参数说明
- `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++ 内部自动进行分块并行搜索,并保持中心优先排序的结果一致性。
## 5. 常见问题
### 5.1 为什么字体缩小时没有并行?
缩小字体后 bbox 依赖当前失败状态,需要同步确认以确保正确性。
### 5.2 多进程是否会导致额外内存开销?
是的,但任务仅用于 bbox 预取,且窗口大小有限,避免过度占用。
### 5.3 若没有字体文件怎么办?
会回退到 PIL 默认字体,但测量与渲染效果可能不同。
@@ -0,0 +1,8 @@
from .wordcloud import (
EfficientWordCloud,
STOPWORDS,
random_color_func,
colormap_color_func,
get_single_color_func,
)
from .color_from_image import ImageColorGenerator
@@ -0,0 +1,55 @@
# Ported from ref/word_cloud/wordcloud/color_from_image.py (MIT License)
import numpy as np
from PIL import ImageFont
class ImageColorGenerator:
"""Color generator that samples colors from a reference image.
Each word is colored using the mean RGB of the region it occupies in
*image*. Pass an instance as ``color_func`` to ``EfficientWordCloud``.
Parameters
----------
image : ndarray, shape (H, W, 3) or (H, W, 4)
Reference color image. Should match the word-cloud canvas size.
default_color : tuple (R, G, B) or None
Fallback color when the word region falls outside *image*.
If None, a ValueError is raised instead.
"""
def __init__(self, image, default_color=None):
if image.ndim not in (2, 3):
raise ValueError(
"ImageColorGenerator needs an image with ndim 2 or 3, "
"got %d" % image.ndim
)
if image.ndim == 3 and image.shape[2] not in (3, 4):
raise ValueError(
"A color image must have 3 or 4 channels, got %d"
% image.shape[2]
)
self.image = image
self.default_color = default_color
def __call__(self, word, font_size, font_path, position, orientation,
**kwargs):
"""Return the mean color of the image patch under the word bounding box."""
font = ImageFont.truetype(font_path, font_size)
from PIL import ImageFont as _IF
transposed_font = _IF.TransposedFont(font, orientation=orientation)
box = transposed_font.getbbox(word) # (left, top, right, bottom)
x, y = position # (row, col) i.e. (y, x) in PIL
patch = self.image[x:x + box[2], y:y + box[3]]
if patch.ndim == 3:
patch = patch[:, :, :3] # drop alpha
reshape = patch.reshape(-1, 3)
if not np.all(np.array(reshape.shape) > 0):
if self.default_color is None:
raise ValueError(
"ImageColorGenerator: word region is outside the image. "
"Pass default_color to suppress this error."
)
return "rgb(%d, %d, %d)" % tuple(self.default_color)
color = np.mean(reshape, axis=0)
return "rgb(%d, %d, %d)" % tuple(color.astype(int))
@@ -0,0 +1,829 @@
/*
* EfficientWordCloud Core (ewc_core) v3.0
*
* v3 changes:
* - Direct pixel-grid scan (query_direct) — like ref, O(1) integral image
* check per position, sequential memory access, cache-friendly
* - Parallel reservoir sampling with thread pool (no std::async overhead)
* - Batch query API to eliminate Python↔C++ per-word overhead
* - Partial integral rebuild (only affected subregion)
*/
#include <Python.h>
#include <vector>
#include <algorithm>
#include <cmath>
#include <cstdlib>
#include <cstdint>
#include <utility>
#include <mutex>
#include <shared_mutex>
#include <thread>
#include <future>
#include <atomic>
#include <random>
#include <functional>
#include <numeric>
// ==========================================
// Thread Pool (avoid per-query thread creation)
// ==========================================
class ThreadPool {
std::vector<std::thread> workers;
std::vector<std::function<void()>> tasks;
std::mutex mtx;
std::condition_variable cv;
std::atomic<bool> stop{false};
public:
ThreadPool(unsigned int n) {
for (unsigned int i = 0; i < n; ++i) {
workers.emplace_back([this] {
while (true) {
std::function<void()> task;
{
std::unique_lock<std::mutex> lock(mtx);
cv.wait(lock, [this] { return stop.load() || !tasks.empty(); });
if (stop.load() && tasks.empty()) return;
task = std::move(tasks.back());
tasks.pop_back();
}
task();
}
});
}
}
template<class F>
std::future<typename std::invoke_result<F>::type> submit(F&& f) {
using R = typename std::invoke_result<F>::type;
auto p = std::make_shared<std::promise<R>>();
auto fut = p->get_future();
{
std::lock_guard<std::mutex> lock(mtx);
if constexpr (std::is_void_v<R>) {
tasks.push_back([p, f=std::forward<F>(f)]() mutable {
try { f(); p->set_value(); }
catch (...) { p->set_exception(std::current_exception()); }
});
} else {
tasks.push_back([p, f=std::forward<F>(f)]() mutable {
try { p->set_value(f()); }
catch (...) { p->set_exception(std::current_exception()); }
});
}
}
cv.notify_one();
return fut;
}
~ThreadPool() {
stop.store(true);
cv.notify_all();
for (auto& w : workers) w.join();
}
};
static ThreadPool& get_pool() {
static unsigned int n = std::max(2u, std::thread::hardware_concurrency());
static ThreadPool pool(n);
return pool;
}
static unsigned int get_num_threads() {
static unsigned int n = std::max(2u, std::thread::hardware_concurrency());
return n;
}
// ==========================================
// Core Data Structure
// ==========================================
class IntegralGrid {
public:
uint32_t* data;
int width;
int height;
mutable std::shared_mutex mutex_;
struct Rect {
int r;
int c;
int h;
int w;
};
// valid coordinates sorted by distance to center (for sorted/spiral search)
std::vector<std::pair<int, int>> valid_coords;
// Lazy update buffers
std::vector<int32_t> diff;
std::vector<Rect> recent_rects;
int dirty_count = 0;
int rebuild_interval = 16; // v3: more frequent rebuilds (was 64), since rebuild is cheaper now
IntegralGrid(int h, int w) : width(w), height(h) {
data = new uint32_t[width * height]();
diff.resize(width * height, 0);
}
~IntegralGrid() {
if (data) delete[] data;
}
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);
// Initialize canvas from mask
ensure_canvas();
for (int i = 0; i < h; ++i) {
uint32_t row_sum = 0;
for (int j = 0; j < w; ++j) {
bool is_blocked = (raw_mask[i * w + j] > 0);
uint32_t val = is_blocked ? 1 : 0;
row_sum += val;
uint32_t prev_row = (i > 0) ? data[(i - 1) * width + j] : 0;
data[i * width + j] = row_sum + prev_row;
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;
}
);
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();
if (bands <= 1 || len == 0) return;
if ((size_t)bands > len) bands = (int)len;
std::vector<size_t> band_sizes(bands, 0);
size_t base = len / bands;
size_t rem = len % bands;
for (int b = 0; b < bands; ++b)
band_sizes[b] = base + (b < (int)rem ? 1 : 0);
std::vector<size_t> band_starts(bands, 0);
size_t offset = 0, max_band_size = 0;
for (int b = 0; b < bands; ++b) {
band_starts[b] = offset;
offset += band_sizes[b];
if (band_sizes[b] > max_band_size) max_band_size = band_sizes[b];
}
std::vector<std::pair<int, int>> reordered;
reordered.reserve(len);
for (size_t i = 0; i < max_band_size; ++i)
for (int b = 0; b < bands; ++b)
if (i < band_sizes[b])
reordered.push_back(valid_coords[band_starts[b] + i]);
valid_coords.swap(reordered);
}
// O(1) rectangle emptiness check via integral image + recent-rect overlap
inline uint32_t get_area_sum(int r, int c, int h, int w) const {
int r_bottom = r + h - 1;
int c_right = c + w - 1;
int r_top = r - 1;
int c_left = c - 1;
uint32_t A = (r_top >= 0 && c_left >= 0) ? data[r_top * width + c_left] : 0;
uint32_t B = (r_top >= 0) ? data[r_top * width + c_right] : 0;
uint32_t C = (c_left >= 0) ? data[r_bottom * width + c_left] : 0;
uint32_t D = data[r_bottom * width + c_right];
uint32_t base_sum = D - B - C + A;
if (base_sum > 0) return base_sum;
if (!recent_rects.empty()) {
int r2 = r + h;
int c2 = c + w;
for (const auto& rect : recent_rects) {
int rr2 = rect.r + rect.h;
int cc2 = rect.c + rect.w;
if (r < rr2 && r2 > rect.r && c < cc2 && c2 > rect.c) return 1;
}
}
return 0;
}
// O(1) fast check without recent_rects (for direct scan where we'll call flush first)
inline uint32_t get_area_sum_fast(int r, int c, int h, int w) const {
int r_bottom = r + h - 1;
int c_right = c + w - 1;
int r_top = r - 1;
int c_left = c - 1;
uint32_t A = (r_top >= 0 && c_left >= 0) ? data[r_top * width + c_left] : 0;
uint32_t B = (r_top >= 0) ? data[r_top * width + c_right] : 0;
uint32_t C = (c_left >= 0) ? data[r_bottom * width + c_left] : 0;
uint32_t D = data[r_bottom * width + c_right];
return D - B - C + A;
}
void rebuild_integral() {
if (dirty_count <= 0) return;
std::vector<int32_t> delta_prev_row(width, 0);
std::vector<int32_t> delta_row(width, 0);
std::vector<uint32_t> integral_prev_row(width, 0);
for (int i = 0; i < height; ++i) {
uint32_t row_sum = 0;
for (int j = 0; j < width; ++j) {
int idx = i * width + j;
int32_t up = (i > 0) ? delta_prev_row[j] : 0;
int32_t left = (j > 0) ? delta_row[j - 1] : 0;
int32_t up_left = (i > 0 && j > 0) ? delta_prev_row[j - 1] : 0;
int32_t delta = diff[idx] + up + left - up_left;
delta_row[j] = delta;
row_sum += (uint32_t)delta;
uint32_t prev = (i > 0) ? integral_prev_row[j] : 0;
uint32_t di = row_sum + prev;
data[idx] += di;
integral_prev_row[j] = di;
}
delta_prev_row.swap(delta_row);
}
std::fill(diff.begin(), diff.end(), 0);
recent_rects.clear();
dirty_count = 0;
}
// Force flush: rebuild integral so get_area_sum_fast is safe
void flush() {
if (dirty_count > 0) rebuild_integral();
}
// v4: Rebuild integral image from raw pixel array (bitmap occupancy like ref)
// raw_pixels: H×W uint8 array where >0 = occupied
void rebuild_from_bitmap(const unsigned char* raw_pixels) {
// Reset lazy update state since we're rebuilding everything
std::fill(diff.begin(), diff.end(), 0);
recent_rects.clear();
dirty_count = 0;
for (int i = 0; i < height; ++i) {
uint32_t row_sum = 0;
for (int j = 0; j < width; ++j) {
uint32_t val = (raw_pixels[i * width + j] > 0) ? 1 : 0;
row_sum += val;
uint32_t prev_row = (i > 0) ? data[(i - 1) * width + j] : 0;
data[i * width + j] = row_sum + prev_row;
}
}
}
// Persistent canvas bitmap (C++ side) — avoids PIL→numpy conversion overhead
std::vector<uint8_t> canvas;
void ensure_canvas() {
if (canvas.empty()) {
canvas.resize(width * height, 0);
}
}
// Stamp a small glyph bitmap onto the canvas at position (pos_r, pos_c)
// glyph: gh×gw uint8 array where >0 = occupied pixel
void stamp_glyph(const unsigned char* glyph, int gh, int gw, int pos_r, int pos_c) {
ensure_canvas();
for (int i = 0; i < gh; ++i) {
int ri = pos_r + i;
if (ri < 0 || ri >= height) continue;
for (int j = 0; j < gw; ++j) {
int cj = pos_c + j;
if (cj < 0 || cj >= width) continue;
if (glyph[i * gw + j] > 0) {
canvas[ri * width + cj] = 1;
}
}
}
}
// 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);
}
// v4: Partial integral rebuild from position (pos_r, pos_c) downward
// Optimized: use row-sum approach (like rebuild_from_bitmap) for the partial region
void rebuild_from_bitmap_partial(const unsigned char* raw_pixels, int pos_r, int pos_c) {
// Reset lazy update state
dirty_count = 0;
recent_rects.clear();
// Clamp to valid range
if (pos_r < 0) pos_r = 0;
if (pos_c < 0) pos_c = 0;
// For each row from pos_r, recompute integral from pos_c onward
// Using the fast row-sum approach
for (int i = pos_r; i < height; ++i) {
const unsigned char* pixel_row = raw_pixels + i * width;
uint32_t* data_row = data + i * width;
// Compute row_sum for columns [0, pos_c) from existing data
// row_sum at pos_c = data[i][pos_c-1] - data[i-1][pos_c-1] (if prev row available)
// But we need the actual row prefix sum, not the integral
// Instead: use the standard formula: integral(i,j) = pixel(i,j) + left + up - diag
// But optimize by accumulating row_sum separately
// row_sum = sum of pixel_row[pos_c..j] for current row
uint32_t row_sum;
if (pos_c > 0) {
// Get the row prefix sum at pos_c-1 from existing integral
uint32_t prev_row_integral = (i > 0) ? data[(i-1) * width + (pos_c - 1)] : 0;
row_sum = data_row[pos_c - 1] - prev_row_integral;
// But wait - data_row[pos_c-1] might be stale for rows > pos_r
// Actually for i == pos_r, row pos_r-1 hasn't been modified, so data[pos_r-1] is correct
// For i > pos_r, data[i-1] has been updated in previous iteration, so it's correct
// And data_row[pos_c-1] is unchanged (we don't modify columns < pos_c)
// So row_sum = integral[i][pos_c-1] - integral[i-1][pos_c-1]
// This gives us the row prefix sum for columns [0, pos_c-1]
} else {
row_sum = 0;
}
for (int j = pos_c; j < width; ++j) {
row_sum += (pixel_row[j] > 0) ? 1 : 0;
uint32_t prev_row = (i > 0) ? data[(i - 1) * width + j] : 0;
data_row[j] = row_sum + prev_row;
}
}
}
void update_rect_add(int r, int c, int h, int w) {
int end_r = std::min(height, r + h);
int end_c = std::min(width, c + w);
if (r < 0 || c < 0 || end_r <= r || end_c <= c) return;
recent_rects.push_back({r, c, end_r - r, end_c - c});
diff[r * width + c] += 1;
if (end_r < height) diff[end_r * width + c] -= 1;
if (end_c < width) diff[r * width + end_c] -= 1;
if (end_r < height && end_c < width) diff[end_r * width + end_c] += 1;
dirty_count++;
if (dirty_count >= rebuild_interval) rebuild_integral();
}
// =========================================================
// v3: Direct pixel-grid scan (like ref's query_integral_image)
// Scans all (i,j) where 0<=i<=H-bh, 0<=j<=W-bw in memory order
// Two-pass: count hits, then pick random one
// =========================================================
struct DirectResult {
bool found;
int y, x;
};
// Count valid positions in row range [row_start, row_end)
uint64_t count_valid_rows(int row_start, int row_end, int box_h, int box_w) const {
uint64_t count = 0;
int max_col = width - box_w;
for (int i = row_start; i < row_end; ++i) {
for (int j = 0; j <= max_col; ++j) {
if (get_area_sum_fast(i, j, box_h, box_w) == 0)
++count;
}
}
return count;
}
// Pick the nth valid position in row range [row_start, row_end)
std::pair<int,int> pick_nth_valid(int row_start, int row_end, int box_h, int box_w, uint64_t target) const {
uint64_t count = 0;
int max_col = width - box_w;
for (int i = row_start; i < row_end; ++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 {i, j};
++count;
}
}
}
return {-1, -1};
}
DirectResult query_direct(int box_h, int box_w, uint32_t seed) {
// Always flush before scanning
flush();
int max_row = height - box_h;
int max_col = width - box_w;
if (max_row < 0 || max_col < 0) return {false, -1, -1};
int n_rows = max_row + 1;
int n_cols = max_col + 1;
int64_t total_positions = (int64_t)n_rows * n_cols;
std::mt19937 rng(seed);
// Fast path: if the entire canvas is empty, all positions are valid
uint32_t total_occupied = data[(height - 1) * width + (width - 1)];
if (total_occupied == 0) {
int y = std::uniform_int_distribution<int>(0, max_row)(rng);
int x = std::uniform_int_distribution<int>(0, max_col)(rng);
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);
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);
if (get_area_sum_fast(y, x, box_h, box_w) == 0) {
return {true, y, x};
}
}
}
// Adaptive: use single thread for small canvases, multi for large
unsigned int nt = 1;
if (total_positions > 200000) {
nt = get_num_threads();
nt = std::min(nt, (unsigned int)n_rows);
}
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;
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;
}
}
}
return {false, -1, -1};
}
// Multi-thread path: parallel count then pick
std::vector<uint64_t> chunk_counts(nt, 0);
std::vector<int> chunk_starts(nt), chunk_ends(nt);
int rows_per = (n_rows + nt - 1) / nt;
for (unsigned int t = 0; t < nt; ++t) {
chunk_starts[t] = t * rows_per;
chunk_ends[t] = std::min(n_rows, (int)(t + 1) * rows_per);
}
auto& pool = get_pool();
std::vector<std::future<uint64_t>> futs;
futs.reserve(nt);
for (unsigned int t = 0; t < nt; ++t) {
if (chunk_starts[t] >= chunk_ends[t]) break;
futs.push_back(pool.submit([this, cs=chunk_starts[t], ce=chunk_ends[t], box_h, box_w]() {
return this->count_valid_rows(cs, ce, box_h, box_w);
}));
}
uint64_t total = 0;
for (size_t t = 0; t < futs.size(); ++t) {
chunk_counts[t] = futs[t].get();
total += chunk_counts[t];
}
if (total == 0) return {false, -1, -1};
uint64_t target = std::uniform_int_distribution<uint64_t>(0, total - 1)(rng);
uint64_t cum = 0;
for (size_t t = 0; t < futs.size(); ++t) {
if (cum + chunk_counts[t] > target) {
auto pos = pick_nth_valid(chunk_starts[t], chunk_ends[t], box_h, box_w, target - cum);
return {true, pos.first, pos.second};
}
cum += chunk_counts[t];
}
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
// =========================================================
std::vector<DirectResult> batch_query(const std::vector<std::tuple<int,int,uint32_t>>& queries) {
std::vector<DirectResult> results;
results.reserve(queries.size());
for (auto& [bh, bw, seed] : queries) {
auto r = query_direct(bh, bw, seed);
if (r.found) {
update_rect_add(r.y, r.x, bh, bw);
}
results.push_back(r);
}
return results;
}
// =========================================================
// Legacy methods (kept for backward compatibility)
// =========================================================
struct SearchResult {
bool found;
int y, x;
size_t index;
};
SearchResult search_range(size_t start_idx, size_t end_idx, int box_h, int box_w, int step) const {
for (size_t i = start_idx; i < end_idx; i += step) {
int y = valid_coords[i].first;
int x = valid_coords[i].second;
if (y + box_h <= height && x + box_w <= width) {
if (get_area_sum(y, x, box_h, box_w) == 0) {
return {true, y, x, i};
}
}
}
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_);
if (dirty_count > 0 && dirty_count >= rebuild_interval) {
lock.unlock();
std::unique_lock<std::shared_mutex> write_lock(mutex_);
if (dirty_count > 0 && dirty_count >= rebuild_interval)
rebuild_integral();
}
}
const size_t len = valid_coords.size();
if (len == 0) return {false, {-1, -1}};
SearchResult best = {false, -1, -1, len};
unsigned int nt = get_num_threads();
nt = std::min(nt, (unsigned int)len);
const size_t chunk = (len + nt - 1) / nt;
auto& pool = get_pool();
std::vector<std::future<SearchResult>> futs;
futs.reserve(nt);
Py_BEGIN_ALLOW_THREADS
for (unsigned int i = 0; i < nt; ++i) {
size_t s = i * chunk, e = std::min(len, s + chunk);
if (s >= e) break;
futs.push_back(pool.submit([this, s, e, box_h, box_w, step]() {
return this->search_range(s, e, box_h, box_w, step);
}));
}
for (auto& f : futs) {
auto res = f.get();
if (res.found && res.index < best.index) best = res;
}
Py_END_ALLOW_THREADS
if (best.found) return {true, {best.y, best.x}};
return {false, {-1, -1}};
}
};
// ==========================================
// Python Bindings
// ==========================================
typedef struct {
PyObject_HEAD
IntegralGrid* grid;
} PyIntegralGrid;
static void Grid_dealloc(PyIntegralGrid* self) {
if (self->grid) delete self->grid;
Py_TYPE(self)->tp_free((PyObject*)self);
}
static PyObject* Grid_new(PyTypeObject* type, PyObject* args, PyObject* kwds) {
PyIntegralGrid* self = (PyIntegralGrid*)type->tp_alloc(type, 0);
if (self) self->grid = NULL;
return (PyObject*)self;
}
static int Grid_init(PyIntegralGrid* self, PyObject* args, PyObject* kwds) {
PyObject* mask_obj;
int h, w;
if (!PyArg_ParseTuple(args, "Oii", &mask_obj, &h, &w)) return -1;
Py_buffer view;
if (PyObject_GetBuffer(mask_obj, &view, PyBUF_SIMPLE) < 0) return -1;
self->grid = new IntegralGrid(h, w);
self->grid->init_from_buffer((unsigned char*)view.buf, h, w);
PyBuffer_Release(&view);
return 0;
}
static PyObject* Grid_reorder_stratified(PyIntegralGrid* self, PyObject* args) {
int bands = 0;
if (!PyArg_ParseTuple(args, "i", &bands)) return NULL;
self->grid->reorder_stratified(bands);
Py_RETURN_NONE;
}
static PyObject* Grid_query_sorted(PyIntegralGrid* self, PyObject* args) {
int box_h, box_w, step = 1;
if (!PyArg_ParseTuple(args, "ii|i", &box_h, &box_w, &step)) return NULL;
auto result = self->grid->find_spot_parallel(box_h, box_w, step);
if (result.first) return Py_BuildValue("ii", result.second.first, result.second.second);
Py_RETURN_NONE;
}
static PyObject* Grid_query_reservoir(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;
// v3: delegate to query_direct for parallel reservoir
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;
}
// v3: Direct pixel-grid scan with parallel counting
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);
if (r.found) return Py_BuildValue("ii", r.y, r.x);
Py_RETURN_NONE;
}
// v3: Batch query — process multiple placements in one C++ call
// Input: list of (box_h, box_w, seed) tuples
// Output: list of (y, x) or None for each
static PyObject* Grid_batch_query(PyIntegralGrid* self, PyObject* args) {
PyObject* query_list;
if (!PyArg_ParseTuple(args, "O", &query_list)) return NULL;
PyObject* seq = PySequence_Fast(query_list, "expected a sequence");
if (!seq) return NULL;
Py_ssize_t n = PySequence_Fast_GET_SIZE(seq);
std::vector<std::tuple<int,int,uint32_t>> queries;
queries.reserve(n);
for (Py_ssize_t i = 0; i < n; ++i) {
PyObject* item = PySequence_Fast_GET_ITEM(seq, i);
int bh, bw;
unsigned int seed = 0;
if (!PyArg_ParseTuple(item, "ii|I", &bh, &bw, &seed)) {
Py_DECREF(seq);
return NULL;
}
queries.push_back({bh, bw, seed});
}
Py_DECREF(seq);
auto results = self->grid->batch_query(queries);
PyObject* result_list = PyList_New(n);
for (Py_ssize_t i = 0; i < n; ++i) {
if (results[i].found) {
PyList_SET_ITEM(result_list, i, Py_BuildValue("ii", results[i].y, results[i].x));
} else {
Py_INCREF(Py_None);
PyList_SET_ITEM(result_list, i, Py_None);
}
}
return result_list;
}
static PyObject* Grid_update(PyIntegralGrid* self, PyObject* args) {
int r, c, h, w;
if (!PyArg_ParseTuple(args, "iiii", &r, &c, &h, &w)) return NULL;
self->grid->update_rect_add(r, c, h, w);
Py_RETURN_NONE;
}
static PyObject* Grid_flush(PyIntegralGrid* self, PyObject* args) {
self->grid->flush();
Py_RETURN_NONE;
}
// v4: Rebuild integral from full bitmap array
static PyObject* Grid_rebuild_from_bitmap(PyIntegralGrid* self, PyObject* args) {
PyObject* arr_obj;
if (!PyArg_ParseTuple(args, "O", &arr_obj)) return NULL;
Py_buffer view;
if (PyObject_GetBuffer(arr_obj, &view, PyBUF_SIMPLE) < 0) return NULL;
self->grid->rebuild_from_bitmap((const unsigned char*)view.buf);
PyBuffer_Release(&view);
Py_RETURN_NONE;
}
// v4: Partial rebuild from position (pos_r, pos_c)
static PyObject* Grid_rebuild_from_bitmap_partial(PyIntegralGrid* self, PyObject* args) {
PyObject* arr_obj;
int pos_r, pos_c;
if (!PyArg_ParseTuple(args, "Oii", &arr_obj, &pos_r, &pos_c)) return NULL;
Py_buffer view;
if (PyObject_GetBuffer(arr_obj, &view, PyBUF_SIMPLE) < 0) return NULL;
self->grid->rebuild_from_bitmap_partial((const unsigned char*)view.buf, pos_r, pos_c);
PyBuffer_Release(&view);
Py_RETURN_NONE;
}
// v4: Stamp glyph bitmap onto C++ canvas and rebuild integral
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);
PyBuffer_Release(&view);
Py_RETURN_NONE;
}
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."},
{"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."},
{"rebuild_from_bitmap", (PyCFunction)Grid_rebuild_from_bitmap, METH_VARARGS, "Rebuild integral from pixel bitmap array."},
{"rebuild_from_bitmap_partial", (PyCFunction)Grid_rebuild_from_bitmap_partial, METH_VARARGS, "Partial integral rebuild from (pos_r, pos_c)."},
{"stamp_and_rebuild", (PyCFunction)Grid_stamp_and_rebuild, METH_VARARGS, "Stamp glyph + partial integral rebuild (avoids PIL->numpy)."},
{NULL}
};
static PyTypeObject PyIntegralGridType = {
PyVarObject_HEAD_INIT(NULL, 0)
"ewc_core.IntegralGrid",
sizeof(PyIntegralGrid),
0,
(destructor)Grid_dealloc,
0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
Py_TPFLAGS_DEFAULT,
"Integral Grid v3",
0, 0, 0, 0, 0, 0,
Grid_methods,
0, 0, 0, 0, 0, 0, 0,
(initproc)Grid_init,
0,
Grid_new,
};
static PyModuleDef ewc_module = {
PyModuleDef_HEAD_INIT, "ewc_core", "EfficientWordCloud Core v3", -1, NULL, NULL, NULL, NULL, NULL
};
PyMODINIT_FUNC PyInit_ewc_core(void) {
PyObject* m;
if (PyType_Ready(&PyIntegralGridType) < 0) return NULL;
m = PyModule_Create(&ewc_module);
if (!m) return NULL;
Py_INCREF(&PyIntegralGridType);
if (PyModule_AddObject(m, "IntegralGrid", (PyObject *)&PyIntegralGridType) < 0) return NULL;
return m;
}
@@ -0,0 +1,192 @@
a
about
above
after
again
against
all
also
am
an
and
any
are
aren't
as
at
be
because
been
before
being
below
between
both
but
by
can
can't
cannot
com
could
couldn't
did
didn't
do
does
doesn't
doing
don't
down
during
each
else
ever
few
for
from
further
get
had
hadn't
has
hasn't
have
haven't
having
he
he'd
he'll
he's
hence
her
here
here's
hers
herself
him
himself
his
how
how's
however
http
i
i'd
i'll
i'm
i've
if
in
into
is
isn't
it
it's
its
itself
just
k
let's
like
me
more
most
mustn't
my
myself
no
nor
not
of
off
on
once
only
or
other
otherwise
ought
our
ours
ourselves
out
over
own
r
same
shall
shan't
she
she'd
she'll
she's
should
shouldn't
since
so
some
such
than
that
that's
the
their
theirs
them
themselves
then
there
there's
therefore
these
they
they'd
they'll
they're
they've
this
those
through
to
too
under
until
up
very
was
wasn't
we
we'd
we'll
we're
we've
were
weren't
what
what's
when
when's
where
where's
which
while
who
who's
whom
why
why's
with
won't
would
wouldn't
www
you
you'd
you'll
you're
you've
your
yours
yourself
yourselves
@@ -0,0 +1,109 @@
# Ported from ref/word_cloud/wordcloud/tokenization.py (MIT License)
from __future__ import division
from itertools import tee
from operator import itemgetter
from collections import defaultdict
from math import log
def _l(k, n, x):
"""Dunning log-likelihood helper."""
return log(max(x, 1e-10)) * k + log(max(1 - x, 1e-10)) * (n - k)
def _collocation_score(count_bigram, count1, count2, n_words):
"""Dunning likelihood ratio collocation score."""
if n_words <= count1 or n_words <= count2:
return 0
N, c12, c1, c2 = n_words, count_bigram, count1, count2
p = c2 / N
p1 = c12 / c1
p2 = (c2 - c12) / (N - c1)
score = (
_l(c12, c1, p) + _l(c2 - c12, N - c1, p)
- _l(c12, c1, p1) - _l(c2 - c12, N - c1, p2)
)
return -2 * score
def _pairwise(iterable):
a, b = tee(iterable)
next(b, None)
return zip(a, b)
def process_tokens(words, normalize_plurals=True):
"""Count words, normalizing case and optionally merging plurals.
Returns
-------
counts : dict str -> int
standard_forms : dict lowercase_str -> canonical_str
"""
d = defaultdict(dict)
for word in words:
wl = word.lower()
case_dict = d[wl]
case_dict[word] = case_dict.get(word, 0) + 1
if normalize_plurals:
merged_plurals = {}
for key in list(d.keys()):
if key.endswith('s') and not key.endswith('ss'):
singular = key[:-1]
if singular in d:
for word, count in d[key].items():
sing_form = word[:-1]
d[singular][sing_form] = d[singular].get(sing_form, 0) + count
merged_plurals[key] = singular
del d[key]
fused_cases = {}
standard_cases = {}
item1 = itemgetter(1)
for word_lower, case_dict in d.items():
first = max(case_dict.items(), key=item1)[0]
fused_cases[first] = sum(case_dict.values())
standard_cases[word_lower] = first
if normalize_plurals:
for plural, singular in merged_plurals.items():
standard_cases[plural] = standard_cases.get(singular, singular)
return fused_cases, standard_cases
def unigrams_and_bigrams(words, stopwords, normalize_plurals=True,
collocation_threshold=30):
"""Return word counts including statistically significant bigrams."""
bigrams = [
p for p in _pairwise(words)
if not any(w.lower() in stopwords for w in p)
]
unigrams = [w for w in words if w.lower() not in stopwords]
n_words = len(unigrams)
counts_unigrams, standard_form = process_tokens(
unigrams, normalize_plurals=normalize_plurals)
counts_bigrams, _ = process_tokens(
[" ".join(b) for b in bigrams], normalize_plurals=normalize_plurals)
orig_counts = counts_unigrams.copy()
for bigram_string, count in counts_bigrams.items():
parts = bigram_string.split(" ", 1)
if len(parts) != 2:
continue
word1 = standard_form.get(parts[0].lower(), parts[0])
word2 = standard_form.get(parts[1].lower(), parts[1])
if word1 not in orig_counts or word2 not in orig_counts:
continue
score = _collocation_score(count, orig_counts[word1],
orig_counts[word2], n_words)
if score > collocation_threshold:
counts_unigrams[word1] -= count
counts_unigrams[word2] -= count
counts_unigrams[bigram_string] = count
# Remove non-positive counts
return {w: c for w, c in counts_unigrams.items() if c > 0}
@@ -0,0 +1,630 @@
import numpy as np
import random as _random
from random import Random
import colorsys
from PIL import Image, ImageDraw, ImageFont, ImageFilter
import re
import os
import sys
from .ewc_core import IntegralGrid
from .tokenization import process_tokens, unigrams_and_bigrams
_FILE = os.path.dirname(__file__)
_STOPWORDS_PATH = os.path.join(_FILE, "stopwords")
def _load_stopwords():
if os.path.exists(_STOPWORDS_PATH):
with open(_STOPWORDS_PATH, encoding="utf-8") as f:
return set(map(str.strip, f))
# Fallback: minimal English stopwords so the module still works without the file
return {"the", "a", "an", "and", "or", "but", "in", "on", "at", "to",
"for", "of", "with", "is", "are", "was", "were", "be", "been",
"it", "its", "this", "that", "i", "you", "he", "she", "we", "they"}
STOPWORDS = _load_stopwords()
# ---------------------------------------------------------------------------
# Color functions (mirrors ref/word_cloud/wordcloud/wordcloud.py)
# ---------------------------------------------------------------------------
def random_color_func(word=None, font_size=None, position=None,
orientation=None, font_path=None, random_state=None):
"""Random hue color generation (HSL, saturation=80%, lightness=50%)."""
if random_state is None:
random_state = Random()
return "hsl(%d, 80%%, 50%%)" % random_state.randint(0, 255)
class colormap_color_func:
"""Color function backed by a matplotlib colormap."""
def __init__(self, colormap):
import matplotlib.pyplot as plt
self.colormap = plt.get_cmap(colormap)
def __call__(self, word, font_size, position, orientation,
random_state=None, **kwargs):
if random_state is None:
random_state = Random()
r, g, b, _ = np.maximum(0, 255 * np.array(
self.colormap(random_state.uniform(0, 1))))
return "rgb({:.0f}, {:.0f}, {:.0f})".format(r, g, b)
def get_single_color_func(color):
"""Return a color func that varies only the HSV value for a given color.
Accepted values are PIL/Pillow color strings, e.g. 'deepskyblue', '#00b4d2'.
"""
from PIL import ImageColor
old_r, old_g, old_b = ImageColor.getrgb(color)
h, s, v = colorsys.rgb_to_hsv(old_r / 255., old_g / 255., old_b / 255.)
def single_color_func(word=None, font_size=None, position=None,
orientation=None, font_path=None, random_state=None):
if random_state is None:
random_state = Random()
r, g, b = colorsys.hsv_to_rgb(h, s, random_state.uniform(0.2, 1))
return "rgb({:.0f}, {:.0f}, {:.0f})".format(r * 255, g * 255, b * 255)
return single_color_func
import logging
class EfficientWordCloud:
"""
EfficientWordCloud Generation Class.
Uses C++ backend (ewc_core) for high-performance collision detection.
Optimized for high-resolution generation (4k/8k+).
Parameters
----------
width, height : int
Canvas size (ignored when mask is provided).
mask : ndarray or None
Shape mask. White pixels (255) are treated as blocked, others as free.
font_path : str or None
Path to a TrueType font file.
max_words : int
Maximum number of words to place.
min_font_size : int
Smallest font size to use.
max_font_size : int or None
Largest font size. Derived automatically when None.
background_color : color
PIL-compatible background color.
prefer_horizontal : float
Probability a word is placed horizontally (01).
mode : str
PIL image mode ('RGB', 'RGBA', …).
use_spiral_search : bool
Use center-out sorted search (True) or reservoir sampling (False).
scale : float
Scaling factor between layout computation and final rendering.
``scale=2`` means the output image is 2× the canvas size in each
dimension while layout is still computed at base resolution.
Equivalent to ref's ``scale`` parameter.
contour_width : float
If > 0 and mask is set, draw the mask contour on the output image.
contour_color : color
PIL-compatible color for the mask contour (default 'black').
margin : int
Pixel gap between words.
stopwords : set of str or None
Words to exclude when processing text. Defaults to built-in STOPWORDS.
regexp : str or None
Override the regex used to tokenize text (default ``r"\\w[\\w']+"``).
collocations : bool
Whether to detect bigrams (default True).
collocation_threshold : int
Dunning score threshold for bigrams (default 30).
normalize_plurals : bool
Strip trailing 's' to merge plurals (default True).
include_numbers : bool
Keep numeric tokens when processing text (default False).
min_word_length : int
Minimum character length for a token to be kept (default 0).
color_func : callable or None
``color_func(word, font_size, position, orientation, font_path,
random_state) -> color``. Overrides *colormap*.
colormap : str or matplotlib colormap or None
Matplotlib colormap used when *color_func* is None.
random_state : int, Random, or None
Seed for reproducibility.
repeat : bool
If True, repeat words (with decreasing weight) until *max_words* or
*min_font_size* is reached (default False).
relative_scaling : float (01)
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,
width=400, height=200,
mask=None,
font_path=None,
max_words=200,
min_font_size=4,
max_font_size=None,
background_color="black",
prefer_horizontal=0.9,
mode="RGB",
use_spiral_search=True,
scale=1,
contour_width=0,
contour_color="black",
margin=2,
color_func=None,
colormap=None,
random_state=None,
relative_scaling="auto",
font_step=1,
repeat=False,
stopwords=None,
regexp=None,
collocations=True,
collocation_threshold=30,
normalize_plurals=True,
include_numbers=False,
min_word_length=0):
self.width = width
self.height = height
self.mask = mask
self.font_path = font_path
self.max_words = max_words
self.min_font_size = min_font_size
self.max_font_size = max_font_size
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
self.repeat = repeat
# relative_scaling default mirrors ref: 0 when repeat, else 0.5
if relative_scaling == "auto":
self.relative_scaling = 0 if repeat else 0.5
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
self.collocation_threshold = collocation_threshold
self.normalize_plurals = normalize_plurals
self.include_numbers = include_numbers
self.min_word_length = min_word_length
# Random state
if isinstance(random_state, int):
self.random_state = Random(random_state)
elif random_state is None:
self.random_state = Random()
else:
self.random_state = random_state
# Color function
if color_func is not None:
self.color_func = color_func
elif colormap is not None:
self.color_func = colormap_color_func(colormap)
else:
self.color_func = random_color_func
self.layout_ = []
# Handle mask
if self.mask is not None:
self.width = self.mask.shape[1]
self.height = self.mask.shape[0]
if self.mask.dtype == bool:
self.boolean_mask = self.mask.astype(np.uint8) * 255
elif self.mask.ndim == 3:
# White pixels (all channels == 255) are blocked
self.boolean_mask = np.where(
np.all(self.mask[:, :, :3] == 255, axis=-1), 255, 0
).astype(np.uint8)
else:
self.boolean_mask = self.mask.astype(np.uint8)
else:
self.boolean_mask = np.zeros((self.height, self.width), dtype=np.uint8)
# Initialize C++ grid (>0 = occupied)
self.grid = IntegralGrid(self.boolean_mask, self.height, self.width)
def generate_from_frequencies(self, frequencies, max_font_size=None):
"""Generate word cloud from a dict of {word: frequency}.
Parameters
----------
frequencies : dict
max_font_size : int or None
Override self.max_font_size for this call (used internally for
the automatic font-size estimation).
"""
sorted_freq = sorted(frequencies.items(), key=lambda x: x[1], reverse=True)
if not sorted_freq:
raise ValueError("Need at least 1 word to generate a word cloud.")
sorted_freq = sorted_freq[:self.max_words]
# Normalize so the top word = 1.0
max_freq = float(sorted_freq[0][1])
sorted_freq = [(w, f / max_freq) for w, f in sorted_freq]
# --- repeat: pad list up to max_words with down-weighted copies ---
if self.repeat and len(sorted_freq) < self.max_words:
import math
times_extend = math.ceil(self.max_words / len(sorted_freq)) - 1
base = list(sorted_freq)
downweight = base[-1][1]
for i in range(times_extend):
factor = downweight ** (i + 1)
sorted_freq.extend([(w, f * factor) for w, f in base])
sorted_freq = sorted_freq[:self.max_words]
self.words_ = dict(sorted_freq)
# --- auto max_font_size estimation (mirrors ref) ---
effective_max = max_font_size if max_font_size is not None else self.max_font_size
if effective_max is None:
if len(sorted_freq) == 1:
effective_max = self.height
else:
# Trial run with just the first 2 words to estimate a good max size.
# We must reinitialize the grid after so it is clean for the real run.
_repeat_bak = self.repeat
self.repeat = False
self.generate_from_frequencies(dict(sorted_freq[:2]),
max_font_size=self.height)
self.repeat = _repeat_bak
sizes = [s for _, s, *_ in self.layout_]
try:
effective_max = int(2 * sizes[0] * sizes[1] / (sizes[0] + sizes[1]))
except (IndexError, ZeroDivisionError):
effective_max = sizes[0] if sizes else self.height
# Reinitialize the C++ grid so the trial run does not consume space
self.grid = IntegralGrid(self.boolean_mask, self.height, self.width)
rs = self.random_state
# No PIL image needed during placement — C++ canvas handles collision.
# The PIL image is constructed lazily in to_image().
self.layout_ = []
font_size = int(effective_max)
last_freq = 1.0
# E1: font object cache {size -> ImageFont}
font_cache: dict = {}
def _get_font(size):
if size not in font_cache:
try:
font_cache[size] = ImageFont.truetype(self.font_path, size)
except IOError:
font_cache[size] = ImageFont.load_default()
return font_cache[size]
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.
# Dummy draw for textbbox measurement
_measure_img = Image.new("L", (1, 1))
_measure_draw = ImageDraw.Draw(_measure_img)
for idx, (word, freq) in enumerate(sorted_freq):
if freq == 0:
continue
# Relative-scaling font size adjustment (mirrors ref logic)
rs_val = self.relative_scaling
if rs_val != 0:
font_size = int(round(
(rs_val * (freq / float(last_freq)) + (1 - rs_val)) * font_size
))
if rs.random() < self.prefer_horizontal:
orientation = None
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]
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
y, x = pos
# Adjust position for margin (like ref: x,y += margin // 2)
draw_x = x + self.margin // 2
draw_y = y + self.margin // 2
# Get glyph bitmap and stamp into C++ canvas
font = _get_font(font_size)
transposed = ImageFont.TransposedFont(font, orientation=orientation)
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)
# Stamp glyph into C++ canvas + rebuild integral
self.grid.stamp_and_rebuild(glyph_arr, gh, gw, draw_y, draw_x)
color = self.color_func(
word=word,
font_size=font_size,
position=(y, x),
orientation=orientation,
font_path=self.font_path,
random_state=rs,
)
self.layout_.append((word, font_size, (y, x), orientation, color))
last_freq = freq
return self
def generate(self, text):
"""Generate word cloud from raw text (calls process_text + generate_from_frequencies)."""
return self.generate_from_text(text)
def generate_from_text(self, text):
"""Process *text* into word frequencies, then generate the word cloud."""
words = self.process_text(text)
self.generate_from_frequencies(words)
return self
def process_text(self, text):
"""Tokenize *text* and return ``{word: count}`` after filtering.
Applies regexp splitting, stopword removal, number/length filters,
plural normalization, and optional bigram collocation detection.
"""
min_len = self.min_word_length
pattern = r"\w[\w']+" if min_len <= 1 else r"\w[\w']+"
regexp = self.regexp if self.regexp is not None else pattern
words = re.findall(regexp, text)
# Strip possessive 's
words = [w[:-2] if w.lower().endswith("'s") else w for w in words]
if not self.include_numbers:
words = [w for w in words if not w.isdigit()]
if self.min_word_length:
words = [w for w in words if len(w) >= self.min_word_length]
stopwords_lower = {s.lower() for s in self.stopwords}
if self.collocations:
word_counts = unigrams_and_bigrams(
words, stopwords_lower,
normalize_plurals=self.normalize_plurals,
collocation_threshold=self.collocation_threshold,
)
else:
words = [w for w in words if w.lower() not in stopwords_lower]
word_counts, _ = process_tokens(words, self.normalize_plurals)
self.words_ = word_counts
return word_counts
def to_image(self):
"""Render the layout to a PIL Image, respecting *scale* and *contour*."""
s = self.scale
out_w = int(self.width * s)
out_h = int(self.height * s)
img = Image.new(self.mode, (out_w, out_h), self.background_color)
draw = ImageDraw.Draw(img)
for word, size, (y, x), orient, color in self.layout_:
try:
font = ImageFont.truetype(self.font_path, int(size * s))
except Exception:
font = ImageFont.load_default()
transposed_font = ImageFont.TransposedFont(font, orientation=orient)
draw.text((int(x * s), int(y * s)), word, font=transposed_font, fill=color)
return self._draw_contour(img)
def _draw_contour(self, img):
"""Draw mask contour on *img* if contour_width > 0."""
if self.mask is None or self.contour_width == 0:
return img
# Build boolean mask: True where drawing area (not blocked)
if self.mask.ndim == 3:
blocked = np.all(self.mask[:, :, :3] == 255, axis=-1)
else:
blocked = self.mask == 255
mask_uint8 = (~blocked).astype(np.uint8) * 255
contour = Image.fromarray(mask_uint8)
contour = contour.resize(img.size)
contour = contour.filter(ImageFilter.FIND_EDGES)
contour_arr = np.array(contour)
# Zero out border pixels so edges aren't drawn at image boundary
contour_arr[[0, -1], :] = 0
contour_arr[:, [0, -1]] = 0
# Gaussian blur controls perceived width (divide by 10 for sub-pixel)
radius = self.contour_width / 10
contour = Image.fromarray(contour_arr)
contour = contour.filter(ImageFilter.GaussianBlur(radius=radius))
contour_arr = np.array(contour) > 0
contour_3d = np.dstack([contour_arr] * 3)
result = np.array(img.convert("RGB")) * ~contour_3d
if self.contour_color != "black":
color_img = Image.new("RGB", img.size, self.contour_color)
result = result + np.array(color_img) * contour_3d
out = Image.fromarray(result.astype(np.uint8))
if self.mode == "RGBA":
out = out.convert("RGBA")
return out
def to_array(self, copy=None):
"""Return the word cloud as a numpy ndarray (H x W x channels)."""
image = self.to_image()
if copy is None:
return np.asarray(image)
try:
return np.asarray(image, copy=copy)
except TypeError:
return np.asarray(image)
def __array__(self, copy=None):
return self.to_array(copy=copy)
def to_file(self, filename):
"""Save to *filename* and return self (for chaining)."""
img = self.to_image()
img.save(filename, optimize=True)
return self
def recolor(self, random_state=None, color_func=None, colormap=None):
"""Re-apply colors to the current layout without regenerating it.
Parameters
----------
random_state : int, Random, or None
color_func : callable or None
colormap : str or matplotlib colormap or None
"""
if isinstance(random_state, int):
random_state = Random(random_state)
elif random_state is None:
random_state = Random()
if color_func is None:
if colormap is not None:
color_func = colormap_color_func(colormap)
else:
color_func = self.color_func
self.layout_ = [
(word, font_size, position, orientation,
color_func(word=word, font_size=font_size, position=position,
orientation=orientation, font_path=self.font_path,
random_state=random_state))
for word, font_size, position, orientation, _ in self.layout_
]
return self
def to_svg(self, filename=None):
"""Export as SVG with scale, correct rotation transforms and XML escaping.
Parameters
----------
filename : str or None
If given, write to this file. Otherwise return the SVG string.
"""
from xml.sax import saxutils
s = self.scale
out_w = int(self.width * s)
out_h = int(self.height * s)
# Derive font metadata from the actual font file
try:
_font_probe = ImageFont.truetype(self.font_path, 12)
raw_family, raw_style = _font_probe.getname()
except Exception:
raw_family, raw_style = "sans-serif", "Regular"
raw_style_lower = raw_style.lower()
font_weight = "bold" if "bold" in raw_style_lower else "normal"
if "italic" in raw_style_lower:
font_style = "italic"
elif "oblique" in raw_style_lower:
font_style = "oblique"
else:
font_style = "normal"
font_family = repr(raw_family)
lines = [
f'<svg width="{out_w}" height="{out_h}" xmlns="http://www.w3.org/2000/svg">',
f'<style>text{{font-family:{font_family};font-weight:{font_weight};'
f'font-style:{font_style};}}</style>',
]
if self.background_color is not None:
lines.append(
f'<rect width="100%" height="100%" style="fill:{self.background_color}"/>'
)
for word, size, (y, x), orient, color in self.layout_:
scaled_size = int(size * s)
try:
font = ImageFont.truetype(self.font_path, scaled_size)
except Exception:
font = ImageFont.load_default()
(size_x, size_y), (offset_x, offset_y) = font.font.getsize(word)
ascent, _ = font.getmetrics()
min_x = -offset_x
max_x = size_x - offset_x
max_y = ascent - offset_y
sx = int(x * s)
sy = int(y * s)
if orient == Image.ROTATE_90:
tx = sx + max_y
ty = sy + max_x - min_x
transform = f"translate({tx},{ty}) rotate(-90)"
else:
tx = sx + min_x
ty = sy + max_y
transform = f"translate({tx},{ty})"
lines.append(
f'<text transform="{transform}" font-size="{scaled_size}" '
f'style="fill:{color}">{saxutils.escape(word)}</text>'
)
lines.append("</svg>")
svg_str = "\n".join(lines)
if filename is not None:
with open(filename, "w", encoding="utf-8") as f:
f.write(svg_str)
return svg_str
+28
View File
@@ -0,0 +1,28 @@
from setuptools import setup, Extension, find_packages
# Define C++ extension
ewc_core = Extension(
'efficient_wordcloud.ewc_core',
sources=['efficient_wordcloud/src/ewc_core.cpp'],
extra_compile_args=['-O3', '-std=c++17'],
language='c++',
)
setup(
name='efficient_wordcloud',
version='1.0.0',
description='A high-performance Word Cloud generator using Spiral Search and Integral Images.',
author='Antigravity',
packages=find_packages(),
ext_modules=[ewc_core],
install_requires=[
'numpy>=1.19.0',
'pillow>=8.0.0',
'matplotlib>=3.3.0'
],
extras_require={
# Main script (`wordcloud_generate_hybrid.py`) reads Excel via pandas.
'script': ['pandas>=1.3.0'],
},
python_requires='>=3.7',
)
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 EfficientWordCloud contributors
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
# EfficientWordCloud Backend
Backend documentation has been consolidated under the project-level `docs/` directory.
Start here:
- [../docs/README.md](../docs/README.md)
- [../docs/PROJECT_STANDARD.md](../docs/PROJECT_STANDARD.md)
- [../docs/ALGORITHM.md](../docs/ALGORITHM.md)
- [../docs/CONFIG.md](../docs/CONFIG.md)
- [../docs/API.md](../docs/API.md)
The source of truth is the current code:
- `backend/core/config.py`
- `backend/core/pipeline.py`
- `backend/core/layout.py`
- `backend/core/weights.py`
- `backend/service/app.py`
- `backend/service/schemas.py`
- `backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp`
+21
View File
@@ -0,0 +1,21 @@
# EfficientWordCloud 后端说明
后端文档已统一迁移到项目根目录的 `docs/`
请从这里开始:
- [../docs/README.md](../docs/README.md)
- [../docs/PROJECT_STANDARD.md](../docs/PROJECT_STANDARD.md)
- [../docs/ALGORITHM.md](../docs/ALGORITHM.md)
- [../docs/CONFIG.md](../docs/CONFIG.md)
- [../docs/API.md](../docs/API.md)
源码事实来源:
- `backend/core/config.py`
- `backend/core/pipeline.py`
- `backend/core/layout.py`
- `backend/core/weights.py`
- `backend/service/app.py`
- `backend/service/schemas.py`
- `backend/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp`
Binary file not shown.
+1
View File
@@ -0,0 +1 @@
"""Core pipeline modules for the wordcloud generator."""
+396
View File
@@ -0,0 +1,396 @@
import argparse
import json
import logging
import os
import random
import sys
from pathlib import Path
import numpy as np
from PIL import ImageFont
from . import paths
BASE_DIR = paths.BASE_DIR
RUNTIME_DIR = paths.RUNTIME_DIR
ASSETS_DIR = paths.ASSETS_DIR
FONTS_DIR = paths.FONTS_DIR
PROJECT_DEFAULT_FONT = paths.PROJECT_DEFAULT_FONT
# 配置日志:只写入文件,不干扰控制台输出
logging.basicConfig(
filename=str(RUNTIME_DIR / "ewc_concurrency.log"),
level=logging.DEBUG,
format='%(asctime)s - %(levelname)s - %(message)s',
filemode='w'
)
# ==================== 0. 配置区(默认值) ====================
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
# --- Text Mode ---
MASK_TEXT = "A"
MASK_FONT_PATH = str(PROJECT_DEFAULT_FONT)
MASK_FONT_SIZE = 3000
# --- 自动画幅与清晰度 ---
AUTO_EXPAND_CANVAS = True
BASE_HD_WIDTH = 8000
BASE_HD_HEIGHT = 4000
MIN_READABLE_HEIGHT_PX = 25
WORK_SCALE = 0.25
# --- 阴阳刻 ---
FILL_ON = "BLACK"
# --- 数据与字体 ---
EXCEL_PATH = "四个方向汇总录取名单.xlsx"
DATA_COL_INDEX = 1
WEIGHT_COL_INDEX = None
WEIGHT_COL_NAME = None
REMOVE_DUPLICATES = False
ENABLE_STROKE_WEIGHTS = True
WC_FONT_PATH = str(PROJECT_DEFAULT_FONT)
FONT_FALLBACK_PATHS = (
"/System/Library/Fonts/STHeiti Medium.ttc",
"/System/Library/Fonts/Hiragino Sans GB.ttc",
"/Library/Fonts/Arial Unicode.ttf",
)
# --- 填充策略 ---
N_REPETITIONS = 1
TARGET_FILL_RATIO = 0.0 # 关闭填充率检测
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
# --- 智能字号搜索 ---
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
CANVAS_RETRY_GROWTH = 1.12
# --- 配色 ---
DARK_COLOR_PALETTE = (
"#102A43",
"#1F4E5F",
"#206A5D",
"#7B341E",
"#5D1F45",
)
LIGHT_COLOR_PALETTE = (
"#EAF2FF",
"#CDECF6",
"#CFF7E6",
"#FFD8C2",
"#F6D1EB",
)
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
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',
'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',
'OUTPUT_PNG', 'OUTPUT_SVG', 'DB_PATH', 'METRICS_FILE', 'SAVE_DEBUG_IMAGES', 'DEBUG_OUTPUT_DIR', 'SEED',
'LAYOUT_ORDER_MODE', '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',
'output_dir': 'OUTPUT_DIR',
'output_prefix': 'OUTPUT_PREFIX',
'mode': 'MODE',
'work_scale': 'WORK_SCALE',
'weight_col_index': 'WEIGHT_COL_INDEX',
'weight_col_name': 'WEIGHT_COL_NAME',
'min_font_size': 'USER_MIN_FONT_SIZE',
'max_font_size': 'USER_MAX_FONT_SIZE',
'font_color': 'FONT_COLOR',
'stroke_weights': 'ENABLE_STROKE_WEIGHTS',
}
CRITICAL_TYPE_CHECKS = {
'MODE': str,
'WORK_SCALE': (int, float),
'DATA_COL_INDEX': int,
'WEIGHT_COL_INDEX': (int, type(None)),
'WEIGHT_COL_NAME': (str, type(None)),
'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)),
}
DEFAULT_CONFIG = {k: v for k, v in globals().items() if k in KNOWN_CONFIG_KEYS}
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 模式)")
parser.add_argument("--output-dir", type=str, help="输出目录")
parser.add_argument("--output-prefix", type=str, help="输出文件前缀")
parser.add_argument("--mode", type=str, choices=["TEXT", "IMAGE"], help="掩膜模式")
parser.add_argument("--work-scale", type=float, help="运算缩放比例")
parser.add_argument("--weight-col-index", type=int, help="Excel 权重列索引")
parser.add_argument("--weight-col-name", type=str, help="Excel 权重列名(优先于索引)")
parser.add_argument("--min-font-size", type=float, help="覆盖最小字号")
parser.add_argument("--max-font-size", type=float, help="覆盖最大字号")
return parser.parse_args()
def _warn(msg):
print(f"[WARN] {msg}")
def _resolve_path(path_str):
p = Path(path_str)
if p.is_absolute():
return p
return BASE_DIR / p
def _with_prefix(filename, prefix):
if not prefix:
return filename
return f"{prefix}_{filename}"
def apply_json_config(config_path):
cfg_path = _resolve_path(config_path)
if not cfg_path.exists():
print(f"错误: 配置文件不存在: {cfg_path}")
sys.exit(1)
try:
with cfg_path.open("r", encoding="utf-8") as f:
data = json.load(f)
except Exception as e:
print(f"错误: 读取配置文件失败: {e}")
sys.exit(1)
if not isinstance(data, dict):
print("错误: 配置文件顶层必须是 JSON 对象")
sys.exit(1)
normalized_data = {}
for key, value in data.items():
key_upper = CONFIG_ALIASES.get(key, key)
normalized_data[key_upper] = value
for key in normalized_data.keys():
if key not in KNOWN_CONFIG_KEYS:
_warn(f"未知配置键: {key}")
for key, expected in CRITICAL_TYPE_CHECKS.items():
if key in normalized_data and not isinstance(normalized_data[key], expected):
print(f"错误: 配置键 {key} 类型错误,期望 {expected},实际 {type(normalized_data[key])}")
sys.exit(1)
for key, value in normalized_data.items():
if key in KNOWN_CONFIG_KEYS:
globals()[key] = value
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',
'output_dir': 'OUTPUT_DIR',
'output_prefix': 'OUTPUT_PREFIX',
'mode': 'MODE',
'work_scale': 'WORK_SCALE',
'weight_col_index': 'WEIGHT_COL_INDEX',
'weight_col_name': 'WEIGHT_COL_NAME',
'min_font_size': 'USER_MIN_FONT_SIZE',
'max_font_size': 'USER_MAX_FONT_SIZE',
}
for arg_key, cfg_key in mapping.items():
value = getattr(args, arg_key)
if value is not None:
globals()[cfg_key] = value
def _resolve_font_path(configured_path, fallback_paths, *, role):
candidates = [_resolve_path(configured_path), *[Path(path) for path in fallback_paths]]
errors = []
for index, candidate in enumerate(candidates):
if not candidate.exists():
errors.append(f"{candidate}: missing")
continue
try:
ImageFont.truetype(str(candidate), 32)
if index == 0:
print(f"[Font] {role} 使用项目字体: {candidate}")
else:
_warn(f"{role} 字体未命中项目内资源,回退到系统字体: {candidate}")
return str(candidate)
except OSError as exc:
errors.append(f"{candidate}: {exc}")
print(f"错误: {role} 字体初始化失败。候选路径: {errors}")
sys.exit(1)
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
# 运行时派生字段
MIN_FONT_SIZE = int(MIN_READABLE_HEIGHT_PX * WORK_SCALE)
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))
MASK_FONT_PATH = _resolve_font_path(MASK_FONT_PATH, FONT_FALLBACK_PATHS, role="mask")
WC_FONT_PATH = _resolve_font_path(WC_FONT_PATH, FONT_FALLBACK_PATHS, role="layout")
OUTPUT_DIR = str(_resolve_path(OUTPUT_DIR))
Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True)
OUTPUT_PNG = str(Path(OUTPUT_DIR) / _with_prefix(Path(OUTPUT_PNG).name, OUTPUT_PREFIX))
OUTPUT_SVG = str(Path(OUTPUT_DIR) / _with_prefix(Path(OUTPUT_SVG).name, OUTPUT_PREFIX))
DB_PATH = str(Path(OUTPUT_DIR) / _with_prefix(Path(DB_PATH).name, OUTPUT_PREFIX))
METRICS_FILE = str(Path(OUTPUT_DIR) / _with_prefix(Path(METRICS_FILE).name, OUTPUT_PREFIX))
DEBUG_OUTPUT_DIR = str(Path(OUTPUT_DIR) / Path(DEBUG_OUTPUT_DIR).name)
def set_random_seed():
if SEED is None:
return
np.random.seed(SEED)
random.seed(SEED)
print(f"[Seed] 使用固定随机种子: {SEED}")
def get_output_background():
return "black" if FILL_ON == "WHITE" else "white"
def get_output_palette():
return LIGHT_COLOR_PALETTE if FILL_ON == "WHITE" else DARK_COLOR_PALETTE
def write_metrics(metrics):
try:
with Path(METRICS_FILE).open("w", encoding="utf-8") as f:
json.dump(metrics, f, ensure_ascii=False, indent=2)
print(f"已保存: {METRICS_FILE}")
except Exception as e:
_warn(f"写入 metrics 失败(不影响主产物): {e}")
+15
View File
@@ -0,0 +1,15 @@
import sys
from .paths import BASE_DIR
lib_path = str(BASE_DIR / "EfficientWordCloud")
if lib_path not in sys.path:
sys.path.insert(0, lib_path)
try:
from efficient_wordcloud import EfficientWordCloud
except ImportError:
print("错误: 找不到 EfficientWordCloud 库。请确保已编译并安装该库。")
sys.exit(1)
__all__ = ["EfficientWordCloud"]
+25
View File
@@ -0,0 +1,25 @@
from matplotlib.font_manager import FontProperties
from PIL import ImageFont
from . import config
_global_font_cache = {}
_font_properties_cache = {}
def get_cached_font(font_path, size):
key = (font_path, size)
if key not in _global_font_cache:
try:
_global_font_cache[key] = ImageFont.truetype(font_path, size)
except IOError as e:
config._warn(f"字体加载失败,回退默认字体: path={font_path}, size={size}, error={e}")
_global_font_cache[key] = ImageFont.load_default()
return _global_font_cache[key]
def get_font_properties(font_path, size):
key = (font_path, size)
if key not in _font_properties_cache:
_font_properties_cache[key] = FontProperties(fname=font_path, size=size)
return _font_properties_cache[key]
+560
View File
@@ -0,0 +1,560 @@
import math
import random
import numpy as np
from PIL import Image, ImageDraw, 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
def normalize_relative_scores(values):
if not values:
return []
v_min = min(values)
v_max = max(values)
if math.isclose(v_min, v_max):
return [1.0 for _ in values]
scale = v_max - v_min
return [(value - v_min) / scale for value in values]
def build_log_rank_scores(freq_list, *, per_word=False):
if not freq_list:
return []
if per_word:
word_weights = {}
for word, freq in freq_list:
f = max(float(freq), 1e-6)
if word not in word_weights or f > word_weights[word]:
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}
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]
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)
]
def pick_palette_color(relative_score):
if config.FONT_COLOR:
return config.FONT_COLOR
palette = config.LIGHT_COLOR_PALETTE if config.FILL_ON == "WHITE" else config.DARK_COLOR_PALETTE
if not palette:
return "#111111"
idx = min(len(palette) - 1, max(0, int(round((1.0 - relative_score) * (len(palette) - 1)))))
return palette[idx]
def _build_layout_sequence(sorted_freq, max_words, layout_order_mode, 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)
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
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())
elif isinstance(frequencies, list):
freq_list = frequencies
else:
raise ValueError("frequencies 必须是字典或 (word, freq) 列表")
sorted_freq = sorted(freq_list, key=lambda x: x[1], reverse=True)
layout_sequence = _build_layout_sequence(
sorted_freq,
self.max_words,
config.LAYOUT_ORDER_MODE,
config.LAYOUT_SEED,
)
if not layout_sequence:
return self
self.layout_ = []
per_word_scores = build_log_rank_scores(freq_list, per_word=True)
word_to_score = {}
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]
# 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)
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)))
target_font_sizes.append(f_size)
gap_fill_list = [] # 收集未成功放置的词,用于第二轮填充
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
while current_size >= min_attempt_size:
orientation = None
rotate = rotation_flags[idx]
if rotate:
orientation = Image.ROTATE_90
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)} 个词")
return self
def to_image(self):
img = Image.new(self.mode, (self.width, self.height), self.background_color)
draw = ImageDraw.Draw(img)
for word, size, (y, x), orient, color in self.layout_:
font = get_cached_font(self.font_path, size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
draw.text((x, y), word, font=font, fill=color)
return img
def to_svg(self, filename):
background = self.background_color
with open(filename, "w", encoding="utf-8") as f:
f.write(
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="{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')
f.write("</svg>\n")
def to_svg_stroke(self, filename, stroke_color="#000000", stroke_width=1.0):
"""生成描边版 SVG,适合激光雕刻机使用(描边路径,无填充)。"""
with open(filename, "w", encoding="utf-8") as f:
f.write(
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(
f'<path d="{path}" 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"):
"""生成点阵填充 SVG:文字区域用密排小圆点填充,适合激光雕刻逐点打标。"""
from .render import render_layout_occupancy
occ = render_layout_occupancy(self.layout_, (self.height, self.width), self.font_path)
occ_arr = np.array(occ)
with open(filename, "w", encoding="utf-8") as f:
f.write(
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')
half = dot_spacing / 2
dot_count = 0
h, w = occ_arr.shape
for gy in range(0, h, dot_spacing):
for gx in range(0, w, dot_spacing):
cy = min(gy + int(half), h - 1)
cx = min(gx + int(half), w - 1)
if occ_arr[cy, cx]:
f.write(
f'<circle cx="{cx}" cy="{cy}" r="{dot_radius}" '
f'fill="{dot_color}" stroke="none"/>\n'
)
dot_count += 1
f.write("</svg>\n")
return dot_count
def to_svg_custom(self, filename, fill_mode="fill", do_stroke=False,
dot_spacing=10, dot_radius=2, color="#000000",
line_spacing=6, line_width=1, line_angle=0,
ring_radius=3, ring_width=1, ring_spacing=8):
"""统一 SVG 导出:fill_mode=fill|dot|line|ring,可叠加描边。"""
# 预先构建所有文字路径(fill / dot 模式共用)
text_paths = []
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)
text_paths.append((path, tx, ty))
except Exception as exc:
config._warn(f"SVG path 导出失败,跳过: {word}, error={exc}")
with open(filename, "w", encoding="utf-8") as f:
f.write(
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')
if fill_mode == "dot":
# 点阵模式:用 SVG pattern 平铺圆点 + clipPath 裁剪到文字形状
f.write('<defs>\n')
f.write(f' <pattern id="dot-pat" x="0" y="0" width="{dot_spacing}" height="{dot_spacing}" patternUnits="userSpaceOnUse">\n')
half = dot_spacing / 2
f.write(f' <circle cx="{half}" cy="{half}" r="{dot_radius}" fill="{color}"/>\n')
f.write(' </pattern>\n')
self._write_text_clip(f, text_paths)
f.write('</defs>\n')
f.write(f'<rect width="{self.width}" height="{self.height}" fill="url(#dot-pat)" clip-path="url(#text-clip)"/>\n')
elif fill_mode == "line":
# 线条填充:用 matplotlib Path 渲染占用蒙版(与 SVG 完全对齐)
import math as _m
occ = render_path_occupancy(self.layout_, (self.height, self.width), self.font_path)
angle = line_angle % 360
rad = _m.radians(angle)
cos_a, sin_a = _m.cos(rad), _m.sin(rad)
h, w = occ.shape
step = 1 # 逐像素采样,保证线段连续
# 垂直方向的总范围(确保覆盖整个画布)
perp_max = abs(h * cos_a) + abs(w * sin_a)
n_lines = max(1, int(perp_max / line_spacing) + 1)
sw = f'{line_width:g}'
path_parts = []
for i in range(n_lines):
d0 = (i - n_lines // 2) * line_spacing
sx = -d0 * sin_a
sy = d0 * cos_a
n_steps = int(perp_max) + 1
run_start = None
for s in range(n_steps + 1):
px = sx + s * step * cos_a
py = sy + s * step * sin_a
ix, iy = int(round(px)), int(round(py))
inside = (0 <= iy < h and 0 <= ix < w and occ[iy, ix])
if inside:
if run_start is None:
run_start = (px, py)
else:
if run_start is not None:
ex = px - step * cos_a
ey = py - step * sin_a
path_parts.append(f'M{run_start[0]:.1f} {run_start[1]:.1f}L{ex:.1f} {ey:.1f}')
run_start = None
if run_start is not None:
ex = sx + n_steps * step * cos_a
ey = sy + n_steps * step * sin_a
path_parts.append(f'M{run_start[0]:.1f} {run_start[1]:.1f}L{ex:.1f} {ey:.1f}')
if path_parts:
f.write(f'<path d="{" ".join(path_parts)}" fill="none" stroke="{color}" stroke-width="{sw}" stroke-linecap="round"/>\n')
elif fill_mode == "ring":
# 空心圆点填充:闭合路径,激光机可描一圈
occ = render_path_occupancy(self.layout_, (self.height, self.width), self.font_path)
h, w = occ.shape
r = ring_radius
sw = f'{ring_width:g}'
circle_parts = []
for gy in range(r, h - r, ring_spacing):
for gx in range(r, w - r, ring_spacing):
if not occ[gy, gx]:
continue
lx = gx - r
rx = gx + r
circle_parts.append(
f'M{lx} {gy}A{r} {r} 0 1 0 {rx} {gy}A{r} {r} 0 1 0 {lx} {gy}Z'
)
if circle_parts:
f.write(f'<path d="{" ".join(circle_parts)}" fill="none" stroke="{color}" stroke-width="{sw}"/>\n')
# 只有 fill 模式和显式描边时才输出 matplotlib 文字路径
# ring/line 模式用 PIL occupancy mask 生成填充,不需要文字轮廓
if fill_mode == "fill" or do_stroke:
for path, tx, ty in text_paths:
fill_attr = color if fill_mode == "fill" else "none"
stroke_attr = f'stroke="{color}" stroke-width="1" stroke-linejoin="round" stroke-linecap="round"' if do_stroke else ""
f.write(f'<path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)" fill="{fill_attr}" {stroke_attr}/>\n')
f.write("</svg>\n")
@staticmethod
def _write_text_clip(f, text_paths):
"""将文字路径写入 <clipPath id="text-clip">(调用方负责 <defs> 开闭)。"""
f.write(' <clipPath id="text-clip">\n')
for path, tx, ty in text_paths:
f.write(f' <path d="{path}" transform="translate({tx:.3f} {ty:.3f}) scale(1 -1)"/>\n')
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():
if code == MplPath.MOVETO:
x, y = vertices
parts.append(f"M{x:.3f} {y:.3f}")
elif code == MplPath.LINETO:
x, y = vertices
parts.append(f"L{x:.3f} {y:.3f}")
elif code == MplPath.CURVE3:
x1, y1, x2, y2 = vertices
parts.append(f"Q{x1:.3f} {y1:.3f} {x2:.3f} {y2:.3f}")
elif code == MplPath.CURVE4:
x1, y1, x2, y2, x3, y3 = vertices
parts.append(
f"C{x1:.3f} {y1:.3f} {x2:.3f} {y2:.3f} {x3:.3f} {y3:.3f}"
)
elif code == MplPath.CLOSEPOLY:
parts.append("Z")
return " ".join(parts)
def render_path_occupancy(layout_data, canvas_shape, font_path):
"""渲染文字占用蒙版:字形笔画=1,字内空洞(如口)=0,外部=0。
使用 PIL 渲染文字蒙版(与画布坐标完全对齐)+ 边界泛洪填充来区分外部区域与字内空洞。
layout_data: [(word, size, (y, x), orient, color), ...] 同 self.layout_
"""
from collections import deque
h, w = canvas_shape
if not layout_data:
return np.zeros((h, w), dtype=np.uint8)
# 用 PIL 渲染文字蒙版(坐标系与 to_image() 完全一致)
mask = Image.new("L", (w, h), 0)
draw = ImageDraw.Draw(mask)
for word, size, (y, x), orient, _color in layout_data:
font = get_cached_font(font_path, size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
draw.text((x, y), word, font=font, fill=255)
occ_raw = (np.array(mask) > 127).astype(np.uint8)
# 泛洪填充:从边框出发标记所有与外部连通的白色区域
# 口 等闭合字符的内部空洞不会与边框连通,因此正确保留为空
outside = np.zeros_like(occ_raw, dtype=np.uint8)
q = deque()
for x in range(w):
if occ_raw[0, x]:
q.append((0, x))
outside[0, x] = 1
if occ_raw[h - 1, x]:
q.append((h - 1, x))
outside[h - 1, x] = 1
for y in range(1, h - 1):
if occ_raw[y, 0]:
q.append((y, 0))
outside[y, 0] = 1
if occ_raw[y, w - 1]:
q.append((y, w - 1))
outside[y, w - 1] = 1
while q:
cy, cx = q.popleft()
for dy, dx in ((-1, 0), (1, 0), (0, -1), (0, 1)):
ny, nx = cy + dy, cx + dx
if 0 <= ny < h and 0 <= nx < w and occ_raw[ny, nx] and not outside[ny, nx]:
outside[ny, nx] = 1
q.append((ny, nx))
# 最终蒙版:文字笔画=1,外部和字内空洞=0
return (occ_raw & (~outside).astype(np.uint8)).astype(np.uint8)
+145
View File
@@ -0,0 +1,145 @@
import os
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw
from . import config
from .fonts import get_cached_font
def analyze_mask(mask):
free = mask == 0
free_area = int(np.sum(free))
total_area = int(mask.size)
free_ratio = (free_area / total_area) if total_area else 0.0
rows = np.where(np.any(free, axis=1))[0]
cols = np.where(np.any(free, axis=0))[0]
bbox_fill_ratio = free_ratio
bbox = None
if rows.size and cols.size:
y0, y1 = int(rows[0]), int(rows[-1])
x0, x1 = int(cols[0]), int(cols[-1])
bbox = (x0, y0, x1, y1)
bbox_area = max(1, (x1 - x0 + 1) * (y1 - y0 + 1))
bbox_fill_ratio = free_area / bbox_area
return {
"free_area": free_area,
"free_ratio": free_ratio,
"bbox": bbox,
"bbox_fill_ratio": bbox_fill_ratio,
}
def normalize_mask_for_fill(mask):
if config.FILL_ON == "WHITE":
return np.where(mask > 128, 0, 255).astype(np.uint8)
return np.where(mask > 128, 255, 0).astype(np.uint8)
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)
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
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
if required_canvas_area > current_area:
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
print(f"[Auto-Size] 扩展画布: {base_w}x{base_h} -> {new_w}x{new_h}")
return new_w, new_h
return base_w, base_h
def prepare_mask(target_w, target_h):
if config.MODE == "TEXT":
img_mask_gen = Image.new("L", (target_w, target_h), 255)
draw_mask = ImageDraw.Draw(img_mask_gen)
font_size = min(config.MASK_FONT_SIZE, int(target_h * 0.75))
font_mask = get_cached_font(config.MASK_FONT_PATH, font_size)
bbox = draw_mask.textbbox((0, 0), config.MASK_TEXT, font=font_mask)
text_w = bbox[2] - bbox[0]
text_h = bbox[3] - bbox[1]
x_pos = (target_w - text_w) // 2
y_pos = (target_h - text_h) // 2
draw_mask.text((x_pos, y_pos), config.MASK_TEXT, fill=0, font=font_mask)
mask_hd = np.array(img_mask_gen)
mask_hd = normalize_mask_for_fill(mask_hd)
return mask_hd, (target_w, target_h), None
if config.MODE == "IMAGE":
if not os.path.exists(config.MASK_IMAGE_PATH):
raise FileNotFoundError(f"找不到掩膜文件 {config.MASK_IMAGE_PATH}")
img_raw = Image.open(config.MASK_IMAGE_PATH)
if img_raw.mode in ('RGBA', 'LA') or (img_raw.mode == 'P' and 'transparency' in img_raw.info):
img_bg = Image.new('RGB', img_raw.size, (255, 255, 255))
if img_raw.mode == 'P':
img_raw = img_raw.convert('RGBA')
img_bg.paste(img_raw, mask=img_raw.split()[-1])
img_src = img_bg.convert('L')
else:
img_src = img_raw.convert("L")
src_w, src_h = img_src.size
if config.IMAGE_CANVAS_MODE == "AUTO" or (target_w is None and target_h is None):
final_w, final_h = src_w, src_h
elif config.IMAGE_CANVAS_MODE == "WIDTH":
final_w = target_w
final_h = int(round(final_w * src_h / src_w))
elif config.IMAGE_CANVAS_MODE == "HEIGHT":
final_h = target_h
final_w = int(round(final_h * src_w / src_h))
else:
final_w, final_h = target_w, target_h
if (final_w, final_h) != (src_w, src_h):
print(f"正在重采样掩膜: {src_w}x{src_h} -> {final_w}x{final_h} (LANCZOS)")
img_src = img_src.resize((final_w, final_h), Image.Resampling.LANCZOS)
threshold = 200
img_src = img_src.point(lambda p: 255 if p > threshold else 0)
# 自动填充边角区域为可填充(黑色)
if config.FILL_CORNERS:
arr = np.array(img_src)
corner_h = int(final_h * config.CORNER_FILL_RATIO)
corner_w = int(final_w * config.CORNER_FILL_RATIO)
# 四个角落区域设为黑色(可填充)
arr[:corner_h, :corner_w] = 0 # 左上
arr[:corner_h, -corner_w:] = 0 # 右上
arr[-corner_h:, :corner_w] = 0 # 左下
arr[-corner_h:, -corner_w:] = 0 # 右下
img_src = Image.fromarray(arr)
print(f"[边角填充] 四角区域 {corner_w}x{corner_h} 已设为可填充")
if config.SAVE_DEBUG_IMAGES:
debug_dir = config.DEBUG_OUTPUT_DIR
os.makedirs(debug_dir, exist_ok=True)
img_src.save(str(Path(debug_dir) / "mask_src.png"))
mask_hd = np.array(img_src)
mask_hd = normalize_mask_for_fill(mask_hd)
return mask_hd, (final_w, final_h), None
raise ValueError(f"未知 MODE: {config.MODE}")
def apply_safe_padding(mask, padding_px=4, padding_ratio=0.003, max_padding=20):
h, w = mask.shape
padding = max(padding_px, int(min(h, w) * padding_ratio))
padding = min(padding, max_padding)
if padding <= 0:
return mask
mask[:padding, :] = 255
mask[-padding:, :] = 255
mask[:, :padding] = 255
mask[:, -padding:] = 255
return mask
+14
View File
@@ -0,0 +1,14 @@
from pathlib import Path
import os
BASE_DIR = Path(__file__).resolve().parents[1]
RUNTIME_DIR = BASE_DIR / ".runtime"
MPL_CONFIG_DIR = RUNTIME_DIR / "matplotlib"
RUNTIME_DIR.mkdir(parents=True, exist_ok=True)
MPL_CONFIG_DIR.mkdir(parents=True, exist_ok=True)
os.environ.setdefault("MPLCONFIGDIR", str(MPL_CONFIG_DIR))
ASSETS_DIR = BASE_DIR / "assets"
FONTS_DIR = ASSETS_DIR / "fonts"
PROJECT_DEFAULT_FONT = Path("assets/fonts/STHeiti Medium.ttc")
+586
View File
@@ -0,0 +1,586 @@
import logging
import os
import sqlite3
import sys
import time
from pathlib import Path
import numpy as np
from PIL import Image, ImageDraw, ImageFont
import pandas as pd
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
log = logging.getLogger("core.pipeline")
def run_generation_pass(names, frequencies_data, name_weights_map, mask_hd, real_hd_w, real_hd_h):
log.info("[run_generation_pass] 开始")
log.info(" 输入: %d 词 | HD尺寸: %dx%d", len(names), real_hd_w, real_hd_h)
w_small = max(1, int(real_hd_w * config.WORK_SCALE))
h_small = max(1, int(real_hd_h * config.WORK_SCALE))
img_small = Image.fromarray(mask_hd).resize((w_small, h_small), Image.NEAREST)
mask_small = np.array(img_small)
apply_safe_padding(mask_small)
log.info(" 运算网格: %dx%d (WORK_SCALE=%.4f)", w_small, h_small, config.WORK_SCALE)
log.info(" mask_small 统计: 总像素=%d, 空闲像素=%d, 空闲率=%.4f",
mask_small.size, int(np.sum(mask_small == 0)),
int(np.sum(mask_small == 0)) / mask_small.size if mask_small.size else 0)
if config.SAVE_DEBUG_IMAGES:
debug_dir = Path(config.DEBUG_OUTPUT_DIR)
debug_dir.mkdir(parents=True, exist_ok=True)
Image.fromarray(mask_hd).save(str(debug_dir / "mask_hd.png"))
Image.fromarray(mask_small).save(str(debug_dir / "mask_small.png"))
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))
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
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)
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
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
if max_font <= min_font:
config._warn(f"字号区间无效: min={min_font}, max={max_font},自动修正 max=min+1")
max_font = min_font + 1
return 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))
wc = OptimizedEfficientWordCloud(
width=w_small,
height=h_small,
mask=mask_small,
font_path=config.WC_FONT_PATH,
max_words=total_target,
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,
)
if config.ENABLE_STRATIFIED_SAMPLING:
wc.grid.reorder_stratified(config.STRATIFIED_BANDS)
wc.generate_from_frequencies(frequencies_data)
return wc, len(wc.layout_)
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)
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
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
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
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:
return {
"wc": None,
"fill_ratio": 0.0,
"occ_fast": None,
"w_small": w_small,
"h_small": h_small,
"base_min_font": base_min_font,
"base_max_font": base_max_font,
"mask_small": mask_small,
"size_scale": final_scale,
}
fill_ratio, occ_fast = compute_fill_ratio_fast(final_wc.layout_, mask_small, config.WC_FONT_PATH)
print(f"填充率: {fill_ratio:.3f}")
log.info("[填充率] 初始填充率: %.4f (最低要求: %.4f)", fill_ratio, config.MIN_ACCEPT_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
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
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")
)
break
if new_fill > fill_ratio:
low_scale = mid_scale
else:
high_scale = mid_scale
retry_round += 1
if fill_ratio < config.MIN_ACCEPT_FILL_RATIO:
final_wc = best_wc
fill_ratio = best_fill
print(f"最终填充率: {fill_ratio:.3f}")
return {
"wc": final_wc,
"fill_ratio": fill_ratio,
"occ_fast": occ_fast,
"w_small": w_small,
"h_small": h_small,
"base_min_font": base_min_font,
"base_max_font": base_max_font,
"mask_small": mask_small,
"size_scale": final_scale,
}
def main():
t_start = time.time()
print("--- 1. 读取数据 ---")
log.info("=" * 60)
log.info("[Pipeline] main() 开始")
log.info(" EXCEL_PATH = %s", config.EXCEL_PATH)
log.info(" DATA_COL = %d", config.DATA_COL_INDEX)
log.info(" MODE = %s", config.MODE)
log.info(" FILL_ON = %s", config.FILL_ON)
log.info(" WORK_SCALE = %.4f", config.WORK_SCALE)
log.info(" SEED = %s", config.SEED)
names = []
df = None
if os.path.exists(config.EXCEL_PATH):
try:
df = pd.read_excel(config.EXCEL_PATH)
raw_names = df.iloc[:, config.DATA_COL_INDEX].dropna().astype(str)
if config.REMOVE_DUPLICATES:
names = raw_names.unique().tolist()
print(f"模式: 去重 | 数量: {len(names)}")
else:
names = raw_names.tolist()
print(f"模式: 保留重复 | 数量: {len(names)}")
except Exception as e:
print(f"读取 Excel 失败: {e}")
log.error("读取 Excel 失败: %s", e)
sys.exit(1)
else:
count = 12000
print(f"未找到Excel,使用测试数据: {count}")
log.info("未找到 Excel,使用测试数据: %d", count)
names = [f"测试_{i % 100}" for i in range(count)]
input_count = len(names)
log.info("[阶段1] 读取完成: input_count=%d, 去重=%s", input_count, config.REMOVE_DUPLICATES)
if names:
sample = names[:min(10, len(names))]
log.info(" 前10个名字: %s", sample)
print("--- 2. 智能画幅计算 ---")
log.info("--- 阶段2: 智能画幅计算 ---")
avg_len = sum(len(n) for n in names) / len(names) if names else 3
log.info(" 平均名字长度: %.2f 字符", avg_len)
log.info(" BASE_HD: %dx%d", config.BASE_HD_WIDTH, config.BASE_HD_HEIGHT)
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",
probe_w, probe_h, probe_stats['free_ratio'], probe_stats['bbox_fill_ratio'])
if probe_stats.get('bbox'):
log.info(" Probe bbox: %s", probe_stats['bbox'])
print(f"[Mask Probe] 可填充比例={probe_stats['free_ratio']:.3f}")
hd_w, hd_h = calculate_dynamic_dimensions(probe_w, probe_h, len(names), avg_len, probe_stats)
log.info(" 动态画幅计算结果: %dx%d", hd_w, hd_h)
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)
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'])
if mask_stats.get('bbox'):
log.info(" bbox=%s", mask_stats['bbox'])
print(f"[Mask Final] 可填充比例={mask_stats['free_ratio']:.3f}")
print("--- 4. 计算权重 ---")
log.info("--- 阶段4: 计算权重 ---")
t_weights = time.time()
if config.ENABLE_STROKE_WEIGHTS:
stroke_weights_map = get_stroke_complexity_batch(names, config.WC_FONT_PATH)
log.info(" 笔画权重计算完成: %d 个词, 耗时=%.3fs", len(stroke_weights_map), time.time() - t_weights)
else:
stroke_weights_map = {}
print("笔画权重已关闭")
log.info(" 笔画权重已关闭")
# 打印权重分布统计
if stroke_weights_map:
w_vals = list(stroke_weights_map.values())
log.info(" 笔画权重分布: min=%.1f, max=%.1f, avg=%.1f, median=%.1f",
min(w_vals), max(w_vals), sum(w_vals)/len(w_vals),
sorted(w_vals)[len(w_vals)//2])
sample_items = list(stroke_weights_map.items())[:5]
log.info(" 笔画权重样本: %s", sample_items)
excel_weights_map = extract_weights_from_df(df, names) if df is not None else {}
if excel_weights_map:
print(f"Excel 权重生效: {len(excel_weights_map)} 个词")
log.info(" Excel 权重生效: %d 个词", len(excel_weights_map))
ew_vals = list(excel_weights_map.values())
log.info(" Excel 权重分布: min=%.1f, max=%.1f, avg=%.1f",
min(ew_vals), max(ew_vals), sum(ew_vals)/len(ew_vals))
elif config.WEIGHT_COL_NAME is not None or config.WEIGHT_COL_INDEX is not None:
fallback = "笔画权重" if config.ENABLE_STROKE_WEIGHTS else "均等权重"
print(f"Excel 权重不可用,已回退{fallback}")
log.info(" Excel 权重不可用,已回退%s", fallback)
name_weights_map = dict(stroke_weights_map)
name_weights_map.update(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
generation_result = None
t_gen = time.time()
while True:
log.info("[画布] 第%d轮生成 pass, 当前画布: %dx%d", canvas_retry_round + 1, real_hd_w, real_hd_h)
generation_result = run_generation_pass(
names,
frequencies_data,
name_weights_map,
mask_hd,
real_hd_w,
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)
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)
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}")
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)
mask_stats = analyze_mask(mask_hd)
final_wc = generation_result["wc"]
fill_ratio = generation_result["fill_ratio"]
w_small = generation_result["w_small"]
h_small = generation_result["h_small"]
log.info("[阶段5完成] 生成耗时=%.2fs, fill_ratio=%.4f, size_scale=%.4f",
time.time() - t_gen, fill_ratio, generation_result["size_scale"])
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))
log.info(" HD layout 词数: %d", len(hd_layout))
log.info(" HD 画布: %dx%d", real_hd_w, real_hd_h)
if hd_layout:
sample = hd_layout[:3]
for s in sample:
log.info(" 样本: text='%s', size=%d, pos=(%d,%d), orient=%s, color=%s",
s[0], s[1], s[2][1], s[2][0], s[3], s[4])
final_wc.layout_ = hd_layout
final_wc.width = real_hd_w
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)
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)
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(" 渲染耗时: %.2fs", time.time() - t_render)
log.info("--- 阶段7: 写入数据库 ---")
t_db = time.time()
try:
conn = sqlite3.connect(config.DB_PATH)
cursor = conn.cursor()
cursor.execute("DROP TABLE IF EXISTS word_locations")
cursor.execute("""
CREATE TABLE word_locations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT,
x INTEGER,
y INTEGER,
font_size INTEGER,
color TEXT,
orientation TEXT,
box_x INTEGER,
box_y INTEGER,
box_width INTEGER,
box_height INTEGER
)
""")
bbox_canvas = Image.new("L", (1, 1), 0)
bbox_draw = ImageDraw.Draw(bbox_canvas)
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)
db_data.append(
(
name,
x,
y,
font_size,
color,
orientation,
bbox[0],
bbox[1],
bbox[2] - bbox[0],
bbox[3] - bbox[1],
)
)
cursor.executemany(
"""
INSERT INTO word_locations
(name, x, y, font_size, color, orientation, box_x, box_y, box_width, box_height)
VALUES (?,?,?,?,?,?,?,?,?,?)
""",
db_data,
)
conn.commit()
conn.close()
log.info(" DB 写入完成: %s, %d 行, 耗时=%.3fs", config.DB_PATH, len(db_data), time.time() - t_db)
except sqlite3.Error as e:
print(f"DB Error: {e}")
log.error(" DB 写入失败: %s", e)
sys.exit(1)
elapsed = time.time() - t_start
placed_count = len(final_wc.layout_)
metrics = {
"seed": config.SEED,
"layout_order_mode": config.LAYOUT_ORDER_MODE,
"layout_seed": config.LAYOUT_SEED,
"input_count": input_count,
"placed_count": placed_count,
"fill_ratio": fill_ratio,
"elapsed_seconds": round(elapsed, 4),
"font_info": {
"layout_font_path": config.WC_FONT_PATH,
"mask_font_path": config.MASK_FONT_PATH,
"palette": list(config.get_output_palette()),
"background": config.get_output_background(),
},
"mask_info": {
"free_ratio": round(mask_stats["free_ratio"], 6),
"bbox_fill_ratio": round(mask_stats["bbox_fill_ratio"], 6),
"canvas_retry_rounds": canvas_retry_round,
},
"canvas_info": {
"hd_width": real_hd_w,
"hd_height": real_hd_h,
"work_width": w_small,
"work_height": h_small,
"work_scale": config.WORK_SCALE,
},
"output_paths": {
"png": config.OUTPUT_PNG,
"svg": config.OUTPUT_SVG,
"db": config.DB_PATH,
"metrics": config.METRICS_FILE,
"debug_dir": config.DEBUG_OUTPUT_DIR,
},
"config_snapshot": {
"mode": config.MODE,
"excel_path": config.EXCEL_PATH,
"mask_image_path": config.MASK_IMAGE_PATH,
"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,
}
}
config.write_metrics(metrics)
print(f"\n✅ 完成! 总耗时: {elapsed:.2f}s")
log.info("=" * 60)
log.info("[Pipeline] 全流程完成!")
log.info(" 总耗时: %.2fs", elapsed)
log.info(" 输入: %d 词 -> 放置: %d", input_count, placed_count)
log.info(" 填充率: %.4f", fill_ratio)
log.info(" 画布: %dx%d (运算: %dx%d)", real_hd_w, real_hd_h, w_small, h_small)
log.info(" 输出: PNG=%s", config.OUTPUT_PNG)
log.info(" 输出: SVG=%s", config.OUTPUT_SVG)
log.info(" 输出: DB=%s", config.DB_PATH)
log.info("=" * 60)
+47
View File
@@ -0,0 +1,47 @@
import numpy as np
from PIL import Image, ImageDraw, ImageFilter, ImageFont
from . import config
from .fonts import get_cached_font
def render_layout_occupancy(layout, mask_shape, font_path):
h, w = mask_shape
canvas = Image.new("L", (w, h), 0)
draw = ImageDraw.Draw(canvas)
for word, size, (y, x), orient, _color in layout:
font = get_cached_font(font_path, size)
if orient:
font = ImageFont.TransposedFont(font, orientation=orient)
draw.text((x, y), word, font=font, fill=255)
return (np.array(canvas) > 0).astype(np.uint8)
def compute_fill_ratio_fast(layout, mask, font_path):
if not layout:
return 0.0, None
occ = render_layout_occupancy(layout, mask.shape, font_path)
free_area = np.sum(mask == 0)
if free_area == 0:
return 0.0, occ
filled_area = np.sum((mask == 0) & (occ == 1))
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
+101
View File
@@ -0,0 +1,101 @@
import math
import numpy as np
import pandas as pd
from PIL import Image, ImageDraw
from . import config
from .fonts import get_cached_font
from .layout import normalize_relative_scores
def get_stroke_complexity_batch(names, font_p, test_size=64):
font = get_cached_font(font_p, test_size)
img = Image.new("L", (test_size, test_size), 255)
draw = ImageDraw.Draw(img)
char_complexity_cache = {}
weights = {}
all_chars = set("".join(names))
for char in all_chars:
draw.rectangle([0, 0, test_size, test_size], fill=255)
draw.text((0, 0), char, font=font, fill=0)
char_complexity_cache[char] = np.sum(np.array(img) < 200)
unique_names = set(names)
for name in unique_names:
if not name:
weights[name] = 10
continue
complexities = [char_complexity_cache.get(c, 10) for c in name]
weights[name] = max(complexities)
return weights
def extract_weights_from_df(df, names):
series = None
if config.WEIGHT_COL_NAME is not None:
if config.WEIGHT_COL_NAME in df.columns:
series = df[config.WEIGHT_COL_NAME]
else:
config._warn(f"权重列名不存在: {config.WEIGHT_COL_NAME},尝试使用权重列索引")
if series is None and config.WEIGHT_COL_INDEX is not None:
if 0 <= config.WEIGHT_COL_INDEX < len(df.columns):
series = df.iloc[:, config.WEIGHT_COL_INDEX]
else:
config._warn(f"权重列索引越界: {config.WEIGHT_COL_INDEX},将回退到笔画权重")
if series is None:
return {}
name_series = df.iloc[:, config.DATA_COL_INDEX]
numeric = pd.to_numeric(series, errors='coerce')
pairs = pd.DataFrame({"name": name_series, "weight": numeric})
pairs = pairs[pairs["name"].notna()]
pairs["name"] = pairs["name"].astype(str)
pairs = pairs[pairs["weight"].notna() & (pairs["weight"] > 0)]
if pairs.empty:
config._warn("Excel 权重列没有可用正数,全部回退到笔画权重")
return {}
if config.REMOVE_DUPLICATES:
grouped = pairs.groupby("name", as_index=False)["weight"].max()
return dict(zip(grouped["name"], grouped["weight"]))
valid_name_set = set(names)
pairs = pairs[pairs["name"].isin(valid_name_set)]
if pairs.empty:
config._warn("Excel 权重与名称列未形成有效映射,全部回退到笔画权重")
return {}
grouped = pairs.groupby("name", as_index=False)["weight"].max()
return dict(zip(grouped["name"], grouped["weight"]))
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
weights = [max(float(weights_map.get(name, 10)), 1.0) for name in names]
if not weights:
return max(config.MIN_FONT_SIZE, 10), max(config.MIN_FONT_SIZE + 4, 20)
log_scores = normalize_relative_scores([math.log1p(weight) for weight in weights])
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 *= 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)))
return min_f, max_f
+10
View File
@@ -0,0 +1,10 @@
# 已废弃:后端 API 旧规格
本文档已被 `docs/API.md` 取代。
请阅读:
- [../../docs/API.md](../../docs/API.md)
- [../../docs/README.md](../../docs/README.md)
API 事实来源是 `backend/service/app.py``backend/service/schemas.py`
@@ -0,0 +1,9 @@
# 已废弃:性能与市场对比
本文档是历史分析材料,不再作为当前项目能力、性能或路线图依据。
当前代码行为请以标准文档和源码为准:
- [../../docs/README.md](../../docs/README.md)
- [../../docs/PROJECT_STANDARD.md](../../docs/PROJECT_STANDARD.md)
- [../../docs/ALGORITHM.md](../../docs/ALGORITHM.md)
@@ -0,0 +1,10 @@
# 已废弃:工作台后端 API 旧规格
本文档已被 `docs/API.md` 取代。
请阅读:
- [../../docs/API.md](../../docs/API.md)
- [../../docs/PROJECT_STANDARD.md](../../docs/PROJECT_STANDARD.md)
当前工作台相关接口包括 Jobs、Templates、Assets、Projects 和 Fonts,均在标准 API 文档中按当前代码整理。
+11
View File
@@ -0,0 +1,11 @@
# 已废弃:项目完整使用手册旧版
本文档已被标准文档拆分取代。
请阅读:
- [../../docs/README.md](../../docs/README.md)
- [../../docs/PROJECT_STANDARD.md](../../docs/PROJECT_STANDARD.md)
- [../../docs/ALGORITHM.md](../../docs/ALGORITHM.md)
- [../../docs/CONFIG.md](../../docs/CONFIG.md)
- [../../docs/API.md](../../docs/API.md)
+9
View File
@@ -0,0 +1,9 @@
fastapi>=0.136.0
uvicorn[standard]>=0.32.0
python-multipart>=0.0.27
pydantic>=2.10.0
pillow>=10.0.0
numpy>=2.0.0
matplotlib>=3.10.0
pandas>=2.0.0
openpyxl>=3.1.0
View File
File diff suppressed because it is too large Load Diff
+119
View File
@@ -0,0 +1,119 @@
from __future__ import annotations
import queue
import threading
import uuid
from dataclasses import dataclass, field
from datetime import datetime, timezone
from pathlib import Path
from .schemas import JobDetail, JobEvent, JobStatus
@dataclass
class JobState:
status: JobStatus
events: list[JobEvent] = field(default_factory=list)
subscribers: list[queue.Queue] = field(default_factory=list)
class JobManager:
def __init__(self) -> None:
self._lock = threading.Lock()
self._jobs: dict[str, JobState] = {}
def create_job(self) -> str:
job_id = uuid.uuid4().hex
now = datetime.now(timezone.utc)
status = JobStatus(
job_id=job_id,
status="queued",
stage="queued",
progress_percent=0,
message="任务已创建",
created_at=now,
updated_at=now,
artifacts={},
error="",
)
with self._lock:
self._jobs[job_id] = JobState(status=status)
return job_id
def exists(self, job_id: str) -> bool:
with self._lock:
return job_id in self._jobs
def get_status(self, job_id: str) -> JobStatus:
with self._lock:
return self._jobs[job_id].status
def get_detail(self, job_id: str) -> JobDetail:
with self._lock:
state = self._jobs[job_id]
return JobDetail(status=state.status, recent_events=state.events[-100:])
def set_artifacts(self, job_id: str, artifacts: dict[str, str]) -> None:
with self._lock:
status = self._jobs[job_id].status
status.artifacts = artifacts
status.updated_at = datetime.now(timezone.utc)
def set_status(
self,
job_id: str,
*,
status: str,
stage: str,
progress_percent: int,
message: str,
error: str | None = None,
) -> None:
with self._lock:
s = self._jobs[job_id].status
s.status = status
s.stage = stage
s.progress_percent = progress_percent
s.message = message
s.updated_at = datetime.now(timezone.utc)
if error is not None:
s.error = error
def add_event(self, job_id: str, *, kind: str, stage: str, progress_percent: int, message: str) -> None:
event = JobEvent(
type=kind,
stage=stage,
progress_percent=progress_percent,
message=message,
timestamp=datetime.now(timezone.utc),
)
with self._lock:
state = self._jobs[job_id]
state.events.append(event)
state.status.stage = stage
state.status.progress_percent = progress_percent
state.status.message = message
state.status.updated_at = event.timestamp
for sub in state.subscribers:
sub.put(event)
def subscribe(self, job_id: str) -> queue.Queue:
q: queue.Queue = queue.Queue()
with self._lock:
self._jobs[job_id].subscribers.append(q)
return q
def unsubscribe(self, job_id: str, q: queue.Queue) -> None:
with self._lock:
subs = self._jobs[job_id].subscribers
if q in subs:
subs.remove(q)
@staticmethod
def resolve_artifact_path(status: JobStatus, kind: str) -> Path:
if kind not in status.artifacts:
raise KeyError(kind)
p = status.artifacts[kind]
if not p:
raise FileNotFoundError(kind)
return Path(p)
+43
View File
@@ -0,0 +1,43 @@
"""
后端日志配置模块。
提供统一的日志格式和两个 handler:
- console: 输出到控制台(uvicorn 可见)
- file: 输出到 backend/.runtime/service.log
"""
import logging
import sys
from pathlib import Path
_LOG_DIR = Path(__file__).resolve().parent.parent / ".runtime"
_LOG_DIR.mkdir(parents=True, exist_ok=True)
_LOG_FILE = _LOG_DIR / "service.log"
_FORMAT = "%(asctime)s | %(levelname)-7s | %(name)s | %(message)s"
_DATE_FORMAT = "%Y-%m-%d %H:%M:%S"
def setup_logging(level: int = logging.DEBUG) -> None:
"""配置全局日志,可重复调用(幂等)。"""
root = logging.getLogger()
if root.handlers:
return # 已配置过
root.setLevel(level)
console = logging.StreamHandler(sys.stdout)
console.setLevel(logging.INFO)
console.setFormatter(logging.Formatter(_FORMAT, datefmt=_DATE_FORMAT))
file_handler = logging.FileHandler(str(_LOG_FILE), mode="a", encoding="utf-8")
file_handler.setLevel(logging.DEBUG)
file_handler.setFormatter(logging.Formatter(_FORMAT, datefmt=_DATE_FORMAT))
root.addHandler(console)
root.addHandler(file_handler)
def get_logger(name: str) -> logging.Logger:
"""获取命名 logger,自动触发 setup。"""
setup_logging()
return logging.getLogger(name)
+165
View File
@@ -0,0 +1,165 @@
from __future__ import annotations
import json
import logging
import os
import subprocess
import sys
import time
from pathlib import Path
from .job_manager import JobManager
from .log_config import get_logger
from .schemas import JobPaths
log = get_logger("service.runner")
STAGE_RULES: list[tuple[str, str, int]] = [
("--- 1. 读取数据 ---", "reading_data", 10),
("--- 2. 智能画幅计算 ---", "sizing_canvas", 20),
("--- 3. 生成掩膜", "building_mask", 35),
("--- 4. 计算权重 ---", "computing_weights", 45),
("--- 5. 启动生成", "placing_words", 65),
("--- 6. 高清渲染 ---", "rendering", 85),
("已保存:", "writing_outputs", 92),
("✅ 完成", "writing_outputs", 99),
]
class JobRunner:
def __init__(self, project_root: Path, manager: JobManager) -> None:
self.project_root = project_root
self.manager = manager
self.script_path = self.project_root / "wordcloud_generate_hybrid.py"
def _parse_stage(self, line: str, current_stage: str, current_progress: int) -> tuple[str, int]:
for token, stage, progress in STAGE_RULES:
if token in line:
return stage, progress
if "尝试 #" in line or "尺度" in line or "二分重试" in line:
progress = max(current_progress, 70)
return "placing_words", min(progress + 1, 84)
return current_stage, current_progress
def run(self, job_id: str, paths: JobPaths, config: dict) -> None:
t_start = time.time()
log.info("=" * 50)
log.info("[Runner] 任务启动 job_id=%s", job_id)
log.info(" config_path = %s", paths.config_path)
log.info(" output_dir = %s", paths.output_dir)
log.info(" 子进程 python = %s", sys.executable)
self.manager.set_status(job_id, status="running", stage="starting", progress_percent=1, message="任务启动")
with paths.config_path.open("w", encoding="utf-8") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
log.info(" 配置文件已写入")
cmd = [
sys.executable,
str(self.script_path),
"--config",
str(paths.config_path),
]
env = os.environ.copy()
process = subprocess.Popen(
cmd,
cwd=str(self.project_root),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
env=env,
)
stage = "starting"
progress = 1
assert process.stdout is not None
for raw in process.stdout:
line = raw.rstrip("\n")
log.info("[Pipeline] %s", line)
stage, progress = self._parse_stage(line, stage, progress)
self.manager.add_event(
job_id,
kind="log",
stage=stage,
progress_percent=progress,
message=line,
)
ret = process.wait()
elapsed = time.time() - t_start
log.info("[Runner] 子进程退出 code=%d 耗时=%.2fs", ret, elapsed)
png = next(paths.output_dir.glob("*.png"), None)
svg = next(paths.output_dir.glob("*[!_stroke].svg"), None)
svg_stroke = next(paths.output_dir.glob("*_stroke.svg"), None)
db = next(paths.output_dir.glob("*.db"), None)
metrics = next(paths.output_dir.glob("*metrics*.json"), None)
log.info("[Runner] 产物扫描:")
log.info(" png = %s", png)
log.info(" svg = %s", svg)
log.info(" svg_stroke = %s", svg_stroke)
log.info(" db = %s", db)
log.info(" metrics = %s", metrics)
artifacts = {
"png": str(png) if png else "",
"svg": str(svg) if svg else "",
"svg_stroke": str(svg_stroke) if svg_stroke else "",
"db": str(db) if db else "",
"metrics": str(metrics) if metrics else "",
}
self.manager.set_artifacts(job_id, artifacts)
if ret == 0 and not png:
log.error("[Runner] 任务失败:退出码=0 但未找到输出图片")
self.manager.add_event(
job_id,
kind="status",
stage="failed",
progress_percent=100,
message="任务失败:未找到输出图片",
)
self.manager.set_status(
job_id,
status="failed",
stage="failed",
progress_percent=100,
message="任务失败",
error="missing png artifact",
)
return
if ret == 0:
log.info("[Runner] ✅ 任务完成 job_id=%s 总耗时=%.2fs", job_id, elapsed)
self.manager.add_event(
job_id,
kind="status",
stage="completed",
progress_percent=100,
message="任务完成",
)
self.manager.set_status(job_id, status="success", stage="completed", progress_percent=100, message="任务完成")
else:
log.error("[Runner] ❌ 任务失败 job_id=%s exit_code=%d 耗时=%.2fs", job_id, ret, elapsed)
self.manager.add_event(
job_id,
kind="status",
stage="failed",
progress_percent=100,
message=f"任务失败,退出码: {ret}",
)
self.manager.set_status(
job_id,
status="failed",
stage="failed",
progress_percent=100,
message="任务失败",
error=f"script exited with code {ret}",
)
+139
View File
@@ -0,0 +1,139 @@
from __future__ import annotations
from datetime import datetime
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field
class JobCreateResponse(BaseModel):
job_id: str
class JobEvent(BaseModel):
type: Literal["log", "status"]
stage: str
progress_percent: int = Field(ge=0, le=100)
message: str
timestamp: datetime
class JobStatus(BaseModel):
job_id: str
status: Literal["queued", "running", "success", "failed"]
stage: str
progress_percent: int = Field(ge=0, le=100)
message: str
created_at: datetime
updated_at: datetime
artifacts: dict[str, str]
error: str = ""
class JobDetail(BaseModel):
status: JobStatus
recent_events: list[JobEvent]
class JobResult(BaseModel):
job_id: str
status: Literal["queued", "running", "success", "failed"]
image_url: str = ""
svg_url: str = ""
svg_stroke_url: str = ""
db_url: str = ""
metrics_url: str = ""
class WordLocation(BaseModel):
id: int
name: str
x: int
y: int
font_size: int
color: str = ""
orientation: Literal["horizontal", "vertical"] = "horizontal"
box_x: int
box_y: int
box_width: int
box_height: int
class JobLocationSearchResult(BaseModel):
job_id: str
query: str = ""
total: int = 0
canvas_width: int = 0
canvas_height: int = 0
matches: list[WordLocation] = Field(default_factory=list)
class JobPaths(BaseModel):
root: Path
input_dir: Path
output_dir: Path
mask_path: Path
excel_path: Path
config_path: Path
class Template(BaseModel):
id: str
name: str
width: int
height: int
aspect_ratio: str
description: str = ""
class Asset(BaseModel):
asset_id: str
name: str
type: str
mime_type: str
width: int
height: int
file_size: int
file_url: str
job_id: str = ""
created_at: datetime
class DesignTemplate(BaseModel):
template_id: str
name: str
description: str = ""
document: dict
reference_asset_ids: list[str] = Field(default_factory=list)
cover_asset_id: str = ""
created_at: datetime
updated_at: datetime
class Project(BaseModel):
project_id: str
name: str
template_id: str
background_color: str
stickers: list[dict] = Field(default_factory=list)
created_at: datetime
updated_at: datetime
class ProjectSummary(BaseModel):
project_id: str
name: str
template_id: str
background_color: str
sticker_count: int = 0
created_at: datetime
updated_at: datetime
class Font(BaseModel):
font_id: str
name: str
filename: str
file_size: int
created_at: datetime
+27
View File
@@ -0,0 +1,27 @@
from __future__ import annotations
from pathlib import Path
from .schemas import JobPaths
class Storage:
def __init__(self, base_dir: Path) -> None:
self.base_dir = base_dir
self.base_dir.mkdir(parents=True, exist_ok=True)
def prepare_job_dirs(self, job_id: str) -> JobPaths:
root = self.base_dir / job_id
input_dir = root / "input"
output_dir = root / "output"
input_dir.mkdir(parents=True, exist_ok=True)
output_dir.mkdir(parents=True, exist_ok=True)
return JobPaths(
root=root,
input_dir=input_dir,
output_dir=output_dir,
mask_path=input_dir / "mask.png",
excel_path=input_dir / "names.xlsx",
config_path=root / "config.json",
)
@@ -0,0 +1,107 @@
{
"template_id": "tmpl_b62e127361f64206800a6a99c6a99163",
"name": "TEST",
"description": "很好的一个设计,但是我现在要写一堆东西来让这个地方有东西看,因为这是一个测试。但是因为这是一个测试,所以这又没有什么很多好东西写,所以我现在讲了一堆东西",
"document": {
"width": 1600,
"height": 1000,
"background": "#ffffff",
"layers": [
{
"id": "layer-default",
"name": "图层 1",
"visible": true,
"locked": false
},
{
"id": "faeaa741-7cc5-436a-a4e3-b6cb94ea9b46",
"name": "原图遮罩",
"visible": true,
"locked": false,
"folderId": "1f67e5e5-158a-4f8e-89a9-1b2009bddc36"
},
{
"id": "b9344479-bff1-4594-ac7c-f9c634319d10",
"name": "词云",
"visible": true,
"locked": false,
"folderId": "1f67e5e5-158a-4f8e-89a9-1b2009bddc36"
},
{
"id": "66d70aba-bd74-49ed-8e26-c18dd84e4bb4",
"name": "图层 4",
"visible": true,
"locked": false
}
],
"layerFolders": [
{
"id": "1f67e5e5-158a-4f8e-89a9-1b2009bddc36",
"name": "词云文件夹 14:57",
"layerIds": [
"faeaa741-7cc5-436a-a4e3-b6cb94ea9b46",
"b9344479-bff1-4594-ac7c-f9c634319d10"
],
"collapsed": false
}
],
"elements": [
{
"id": "ff520dac-f26c-441a-a1c2-288d613372a2",
"type": "sticker",
"assetId": "asset_e12411c482094771afaacf6a2c144ec1",
"layerId": "faeaa741-7cc5-436a-a4e3-b6cb94ea9b46",
"groupId": "7a1cc966-615a-43b7-a6f8-6e57ab1ca60d",
"x": 527,
"y": 116,
"width": 874,
"height": 686,
"rotation": 0,
"opacity": 0.34
},
{
"id": "260edba6-4345-41a1-93cf-8cf743e9f370",
"type": "sticker",
"assetId": "asset_b8969d115c8d4e66bd259947b24b2ecd",
"layerId": "b9344479-bff1-4594-ac7c-f9c634319d10",
"groupId": "7a1cc966-615a-43b7-a6f8-6e57ab1ca60d",
"x": 527,
"y": 116,
"width": 874,
"height": 686,
"rotation": 0,
"opacity": 1
},
{
"id": "adc6f2dd-1531-4a7d-b43d-7541de40ca82",
"type": "sticker",
"assetId": "asset_d4d2b921dc544ee485161b843e70a061",
"layerId": "layer-default",
"x": 102,
"y": 112,
"width": 420,
"height": 280,
"rotation": 0,
"opacity": 1
},
{
"id": "bebd4b1e-1851-4fb9-a27b-caa09d37c22a",
"type": "sticker",
"assetId": "asset_3a667459a53d464a9f7b0860a52e3e45",
"layerId": "66d70aba-bd74-49ed-8e26-c18dd84e4bb4",
"x": 123,
"y": 522,
"width": 420,
"height": 280,
"rotation": 0,
"opacity": 1
}
]
},
"reference_asset_ids": [
"asset_5ae393dd419244ecbf0b222b87a969e3"
],
"cover_asset_id": "asset_5ae393dd419244ecbf0b222b87a969e3",
"created_at": "2026-06-13T06:59:45.363774+00:00",
"updated_at": "2026-06-13T06:59:45.363774+00:00"
}
+97
View File
@@ -0,0 +1,97 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "$0")" && pwd)"
VENV_DIR="$ROOT_DIR/.venv"
BACKEND_PORT="${BACKEND_PORT:-8000}"
# ── find python ──────────────────────────────────────────
PYTHON=""
for cmd in python3.13 python3.12 python3.11 python3.10 python3.9 python3; do
if command -v "$cmd" >/dev/null 2>&1; then
PYTHON="$cmd"
break
fi
done
[[ -n "$PYTHON" ]] || { echo "[ERROR] 找不到 python3"; exit 1; }
# verify version >= 3.9
if ! "$PYTHON" -c 'import sys; raise SystemExit(0 if sys.version_info >= (3,9) else 1)'; then
echo "[ERROR] 需要 Python 3.9+"; exit 1
fi
# ── deps check & install ─────────────────────────────────
DEPS_OK=0
if "$PYTHON" -c "
import importlib.util, sys
mods = ('fastapi','uvicorn','pandas','PIL','numpy','matplotlib','pydantic','openpyxl')
for m in mods:
if importlib.util.find_spec(m) is None:
sys.exit(1)
" 2>/dev/null; then
DEPS_OK=1
fi
if [[ "$DEPS_OK" == "0" ]]; then
# try venv first
if [[ -f "$VENV_DIR/bin/python" ]] && "$VENV_DIR/bin/python" --version >/dev/null 2>&1; then
PYTHON="$VENV_DIR/bin/python"
if "$PYTHON" -c "
import importlib.util, sys
for m in ('fastapi','uvicorn','pandas','PIL','numpy','matplotlib','pydantic','openpyxl'):
if importlib.util.find_spec(m) is None: sys.exit(1)
" 2>/dev/null; then
DEPS_OK=1
fi
fi
fi
if [[ "$DEPS_OK" == "0" ]]; then
echo "[INFO] 安装依赖 (首次或缺失) ..."
# create venv if needed
if [[ ! -f "$VENV_DIR/bin/python" ]]; then
"$PYTHON" -m venv "$VENV_DIR"
fi
PYTHON="$VENV_DIR/bin/python"
"$PYTHON" -m pip install -q --upgrade pip
"$PYTHON" -m pip install -q fastapi uvicorn python-multipart pydantic pandas openpyxl pillow numpy matplotlib
fi
# ── C++ extension ────────────────────────────────────────
EXT=$($PYTHON -c 'import importlib.machinery; print(importlib.machinery.EXTENSION_SUFFIXES[0])')
EWC_SO="$ROOT_DIR/EfficientWordCloud/efficient_wordcloud/ewc_core${EXT}"
EWC_CPP="$ROOT_DIR/EfficientWordCloud/efficient_wordcloud/src/ewc_core.cpp"
if [[ ! -f "$EWC_SO" ]] || [[ "$EWC_CPP" -nt "$EWC_SO" ]]; then
echo "[INFO] 编译 ewc_core ..."
(cd "$ROOT_DIR/EfficientWordCloud" && "$PYTHON" setup.py build_ext --inplace >/dev/null)
echo "[OK] 编译完成"
else
echo "[OK] ewc_core 就绪"
fi
# ── ensure runtime dirs ──────────────────────────────────
mkdir -p "$ROOT_DIR/service_workspace" "$ROOT_DIR/service_assets" "$ROOT_DIR/service_projects"
# ── port: auto-kill occupant ─────────────────────────────
if ss -tln 2>/dev/null | grep -q ":$BACKEND_PORT "; then
echo "[INFO] 端口 $BACKEND_PORT 被占用,正在释放 ..."
fuser -k "$BACKEND_PORT/tcp" >/dev/null 2>&1 || true
sleep 1
fi
# ── start ────────────────────────────────────────────────
echo "[INFO] 启动后端 http://0.0.0.0:${BACKEND_PORT}"
PYTHONPATH="$ROOT_DIR/EfficientWordCloud" \
"$PYTHON" -m uvicorn service.app:app \
--app-dir "$ROOT_DIR" \
--host 0.0.0.0 --port "$BACKEND_PORT" \
--reload &
PID=$!
echo "[OK] PID: $PID"
echo "[OK] API: http://0.0.0.0:${BACKEND_PORT}/docs"
trap 'echo; echo "[INFO] 停止 ..."; kill $PID 2>/dev/null || true; wait $PID 2>/dev/null || true; echo "[OK] 已停止"; exit 0' INT TERM
wait "$PID"
+12
View File
@@ -0,0 +1,12 @@
from core import config
from core.pipeline import main
if __name__ == "__main__":
args = config.parse_args()
if args.config:
config.apply_json_config(args.config)
config.apply_cli_overrides(args)
config.finalize_runtime_config()
config.set_random_seed()
main()