Skip to content

feat(skills): add desktop-pet skill for creating pixel-art companions#4808

Merged
wenshao merged 1 commit into
QwenLM:mainfrom
xxlaura:feat/desktop-pet-skill
Jun 18, 2026
Merged

feat(skills): add desktop-pet skill for creating pixel-art companions#4808
wenshao merged 1 commit into
QwenLM:mainfrom
xxlaura:feat/desktop-pet-skill

Conversation

@xxlaura

@xxlaura xxlaura commented Jun 5, 2026

Copy link
Copy Markdown
Contributor

What this PR does

Adds a bundled desktop-pet skill under .qwen/skills/desktop-pet/ that generates chibi pixel-art desktop pet companions for any character the user names — F1 drivers, anime characters, celebrities, fictional characters, animals, etc. The skill includes a SKILL.md with agent instructions (character research → color palette → spritesheet generation → pet.json → activation) and a scripts/gen_spritesheet.py parameterized generator that produces a 1536×1872 RGBA spritesheet (8 cols × 9 rows, 192×208 px cells) compatible with the existing pet animation system.

Why it's needed

Qwen Code's floating desktop pet is a delightful companion feature, but users are limited to the built-in Qwen capybara. The custom pet system (~/.qwen/pets/) already supports loading user-created spritesheets via pet.json manifests, but there is no easy way for users to create one. This skill bridges that gap by letting users simply name a character, and the agent handles everything from visual research to final activation end-to-end. It turns a multi-step technical process (researching colors, generating a properly-formatted sprite atlas, writing a manifest) into a single conversational interaction.

Reviewer Test Plan

How to verify

  1. Invoke the skill: in Qwen Code, type /desktop-pet or say "I want Piastri as my desktop pet"
  2. Confirm the agent creates ~/.qwen/pets/piastri/spritesheet.webp (1536×1872 RGBA webp) and ~/.qwen/pets/piastri/pet.json
  3. Open Qwen Code Settings → Appearance → Pet Companion, click Refresh, confirm "Piastri" appears in the list
  4. Select it and verify the animated pet renders in the floating window
  5. Repeat with a different character type (e.g., an anime character with spiky hair + glasses, or an animal with ears + tail) to exercise different headgear/features

Evidence (Before & After)

N/A — this is a new skill addition, not a change to existing UI.

Tested on

OS Status
🍏 macOS
🪟 Windows ⚠️
🐧 Linux ⚠️

Tested locally on macOS with multiple character types: F1 driver (Piastri — McLaren papaya + cap + #81), anime character (Gojo Satoru — dark outfit + spiky white hair + glasses), animal (Shiba Inu — fur colors + ears + tail), racing (helmet + #44), fantasy (crown + long hair + logo). All produce valid spritesheets that load correctly via the custom pet system. Windows and Linux not tested locally but the Python/Pillow dependency is cross-platform.

Environment (optional)

  • macOS 15+, Python 3.9+, Pillow (installed via pip3 install Pillow)

Risk & Scope

  • Main risk or tradeoff: Adds a Python dependency (Pillow) — mitigated by the skill checking for availability and reporting clearly if missing. Pillow is already a common package on most developer machines.
  • Not validated / out of scope: Windows and Linux testing; complex character features beyond the current 10 headgear types and 5 hair styles.
  • Breaking changes / migration notes: None — this is a purely additive skill with no changes to existing code.

Linked Issues

Closes #4807

中文说明

这个 PR 做了什么

.qwen/skills/desktop-pet/ 下新增了一个内置的 desktop-pet 技能,可以为用户指定的任何角色(F1 车手、动漫角色、名人、虚构角色、动物等)生成 Q版像素风格的桌面宠物。技能包含 SKILL.md(Agent 指令:角色调研 → 配色设计 → 精灵表生成 → pet.json → 激活)以及 scripts/gen_spritesheet.py(参数化生成器,输出兼容现有宠物动画系统的 1536×1872 RGBA 精灵表,8列×9行,192×208 像素单元格)。

为什么需要

Qwen Code 的浮动桌面宠物是一个很有趣的伴侣功能,但用户只能使用内置的 Qwen 水豚。自定义宠物系统(~/.qwen/pets/)已经支持通过 pet.json 清单加载用户创建的精灵表,但用户没有简单的方式来制作。这个技能填补了这个空白——用户只需说出角色名字,Agent 就能完成从视觉调研到最终激活的全部工作。它把一个多步骤的技术流程(调研颜色、生成格式正确的精灵图集、编写清单文件)变成了一次简单的对话交互。

审阅者测试计划

如何验证

  1. 调用技能:在 Qwen Code 中输入 /desktop-pet 或说"我想要皮亚斯特里当桌宠"
  2. 确认 Agent 创建了 ~/.qwen/pets/piastri/spritesheet.webp(1536×1872 RGBA webp)和 ~/.qwen/pets/piastri/pet.json
  3. 打开 Qwen Code 设置 → 外观 → 宠物伴侣,点击刷新,确认"Piastri"出现在列表中
  4. 选择它并验证动画宠物在浮动窗口中正确渲染
  5. 用不同类型的角色重复测试(如带尖发+眼镜的动漫角色,或带耳朵+尾巴的动物)以验证不同的头饰/特征

证据(前后对比)

不适用——这是新增技能,不涉及现有 UI 变更。

测试平台

在 macOS 上本地测试了多种角色类型:F1 车手(皮亚斯特里——迈凯伦木瓜橙 + 帽子 + #81)、动漫角色(五条悟——深色服装 + 白色尖发 + 眼镜)、动物(柴犬——毛色 + 耳朵 + 尾巴)、赛车(头盔 + #44)、奇幻(王冠 + 长发 + 徽标)。全部生成有效的精灵表,可通过自定义宠物系统正确加载。Windows 和 Linux 未本地测试,但 Python/Pillow 依赖是跨平台的。

风险与范围

  • 主要风险/权衡:新增 Python 依赖(Pillow)——通过技能检查可用性并在缺失时清晰提示来缓解。Pillow 在大多数开发者机器上已是常见包。
  • 未验证/不在范围内:Windows 和 Linux 测试;超出当前 10 种头饰和 5 种发型之外的复杂角色特征。
  • 破坏性变更/迁移说明:无——这是纯新增技能,不修改现有代码。

关联 Issue

Closes #4807

Add a bundled skill at .qwen/skills/desktop-pet/ that generates chibi
pixel-art desktop pet companions for any character the user names
(F1 drivers, anime characters, celebrities, animals, etc.).

- SKILL.md: agent instructions for character research, color palette
  design, spritesheet generation, and pet activation
- scripts/gen_spritesheet.py: parameterized generator producing a
  1536x1872 RGBA spritesheet (8x9 grid, 192x208 cells) compatible
  with the pet animation system (9 states, 10 headgear, 5 hair styles)

Output goes to ~/.qwen/pets/ for auto-discovery by the custom pet system.

Closes QwenLM#4807
@qwen-code-ci-bot

Copy link
Copy Markdown
Collaborator

Thanks for the PR, @xxlaura!

Template looks good — all required sections present, bilingual, test plan included ✓

On direction: this is a clean fit. The desktop pet companion is an established feature, and the custom pet system (~/.qwen/pets/) already exists — this skill fills a genuine gap by making it easy for users to create their own pets. It's a P3 feature request (#4807) but it's the kind of delightful, low-risk addition that makes a product feel polished. No CHANGELOG reference for desktop pets specifically, but the area is relevant. No sensitive areas touched (no auth/sandbox/telemetry/release concerns).

On approach: the scope feels right for what it does. A procedural pixel-art generator with 9 animation states, multiple hair/headgear/accessory options, and Pillow as the only external dependency — 776 lines is reasonable for this kind of parameterized drawing code. It's purely additive (2 new files, 0 modifications to existing code), bundled as a skill which is the established pattern. One minor note for Stage 2: the script hardcodes a macOS font path (/System/Library/Fonts/Helvetica.ttc) for chest numbers — there's a fallback to the default bitmap font, but it'll look different on Linux/Windows. Worth checking in code review.

Moving on to code review. 🔍

中文说明

感谢贡献,@xxlaura

模板完整——所有必要章节齐全,双语,包含测试计划 ✓

方向:非常契合。桌面宠物伴侣是已有功能,自定义宠物系统(~/.qwen/pets/)也已存在——这个技能填补了用户自建宠物的真实缺口。虽然是 P3 功能请求(#4807),但属于那种让产品更有温度的低风险改进。CHANGELOG 中没有桌面宠物的专门记录,但领域相关。不涉及敏感区域(无认证/沙箱/遥测/发布相关问题)。

方案:范围合理。一个基于 Pillow 的程序化像素画生成器,9 种动画状态、多种发型/头饰/配件选项,776 行代码对于这类参数化绘图来说是合理的。纯新增(2 个新文件,0 修改),以技能形式打包,符合既有模式。一个小注意点留到 Stage 2:脚本中硬编码了 macOS 字体路径(/System/Library/Fonts/Helvetica.ttc)用于胸前号码渲染——虽然有回退到默认位图字体,但在 Linux/Windows 上显示效果会不同,代码审查时值得检查。

进入代码审查 🔍

Qwen Code · qwen3.7-max

fill_rect(draw, hx + 18*s, hy + s, s, s, (150, 200, 255))


def draw_character(draw, cx, cy, colors, features, scale=3, flip=False,

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] The flip parameter does not actually mirror the character. It is only used in draw_headgear() to shift the cap brim position — the body, legs, arms, head, and face are drawn identically regardless of flip. Row 2 ("running-left") sets flip: True on all 8 frames, but the character still faces right (crab-walking left). For any headgear other than "cap", rows 1 and 2 produce visually identical output.

Suggested change
def draw_character(draw, cx, cy, colors, features, scale=3, flip=False,
def draw_character(draw, cx, cy, colors, features, scale=3, flip=False,
arm_angle=0, leg_offset=0, body_tilt=0, head_tilt=0,
expression="normal", arm_wave=False, jump_y=0, collapsed=False):
"""Draw a chibi character centered at (cx, cy). flip is handled by the caller via Image.transpose."""

Then in gen_row(), apply the flip after drawing:

if fc.get("flip"):
    img = img.transpose(Image.FLIP_LEFT_RIGHT)
sheet.paste(img, (col * CW, row * CH), img)

— qwen3.7-plus via Qwen Code /review

fill_rect(draw, left_arm_x - s, arm_y + 10*s, 5*s, 4*s, skin)
fill_rect(draw, right_arm_x, arm_y - 8*s, 5*s, 10*s, outfit)
fill_rect(draw, right_arm_x, arm_y - 10*s, 6*s, 4*s, skin)
elif arm_angle > 0:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] Negative arm_angle values are silently ignored. The condition elif arm_angle > 0: misses negative values — running animation frames 5–7 use arm_angle of -2 and -3 (backward arm swing), but these fall into the else branch (neutral pose). This means 3 of 8 running frames have no arm animation, causing the arms to snap back to neutral mid-stride.

Suggested change
elif arm_angle > 0:
elif arm_angle != 0:

The existing coordinate math (left_arm_x - arm_angle, right_arm_x + arm_angle) already handles negative values correctly.

— qwen3.7-plus via Qwen Code /review

if isinstance(c, str) and c.startswith("#"):
h = c.lstrip("#")
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
return c

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] tuple_color() silently passes through invalid values, causing cryptic crashes downstream. Specific failure modes:

  • "#FFF" (3-char hex) → int("", 16) raises ValueError
  • "red" (named color) → returned as string → darken("red") raises TypeError
  • null/None → returned as-is → None[:3] raises TypeError
  • [300, -10, 0] (out-of-range) → passed to Pillow → ValueError
Suggested change
return c
def tuple_color(c):
if isinstance(c, list):
if len(c) not in (3, 4) or not all(isinstance(v, int) and 0 <= v <= 255 for v in c):
raise ValueError(f"Invalid color (need [R,G,B] with 0-255): {c}")
return tuple(c)
if isinstance(c, str) and c.startswith("#"):
h = c.lstrip("#")
if len(h) == 3:
h = "".join(ch * 2 for ch in h)
if len(h) != 6:
raise ValueError(f"Invalid hex color (need #RRGGBB): #{h}")
return tuple(int(h[i:i+2], 16) for i in (0, 2, 4))
if isinstance(c, tuple) and len(c) in (3, 4):
return c
raise ValueError(f"Invalid color value (need [R,G,B] or '#RRGGBB'): {c!r}")

— qwen3.7-plus via Qwen Code /review

{"jump_y": 0, "head_tilt": 0, "expression": "normal"},
])

