File size: 17,993 Bytes
273b8b1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
import cv2
import numpy as np
import os
import re
import glob

def clean_filename(text):
    """清理文件名"""
    text = text.lower().strip()
    text = re.sub(r'[\s\W]+', '_', text)
    return text.strip('_')

def sort_contours(cnts, method="left-to-right"):
    """
    对轮廓进行排序。
    对于 Grid 布局,我们需要 'top-to-bottom' 然后 'left-to-right' 的混合排序。
    """
    if not cnts:
        return [], []

    # 获取每个轮廓的 Bounding Box
    boundingBoxes = [cv2.boundingRect(c) for c in cnts]
    
    # 将轮廓和bbox打包
    cnts_boxes = list(zip(cnts, boundingBoxes))
    
    # 1. 按照 Y 坐标(从上到下)进行初步排序
    # key: y
    cnts_boxes.sort(key=lambda b: b[1][1])
    
    # 2. 分行处理
    # 由于手工画线或扫描误差,同一行的y坐标可能不完全相同。
    # 我们需要设定一个阈值,认为y坐标相近的是“同一行”。
    rows = []
    current_row = []
    if cnts_boxes:
        # 以第一个轮廓的高度作为参考阈值
        ref_h = cnts_boxes[0][1][3]
        tolerance = ref_h * 0.5 # 容差设为高度的一半
        
        last_y = cnts_boxes[0][1][1]
        
        for c, box in cnts_boxes:
            y = box[1]
            if y <= last_y + tolerance:
                current_row.append((c, box))
            else:
                # 新的一行
                rows.append(current_row)
                current_row = [(c, box)]
                last_y = y
        # 添加最后一行
        if current_row:
            rows.append(current_row)

    # 3. 对每一行内部,按照 X 坐标(从左到右)排序
    final_sorted = []
    row_counts = []
    for i, row in enumerate(rows):
        # key: x
        row.sort(key=lambda b: b[1][0])
        row_counts.append(len(row))
        for item in row:
            final_sorted.append(item[1]) # 只返回 bbox (x, y, w, h)

    return final_sorted, row_counts

def uniform_grid_split(img, expected_cols=6, expected_rows=4, margin_percent=0.02):
    """
    均匀分割方法:直接按照预期的行列数均匀分割图像
    适用于网格线不连续或没有明显网格线的情况
    
    Args:
        img: 输入图像
        expected_cols: 期望的列数
        expected_rows: 期望的行数
        margin_percent: 边缘裁剪比例(去除可能的边框)
    
    Returns:
        排序好的 (x, y, w, h) 列表
    """
    h_img, w_img = img.shape[:2]
    
    # 去除边缘
    margin_x = int(w_img * margin_percent)
    margin_y = int(h_img * margin_percent)
    
    effective_width = w_img - 2 * margin_x
    effective_height = h_img - 2 * margin_y
    
    # 计算每个单元格的尺寸
    cell_width = effective_width // expected_cols
    cell_height = effective_height // expected_rows
    
    boxes = []
    for row in range(expected_rows):
        for col in range(expected_cols):
            x = margin_x + col * cell_width
            y = margin_y + row * cell_height
            boxes.append((x, y, cell_width, cell_height))
    
    return boxes

