Add image-to-stamp: convert images to 3D-printable STL stamps - #9
Conversation
Convert a black-and-white image into a paint stamp: dark regions become raised relief, the design is mirrored so the impression reads correctly, and the output is a watertight, support-free STL (flat base plate with vertical-walled relief). - python_template/stamp.py: mesh generation, binary STL writer, CLI - image-to-stamp console entry point with width/base/relief/threshold opts - add pillow dependency - tests covering masking, heightmap, mesh, STL round-trip, watertightness Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5v52VYvCVcGd4xwzqFygG
Reviewer's GuideImplements a complete image-to-STL stamp pipeline, including image preprocessing, heightmap generation, watertight mesh construction, STL serialization, CLI wiring, and comprehensive tests, plus wiring in a console script and docs. Sequence diagram for the image-to-stamp CLI pipelinesequenceDiagram
actor User
participant image_to_stamp_cli as image_to_stamp
participant main
participant image_to_stamp_stl
participant load_mask
participant heightmap_from_mask
participant _stamp_triangles
participant write_binary_stl
User ->> image_to_stamp_cli: run
image_to_stamp_cli ->> main: main(argv)
main ->> main: build_parser()
main ->> main: parse_args()
main ->> main: StampConfig(...)
main ->> image_to_stamp_stl: image_to_stamp_stl(image_path, stl_path, config)
image_to_stamp_stl ->> load_mask: load_mask(image_path, config)
load_mask -->> image_to_stamp_stl: mask
image_to_stamp_stl ->> heightmap_from_mask: heightmap_from_mask(mask, config)
heightmap_from_mask -->> image_to_stamp_stl: heights
image_to_stamp_stl ->> _stamp_triangles: _stamp_triangles(heights, pixel_size)
_stamp_triangles -->> image_to_stamp_stl: triangles
image_to_stamp_stl ->> write_binary_stl: write_binary_stl(stl_path, triangles)
write_binary_stl -->> image_to_stamp_stl: stl_written
image_to_stamp_stl -->> main: triangles
main -->> User: print summary and exit
Flow diagram for the image-to-STL stamp generation pipelineflowchart TD
start["Input image path"] --> load_mask_step["load_mask(image_path, config)"]
load_mask_step --> heightmap_step["heightmap_from_mask(mask, config)"]
heightmap_step --> triangles_step["_stamp_triangles(heights, pixel_size)"]
triangles_step --> write_step["write_binary_stl(stl_path, triangles)"]
write_step --> end_node["STL file on disk"]
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
- Use PIL enum members (Image.Transpose.FLIP_LEFT_RIGHT, Image.Resampling.LANCZOS) to satisfy pylint no-member checks - Drop unused 'rows' unpacking in image_to_stamp_stl - Use tempfile.mkdtemp/shutil.rmtree in tests to avoid pylint consider-using-with - Add trailing newlines to rockerc.yaml and scripts/install_pixi.sh flagged by the end-of-file-fixer pre-commit hook Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A5v52VYvCVcGd4xwzqFygG
There was a problem hiding this comment.
Hey - I've found 3 issues, and left some high level feedback:
- In
load_mask, consider opening the image with a context manager (e.g.with Image.open(...) as img) or explicitly closing it to avoid leaking file handles in long-running or batch use cases. - It would be helpful to validate CLI/config parameters (e.g. enforcing
thresholdwithin 0–255 and positive values forwidth,base,relief, andmax_pixels) so misconfiguration fails fast with a clear error rather than producing odd geometry.
Prompt for AI Agents
Please address the comments from this code review:
## Overall Comments
- In `load_mask`, consider opening the image with a context manager (e.g. `with Image.open(...) as img`) or explicitly closing it to avoid leaking file handles in long-running or batch use cases.
- It would be helpful to validate CLI/config parameters (e.g. enforcing `threshold` within 0–255 and positive values for `width`, `base`, `relief`, and `max_pixels`) so misconfiguration fails fast with a clear error rather than producing odd geometry.
## Individual Comments
### Comment 1
<location path="python_template/stamp.py" line_range="54-62" />
<code_context>
+ img = img.transpose(Image.FLIP_LEFT_RIGHT)
+ pixels = np.asarray(img, dtype=np.uint8)
+ # Dark pixels (below threshold) are the design that carries paint.
+ mask = pixels < config.threshold
+ if config.invert:
+ mask = ~mask
</code_context>
<issue_to_address>
**suggestion:** Consider validating or clamping the grayscale threshold to the 0–255 range.
Values outside 0–255 will still pass the `<` check but can yield confusing masks (e.g., threshold <= 0 or very large values effectively turning the whole image into design/background). Centralizing a check in `load_mask` (or when building the config) to either clamp into [0, 255] or raise a `ValueError` would prevent unexpected geometry from being generated.
```suggestion
img = _downsample(img, config.max_pixels)
if config.mirror:
img = img.transpose(Image.FLIP_LEFT_RIGHT)
pixels = np.asarray(img, dtype=np.uint8)
# Clamp grayscale threshold into the valid 0–255 range.
threshold = int(config.threshold)
if threshold < 0:
threshold = 0
elif threshold > 255:
threshold = 255
# Dark pixels (below threshold) are the design that carries paint.
mask = pixels < threshold
if config.invert:
mask = ~mask
return mask
```
</issue_to_address>
### Comment 2
<location path="python_template/stamp.py" line_range="67-71" />
<code_context>
+ return img
+ scale = max_pixels / longest
+ new_size = (max(1, round(img.width * scale)), max(1, round(img.height * scale)))
+ return img.resize(new_size, Image.LANCZOS)
+
+
</code_context>
<issue_to_address>
**suggestion:** Use the modern Pillow resampling enum to avoid deprecation issues.
Newer Pillow versions expose these filters via `Image.Resampling` and deprecate `Image.LANCZOS`. Consider switching to `Image.Resampling.LANCZOS`, optionally with a small compatibility shim for older versions, to avoid deprecation warnings and keep this future-proof.
```suggestion
longest = max(img.size)
if longest <= max_pixels:
return img
scale = max_pixels / longest
new_size = (max(1, round(img.width * scale)), max(1, round(img.height * scale)))
# Prefer modern Pillow Resampling enum when available, with a fallback for older versions.
resample_filter = getattr(getattr(Image, "Resampling", Image), "LANCZOS")
return img.resize(new_size, resample=resample_filter)
```
</issue_to_address>
### Comment 3
<location path="python_template/stamp.py" line_range="258-265" />
<code_context>
+def main(argv: list[str] | None = None) -> int:
+ args = build_parser().parse_args(argv)
+ output = args.output or str(Path(args.image).with_suffix(".stl"))
+ config = StampConfig(
+ width_mm=args.width,
+ base_thickness=args.base,
+ relief_height=args.relief,
+ threshold=args.threshold,
+ invert=args.invert,
+ mirror=not args.no_mirror,
+ max_pixels=args.max_pixels,
+ )
+ triangles = image_to_stamp_stl(args.image, output, config)
</code_context>
<issue_to_address>
**issue (bug_risk):** Validate physical parameters (width, base, relief, max_pixels) to guard against invalid CLI input.
Non-positive values for these parameters can produce degenerate geometry (e.g., zero-width plates, inverted heights, or invalid downsampling). Please add argument-level validation with clear error messages so invalid inputs fail fast instead of generating confusing STL output.
</issue_to_address>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| img = _downsample(img, config.max_pixels) | ||
| if config.mirror: | ||
| img = img.transpose(Image.FLIP_LEFT_RIGHT) | ||
| pixels = np.asarray(img, dtype=np.uint8) | ||
| # Dark pixels (below threshold) are the design that carries paint. | ||
| mask = pixels < config.threshold | ||
| if config.invert: | ||
| mask = ~mask | ||
| return mask |
There was a problem hiding this comment.
suggestion: Consider validating or clamping the grayscale threshold to the 0–255 range.
Values outside 0–255 will still pass the < check but can yield confusing masks (e.g., threshold <= 0 or very large values effectively turning the whole image into design/background). Centralizing a check in load_mask (or when building the config) to either clamp into [0, 255] or raise a ValueError would prevent unexpected geometry from being generated.
| img = _downsample(img, config.max_pixels) | |
| if config.mirror: | |
| img = img.transpose(Image.FLIP_LEFT_RIGHT) | |
| pixels = np.asarray(img, dtype=np.uint8) | |
| # Dark pixels (below threshold) are the design that carries paint. | |
| mask = pixels < config.threshold | |
| if config.invert: | |
| mask = ~mask | |
| return mask | |
| img = _downsample(img, config.max_pixels) | |
| if config.mirror: | |
| img = img.transpose(Image.FLIP_LEFT_RIGHT) | |
| pixels = np.asarray(img, dtype=np.uint8) | |
| # Clamp grayscale threshold into the valid 0–255 range. | |
| threshold = int(config.threshold) | |
| if threshold < 0: | |
| threshold = 0 | |
| elif threshold > 255: | |
| threshold = 255 | |
| # Dark pixels (below threshold) are the design that carries paint. | |
| mask = pixels < threshold | |
| if config.invert: | |
| mask = ~mask | |
| return mask |
| longest = max(img.size) | ||
| if longest <= max_pixels: | ||
| return img | ||
| scale = max_pixels / longest | ||
| new_size = (max(1, round(img.width * scale)), max(1, round(img.height * scale))) |
There was a problem hiding this comment.
suggestion: Use the modern Pillow resampling enum to avoid deprecation issues.
Newer Pillow versions expose these filters via Image.Resampling and deprecate Image.LANCZOS. Consider switching to Image.Resampling.LANCZOS, optionally with a small compatibility shim for older versions, to avoid deprecation warnings and keep this future-proof.
| longest = max(img.size) | |
| if longest <= max_pixels: | |
| return img | |
| scale = max_pixels / longest | |
| new_size = (max(1, round(img.width * scale)), max(1, round(img.height * scale))) | |
| longest = max(img.size) | |
| if longest <= max_pixels: | |
| return img | |
| scale = max_pixels / longest | |
| new_size = (max(1, round(img.width * scale)), max(1, round(img.height * scale))) | |
| # Prefer modern Pillow Resampling enum when available, with a fallback for older versions. | |
| resample_filter = getattr(getattr(Image, "Resampling", Image), "LANCZOS") | |
| return img.resize(new_size, resample=resample_filter) |
| config = StampConfig( | ||
| width_mm=args.width, | ||
| base_thickness=args.base, | ||
| relief_height=args.relief, | ||
| threshold=args.threshold, | ||
| invert=args.invert, | ||
| mirror=not args.no_mirror, | ||
| max_pixels=args.max_pixels, |
There was a problem hiding this comment.
issue (bug_risk): Validate physical parameters (width, base, relief, max_pixels) to guard against invalid CLI input.
Non-positive values for these parameters can produce degenerate geometry (e.g., zero-width plates, inverted heights, or invalid downsampling). Please add argument-level validation with clear error messages so invalid inputs fail fast instead of generating confusing STL output.
Summary
Add a complete image-to-stamp conversion tool that transforms black-and-white images into watertight, 3D-printable STL files suitable for physical stamps. The implementation includes image processing, heightmap generation, mesh construction, and binary STL serialization.
Key Changes
New module
python_template/stamp.py: Complete stamp generation pipelineStampConfigdataclass for configurable physical parameters (width, base thickness, relief height, threshold, mirroring, etc.)load_mask(): Load and preprocess images (downsampling, mirroring, thresholding)heightmap_from_mask(): Convert binary mask to per-pixel height values_stamp_triangles(): Generate watertight triangle mesh from heightmap with per-pixel columns, flat tops/bottoms, and vertical walls_triangle_normals(): Compute unit normals for all triangleswrite_binary_stl(): Serialize mesh to binary STL format with proper headers and normalsimage_to_stamp_stl(): High-level API combining all stepsbuild_parser()andmain()for command-line usageComprehensive test suite
test/test_stamp.py:Updated
pyproject.toml:image-to-stampconsole script entry pointpillowdependency for image loadingUpdated
README.md:Notable Implementation Details
https://claude.ai/code/session_01A5v52VYvCVcGd4xwzqFygG
Summary by Sourcery
Add an image-to-stamp pipeline that converts black-and-white images into watertight, 3D-printable STL stamp meshes with a CLI and Python API.
New Features:
Enhancements:
Build:
Documentation:
Tests: