Compare commits

...

4 Commits

Author SHA1 Message Date
raiot 0f784cecc7 feat: new tower and similiarity socre calc 2024-11-06 02:16:57 +08:00
raiot 0f758396a7 metrics 2024-10-29 19:38:18 +08:00
raiot f0096d2c74 gui improvement and blueprint 2024-10-16 22:53:09 +08:00
raiot 53ccf4439c auto 2024-10-10 21:01:50 +08:00
5 changed files with 513 additions and 69 deletions

176
.gitignore vendored Normal file
View File

@ -0,0 +1,176 @@
# Created by https://www.toptal.com/developers/gitignore/api/python
# Edit at https://www.toptal.com/developers/gitignore?templates=python
### Python ###
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/#use-with-ide
.pdm.toml
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
### Python Patch ###
# Poetry local configuration file - https://python-poetry.org/docs/configuration/#local-configuration
poetry.toml
# ruff
.ruff_cache/
# LSP config files
pyrightconfig.json
# End of https://www.toptal.com/developers/gitignore/api/python

View File

@ -3,15 +3,16 @@ import math
import numpy as np
import random
from utils.cv_marker import cap_and_mark
from utils.stack_exe import Stackbot
# from utils.stack_exe import Stackbot
import cv2
import sys
pygame.init()
WIDTH, HEIGHT = 800, 600
screen = pygame.display.set_mode((WIDTH, HEIGHT))
pygame.display.set_caption("积木塔仿真")
pygame.display.set_caption("Stack Simulation")
WHITE = (255, 255, 255)
BLACK = (0, 0, 0)
@ -28,6 +29,14 @@ cap.set(6,cv2.VideoWriter.fourcc('M','J','P','G'))
cap.set(cv2.CAP_PROP_FRAME_WIDTH, 1920)
cap.set(cv2.CAP_PROP_FRAME_HEIGHT, 1080)
# 定义屏幕尺寸
SCREEN_WIDTH = 800
SCREEN_HEIGHT = 600
# 定义按钮尺寸
BUTTON_WIDTH = 200
BUTTON_HEIGHT = 200
class Block:
def __init__(self, x, y, width, height, angle, layer):
self.x = x
@ -62,27 +71,34 @@ class Block:
class Tower:
def __init__(self):
def __init__(self, blueprint, tower_image=None):
self.blocks = []
self.current_layer = 0
self.layer_centroids = [] # 存储每层的重心
self.stability_threshold = 10 # 稳定性阈值(像素)
self.rotation_angle = 15 # 预设每层旋转角度
self.angle_tolerance = 5 # 允许的角度偏移
self.position_tolerance = 10 # 允许的位置偏移
self.layer_centroids = []
self.stability_threshold = 10
self.rotation_angle = 15
self.angle_tolerance = 5
self.position_tolerance = 10
self.sim_to_robot_matrix = np.array([
[1, 0, 0, -300], # 假设的转换矩阵,需要根据实际情况调整
[1, 0, 0, -300],
[0, -1, 0, -200],
[0, 0, 1, 0],
[0, 0, 0, 1]
])
self.robot_to_sim_matrix = np.linalg.inv(self.sim_to_robot_matrix)
self.stack_bot = Stackbot()
self.is_upper = False
self.blueprint = blueprint
self.center_x, self.center_y = 300, 300 # 定义塔的中心点
self.ideal_positions = self.get_ideal_positions()
self.position_tolerance = 20
self.angle_tolerance = 15
self.tower_image = None
if tower_image:
self.tower_image = pygame.transform.scale(tower_image, (150, 150)) # 调整图片大小
def sim_to_robot_coords(self, x, y, angle):
sim_coords = np.array([x, y, 0, 1])
@ -106,7 +122,7 @@ class Tower:
angle (float): 机器人的角度
返回值
bool如果方块添加成返回 True否则返回 False
bool如果方块添加成返回 True否则返回 False
注意这个函数需要先调用 robot_to_sim_coords 来将机器人坐标转换为模拟坐标并且需要一个名为 Block 的类来创建方块对象
"""
@ -121,21 +137,27 @@ class Tower:
def add_block(self, block):
if self.current_layer == 5:
self.is_upper = True
print("is upper")
self.add_block_from_sim(block)
return True
# if self.current_layer == 5:
# self.is_upper = True
# print("is upper")
if not self.check_interference(block):
self.blocks.append(block)
self.stack_bot.pick_stack()
robo_xyz = self.sim_to_robot_coords(block.x, block.y, block.angle)
print(block.x, block.y, block.angle)
if self.is_upper:
self.stack_bot.place_stack(robo_xyz[0], robo_xyz[1], robo_xyz[2], self.current_layer - 5)
else:
self.stack_bot.place_stack(robo_xyz[0], robo_xyz[1], robo_xyz[2], self.current_layer)
return True
return False
# if not self.check_interference(block):
# self.blocks.append(block)
# self.stack_bot.pick_stack()
# robo_xyz = self.sim_to_robot_coords(block.x, block.y, block.angle)
# print(block.x, block.y, block.angle)
# if self.is_upper:
# self.stack_bot.place_stack(robo_xyz[0], robo_xyz[1], robo_xyz[2], self.current_layer - 5)
# else:
# self.stack_bot.place_stack(robo_xyz[0], robo_xyz[1], robo_xyz[2], self.current_layer)
# return True
# return False
def add_block_from_sim(self, block):
self.blocks.append(block)
return True
def draw(self, surface):
for block in self.blocks:
@ -252,49 +274,84 @@ class Tower:
self.layer_centroids.append(centroid)
self.current_layer += 1
def get_ideal_positions(self):
# 将相对坐标转换为绝对坐标
absolute_positions = []
for layer in self.blueprint:
layer_positions = []
for rel_x, rel_y, angle in layer:
abs_x = self.center_x + rel_x
abs_y = self.center_y + rel_y
layer_positions.append((abs_x, abs_y, angle))
absolute_positions.append(layer_positions)
return absolute_positions
def auto_place_block(self):
if self.current_layer == 0:
# 对于第一层,直接在中心放置
return Block(WIDTH // 2, HEIGHT // 2, 100, 20, 0, self.current_layer)
if self.current_layer >= len(self.ideal_positions):
print("塔已经完成")
return None, 0
base_centroid = self.layer_centroids[0] if self.layer_centroids else (WIDTH // 2, HEIGHT // 2)
current_centroid = self.calculate_current_layer_centroid() or base_centroid
possible_positions = self.generate_possible_positions()
best_position, similarity_score = self.find_best_position(possible_positions)
# 计算当前层的总质量(假设质量与宽度成正比)
total_mass = sum(block.width for block in self.blocks if block.layer == self.current_layer)
if best_position:
x, y, angle = best_position
print(f"选择的最佳位置: ({x}, {y}) 角度为 {angle}")
return Block(x, y, 100, 20, angle, self.current_layer), similarity_score
else:
print("无法找到合适的放置位置")
return None, 0
def generate_possible_positions(self):
ideal_positions = self.ideal_positions[self.current_layer]
possible_positions = []
for ideal_x, ideal_y, ideal_angle in ideal_positions:
for dx in range(-self.position_tolerance, self.position_tolerance + 1, 2):
for dy in range(-self.position_tolerance, self.position_tolerance + 1, 2):
for dangle in range(-self.angle_tolerance, self.angle_tolerance + 1, 5):
x = ideal_x + dx
y = ideal_y + dy
angle = (ideal_angle + dangle) % 360
possible_positions.append((x, y, angle))
print(f"生成的可能位置数量: {len(possible_positions)}")
return possible_positions
def find_best_position(self, possible_positions):
best_position = None
min_distance = float('inf')
best_similarity_score = 0
# 假设新积木块的质量与宽度成正比
new_block_mass = 100 # 新积木块的宽度
blocks_in_current_layer = sum(1 for block in self.blocks if block.layer == self.current_layer)
ideal_positions = self.ideal_positions[self.current_layer]
if blocks_in_current_layer < len(ideal_positions):
ideal_x, ideal_y, ideal_angle = ideal_positions[blocks_in_current_layer]
else:
print("警告:当前层的所有位置都已被填满")
return None, 0
# 计算理想位置
ideal_x = (base_centroid[0] * (total_mass + new_block_mass) - current_centroid[0] * total_mass) / new_block_mass
ideal_y = (base_centroid[1] * (total_mass + new_block_mass) - current_centroid[1] * total_mass) / new_block_mass
# 计算木块中心到塔重心的角度
dx = base_centroid[0] - ideal_x
dy = base_centroid[1] - ideal_y
center_to_centroid_angle = math.degrees(math.atan2(dy, dx))
# 计算垂直于中心线的理想角度
ideal_angle = (center_to_centroid_angle + 90) % 180 + 90
# 步骤 2: 寻找最佳角度
best_block = None
min_angle_diff = float('inf')
for angle in range(0, 180, 15): # 每15度尝试一次
test_block = Block(ideal_x, ideal_y, 100, 20, angle, self.current_layer)
for x, y, angle in possible_positions:
new_block = Block(x, y, 100, 20, angle, self.current_layer)
if not self.check_interference(test_block):
# 检查是否有支撑
if self.has_support(test_block):
# 计算与理想角度的差异
angle_diff = min((angle - ideal_angle) % 180, (ideal_angle - angle) % 180)
if angle_diff < min_angle_diff:
min_angle_diff = angle_diff
best_block = test_block
if self.has_support(new_block) and not self.check_interference(new_block):
temp_tower = Tower(self.blueprint)
temp_tower.blocks = self.blocks.copy()
temp_tower.add_block(new_block)
if temp_tower.check_stability():
distance = np.sqrt((x - ideal_x)**2 + (y - ideal_y)**2) + abs(angle - ideal_angle)
# 方案1使用指数函数
ratio = distance / (np.sqrt(WIDTH**2 + HEIGHT**2) + 360)
similarity_score = 100 * (1 - np.power(ratio, 0.5)) # 使用0.5次方增加敏感度
if distance < min_distance:
min_distance = distance
best_position = (x, y, angle)
best_similarity_score = similarity_score
return best_block
print(f"选择的最佳位置: {best_position},相似性评分: {best_similarity_score:.2f}")
return best_position, best_similarity_score
def has_support(self, block):
if self.current_layer == 0:
@ -353,10 +410,193 @@ class Tower:
self.add_block_from_robot(block[0][0], block[0][1], block[1])
def draw_tower_image(self, screen):
if self.tower_image:
image_rect = self.tower_image.get_rect()
image_rect.topright = (SCREEN_WIDTH - 10, 10) # 放置在右上角留出10像素的边距
screen.blit(self.tower_image, image_rect)
def show_tower_selection(screen):
# 加载塔型图片
tower_images = [
pygame.image.load("tower1.png"),
pygame.image.load("tower2.png"),
pygame.image.load("tower3.png")
]
# 调整图片大小
tower_images = [pygame.transform.scale(img, (BUTTON_WIDTH, BUTTON_HEIGHT)) for img in tower_images]
# 计算按钮位置
button_y = (SCREEN_HEIGHT - BUTTON_HEIGHT) // 2
button_x_start = (SCREEN_WIDTH - (BUTTON_WIDTH * 3 + 40)) // 2
# 创建按钮矩形
buttons = [
pygame.Rect(button_x_start + i * (BUTTON_WIDTH + 20), button_y, BUTTON_WIDTH, BUTTON_HEIGHT)
for i in range(3)
]
while True:
for event in pygame.event.get():
if event.type == pygame.QUIT:
pygame.quit()
sys.exit()
if event.type == pygame.MOUSEBUTTONDOWN:
mouse_pos = pygame.mouse.get_pos()
for i, button in enumerate(buttons):
if button.collidepoint(mouse_pos):
return i # 返回选择的塔型索引
screen.fill(WHITE)
# 绘制按钮和图片
for i, (button, image) in enumerate(zip(buttons, tower_images)):
screen.blit(image, button.topleft)
pygame.draw.rect(screen, BLACK, button, 2)
# 添加标题
font = pygame.font.Font(None, 36)
title = font.render("Select Tower Type", True, BLACK)
title_rect = title.get_rect(center=(SCREEN_WIDTH // 2, 50))
screen.blit(title, title_rect)
pygame.display.flip()
def polar_to_cartesian(r, theta, angle):
"""
将极坐标转换为直角坐标
参数:
r: 半径
theta: 极角()
angle: 方块的旋转角度()
返回:
(x, y, angle): 直角坐标和方块旋转角度
"""
# 将角度转换为弧度
theta_rad = math.radians(theta)
# 计算 x 和 y
x = r * math.cos(theta_rad)
y = r * math.sin(theta_rad)
# 四舍五入到整数
x = round(x)
y = round(y)
return (x, y, angle)
def get_tower_blueprint_1():
# 第一种塔型的蓝图(相对坐标)
return [
[(-40, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
[(-35, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
[(-30, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
[(-25, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
[(-20, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
[(-15, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
[(-10, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
[(-5, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
[(0, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
[(-5, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
[(-10, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
[(-15, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
[(-20, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
[(-25, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
[(-30, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
[(-35, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
[(-40, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
]
def get_tower_blueprint_2():
# 使用极坐标定义第二种塔型的蓝图
return [
[(-30, 0, 90), (30, 0, 90)],
[(-3, 30, -5), (3, -30, -5)],
[(-29, -5, 80), (29, 5, 80)],
[(-8, 29, -15), (8, -29, -15)],
[(-28, -10, 70), (28, 10, 70)],
[(-13, 27, -25), (13, -27, -25)],
[(-27, -13, 60), (27, 13, 60)],
[(-18, 25, -35), (18, -25, -35)],
[(-26, -18, 50), (26, 18, 50)],
[(-21, 23, -45), (21, -23, -45)],
[(-25, -21, 40), (25, 21, 40)],
[(-22, 20, -55), (22, -20, -55)],
[(-24, -22, 30), (24, 22, 30)],
[(-20, 19, -65), (20, -19, -65)],
[(-23, -20, 20), (23, 20, 20)],
[(-19, 18, -75), (19, -18, -75)],
[(-22, -19, 10), (22, 19, 10)],
[(-17, 17, -85), (17, -17, -85)],
[(-21, -17, 0), (21, 17, 0)],
[(-16, 16, -95), (16, -16, -95)],
]
def get_tower_blueprint_3():
# 第三种塔型的蓝图(相对坐标)
return [
[(-60, -60, 45), (60, -60, 135), (-60, 60, 135), (60, 60, 45)],
[(0, -80, 0), (80, 0, 90), (0, 80, 0), (-80, 0, 90)],
[(-55, -55, 45), (55, -55, 135), (-55, 55, 135), (55, 55, 45)],
[(0, -75, 0), (75, 0, 90), (0, 75, 0), (-75, 0, 90)],
[(-50, -50, 45), (50, -50, 135), (-50, 50, 135), (50, 50, 45)],
[(0, -70, 0), (70, 0, 90), (0, 70, 0), (-70, 0, 90)],
[(-45, -45, 45), (45, -45, 135), (-45, 45, 135), (45, 45, 45)],
[(0, -65, 0), (65, 0, 90), (0, 65, 0), (-65, 0, 90)],
[(-40, -40, 45), (40, -40, 135), (-40, 40, 135), (40, 40, 45)],
[(0, -60, 0), (60, 0, 90), (0, 60, 0), (-60, 0, 90)],
[(-40, 0, 90), (40, 0, 90)],
[(0, 40, 0), (0, -40, 0)],
]
def main():
tower = Tower()
pygame.init()
screen = pygame.display.set_mode((SCREEN_WIDTH, SCREEN_HEIGHT))
pygame.display.set_caption("Stack Simulation")
# 加载塔型图片
tower_images = [
pygame.image.load("tower1.png"),
pygame.image.load("tower2.png"),
pygame.image.load("tower3.png")
]
# 显示塔选择界面
selected_tower = show_tower_selection(screen)
# 根据选择的塔型设置相应的蓝图和图片
if selected_tower == 0:
tower_blueprint = get_tower_blueprint_1()
elif selected_tower == 1:
tower_blueprint = get_tower_blueprint_2()
else:
tower_blueprint = get_tower_blueprint_3()
tower = Tower(tower_blueprint, tower_images[selected_tower])
clock = pygame.time.Clock()
current_block = None
similarity_score = 0 # 初始化相似性评分
font = pygame.font.Font(None, 36) # 创建字体对象
running = True
while running:
@ -370,9 +610,27 @@ def main():
elif event.type == pygame.MOUSEMOTION:
if current_block:
current_block.x, current_block.y = pygame.mouse.get_pos()
# 实时计算当前位置的相似性评分
ideal_positions = tower.ideal_positions[tower.current_layer]
blocks_in_current_layer = sum(1 for block in tower.blocks if block.layer == tower.current_layer)
if blocks_in_current_layer < len(ideal_positions):
ideal_x, ideal_y, ideal_angle = ideal_positions[blocks_in_current_layer]
distance = np.sqrt((current_block.x - ideal_x)**2 + (current_block.y - ideal_y)**2) + abs(current_block.angle - ideal_angle)
# similarity_score = 100 * (1 - distance / (np.sqrt(WIDTH**2 + HEIGHT**2) + 360))
ratio = distance / (np.sqrt(WIDTH**2 + HEIGHT**2) + 360)
similarity_score = 100 * (1 - np.power(ratio, 0.5)) # 使用0.5次方增加敏感度
elif event.type == pygame.MOUSEBUTTONUP:
if event.button == 1 and current_block:
if tower.add_block(current_block):
# 计算最终放置位置的相似性评分
ideal_positions = tower.ideal_positions[tower.current_layer]
blocks_in_current_layer = sum(1 for block in tower.blocks if block.layer == tower.current_layer)
if blocks_in_current_layer < len(ideal_positions):
ideal_x, ideal_y, ideal_angle = ideal_positions[blocks_in_current_layer]
distance = np.sqrt((current_block.x - ideal_x)**2 + (current_block.y - ideal_y)**2) + abs(current_block.angle - ideal_angle)
similarity_score = 100 * (1 - distance / (np.sqrt(WIDTH**2 + HEIGHT**2) + 360))
if tower.add_block_from_sim(current_block):
print(f"Block placed with similarity score: {similarity_score:.2f}")
current_block = None
else:
print("CANNOT place block here, there is already a block there.")
@ -383,11 +641,15 @@ def main():
elif event.key == pygame.K_SPACE: # 按 'Space' 键确认当前层完成放置
tower.increase_layer()
elif event.key == pygame.K_a: # 按 'A' 键自动放置
auto_block = tower.auto_place_block()
auto_block, new_similarity_score = tower.auto_place_block()
if auto_block:
tower.add_block(auto_block)
if tower.add_block_from_sim(auto_block):
print(f"Successfully placed block at ({auto_block.x}, {auto_block.y}) with angle {auto_block.angle}")
similarity_score = new_similarity_score
else:
print("Cannot place block")
else:
print("Cannot find a suitable position for auto-placement.")
print("Cannot find suitable position")
elif event.key == pygame.K_n: # 按 'N' 键自动放置新的一层
tower.auto_place_layer()
elif event.key == pygame.K_s: # 按 'S' 键扫描当前层
@ -399,6 +661,10 @@ def main():
if current_block:
current_block.draw(screen)
# 显示相似性评分
score_text = font.render(f"Similarity Score: {similarity_score:.2f}", True, (0, 0, 0))
screen.blit(score_text, (10, 50))
# 计算并绘制当前层的重心
current_centroid = tower.calculate_current_layer_centroid()
if current_centroid:
@ -418,13 +684,15 @@ def main():
screen.blit(text, (WIDTH // 2 - text.get_width() // 2, 20))
font = pygame.font.Font(None, 36)
layer_text = font.render(f"Current layer: {tower.current_layer + 1}", True, BLACK)
layer_text = font.render(f"Current Layer: {tower.current_layer + 1}", True, BLACK)
screen.blit(layer_text, (10, 10))
tower.draw_tower_image(screen)
pygame.display.flip()
clock.tick(60)
pygame.quit()
if __name__ == "__main__":
main()
main()

BIN
tower1.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
tower2.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 42 KiB

BIN
tower3.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB