mirror of
https://github.com/Shu-Ying/main_plugin_farm.git
synced 2026-11-02 10:40:14 +08:00
fixed # 修复作物绘制位置异常的BUG
This commit is contained in:
@@ -56,15 +56,17 @@ top = Cy + 39.75 × s - 0.75 × H × s
|
|||||||
场景倍率为 2,底边对齐的计算误差不超过 0.5 输出像素。
|
场景倍率为 2,底边对齐的计算误差不超过 0.5 输出像素。
|
||||||
这验证了预览公式的执行与图片效果,不代表所有素材已逐一核对原图。
|
这验证了预览公式的执行与图片效果,不代表所有素材已逐一核对原图。
|
||||||
|
|
||||||
正式渲染器已切换到本文公式。`crop_positions.json` schema=2 保存统一规则,
|
正式渲染器现使用 `crop_positions.json` schema=4 的本地可见落地机制,
|
||||||
不再包含人工逐阶段锚点;导出 metadata 也不再写入人工标注。
|
不包含人工逐阶段锚点。种子和枯萎仍使用场景包原始公共精灵,
|
||||||
种子和枯萎使用场景包原始公共精灵,分别为 69×30 和 209×92。
|
分别为 69×30 和 209×92。
|
||||||
|
|
||||||
现有 3072 基准场景沿用旧土地布局;以其土地显示高度 `D` 除以 79.5
|
此前将本地土地完整 PNG 高度除以 79.5,混用了透明画布与父矩形语义。
|
||||||
得到原版单位到当前布局的统一倍率,再换算作物与虫子尺寸和坐标。
|
当前通过全部 30 块地中心计算整体尺度;作物保持直立等比缩放,以可见
|
||||||
跨输出分辨率只追加 `width/3072`,不重复应用 0.75。
|
底部区域的加权中心对齐本地土地中心,不再使用网页底边锚点。
|
||||||
旧土地的宽高比例仍沿用本地布局,因此本次是定位规则移植,
|
虫子保留局部坐标映射;输出分辨率再乘 `width/3072`。
|
||||||
不是对整个网页场景像素级复刻。
|
详见 [作物场景映射修正](22-crop-scene-mapping.md)。
|
||||||
|
原网页规则仍成立,但不能把本地 PNG 高度直接视为上文的 D。
|
||||||
|
沿用的旧土地网格存在拟合残差,不是网页场景的像素级复刻。
|
||||||
|
|
||||||
`scripts/preview_crop_positions.py` 已改为调用正式渲染器,生成
|
`scripts/preview_crop_positions.py` 已改为调用正式渲染器,生成
|
||||||
1024、1536、3072 三种输出档位的作物阶段及虫子预览。
|
1024、1536、3072 三种输出档位的作物阶段及虫子预览。
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
# 真寻农场可见落地机制
|
||||||
|
|
||||||
|
用户验收指出:完整纹理底边对齐网页父矩形,即使计算过程正确,仍不能
|
||||||
|
保证本地作物根部落在土地中心。本地成熟胡萝卜画布高 168,可见内容仅到
|
||||||
|
102;直接对齐整张纹理底边会让根部偏向地块后上方。
|
||||||
|
|
||||||
|
当前机制以真寻农场自身的落地效果为准,配置为 schema=4、
|
||||||
|
mode=visible_contact_center,不再对作物执行网页底边锚点或倾斜变换。
|
||||||
|
|
||||||
|
## 通用落地规则
|
||||||
|
|
||||||
|
1. 每块土地的中心 C 取本地绘制矩形中心。
|
||||||
|
2. 作物等比缩放并保持直立,不旋转、不剪切;保留完整纹理文件。
|
||||||
|
3. 在最终尺寸图片中,忽略 alpha 小于 32 的边缘像素,取得可见范围。
|
||||||
|
4. 生长图、成熟图、枯萎图取可见高度最下方 20% 的区域,计算该区域
|
||||||
|
有效像素的 alpha 加权中心 P,作为自动落地参考点。
|
||||||
|
5. 种子图取可见包围盒中心作为 P,避免种子簇偏离土地中心。
|
||||||
|
6. 左上角绘制位置为 `round(C × 输出比例 - P)`。
|
||||||
|
|
||||||
|
这样上下左右增加透明留白不会改变可见落点。没有逐作物、逐阶段的手调
|
||||||
|
偏移;同一规则应用于所有素材。底部区域是图像推导的落地参考,并非声称
|
||||||
|
能够识别所有美术素材的植物根系,后续新增素材仍应做视觉抽查。
|
||||||
|
|
||||||
|
## 缩放与虫子
|
||||||
|
|
||||||
|
已有 30 块地的场景映射仍用于取得整体尺寸尺度及虫子的局部坐标。
|
||||||
|
作物只取其面积对应的统一尺度 `sqrt(abs(det(A)))≈1.471`,不将倾斜、
|
||||||
|
剪切施加到作物图片。生长图尺寸系数为 0.75,种子和枯萎为 0.5;
|
||||||
|
最后乘输出宽度/3072。这里的系数仅决定大小,不再决定落地锚点。
|
||||||
|
|
||||||
|
虫子不使用植物底部算法,保持原来的局部位置和显示尺寸规则。
|
||||||
|
土地映射原始参考及匹配顺序保留于 crop_positions.json;旧土地图不规则,
|
||||||
|
中心拟合 RMS≈4.18、最大残差≈9.08 像素,但作物逐块使用本地实际中心,
|
||||||
|
不使用拟合平移作为落点。这不是网页的逐像素复刻。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
包含增加透明留白不改变落地参考、上方叶片不拉偏底部参考、透明图片回退、
|
||||||
|
种子/枯萎分支、1024/1536/3072 三种尺寸,以及成熟胡萝卜和固定随机种子的
|
||||||
|
五种作物各阶段预览。随机抽样为杨梅、椰子、冬瓜、白萝卜、花生。
|
||||||
|
|
||||||
|
`scripts/preview_crop_mapping.py --output <目录>` 可复现预览,包含带虫和
|
||||||
|
隐藏虫子便于检查的成熟胡萝卜图。此过程不读玩家数据库、不部署或重启。
|
||||||
@@ -4,20 +4,65 @@ import argparse
|
|||||||
import hashlib
|
import hashlib
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from zipfile import ZIP_STORED, ZipFile, ZipInfo
|
from zipfile import ZIP_STORED, ZipFile, ZipInfo
|
||||||
|
|
||||||
from PIL import Image
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def fit_grid(source, target):
|
||||||
|
if len(source) != 30 or len(target) != 30:
|
||||||
|
raise ValueError('Expected all 30 reference land centers')
|
||||||
|
design = [[x, y, 1] for x, y in source]
|
||||||
|
normal = [[sum(row[i]*row[j] for row in design)
|
||||||
|
for j in range(3)] for i in range(3)]
|
||||||
|
|
||||||
|
def solve(values):
|
||||||
|
matrix = [row[:] + [value] for row, value in zip(normal, values)]
|
||||||
|
for i in range(3):
|
||||||
|
pivot = max(range(i, 3), key=lambda k: abs(matrix[k][i]))
|
||||||
|
matrix[i], matrix[pivot] = matrix[pivot], matrix[i]
|
||||||
|
divisor = matrix[i][i]
|
||||||
|
if abs(divisor) < 1e-8:
|
||||||
|
raise ValueError('Reference grid is degenerate')
|
||||||
|
matrix[i] = [v/divisor for v in matrix[i]]
|
||||||
|
for j in range(3):
|
||||||
|
if i != j:
|
||||||
|
factor = matrix[j][i]
|
||||||
|
matrix[j] = [v-factor*w for v, w in zip(
|
||||||
|
matrix[j], matrix[i],
|
||||||
|
)]
|
||||||
|
return [row[-1] for row in matrix]
|
||||||
|
|
||||||
|
affine = [solve([sum(row[i]*point[k]
|
||||||
|
for row, point in zip(design, target))
|
||||||
|
for i in range(3)]) for k in range(2)]
|
||||||
|
errors = [math.hypot(*[
|
||||||
|
sum(a*b for a, b in zip(row, point))-value
|
||||||
|
for row, value in zip(affine, dest)
|
||||||
|
]) for point, dest in zip(design, target)]
|
||||||
|
if max(errors) > 10:
|
||||||
|
raise ValueError('Local grid no longer matches reference geometry')
|
||||||
|
return [row[:2] for row in affine], {
|
||||||
|
'rms': math.sqrt(sum(e*e for e in errors)/len(errors)),
|
||||||
|
'maximum': max(errors), 'points': len(errors),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def apply_positions(root, metadata):
|
def apply_positions(root, metadata):
|
||||||
document = json.loads((
|
document = json.loads((
|
||||||
root / "src/farm_render/data/crop_positions.json"
|
root / "src/farm_render/data/crop_positions.json"
|
||||||
).read_bytes())
|
).read_bytes())
|
||||||
if document.get("schema") != 2:
|
if document.get("schema") != 4:
|
||||||
raise ValueError("Unsupported crop position schema")
|
raise ValueError("Unsupported crop position schema")
|
||||||
if document.get("mode") != "full_canvas_bottom_center":
|
if document.get("mode") != "visible_contact_center":
|
||||||
raise ValueError("Unsupported crop placement mode")
|
raise ValueError("Unsupported crop placement mode")
|
||||||
|
w, h = metadata['soil_size']
|
||||||
|
target = [[p['x']+w/2, p['y']+h/2] for p in metadata['positions']]
|
||||||
|
transform, errors = fit_grid(document['source_land_centers'], target)
|
||||||
|
document['scene_transform'] = transform
|
||||||
|
document['grid_fit'] = errors
|
||||||
metadata["placement"] = document
|
metadata["placement"] = document
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
"""离线校验统一地块映射:成熟胡萝卜与随机五种作物各阶段。"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
from dataclasses import replace
|
||||||
|
from pathlib import Path
|
||||||
|
import random
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
sys.path.insert(0, str(ROOT / 'src'))
|
||||||
|
|
||||||
|
from ci_font import choose_font
|
||||||
|
from farm_core import Catalogue, Identity
|
||||||
|
from farm_core.domain import Account, Plot, Snapshot
|
||||||
|
from farm_render import FarmRenderer, RenderOptions
|
||||||
|
from farm_render.canvas import Canvas
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description=__doc__)
|
||||||
|
parser.add_argument('--output', type=Path, required=True)
|
||||||
|
args = parser.parse_args()
|
||||||
|
args.output.mkdir(parents=True, exist_ok=True)
|
||||||
|
renderer = FarmRenderer(choose_font())
|
||||||
|
who = Identity('preview', 'local', 'qq', 'crop-mapping')
|
||||||
|
catalogue = Catalogue.bundled()
|
||||||
|
carrot = catalogue.get('胡萝卜')
|
||||||
|
data = Snapshot(Account('成熟胡萝卜 · 统一公式校验', 11, 0, 6), (), tuple(
|
||||||
|
Plot(i, carrot, 100, pest_count=3, pest_spawned=3, maturity_pests=3)
|
||||||
|
for i in range(1, 4)
|
||||||
|
))
|
||||||
|
result = renderer.render(who, data, 200, RenderOptions.high(
|
||||||
|
format='JPEG', theme='autumn',
|
||||||
|
))
|
||||||
|
(args.output / 'carrot-mature-1536.jpg').write_bytes(result.content)
|
||||||
|
clean = replace(data, plots=tuple(
|
||||||
|
replace(plot, pest_count=0) for plot in data.plots
|
||||||
|
))
|
||||||
|
result = renderer.render(who, clean, 200, RenderOptions.high(
|
||||||
|
format='JPEG', theme='autumn',
|
||||||
|
))
|
||||||
|
(args.output / 'carrot-mature-clear-1536.jpg').write_bytes(result.content)
|
||||||
|
candidates = [p for p in catalogue if p.buyable
|
||||||
|
and p.name in renderer.assets.metadata['plants']
|
||||||
|
and f'plant/{p.name}/4.png' in
|
||||||
|
renderer.assets.metadata['images']]
|
||||||
|
chosen = random.Random(20261021).sample(candidates, 5)
|
||||||
|
font = ImageFont.truetype(str(renderer.font_path), 24)
|
||||||
|
sheet = Image.new('RGB', (1536, 6*270), '#f4efdf')
|
||||||
|
draw = ImageDraw.Draw(sheet)
|
||||||
|
for row, plant in enumerate([carrot]+chosen):
|
||||||
|
phases = list(dict.fromkeys(map(int,
|
||||||
|
renderer.assets.metadata['plants'][plant.name]['phase'].split(','),
|
||||||
|
)))
|
||||||
|
draw.text((20, row*270+10), f'{plant.name} #{plant.plant_id}',
|
||||||
|
font=font, fill='#513819')
|
||||||
|
for stage, now in enumerate([0]+phases):
|
||||||
|
snapshot = Snapshot(data.account, (), (
|
||||||
|
Plot(1, plant, plant.growth_seconds),
|
||||||
|
))
|
||||||
|
scene = renderer.scene(who, snapshot, now)
|
||||||
|
canvas = Canvas.new((1024, 624), '#f4efdf')
|
||||||
|
pos = renderer.assets.metadata['positions'][0]
|
||||||
|
soil = renderer.assets.image('soil/普通土地.png', (78, 52))
|
||||||
|
canvas.paste(soil, (round(pos['x']/3), round(pos['y']/3)))
|
||||||
|
renderer._plants(canvas, scene, RenderOptions())
|
||||||
|
tile = canvas.image.crop((390, 100, 600, 310))
|
||||||
|
x, y = stage*250+10, row*270+45
|
||||||
|
sheet.paste(tile.convert('RGB'), (x, y))
|
||||||
|
draw.text((x+10, y+190), f'阶段 {stage}', font=font,
|
||||||
|
fill='#513819')
|
||||||
|
sheet.save(args.output / 'six-crops-stages.jpg', quality=90)
|
||||||
|
print('Crop mapping previews generated')
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
main()
|
||||||
@@ -1,9 +1,166 @@
|
|||||||
{
|
{
|
||||||
"schema": 2,
|
"schema": 4,
|
||||||
"mode": "full_canvas_bottom_center",
|
"mode": "visible_contact_center",
|
||||||
"reference_soil_height": 79.5,
|
"reference_soil_height": 79.5,
|
||||||
"growth_scale": 0.75,
|
"growth_scale": 0.75,
|
||||||
"common_scale": 0.5,
|
"common_scale": 0.5,
|
||||||
"wither_down": 8,
|
"basis": "Local visible ground-contact centroid aligned to actual land center; upright uniform sprite scaling",
|
||||||
"basis": "WebGLMainSceneLandController.SetCropTexture and mainscene RectTransform"
|
"reference_soil_width": 153,
|
||||||
|
"source_land_order": [
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
7,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
16,
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
8,
|
||||||
|
11,
|
||||||
|
14,
|
||||||
|
17,
|
||||||
|
3,
|
||||||
|
6,
|
||||||
|
9,
|
||||||
|
12,
|
||||||
|
15,
|
||||||
|
18,
|
||||||
|
19,
|
||||||
|
20,
|
||||||
|
21,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
24,
|
||||||
|
25,
|
||||||
|
26,
|
||||||
|
27,
|
||||||
|
28,
|
||||||
|
29,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"source_land_centers": [
|
||||||
|
[
|
||||||
|
-55.50004959106445,
|
||||||
|
162.5499725341797
|
||||||
|
],
|
||||||
|
[
|
||||||
|
24.300010681152344,
|
||||||
|
123.5999984741211
|
||||||
|
],
|
||||||
|
[
|
||||||
|
104.4000015258789,
|
||||||
|
84.69998168945312
|
||||||
|
],
|
||||||
|
[
|
||||||
|
180.5999298095703,
|
||||||
|
45.89997100830078
|
||||||
|
],
|
||||||
|
[
|
||||||
|
256.9000244140625,
|
||||||
|
7.49997615814209
|
||||||
|
],
|
||||||
|
[
|
||||||
|
335.9000244140625,
|
||||||
|
-30.500019073486328
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-132.0,
|
||||||
|
122.80000305175781
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-52.199981689453125,
|
||||||
|
83.8499984741211
|
||||||
|
],
|
||||||
|
[
|
||||||
|
27.899959564208984,
|
||||||
|
44.95000076293945
|
||||||
|
],
|
||||||
|
[
|
||||||
|
104.09992218017578,
|
||||||
|
6.14999532699585
|
||||||
|
],
|
||||||
|
[
|
||||||
|
180.40000915527344,
|
||||||
|
-32.25
|
||||||
|
],
|
||||||
|
[
|
||||||
|
259.4000244140625,
|
||||||
|
-70.25
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-208.50010681152344,
|
||||||
|
83.05000305175781
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-128.6999969482422,
|
||||||
|
44.099998474121094
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-48.60007095336914,
|
||||||
|
5.200003623962402
|
||||||
|
],
|
||||||
|
[
|
||||||
|
27.599895477294922,
|
||||||
|
-33.600006103515625
|
||||||
|
],
|
||||||
|
[
|
||||||
|
103.89999389648438,
|
||||||
|
-72.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
182.89999389648438,
|
||||||
|
-110.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-285.0,
|
||||||
|
43.29999923706055
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-205.199951171875,
|
||||||
|
4.350001811981201
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-125.10001373291016,
|
||||||
|
-34.549991607666016
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-48.90004348754883,
|
||||||
|
-73.3499984741211
|
||||||
|
],
|
||||||
|
[
|
||||||
|
27.400035858154297,
|
||||||
|
-111.74999237060547
|
||||||
|
],
|
||||||
|
[
|
||||||
|
106.40003967285156,
|
||||||
|
-149.74998474121094
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-361.5000305175781,
|
||||||
|
3.5499942302703857
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-281.6999206542969,
|
||||||
|
-35.400001525878906
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-201.60000610351562,
|
||||||
|
-74.30000305175781
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-125.40000915527344,
|
||||||
|
-113.0999984741211
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-49.09993362426758,
|
||||||
|
-151.49998474121094
|
||||||
|
],
|
||||||
|
[
|
||||||
|
29.900068283081055,
|
||||||
|
-189.49998474121094
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"contact_alpha_threshold": 32,
|
||||||
|
"contact_band_fraction": 0.2,
|
||||||
|
"sprite_axes": "upright"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6479,13 +6479,185 @@
|
|||||||
"sample_rate": 24
|
"sample_rate": 24
|
||||||
},
|
},
|
||||||
"placement": {
|
"placement": {
|
||||||
"basis": "WebGLMainSceneLandController.SetCropTexture and mainscene RectTransform",
|
"basis": "Local visible ground-contact centroid aligned to actual land center; upright uniform sprite scaling",
|
||||||
"common_scale": 0.5,
|
"common_scale": 0.5,
|
||||||
|
"contact_alpha_threshold": 32,
|
||||||
|
"contact_band_fraction": 0.2,
|
||||||
|
"grid_fit": {
|
||||||
|
"maximum": 9.082568081176591,
|
||||||
|
"points": 30,
|
||||||
|
"rms": 4.178633329197882
|
||||||
|
},
|
||||||
"growth_scale": 0.75,
|
"growth_scale": 0.75,
|
||||||
"mode": "full_canvas_bottom_center",
|
"mode": "visible_contact_center",
|
||||||
"reference_soil_height": 79.5,
|
"reference_soil_height": 79.5,
|
||||||
"schema": 2,
|
"reference_soil_width": 153,
|
||||||
"wither_down": 8
|
"scene_transform": [
|
||||||
|
[
|
||||||
|
1.467423053281315,
|
||||||
|
0.07509807118862355
|
||||||
|
],
|
||||||
|
[
|
||||||
|
0.02736505575131576,
|
||||||
|
-1.4715307877291894
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"schema": 4,
|
||||||
|
"source_land_centers": [
|
||||||
|
[
|
||||||
|
-55.50004959106445,
|
||||||
|
162.5499725341797
|
||||||
|
],
|
||||||
|
[
|
||||||
|
24.300010681152344,
|
||||||
|
123.5999984741211
|
||||||
|
],
|
||||||
|
[
|
||||||
|
104.4000015258789,
|
||||||
|
84.69998168945312
|
||||||
|
],
|
||||||
|
[
|
||||||
|
180.5999298095703,
|
||||||
|
45.89997100830078
|
||||||
|
],
|
||||||
|
[
|
||||||
|
256.9000244140625,
|
||||||
|
7.49997615814209
|
||||||
|
],
|
||||||
|
[
|
||||||
|
335.9000244140625,
|
||||||
|
-30.500019073486328
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-132.0,
|
||||||
|
122.80000305175781
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-52.199981689453125,
|
||||||
|
83.8499984741211
|
||||||
|
],
|
||||||
|
[
|
||||||
|
27.899959564208984,
|
||||||
|
44.95000076293945
|
||||||
|
],
|
||||||
|
[
|
||||||
|
104.09992218017578,
|
||||||
|
6.14999532699585
|
||||||
|
],
|
||||||
|
[
|
||||||
|
180.40000915527344,
|
||||||
|
-32.25
|
||||||
|
],
|
||||||
|
[
|
||||||
|
259.4000244140625,
|
||||||
|
-70.25
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-208.50010681152344,
|
||||||
|
83.05000305175781
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-128.6999969482422,
|
||||||
|
44.099998474121094
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-48.60007095336914,
|
||||||
|
5.200003623962402
|
||||||
|
],
|
||||||
|
[
|
||||||
|
27.599895477294922,
|
||||||
|
-33.600006103515625
|
||||||
|
],
|
||||||
|
[
|
||||||
|
103.89999389648438,
|
||||||
|
-72.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
182.89999389648438,
|
||||||
|
-110.0
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-285.0,
|
||||||
|
43.29999923706055
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-205.199951171875,
|
||||||
|
4.350001811981201
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-125.10001373291016,
|
||||||
|
-34.549991607666016
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-48.90004348754883,
|
||||||
|
-73.3499984741211
|
||||||
|
],
|
||||||
|
[
|
||||||
|
27.400035858154297,
|
||||||
|
-111.74999237060547
|
||||||
|
],
|
||||||
|
[
|
||||||
|
106.40003967285156,
|
||||||
|
-149.74998474121094
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-361.5000305175781,
|
||||||
|
3.5499942302703857
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-281.6999206542969,
|
||||||
|
-35.400001525878906
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-201.60000610351562,
|
||||||
|
-74.30000305175781
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-125.40000915527344,
|
||||||
|
-113.0999984741211
|
||||||
|
],
|
||||||
|
[
|
||||||
|
-49.09993362426758,
|
||||||
|
-151.49998474121094
|
||||||
|
],
|
||||||
|
[
|
||||||
|
29.900068283081055,
|
||||||
|
-189.49998474121094
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"source_land_order": [
|
||||||
|
1,
|
||||||
|
4,
|
||||||
|
7,
|
||||||
|
10,
|
||||||
|
13,
|
||||||
|
16,
|
||||||
|
2,
|
||||||
|
5,
|
||||||
|
8,
|
||||||
|
11,
|
||||||
|
14,
|
||||||
|
17,
|
||||||
|
3,
|
||||||
|
6,
|
||||||
|
9,
|
||||||
|
12,
|
||||||
|
15,
|
||||||
|
18,
|
||||||
|
19,
|
||||||
|
20,
|
||||||
|
21,
|
||||||
|
22,
|
||||||
|
23,
|
||||||
|
24,
|
||||||
|
25,
|
||||||
|
26,
|
||||||
|
27,
|
||||||
|
28,
|
||||||
|
29,
|
||||||
|
30
|
||||||
|
],
|
||||||
|
"sprite_axes": "upright"
|
||||||
},
|
},
|
||||||
"plants": {
|
"plants": {
|
||||||
"ATTomato": {
|
"ATTomato": {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""把 Unity 局部坐标映射到本地地块;保留完整纹理及透明留白。"""
|
||||||
|
|
||||||
|
from math import ceil, floor
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
|
||||||
|
def ground_contact(image, centered=False, threshold=32, band_fraction=.2):
|
||||||
|
alpha = image.convert('RGBA').getchannel('A')
|
||||||
|
box = alpha.point(lambda value: 255 if value >= threshold else 0).getbbox()
|
||||||
|
if box is None:
|
||||||
|
return image.width/2, image.height/2
|
||||||
|
left, top, right, bottom = box
|
||||||
|
if centered:
|
||||||
|
return (left+right)/2, (top+bottom)/2
|
||||||
|
start = max(top, bottom-max(1, round((bottom-top)*band_fraction)))
|
||||||
|
pixels = alpha.load()
|
||||||
|
weight = x_sum = y_sum = 0
|
||||||
|
for y in range(start, bottom):
|
||||||
|
for x in range(left, right):
|
||||||
|
value = pixels[x, y]
|
||||||
|
if value >= threshold:
|
||||||
|
weight += value
|
||||||
|
x_sum += (x+.5)*value
|
||||||
|
y_sum += (y+.5)*value
|
||||||
|
return x_sum/weight, y_sum/weight
|
||||||
|
|
||||||
|
|
||||||
|
def place_sprite(image, center, offset, pivot, factor, transform, scale):
|
||||||
|
(a, b), (c, d) = transform
|
||||||
|
anchor_x = (center[0]+a*offset[0]+b*offset[1])*scale
|
||||||
|
anchor_y = (center[1]+c*offset[0]+d*offset[1])*scale
|
||||||
|
m00, m01 = a*factor*scale, -b*factor*scale
|
||||||
|
m10, m11 = c*factor*scale, -d*factor*scale
|
||||||
|
px, py = image.width*pivot[0], image.height*pivot[1]
|
||||||
|
corners = [(
|
||||||
|
anchor_x+m00*(x-px)+m01*(y-py),
|
||||||
|
anchor_y+m10*(x-px)+m11*(y-py),
|
||||||
|
) for x, y in ((0, 0), (image.width, 0),
|
||||||
|
(0, image.height), image.size)]
|
||||||
|
left = floor(min(x for x, _ in corners))
|
||||||
|
top = floor(min(y for _, y in corners))
|
||||||
|
width = max(1, ceil(max(x for x, _ in corners))-left)
|
||||||
|
height = max(1, ceil(max(y for _, y in corners))-top)
|
||||||
|
det = m00*m11-m01*m10
|
||||||
|
if abs(det) < 1e-12:
|
||||||
|
raise ValueError('Degenerate scene transform')
|
||||||
|
i00, i01, i10, i11 = m11/det, -m01/det, -m10/det, m00/det
|
||||||
|
dx, dy = left-anchor_x, top-anchor_y
|
||||||
|
result = image.transform(
|
||||||
|
(width, height), Image.Transform.AFFINE,
|
||||||
|
(i00, i01, px+i00*dx+i01*dy,
|
||||||
|
i10, i11, py+i10*dx+i11*dy), Image.Resampling.BICUBIC,
|
||||||
|
)
|
||||||
|
return result, (left, top)
|
||||||
+28
-31
@@ -4,6 +4,7 @@ from dataclasses import replace
|
|||||||
import hashlib
|
import hashlib
|
||||||
from io import BytesIO
|
from io import BytesIO
|
||||||
import logging
|
import logging
|
||||||
|
from math import sqrt
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from time import perf_counter
|
from time import perf_counter
|
||||||
|
|
||||||
@@ -13,6 +14,7 @@ from .assets import Assets, ByteLRU
|
|||||||
from .canvas import Canvas, font
|
from .canvas import Canvas, font
|
||||||
from .layout import scene_from_snapshot
|
from .layout import scene_from_snapshot
|
||||||
from .models import RenderOptions, RenderResult
|
from .models import RenderOptions, RenderResult
|
||||||
|
from .placement import ground_contact, place_sprite
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
MAX_AVATAR_BYTES = 2 * 1024 * 1024
|
||||||
@@ -138,7 +140,8 @@ class FarmRenderer:
|
|||||||
s = lambda n: self._scale(n, options)
|
s = lambda n: self._scale(n, options)
|
||||||
soil_w, soil_h = self.assets.metadata["soil_size"]
|
soil_w, soil_h = self.assets.metadata["soil_size"]
|
||||||
rules = self.assets.metadata["placement"]
|
rules = self.assets.metadata["placement"]
|
||||||
unit = soil_h / rules["reference_soil_height"]
|
(a, b), (c, d) = rules['scene_transform']
|
||||||
|
scene_scale = sqrt(abs(a*d-b*c))
|
||||||
first_ripe = True
|
first_ripe = True
|
||||||
for plot in scene.plots:
|
for plot in scene.plots:
|
||||||
if not plot.sprite:
|
if not plot.sprite:
|
||||||
@@ -147,29 +150,28 @@ class FarmRenderer:
|
|||||||
metadata = self.assets.metadata["images"].get(plot.sprite)
|
metadata = self.assets.metadata["images"].get(plot.sprite)
|
||||||
if metadata is None:
|
if metadata is None:
|
||||||
logger.warning("farm render missing packaged crop sprite")
|
logger.warning("farm render missing packaged crop sprite")
|
||||||
plant = Image.new("RGBA", (max(1, s(60)),) * 2, "#597b45")
|
plant = Image.new("RGBA", (60, 60), "#597b45")
|
||||||
ImageDraw.Draw(plant).text(
|
ImageDraw.Draw(plant).text(
|
||||||
(s(10), 0), "?", fill="white",
|
(10, 0), "?", fill="white",
|
||||||
font=font(self.font_path, max(10, s(48))),
|
font=font(self.font_path, 48),
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
factor = rules["common_scale"] if (
|
plant = self.assets.image(plot.sprite)
|
||||||
plot.stage == 0 or plot.state == "withered"
|
factor = rules['growth_scale']
|
||||||
) else rules["growth_scale"]
|
|
||||||
size = [n * factor * unit for n in metadata["size"]]
|
|
||||||
plant = self.assets.image(
|
|
||||||
plot.sprite, tuple(max(1, s(n)) for n in size),
|
|
||||||
)
|
|
||||||
center_x = pos["x"] + soil_w / 2
|
|
||||||
bottom = pos["y"] + soil_h
|
|
||||||
if plot.stage == 0 or plot.state == "withered":
|
if plot.stage == 0 or plot.state == "withered":
|
||||||
bottom = pos["y"] + soil_h / 2
|
factor = rules['common_scale']
|
||||||
if plot.state == "withered":
|
scale = scene_scale*factor*options.width/3072
|
||||||
bottom += rules["wither_down"] * unit
|
plant = plant.resize(
|
||||||
xy = (
|
tuple(max(1, round(n*scale)) for n in plant.size),
|
||||||
round(center_x * options.width / 3072 - plant.width / 2),
|
Image.Resampling.LANCZOS,
|
||||||
s(bottom) - plant.height,
|
|
||||||
)
|
)
|
||||||
|
contact = ground_contact(
|
||||||
|
plant, centered=plot.stage == 0 and plot.state != 'withered',
|
||||||
|
threshold=rules['contact_alpha_threshold'],
|
||||||
|
band_fraction=rules['contact_band_fraction'],
|
||||||
|
)
|
||||||
|
xy = (round((pos['x']+soil_w/2)*options.width/3072-contact[0]),
|
||||||
|
round((pos['y']+soil_h/2)*options.width/3072-contact[1]))
|
||||||
canvas.paste(plant, xy)
|
canvas.paste(plant, xy)
|
||||||
if plot.state == "ripe" and first_ripe:
|
if plot.state == "ripe" and first_ripe:
|
||||||
icon = self.assets.image(
|
icon = self.assets.image(
|
||||||
@@ -179,25 +181,20 @@ class FarmRenderer:
|
|||||||
first_ripe = False
|
first_ripe = False
|
||||||
|
|
||||||
def _pests(self, canvas, scene, options):
|
def _pests(self, canvas, scene, options):
|
||||||
s = lambda n: self._scale(n, options)
|
|
||||||
soil_w, soil_h = self.assets.metadata["soil_size"]
|
soil_w, soil_h = self.assets.metadata["soil_size"]
|
||||||
unit = soil_h / self.assets.metadata["placement"][
|
transform = self.assets.metadata['placement']['scene_transform']
|
||||||
"reference_soil_height"
|
|
||||||
]
|
|
||||||
config = self.assets.metadata["pest"]
|
config = self.assets.metadata["pest"]
|
||||||
size = tuple(max(1, s(n * unit)) for n in config["display_size"])
|
|
||||||
for plot in scene.plots:
|
for plot in scene.plots:
|
||||||
if plot.state not in ("growing", "ripe"):
|
if plot.state not in ("growing", "ripe"):
|
||||||
continue
|
continue
|
||||||
pos = self.assets.metadata["positions"][plot.slot - 1]
|
pos = self.assets.metadata["positions"][plot.slot - 1]
|
||||||
for frame, (x, y) in zip(plot.pest_frames, config["positions"]):
|
for frame, (x, y) in zip(plot.pest_frames, config["positions"]):
|
||||||
image = self.assets.image(frame, size)
|
image = self.assets.image(frame, config['display_size'])
|
||||||
center_x = pos["x"] + soil_w / 2 + x * unit
|
image, xy = place_sprite(
|
||||||
center_y = pos["y"] + soil_h / 2 - y * unit
|
image, (pos['x']+soil_w/2, pos['y']+soil_h/2),
|
||||||
canvas.paste(image, (
|
(x, y), (.5, .5), 1, transform, options.width/3072,
|
||||||
round(center_x * options.width / 3072 - image.width / 2),
|
)
|
||||||
round(center_y * options.width / 3072 - image.height / 2),
|
canvas.paste(image, xy)
|
||||||
))
|
|
||||||
|
|
||||||
def _avatar(self, content, size):
|
def _avatar(self, content, size):
|
||||||
if content and len(content) <= MAX_AVATAR_BYTES:
|
if content and len(content) <= MAX_AVATAR_BYTES:
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from farm_core import Catalogue, Identity
|
|||||||
from farm_core.domain import Account, Plot, Snapshot
|
from farm_core.domain import Account, Plot, Snapshot
|
||||||
from farm_render import FarmRenderer, RenderOptions
|
from farm_render import FarmRenderer, RenderOptions
|
||||||
from farm_render.canvas import Canvas
|
from farm_render.canvas import Canvas
|
||||||
|
from farm_render.placement import ground_contact
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
sys.path.insert(0, str(ROOT / "scripts"))
|
sys.path.insert(0, str(ROOT / "scripts"))
|
||||||
@@ -26,8 +27,8 @@ class CropPositionTests(unittest.TestCase):
|
|||||||
(Plot(1, plant, plant.growth_seconds),),
|
(Plot(1, plant, plant.growth_seconds),),
|
||||||
), now)
|
), now)
|
||||||
|
|
||||||
def test_growth_stages_use_full_canvas_bottom_for_every_crop(self):
|
def test_visible_contacts_use_land_center_for_all_stages_and_sizes(self):
|
||||||
for name in ("草莓", "橙子", "樱桃", "茄子", "黄瓜"):
|
for name in ("胡萝卜", "草莓", "橙子", "樱桃", "茄子", "黄瓜"):
|
||||||
phases = self.renderer.assets.metadata["plants"][name]["phase"]
|
phases = self.renderer.assets.metadata["plants"][name]["phase"]
|
||||||
for now in dict.fromkeys(map(int, phases.split(","))):
|
for now in dict.fromkeys(map(int, phases.split(","))):
|
||||||
for width in (1024, 1536, 3072):
|
for width in (1024, 1536, 3072):
|
||||||
@@ -43,19 +44,15 @@ class CropPositionTests(unittest.TestCase):
|
|||||||
pos = meta["positions"][0]
|
pos = meta["positions"][0]
|
||||||
sw, sh = meta["soil_size"]
|
sw, sh = meta["soil_size"]
|
||||||
scale = width / 3072
|
scale = width / 3072
|
||||||
self.assertLessEqual(abs(
|
|
||||||
xy[0] + image.width / 2
|
|
||||||
- (pos["x"] + sw/2) * scale
|
|
||||||
), 1)
|
|
||||||
self.assertLessEqual(abs(
|
|
||||||
xy[1] + image.height
|
|
||||||
- (pos["y"] + sh) * scale
|
|
||||||
), .5)
|
|
||||||
native = meta["images"][scene.plots[0].sprite]["size"]
|
native = meta["images"][scene.plots[0].sprite]["size"]
|
||||||
self.assertEqual(image.size, tuple(
|
cx, cy = pos['x']+sw/2, pos['y']+sh/2
|
||||||
max(1, round(n * .75 * sh / 79.5 * scale))
|
contact = ground_contact(image)
|
||||||
for n in native
|
for value, expected in zip(
|
||||||
))
|
(xy[0]+contact[0], xy[1]+contact[1]), (cx, cy),
|
||||||
|
):
|
||||||
|
self.assertLessEqual(abs(value-expected*scale), .5)
|
||||||
|
self.assertAlmostEqual(image.width/image.height,
|
||||||
|
native[0]/native[1], delta=.06)
|
||||||
|
|
||||||
def test_seed_and_wither_use_public_sprites_and_distinct_targets(self):
|
def test_seed_and_wither_use_public_sprites_and_distinct_targets(self):
|
||||||
seed = self.scene("草莓", 0)
|
seed = self.scene("草莓", 0)
|
||||||
@@ -65,7 +62,7 @@ class CropPositionTests(unittest.TestCase):
|
|||||||
seed.account, (), (Plot(1, plant, None, plant.crop),),
|
seed.account, (), (Plot(1, plant, None, plant.crop),),
|
||||||
), 999999)
|
), 999999)
|
||||||
self.assertEqual(dead.plots[0].sprite, "webgl/wither.png")
|
self.assertEqual(dead.plots[0].sprite, "webgl/wither.png")
|
||||||
for scene, shift in ((seed, 0), (dead, 8)):
|
for scene in (seed, dead):
|
||||||
canvas = Canvas.new((3072, 1872))
|
canvas = Canvas.new((3072, 1872))
|
||||||
with patch.object(canvas, "paste") as paste:
|
with patch.object(canvas, "paste") as paste:
|
||||||
self.renderer._plants(canvas, scene, RenderOptions(width=3072))
|
self.renderer._plants(canvas, scene, RenderOptions(width=3072))
|
||||||
@@ -73,9 +70,9 @@ class CropPositionTests(unittest.TestCase):
|
|||||||
meta = self.renderer.assets.metadata
|
meta = self.renderer.assets.metadata
|
||||||
pos = meta["positions"][0]
|
pos = meta["positions"][0]
|
||||||
height = meta["soil_size"][1]
|
height = meta["soil_size"][1]
|
||||||
|
contact = ground_contact(image, centered=scene == seed)
|
||||||
self.assertLessEqual(abs(
|
self.assertLessEqual(abs(
|
||||||
xy[1] + image.height - (pos["y"] + height/2
|
xy[1]+contact[1]-(pos['y']+height/2)
|
||||||
+ shift*height/79.5)
|
|
||||||
), .5)
|
), .5)
|
||||||
|
|
||||||
def test_missing_sprite_keeps_safe_placeholder(self):
|
def test_missing_sprite_keeps_safe_placeholder(self):
|
||||||
@@ -90,7 +87,9 @@ class CropPositionTests(unittest.TestCase):
|
|||||||
|
|
||||||
def test_exported_configuration_has_no_manual_stage_anchors(self):
|
def test_exported_configuration_has_no_manual_stage_anchors(self):
|
||||||
meta = self.renderer.assets.metadata
|
meta = self.renderer.assets.metadata
|
||||||
self.assertEqual(meta["placement"]["schema"], 2)
|
self.assertEqual(meta["placement"]["schema"], 4)
|
||||||
|
self.assertEqual(meta['placement']['mode'], 'visible_contact_center')
|
||||||
|
self.assertLess(meta['placement']['grid_fit']['rms'], 5)
|
||||||
self.assertTrue(all("stages" not in p for p in meta["plants"].values()))
|
self.assertTrue(all("stages" not in p for p in meta["plants"].values()))
|
||||||
|
|
||||||
def test_pest_count_changes_cache_and_pest_positions_use_land_center(self):
|
def test_pest_count_changes_cache_and_pest_positions_use_land_center(self):
|
||||||
@@ -113,14 +112,14 @@ class CropPositionTests(unittest.TestCase):
|
|||||||
meta = self.renderer.assets.metadata
|
meta = self.renderer.assets.metadata
|
||||||
pos = meta["positions"][0]
|
pos = meta["positions"][0]
|
||||||
sw, sh = meta["soil_size"]
|
sw, sh = meta["soil_size"]
|
||||||
unit = sh / 79.5
|
(a, b), (c, d) = meta['placement']['scene_transform']
|
||||||
for call, (x, y) in zip(paste.call_args_list,
|
for call, (x, y) in zip(paste.call_args_list,
|
||||||
((-26, 13), (-5, 42), (24, 24))):
|
((-26, 13), (-5, 42), (24, 24))):
|
||||||
image, xy = call.args
|
image, xy = call.args
|
||||||
self.assertLessEqual(abs(xy[0] + image.width/2
|
self.assertLessEqual(abs(xy[0] + image.width/2
|
||||||
- (pos["x"]+sw/2+x*unit)), .5)
|
- (pos["x"]+sw/2+a*x+b*y)), 1)
|
||||||
self.assertLessEqual(abs(xy[1] + image.height/2
|
self.assertLessEqual(abs(xy[1] + image.height/2
|
||||||
- (pos["y"]+sh/2-y*unit)), .5)
|
- (pos["y"]+sh/2+c*x+d*y)), 1)
|
||||||
self.assertEqual(len(meta["pest"]["frames"]), 38)
|
self.assertEqual(len(meta["pest"]["frames"]), 38)
|
||||||
|
|
||||||
def test_pest_frames_are_varied_and_stable_across_renderers_and_sizes(self):
|
def test_pest_frames_are_varied_and_stable_across_renderers_and_sizes(self):
|
||||||
|
|||||||
@@ -0,0 +1,52 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
import sys
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
from farm_render.placement import ground_contact, place_sprite
|
||||||
|
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / 'scripts'))
|
||||||
|
from export_render_assets import fit_grid
|
||||||
|
|
||||||
|
|
||||||
|
class SceneTransformTests(unittest.TestCase):
|
||||||
|
def test_visible_base_ignores_canvas_padding_and_top_heavy_foliage(self):
|
||||||
|
image = Image.new('RGBA', (120, 200))
|
||||||
|
image.paste((0, 255, 0, 255), (5, 5, 115, 45))
|
||||||
|
image.paste((120, 70, 30, 255), (70, 80, 90, 100))
|
||||||
|
x, y = ground_contact(image)
|
||||||
|
self.assertEqual(x, 80)
|
||||||
|
self.assertEqual(y, 90.5)
|
||||||
|
padded = Image.new('RGBA', (160, 300))
|
||||||
|
padded.paste(image, (20, 30))
|
||||||
|
self.assertEqual(ground_contact(padded), (x+20, y+30))
|
||||||
|
self.assertEqual(ground_contact(Image.new('RGBA', (10, 20))), (5, 10))
|
||||||
|
|
||||||
|
def test_affine_fit_recovers_scale_rotation_and_shear(self):
|
||||||
|
source = [(i % 6 * 70, i // 6 * 40) for i in range(30)]
|
||||||
|
target = [(3*x+.2*y+600, .1*x-2*y+400) for x, y in source]
|
||||||
|
transform, errors = fit_grid(source, target)
|
||||||
|
for actual, expected in zip(sum(transform, []), (3, .2, .1, -2)):
|
||||||
|
self.assertAlmostEqual(actual, expected)
|
||||||
|
self.assertLess(errors['maximum'], 1e-8)
|
||||||
|
target[0], target[-1] = target[-1], target[0]
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
fit_grid(source, target)
|
||||||
|
|
||||||
|
def test_transparent_margin_is_preserved_and_pivot_maps_to_parent(self):
|
||||||
|
image = Image.new('RGBA', (20, 40))
|
||||||
|
image.paste((255, 0, 0, 255), (8, 8, 12, 12))
|
||||||
|
result, xy = place_sprite(
|
||||||
|
image, (100, 100), (0, -10), (.5, 1), 1,
|
||||||
|
((2, 0), (0, -2)), 1,
|
||||||
|
)
|
||||||
|
self.assertEqual(result.size, (40, 80))
|
||||||
|
self.assertEqual(xy, (80, 40))
|
||||||
|
self.assertEqual(result.getpixel((20, 20)), (255, 0, 0, 255))
|
||||||
|
self.assertEqual(result.getpixel((20, 70))[3], 0)
|
||||||
|
|
||||||
|
def test_zero_transform_is_rejected(self):
|
||||||
|
with self.assertRaises(ValueError):
|
||||||
|
place_sprite(Image.new('RGBA', (5, 5)), (0, 0), (0, 0),
|
||||||
|
(.5, 1), 1, ((0, 0), (0, 0)), 1)
|
||||||
Reference in New Issue
Block a user