# Row 7: running (generic, same as row 1)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Row 7 ("running generic") uses the exact same 8 frame configs as Row 1 ("running-right") — every field is identical. This wastes atlas space with a duplicate animation. Either give Row 7 a distinct animation (e.g., walking with smaller offsets) or extract the shared config into a named variable to make the duplication explicit:

RUNNING_RIGHT_FRAMES = [
    {"leg_offset": 0, "body_tilt": 0, ...},
    ...
]
gen_row(sheet, 1, colors, features, RUNNING_RIGHT_FRAMES)
# Row 7: generic running — same art as running-right
gen_row(sheet, 7, colors, features, RUNNING_RIGHT_FRAMES)

— qwen3.7-plus via Qwen Code /review

import argparse
import json
import sys
import math

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] import math is never used anywhere in the file. Remove it.

— qwen3.7-plus via Qwen Code /review

sheet = Image.new("RGBA", (W, H), (0, 0, 0, 0))
gen_all_animations(sheet, colors, features)

import os

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] import os is inside main() while all other imports are at the top of the file. Move it to the top-level import block (PEP 8).

— qwen3.7-plus via Qwen Code /review


import os
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
sheet.save(args.output, "WEBP", quality=95, lossless=True)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] quality=95 is silently ignored when lossless=True (per Pillow docs). Remove it to avoid misleading readers:

