Skip to content

Add image-to-stamp: convert images to 3D-printable STL stamps - #9

Open
blooop wants to merge 2 commits into
mainfrom
claude/stamp-3d-model-stl-3uanqb
Open

Add image-to-stamp: convert images to 3D-printable STL stamps#9
blooop wants to merge 2 commits into
mainfrom
claude/stamp-3d-model-stl-3uanqb

Conversation

@blooop

@blooop blooop commented Jul 10, 2026

Copy link
Copy Markdown
Owner

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 pipeline

    • StampConfig dataclass 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 triangles
    • write_binary_stl(): Serialize mesh to binary STL format with proper headers and normals
    • image_to_stamp_stl(): High-level API combining all steps
    • CLI interface with build_parser() and main() for command-line usage
  • Comprehensive test suite test/test_stamp.py:

    • Tests for mask generation (dark/light pixels, inversion, mirroring)
    • Heightmap level validation
    • Mesh topology (single pixel box has correct triangle count and height range)
    • Normal vector unit-length verification
    • End-to-end STL generation and validation
    • Binary STL roundtrip testing
    • Error handling for blank images
  • Updated pyproject.toml:

    • Added image-to-stamp console script entry point
    • Added pillow dependency for image loading
  • Updated README.md:

    • Added usage documentation with CLI examples and Python API examples
    • Explained the stamp concept and key options

Notable Implementation Details

  • The mesh is watertight with no T-junctions: per-pixel bottoms align with per-pixel walls
  • Vertical-walled relief (stepped heightmap) prints cleanly without supports
  • Design is automatically mirrored left-to-right so the stamped impression matches the source image
  • Dark pixels (below threshold) become the raised relief that picks up paint
  • Configurable downsampling prevents excessive triangle counts for large images

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:

  • Introduce a StampConfig-driven pipeline to convert images into stepped-heightmap STL stamp meshes.
  • Expose a command-line interface and Python API for generating physical stamp STL files from images.

Enhancements:

  • Implement binary STL serialization utilities with computed triangle normals for arbitrary meshes.

Build:

  • Register an image-to-stamp console_script entry point and add Pillow as a dependency for image loading.

Documentation:

  • Document the image-to-stamp tool with CLI usage, configuration options, and Python API examples in the README.

Tests:

  • Add tests covering mask generation, heightmap construction, mesh topology and normals, STL roundtrip integrity, and error handling for blank images.

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
@sourcery-ai

sourcery-ai Bot commented Jul 10, 2026

Copy link
Copy Markdown

Reviewer's Guide

Implements 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 pipeline

sequenceDiagram
    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
Loading

Flow diagram for the image-to-STL stamp generation pipeline

flowchart 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"]
Loading

File-Level Changes

Change Details Files
Implemented configurable image-to-stamp STL generation pipeline with heightmap-based, watertight mesh construction and binary STL output.
  • Added StampConfig dataclass encapsulating physical and image-processing parameters for stamp generation.
  • Implemented load_mask with grayscale conversion, optional mirroring, thresholding, inversion, and downsampling via a helper _downsample.
  • Implemented heightmap_from_mask to convert the boolean design mask into per-pixel top-surface heights combining base thickness and relief height.
  • Implemented _stamp_triangles to generate a per-pixel, watertight triangle mesh including tops, per-pixel bottoms, and vertical walls/steps with consistent winding and coordinate mapping.
  • Implemented _triangle_normals to compute unit normals for all triangles and write_binary_stl to serialize triangles and normals to binary STL with a standard header.
  • Implemented image_to_stamp_stl as the high-level API orchestrating mask generation, heightmap creation, mesh construction, STL writing, and erroring on blank designs.
  • Implemented build_parser and main to expose a CLI that maps arguments into StampConfig and writes an STL while reporting triangle count.
python_template/stamp.py
Added tests to validate mask generation, heightmap levels, mesh topology, normal correctness, STL I/O, and error handling for blank designs.
  • Created helper _read_binary_stl to parse binary STL files back into triangle arrays for validation.
  • Added tests for heightmap_from_mask to ensure correct base and relief height combinations in mixed design/non-design masks.
  • Added tests verifying load_mask behavior for dark-as-design semantics, mirroring, and invert flag.
  • Added tests ensuring a single raised pixel produces a box-shaped mesh with the expected triangle count and z-range.
  • Added tests validating _triangle_normals produce unit-length normals and that write_binary_stl round-trips a simple triangle.
  • Added end-to-end test using image_to_stamp_stl to check footprint width, maximum height, and non-empty triangle output, as well as raising ValueError for blank images.
test/test_stamp.py
Documented and wired the image-to-stamp tool into the project as a console script with new dependency.
  • Updated README.md with a new section describing the image-to-stamp feature, CLI invocation examples, key options, and Python API usage.
  • Registered image-to-stamp as a console script entry point pointing to python_template.stamp:main.
  • Added pillow as a project dependency to support image loading and processing.
README.md
pyproject.toml

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

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

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 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.
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>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread python_template/stamp.py
Comment on lines +54 to +62
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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

Comment thread python_template/stamp.py
Comment on lines +67 to +71
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)))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
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)

Comment thread python_template/stamp.py
Comment on lines +258 to +265
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,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants