喵喵妙妙塔求解器(Meow Tower Solver)

喵喵妙妙塔求解器

Demo

left_strings = ['4', '6 1 1', '6 3 1', '6 3 1', '12 1', '4 5 1', '2 6', '10', '11', '7', '1', '4', '1', '9', '2 2 2 2']
top_strings = ['4', '6 1 1', '9 2', '10 1 1', '6 3 1 2', '4 3 1 2', '1 7', '6 2', '6 2', '9 1', '7 2', '8 1', '1', '1 1', '2']
hint_strings = ['3 9', '3 15', '4 7', '6 13']

Solution:

00: 01 02 03 04 05 | 06 07 08 09 10 | 11 12 13 14 15 |
------------------------------------------------------------
01:    02 03 04 05 |                |                |
02: 01 02 03 04 05 | 06          10 |    12          |
03: 01 02 03 04 05 | 06          10 | 11 12    14    |
04: 01 02 03 04 05 | 06          10 | 11 12       15 |
05: 01 02 03 04 05 | 06 07 08 09 10 | 11 12       15 |
------------------------------------------------------------
06:    02 03 04 05 |       08 09 10 | 11 12    14    |
07:       03 04    |       08 09 10 | 11 12 13       |
08:       03 04 05 | 06 07 08 09 10 | 11 12          |
09:    02 03 04 05 | 06 07 08 09 10 | 11 12          |
10:          04 05 | 06 07 08 09 10 |                |
------------------------------------------------------------
11:                |    07          |                |
12:          04 05 | 06 07          |                |
13:                |    07          |                |
14:       03 04 05 | 06 07 08 09 10 | 11             |
15:    02 03    05 | 06    08 09    | 11 12          |
------------------------------------------------------------

Step 1: Screenshot -> Strings

import cv2
import numpy as np
import pytesseract
import os

# Windows 用户请取消注释并确认路径
pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe'

def preprocess(roi, scale=4):
    # 1. 放大: 使用 CUBIC 插值,保证字体圆润,不要用 NEAREST (会变锯齿)
    roi = cv2.resize(roi, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
    
    # 2. 二值化: 提取亮色文字
    _, binary = cv2.threshold(roi, 160, 255, cv2.THRESH_BINARY)
    
    # 3. 转为白底黑字
    binary = cv2.bitwise_not(binary)
    
    # 4. 适度腐蚀 (让字变细,断开粘连),但力度不要太大,防止断笔
    kernel = np.ones((3, 3), np.uint8)
    binary = cv2.dilate(binary, kernel, iterations=1)
    
    # 5. 加边框
    binary = cv2.copyMakeBorder(binary, 20, 20, 20, 20, cv2.BORDER_CONSTANT, value=255)
    return binary

def get_horizontal_text_via_box(roi):
    """
    左侧:通过 X 轴坐标判断空格
    """
    # psm 7: Treat the image as a single text line.
    config = r'--oem 3 --psm 7 -c tessedit_char_whitelist=0123456789'
    
    try:
        # 获取字符框
        boxes_str = pytesseract.image_to_boxes(roi, config=config)
    except:
        return None # 失败则回退

    if not boxes_str:
        return None

    boxes = []
    lines = boxes_str.splitlines()
    for line in lines:
        parts = line.split()
        if len(parts) < 4: continue
        # parts: char, left, bottom, right, top, page
        boxes.append({
            'char': parts[0],
            'left': int(parts[1]),
            'right': int(parts[3])
        })

    # 按左坐标排序
    boxes.sort(key=lambda x: x['left'])
    if not boxes: return None

    # 计算平均字符宽度
    total_width = sum(b['right'] - b['left'] for b in boxes)
    avg_width = total_width / len(boxes)
    
    # 间距阈值: 字符宽度的 0.5 倍
    gap_threshold = avg_width * 0.5

    result = boxes[0]['char']
    for i in range(1, len(boxes)):
        gap = boxes[i]['left'] - boxes[i-1]['right']
        if gap > gap_threshold:
            result += " "
        result += boxes[i]['char']
        
    return result

def get_vertical_text_via_box(roi):
    """
    顶部:通过 Y 轴坐标判断换行/空格
    """
    # psm 6: Assume a single uniform block of text.
    config = r'--oem 3 --psm 6 -c tessedit_char_whitelist=0123456789'
    
    h, w = roi.shape[:2]
    try:
        boxes_str = pytesseract.image_to_boxes(roi, config=config)
    except:
        return ""

    if not boxes_str: return ""

    chars = []
    for line in boxes_str.splitlines():
        parts = line.split()
        if len(parts) < 4: continue
        
        # Tesseract 坐标 (0,0 在左下角)
        left, bottom, right, top = int(parts[1]), int(parts[2]), int(parts[3]), int(parts[4])
        
        # 转换为 OpenCV Y (0,0 在左上角) -> 用于排序
        # y_center 越小,说明越靠上
        y_center_img = h - (top + bottom) / 2
        
        chars.append({
            'char': parts[0],
            'y': y_center_img,
            'x': (left + right) / 2,
            'h': top - bottom
        })

    # 1. 核心:按 Y (从上到下) 排序
    chars.sort(key=lambda k: k['y'])

    # 2. 分组逻辑
    avg_h = sum(c['h'] for c in chars) / len(chars)
    y_threshold = avg_h * 0.6 # 同一行判定的阈值

    rows = []
    current_row = [chars[0]]

    for i in range(1, len(chars)):
        diff = abs(chars[i]['y'] - chars[i-1]['y'])
        if diff < y_threshold:
            current_row.append(chars[i])
        else:
            rows.append(current_row)
            current_row = [chars[i]]
    rows.append(current_row)

    # 3. 拼接
    final_tokens = []
    for row in rows:
        # 同一行内按 X 排序 (针对 '10' 这种情况)
        row.sort(key=lambda k: k['x'])
        token = "".join([c['char'] for c in row])
        final_tokens.append(token)

    return " ".join(final_tokens)

def process_image(image_path):
    if not os.path.exists('debug_images'): os.makedirs('debug_images')

    img = cv2.imread(image_path)
    gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
    
    # 1. 定位 Grid
    _, thresh = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY)
    contours, _ = cv2.findContours(thresh, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
    
    if not contours:
        print("Error: Grid not found")
        return

    grid_contour = max(contours, key=cv2.contourArea)
    x, y, w, h = cv2.boundingRect(grid_contour)
    
    rows, cols = 15, 15
    cell_h = h // rows
    cell_w = w // cols
    
    print(f"Grid: {w}x{h}")

    left_strings = []
    top_strings = []
    hint_strings = []

    # --- 处理 Left ---
    print("Processing Left...")
    for i in range(rows):
        roi_x = max(0, x - int(cell_w * 5))
        roi_y = y + i * cell_h + 2
        roi_h_roi = cell_h - 4
        roi_w = x - roi_x
        
        roi = gray[roi_y:roi_y+roi_h_roi, roi_x:roi_x+roi_w]
        
        # 预处理
        processed = preprocess(roi)
        cv2.imwrite(f'debug_images/left_{i}.png', processed) # Debug
        
        # 优先尝试 Box 方法 (解决粘连)
        text = get_horizontal_text_via_box(processed)
        
        # 如果 Box 方法完全没识别到东西 (比如图片太糊),回退到普通 String 方法
        if text is None:
            config = r'--oem 3 --psm 7 -c tessedit_char_whitelist=0123456789'
            text = pytesseract.image_to_string(processed, config=config).strip()
            # 简单的后处理 hack
            if len(text) == 2 and text[0] != '1' and ' ' not in text: 
                 # 如果识别出 "24" 这种,大概率是 "2 4"
                 pass 
        
        left_strings.append(text if text else "")

    # --- 处理 Top ---
    print("Processing Top...")
    for j in range(cols):
        roi_x = x + j * cell_w + 2
        # 往上多取一点,防止漏字
        roi_y_start = max(0, y - int(cell_h * 9))
        roi_w_roi = cell_w - 4
        
        roi = gray[roi_y_start:y, roi_x:roi_x+roi_w_roi]
        
        # 预处理 (不旋转!)
        processed = preprocess(roi)
        cv2.imwrite(f'debug_images/top_{j}.png', processed) # Debug
        
        # 使用竖排逻辑
        text = get_vertical_text_via_box(processed)
        top_strings.append(text)

    # --- 处理 Hints ---
    print("Processing Hints...")
    for r in range(rows):
        for c in range(cols):
            cx = x + c * cell_w
            cy = y + r * cell_h
            # 取中间一小块,避开线条
            cell = img[cy+7:cy+cell_h-7, cx+7:cx+cell_w-7]
            
            hsv = cv2.cvtColor(cell, cv2.COLOR_BGR2HSV)
            # 灰色/淡紫色 Mask
            mask = cv2.inRange(hsv, np.array([0, 5, 50]), np.array([180, 80, 230]))
            
            if cv2.countNonZero(mask) > 5:
                hint_strings.append(f"{r+1} {c+1}")

    print("\n" + "="*30)
    print(f"left_strings = {left_strings}")
    print(f"top_strings = {top_strings}")
    print(f"hint_strings = {hint_strings}")
    print("="*30)

process_image('unnamed.jpg')

Step 2: Get the solution

from ortools.linear_solver import pywraplp
from ortools.sat.python import cp_model

left_strings = ['4', '6 1 1', '6 3 1', '6 3 1', '12 1', '4 5 1', '2 6', '10', '11', '7', '1', '4', '1', '9', '2 2 2 2']
top_strings = ['4', '6 1 1', '9 2', '10 1 1', '6 3 1 2', '4 3 1 2', '1 7', '6 2', '6 2', '9 1', '7 2', '8 1', '1', '1 1', '2']
hint_strings = ['3 9', '3 15', '4 7', '6 13']

def parse_input(s: list[str]) -> list[list[int]]:
    return [list(map(int, line.split())) for line in s]

def compute_ranage(clue, length, i):
    min_start = sum(clue[:i]) + i
    max_start = length - sum(clue[i+1:]) - (len(clue) - i - 1) - clue[i]
    return (min_start, max_start)

def create_cp_constraints(model, x, all_clues, prefix = 'R'):
    num_rows = len(x)
    num_cols = len(x[0])

    for i in range(num_rows):
        # row must have correct number of filled cells
        print(f'{prefix} {i+1} total filled cells: {sum(all_clues[i])}')
        model.Add(sum(x[i][j] for j in range(num_cols)) == sum(all_clues[i]))

    clue_vars = {}
    for i in range(num_rows):
        clues = all_clues[i]
        clue_count = len(clues)
        print(f'{prefix} {i+1} clues: {clues}')
        for j in range(clue_count):
            min_start, max_start = compute_ranage(clues, num_cols, j)
            clue = clues[j]
            before = sum(clues[:j])
            print(f'{prefix} {i+1} clue {j+1} (length {clue}) can start between {min_start} and {max_start}')
            for k in range(min_start, max_start + 1):
                clue_vars[i, j, k] = model.NewBoolVar(f'{prefix}[{i},{j},{k}]')

                # clue can only start if there is enough space
                if k > 0:
                    model.AddAtMostOne([clue_vars[i, j, k], x[i][k - 1]])

                # clue must occupy its length    
                model.Add(sum(x[i][l] for l in range(k,k+clue)) == clue).OnlyEnforceIf(clue_vars[i, j, k])

                # previous clues must fit before
                model.Add(sum(x[i][l] for l in range(0,k)) == before).OnlyEnforceIf(clue_vars[i, j, k])

                # last clue must fit in the row
                if j == clue_count - 1:
                    model.Add(sum(x[i][l] for l in range(k + clue, num_cols)) == 0).OnlyEnforceIf(clue_vars[i, j, k])
            
            # each clue must appear exactly once in the row
            model.AddExactlyOne([clue_vars[i, j, k] for k in range(min_start, max_start + 1)])

    return clue_vars

def create_cp_model(rows, cols, hints = []):
    model = cp_model.CpModel()

    num_rows = len(rows)
    num_cols = len(cols)

    # Create variables
    x = []
    for i in range(num_rows):
        x.append([model.NewBoolVar(f'x[{i},{j}]') for j in range(num_cols)])
    
    for hint in hints:
        r, c = hint
        model.Add(x[r-1][c-1] == 0)

    y = []
    for j in range(num_cols):
        current = []
        for i in range(num_rows):
            current.append(x[i][j])
        y.append(current)

    r_clue_vars = create_cp_constraints(model, x, rows, 'R')
    c_clue_vars = create_cp_constraints(model, y, cols, 'C')

    # Objective
    model.Maximize(sum(x[i][j] for i in range(num_rows) for j in range(num_cols)))

    return model, x, r_clue_vars, c_clue_vars

def solve_cp(rows, cols, hints = []):
    model, x, r_clue_vars, c_clue_vars = create_cp_model(rows, cols, hints)
    solver = cp_model.CpSolver()
    status = solver.solve(model)
    if status != cp_model.OPTIMAL:
        print("The problem does not have an optimal solution.")
        return
    solution = []
    for i in range(len(x)):
        solution.append([solver.Value(x[i][j]) > 0.5 for j in range(len(x[0]))])
    r_clue_solution = {}
    for key in r_clue_vars:
        i, j, k = key
        if solver.Value(r_clue_vars[key]) > 0.5:
            r_clue_solution[i,j] = k
            if not solution[i][k - 1]:
                print(f'R clue {i},{j} starts at {k}, Good!')
            else:
                print(f'R clue {i},{j} starts at {k}, ERROR!')
    c_clue_solution = {}
    for key in c_clue_vars:
        i, j, k = key
        if solver.Value(c_clue_vars[key]) > 0.5:
            c_clue_solution[i,j] = k
    return solution, r_clue_solution, c_clue_solution

def print_solution(solution):
    for i in range(-1, len(solution)):
        line = '%02d:' % (i + 1)
        for j in range(len(solution[0])):
            if solution[i][j] or i == -1:
                line += ' %02d' % (j + 1)
            else:
                line += '   '
            if j % 5 == 4:
                line += ' |'
        print(line)
        if (i + 5) % 5 == 4:
            print('-' * 60)


def solve(row_strings, col_strings, hint_strings = []):
    
    rows = parse_input(row_strings)
    cols = parse_input(col_strings)
    hints = parse_input(hint_strings)

    if sum([sum(r) for r in rows]) != sum([sum(c) for c in cols]):
        print("Error: sum of row clues does not match sum of column clues")
        return
    
    num_rows = len(rows)
    num_cols = len(cols)
    print("%s x %s\n" % (num_rows, num_cols), rows, cols)

    solution, r_clue_solution, c_clue_solution = solve_cp(rows, cols, hints)
    
    print("R clue solution:", r_clue_solution)
    print("C clue solution:", c_clue_solution)
    print_solution(solution)

def main():
    solve(left_strings, top_strings, hint_strings)

if __name__ == "__main__":
    main()