Suggested change
sheet.save(args.output, "WEBP", quality=95, lossless=True)
sheet.save(args.output, "WEBP", lossless=True)

— qwen3.7-plus via Qwen Code /review

if num:
try:
font_size = max(5 * s, 8)
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Hardcoded macOS font path fails silently on Linux/Windows. The bare except Exception swallows the error and falls back to ImageFont.load_default(), which renders a low-quality bitmap font at a different size. The jersey number will look noticeably different on non-macOS platforms with no warning.

Consider a cross-platform font lookup:

font = None
for fp in ["/System/Library/Fonts/Helvetica.ttc",
           "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf"]:
    try:
        font = ImageFont.truetype(fp, font_size)
        break
    except Exception:
        continue
if font is None:
    font = ImageFont.load_default()

— qwen3.7-plus via Qwen Code /review

# Merge colors with defaults (convert all to tuples)
colors = {k: tuple_color(v) for k, v in DEFAULT_COLORS.items()}
for k, v in config.get("colors", {}).items():
colors[k] = tuple_color(v)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] json.loads(args.config) and json.load(f) have no error handling. When the AI agent passes malformed JSON (common with shell escaping), the user sees a raw JSONDecodeError traceback with no context about the original input.

try:
    config = json.loads(args.config)
except json.JSONDecodeError as e:
    sys.exit(f"ERROR: Invalid JSON config: {e}\nConfig was: {args.config[:200]}")

— qwen3.7-plus via Qwen Code /review

gen_all_animations(sheet, colors, features)

import os
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The --output path is not validated — the script will create directories and write files anywhere the user has permission. Since SKILL.md constructs the path from a user-supplied character_id, a value like ../../tmp/evil could escape ~/.qwen/pets/. Consider validating:

output_path = os.path.abspath(os.path.expanduser(args.output))
pets_dir = os.path.expanduser("~/.qwen/pets")
if not output_path.startswith(pets_dir + os.sep):
    sys.exit(f"ERROR: output must be under {pets_dir}")

— qwen3.7-plus via Qwen Code /review

qqqys commented Jun 8, 2026

Copy link
Copy Markdown
Collaborator

This is a very nice skill. One thing though: this looks more like a desktop-side feature. Could you submit this as a PR against the desktop branch instead?

I'd be very happy to have this become a built-in capability for the desktop experience.

fill_rect(draw, right_arm_x + s, arm_y + 10*s, 5*s, 4*s, skin)

# --- Scarf (drawn between body and head) ---
draw_extras(draw, cx, cy, s, colors, features, bx, by, expression)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Critical] draw_extras(...) is called here, but the opaque head rectangle is drawn later at line 289: fill_rect(draw, hx, hy, 20*s, 18*s, skin). Glasses coordinates (hx+3s..hx+17s, hy+7s..hy+12s) fall entirely within the head bounding box, so the skin fill paints over them completely. Same for sweat_drop at hx+18s, hy+2s. Any pet configured with "glasses" or "sweat_drop" in extras renders identically to one without — the features are silently dropped. The Gojo Satoru example in SKILL.md uses glasses.

Move draw_extras(...) to after draw_headgear(...) (at the end of draw_character) so that glasses and sweat_drop are drawn on top of the head:

Suggested change
draw_extras(draw, cx, cy, s, colors, features, bx, by, expression)
# --- HEADGEAR ---
draw_headgear(draw, hx, hy, s, colors, features, flip)
# --- Extras (after head so glasses/sweat_drop are visible) ---
draw_extras(draw, cx, cy, s, colors, features, bx, by, expression)

— qwen3.7-max via Qwen Code /review

# --- FACE ---
ey = hy + 8*s

if expression == "blink":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The blink branch draws only closed-eye lines — no mouth is drawn. Since this is an elif, the else (normal) branch with mouth code is skipped. Every other expression branch includes mouth drawing. During idle animation (frames 2, 7) and waiting animation (frames 4, 5), the character's mouth disappears entirely, causing a visible flicker.

Add the normal mouth drawing to the blink branch:

Suggested change
if expression == "blink":
if expression == "blink":
fill_rect(draw, hx + 4*s, ey + s, 4*s, s, eye_c)
fill_rect(draw, hx + 12*s, ey + s, 4*s, s, eye_c)
fill_rect(draw, hx + 8*s, ey + 5*s, 4*s, s, mouth_c)
fill_rect(draw, hx + 7*s, ey + 4*s, s, s, mouth_c)
fill_rect(draw, hx + 12*s, ey + 4*s, s, s, mouth_c)

— qwen3.7-max via Qwen Code /review

colors[k] = tuple_color(v)

# Auto-derive missing colors
if "hair_light" not in config.get("colors", {}):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] Auto-derivation only handles hair_light and mouth. When a user overrides base colors (e.g., skin: [120,80,50]) without providing shade variants, skin_dark remains at the DEFAULT_COLORS value ([200,160,120]) — which may be lighter than the user's base skin. SKILL.md examples don't include skin_dark, outfit_dark, or outfit_light, so most users following the examples would get silently incorrect shade colors.

Auto-derive all shade colors from base colors when not explicitly provided:

