diff --git a/docs/15-crop-position-formula.md b/docs/15-crop-position-formula.md index 0205018..0fbe708 100644 --- a/docs/15-crop-position-formula.md +++ b/docs/15-crop-position-formula.md @@ -56,15 +56,17 @@ top = Cy + 39.75 × s - 0.75 × H × s 场景倍率为 2,底边对齐的计算误差不超过 0.5 输出像素。 这验证了预览公式的执行与图片效果,不代表所有素材已逐一核对原图。 -正式渲染器已切换到本文公式。`crop_positions.json` schema=2 保存统一规则, -不再包含人工逐阶段锚点;导出 metadata 也不再写入人工标注。 -种子和枯萎使用场景包原始公共精灵,分别为 69×30 和 209×92。 +正式渲染器现使用 `crop_positions.json` schema=4 的本地可见落地机制, +不包含人工逐阶段锚点。种子和枯萎仍使用场景包原始公共精灵, +分别为 69×30 和 209×92。 -现有 3072 基准场景沿用旧土地布局;以其土地显示高度 `D` 除以 79.5 -得到原版单位到当前布局的统一倍率,再换算作物与虫子尺寸和坐标。 -跨输出分辨率只追加 `width/3072`,不重复应用 0.75。 -旧土地的宽高比例仍沿用本地布局,因此本次是定位规则移植, -不是对整个网页场景像素级复刻。 +此前将本地土地完整 PNG 高度除以 79.5,混用了透明画布与父矩形语义。 +当前通过全部 30 块地中心计算整体尺度;作物保持直立等比缩放,以可见 +底部区域的加权中心对齐本地土地中心,不再使用网页底边锚点。 +虫子保留局部坐标映射;输出分辨率再乘 `width/3072`。 +详见 [作物场景映射修正](22-crop-scene-mapping.md)。 +原网页规则仍成立,但不能把本地 PNG 高度直接视为上文的 D。 +沿用的旧土地网格存在拟合残差,不是网页场景的像素级复刻。 `scripts/preview_crop_positions.py` 已改为调用正式渲染器,生成 1024、1536、3072 三种输出档位的作物阶段及虫子预览。 diff --git a/docs/22-crop-scene-mapping.md b/docs/22-crop-scene-mapping.md new file mode 100644 index 0000000..4d845dc --- /dev/null +++ b/docs/22-crop-scene-mapping.md @@ -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 <目录>` 可复现预览,包含带虫和 +隐藏虫子便于检查的成熟胡萝卜图。此过程不读玩家数据库、不部署或重启。 diff --git a/scripts/export_render_assets.py b/scripts/export_render_assets.py index d5f4754..23f550d 100644 --- a/scripts/export_render_assets.py +++ b/scripts/export_render_assets.py @@ -4,20 +4,65 @@ import argparse import hashlib from io import BytesIO import json +import math from pathlib import Path from zipfile import ZIP_STORED, ZipFile, ZipInfo 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): document = json.loads(( root / "src/farm_render/data/crop_positions.json" ).read_bytes()) - if document.get("schema") != 2: + if document.get("schema") != 4: 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") + 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 diff --git a/scripts/preview_crop_mapping.py b/scripts/preview_crop_mapping.py new file mode 100644 index 0000000..3e68da8 --- /dev/null +++ b/scripts/preview_crop_mapping.py @@ -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() diff --git a/src/farm_render/data/crop_positions.json b/src/farm_render/data/crop_positions.json index 132a046..11d3da9 100644 --- a/src/farm_render/data/crop_positions.json +++ b/src/farm_render/data/crop_positions.json @@ -1,9 +1,166 @@ { - "schema": 2, - "mode": "full_canvas_bottom_center", + "schema": 4, + "mode": "visible_contact_center", "reference_soil_height": 79.5, "growth_scale": 0.75, "common_scale": 0.5, - "wither_down": 8, - "basis": "WebGLMainSceneLandController.SetCropTexture and mainscene RectTransform" + "basis": "Local visible ground-contact centroid aligned to actual land center; upright uniform sprite scaling", + "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" } diff --git a/src/farm_render/data/metadata.json b/src/farm_render/data/metadata.json index 3bc7883..e6e6a4c 100644 --- a/src/farm_render/data/metadata.json +++ b/src/farm_render/data/metadata.json @@ -6479,13 +6479,185 @@ "sample_rate": 24 }, "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, + "contact_alpha_threshold": 32, + "contact_band_fraction": 0.2, + "grid_fit": { + "maximum": 9.082568081176591, + "points": 30, + "rms": 4.178633329197882 + }, "growth_scale": 0.75, - "mode": "full_canvas_bottom_center", + "mode": "visible_contact_center", "reference_soil_height": 79.5, - "schema": 2, - "wither_down": 8 + "reference_soil_width": 153, + "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": { "ATTomato": { diff --git a/src/farm_render/placement.py b/src/farm_render/placement.py new file mode 100644 index 0000000..e2a0e65 --- /dev/null +++ b/src/farm_render/placement.py @@ -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) diff --git a/src/farm_render/renderer.py b/src/farm_render/renderer.py index 7b4211d..966628e 100644 --- a/src/farm_render/renderer.py +++ b/src/farm_render/renderer.py @@ -4,6 +4,7 @@ from dataclasses import replace import hashlib from io import BytesIO import logging +from math import sqrt from pathlib import Path from time import perf_counter @@ -13,6 +14,7 @@ from .assets import Assets, ByteLRU from .canvas import Canvas, font from .layout import scene_from_snapshot from .models import RenderOptions, RenderResult +from .placement import ground_contact, place_sprite logger = logging.getLogger(__name__) MAX_AVATAR_BYTES = 2 * 1024 * 1024 @@ -138,7 +140,8 @@ class FarmRenderer: s = lambda n: self._scale(n, options) soil_w, soil_h = self.assets.metadata["soil_size"] 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 for plot in scene.plots: if not plot.sprite: @@ -147,29 +150,28 @@ class FarmRenderer: metadata = self.assets.metadata["images"].get(plot.sprite) if metadata is None: 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( - (s(10), 0), "?", fill="white", - font=font(self.font_path, max(10, s(48))), + (10, 0), "?", fill="white", + font=font(self.font_path, 48), ) else: - factor = rules["common_scale"] if ( - plot.stage == 0 or plot.state == "withered" - ) 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 + plant = self.assets.image(plot.sprite) + factor = rules['growth_scale'] if plot.stage == 0 or plot.state == "withered": - bottom = pos["y"] + soil_h / 2 - if plot.state == "withered": - bottom += rules["wither_down"] * unit - xy = ( - round(center_x * options.width / 3072 - plant.width / 2), - s(bottom) - plant.height, + factor = rules['common_scale'] + scale = scene_scale*factor*options.width/3072 + plant = plant.resize( + tuple(max(1, round(n*scale)) for n in plant.size), + Image.Resampling.LANCZOS, ) + 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) if plot.state == "ripe" and first_ripe: icon = self.assets.image( @@ -179,25 +181,20 @@ class FarmRenderer: first_ripe = False def _pests(self, canvas, scene, options): - s = lambda n: self._scale(n, options) soil_w, soil_h = self.assets.metadata["soil_size"] - unit = soil_h / self.assets.metadata["placement"][ - "reference_soil_height" - ] + transform = self.assets.metadata['placement']['scene_transform'] config = self.assets.metadata["pest"] - size = tuple(max(1, s(n * unit)) for n in config["display_size"]) for plot in scene.plots: if plot.state not in ("growing", "ripe"): continue pos = self.assets.metadata["positions"][plot.slot - 1] for frame, (x, y) in zip(plot.pest_frames, config["positions"]): - image = self.assets.image(frame, size) - center_x = pos["x"] + soil_w / 2 + x * unit - center_y = pos["y"] + soil_h / 2 - y * unit - canvas.paste(image, ( - round(center_x * options.width / 3072 - image.width / 2), - round(center_y * options.width / 3072 - image.height / 2), - )) + image = self.assets.image(frame, config['display_size']) + image, xy = place_sprite( + image, (pos['x']+soil_w/2, pos['y']+soil_h/2), + (x, y), (.5, .5), 1, transform, options.width/3072, + ) + canvas.paste(image, xy) def _avatar(self, content, size): if content and len(content) <= MAX_AVATAR_BYTES: diff --git a/tests/test_crop_positions.py b/tests/test_crop_positions.py index 48dff5d..840e98b 100644 --- a/tests/test_crop_positions.py +++ b/tests/test_crop_positions.py @@ -8,6 +8,7 @@ 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 +from farm_render.placement import ground_contact ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(ROOT / "scripts")) @@ -26,8 +27,8 @@ class CropPositionTests(unittest.TestCase): (Plot(1, plant, plant.growth_seconds),), ), now) - def test_growth_stages_use_full_canvas_bottom_for_every_crop(self): - for name in ("草莓", "橙子", "樱桃", "茄子", "黄瓜"): + def test_visible_contacts_use_land_center_for_all_stages_and_sizes(self): + for name in ("胡萝卜", "草莓", "橙子", "樱桃", "茄子", "黄瓜"): phases = self.renderer.assets.metadata["plants"][name]["phase"] for now in dict.fromkeys(map(int, phases.split(","))): for width in (1024, 1536, 3072): @@ -43,19 +44,15 @@ class CropPositionTests(unittest.TestCase): pos = meta["positions"][0] sw, sh = meta["soil_size"] 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"] - self.assertEqual(image.size, tuple( - max(1, round(n * .75 * sh / 79.5 * scale)) - for n in native - )) + cx, cy = pos['x']+sw/2, pos['y']+sh/2 + contact = ground_contact(image) + 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): seed = self.scene("草莓", 0) @@ -65,7 +62,7 @@ class CropPositionTests(unittest.TestCase): seed.account, (), (Plot(1, plant, None, plant.crop),), ), 999999) 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)) with patch.object(canvas, "paste") as paste: self.renderer._plants(canvas, scene, RenderOptions(width=3072)) @@ -73,9 +70,9 @@ class CropPositionTests(unittest.TestCase): meta = self.renderer.assets.metadata pos = meta["positions"][0] height = meta["soil_size"][1] + contact = ground_contact(image, centered=scene == seed) self.assertLessEqual(abs( - xy[1] + image.height - (pos["y"] + height/2 - + shift*height/79.5) + xy[1]+contact[1]-(pos['y']+height/2) ), .5) 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): 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())) 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 pos = meta["positions"][0] 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, ((-26, 13), (-5, 42), (24, 24))): image, xy = call.args 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 - - (pos["y"]+sh/2-y*unit)), .5) + - (pos["y"]+sh/2+c*x+d*y)), 1) self.assertEqual(len(meta["pest"]["frames"]), 38) def test_pest_frames_are_varied_and_stable_across_renderers_and_sizes(self): diff --git a/tests/test_scene_transform.py b/tests/test_scene_transform.py new file mode 100644 index 0000000..95cb8b5 --- /dev/null +++ b/tests/test_scene_transform.py @@ -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)