def detect_grid_cells_with_lines(img, expected_cols=6, expected_rows=4):
    """
    通过形态学操作检测网格线,并提取每个格子的坐标
    """
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    
    # 二值化 (反转:背景黑,内容/线白)
    # 使用自适应阈值来应对光照或颜色不均
    thresh = cv2.adaptiveThreshold(gray, 255, cv2.ADAPTIVE_THRESH_GAUSSIAN_C, 
                                   cv2.THRESH_BINARY_INV, 11, 2)

    # 定义结构元素 (Kernel) - 增大kernel以更好地检测断裂的线
    h_img, w_img = img.shape[:2]
    # 水平线 Kernel: 宽度长,高度为1
    horizontal_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (w_img // 15, 1))
    # 垂直线 Kernel: 宽度为1,高度长
    vertical_kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (1, h_img // 15))

    # 1. 提取水平线
    detect_horizontal = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, horizontal_kernel, iterations=2)
    
    # 2. 提取垂直线
    detect_vertical = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, vertical_kernel, iterations=2)

    # 3. 合并网格线
    grid_mask = cv2.addWeighted(detect_horizontal, 0.5, detect_vertical, 0.5, 0)
    _, grid_mask = cv2.threshold(grid_mask, 0, 255, cv2.THRESH_BINARY)
    
    # 更强的膨胀操作,连接断裂的网格线
    kernel_dilate = cv2.getStructuringElement(cv2.MORPH_RECT, (5, 5))
    grid_mask = cv2.dilate(grid_mask, kernel_dilate, iterations=3)
    
    # 闭运算,进一步连接断裂
    kernel_close = cv2.getStructuringElement(cv2.MORPH_RECT, (7, 7))
    grid_mask = cv2.morphologyEx(grid_mask, cv2.MORPH_CLOSE, kernel_close, iterations=2)

    # 4. 寻找所有的“洞”(即单元格)
    # 我们通过查找 grid_mask 的轮廓,通常很难直接找到内部的矩形。
    # 更好的方法是:找出网格线轮廓,画在全黑背景上,然后寻找连通组件,或者反转图片找白色方块。
    
    # 这里我们采用“反转 mask”法:网格线是黑,格子是白
    contours_mask = cv2.bitwise_not(grid_mask)
    contours, _ = cv2.findContours(contours_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)

    # 计算期望的单元格面积
    expected_cell_area = (w_img * h_img) / (expected_cols * expected_rows)
    
    # 过滤微小的噪点轮廓,同时也过滤过大的轮廓
    # 放宽过滤条件以捕获更多单元格
    min_area = expected_cell_area / 20  # 单元格面积的 1/20 (之前是1/10)
    max_area = expected_cell_area * 3   # 单元格面积的3倍 (之前是2倍)
    
    # 调试信息
    print(f"    [调试] 图像尺寸: {w_img}x{h_img}, 检测到轮廓: {len(contours)}个")
    print(f"    [调试] 期望单元格面积: {expected_cell_area:.0f}, 过滤范围: {min_area:.0f}-{max_area:.0f}")
    
    # 统计被过滤掉的轮廓
    filtered_out = []
    valid_contours = []
    for c in contours:
        area = cv2.contourArea(c)
        if min_area < area < max_area:
            valid_contours.append(c)
        else:
            x, y, w, h = cv2.boundingRect(c)
            filtered_out.append((area, x, y, w, h))
    
    if filtered_out:
        print(f"    [调试] 被过滤掉 {len(filtered_out)} 个轮廓:")
        for area, x, y, w, h in sorted(filtered_out, key=lambda t: t[0], reverse=True)[:5]:
            print(f"      - 面积={area:.0f}, 位置=({x},{y}), 尺寸={w}x{h}")

    # 排序:确保顺序是 左->右,上->下
    sorted_boxes, row_counts = sort_contours(valid_contours)
    
    return sorted_boxes, row_counts

def detect_grid_cells(img, expected_cols=6, expected_rows=4):
    """
    鲁棒的网格检测方法:首先尝试检测网格线,如果失败则使用均匀分割
    """
    expected_count = expected_cols * expected_rows
    
    # 方法1: 尝试检测网格线
    sorted_boxes, row_counts = detect_grid_cells_with_lines(img, expected_cols, expected_rows)
    
    # 严格检查:必须恰好检测到期望数量的单元格
    if len(sorted_boxes) != expected_count:
        print(f"  网格线检测不理想(检测到 {len(sorted_boxes)} 个单元格,期望 {expected_count} 个)")
        if row_counts:
            row_info = ", ".join([f"第{i+1}行: {count}个" for i, count in enumerate(row_counts)])
            print(f"  检测到的行分布: {row_info}")
        print(f"  切换到均匀分割模式...")
        sorted_boxes = uniform_grid_split(img, expected_cols, expected_rows)
        # 均匀分割时,打印每行的单元格数
        print(f"  均匀分割结果:每行 {expected_cols} 个单元格,共 {expected_rows} 行")
    else:
        print(f"  ✓ 成功检测到 {len(sorted_boxes)} 个网格单元格(符合预期)")
        # 打印每行的单元格数量
        if row_counts:
            row_info = ", ".join([f"第{i+1}行: {count}个" for i, count in enumerate(row_counts)])
            print(f"  行分布: {row_info}")
    
    return sorted_boxes