Suggested change
if "hair_light" not in config.get("colors", {}):
# Auto-derive missing colors
user_color_keys = set(config.get("colors", {}).keys())
if "hair_light" not in user_color_keys:
colors["hair_light"] = lighten(colors["hair"], 30)
if "mouth" not in user_color_keys:
colors["mouth"] = darken(colors["skin"], 80)
if "skin_dark" not in user_color_keys:
colors["skin_dark"] = darken(colors["skin"], 25)
if "outfit_dark" not in user_color_keys:
colors["outfit_dark"] = darken(colors["outfit"], 40)
if "outfit_light" not in user_color_keys:
colors["outfit_light"] = lighten(colors["outfit"], 40)

— qwen3.7-max via Qwen Code /review

fill_rect(draw, hx + 13*s, ey, 3*s, 3*s, eye_c)
fill_rect(draw, hx + 14*s, ey + s, s, s, (255, 255, 255))
fill_rect(draw, hx + 8*s, ey + 4*s, 4*s, 3*s, mouth_c)
fill_rect(draw, hx + 9*s, ey + 5*s, 2*s, s, (180, 80, 80))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The inner mouth rect hardcodes (180, 80, 80) while the outer rect at line 361 correctly uses mouth_c. All other expressions consistently use mouth_c for all mouth pixels. If a user provides a custom mouth color, the surprised expression renders a two-tone mouth while all other expressions render correctly.

Suggested change
fill_rect(draw, hx + 9*s, ey + 5*s, 2*s, s, (180, 80, 80))
fill_rect(draw, hx + 9*s, ey + 5*s, 2*s, s, mouth_c)

— qwen3.7-max via Qwen Code /review

@tanzhenxin tanzhenxin added the type/feature-request New feature or enhancement request label Jun 8, 2026

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two additional findings beyond the 14 existing inline comments. The three Critical bugs already flagged (flip not mirroring, negative arm_angle silently dropped, draw-order hiding glasses) are the main blockers — these two are lower-severity additions.

— qwen-code via Qwen Code /review

fill_rect(draw, hx + 14*s, ey + s, s, s, (255, 255, 255))
fill_rect(draw, hx + 8*s, ey + 4*s, 4*s, 3*s, mouth_c)
fill_rect(draw, hx + 9*s, ey + 5*s, 2*s, s, (180, 80, 80))
elif expression == "determined":

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The determined expression branch (lines 363–370) is dead code — no animation frame config in gen_all_animations() ever sets expression: "determined". The five expressions actually used across all 9 rows are: normal, blink, happy, sad, surprised. Either wire it into an animation (e.g., Row 8 "review/thinking" could transition through determined before happy) or remove the branch to keep the code minimal.

— qwen-code via Qwen Code /review

def draw_extras(draw, cx, cy, s, colors, features, bx, by, expression):
"""Draw extra features like glasses, scarf, tail, wings."""
extras = features.get("extras", [])
hx = cx - 10 * s

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] draw_extras computes hx = cx - 10 * s without head_tilt, but draw_character draws the head at hx = cx - 10*s + head_tilt (line 283). When head_tilt != 0 (e.g., Row 8 frames 2–3 use head_tilt: 4), glasses are drawn at the wrong x position relative to the head — they appear to slide off the face. This is currently masked by the draw-order bug (Critical comment at line 280) that hides glasses behind the opaque head rect, but will surface once that is fixed. Pass head_tilt to draw_extras so glasses track the head.

— qwen-code via Qwen Code /review

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review Summary

This PR adds a desktop-pet skill with a Python spritesheet generator. Overall the implementation is well-structured with clear SKILL.md instructions and a comprehensive animation system. I found one functional bug and one cross-platform concern.

jump_y=fc.get("jump_y", 0),
collapsed=fc.get("collapsed", False),
)
sheet.paste(img, (col * CW, row * CH), img)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: flip=True does not mirror the character

The flip parameter flows from gen_rowdraw_characterdraw_headgear, but it only affects the cap brim position (lines ~95-98). The character's body, face, hair, arms, and legs are never horizontally mirrored.

