mishig/so101-painting / scripts /paris_design.py
mishig's picture
download
raw
8.28 kB
"""A brush-width-aware Eiffel Tower and Seine stroke plan.
Coordinates are normalized canvas coordinates: u=0 is the left edge and v=0
is the top edge. These are desired strokes, not observations or a claim that
the simulator has painted them. The default plan has 19 strokes.
"""
from __future__ import annotations
import math
from pathlib import Path
COLORS = {
"brown": (112, 73, 47),
"blue": (35, 112, 201),
"green": (39, 148, 93),
"orange": (240, 140, 48),
}
# New versions should be added after inspecting an actual camera result.
# Version 1 is a credible initial design, not an intentionally weak baseline.
VERSIONS = {
1: {"tower_width": .40, "crossbrace": True, "detail": 1, "river_spacing": .044},
}
def _linspace(start, stop, count):
return [start + (stop-start)*i/(count-1) for i in range(count)]
def _quadratic(start, control, end, count=13):
return [[(1-t)**2*start[axis] + 2*(1-t)*t*control[axis] + t*t*end[axis]
for axis in (0, 1)] for t in _linspace(0, 1, count)]
def design(version=1, *, tower_width=None, crossbrace=None, detail=None, river_spacing=None):
"""Return [{color, label, points:[[u,v], ...]}, ...] for physical strokes.
``tower_width`` is the normalized width between the outer tower feet
(default .40). ``crossbrace`` enables the large middle X. ``detail`` is
0 for that X only, or 1 to include a second, smaller upper X. A larger
``river_spacing`` separates the three blue curves. All overrides are
explicit; the function does not infer improvements from unseen results.
Use one continuous contact for each list of points. Lift between strokes.
Reload brown paint as needed during the tower; the planner does not read
paint load or any other simulator state.
"""
if version not in VERSIONS:
raise ValueError(f"Unknown design version {version!r}; available: {tuple(VERSIONS)}")
options = dict(VERSIONS[version])
for name, value in (("tower_width", tower_width), ("crossbrace", crossbrace),
("detail", detail), ("river_spacing", river_spacing)):
if value is not None:
options[name] = value
width = float(options["tower_width"])
spacing = float(options["river_spacing"])
if not .34 <= width <= .50:
raise ValueError("tower_width must be between .34 and .50")
if not .034 <= spacing <= .057:
raise ValueError("river_spacing must be between .034 and .057")
if options["detail"] not in (0, 1):
raise ValueError("detail must be 0 or 1")
strokes = []
def stroke(color, label, points):
points = [[round(float(u), 6), round(float(v), 6)] for u, v in points]
if len(points) < 2 or any(not (0 <= u <= 1 and 0 <= v <= 1) for u, v in points):
raise ValueError(f"Invalid stroke {label}")
strokes.append({"color": color, "label": label, "points": points})
def tower(label, points):
# Construction coordinates use an outer-foot span of .43.
stroke("brown", label, [[.5 + (u-.5)*width/.43, v] for u, v in points])
# Curved, flaring legs and a short antenna are the primary silhouette.
outline = [[.5,.205], [.478,.305], [.450,.404], [.407,.510], [.350,.604], [.285,.690]]
tower("tower-left-outline", list(reversed(outline)))
tower("tower-right-outline", [[1-u,v] for u,v in outline])
tower("tower-antenna", [[.5,.120],[.5,.217]])
# The observation decks extend past the sloping legs, like the real tower.
tower("tower-lower-deck", [[.351,.550],[.649,.550]])
tower("tower-middle-deck", [[.429,.415],[.571,.415]])
tower("tower-upper-deck", [[.462,.285],[.538,.285]])
# Leave a large white opening below the lower deck. The broad arch reads
# clearly with a 3.6-5.5mm brush; small lattice details would merge here.
arch = _quadratic([.365,.690],[.393,.593],[.5,.590])
arch += _quadratic([.5,.590],[.607,.593],[.635,.690])[1:]
tower("tower-base-arch", arch)
tower("tower-left-foot", [[.268,.695],[.394,.695]])
tower("tower-right-foot", [[.606,.695],[.732,.695]])
if options["crossbrace"]:
tower("tower-middle-brace-rising", [[.401,.529],[.557,.434]])
tower("tower-middle-brace-falling", [[.443,.434],[.599,.529]])
if options["detail"]:
tower("tower-upper-brace-rising", [[.451,.394],[.526,.319]])
tower("tower-upper-brace-falling", [[.474,.319],[.549,.394]])
# A single spiral fills the sun by overlapping real brush passes.
sun = []
for t in _linspace(0, 1, 68):
angle = t*math.tau*2.4
radius = .060*t
sun.append([.790 + radius*math.cos(angle), .218 + .9*radius*math.sin(angle)])
stroke("orange", "sun", sun)
# Two disconnected banks keep the tower base open and uncluttered.
stroke("green", "left-river-bank", _quadratic([.100,.754],[.225,.710],[.352,.748]))
stroke("green", "right-river-bank", _quadratic([.654,.748],[.770,.714],[.900,.750]))
# Three separated, gently curving blue strokes suggest a broad river and
# reflected light. The lowest remains safely inside the paper margin.
for index in range(3):
river = [[u, .795 + index*spacing + .020*math.sin((u-.10)/.80*math.tau-.5)]
for u in _linspace(.105,.895,31)]
stroke("blue", f"seine-curve-{index+1}", river)
return strokes
def estimated_seconds(strokes=None, *, speed=.035, reloads=6):
"""Planning estimate for the current controller; not measured runtime.
Uses a 135x150mm canvas, 2.45s approach/touch/lift per stroke, a minimum
.8s contact, and 3.8s per palette visit. It excludes calibration and video
holds. Six visits allow three brown reloads plus orange, green, and blue.
"""
if strokes is None:
strokes = design()
seconds = 3.8*reloads
for item in strokes:
length = sum(math.hypot((b[0]-a[0])*.135, (b[1]-a[1])*.150)
for a,b in zip(item["points"],item["points"][1:]))
seconds += 2.45 + max(.8,length/speed)
return seconds
def render_target(path, version=1, *, size=800, brush_width_mm=4.5, **overrides):
"""Save an explicitly labeled reference PNG, not simulated brush output.
The drawing panel preserves the real canvas's 135:150 aspect ratio.
The solid lines indicate intended paths and nominal width; actual paint
appearance depends on physical contact, pigment load, and camera lighting.
"""
from PIL import Image, ImageDraw, ImageFont
strokes = design(version, **overrides)
height, width = int(size), round(size*.135/.150)
header, footer = 68, 42
image = Image.new("RGB", (width,height+header+footer), (241,239,233))
draw = ImageDraw.Draw(image)
title_font = ImageFont.load_default(size=max(14,round(size*.024)))
note_font = ImageFont.load_default(size=max(11,round(size*.016)))
draw.text((18,12), "EIFFEL TOWER + SEINE", fill=(55,51,44), font=title_font)
draw.text((18,40), f"TARGET PLAN v{version} / REFERENCE ONLY", fill=(97,90,77), font=note_font)
draw.rectangle((0,header,width-1,header+height-1), fill=(255,253,247))
line_width = max(1,round(brush_width_mm/150*height))
for item in strokes:
points = [(round(u*(width-1)), header+round(v*(height-1))) for u,v in item["points"]]
color = COLORS[item["color"]]
draw.line(points, fill=color, width=line_width, joint="curve")
radius = line_width/2
for x,y in (points[0],points[-1]):
draw.ellipse((x-radius,y-radius,x+radius,y+radius), fill=color)
draw.text((18,header+height+12), "Desired strokes - not a simulator result or camera image.",
fill=(97,90,77), font=note_font)
destination = Path(path)
destination.parent.mkdir(parents=True, exist_ok=True)
image.save(destination)
return destination
if __name__ == "__main__":
import argparse
import json
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--render", type=Path, help="Write a labeled target-plan PNG.")
args = parser.parse_args()
if args.render:
print(render_target(args.render))
print(json.dumps({"strokes":len(design()), "estimated_sim_seconds":round(estimated_seconds(),1)}))

Xet Storage Details

Size:
8.28 kB
·
Xet hash:
693ef746d8d06e3a5d2dbc87afca2c551df43df1b89a0e5b0e387e7010d21d8e

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.