def is_likely_text_region(img_region, thresh_region):
    """
    判断一个区域是否可能是文字
    文字的特征:
    1. 主要是黑色或深色
    2. 高度较小
    3. 像素密度适中(不是纯色块)
    """
    if img_region.shape[0] == 0 or img_region.shape[1] == 0:
        return False
    
    # 转换为灰度(如果不是)
    if len(img_region.shape) == 3:
        gray_region = cv2.cvtColor(img_region, cv2.COLOR_BGR2GRAY)
    else:
        gray_region = img_region
    
    # 检查1:高度不能太大(文字通常较矮)
    height_ratio = img_region.shape[0] / img_region.shape[1] if img_region.shape[1] > 0 else 1
    if height_ratio > 0.3:  # 如果高度超过宽度的30%,可能不是单行文字
        return False
    
    # 检查2:颜色是否偏暗(文字通常是黑色或深色)
    mean_brightness = np.mean(gray_region)
    if mean_brightness > 200:  # 太亮,不像文字
        return False
    
    # 检查3:内容像素占比(文字不会太密集也不会太稀疏)
    content_pixels = np.sum(thresh_region > 0)
    total_pixels = thresh_region.shape[0] * thresh_region.shape[1]
    density = content_pixels / total_pixels if total_pixels > 0 else 0
    
    if density < 0.05 or density > 0.5:  # 密度不在合理范围
        return False
    
    return True

def detect_and_remove_text(img, thresh, row_sums):
    """
    检测并移除图标上方或下方的文字标题
    
    返回: (top_crop, bottom_crop) - 需要裁剪的上下边界
    """
    h = len(row_sums)
    
    # 定义"空白行"的阈值(行和很小)
    empty_threshold = max(5, img.shape[1] * 0.01)  # 至少5,或宽度的1%
    # 定义"间隙"的最小行数
    min_gap_rows = max(2, int(h * 0.02))  # 至少2行,或高度的2%
    
    # 找到所有内容行(非空白行)
    content_rows = [i for i, val in enumerate(row_sums) if val > empty_threshold]
    
    if len(content_rows) == 0:
        return 0, h
    
    # 找到主要内容区域(最大的连续内容块)
    # 先找出所有的间隙
    gaps = []
    if len(content_rows) > 1:
        for i in range(len(content_rows) - 1):
            gap_size = content_rows[i + 1] - content_rows[i] - 1
            if gap_size >= min_gap_rows:
                gap_start = content_rows[i]
                gap_end = content_rows[i + 1]
                gaps.append((gap_start, gap_end, gap_size))
    
    top_crop = 0
    bottom_crop = h
    
    # 如果存在明显的间隙,说明可能有分离的文字
    if gaps:
        # 找到最大的间隙
        largest_gap = max(gaps, key=lambda x: x[2])
        gap_start, gap_end, gap_size = largest_gap
        
        # 计算间隙上方和下方的内容量和行数
        top_rows = gap_start
        bottom_rows = h - gap_end
        top_content = sum(row_sums[:gap_start])
        bottom_content = sum(row_sums[gap_end:])
        
        # 判断哪一部分是主要图标,哪一部分是文字
        # 文字的特征:1) 内容较少 2) 行数较少 3) 符合文字特征
        
        # 检查上方区域
        if top_rows > 0 and top_rows < h * 0.3:  # 上方行数不超过30%
            if top_content < bottom_content * 0.4:  # 上方内容明显少于下方
                # 进一步检查是否像文字
                top_region = img[:gap_start, :]
                top_thresh = thresh[:gap_start, :]
                if is_likely_text_region(top_region, top_thresh):
                    top_crop = gap_end
        
        # 检查下方区域
        if bottom_rows > 0 and bottom_rows < h * 0.3:  # 下方行数不超过30%
            if bottom_content < top_content * 0.4:  # 下方内容明显少于上方
                # 进一步检查是否像文字
                bottom_region = img[gap_end:, :]
                bottom_thresh = thresh[gap_end:, :]
                if is_likely_text_region(bottom_region, bottom_thresh):
                    bottom_crop = gap_start
    
    return top_crop, bottom_crop

def smart_crop_icon(img, padding=10):
    """
    单个 Icon 处理:去字、去空、加 Padding
    增强版:可以检测并删除上方或下方的文字标题
    """
    h, w = img.shape[:2]
    
    # 1. 裁剪掉可能残留的网格边缘 (比如四周切掉 3px)
    margin = 3
    if h > 2*margin and w > 2*margin:
        img = img[margin:-margin, margin:-margin]
        h, w = img.shape[:2]
    
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    _, thresh = cv2.threshold(gray, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)

    # 2. 使用改进的方法检测并移除文字
    row_sums = np.sum(thresh, axis=1)
    top_crop, bottom_crop = detect_and_remove_text(img, thresh, row_sums)
    
    # 应用裁剪
    if top_crop > 0 or bottom_crop < h:
        img = img[top_crop:bottom_crop, :]
        thresh = thresh[top_crop:bottom_crop, :]

    # 3. 寻找 Icon 的精确边界
    coords = cv2.findNonZero(thresh)
    if coords is not None:
        x, y, w_box, h_box = cv2.boundingRect(coords)
        
        # 裁剪并添加 Padding
        # 创建一个新的白色画布
        final_h = h_box + 2 * padding
        final_w = w_box + 2 * padding
        canvas = np.ones((final_h, final_w, 3), dtype=np.uint8) * 255
        
        # 提取 icon 内容
        icon_content = img[y:y+h_box, x:x+w_box]
        
        # 将 icon 贴到画布中心
        canvas[padding:padding+h_box, padding:padding+w_box] = icon_content
        return canvas
    
    return img