This means the "running-left" animation (row 2) — which sets flip=True — will still show the character facing right, just with a slightly shifted cap brim. All asymmetric features (number on chest, scarf tail direction, arm wave side) remain unflipped.

Suggested fix — mirror the rendered frame in gen_row before pasting:

def gen_row(sheet, row, colors, features, frame_configs):
    for col, fc in enumerate(frame_configs):
        img = Image.new("RGBA", (CW, CH), (0, 0, 0, 0))
        d = ImageDraw.Draw(img)
        draw_character(d, CW // 2 + fc.get("dx", 0), CH // 2 + 20, ...)
        if fc.get("flip", False):
            img = img.transpose(Image.FLIP_LEFT_RIGHT)
        sheet.paste(img, (col * CW, row * CH), img)

Then you can remove the flip parameter from draw_character and draw_headgear since the mirroring is handled at the image level, which is more robust.

if num:
try:
font_size = max(5 * s, 8)
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cross-platform concern: hardcoded macOS font path

/System/Library/Fonts/Helvetica.ttc only exists on macOS. On Linux and Windows, the except branch falls back to ImageFont.load_default(), which is a tiny bitmap font that renders numbers poorly at 5*s (15px) size.

Since the PR body notes Linux/Windows as untested and the skill targets cross-platform use, consider adding fallback font paths:

font_paths = [
    "/System/Library/Fonts/Helvetica.ttc",           # macOS
    "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", # Linux (Debian/Ubuntu)
    "C:\\Windows\\Fonts\\arial.ttf",                     # Windows
]
font = None
for fp in font_paths:
    try:
        font = ImageFont.truetype(fp, font_size)
        break
    except (OSError, IOError):
        continue
if font is None:
    font = ImageFont.load_default()

| 3 | waving | Waving at user (8 frames) |
| 4 | jumping | Jumping celebration (8 frames) |
| 5 | failed | Sad/collapsed on error (8 frames) |
| 6 | waiting | Idle tapping (8 frames) |

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bug] The open command is macOS-only. On Linux the equivalent is xdg-open, and on Windows it is start. Since this skill is advertised as cross-platform (the PR lists Linux and Windows with warnings), the agent instructions should either use OS-appropriate commands or add a note like:

# macOS
open ~/.qwen/pets/<character_id>/spritesheet.webp
# Linux
xdg-open ~/.qwen/pets/<character_id>/spritesheet.webp

`glasses` · `scarf` · `tail` · `wings` · `number` (set `features.number`) · `logo` · `sweat_drop`

## Example Characters

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Bug] The F1 Driver example sets both "number": "81" and "extras": ["logo"], but the generator code (gen_spritesheet.py ~line 250) has:

if "logo" in features.get("extras", []) and not num:

This means logo is silently ignored whenever number is set. The example is misleading — the logo will never appear on the spritesheet. Either remove "logo" from the example's extras, or document that number takes precedence over logo.

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #4808

Scope reviewed: 2 files (SKILL.md, gen_spritesheet.py), +776 lines

I focused on high-confidence functional bugs not already covered by the 20 existing inline comments. Most issues in this PR have been thoroughly reviewed by @wenshao and @DragonnZhang. I found one additional bug:

Bug found: draw_extras() receives pre-jump cy, causing accessories to detach during jumps

At the call site on line ~280:

cy += jump_y  # line 213 — cy is modified
...
draw_extras(draw, cx, cy, s, colors, features, bx, by, expression)  # passes post-jump cy

Wait — re-checking: cy += jump_y mutates cy in place at line 213. The draw_extras call on line ~280 does receive the post-jump cy. However, draw_extras computes head coordinates as:

hx = cx - 10 * s
hy = cy - 16 * s

This uses the passed cy (which IS post-jump). The head is then drawn at:

hx = cx - 10*s + head_tilt  # line 283
hy = cy - 16*s              # line 284

Both use the same post-jump cy. So actually the draw_extras glasses and the head share the same cy basis. My initial analysis was incorrect — this is NOT a bug.

