fixed # 修复作物绘制位置异常的BUG

This commit is contained in:
2026-10-21 10:16:58 +08:00
parent 07a2f0f53e
commit e5bec1a2b6
10 changed files with 671 additions and 70 deletions
+10 -8
View File
@@ -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 三种输出档位的作物阶段及虫子预览。
+43
View File
@@ -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 <目录>` 可复现预览,包含带虫和
隐藏虫子便于检查的成熟胡萝卜图。此过程不读玩家数据库、不部署或重启。
+47 -2
View File
@@ -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
+79
View File
@@ -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()
+161 -4
View File
@@ -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"
} }
+176 -4
View File
@@ -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": {
+55
View File
@@ -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
View File
@@ -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:
+20 -21
View File
@@ -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):
+52
View File
@@ -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)