def process_image_robust(image_path, labels_data):
    if not os.path.exists(image_path):
        print(f"Error: {image_path} not found.")
        return

    print(f"Processing: {image_path} ...")
    img = cv2.imread(image_path)
    
    # 1. 检测网格
    # 返回的是排序好的 (x, y, w, h) 列表
    grid_boxes = detect_grid_cells(img, expected_cols=6, expected_rows=4)
    
    # 2. 准备文本数据
    lines = [l.strip() for l in labels_data.strip().split('\n') if l.strip()]
    style = "flat"
    start_idx = 0
    if lines[0].lower().startswith("style:"):
        style = clean_filename(lines[0].split(':')[1])
        start_idx = 1
        
    output_dir = "extracted_icons"
    if not os.path.exists(output_dir):
        os.makedirs(output_dir)

    # 3. 遍历并保存
    for i, box in enumerate(grid_boxes):
        text_idx = start_idx + i
        if text_idx >= len(lines):
            break
            
        # 解析文本
        line_text = lines[text_idx]
        parts = line_text.split(',', 1)
        if len(parts) == 2:
            category = clean_filename(parts[0])
            name = clean_filename(parts[1])
        else:
            category = "icon"
            name = clean_filename(parts[0])
            
        filename = f"{category}-{name}-{style}.png"
        save_path = os.path.join(output_dir, filename)
        
        # 提取单元格
        x, y, w, h = box
        cell_img = img[y:y+h, x:x+w]
        
        # 智能裁切
        final_img = smart_crop_icon(cell_img, padding=10)
        
        cv2.imwrite(save_path, final_img)
        # print(f"Saved: {filename}") # 减少刷屏

    print(f"Done. Extracted {len(grid_boxes)} icons to '{output_dir}/'.\n")

def process_batch_range(start_batch, end_batch, base_dir="generated_icons"):
    """
    批量处理指定范围内的batch文件夹下的所有png文件
    
    Args:
        start_batch: 起始batch编号 (例如: 1)
        end_batch: 结束batch编号 (例如: 10)
        base_dir: batch文件夹所在的基础目录
    """
    print(f"开始批量处理 batch_{start_batch:04d} 到 batch_{end_batch:04d} ...\n")
    
    total_processed = 0
    failed_files = []
    
    for batch_num in range(start_batch, end_batch + 1):
        batch_dir = os.path.join(base_dir, f"batch_{batch_num:04d}")
        
        # 检查batch文件夹是否存在
        if not os.path.exists(batch_dir):
            print(f"Warning: {batch_dir} 不存在,跳过...")
            continue
        
        print(f"处理 {batch_dir} ...")
        
        # 查找该batch下的所有png文件
        png_files = glob.glob(os.path.join(batch_dir, "*.png"))
        
        if not png_files:
            print(f"  未找到png文件,跳过...")
            continue
        
        # 处理每个png文件
        for png_path in sorted(png_files):
            # 构造对应的txt文件路径
            txt_path = png_path.rsplit('.', 1)[0] + '.txt'
            
            # 检查txt文件是否存在
            if not os.path.exists(txt_path):
                print(f"  Warning: {txt_path} 不存在,跳过 {os.path.basename(png_path)}")
                failed_files.append(png_path)
                continue
            
            # 读取txt文件内容
            try:
                with open(txt_path, 'r', encoding='utf-8') as f:
                    labels_data = f.read()
                
                # 处理图像
                process_image_robust(png_path, labels_data)
                total_processed += 1
                
            except Exception as e:
                print(f"  Error processing {os.path.basename(png_path)}: {str(e)}")
                failed_files.append(png_path)
    
    # 输出总结
    print(f"\n{'='*60}")
    print(f"批量处理完成!")
    print(f"成功处理: {total_processed} 个文件")
    
    if failed_files:
        print(f"失败/跳过: {len(failed_files)} 个文件")
        print("失败文件列表:")
        for f in failed_files:
            print(f"  - {f}")
    print(f"{'='*60}")

if __name__ == "__main__":
    # 设置要处理的batch范围
    START_BATCH = 1    # 起始batch编号
    END_BATCH = 200     # 结束batch编号
    
    # 执行批量处理
    process_batch_range(START_BATCH, END_BATCH)