After careful re-analysis, I found no new high-confidence issues beyond what the existing reviewers have already flagged. The three Critical bugs (flip not mirroring, negative arm_angle dropped, glasses draw-order) are the correct blockers. The suggestions are reasonable.

Verdict: The existing review coverage on this PR is excellent. No additional findings from my pass.

@DragonnZhang DragonnZhang left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review — PR #4808 (second pass, corrected)

Scope: 2 files (SKILL.md, gen_spritesheet.py), +776 lines

I independently reviewed the full diff looking for high-confidence functional bugs not already covered by the 20 existing inline comments from @wenshao and @DragonnZhang.

Result: No new issues to add. The existing review coverage is thorough and correct. The three Critical bugs are the right blockers:

  1. flip=True does not mirror the character (line 194/404) — only affects cap brim position; body, face, hair, arms, legs are all drawn identically regardless of flip. Row 2 ("running left") is visually broken.

  2. Negative arm_angle silently dropped (line 268) — elif arm_angle > 0: misses negative values used in running frames 5-7, so backward arm swing is lost.

  3. Glasses drawn before opaque head rect (line 280) — draw_extras() renders glasses, then the head fill_rect at line 289 paints over them with skin color.

I verified several potential issues I initially suspected (jump_y offset propagation to extras, body coordinate consistency) and confirmed they are NOT bugs — cy += jump_y mutates cy in place before draw_extras is called, and body coordinates bx/by properly include body_tilt.

Recommendation: Fix the three Critical bugs, then this is ready to merge. The existing suggestions (unused import math, macOS-only font path, quality=95 with lossless=True, blink missing mouth, dead determined expression) are all valid quality improvements worth addressing.

@pomelo-nwu pomelo-nwu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm

fill_rect(draw, bx + 21*s, by + 4*s, 3*s, 5*s, wing_c)
fill_rect(draw, bx + 18*s, by + 3*s, 3*s, 5*s, wing_dark)

if "sweat_drop" in extras and expression in ("waiting", "failed"):

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] sweat_drop checks expression in ("waiting", "failed"), but these are animation row names (Row 5 = "failed", Row 6 = "waiting"), not expression values. The expression parameter passed from gen_row only takes: "normal", "blink", "happy", "sad", "surprised", "determined". Row 5 uses "expression": "sad" and Row 6 uses "expression": "normal" — neither matches "waiting" or "failed".

The sweat_drop extra is dead code and can never render regardless of configuration.

Suggested change
if "sweat_drop" in extras and expression in ("waiting", "failed"):
if "sweat_drop" in extras and expression in ("sad", "surprised"):

— qwen3.7-max via Qwen Code /review

hy = cy - 16*s

# Hair back
fill_rect(draw, hx - s, hy - 2*s, 22*s, 6*s, hair_c)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The hair back layer fill_rect(draw, hx - s, hy - 2*s, 22*s, 6*s, hair_c) is drawn unconditionally for all hair styles, including "bald". The bald branch (line 322-323) only adds a narrow skin_dark shading that is 16*s wide — it does not cover the 22*s-wide hair back. This leaves 3*s-wide hair-colored strips visible on each side above a bald character's head, breaking the bald silhouette. Verified pixel-level: all 72 frames affected.

Suggested change
fill_rect(draw, hx - s, hy - 2*s, 22*s, 6*s, hair_c)
# Hair back (behind head)
if hair_style != "bald":
fill_rect(draw, hx - s, hy - 2*s, 22*s, 6*s, hair_c)

Note: hair_style is read at line 292, so either move the read above this line or restructure the block.

— qwen3.7-max via Qwen Code /review

@wenshao
wenshao merged commit 24d7595 into QwenLM:main Jun 18, 2026
55 of 56 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type/feature-request New feature or enhancement request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(skills): add desktop-pet skill for creating custom pixel-art companions

7 participants