mirror of
https://github.com/harry7557558/spirula-studio.git
synced 2026-10-02 02:44:54 +08:00
masking: pen tool, separate areas for each camera (#100), drop masking with separate script
This commit is contained in:
@@ -51,8 +51,8 @@ tools/guictl.py drive the GUI from a script -- list the widgets on
|
||||
screen, click them, read the framebuffer back.
|
||||
tools/gui_mcp.py is the same surface as an MCP
|
||||
server; docs/notes/gui-automation.md
|
||||
reference/scripts/ dataset preprocessing CLI tools (Python, standalone;
|
||||
mask.py is embedded into the GUI binary)
|
||||
reference/scripts/ dataset preprocessing CLI tools (Python, standalone,
|
||||
run by hand; nothing in the build uses them)
|
||||
reference/python/ hand-run tools on NO code path: eval_lpips.py,
|
||||
benchmark.py, camera_utils.py (the unported
|
||||
orientation_method / center_method reference,
|
||||
|
||||
+5
-14
@@ -29,7 +29,7 @@
|
||||
function(ss_configure_app target)
|
||||
target_include_directories(${target} PRIVATE
|
||||
${SS_SRC}
|
||||
${CMAKE_BINARY_DIR} # app_generated/{viewer_html,mask_py}.h
|
||||
${CMAKE_BINARY_DIR} # app_generated/*.h
|
||||
${CUDAToolkit_INCLUDE_DIRS}
|
||||
)
|
||||
target_link_libraries(${target} PRIVATE ${SS_APP_LIBS})
|
||||
@@ -198,14 +198,6 @@ if(SS_BUILD_GUI)
|
||||
target_link_libraries(imgui_glfw PUBLIC glfw)
|
||||
set_property(TARGET imgui_glfw PROPERTY CXX_STANDARD 17)
|
||||
|
||||
# Embed reference/scripts/mask.py (AI masking helper, run via external
|
||||
# Python) so the exe is self-contained. Same mechanism as the
|
||||
# viewer.html embed.
|
||||
ss_embed_file(
|
||||
${SS_ROOT}/reference/scripts/mask.py
|
||||
${CMAKE_BINARY_DIR}/app_generated/mask_py.h
|
||||
MaskPy)
|
||||
|
||||
# ---- fonts (src/app/gui/Fonts.h, docs/i18n.md) ----
|
||||
#
|
||||
# The Latin/Cyrillic face IS embedded: at 59 KB it costs nothing, and
|
||||
@@ -257,11 +249,9 @@ if(SS_BUILD_GUI)
|
||||
list(APPEND SS_TOOL_LIBS "-framework AppKit")
|
||||
endif()
|
||||
|
||||
# In-process segmentation (interactive preview + dataset masking) and, when
|
||||
# patented modules are enabled, in-process video decoding. Both are
|
||||
# optional: DatasetPrep falls back to python + reference/scripts/mask.py
|
||||
# and to ffmpeg, and the GUI hides what this build cannot do rather than
|
||||
# failing at run time.
|
||||
# In-process segmentation and, with SS_ENABLE_PATENTED, video decoding.
|
||||
# Without them the GUI masks only fixed areas of the frame and decodes with
|
||||
# ffmpeg, and says so rather than failing at run time.
|
||||
if(SS_BUILD_SAM)
|
||||
list(APPEND SS_TOOL_DEFS SS_BUILD_SAM=1)
|
||||
if(SS_ENABLE_PATENTED)
|
||||
@@ -466,6 +456,7 @@ if(SS_BUILD_GUI)
|
||||
${SS_SRC}/app/FrameLook.cpp
|
||||
${SS_SRC}/app/gui/mask/Livewire.cpp
|
||||
${SS_SRC}/app/gui/mask/PathTool.cpp
|
||||
${SS_SRC}/app/gui/mask/PenTool.cpp
|
||||
${SS_SRC}/app/gui/Picture.cpp
|
||||
${SS_SRC}/app/gui/mask/MaskSlideshow.cpp)
|
||||
ss_configure_app(mask_doc_test)
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# Embedding data files into the executables as byte arrays, so the apps are
|
||||
# self-contained (no runtime lookup of viewer.html or reference/scripts/mask.py).
|
||||
# self-contained (no runtime lookup of viewer.html, fonts or icons).
|
||||
|
||||
# ss_hex_to_literal(<hex string> <out var>)
|
||||
#
|
||||
|
||||
@@ -176,13 +176,9 @@ else()
|
||||
endif()
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GPU inference (src/nn/) and segmentation (src/sam/)
|
||||
#
|
||||
# The native replacement for the reference/scripts/mask.py subprocess: SAM 2
|
||||
# / SAM 3 on the same Vulkan + Slang stack as the SfM module, over a
|
||||
# reusable inference layer. Same rule as SfM -- Vulkan-only, on by default
|
||||
# only for the Vulkan build, opt-in for CUDA if the Vulkan SDK is present.
|
||||
# See cmake/SsNn.cmake and src/nn/README.md.
|
||||
# GPU inference (src/nn/) and SAM segmentation (src/sam/). Vulkan-only like
|
||||
# SfM: on by default for the Vulkan build, opt-in for CUDA with the Vulkan SDK.
|
||||
# Without it the GUI has no model-based masking. See cmake/SsNn.cmake.
|
||||
# ---------------------------------------------------------------------------
|
||||
if(SS_BACKEND STREQUAL "vulkan")
|
||||
option(SS_BUILD_SAM "Build the inference layer + SAM segmentation" ON)
|
||||
|
||||
+3
-3
@@ -331,9 +331,9 @@ Notarization needs a paid Apple Developer account, so it is not wired into the
|
||||
build.
|
||||
|
||||
One behaviour is bundle-specific: a Finder launch inherits launchd's PATH
|
||||
(`/usr/bin:/bin:/usr/sbin:/sbin`), which has no Homebrew in it, so COLMAP,
|
||||
ffmpeg and python3 would be missing from an app that finds them fine when
|
||||
started from a shell. `gui::add_desktop_search_paths()`
|
||||
(`/usr/bin:/bin:/usr/sbin:/sbin`), which has no Homebrew in it, so COLMAP and
|
||||
ffmpeg would be missing from an app that finds them fine when started from a
|
||||
shell. `gui::add_desktop_search_paths()`
|
||||
(`src/app/AppPaths.h`) appends the package managers' directories at
|
||||
startup, after any PATH the process actually inherited.
|
||||
|
||||
|
||||
+4
-4
@@ -103,10 +103,10 @@ maximum of exactly 1.0 across a whole scene-linear capture.
|
||||
`ImageCompare`'s "Original file" pane, which quantizes the file's own values
|
||||
with no transfer curve so it shows what is stored rather than a display of it.
|
||||
|
||||
The one path that cannot read an EXR is the **external masking fallback**,
|
||||
`reference/scripts/mask.py`: Pillow has no EXR reader. It now counts the files
|
||||
it could not open and says so at the end of the run rather than leaving the
|
||||
capture silently unmasked. The built-in masking (`SS_BUILD_SAM`) reads them.
|
||||
Masking (`SS_BUILD_SAM`) reads them too. The hand-run
|
||||
`reference/scripts/mask.py` cannot -- Pillow has no EXR reader -- so it counts
|
||||
the files it could not open and says so at the end of the run rather than
|
||||
leaving the capture silently unmasked.
|
||||
|
||||
## Testing
|
||||
|
||||
|
||||
+148
-47
@@ -1,10 +1,11 @@
|
||||
# Fixed areas of the frame: the stencil, its tools and its file
|
||||
|
||||
"Remove fixed areas of the frame" on the dataset screen is `app::FrameStencil`:
|
||||
per input, a fitted fisheye border plus shapes drawn by hand. Both are geometry,
|
||||
not segmentation, so they need no model and are the same on every frame of a
|
||||
camera. This note covers the drawn half: what a shape is, the tools that draw
|
||||
one, the SVG file a set of them is saved as, and how a saved set reaches a
|
||||
per input, a fitted fisheye border plus shapes drawn by hand, optionally
|
||||
different for each of the input's cameras. Both are geometry, not segmentation,
|
||||
so they need no model and are the same on every frame of a camera. This note
|
||||
covers the drawn half: what a shape is, the tools that draw one, per-camera
|
||||
areas, the SVG file a set of them is saved as, and how a saved set reaches a
|
||||
dataset preset and a batch run.
|
||||
|
||||
## Shapes
|
||||
@@ -17,8 +18,15 @@ and y by height:
|
||||
|---|---|---|
|
||||
| `Rect` | two corners in `cx,cy` / `rx,ry` | Box |
|
||||
| `Ellipse` | centre `cx,cy`, radii `rx,ry` | Ellipse |
|
||||
| `Path` | `pts`: 3+ corners, closed, even-odd | Lasso, Polygon, Path (livewire) |
|
||||
| `Path` | `pts`: 3+ corners, closed, even-odd | Lasso, Path (livewire) |
|
||||
| `Stroke` | `pts`: 1+ points; half-width `rx,ry` per axis | Brush, Eraser |
|
||||
| `Bezier` | `pts`: 2+ anchors of six floats (in-handle, point, out-handle), closed, even-odd | Pen |
|
||||
|
||||
A `Bezier`'s segment i runs from anchor i's point through its out-handle and
|
||||
anchor i+1's in-handle to that anchor's point, the last back to the first; a
|
||||
corner has both handles on its point. The math (flattening by Wang's bound,
|
||||
splitting, nearest point, the smooth-handle rule) is `core/CubicBezier.h`,
|
||||
shared by the rasterizer, the pen tool and the editor.
|
||||
|
||||
Each shape either removes what is inside it or keeps it. Shapes apply **in
|
||||
order**, the last one covering a pixel decides it, and the base is "keep"
|
||||
@@ -35,28 +43,66 @@ detected radius; Ctrl+click allows numeric entry within that range. Preview and
|
||||
batch processing both apply `app::shrink_border`.
|
||||
|
||||
**Edit border ellipse** converts the current adjusted circle into the first
|
||||
keep-shape, turns off automatic detection, and starts a new shape edit history.
|
||||
The mask is unchanged by conversion; Select can then move its centre and resize
|
||||
its two axes. This fixed ellipse is saved with the other shapes and applies to
|
||||
the whole input, so use separate inputs for cameras with different borders.
|
||||
Automatic detection must receive `shrink=0` before conversion; the stencil's
|
||||
shrink is applied once when the ellipse is inserted.
|
||||
keep-shape and turns off automatic detection; it is one undo step. The mask is
|
||||
unchanged by conversion; Select can then move its centre and resize its two
|
||||
axes. On an input with several cameras it first turns on **Separate areas for
|
||||
each camera** (below), so the ellipse is the shown camera's alone and the other
|
||||
lenses keep their own fitted borders. Automatic detection must receive
|
||||
`shrink=0` before conversion; the stencil's shrink is applied once when the
|
||||
ellipse is inserted.
|
||||
|
||||
## Tools
|
||||
|
||||
`SegmentPanel` (Try the mask...) has the mask editor's tool row over the
|
||||
picture: Select `V`, Box `B`, Ellipse `E`, Lasso `L`, Polygon `P`, Brush `C`,
|
||||
Eraser `X`, Path `I`, and Add / Subtract. A plain stroke adds to what is removed;
|
||||
Subtract, the eraser and Ctrl each flip that to keep, so Ctrl with the eraser
|
||||
removes again, as in the mask editor. Undo and redo (Ctrl+Z, Ctrl+Shift+Z,
|
||||
Ctrl+Y) cover every change to the list, including moves, flips and loads.
|
||||
Under Select, clicks on the picture still prompt the model; pick a shape in the
|
||||
list to move or resize it.
|
||||
picture: Select `V`, Points `A`, Box `B`, Ellipse `E`, Lasso `L`, Pen `P`,
|
||||
Brush `C`, Eraser `X`, Path `I`, and Add / Subtract. A plain stroke adds to
|
||||
what is removed; Subtract, the eraser and Ctrl each flip that to keep, so Ctrl
|
||||
with the eraser removes again, as in the mask editor. Undo and redo (Ctrl+Z,
|
||||
Ctrl+Shift+Z, Ctrl+Y) cover every change to the stencil, including moves,
|
||||
flips, loads, the border conversion and the per-camera switch. Under Select,
|
||||
clicks on the picture still prompt the model; pick a shape in the list to move
|
||||
or resize it.
|
||||
|
||||
The tools are the 3D editor's `EditTool` producing a `ShapeStroke` in canvas
|
||||
pixels; `stencil_shape_from_stroke` (`src/app/gui/StencilEdit.h`) turns that
|
||||
into a `MaskShape`. That file also holds hit testing, moving, resize handles and
|
||||
the undo history, with no ImGui, and `stencil_edit_test` covers it.
|
||||
into a `MaskShape`. That file also holds hit testing, moving, resize handles,
|
||||
a pen shape's points and the undo history, with no ImGui, and
|
||||
`stencil_edit_test` covers it.
|
||||
|
||||
### The pen
|
||||
|
||||
`P`, with the grammar Illustrator, Photoshop, Figma and Affinity share, so the
|
||||
Polygon tool is gone from this row (a pen that is only clicked is a polygon):
|
||||
|
||||
| input | does |
|
||||
|---|---|
|
||||
| click | a corner anchor |
|
||||
| drag | a smooth anchor: the out-handle follows the pointer, the in-handle mirrors it |
|
||||
| Shift | the new anchor, or the handle, snaps to 45-degree steps |
|
||||
| Alt while dragging | the in-handle stays put; only the out-handle moves (a cusp) |
|
||||
| Space while dragging | moves the anchor being placed; the handles resume from there |
|
||||
| click the last anchor | takes its out-handle back, so the next segment leaves straight |
|
||||
| Ctrl+drag | moves an anchor or handle already placed (the "direct selection for now") |
|
||||
| click the first anchor, Enter, right click | closes the path; a drag on the first anchor also sets its handles |
|
||||
| Ctrl+Z, Backspace | take the last anchor back |
|
||||
| Esc | cancels the path |
|
||||
|
||||
A badge beside the pointer shows a ring (close), a caret (straighten) or an
|
||||
arrow (edit), where a vector editor would change the cursor. With a pen shape
|
||||
selected, the pen also edits it: a click on its outline adds an anchor there
|
||||
(the curve does not change), a click on an anchor deletes it, and Alt+click on
|
||||
an anchor makes it a corner, or Alt+drag pulls new symmetric handles out of it.
|
||||
|
||||
`PenTool` (`src/app/gui/mask/PenTool.h`) is the same class in the mask editor,
|
||||
where the closed path is flattened and painted. Its points are kept in frame
|
||||
pixels through `PathSpace`, as the livewire path's are.
|
||||
|
||||
**Points** (`A`, direct selection) shows a pen shape's anchors as squares and
|
||||
handles as circles. Drag either; a handle of a smooth anchor swings the other
|
||||
to stay opposite at its own length, and Alt+drag moves it alone. Shift keeps
|
||||
45-degree steps. Delete or Backspace removes the picked anchor (never below
|
||||
two). A click inside another shape picks it; other kinds move and resize as
|
||||
under Select.
|
||||
|
||||
## The file: SVG in normalized coordinates
|
||||
|
||||
@@ -83,45 +129,100 @@ the undo history, with no ImGui, and `stencil_edit_test` covers it.
|
||||
axis. A viewer draws `stroke-width`, their geometric mean doubled, which is
|
||||
as close as one SVG width can get.
|
||||
- `<title>` is the name the set is listed under.
|
||||
- `data-camera` on the root, when present, says the file is one camera's
|
||||
part of a per-camera set (below).
|
||||
|
||||
A `Bezier` is a `<path>` of `C` commands, one per segment, straight ones
|
||||
included, ending on the first anchor; the reader folds that last anchor back
|
||||
into the first, so a file round-trips to the same anchors.
|
||||
|
||||
The reader also takes hand-made SVG: `rect`, `circle`, `ellipse`, `polygon`,
|
||||
`polyline`, `line` and `path` (all commands, curves and arcs flattened to 24
|
||||
segments), `style=""` and presentation attributes, inherited through `<g>`,
|
||||
and any `viewBox` (pixel coordinates are fine). A path's subpaths become one
|
||||
shape each, so a hole is a later keep shape rather than an even-odd subpath.
|
||||
`transform`, `<use>` and `<image>` are refused by name rather than misplaced.
|
||||
`polyline`, `line` and `path` (all commands), `style=""` and presentation
|
||||
attributes, inherited through `<g>`, and any `viewBox` (pixel coordinates are
|
||||
fine). A filled subpath with a curve (`C`, `S`, `Q`, `T`; quadratics raised to
|
||||
cubics) and no arc becomes a `Bezier`, so a shape drawn in Inkscape or
|
||||
Illustrator stays editable with Points; one with an arc is flattened to 24
|
||||
segments per curve. A path's subpaths become one shape each, so a hole is a
|
||||
later keep shape rather than an even-odd subpath. `transform`, `<use>` and
|
||||
`<image>` are refused by name rather than misplaced.
|
||||
|
||||
## Per camera
|
||||
|
||||
One input can write several cameras: the two lenses of a dual-fisheye `.insv`
|
||||
or `.osv` (`cam0/`, `cam1/`), a folder of `.insp` photos cut in two, the views
|
||||
a GoPro `.360` is unwrapped into (ten `cam0/`..`cam9/` faces by default, the
|
||||
two EAC tracks with 360 unwrapping off, one image for equirectangular), or a
|
||||
folder of photos in subfolders. By default the shapes are the input's and every
|
||||
camera gets them, and the border is fitted per camera.
|
||||
|
||||
**Separate areas for each camera**, under the shapes, gives each camera its own
|
||||
`app::CameraStencil`: border on/off, shrink and shapes. Turning it on copies
|
||||
the input's stencil to every camera the panel knows, so nothing changes until
|
||||
you draw; the **Camera** picker then chooses which one the tools edit. Turning
|
||||
it off keeps the shown camera's as the input's. `FrameStencil::cameras` is
|
||||
keyed by the folder the frames land in, exactly as `group_frames_by_camera`
|
||||
and DatasetPrep's `StencilRaster` key them, and a camera it does not list uses
|
||||
the input's. The panel keys a packed photo's lens as `cam<k>` plus the photo's
|
||||
own subfolder, which is where the split writes it.
|
||||
|
||||
### Per-camera sets
|
||||
|
||||
A saved set (`app::MaskSet`) is one shape list for every camera, or one list
|
||||
per camera. On disk a per-camera set is one SVG per camera, all with the same
|
||||
`<title>`, each naming its camera in `data-camera`; a camera with nothing drawn
|
||||
has no file. `app::mask_set_of` decides which an input is: one list when it has
|
||||
no separate areas or when every camera draws the same, per camera otherwise.
|
||||
`app::apply_mask_set` puts a set back, each named camera its own list and every
|
||||
other camera the set's shared one. The fitted border is never in a set, but a
|
||||
list that starts with a keep shape (an edited border ellipse, say) is the kept
|
||||
region already, and the fit would only widen it, since keep shapes add up; so
|
||||
loading one turns that camera's fit off. Every other border setting stays.
|
||||
|
||||
`app::load_mask_svg_set` reads a file as its set: a file naming no camera on
|
||||
its own, a file naming one together with every file beside it that has its
|
||||
title and names a camera. So any one file of a set, picked anywhere a file is
|
||||
picked, loads the whole set. A camera name is a folder key (`cam1`, `cam0/sub`
|
||||
for a packed photo), and a set only means something on an input whose cameras
|
||||
are keyed the same way.
|
||||
|
||||
## Where a saved set goes
|
||||
|
||||
- **Saved**: `<config>/presets/stencil/<name>.svg`, from **Save...** in the
|
||||
panel (`StencilPreset.h`). **Load...** there replaces the shapes on the input
|
||||
the panel is open on.
|
||||
- **Saved**: `<config>/presets/stencil/<name>.svg`, or `<name>-<camera>.svg`
|
||||
per camera, from **Save...** in the panel (`StencilPreset.h`). With separate
|
||||
areas on it saves the whole input's set, not the shown camera's. Saving a
|
||||
name again removes every file of the old set first. **Load...** puts a
|
||||
one-list set where the tools draw (the input's, or the shown camera's with
|
||||
separate areas on); a per-camera set turns separate areas on and goes on
|
||||
the cameras it names, and is refused, with its camera names, on an input
|
||||
that has none of them. The list shows a per-camera set once, as
|
||||
`Name [cam0, cam1]`.
|
||||
- **Every input**: the dataset screen's **Drawn areas** picker, under the
|
||||
fixed-areas checkbox, draws a saved set on every input, including inputs
|
||||
added afterwards. Editing the shapes in the panel drops the name, since it no
|
||||
longer describes what is drawn.
|
||||
fixed-areas checkbox, draws a saved set on every input, per camera for a
|
||||
per-camera set, including inputs added afterwards. Editing the shapes in the
|
||||
panel drops the name, since it no longer describes what is drawn.
|
||||
- **Presets and batch**: the name is `mask_frame_shapes` in a dataset preset.
|
||||
A batch row whose preset has the fixed-areas option on gets the border fit
|
||||
and that set on every input; a name that no longer resolves fails the row.
|
||||
- **With the dataset**: a run writes each input's drawn shapes to
|
||||
`<dataset>/frame_stencil/<input>.svg` (the folder holds only the latest
|
||||
run's), so they survive a session nobody saved from. **Load...** lists them
|
||||
under "In this dataset", and **Other file...** loads any SVG, such as
|
||||
another dataset's.
|
||||
- **CLI**: `spirula sam mask --shape <file>.svg` reads the same file.
|
||||
- **With the dataset**: a run writes each input's set to
|
||||
`<dataset>/frame_stencil/`: `<input>.svg`, or `<input>-<camera>.svg` per
|
||||
camera when its cameras differ, titled `<dataset> - <input file>`. Two
|
||||
inputs of one name get `<input>-2` and a `(2)` title, so their camera files
|
||||
never load back as one set. The folder holds only the latest run's, so they
|
||||
survive a session nobody saved from. **Load...** lists them under "In this
|
||||
dataset", and **Other file...** loads any SVG, with its set.
|
||||
- **CLI**: `spirula sam mask --shape <file>.svg` reads the same files; one
|
||||
camera's file brings its set, each camera's list for its own folder under
|
||||
`--frames`.
|
||||
|
||||
## Adding a shape kind (the pen tool)
|
||||
## Adding a shape kind
|
||||
|
||||
A pen tool with Bezier handles is the next kind. The places it touches:
|
||||
`Bezier` is the worked example. The places a kind touches:
|
||||
|
||||
1. `MaskShape::Kind` and its fields (e.g. anchors and control points in `pts`).
|
||||
2. `rasterize_frame_mask`: flatten into the kind's own plane, as `Path` and
|
||||
`Stroke` do, so the ordering rule is untouched.
|
||||
1. `MaskShape::Kind` and its fields.
|
||||
2. `rasterize_frame_mask`: fill the kind's own plane, as `Path`, `Stroke` and
|
||||
`Bezier` do, so the ordering rule is untouched.
|
||||
3. `parse_mask_shapes` / `format_mask_shapes`, the CLI spelling.
|
||||
4. `write_mask_svg`: a `<path>` with `C` commands. `parse_svg_path` already
|
||||
reads them; keep the control points by mapping `C` to the new kind instead
|
||||
of flattening, when the element carries its `data-op`.
|
||||
5. `StencilEdit`: `stencil_contains`, `stencil_move`, and handles for the
|
||||
anchors and control points.
|
||||
4. `write_mask_svg` and `read_mask_svg`.
|
||||
5. `StencilEdit`: `stencil_contains`, `stencil_move`, and its handles.
|
||||
6. `SegmentPanel`: the tool button and key, the outline, and the list label.
|
||||
7. `frame_mask_test` (raster and SVG round trip) and `stencil_edit_test`.
|
||||
|
||||
@@ -15,13 +15,13 @@ GUI acceptance remain unverified because this checkout has no model weights or
|
||||
bounded capture fixtures and no GUI launch was performed.
|
||||
|
||||
The external compatibility tail is intentionally not implemented. COLMAP is
|
||||
not installed, the external Python masking dependencies are unavailable, and
|
||||
the installed ffmpeg path has no application-level GPU routing in this plan.
|
||||
Do not describe external COLMAP/Python work as honoring the native selector.
|
||||
not installed, and the installed ffmpeg path has no application-level GPU
|
||||
routing in this plan. Do not describe external COLMAP work as honoring the
|
||||
native selector.
|
||||
|
||||
## Goal and scope
|
||||
|
||||
Make the selected GPU control **built-in SfM, masking, and geometry**, in the GUI and native CLI. Selection means the physical device that executes the work, not merely the name shown by the training picker. Complete and verify this native milestone before implementing external COLMAP/Python routing.
|
||||
Make the selected GPU control **built-in SfM, masking, and geometry**, in the GUI and native CLI. Selection means the physical device that executes the work, not merely the name shown by the training picker. Complete and verify this native milestone before implementing external COLMAP routing.
|
||||
|
||||
| Native milestone | Required coverage |
|
||||
|---|---|
|
||||
@@ -29,7 +29,7 @@ Make the selected GPU control **built-in SfM, masking, and geometry**, in the GU
|
||||
| Masking | SAM 2 and SAM 3; GUI preview and dataset masking; native segment, track, mask, extract, and GPU-backed video paths |
|
||||
| Geometry | MoGe-2 and Metric3D v2; GUI preview, dataset-run child process, and CLI |
|
||||
| Supporting integration | Existing Vulkan training choice, early preview/capability probing, native decoder sharing of NN, and runtime teardown/recreation |
|
||||
| Separate compatibility tail | External COLMAP and the existing external Python masking path; their flags, device namespaces, visibility rules, and fallback policy |
|
||||
| Separate compatibility tail | External COLMAP; its flags, device namespaces, visibility rules, and fallback policy |
|
||||
|
||||
Not included: multi-GPU execution, one GPU assignment per stage, hot switching, merging the three Vulkan runtimes, model/kernel changes, a new Python dependency, a new recovery system, or CUDA validation. Keep patented decoding disabled by default. External ffmpeg hardware-acceleration routing is not added by this plan; preserve the existing ffmpeg fallback.
|
||||
|
||||
@@ -50,7 +50,7 @@ The implementation sources, rather than older architecture plans, establish this
|
||||
| Native job propagation | `src/app/gui/SfmRunner.cpp`, `recon_args`, omits `--device`; the same arguments feed `SfmInProcess.cpp` and the self-child path. `GeometryRunner.cpp` also omits it. `SegmentPanel`, `DatasetPrep`, and `GeometryPanel` do not receive the training choice. |
|
||||
| Premature first use | With `SS_HAVE_VIDEO`, `DatasetPrep::backends()` caches a call to `VideoPipeline::availability()`, which creates the NN context. Video preview/open can also use NN before model loading. Merely presenting preprocessing capabilities can therefore consume the first-use decision. |
|
||||
| Teardown | `nn::shutdown()` destroys stream, pipelines, pools, allocations, and context. Dataset preparation invokes it at job end; preview panels can retain the context after unloading weights. A later context generation currently selects afresh. The training backend has a different, process-lifetime context. |
|
||||
| External processes | `Subprocess.cpp` uses an inherited environment (`CreateProcess` with a null environment block, or `execvp`). `ColmapRunner` and the Python masking launch in `DatasetPrep` provide no explicit GPU routing. COLMAP's existing `ba_use_gpu` boolean is not a device selection. |
|
||||
| External processes | `Subprocess.cpp` uses an inherited environment (`CreateProcess` with a null environment block, or `execvp`). `ColmapRunner` provides no explicit GPU routing. COLMAP's existing `ba_use_gpu` boolean is not a device selection. |
|
||||
|
||||
`backend::DeviceInfo`, `nn::DeviceInfo`, and the private SfM context expose no device UUID today. In a CUDA build the existing training picker enumerates CUDA devices, not Vulkan devices; its integer must never be forwarded as a native Vulkan index.
|
||||
|
||||
@@ -133,7 +133,6 @@ The following lanes share Phase A's resolved-device contract. They may run concu
|
||||
- Remove selection-specific `SS_VK_DEVICE` mutation in `sam_extract`; configure the native request before any decoder or model first use. Leave unrelated validation/profiling behavior unchanged.
|
||||
- Have Session/model reuse compare effective physical identity, not two textual spellings of the same selector. Default model allocation and trackers inherit the configured device.
|
||||
- Preserve both SAM families, existing prompt/tracking semantics, and model unloading. Native video and masking share the selected NN device where decoding is built in. The normal ffmpeg decode fallback remains available on a device without video support.
|
||||
- Keep external Python masking unchanged in this lane; the UI must not claim that it honors the native selector yet.
|
||||
|
||||
**Acceptance:** actual SAM 2 visual and SAM 3 text/visual masks run on the chosen UUID in preview and batch paths; tracking spans multiple frames. Segment, track, extract, and GPU-backed video agree on numeric/name/UUID semantics. Preview followed by a dataset job, NN shutdown, and another preview retains the choice. A decode capability probe cannot select a different device first.
|
||||
|
||||
@@ -160,21 +159,20 @@ The following lanes share Phase A's resolved-device contract. They may run concu
|
||||
6. Report the actual resolved UUID/name at workload startup through existing log facilities. Verify identity at every context creation, including later SfM worker contexts; avoid per-kernel logging or a new telemetry system. A copied argv value or one process-global name banner is insufficient evidence.
|
||||
7. Run the native acceptance matrix below. After it passes, update the existing subsystem/app/backend documentation that describes independent selection and environment precedence; remove throwaway smoke artifacts. Do not create an unrelated documentation or recovery subsystem.
|
||||
|
||||
**Native completion gate:** all three built-in workflows obey one selection in their real GUI/CLI surfaces, lifecycle/error cases pass, and native child routing is explicit. This gate can ship without external COLMAP or Python device routing. Mark external routing as independent in interface/help text rather than implying an app-wide guarantee it does not yet provide.
|
||||
**Native completion gate:** all three built-in workflows obey one selection in their real GUI/CLI surfaces, lifecycle/error cases pass, and native child routing is explicit. This gate can ship without external COLMAP device routing. Mark external routing as independent in interface/help text rather than implying an app-wide guarantee it does not yet provide.
|
||||
|
||||
### Phase D — External compatibility tail
|
||||
|
||||
This is a separately reviewable follow-on, not a hidden prerequisite of Phases A–C.
|
||||
|
||||
**Files:** `src/app/gui/ColmapRunner.{h,cpp}`, external Python launch in `DatasetPrep.cpp`, `src/app/gui/Subprocess.{h,cpp}`, and `reference/scripts/mask.py` only if its existing CLI needs an explicit device option.
|
||||
**Files:** `src/app/gui/ColmapRunner.{h,cpp}` and `src/app/gui/Subprocess.{h,cpp}`.
|
||||
|
||||
1. Inventory the actual launched COLMAP/Python GPU operations and supported versions. Check the installed command's help for GPU index/use flags; do not assume the same option names across COLMAP releases. Extraction, matching, and GPU BA are separate operations. Preserve existing fisheye/CPU algorithm decisions.
|
||||
1. Inventory the actual launched COLMAP GPU operations and supported versions. Check the installed command's help for GPU index/use flags; do not assume the same option names across COLMAP releases. Extraction, matching, and GPU BA are separate operations. Preserve existing fisheye/CPU algorithm decisions.
|
||||
2. Define a compatibility mapping from native physical identity to the external runtime's identity/visible ordinal. Never pass a Vulkan integer straight to CUDA. Match a queried identity where supported; otherwise require an explicitly labeled external-runtime device override. Names alone are not sufficient on a machine with identical GPUs.
|
||||
3. Account for the caller's existing visibility restrictions and ordinal remapping. Scope any environment override to the specific child process; leave the parent and unrelated children unchanged. Extend the existing `run_process` environment support only as much as these launch sites require, on Windows and POSIX.
|
||||
4. For COLMAP, emit only supported device flags at each GPU operation and retain its CPU controls. A boolean such as `ba_use_gpu` cannot establish which GPU ran BA. Verify against the supported installed versions, not solely the latest web documentation.
|
||||
5. For external Python masking, apply the selected external device before the framework initializes it. Reuse the existing script/interpreter path; do not add Python to the native build, import reference tooling into native execution, or bundle model/framework dependencies.
|
||||
6. If the selected native device has no supported external-runtime counterpart, report that explicitly and offer the existing native path or an explicit external/CPU choice where that tool supports one. Do not silently choose the first NVIDIA GPU, select CPU, or override user visibility restrictions while claiming the original GPU was honored.
|
||||
7. Verify external routing separately on the supported COLMAP/Python installations: actual selected device, existing visibility filters, invalid/missing mappings, unavailable framework/backend, and child-environment isolation. No new CUDA backend validation project is part of this tail.
|
||||
5. If the selected native device has no supported external-runtime counterpart, report that explicitly and offer the existing native path or an explicit external/CPU choice where that tool supports one. Do not silently choose the first NVIDIA GPU, select CPU, or override user visibility restrictions while claiming the original GPU was honored.
|
||||
6. Verify external routing separately on the supported COLMAP installations: actual selected device, existing visibility filters, invalid/missing mappings, unavailable framework/backend, and child-environment isolation. No new CUDA backend validation project is part of this tail.
|
||||
|
||||
Reference: [COLMAP CLI documentation](https://colmap.github.io/cli.html). The installed executable's help remains authoritative for its supported flags.
|
||||
|
||||
|
||||
@@ -22,23 +22,35 @@ On macOS, Ctrl below means Command, as elsewhere in the app.
|
||||
|
||||
| tool | key | what it does |
|
||||
|---|---|---|
|
||||
| Box, Ellipse, Lasso, Polygon, Brush | `B` `E` `L` `P` `C` | the 3D editor's shapes, painted into the mask |
|
||||
| Box, Ellipse, Lasso, Brush | `B` `E` `L` `C` | the 3D editor's shapes, painted into the mask |
|
||||
| Pen | `P` | Bezier curves, as in a vector editor; clicks alone make a polygon |
|
||||
| Eraser | `X` | the brush with the modes swapped: a plain drag keeps |
|
||||
| Path | `I` | pen with livewire: each anchor snaps to the edge |
|
||||
| SAM | `G` | click an object, or type what to drop |
|
||||
|
||||
Shapes, brush and path paint with the 3D editor's selection grammar:
|
||||
Shapes, brush, pen and path paint with the 3D editor's selection grammar:
|
||||
a plain or Shift drag **drops**, Ctrl **keeps**, Shift+Ctrl **clears** the
|
||||
correction back to what the run wrote. The modifiers are read when the shape
|
||||
completes. **Add / Subtract**, beside the frame slider, swaps drop and keep
|
||||
for every tool, SAM clicks included: under Subtract a plain drag keeps and Ctrl
|
||||
drops. The eraser swaps them too, so under Subtract it drops. Right click or Enter closes a polygon; Ctrl+Z takes back its last
|
||||
point. Esc cancels a shape in progress.
|
||||
completes, or, for the pen and the path, when the first anchor is placed.
|
||||
**Add / Subtract**, beside the frame slider, swaps drop and keep for every
|
||||
tool, SAM clicks included: under Subtract a plain drag keeps and Ctrl drops.
|
||||
The eraser swaps them too, so under Subtract it drops. Esc cancels a shape in
|
||||
progress.
|
||||
|
||||
**Brush size** is in mask pixels, 1 to 4096, and shared by the brush and the
|
||||
eraser. The slider shows while either is active; `[` and `]` step it, and
|
||||
Alt+wheel over the picture changes it continuously.
|
||||
|
||||
**Pen.** A click drops a corner and a drag a smooth anchor whose handles
|
||||
follow the pointer; Shift snaps to 45-degree steps, Alt while dragging moves
|
||||
the out-handle alone, Space while dragging moves the anchor, a click on the
|
||||
last anchor straightens the next segment, and Ctrl+drag moves any anchor or
|
||||
handle already placed. Hold Ctrl (keep) or Shift+Ctrl (clear) when you place
|
||||
the first anchor. Click the first anchor, press Enter or right click to close
|
||||
and paint the inside; Ctrl+Z or Backspace removes an anchor; Esc cancels. The
|
||||
full table is in [frame-stencil.md](frame-stencil.md#the-pen); the path keeps
|
||||
frame pixels, so zooming mid-path is fine.
|
||||
|
||||
**Path.** Click along an edge to drop anchors; the segment from the last
|
||||
anchor follows the lowest-cost edge path to the cursor (Mortensen and
|
||||
Barrett's intelligent scissors). Click the first anchor, press Enter or right
|
||||
@@ -139,7 +151,8 @@ MaskDoc one frame in memory: base, two layers, composite, undo history
|
||||
MaskWindow the view: pane <-> mask mapping and the window texture's pixels
|
||||
MaskSession the frames, the worker, the scan, the open frame, SAM, slideshow
|
||||
MaskPanel.cpp the window: canvas, tools, status, keys; the only GL
|
||||
PathTool / Livewire the pen and its edge search
|
||||
PathTool / Livewire the livewire path and its edge search
|
||||
PenTool the Bezier pen, shared with the stencil panel
|
||||
MaskAdd / MaskSam SAM results to stencils / the checkpoint and its job thread
|
||||
MaskSlideshow the slideshow's decoder ring and clock
|
||||
```
|
||||
|
||||
@@ -781,7 +781,7 @@ correct regional face if the user switches away from the embedded region. Keep
|
||||
the fetch path compiled in for every value except `none`.
|
||||
|
||||
Embedding reuses `ssplat_embed_file()` from `cmake/SsplatEmbed.cmake` — the
|
||||
same mechanism as `viewer.html` and `mask.py`. Fonts are OFL-1.1, which is
|
||||
same mechanism as `viewer.html`. Fonts are OFL-1.1, which is
|
||||
GPLv3-compatible for bundling; ship the licence text alongside, and do not
|
||||
rename the font files (OFL reserved font name clause).
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ It replaces `src/app/gui/ColmapRunner.cpp`'s COLMAP half: `colmap
|
||||
feature_extractor / *_matcher / mapper / model_merger / bundle_adjuster`, the
|
||||
vocabulary-tree download, and the COLMAP version check. It does **not** replace
|
||||
ColmapRunner's other half — ffmpeg frame extraction, sharpest-frame selection,
|
||||
multi-track `.insv` splitting, and AI masking via `reference/scripts/mask.py` — which is
|
||||
multi-track `.insv` splitting, and AI masking — which is
|
||||
shared, not COLMAP-specific, and gets factored out for both paths (phase 5).
|
||||
|
||||
Output is unchanged in kind: `<workspace>/sparse/0/{cameras,images,points3D}.bin`
|
||||
@@ -269,8 +269,8 @@ The user installs nothing either way: the child is our own binary, found via
|
||||
`AppPaths::exe_path`, not via PATH.
|
||||
|
||||
Everything else landed as written: `DatasetPrep` (item 1, and it grew built-in
|
||||
video decoding and masking with the ffmpeg/Python subprocesses kept as
|
||||
fallbacks), `Screen::NewDataset` and the engine selector (item 3), the beginner
|
||||
video decoding with the ffmpeg subprocess kept as a fallback, and in-process
|
||||
masking), `Screen::NewDataset` and the engine selector (item 3), the beginner
|
||||
panel with its auto-detection (item 4), and the settings persistence (item 6).
|
||||
|
||||
Item 5 — the "All SfM options" editor over the phase-2 descriptor table — was
|
||||
|
||||
+2
-2
@@ -26,8 +26,8 @@ std::string exe_path();
|
||||
std::string exe_dir();
|
||||
|
||||
// macOS only. A Finder launch inherits launchd's PATH -- /usr/bin:/bin:
|
||||
// /usr/sbin:/sbin -- so colmap, ffmpeg and python3 are invisible to the bundle
|
||||
// though a shell finds them. Appends, so an inherited PATH still wins.
|
||||
// /usr/sbin:/sbin -- so colmap and ffmpeg are invisible to the bundle though a
|
||||
// shell finds them. Appends, so an inherited PATH still wins.
|
||||
void add_desktop_search_paths();
|
||||
|
||||
} // namespace app
|
||||
|
||||
+76
-9
@@ -3,6 +3,7 @@
|
||||
#include "app/FrameMask.h"
|
||||
|
||||
#include "app/FrameLook.h"
|
||||
#include "core/CubicBezier.h"
|
||||
#include "core/ExrImage.h"
|
||||
#include "core/PolygonFill.h"
|
||||
|
||||
@@ -575,7 +576,7 @@ MaskShape shrink_border(MaskShape shape, float shrink) {
|
||||
return shape;
|
||||
}
|
||||
|
||||
bool edit_detected_border(FrameStencil& stencil, const BorderDetect& border) {
|
||||
bool edit_detected_border(CameraStencil& stencil, const BorderDetect& border) {
|
||||
if (!stencil.detect_border || !border.found) return false;
|
||||
stencil.mask.shapes.insert(stencil.mask.shapes.begin(),
|
||||
shrink_border(border.shape, stencil.shrink));
|
||||
@@ -583,6 +584,47 @@ bool edit_detected_border(FrameStencil& stencil, const BorderDetect& border) {
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MaskSet::empty() const {
|
||||
for (const auto& [camera, list] : cameras)
|
||||
if (!list.empty()) return false;
|
||||
return shapes.empty();
|
||||
}
|
||||
|
||||
MaskSet mask_set_of(const FrameStencil& stencil) {
|
||||
MaskSet out;
|
||||
if (!stencil.per_camera()) {
|
||||
out.shapes = stencil.mask.shapes;
|
||||
return out;
|
||||
}
|
||||
// The input's own list is only a fallback here: every camera the panel
|
||||
// knew was given an entry when separate areas were turned on.
|
||||
const std::string first = format_mask_shapes(stencil.cameras.begin()->second.mask.shapes);
|
||||
bool same = true;
|
||||
for (const auto& [camera, cs] : stencil.cameras)
|
||||
same = same && format_mask_shapes(cs.mask.shapes) == first;
|
||||
if (same) {
|
||||
out.shapes = stencil.cameras.begin()->second.mask.shapes;
|
||||
return out;
|
||||
}
|
||||
for (const auto& [camera, cs] : stencil.cameras) out.cameras[camera] = cs.mask.shapes;
|
||||
return out;
|
||||
}
|
||||
|
||||
void apply_mask_set(FrameStencil& stencil, const MaskSet& set) {
|
||||
auto put = [](CameraStencil& cs, const std::vector<MaskShape>& list) {
|
||||
cs.mask.shapes = list;
|
||||
if (!list.empty() && !list.front().remove) cs.detect_border = false;
|
||||
};
|
||||
put(stencil, set.shapes);
|
||||
for (auto& [camera, cs] : stencil.cameras) put(cs, set.shapes);
|
||||
for (const auto& [camera, list] : set.cameras) {
|
||||
auto it = stencil.cameras.find(camera);
|
||||
if (it == stencil.cameras.end())
|
||||
it = stencil.cameras.emplace(camera, static_cast<const CameraStencil&>(stencil)).first;
|
||||
put(it->second, list);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shapes
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -621,6 +663,17 @@ bool parse_mask_shapes(const std::string& spec, std::vector<MaskShape>& out,
|
||||
out.push_back(s);
|
||||
continue;
|
||||
}
|
||||
if (kind == "bezier") {
|
||||
s.kind = MaskShape::Kind::Bezier;
|
||||
if (!parse_floats(nums, s.pts) ||
|
||||
s.pts.size() < 2 * bezier::kAnchorFloats ||
|
||||
s.pts.size() % bezier::kAnchorFloats) {
|
||||
error = piece;
|
||||
return false;
|
||||
}
|
||||
out.push_back(s);
|
||||
continue;
|
||||
}
|
||||
if (kind == "stroke") {
|
||||
s.kind = MaskShape::Kind::Stroke;
|
||||
if (!parse_floats(nums, s.pts) || s.pts.size() < 4 || s.pts.size() % 2 ||
|
||||
@@ -661,7 +714,12 @@ std::string format_mask_shapes(const std::vector<MaskShape>& shapes) {
|
||||
char buf[96];
|
||||
for (const MaskShape& s : shapes) {
|
||||
std::string piece = s.remove ? "-" : "";
|
||||
if (s.kind == MaskShape::Kind::Path || s.kind == MaskShape::Kind::Stroke) {
|
||||
if (s.kind == MaskShape::Kind::Bezier) {
|
||||
// A handle is dragged by a fraction of a pixel; four places lose that at 8K.
|
||||
piece += "bezier ";
|
||||
for (size_t i = 0; i < s.pts.size(); i++)
|
||||
append_printf(piece, "%s%.5f", i ? "," : "", s.pts[i]);
|
||||
} else if (s.kind == MaskShape::Kind::Path || s.kind == MaskShape::Kind::Stroke) {
|
||||
const bool stroke = s.kind == MaskShape::Kind::Stroke;
|
||||
piece += stroke ? "stroke " : "path ";
|
||||
if (stroke) append_printf(piece, "%.5f,%.5f", s.rx, s.ry);
|
||||
@@ -728,8 +786,9 @@ bool rasterize_frame_mask(const FrameMask& m, int width, int height,
|
||||
}
|
||||
const bool base = m.shapes.empty() || m.shapes.front().remove;
|
||||
|
||||
// A path or stroke is filled once into its own plane; the pixel loop then
|
||||
// reads it like any other inside test, so the ordering rule is untouched.
|
||||
// A path, curve or stroke is filled once into its own plane; the pixel
|
||||
// loop then reads it like any other inside test, so the ordering rule is
|
||||
// untouched.
|
||||
std::vector<std::vector<uint8_t>> paths(m.shapes.size());
|
||||
std::vector<float> px;
|
||||
for (size_t k = 0; k < m.shapes.size(); k++) {
|
||||
@@ -739,13 +798,20 @@ bool rasterize_frame_mask(const FrameMask& m, int width, int height,
|
||||
fill_stroke(s, width, height, paths[k].data());
|
||||
continue;
|
||||
}
|
||||
if (s.kind != MaskShape::Kind::Path) continue;
|
||||
if (s.kind == MaskShape::Kind::Bezier) {
|
||||
paths[k].assign((size_t)width * height, 0);
|
||||
bezier::flatten_closed(s.pts.data(), s.pts.size() / bezier::kAnchorFloats,
|
||||
(float)width, (float)height, 0.05f, px);
|
||||
} else if (s.kind == MaskShape::Kind::Path) {
|
||||
paths[k].assign((size_t)width * height, 0);
|
||||
px.resize(s.pts.size());
|
||||
for (size_t i = 0; i + 1 < s.pts.size(); i += 2) {
|
||||
px[i] = s.pts[i] * (float)width;
|
||||
px[i + 1] = s.pts[i + 1] * (float)height;
|
||||
}
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
polyfill::fill_even_odd(px.data(), px.size() / 2, width, height, paths[k].data(), 1);
|
||||
}
|
||||
|
||||
@@ -759,7 +825,7 @@ bool rasterize_frame_mask(const FrameMask& m, int width, int height,
|
||||
for (size_t k = 0; k < m.shapes.size(); k++) {
|
||||
const MaskShape& s = m.shapes[k];
|
||||
bool inside;
|
||||
if (s.kind == MaskShape::Kind::Path || s.kind == MaskShape::Kind::Stroke) {
|
||||
if (!paths[k].empty()) {
|
||||
inside = paths[k][(size_t)y * width + x] != 0;
|
||||
} else if (s.kind == MaskShape::Kind::Ellipse) {
|
||||
if (s.rx <= 0.0f || s.ry <= 0.0f) continue;
|
||||
@@ -1027,11 +1093,12 @@ int64_t apply_frame_stencil(const FrameStencilRun& run,
|
||||
if (sinks.cancel && sinks.cancel->load()) return written;
|
||||
if (sinks.camera) sinks.camera(rel, (int64_t)files.size());
|
||||
|
||||
FrameMask fm = run.stencil.mask;
|
||||
const CameraStencil& stencil = run.stencil.for_camera(rel);
|
||||
FrameMask fm = stencil.mask;
|
||||
BorderDetect border;
|
||||
if (run.stencil.detect_border) {
|
||||
if (stencil.detect_border) {
|
||||
BorderDetectOptions o = run.detect;
|
||||
o.shrink = run.stencil.shrink;
|
||||
o.shrink = stencil.shrink;
|
||||
border = detect_fisheye_border(files, o);
|
||||
// First, so the shapes drawn on top are applied to it in order.
|
||||
if (border.found) fm.shapes.insert(fm.shapes.begin(), border.shape);
|
||||
|
||||
+40
-9
@@ -21,14 +21,14 @@
|
||||
namespace app {
|
||||
|
||||
struct MaskShape {
|
||||
enum class Kind { Ellipse, Rect, Path, Stroke };
|
||||
enum class Kind { Ellipse, Rect, Path, Stroke, Bezier };
|
||||
Kind kind = Kind::Ellipse;
|
||||
bool remove = false; // false = keep what is inside
|
||||
float cx = 0.5f, cy = 0.5f; // rect: the two corners go in cx,cy / rx,ry
|
||||
// Stroke: its half-width per axis, so a brush stays round on any aspect.
|
||||
float rx = 0.5f, ry = 0.5f;
|
||||
// x,y pairs. Path: 3+ corners, closed, even-odd. Stroke: 1+ points of a
|
||||
// round-capped polyline, what a brush drags.
|
||||
// round-capped polyline. Bezier: 2+ anchors (core/CubicBezier.h), closed.
|
||||
std::vector<float> pts;
|
||||
};
|
||||
|
||||
@@ -46,8 +46,8 @@ struct FrameMask {
|
||||
};
|
||||
|
||||
// "ellipse 0.5,0.5,0.49,0.49; -rect 0.2,0.9,0.8,1; path 0.1,0.1,0.9,0.1,0.5,0.9":
|
||||
// ';'-separated, '-' removes. rect: two corners; ellipse: centre, radii; path:
|
||||
// 3+ corners; stroke: rx, ry, then 1+ points. The file form is app/FrameMaskSvg.h.
|
||||
// ';'-separated, '-' removes; rect, ellipse, path, stroke (rx, ry, points) and
|
||||
// bezier (six numbers an anchor) as MaskShape stores them. File: FrameMaskSvg.h.
|
||||
bool parse_mask_shapes(const std::string& spec, std::vector<MaskShape>& out,
|
||||
std::string& error);
|
||||
std::string format_mask_shapes(const std::vector<MaskShape>& shapes);
|
||||
@@ -126,19 +126,50 @@ private:
|
||||
// Applying a stencil to a folder of frames
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// One input's decision: the shapes drawn on it, and whether the lens circle
|
||||
// should also be fitted. Both, because the two answer different questions --
|
||||
// a dual-fisheye file writes two cameras whose circles differ by about 1% of
|
||||
// One camera's decision: the shapes drawn on it, and whether its lens circle is
|
||||
// fitted. Both, because a dual-fisheye file's two circles differ by about 1% of
|
||||
// the radius, and whatever drew the shapes only ever saw one of them.
|
||||
struct FrameStencil {
|
||||
struct CameraStencil {
|
||||
FrameMask mask;
|
||||
bool detect_border = false;
|
||||
float shrink = 0.01f; // overrides BorderDetectOptions::shrink
|
||||
bool empty() const { return mask.empty() && !detect_border; }
|
||||
};
|
||||
|
||||
// One input's: shared by all of its cameras, except those listed in `cameras`
|
||||
// by the folder their frames land in ("cam1", a 360 view, a photo subfolder --
|
||||
// group_frames_by_camera's keys), which get their own instead.
|
||||
struct FrameStencil : CameraStencil {
|
||||
std::map<std::string, CameraStencil> cameras;
|
||||
bool per_camera() const { return !cameras.empty(); }
|
||||
const CameraStencil& for_camera(const std::string& camera) const {
|
||||
const auto it = cameras.find(camera);
|
||||
return it == cameras.end() ? *this : it->second;
|
||||
}
|
||||
bool empty() const {
|
||||
for (const auto& [name, c] : cameras)
|
||||
if (!c.empty()) return false;
|
||||
return CameraStencil::empty();
|
||||
}
|
||||
};
|
||||
|
||||
// The detection must have shrink=0; the stencil supplies the adjustment.
|
||||
bool edit_detected_border(FrameStencil& stencil, const BorderDetect& border);
|
||||
bool edit_detected_border(CameraStencil& stencil, const BorderDetect& border);
|
||||
|
||||
// A saved set of drawn areas: `shapes` for every camera, except each camera
|
||||
// `cameras` names, which gets its own. A per-camera set is one SVG per camera.
|
||||
struct MaskSet {
|
||||
std::vector<MaskShape> shapes;
|
||||
std::map<std::string, std::vector<MaskShape>> cameras;
|
||||
bool per_camera() const { return !cameras.empty(); }
|
||||
bool empty() const;
|
||||
};
|
||||
// What an input draws, as one list when every camera draws the same.
|
||||
MaskSet mask_set_of(const FrameStencil& stencil);
|
||||
// Replaces every drawn shape of `stencil` with `set`'s. A list starting with a
|
||||
// keep shape (an edited border ellipse) is the kept region already, and the
|
||||
// fitted circle would only widen it, so its camera's fit goes off.
|
||||
void apply_mask_set(FrameStencil& stencil, const MaskSet& set);
|
||||
|
||||
// Image files under `dir`, grouped by the folder holding them -- a folder is a
|
||||
// camera, which is what a multi-track extraction writes (cam0/, cam1/). Keys
|
||||
|
||||
+107
-8
@@ -2,11 +2,14 @@
|
||||
|
||||
#include "app/FrameMaskSvg.h"
|
||||
|
||||
#include "core/CubicBezier.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <cctype>
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <map>
|
||||
#include <sstream>
|
||||
@@ -359,6 +362,20 @@ void arc(std::vector<float>& pts, float x1, float y1, float rx, float ry, float
|
||||
}
|
||||
}
|
||||
|
||||
// A path's last anchor sitting on its first is the same anchor, reached
|
||||
// again: it keeps the first's out-handle and brings its own in-handle.
|
||||
void close_anchors(std::vector<float>& a) {
|
||||
constexpr size_t k = bezier::kAnchorFloats;
|
||||
const size_t n = a.size() / k;
|
||||
if (n < 2) return;
|
||||
const float* last = &a[(n - 1) * k];
|
||||
const float tol = 1e-6f * std::max({1.0f, std::fabs(a[2]), std::fabs(a[3])});
|
||||
if (std::fabs(last[2] - a[2]) > tol || std::fabs(last[3] - a[3]) > tol) return;
|
||||
a[0] = last[0];
|
||||
a[1] = last[1];
|
||||
a.resize((n - 1) * k);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::string& error) {
|
||||
@@ -369,10 +386,15 @@ bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::str
|
||||
float cx2 = 0, cy2 = 0;
|
||||
char prev = 0, cmd = 0;
|
||||
SvgSubpath* cur = nullptr;
|
||||
// Per subpath in `out`: whether it had a curve, and an arc.
|
||||
std::vector<char> curved, arced;
|
||||
auto begin = [&](float nx, float ny) {
|
||||
out.push_back({});
|
||||
curved.push_back(0);
|
||||
arced.push_back(0);
|
||||
cur = &out.back();
|
||||
cur->pts = {nx, ny};
|
||||
cur->anchors = {nx, ny, nx, ny, nx, ny};
|
||||
x = sx = nx;
|
||||
y = sy = ny;
|
||||
};
|
||||
@@ -380,6 +402,15 @@ bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::str
|
||||
if (!cur || cur->closed) begin(x, y);
|
||||
return cur;
|
||||
};
|
||||
auto line_anchor = [&](float px, float py) {
|
||||
cur->anchors.insert(cur->anchors.end(), {px, py, px, py, px, py});
|
||||
};
|
||||
auto curve_anchor = [&](float x1, float y1, float x2, float y2, float ex, float ey) {
|
||||
cur->anchors[cur->anchors.size() - 2] = x1;
|
||||
cur->anchors[cur->anchors.size() - 1] = y1;
|
||||
cur->anchors.insert(cur->anchors.end(), {x2, y2, ex, ey, ex, ey});
|
||||
curved.back() = 1;
|
||||
};
|
||||
while (!p.at_end()) {
|
||||
if (p.at_command()) cmd = p.command();
|
||||
else if (!cmd) {
|
||||
@@ -407,16 +438,19 @@ bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::str
|
||||
x = ox + v[0];
|
||||
y = oy + v[1];
|
||||
need()->pts.insert(cur->pts.end(), {x, y});
|
||||
line_anchor(x, y);
|
||||
break;
|
||||
case 'H':
|
||||
if (!read(1)) { error = d; return false; }
|
||||
x = (rel ? x : 0.0f) + v[0];
|
||||
need()->pts.insert(cur->pts.end(), {x, y});
|
||||
line_anchor(x, y);
|
||||
break;
|
||||
case 'V':
|
||||
if (!read(1)) { error = d; return false; }
|
||||
y = (rel ? y : 0.0f) + v[0];
|
||||
need()->pts.insert(cur->pts.end(), {x, y});
|
||||
line_anchor(x, y);
|
||||
break;
|
||||
case 'C':
|
||||
case 'S': {
|
||||
@@ -436,6 +470,7 @@ bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::str
|
||||
cy2 = oy + v[1];
|
||||
const float ex = ox + v[2], ey = oy + v[3];
|
||||
cubic(need()->pts, x, y, x1, y1, cx2, cy2, ex, ey);
|
||||
curve_anchor(x1, y1, cx2, cy2, ex, ey);
|
||||
x = ex;
|
||||
y = ey;
|
||||
break;
|
||||
@@ -455,6 +490,10 @@ bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::str
|
||||
}
|
||||
const float ex = ox + v[0], ey = oy + v[1];
|
||||
quad(need()->pts, x, y, cx2, cy2, ex, ey);
|
||||
// Degree elevation: the cubic with these handles IS the quadratic.
|
||||
curve_anchor(x + 2.0f / 3.0f * (cx2 - x), y + 2.0f / 3.0f * (cy2 - y),
|
||||
ex + 2.0f / 3.0f * (cx2 - ex), ey + 2.0f / 3.0f * (cy2 - ey),
|
||||
ex, ey);
|
||||
x = ex;
|
||||
y = ey;
|
||||
break;
|
||||
@@ -468,6 +507,8 @@ bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::str
|
||||
}
|
||||
const float ex = ox + v[3], ey = oy + v[4];
|
||||
arc(need()->pts, x, y, v[0], v[1], v[2], large, sweep, ex, ey);
|
||||
line_anchor(ex, ey);
|
||||
arced.back() = 1;
|
||||
x = ex;
|
||||
y = ey;
|
||||
break;
|
||||
@@ -483,6 +524,11 @@ bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::str
|
||||
}
|
||||
prev = up;
|
||||
}
|
||||
for (size_t k = 0; k < out.size(); k++) {
|
||||
if (curved[k] && !arced[k]) close_anchors(out[k].anchors);
|
||||
if (!curved[k] || arced[k] || out[k].anchors.size() < 2 * bezier::kAnchorFloats)
|
||||
out[k].anchors.clear();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -490,12 +536,15 @@ bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::str
|
||||
// Writing
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
std::string write_mask_svg(const std::vector<MaskShape>& shapes, const std::string& title) {
|
||||
std::string write_mask_svg(const std::vector<MaskShape>& shapes, const std::string& title,
|
||||
const std::string& camera) {
|
||||
std::string o;
|
||||
o += "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
|
||||
// width/height only size a viewer's window; the geometry is the viewBox.
|
||||
o += "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 1 1\" width=\"1024\" "
|
||||
"height=\"1024\" preserveAspectRatio=\"none\">\n";
|
||||
"height=\"1024\" preserveAspectRatio=\"none\"";
|
||||
if (!camera.empty()) o += " data-camera=\"" + escape_xml(camera) + "\"";
|
||||
o += ">\n";
|
||||
if (!title.empty()) o += " <title>" + escape_xml(title) + "</title>\n";
|
||||
o += " <desc>Spirula Studio frame stencil. Coordinates are normalized to the image, "
|
||||
"(0,0) top left to (1,1) bottom right. Shapes paint in order: black removes, "
|
||||
@@ -528,6 +577,22 @@ std::string write_mask_svg(const std::vector<MaskShape>& shapes, const std::stri
|
||||
"\" fill-rule=\"evenodd\"/>\n";
|
||||
break;
|
||||
}
|
||||
case MaskShape::Kind::Bezier: {
|
||||
// Every segment a C, straight ones too, so it reads back as a
|
||||
// Bezier; the last ends on the first anchor, which reading merges.
|
||||
const size_t n = s.pts.size() / bezier::kAnchorFloats;
|
||||
if (n == 0) break;
|
||||
std::string d = "M" + num(s.pts[2]) + " " + num(s.pts[3]);
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
float c[8];
|
||||
bezier::segment(s.pts.data(), n, i, c);
|
||||
d += " C" + num(c[2]) + " " + num(c[3]) + " " + num(c[4]) + " " +
|
||||
num(c[5]) + " " + num(c[6]) + " " + num(c[7]);
|
||||
}
|
||||
o += head + "path data-op=\"" + op + "\" d=\"" + d + " Z\" fill=\"" + ink +
|
||||
"\" fill-rule=\"evenodd\"/>\n";
|
||||
break;
|
||||
}
|
||||
case MaskShape::Kind::Stroke: {
|
||||
std::string pts;
|
||||
for (size_t i = 0; i + 1 < s.pts.size(); i += 2)
|
||||
@@ -556,9 +621,10 @@ std::string write_mask_svg(const std::vector<MaskShape>& shapes, const std::stri
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
bool read_mask_svg(const std::string& text, std::vector<MaskShape>& out, std::string& title,
|
||||
std::string& error) {
|
||||
std::string& error, std::string* camera) {
|
||||
out.clear();
|
||||
title.clear();
|
||||
if (camera) camera->clear();
|
||||
float vx = 0, vy = 0, vw = 1, vh = 1;
|
||||
bool seen_svg = false;
|
||||
std::vector<Style> stack{Style{}};
|
||||
@@ -665,6 +731,7 @@ bool read_mask_svg(const std::string& text, std::vector<MaskShape>& out, std::st
|
||||
if (tag.name == "svg") {
|
||||
if (!seen_svg) {
|
||||
seen_svg = true;
|
||||
if (camera) *camera = trim(tag.attrs["data-camera"]);
|
||||
const std::vector<float> vb = parse_list(tag.attrs["viewBox"]);
|
||||
if (vb.size() == 4 && vb[2] > 0 && vb[3] > 0) {
|
||||
vx = vb[0]; vy = vb[1]; vw = vb[2]; vh = vb[3];
|
||||
@@ -731,7 +798,12 @@ bool read_mask_svg(const std::string& text, std::vector<MaskShape>& out, std::st
|
||||
error = "<path d=\"" + error + "\">: unreadable path data";
|
||||
return false;
|
||||
}
|
||||
for (const SvgSubpath& sp : subs) emit(st, to_norm(sp.pts), sp.closed, nullptr);
|
||||
for (const SvgSubpath& sp : subs) {
|
||||
MaskShape curve;
|
||||
curve.kind = MaskShape::Kind::Bezier;
|
||||
curve.pts = to_norm(sp.anchors);
|
||||
emit(st, to_norm(sp.pts), sp.closed, sp.anchors.empty() ? nullptr : &curve);
|
||||
}
|
||||
}
|
||||
if (!tag.self_closing && tag.name != "rect" && tag.name != "circle" &&
|
||||
tag.name != "ellipse" && tag.name != "polygon" && tag.name != "polyline" &&
|
||||
@@ -747,7 +819,7 @@ bool read_mask_svg(const std::string& text, std::vector<MaskShape>& out, std::st
|
||||
}
|
||||
|
||||
bool load_mask_svg(const std::string& path, std::vector<MaskShape>& out, std::string& title,
|
||||
std::string& error) {
|
||||
std::string& error, std::string* camera) {
|
||||
std::ifstream f(path, std::ios::binary);
|
||||
if (!f) {
|
||||
error = path;
|
||||
@@ -755,17 +827,44 @@ bool load_mask_svg(const std::string& path, std::vector<MaskShape>& out, std::st
|
||||
}
|
||||
std::stringstream ss;
|
||||
ss << f.rdbuf();
|
||||
if (!read_mask_svg(ss.str(), out, title, error)) {
|
||||
if (!read_mask_svg(ss.str(), out, title, error, camera)) {
|
||||
error = path + ": " + error;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool load_mask_svg_set(const std::string& path, MaskSet& out, std::string& title,
|
||||
std::string& error) {
|
||||
out = MaskSet{};
|
||||
std::string camera;
|
||||
std::vector<MaskShape> shapes;
|
||||
if (!load_mask_svg(path, shapes, title, error, &camera)) return false;
|
||||
if (camera.empty()) {
|
||||
out.shapes = std::move(shapes);
|
||||
return true;
|
||||
}
|
||||
out.cameras[camera] = std::move(shapes);
|
||||
namespace fs = std::filesystem;
|
||||
std::error_code ec;
|
||||
const fs::path self = fs::path(path).lexically_normal();
|
||||
for (fs::directory_iterator it(self.parent_path().empty() ? fs::path(".") : self.parent_path(), ec),
|
||||
end;
|
||||
!ec && it != end; it.increment(ec)) {
|
||||
if (it->path().extension() != ".svg" || it->path().lexically_normal() == self) continue;
|
||||
std::string t, c, err;
|
||||
std::vector<MaskShape> other;
|
||||
// A sibling that does not read is not this set's business.
|
||||
if (!load_mask_svg(it->path().string(), other, t, err, &c)) continue;
|
||||
if (!c.empty() && t == title && !out.cameras.count(c)) out.cameras[c] = std::move(other);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool save_mask_svg(const std::string& path, const std::vector<MaskShape>& shapes,
|
||||
const std::string& title, std::string& error) {
|
||||
const std::string& title, std::string& error, const std::string& camera) {
|
||||
std::ofstream f(path, std::ios::binary | std::ios::trunc);
|
||||
if (f) f << write_mask_svg(shapes, title);
|
||||
if (f) f << write_mask_svg(shapes, title, camera);
|
||||
if (!f) {
|
||||
error = path;
|
||||
return false;
|
||||
|
||||
+19
-8
@@ -12,24 +12,35 @@
|
||||
|
||||
namespace app {
|
||||
|
||||
// `title` becomes <title>, the name a saved stencil is listed under.
|
||||
std::string write_mask_svg(const std::vector<MaskShape>& shapes, const std::string& title = "");
|
||||
// `title` becomes <title>, the name a saved stencil is listed under. `camera`
|
||||
// marks one file of a per-camera set (data-camera on the root).
|
||||
std::string write_mask_svg(const std::vector<MaskShape>& shapes, const std::string& title = "",
|
||||
const std::string& camera = "");
|
||||
|
||||
// Also reads hand-made SVG (rect, circle, ellipse, polygon, polyline, line,
|
||||
// path; any viewBox), curves flattened, one shape per subpath. False, with
|
||||
// `error`, on anything it cannot place -- a transform, say.
|
||||
// Also reads hand-made SVG, one shape per subpath: a filled one with curves
|
||||
// and no arc stays a Bezier, the rest are flattened. False, with `error`, on
|
||||
// anything it cannot place -- a transform, say. docs/notes/frame-stencil.md.
|
||||
bool read_mask_svg(const std::string& text, std::vector<MaskShape>& out, std::string& title,
|
||||
std::string& error);
|
||||
std::string& error, std::string* camera = nullptr);
|
||||
|
||||
bool load_mask_svg(const std::string& path, std::vector<MaskShape>& out, std::string& title,
|
||||
std::string& error);
|
||||
std::string& error, std::string* camera = nullptr);
|
||||
bool save_mask_svg(const std::string& path, const std::vector<MaskShape>& shapes,
|
||||
const std::string& title, std::string& error);
|
||||
const std::string& title, std::string& error,
|
||||
const std::string& camera = "");
|
||||
|
||||
// A file, as the set it belongs to: on its own, or, when it names a camera,
|
||||
// with every file beside it that has its title and names a camera.
|
||||
bool load_mask_svg_set(const std::string& path, MaskSet& out, std::string& title,
|
||||
std::string& error);
|
||||
|
||||
// One subpath of an SVG path's `d`, curves flattened, in the path's own units.
|
||||
struct SvgSubpath {
|
||||
std::vector<float> pts; // x,y pairs
|
||||
bool closed = false;
|
||||
// The same outline as Bezier anchors (core/CubicBezier.h), quadratics
|
||||
// raised to cubics; empty unless it has a curve and no arc.
|
||||
std::vector<float> anchors;
|
||||
};
|
||||
bool parse_svg_path(const std::string& d, std::vector<SvgSubpath>& out, std::string& error);
|
||||
|
||||
|
||||
+1
-1
@@ -98,7 +98,7 @@ GUI (Basic Options) for remote monitoring.
|
||||
| `gui/SourceList.h/.cpp` | What a picked path means as an input, and what a LIST of them decides: the lens a file implies, the 360 packing and track count only a probe can answer, the `images/<subdir>` each input's frames go to, the output folder the list implies, and the reconstruction defaults the capture's own kind sets (a video is a capture in order, a dual-lens file is a rig, a 360 file is one camera warped into views). It lives here rather than on the dataset screen because a batch row builds the same list from the same paths with nobody watching, and two answers to "which lens is this capture" would be one too many. `resolve_source_lenses()` is the part that must survive a preset applied afterwards: a preset decides everything except the lens a capture is KNOWN to need, because a dual-fisheye clip fitted with a rectilinear model reconstructs into nothing. |
|
||||
| `gui/MaskSettings.h` | The mask prompt and its thresholds, split out of `SegmentPanel.h` so a preset module does not depend on a UI panel. |
|
||||
| `gui/BatchProcess.h/.cpp` | **Batch processing**: a list of rows, each of which can build a dataset, train it any number of times, and mesh what came out -- and none of those three has to exist when the queue is started. A row carries the videos or photo folders to build from, the dataset folder (which the Dataset stage WRITES and the Train stage READS, so the two cannot disagree), a dataset preset, a list of `BatchRun`s and a `BatchMeshOptions`. A **run** is one training pass: its preset, and text overrides of that preset's **max splats / SH degree / steps** -- text because "unset" and "0" are different answers and 0 is a legal `--sh-degree` -- plus whether the row's Mesh stage covers it, which is what lets one row train a large model for its appearance and a cheap one beside it for the mesh. The **mesh options** are a preset plus the colors and formats to write over what it says; empty means "leave the preset alone" rather than "write nothing". `batch_plan()` expands the rows into the tasks they will run as -- one per stage per run, and one mesh per run that asked for it -- which is both what the screen previews before the start and what the driver walks. `batch_progress()` turns the same list into the two bars and the estimate the screens draw, from what the finished tasks of each stage actually took. The list is data, not a runner: `GuiApp::advance_batch()` launches a task, waits for the runner that owns that stage to go idle, records what happened and launches the next -- driving the live screens rather than being a fourth implementation of what `SfmRunner`, `TrainRunner` and `MeshRunner` already are. A task that fails is an ordinary transition: it is recorded, the tasks downstream of it in the same row are passed over (what they needed was never produced), and the next row still gets its turn. `batch_check_row()` is the "warn before anything starts" half, and it is what makes a weekend-long queue worth setting up: an input that is not there, a preset file that has gone missing, a segmentation or geometry checkpoint not downloaded (a batch cannot stop to accept a licence), masking with no prompt (a batch cannot be prompted with clicks), a flag `train_config_unsupported()` refuses, a folder that already holds a reconstruction the run would ADD to rather than rebuild, two rows building into one folder, a row training a dataset a LATER row builds, a preset made for video pointed at photographs, a meshing stage with no run ticked for it, and colors and formats with no pair between them. Nothing waits for a button: the check runs on every edit and on a timer, so a preset file deleted while the screen is open is reported where it happened. Fatal issues block the start (with a "skip the bad rows" way past); the rest are said out loud. The list is kept in `<config_dir>/batch.json` -- losing a queue to a crash three hours in is the one failure a user cannot recover from by trying again -- and a file from the train-only era still loads. A finished queue can **run a command** (`batch_command` in `gui.conf`, so it belongs to the machine rather than to the rows): `kBatchMessageToken` -- `{message}` -- is replaced by a one-line summary of how it went and always arrives as a single argument, braces because `<>`, `%..%` and `$..` are all punctuation some shell would eat. It runs however the queue ended, and the Test button beside the field sends a test message instead, which is the only honest way to find out whether a notifier works before leaving a queue overnight. The field is a box that grows with what is in it (one line while empty, up to twelve), because the real command is a `curl` with a JSON body that nobody writes on one line -- `gui.conf` is one setting per line, so the breaks are escaped on the way in and out. |
|
||||
| `gui/ColmapRunner.h/.cpp` | images/video → COLMAP dataset. Requires **COLMAP >= 4.x** (version probed from `colmap help`; 3.x-era `use_gpu` flags are gone — flags follow `reference/scripts/run_colmap.bash`): feature_extractor (**SIFT or ALIKED** via `FeatureExtraction.type`; camera model = any the parser supports; single camera / per-folder / per-image; optional **initial focal length** — `init_focal_factor` × width probed via `stbi_info`, composed into `ImageReader.camera_params` with centered principal point + zero distortion, or a raw `camera_params` string) → **explicitly chosen** exhaustive / sequential / vocab-tree matcher (no "auto"; the GUI presets sequential for video, exhaustive for photos). Sequential supports overlap + **quadratic overlap** (on by default) + **loop closure** (`SequentialMatching.loop_detection` via the vocab tree — SIFT only; the tree is auto-found near the workspace/cache or downloaded via curl into `~/.cache/spirula-studio`). **LightGlue** matching (`FeatureMatching.type` `SIFT_LIGHTGLUE`/`ALIKED_LIGHTGLUE`, default for ALIKED) → mapper (`ba_use_gpu` — **forced OFF for fisheye models**, COLMAP's GPU BA doesn't support them; `Mapper.ba_refine_extra_params 0` for perspective models by default — distortion held fixed for stability and recovered in the final BA, per run_colmap.bash's advice; `min_num_matches`) → best-effort **model_merger** when the mapper splits (kept only if the merged model registers more images; written as the next `sparse/<N>` — on a real X5 capture this fused 86+39 partials into 116/118 frames) → optional bundle_adjuster refinement **on the largest/merged model** (was hardcoded `sparse/0`), with a **verify-and-revert guard**: mean reprojection error before/after via `model_analyzer`, refinement discarded when worse/non-finite (releasing pp + 8 thin-prism coefficients on a ~200° fisheye reliably diverges to ~1e150 px — pp additionally stays fixed for fisheye). `.insv` preset: THIN_PRISM_FISHEYE, per-folder cameras, focal factor 0.269 (Insta360 X5: fx=fy≈0.269·width), **exhaustive** matcher (the two lens tracks are concatenated, so sequential misses cross-lens pairs: 116/118 exhaustive vs 68/118 sequential+loop on the same capture). **Resume** (`ColmapJob::resume`, GUI checkbox appears when the workspace holds a previous run): extracted frames are kept per track, mask.py resumes on its own, COLMAP skips features/matches already in database.db, and existing `sparse/<N>` models skip the mapper (the mapper only writes models on completion, so partials are never trusted); with resume off a non-empty workspace is refused rather than mixed into. A measured resume replay of a full .insv run took 39 s vs 246 s. **Repetitive-scene knobs** (Advanced → "Repetitive scenes", with an Off/Low/Medium/High preset combo; all 0 = default): `SiftMatching.max_ratio`, `TwoViewGeometry.min_num_inliers`, `Mapper.abs_pose_min_num_inliers` / `abs_pose_min_inlier_ratio` / `abs_pose_max_error` — stricter matching + registration so similar-looking rooms don't weld together. The GUI's default workspace auto-suffixes `_2`, `_3`, ... instead of pointing at an existing non-empty folder. Optional **AI masking**: the CMake-embedded `reference/scripts/mask.py` runs via external Python (lang-segment-anything / SAM-3), masks feed `ImageReader.mask_path` + the trainer's masks dir; missing-package output is detected and surfaced with install instructions. Photo-folder inputs are indexed **in place**, recursively (no copy) — the absolute image dir is handed to the GUI in-memory for the immediate open; no marker file is written, so on later re-opens set `data.image_dir` in the dataparser options (video datasets use the default `images/`). Multi-track `.insv` videos split into `images/cam<N>/` + per-folder cameras. The prep stage is shared, so a COLMAP run can take several inputs too — but `feature_extractor` fits ONE camera model to the whole run, so the per-input lens models only mean anything on the built-in path (the panel says so). The mapper may emit several partial models — the trainers auto-pick the largest (below). |
|
||||
| `gui/ColmapRunner.h/.cpp` | images/video → COLMAP dataset. Requires **COLMAP >= 4.x** (version probed from `colmap help`; 3.x-era `use_gpu` flags are gone — flags follow `reference/scripts/run_colmap.bash`): feature_extractor (**SIFT or ALIKED** via `FeatureExtraction.type`; camera model = any the parser supports; single camera / per-folder / per-image; optional **initial focal length** — `init_focal_factor` × width probed via `stbi_info`, composed into `ImageReader.camera_params` with centered principal point + zero distortion, or a raw `camera_params` string) → **explicitly chosen** exhaustive / sequential / vocab-tree matcher (no "auto"; the GUI presets sequential for video, exhaustive for photos). Sequential supports overlap + **quadratic overlap** (on by default) + **loop closure** (`SequentialMatching.loop_detection` via the vocab tree — SIFT only; the tree is auto-found near the workspace/cache or downloaded via curl into `~/.cache/spirula-studio`). **LightGlue** matching (`FeatureMatching.type` `SIFT_LIGHTGLUE`/`ALIKED_LIGHTGLUE`, default for ALIKED) → mapper (`ba_use_gpu` — **forced OFF for fisheye models**, COLMAP's GPU BA doesn't support them; `Mapper.ba_refine_extra_params 0` for perspective models by default — distortion held fixed for stability and recovered in the final BA, per run_colmap.bash's advice; `min_num_matches`) → best-effort **model_merger** when the mapper splits (kept only if the merged model registers more images; written as the next `sparse/<N>` — on a real X5 capture this fused 86+39 partials into 116/118 frames) → optional bundle_adjuster refinement **on the largest/merged model** (was hardcoded `sparse/0`), with a **verify-and-revert guard**: mean reprojection error before/after via `model_analyzer`, refinement discarded when worse/non-finite (releasing pp + 8 thin-prism coefficients on a ~200° fisheye reliably diverges to ~1e150 px — pp additionally stays fixed for fisheye). `.insv` preset: THIN_PRISM_FISHEYE, per-folder cameras, focal factor 0.269 (Insta360 X5: fx=fy≈0.269·width), **exhaustive** matcher (the two lens tracks are concatenated, so sequential misses cross-lens pairs: 116/118 exhaustive vs 68/118 sequential+loop on the same capture). **Resume** (`ColmapJob::resume`, GUI checkbox appears when the workspace holds a previous run): extracted frames are kept per track, masks already written are kept, COLMAP skips features/matches already in database.db, and existing `sparse/<N>` models skip the mapper (the mapper only writes models on completion, so partials are never trusted); with resume off a non-empty workspace is refused rather than mixed into. A measured resume replay of a full .insv run took 39 s vs 246 s. **Repetitive-scene knobs** (Advanced → "Repetitive scenes", with an Off/Low/Medium/High preset combo; all 0 = default): `SiftMatching.max_ratio`, `TwoViewGeometry.min_num_inliers`, `Mapper.abs_pose_min_num_inliers` / `abs_pose_min_inlier_ratio` / `abs_pose_max_error` — stricter matching + registration so similar-looking rooms don't weld together. The GUI's default workspace auto-suffixes `_2`, `_3`, ... instead of pointing at an existing non-empty folder. Optional **AI masking** (the shared `DatasetPrep` stage; in-process SAM 2 / SAM 3, `src/sam/`): masks feed `ImageReader.mask_path` + the trainer's masks dir. Photo-folder inputs are indexed **in place**, recursively (no copy) — the absolute image dir is handed to the GUI in-memory for the immediate open; no marker file is written, so on later re-opens set `data.image_dir` in the dataparser options (video datasets use the default `images/`). Multi-track `.insv` videos split into `images/cam<N>/` + per-folder cameras. The prep stage is shared, so a COLMAP run can take several inputs too — but `feature_extractor` fits ONE camera model to the whole run, so the per-input lens models only mean anything on the built-in path (the panel says so). The mapper may emit several partial models — the trainers auto-pick the largest (below). |
|
||||
| `gui/FrameSelect.h/.cpp` | Blur-aware video frame selection (multithreaded C++ port of `reference/scripts/extract_frames.py`): ffmpeg extracts candidates at (target fps x sharpness window) with `-nostdin` (**without it ffmpeg can hang reading stdin** — the "stuck at ffmpeg" bug), then the sharpest per window is kept (`FrameSharpness`, scored across all cores). With the adaptive rate on it extracts at (target fps x max(window, spread)) instead and hands the candidates to `FrameMotion`, which decides which of them to keep -- so the fallback adapts without a second decode. |
|
||||
| `gui/FileDialog.h/.cpp` | Built-in ImGui file/folder browser (no native-dialog dep; works over WSLg/remote X). File mode takes an optional **multi-select** (`open(..., multi_select=true)`, `results()`): clicking a file toggles it, so a dataset can be built from several clips in one pick. |
|
||||
| `gui/DatasetPrep.h/.cpp` | Everything between "the user picked an input" and "there is an image directory ready for SfM", shared by both reconstruction paths: frame extraction (in-process VK video decode → ffmpeg fallback), sharpest-frame selection, `.insv` track split, AI masking, and the resume rules. A job takes a **list of inputs** (`PrepInput`: path, video-or-folder, the `images/<subdir>` its frames go to, the masks that came with it, and the lens + focal factor that belong to it). **A picked photo folder is resolved by the project's own layout conventions** (`resolve_photo_folder`, matching `spirula sfm auto`'s probing and the dataparsers' `mask_dir = "masks"`): `<picked>/images` is the folder to index when it exists, and the `masks/` beside it holds masks that are **already made** -- those are used as they are and that input is never AI-masked (generating over them would write into a folder we were only asked to read). A folder named `masks` is never an input of its own: picked or dropped, it attaches to the input whose images it sits beside. **Every image walk follows directory symlinks** -- a prepared capture whose `images/`+`masks/` are links into the raw one is an ordinary layout, and the default iterator returns nothing for it -- and never descends into a `masks/` nested under the images, which would otherwise double the dataset with PNGs that are not views. One folder of photos is still read where it is; anything else is gathered into the dataset's own `images/`, one sub-folder per input (a multi-track video adds `cam0/`, `cam1/` under that), which is what makes the inputs separate cameras. `camera_subfolders` reports the folders that hold images **directly**, at any depth and including the root, so a capture handed over as `1/cam0`, `1/cam1`, `2/cam0` ... is as many camera groups as `--camera-mode folder` will make of it; it is depth- and count-bounded because each one becomes a row in the dataset panel. Photo folders in a multi-input job are **hard-linked** into place (masks too, so the two trees keep mirroring), copied when the filesystem refuses. `probe_workspace` reports what the output folder already holds, split into what a run can **reuse** (extracted frames / features / matches / masks -> the resume checkbox) and what it would **replace** (`sparse/` -> a warning): an existing reconstruction is never treated as resumable, since a folder holding one opens in the trainer when dropped, so anything that gets to this screen with a `sparse/` in it is someone else's dataset or a finished one. The input's own images and masks never count as leftovers -- which matters because the output folder for a capture that already has `images/` **is that folder**: `sparse/` belongs beside `images/` and `masks/`, which is exactly where every parser looks. Masks mirror the image tree and are generated **per input**, so the tracker's memory bank never crosses from one capture into the next; a clicked object prompts only the input it was drawn on (`MaskClick::source`), and a clicks-only job whose inputs are not all prompted is **refused** rather than run -- a dataset where three of four camera folders kept the subject reads as a masking run that worked. |
|
||||
|
||||
@@ -680,12 +680,15 @@ int cmd_mask(const Options& o) {
|
||||
std::string bad, title;
|
||||
const bool svg = o.shape_spec.size() > 4 &&
|
||||
o.shape_spec.compare(o.shape_spec.size() - 4, 4, ".svg") == 0;
|
||||
if (svg ? !app::load_mask_svg(o.shape_spec, run.stencil.mask.shapes, title, bad)
|
||||
: !app::parse_mask_shapes(o.shape_spec, run.stencil.mask.shapes, bad)) {
|
||||
// A file the GUI saved for one camera brings its set's other cameras.
|
||||
app::MaskSet set;
|
||||
if (svg ? !app::load_mask_svg_set(o.shape_spec, set, title, bad)
|
||||
: !app::parse_mask_shapes(o.shape_spec, set.shapes, bad)) {
|
||||
std::fprintf(stderr, "%s\n",
|
||||
format(cmsg::sam_mask_bad_shape, {bad}).c_str());
|
||||
return 2;
|
||||
}
|
||||
app::apply_mask_set(run.stencil, set);
|
||||
} else {
|
||||
run.stencil.detect_border = true;
|
||||
}
|
||||
|
||||
@@ -407,10 +407,7 @@ void check_dataset_stage(const BatchRow& row, const BatchCapabilities& caps,
|
||||
const bool prompted = !caps.mask_model_prompted || caps.mask_model_prompted(s.mask_model_id);
|
||||
if (prompted && s.mask.prompt.empty())
|
||||
out.push_back(issue_of(msg::chk_mask_no_prompt, kSt, true));
|
||||
if (s.sfm.prep.force_external_masking) {
|
||||
// mask.py resolves its own model by name; nothing here can say
|
||||
// whether it is there.
|
||||
} else if (!caps.masking) {
|
||||
if (!caps.masking) {
|
||||
out.push_back(issue_of(msg::chk_masking_unavailable, kSt, true));
|
||||
} else if (caps.mask_model_ready &&
|
||||
!caps.mask_model_ready(s.mask_model_id, s.mask_detector_id)) {
|
||||
@@ -694,15 +691,16 @@ bool batch_build_dataset_job(const BatchRow& row, const std::string& ffmpeg_exe,
|
||||
}
|
||||
// The screen draws these on each input; a batch row has only the preset.
|
||||
if (settings.border_enable) {
|
||||
std::vector<app::MaskShape> shapes;
|
||||
app::MaskSet set;
|
||||
if (!settings.frame_shapes.empty() &&
|
||||
!load_stencil_preset(settings.frame_shapes, shapes, error)) {
|
||||
!load_stencil_preset(settings.frame_shapes, set, error)) {
|
||||
error = "drawn areas not found: " + error;
|
||||
return false;
|
||||
}
|
||||
for (PrepInput& in : sources) {
|
||||
in.stencil.detect_border = true;
|
||||
in.stencil.mask.shapes = shapes;
|
||||
// A GoPro's views are cut out of its sphere: no lens border to fit.
|
||||
in.stencil.detect_border = !in.pano360.valid() && !is_pano360_path(in.path);
|
||||
app::apply_mask_set(in.stencil, set);
|
||||
}
|
||||
}
|
||||
resolve_source_lenses(sources, settings.sfm, settings.colmap);
|
||||
|
||||
@@ -285,9 +285,6 @@ void ColmapRunner::take_masking(PrepJob& prep) {
|
||||
prep.mask_model_path = _live.mask_model_path;
|
||||
prep.mask_detector_path = _live.mask_detector_path;
|
||||
prep.mask_detector_threshold = _live.mask_detector_threshold;
|
||||
prep.mask_model_name = _live.mask_model;
|
||||
prep.force_external_masking = _live.force_external_masking;
|
||||
prep.python_exe = _live.python_exe;
|
||||
}
|
||||
|
||||
void ColmapRunner::cancel() { _cancel = true; }
|
||||
@@ -544,9 +541,6 @@ void ColmapRunner::run(ColmapJob job) {
|
||||
pj.mask_model_path = job.mask_model_path;
|
||||
pj.mask_detector_path = job.mask_detector_path;
|
||||
pj.mask_detector_threshold = job.mask_detector_threshold;
|
||||
pj.mask_model_name = job.mask_model;
|
||||
pj.force_external_masking = job.force_external_masking;
|
||||
pj.python_exe = job.python_exe;
|
||||
|
||||
DatasetPrep dp(&_prog, _films, _cancel);
|
||||
if (!dp.run(pj, prep, err, [this](PrepJob& p) { take_masking(p); }))
|
||||
|
||||
@@ -59,18 +59,13 @@ struct ColmapJob {
|
||||
// honoured by the built-in reconstruction.
|
||||
std::vector<PrepInput> inputs;
|
||||
std::string workspace; // output dataset dir (created)
|
||||
bool resume = true; // reuse artifacts an interrupted
|
||||
// run left in the workspace:
|
||||
// extracted frames, masks (mask.py
|
||||
// resumes), features + matches
|
||||
// (COLMAP skips existing DB rows),
|
||||
// and completed sparse models.
|
||||
bool resume = true; // reuse what an interrupted run
|
||||
// left: frames, masks, features +
|
||||
// matches, completed sparse models.
|
||||
// false = require a clean folder.
|
||||
std::string colmap_exe = "colmap";
|
||||
std::string ffmpeg_exe = "ffmpeg";
|
||||
std::string python_exe = "python3"; // for the masking script
|
||||
bool force_external_decode = false; // ffmpeg even when we could decode
|
||||
bool force_external_masking = false; // mask.py even when we could segment
|
||||
|
||||
// Steps a re-run redoes rather than reuses; see PrepJob.
|
||||
bool redo_frames = false;
|
||||
@@ -153,9 +148,7 @@ struct ColmapJob {
|
||||
bool final_bundle_adjust = true; // bundle_adjuster refinement pass
|
||||
std::string vocab_tree_path; // "" = auto find / download
|
||||
|
||||
// AI masking. The built-in path wants a checkpoint file
|
||||
// (mask_model_path, from ModelCache); the mask.py fallback wants
|
||||
// a model name it understands (mask_model).
|
||||
// AI masking
|
||||
bool mask_enable = false;
|
||||
std::string mask_prompt; // "people; cars; ..."
|
||||
std::string mask_negative_prompt;
|
||||
@@ -163,7 +156,6 @@ struct ColmapJob {
|
||||
std::string mask_model_path;
|
||||
std::string mask_detector_path; // Grounding DINO, when one is paired
|
||||
float mask_detector_threshold = 0.3f;
|
||||
std::string mask_model = "sam2.1_hiera_large";
|
||||
int mask_max_image_size = 1600;
|
||||
float mask_dilate_ratio = 0.05f;
|
||||
float mask_threshold = 0.5f; // all five: see PrepJob in DatasetPrep.h
|
||||
|
||||
+16
-115
@@ -12,8 +12,6 @@
|
||||
#include "app/gui/FrameSelect.h"
|
||||
#include "app/gui/Subprocess.h"
|
||||
|
||||
#include "app_generated/mask_py.h" // kMaskPy[], from reference/scripts/mask.py
|
||||
|
||||
#include "core/ExrImage.h"
|
||||
#include "core/ImageOrient.h"
|
||||
#include "sfm/core/Exif.h"
|
||||
@@ -332,11 +330,12 @@ public:
|
||||
.push_back(f.string());
|
||||
}
|
||||
for (auto& [camera, group] : groups) {
|
||||
app::FrameMask fm = st.mask;
|
||||
const app::CameraStencil& cs = st.for_camera(camera);
|
||||
app::FrameMask fm = cs.mask;
|
||||
app::BorderDetect border;
|
||||
if (st.detect_border) {
|
||||
if (cs.detect_border) {
|
||||
app::BorderDetectOptions o;
|
||||
o.shrink = st.shrink;
|
||||
o.shrink = cs.shrink;
|
||||
border = app::detect_fisheye_border(group, o);
|
||||
// First, so the shapes drawn on top are applied to it in order.
|
||||
if (border.found) fm.shapes.insert(fm.shapes.begin(), border.shape);
|
||||
@@ -980,11 +979,6 @@ const Backends& backends() {
|
||||
#else
|
||||
b.masking_reason = "built without the segmentation module "
|
||||
"(-DSS_BUILD_SAM=OFF)";
|
||||
b.masking_note =
|
||||
"Masks are made by an external Python script "
|
||||
"(reference/scripts/mask.py with lang-segment-anything, which "
|
||||
"needs a CUDA PyTorch). Set the Python path under Tool "
|
||||
"locations if it is not on PATH.";
|
||||
#endif
|
||||
return b;
|
||||
}();
|
||||
@@ -1158,7 +1152,6 @@ bool DatasetPrep::run(const PrepJob& job_in, PrepResult& out, std::string& error
|
||||
// can run per input (see generate_masks) instead of over one flat tree.
|
||||
struct Prepared {
|
||||
std::string images, masks; // absolute
|
||||
std::string images_rel, masks_rel; // relative to the workspace
|
||||
// This input's masks already exist: brought along by the input, taken
|
||||
// from its alpha channel, or kept by a resumed run.
|
||||
bool have_masks = false;
|
||||
@@ -1190,13 +1183,12 @@ bool DatasetPrep::run(const PrepJob& job_in, PrepResult& out, std::string& error
|
||||
// are what says so, since this path copies nothing.
|
||||
if (camera_subfolders(out.image_dir).size() > 1)
|
||||
out.per_folder_cameras = true;
|
||||
per[0].images = per[0].images_rel = out.image_dir;
|
||||
per[0].images = out.image_dir;
|
||||
if (in.mask_dir.empty()) {
|
||||
// Nothing came with them, so anything generated goes in the
|
||||
// dataset, next to the reconstruction rather than next to the
|
||||
// photos -- a folder we were only asked to read.
|
||||
per[0].masks = (ws / "masks").string();
|
||||
per[0].masks_rel = "masks";
|
||||
skip_dir = per[0].masks;
|
||||
} else {
|
||||
const std::string bundled = fs::absolute(in.mask_dir, ec).string();
|
||||
@@ -1205,7 +1197,7 @@ bool DatasetPrep::run(const PrepJob& job_in, PrepResult& out, std::string& error
|
||||
if (in.stencil.empty()) {
|
||||
// Nothing to fold in, so they are read where they lie and keep
|
||||
// whichever convention they arrived in.
|
||||
per[0].masks = per[0].masks_rel = bundled;
|
||||
per[0].masks = out.mask_dir_cfg = bundled;
|
||||
out.mask_dir_flipped = job.flip_found_masks;
|
||||
} else {
|
||||
// The stencil has to go somewhere, and a masks/ under the
|
||||
@@ -1215,10 +1207,9 @@ bool DatasetPrep::run(const PrepJob& job_in, PrepResult& out, std::string& error
|
||||
const bool under_images =
|
||||
inside(fs::path(bundled), fs::path(out.image_dir));
|
||||
per[0].masks = under_images ? bundled : (ws / "masks").string();
|
||||
per[0].masks_rel = under_images ? bundled : std::string("masks");
|
||||
out.mask_dir_cfg = under_images ? bundled : std::string("masks");
|
||||
}
|
||||
out.mask_dir = per[0].masks;
|
||||
out.mask_dir_cfg = per[0].masks_rel;
|
||||
skip_dir = per[0].masks;
|
||||
}
|
||||
} else {
|
||||
@@ -1233,8 +1224,6 @@ bool DatasetPrep::run(const PrepJob& job_in, PrepResult& out, std::string& error
|
||||
Prepared& p = per[i];
|
||||
p.images = under(out.image_dir, in.subdir).string();
|
||||
p.masks = under((ws / "masks").string(), in.subdir).string();
|
||||
p.images_rel = under("images", in.subdir).generic_string();
|
||||
p.masks_rel = under("masks", in.subdir).generic_string();
|
||||
// Folded in from where they lie, not from the copies gathered next
|
||||
// to the images: a second run would otherwise fold the fold.
|
||||
if (!in.mask_dir.empty() &&
|
||||
@@ -1314,8 +1303,7 @@ bool DatasetPrep::run(const PrepJob& job_in, PrepResult& out, std::string& error
|
||||
// A subject model (BiRefNet) finds what to mask by itself.
|
||||
bool subject = false;
|
||||
#ifdef SS_BUILD_SAM
|
||||
subject = !job.force_external_masking && backends().builtin_masking &&
|
||||
sam::is_subject_model(job.mask_model_path);
|
||||
subject = sam::is_subject_model(job.mask_model_path);
|
||||
#endif
|
||||
if (!subject && job.mask_prompt.empty() && job.mask_clicks.empty()) {
|
||||
error = lmsg::err_mask_no_target.get();
|
||||
@@ -1345,8 +1333,7 @@ bool DatasetPrep::run(const PrepJob& job_in, PrepResult& out, std::string& error
|
||||
// The step's bar covers the stencil pass as well as segmentation, so both
|
||||
// are planned before either runs. A stencil the built-in masker folds in
|
||||
// is not a pass: planned and dropped afterwards, it doubled the total.
|
||||
const bool folds = job.mask_enable && !job.force_external_masking &&
|
||||
backends().builtin_masking;
|
||||
const bool folds = job.mask_enable && backends().builtin_masking;
|
||||
std::vector<int64_t> stencil_planned(job.inputs.size(), 0);
|
||||
for (size_t i = 0; i < job.inputs.size(); i++) {
|
||||
if (folds && !per[i].have_masks && !job.inputs[i].stencil.empty())
|
||||
@@ -1369,8 +1356,7 @@ bool DatasetPrep::run(const PrepJob& job_in, PrepResult& out, std::string& error
|
||||
// input arrived with. Segmenting over those would replace an
|
||||
// answer the user already has.
|
||||
if (per[i].have_masks) continue;
|
||||
if (!generate_masks(job, job.inputs[i], per[i].images,
|
||||
per[i].images_rel, per[i].masks, per[i].masks_rel,
|
||||
if (!generate_masks(job, job.inputs[i], per[i].images, per[i].masks,
|
||||
per[i].stencil_folded, error))
|
||||
return false;
|
||||
_masks_tally.settle(mask_planned[i], mask_planned[i]);
|
||||
@@ -2523,32 +2509,17 @@ bool DatasetPrep::split_packed_frames(const PrepInput& in,
|
||||
|
||||
bool DatasetPrep::generate_masks(const PrepJob& job, const PrepInput& in,
|
||||
const std::string& images,
|
||||
const std::string& images_rel,
|
||||
const std::string& masks,
|
||||
const std::string& masks_rel,
|
||||
bool& folded, std::string& error) {
|
||||
if (!job.force_external_masking && backends().builtin_masking) {
|
||||
// A missing checkpoint is a download, not an install. Falling through
|
||||
// to the Python masker answered "the model is not here" with "pip
|
||||
// install lang-segment-anything", which is advice for another problem.
|
||||
if (!backends().builtin_masking) {
|
||||
error = lmsg::err_no_builtin_segmentation.get();
|
||||
return false;
|
||||
}
|
||||
if (job.mask_model_path.empty()) {
|
||||
error = lmsg::err_mask_model_not_downloaded.get();
|
||||
return false;
|
||||
}
|
||||
return generate_masks_builtin(job, in, images, masks, folded, error);
|
||||
}
|
||||
if (job.mask_model_name.empty()) {
|
||||
error = lmsg::err_subject_needs_builtin.get();
|
||||
return false;
|
||||
}
|
||||
if (!job.mask_clicks.empty()) {
|
||||
// The Python fallback is lang-segment-anything: text in, masks out. It
|
||||
// has no way to take a click, so saying so beats writing masks that
|
||||
// quietly ignore half of what the user asked for.
|
||||
error = lmsg::err_clicks_need_builtin.get();
|
||||
return false;
|
||||
}
|
||||
return generate_masks_python(job, images_rel, masks_rel, error);
|
||||
}
|
||||
|
||||
bool DatasetPrep::generate_masks_builtin(const PrepJob& job, const PrepInput& in,
|
||||
@@ -2625,7 +2596,7 @@ bool DatasetPrep::generate_masks_builtin(const PrepJob& job, const PrepInput& in
|
||||
[&](const std::string& camera, const app::BorderDetect& d) {
|
||||
const std::string name =
|
||||
camera.empty() ? in.path : camera;
|
||||
if (!in.stencil.detect_border) return;
|
||||
if (!in.stencil.for_camera(camera).detect_border) return;
|
||||
if (!d.found) {
|
||||
log(fmt(lmsg::frame_mask_no_border, {name}), false);
|
||||
return;
|
||||
@@ -2764,7 +2735,7 @@ bool DatasetPrep::apply_stencil(const PrepJob& job, const PrepInput& in,
|
||||
};
|
||||
sinks.resolved = [&](const std::string& camera, const app::FrameMask&,
|
||||
const app::BorderDetect& d) {
|
||||
if (!in.stencil.detect_border) return;
|
||||
if (!in.stencil.for_camera(camera).detect_border) return;
|
||||
const std::string name = camera.empty() ? in.path : camera;
|
||||
if (!d.found) {
|
||||
log(fmt(lmsg::frame_mask_no_border, {name}), /*detail=*/false);
|
||||
@@ -2784,74 +2755,4 @@ bool DatasetPrep::apply_stencil(const PrepJob& job, const PrepInput& in,
|
||||
return true;
|
||||
}
|
||||
|
||||
// The Python fallback: the embedded reference/scripts/mask.py run through an
|
||||
// external interpreter with lang-segment-anything. It prints an install hint
|
||||
// and exits 0 when the packages are missing, so that is detected from its
|
||||
// output rather than its exit code.
|
||||
bool DatasetPrep::generate_masks_python(const PrepJob& job,
|
||||
const std::string& images_rel,
|
||||
const std::string& masks_rel,
|
||||
std::string& error) {
|
||||
enter(Stage::Masks, lmsg::stage_masks_python.get());
|
||||
if (!command_exists(job.python_exe)) {
|
||||
error = fmt(lmsg::err_python_missing, {job.python_exe});
|
||||
return false;
|
||||
}
|
||||
const fs::path ws = job.workspace;
|
||||
const fs::path script = ws / ".spirula_mask.py";
|
||||
{
|
||||
FILE* f = std::fopen(script.string().c_str(), "wb");
|
||||
if (!f) {
|
||||
error = fmt(lmsg::err_cannot_write, {script.string()});
|
||||
return false;
|
||||
}
|
||||
std::fwrite(kMaskPy, 1, kMaskPySize, f);
|
||||
std::fclose(f);
|
||||
}
|
||||
std::vector<std::string> argv = {
|
||||
job.python_exe, script.string(), ws.string(),
|
||||
"--prompt", job.mask_prompt,
|
||||
"--images", images_rel,
|
||||
"--masks", masks_rel,
|
||||
"--max_image_size", std::to_string(job.mask_max_image_size),
|
||||
"--model", job.mask_model_name,
|
||||
};
|
||||
if (!job.mask_negative_prompt.empty()) {
|
||||
argv.push_back("--negative_prompt");
|
||||
argv.push_back(job.mask_negative_prompt);
|
||||
}
|
||||
// Without this the external path silently ignores the polarity and always
|
||||
// removes what the prompt named -- the exact opposite of what an object
|
||||
// capture asked for.
|
||||
if (job.mask_keep_subject) argv.push_back("--keep_prompted");
|
||||
std::string install_hint;
|
||||
std::string cmd;
|
||||
for (const auto& a : argv) cmd += (cmd.empty() ? "$ " : " ") + a;
|
||||
log(cmd);
|
||||
const int rc = run_process(argv, "", [&](const std::string& l) {
|
||||
log(l);
|
||||
if (l.find("not found or not installed properly") != std::string::npos ||
|
||||
l.find("ModuleNotFoundError") != std::string::npos)
|
||||
install_hint = l;
|
||||
}, _cancel);
|
||||
if (rc == kCancelled) { error = lmsg::err_cancelled.get(); return false; }
|
||||
|
||||
std::error_code ec;
|
||||
const bool have_masks = fs::is_directory(ws / masks_rel, ec) &&
|
||||
!fs::is_empty(ws / masks_rel, ec);
|
||||
if (!install_hint.empty() || rc != 0 || !have_masks) {
|
||||
// Three whole sentences rather than one with tails appended: the
|
||||
// install advice is the message when there is any, and which advice
|
||||
// depends on the model.
|
||||
if (install_hint.empty())
|
||||
error = lmsg::err_mask_generation_failed.get();
|
||||
else if (job.mask_model_name == "sam3")
|
||||
error = lmsg::err_mask_missing_packages_sam3.get();
|
||||
else
|
||||
error = lmsg::err_mask_missing_packages.get();
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace gui
|
||||
|
||||
+15
-38
@@ -2,27 +2,15 @@
|
||||
|
||||
// DatasetPrep -- everything between "the user picked a video or a folder of
|
||||
// photos" and "there is an image directory (and maybe a mask directory) ready
|
||||
// for structure-from-motion".
|
||||
// for structure-from-motion": frame extraction and selection, splitting
|
||||
// multi-lens captures, AI masking, and the resume rules. Both dataset paths --
|
||||
// the built-in SfM and the external COLMAP -- run it first.
|
||||
//
|
||||
// It was the half of ColmapRunner that had nothing to do with COLMAP: frame
|
||||
// extraction, sharpest-frame selection, splitting a multi-track 360 file into
|
||||
// one folder per lens, AI masking, and the resume rules that let an
|
||||
// interrupted run reuse what it left behind. Both dataset paths -- the
|
||||
// built-in SfM and the external COLMAP -- run it first, so a change to how
|
||||
// frames are chosen cannot apply to only one of them.
|
||||
//
|
||||
// Each stage has a built-in implementation and an external fallback:
|
||||
//
|
||||
// frames in-process VK_KHR_video_decode_* (SS_ENABLE_PATENTED)
|
||||
// -> ffmpeg subprocess
|
||||
// masks in-process SAM 2 / SAM 3 (SS_BUILD_SAM)
|
||||
// -> python + reference/scripts/mask.py
|
||||
//
|
||||
// The built-in path is the default when it is compiled in and the device
|
||||
// supports it; the fallback is picked automatically otherwise, and can be
|
||||
// forced (a codec the driver cannot decode, an HDR transfer ffmpeg handles
|
||||
// better). `Backends` reports what this build and this machine can actually
|
||||
// do, so the GUI can say so instead of failing at run time.
|
||||
// Frames decode in-process (SS_ENABLE_PATENTED) or through ffmpeg, which can
|
||||
// be forced for a codec the driver cannot decode or an HDR transfer ffmpeg
|
||||
// handles better. Masks come from SAM 2 / SAM 3 in-process (SS_BUILD_SAM);
|
||||
// without it only the fixed-area stencil applies. `Backends` says which, so
|
||||
// the GUI can say so instead of failing at run time.
|
||||
|
||||
#include "app/FfmpegVideo.h"
|
||||
#include "app/FrameLook.h"
|
||||
@@ -249,7 +237,7 @@ struct PrepJob {
|
||||
|
||||
// The device request for built-in decoding and masking: "auto", an ordinal,
|
||||
// a name substring or "uuid:<32 hex>". Frozen at the top of run(); a bad
|
||||
// value fails the run. External Python masking does not read this.
|
||||
// value fails the run.
|
||||
std::string device;
|
||||
|
||||
// ---- video extraction ----
|
||||
@@ -311,15 +299,11 @@ struct PrepJob {
|
||||
// its own, and run() refuses the job rather than half-mask the capture.
|
||||
std::vector<MaskClick> mask_clicks;
|
||||
|
||||
// Built-in: checkpoint files (ModelCache resolves them), with the text
|
||||
// detector when one is paired. External: the name reference/scripts/mask.py understands,
|
||||
// "" when it has none (BiRefNet).
|
||||
// Checkpoint files (ModelCache resolves them), with the text detector when
|
||||
// one is paired.
|
||||
std::string mask_model_path;
|
||||
std::string mask_detector_path;
|
||||
float mask_detector_threshold = 0.3f; // sam::MaskOptions::detector_threshold
|
||||
std::string mask_model_name = "sam2.1_hiera_large";
|
||||
bool force_external_masking = false;
|
||||
std::string python_exe = "python3";
|
||||
};
|
||||
|
||||
// The rate a row is actually extracted at (0 = every frame). A row's 0 means
|
||||
@@ -452,12 +436,9 @@ inline ReconStamp frames_stamp(const PrepJob& job) {
|
||||
return st;
|
||||
}
|
||||
|
||||
// What this build, on this machine, can do without an external tool.
|
||||
//
|
||||
// Two strings per stage on purpose: `*_reason` names the option or the missing
|
||||
// device feature and belongs in a log or a tooltip; `*_note` is the sentence
|
||||
// shown on the screen, which should tell a user what will happen rather than
|
||||
// which CMake flag was off when the binary was made.
|
||||
// What this build can do in-process. `*_reason` names the missing option or
|
||||
// device feature, for a log or a tooltip; the screen says what happens instead
|
||||
// (`video_note`, dataset::mask_objects_need_segmentation).
|
||||
struct Backends {
|
||||
// Build-level answers only. The runtime answers (can THIS device decode?)
|
||||
// are not here: probing them creates the inference context, which must wait
|
||||
@@ -467,7 +448,6 @@ struct Backends {
|
||||
std::string video_note;
|
||||
bool builtin_masking = false;
|
||||
std::string masking_reason;
|
||||
std::string masking_note;
|
||||
};
|
||||
// What this binary was built with. Creates no device, so it is safe on the UI
|
||||
// thread and before a GPU choice exists.
|
||||
@@ -732,14 +712,11 @@ private:
|
||||
// the masks as they were produced, which is what lets apply_stencil be
|
||||
// skipped -- it would otherwise decode and re-encode every mask again.
|
||||
bool generate_masks(const PrepJob& job, const PrepInput& in,
|
||||
const std::string& images, const std::string& images_rel,
|
||||
const std::string& masks, const std::string& masks_rel,
|
||||
const std::string& images, const std::string& masks,
|
||||
bool& folded, std::string& error);
|
||||
bool generate_masks_builtin(const PrepJob& job, const PrepInput& in,
|
||||
const std::string& images, const std::string& masks,
|
||||
bool& folded, std::string& error);
|
||||
bool generate_masks_python(const PrepJob& job, const std::string& images_rel,
|
||||
const std::string& masks_rel, std::string& error);
|
||||
// The static stencil on its own, for the masks segmentation did not make.
|
||||
// `merge_from` names the masks it folds in when they are not the ones it
|
||||
// writes -- the tree the photos arrived with; "" is `masks` itself.
|
||||
|
||||
@@ -58,7 +58,6 @@ namespace {
|
||||
X("mask_memory", sfm.prep.mask_memory) \
|
||||
X("mask_detect_every", sfm.prep.mask_detect_every) \
|
||||
X("mask_memory_frames", sfm.prep.mask_memory_frames) \
|
||||
X("force_external_masking", sfm.prep.force_external_masking) \
|
||||
/* ---- depth and normals ---- */ \
|
||||
X("geometry_enable", sfm.geometry.enable) \
|
||||
X("geometry_model", sfm.geometry.model) \
|
||||
|
||||
+23
-34
@@ -380,7 +380,6 @@ void GuiApp::load_settings() {
|
||||
_model_recents.push_back(v);
|
||||
else if (k == "colmap_exe" && !v.empty()) _colmap_exe = v;
|
||||
else if (k == "ffmpeg_exe" && !v.empty()) _ffmpeg_exe = v;
|
||||
else if (k == "python_exe" && !v.empty()) _python_exe = v;
|
||||
else if (k == "sfm_engine") _engine = v == "colmap" ? Engine::Colmap
|
||||
: Engine::BuiltIn;
|
||||
else if (k == "batch_command") _batch_cmd = unescape_setting(v);
|
||||
@@ -432,7 +431,6 @@ void GuiApp::save_settings() {
|
||||
std::fprintf(f, "recent_model=%s\n", r.c_str());
|
||||
std::fprintf(f, "colmap_exe=%s\n", _colmap_exe.c_str());
|
||||
std::fprintf(f, "ffmpeg_exe=%s\n", _ffmpeg_exe.c_str());
|
||||
std::fprintf(f, "python_exe=%s\n", _python_exe.c_str());
|
||||
std::fprintf(f, "sfm_engine=%s\n",
|
||||
_engine == Engine::Colmap ? "colmap" : "builtin");
|
||||
std::fprintf(f, "batch_command=%s\n", escape_setting(_batch_cmd).c_str());
|
||||
@@ -676,7 +674,6 @@ void GuiApp::write_run_settings(std::ofstream& f) {
|
||||
line("ba_cpu", cfg_str(j.ba_cpu));
|
||||
line("subprocess", cfg_str(j.subprocess));
|
||||
line("force_external_decode", cfg_str(j.prep.force_external_decode));
|
||||
line("force_external_masking", cfg_str(j.prep.force_external_masking));
|
||||
line("video_fps", cfg_str(j.prep.video_fps));
|
||||
line("adaptive_fps", cfg_str(j.prep.adaptive_fps));
|
||||
if (j.prep.adaptive_fps) line("adaptive_range", cfg_str(j.prep.adaptive_range));
|
||||
@@ -939,7 +936,7 @@ void GuiApp::apply_dataset_settings(const DatasetSettings& in) {
|
||||
_resume = s.sfm.prep.resume;
|
||||
_photo_import = s.sfm.prep.photo_import;
|
||||
_flip_found_masks = s.sfm.prep.flip_found_masks;
|
||||
_mask_enable = s.sfm.prep.mask_enable;
|
||||
_mask_enable = s.sfm.prep.mask_enable && backends().builtin_masking;
|
||||
_mask_memory = s.sfm.prep.mask_memory;
|
||||
_mask_detect_every = s.sfm.prep.mask_detect_every;
|
||||
_mask_memory_frames = s.sfm.prep.mask_memory_frames;
|
||||
@@ -2382,8 +2379,8 @@ void GuiApp::add_existing_dataset(const std::string& dir) {
|
||||
// What this run draws on its inputs, kept in the output folder for next time.
|
||||
void GuiApp::save_run_stencils() {
|
||||
if (!_border_enable || _workspace.empty()) return;
|
||||
std::vector<std::pair<std::string, std::vector<app::MaskShape>>> inputs;
|
||||
for (const PrepInput& in : _sources) inputs.push_back({in.path, in.stencil.mask.shapes});
|
||||
std::vector<std::pair<std::string, app::MaskSet>> inputs;
|
||||
for (const PrepInput& in : _sources) inputs.push_back({in.path, app::mask_set_of(in.stencil)});
|
||||
std::string err;
|
||||
const std::vector<std::string> written = save_dataset_stencils(_workspace, inputs, err);
|
||||
if (!err.empty()) log(i18n::format(dmsg::stencil_save_failed, {err}));
|
||||
@@ -2392,15 +2389,15 @@ void GuiApp::save_run_stencils() {
|
||||
|
||||
void GuiApp::apply_frame_shapes(size_t first_input) {
|
||||
if (_frame_shapes.empty()) return;
|
||||
std::vector<app::MaskShape> shapes;
|
||||
app::MaskSet set;
|
||||
std::string err;
|
||||
if (!load_stencil_preset(_frame_shapes, shapes, err)) {
|
||||
if (!load_stencil_preset(_frame_shapes, set, err)) {
|
||||
log(i18n::format(dmsg::stencil_load_failed, {err}));
|
||||
_frame_shapes.clear();
|
||||
return;
|
||||
}
|
||||
for (size_t i = first_input; i < _sources.size(); i++)
|
||||
_sources[i].stencil.mask.shapes = shapes;
|
||||
app::apply_mask_set(_sources[i].stencil, set);
|
||||
}
|
||||
|
||||
// A folder of EXRs declares its own colour space, and the picker for it is
|
||||
@@ -2743,7 +2740,9 @@ std::string GuiApp::state_json() {
|
||||
out += kDownload[(int)_download.state()];
|
||||
out += "\",\"license_prompt\":" + quoted(_license_prompt);
|
||||
// Index order is the declaration order of mask::CanvasMode.
|
||||
static const char* kCanvasModes[] = {"shape", "eraser", "path", "sam"};
|
||||
static const char* kCanvasModes[] = {"shape", "eraser", "path", "pen", "sam"};
|
||||
static_assert(sizeof(kCanvasModes) / sizeof(kCanvasModes[0]) == (size_t)mask::CanvasMode::Sam + 1,
|
||||
"one name per mask::CanvasMode");
|
||||
const mask::MaskDoc* mdoc = _mask_editor.doc();
|
||||
out += ",\"mask_editor_mode\":\"";
|
||||
out += kCanvasModes[(int)_mask_editor.mode()];
|
||||
@@ -2779,6 +2778,7 @@ std::string GuiApp::state_json() {
|
||||
std::to_string(_mask_editor.sam_click_y()) + "]";
|
||||
out += ",\"mask_editor_canvas_h\":" + std::to_string(_mask_editor.canvas_height());
|
||||
out += ",\"mask_editor_anchors\":" + std::to_string(_mask_editor.path_anchors());
|
||||
out += ",\"mask_editor_pen_anchors\":" + std::to_string(_mask_editor.pen_anchors());
|
||||
out += ",\"sam_margin\":" + std::to_string(_mask_editor.sam_margin());
|
||||
out += ",\"mask_dilate_ratio\":" + std::to_string(_mask.dilate_ratio);
|
||||
// The dataset screen's clicked objects, which no editor click may reach,
|
||||
@@ -3354,8 +3354,7 @@ void GuiApp::request_model_download(const std::string& id, const std::string& de
|
||||
// download, not something the run can do anything about, so it is asked before
|
||||
// starting rather than reported twenty minutes in.
|
||||
bool GuiApp::mask_model_missing() const {
|
||||
if (!_mask_enable || _sfm_job.prep.force_external_masking) return false;
|
||||
if (!backends().builtin_masking) return false;
|
||||
if (!_mask_enable) return false;
|
||||
// Inputs that arrived with their own masks are never segmented, so a job
|
||||
// made only of those needs no model at all.
|
||||
bool all_bring_masks = !_sources.empty();
|
||||
@@ -3399,7 +3398,6 @@ void GuiApp::sync_dataset_jobs() {
|
||||
prep.force_external_decode = _sfm_job.prep.force_external_decode;
|
||||
prep.sync_tracks = _sfm_job.prep.sync_tracks;
|
||||
prep.ffmpeg_exe = _ffmpeg_exe;
|
||||
prep.python_exe = _python_exe;
|
||||
prep.mask_enable = _mask_enable;
|
||||
prep.flip_found_masks = _use_found_masks && _flip_found_masks;
|
||||
prep.photo_import = _photo_import;
|
||||
@@ -3419,9 +3417,6 @@ void GuiApp::sync_dataset_jobs() {
|
||||
prep.mask_model_path = mask_model.model;
|
||||
prep.mask_detector_path = mask_model.detector;
|
||||
prep.mask_detector_threshold = _mask.box_threshold;
|
||||
prep.force_external_masking = _sfm_job.prep.force_external_masking;
|
||||
if (const ModelEntry* e = find_model(_model_id))
|
||||
prep.mask_model_name = e->legacy_name;
|
||||
// The one frozen choice, re-applied here because this function rebuilds
|
||||
// prep from panel state and would otherwise drop it. Empty before the
|
||||
// freeze, which is exactly what an unstarted job wants.
|
||||
@@ -3442,10 +3437,8 @@ void GuiApp::sync_dataset_jobs() {
|
||||
_colmap_job.max_frames = prep.max_frames;
|
||||
_colmap_job.force_external_decode = prep.force_external_decode;
|
||||
_colmap_job.photo_import = prep.photo_import;
|
||||
_colmap_job.force_external_masking = prep.force_external_masking;
|
||||
_colmap_job.colmap_exe = _colmap_exe;
|
||||
_colmap_job.ffmpeg_exe = _ffmpeg_exe;
|
||||
_colmap_job.python_exe = _python_exe;
|
||||
_colmap_job.mask_enable = prep.mask_enable;
|
||||
_colmap_job.mask_prompt = prep.mask_prompt;
|
||||
_colmap_job.mask_negative_prompt = prep.mask_negative_prompt;
|
||||
@@ -3461,7 +3454,6 @@ void GuiApp::sync_dataset_jobs() {
|
||||
_colmap_job.mask_model_path = prep.mask_model_path;
|
||||
_colmap_job.mask_detector_path = prep.mask_detector_path;
|
||||
_colmap_job.mask_detector_threshold = prep.mask_detector_threshold;
|
||||
_colmap_job.mask_model = prep.mask_model_name;
|
||||
|
||||
_sfm_job.prep.redo_frames = _colmap_job.redo_frames = _redo_frames;
|
||||
_sfm_job.prep.redo_masks = _colmap_job.redo_masks = _redo_masks;
|
||||
@@ -4758,17 +4750,25 @@ void GuiApp::draw_masking_options() {
|
||||
{with_masks, (int)_sources.size()});
|
||||
}
|
||||
|
||||
ImGui::BeginDisabled(!backends().builtin_masking);
|
||||
ui::Checkbox(dmsg::mask_enable, &_mask_enable);
|
||||
ImGui::EndDisabled();
|
||||
if (backends().builtin_masking) {
|
||||
ui::help_on_hover(dmsg::mask_enable_help);
|
||||
} else {
|
||||
ui::TextDisabledWrapped(dmsg::mask_objects_need_segmentation);
|
||||
ui::help_on_hover_raw(backends().masking_reason.c_str());
|
||||
}
|
||||
|
||||
// A sibling, not a child: the stencil is geometry, so it works with no
|
||||
// model downloaded and on a build with no segmentation in it at all.
|
||||
if (ui::Checkbox(dmsg::mask_border_enable, &_border_enable) && _border_enable) {
|
||||
// Ticking it has to do something on its own. The fisheye border is
|
||||
// what it is for, so an input with nothing drawn on it yet gets the
|
||||
// fit switched on; one that has been edited is left alone.
|
||||
// fit, unless it is a GoPro's sphere, whose views have no border.
|
||||
for (PrepInput& in : _sources)
|
||||
if (in.stencil.empty()) in.stencil.detect_border = true;
|
||||
if (in.stencil.empty() && !in.pano360.valid() && !is_pano360_path(in.path))
|
||||
in.stencil.detect_border = true;
|
||||
}
|
||||
ui::help_on_hover(dmsg::mask_border_enable_help);
|
||||
if (_border_enable) {
|
||||
@@ -4781,7 +4781,7 @@ void GuiApp::draw_masking_options() {
|
||||
if (ui::Selectable(dmsg::stencil_areas_per_input, _frame_shapes.empty()))
|
||||
_frame_shapes.clear();
|
||||
for (const StencilPreset& p : _frame_shapes_list)
|
||||
if (ui::SelectableRaw(p.name, p.name == _frame_shapes)) {
|
||||
if (ui::SelectableRaw(stencil_preset_label(p), p.name == _frame_shapes)) {
|
||||
_frame_shapes = p.name;
|
||||
apply_frame_shapes();
|
||||
}
|
||||
@@ -4803,9 +4803,8 @@ void GuiApp::draw_masking_options() {
|
||||
ImGui::Indent();
|
||||
|
||||
const ModelEntry* entry = find_model(_model_id);
|
||||
const bool builtin_masking = backends().builtin_masking;
|
||||
|
||||
if (_mask_enable && builtin_masking) {
|
||||
if (_mask_enable) {
|
||||
const ModelEntry* before = entry;
|
||||
draw_mask_model_picker(_model_id, &_mask_detector_id, _download, [this] {
|
||||
request_model_download(_model_id, _mask_detector_id);
|
||||
@@ -4842,9 +4841,6 @@ void GuiApp::draw_masking_options() {
|
||||
ui::TextColoredWrapped(kWarn, dmsg::mask_inputs_need_clicks,
|
||||
{unprompted});
|
||||
}
|
||||
} else if (_mask_enable) {
|
||||
ui::TextWrappedRaw(backends().masking_note);
|
||||
ui::help_on_hover_raw(backends().masking_reason.c_str());
|
||||
}
|
||||
|
||||
const bool mask_preview_busy = native_work_busy();
|
||||
@@ -6145,10 +6141,6 @@ void GuiApp::draw_sfm_advanced() {
|
||||
ImGui::EndDisabled();
|
||||
ui::help_on_hover(backends().builtin_video ? dmsg::use_ffmpeg_help
|
||||
: dmsg::use_ffmpeg_always);
|
||||
ImGui::BeginDisabled(!backends().builtin_masking);
|
||||
ui::Checkbox(dmsg::use_python_masking, &_sfm_job.prep.force_external_masking);
|
||||
ImGui::EndDisabled();
|
||||
ui::help_on_hover(dmsg::use_python_masking_help);
|
||||
|
||||
ui::Checkbox(dmsg::sfm_ba_cpu, &_sfm_job.ba_cpu);
|
||||
ui::help_on_hover(dmsg::sfm_ba_cpu_help);
|
||||
@@ -6303,9 +6295,6 @@ void GuiApp::draw_tool_locations() {
|
||||
ui::help_on_hover(backends().builtin_video
|
||||
? dmsg::ffmpeg_executable_help_fallback
|
||||
: dmsg::ffmpeg_executable_help_always);
|
||||
ImGui::SetNextItemWidth(px(300.0f));
|
||||
ch |= ui::InputText(dmsg::python_executable, &_python_exe);
|
||||
ui::help_on_hover(dmsg::python_executable_help);
|
||||
if (ffmpeg_changed) mark_source_metadata_dirty();
|
||||
if (ch) save_settings();
|
||||
}
|
||||
|
||||
@@ -906,11 +906,6 @@ private:
|
||||
std::map<std::string, std::string> _dialog_dirs;
|
||||
std::string _colmap_exe = "colmap";
|
||||
std::string _ffmpeg_exe = "ffmpeg";
|
||||
#ifdef _WIN32
|
||||
std::string _python_exe = "python";
|
||||
#else
|
||||
std::string _python_exe = "python3";
|
||||
#endif
|
||||
|
||||
// Log console. `_log_dropped` counts the lines trimmed off the front since
|
||||
// the panel was last drawn: every one of them moves the remaining text up
|
||||
|
||||
@@ -67,36 +67,33 @@ const std::vector<ModelEntry>& model_catalog() {
|
||||
static const std::vector<ModelEntry> kCatalog = {
|
||||
{"sam3-q4_0", "sam3-q4_0.ggml",
|
||||
&dmsg::model_sam3_label, &dmsg::model_sam3_blurb,
|
||||
"sam3", 707ull << 20, true, "sam3"},
|
||||
"sam3", 707ull << 20, true},
|
||||
{"sam3-f16", "sam3-f16.ggml",
|
||||
&dmsg::model_sam3_f16_label, &dmsg::model_sam3_f16_blurb,
|
||||
"sam3", 1884ull << 20, true, "sam3"},
|
||||
"sam3", 1884ull << 20, true},
|
||||
{"sam2.1-large", "sam2.1_hiera_large_f16.ggml",
|
||||
&dmsg::model_sam21_large_label, &dmsg::model_sam21_large_blurb,
|
||||
"sam2", 430ull << 20, false, "sam2.1_hiera_large"},
|
||||
"sam2", 430ull << 20, false},
|
||||
{"sam2.1-base-plus", "sam2.1_hiera_base_plus_f16.ggml",
|
||||
&dmsg::model_sam21_baseplus_label, &dmsg::model_sam21_baseplus_blurb,
|
||||
"sam2", 156ull << 20, false, "sam2.1_hiera_base_plus"},
|
||||
"sam2", 156ull << 20, false},
|
||||
{"sam2.1-small", "sam2.1_hiera_small_f16.ggml",
|
||||
&dmsg::model_sam21_small_label, &dmsg::model_sam21_small_blurb,
|
||||
"sam2", 89ull << 20, false, "sam2.1_hiera_small"},
|
||||
"sam2", 89ull << 20, false},
|
||||
{"sam2.1-tiny", "sam2.1_hiera_tiny_f16.ggml",
|
||||
&dmsg::model_sam21_tiny_label, &dmsg::model_sam21_tiny_blurb,
|
||||
"sam2", 76ull << 20, false, "sam2.1_hiera_tiny"},
|
||||
#ifdef SS_BUILD_SAM
|
||||
// No prompt at all, so nothing the Python fallback can run.
|
||||
"sam2", 76ull << 20, false},
|
||||
{"birefnet", "birefnet-general.safetensors",
|
||||
&dmsg::model_birefnet_label, &dmsg::model_birefnet_blurb,
|
||||
"birefnet", 444473596ull, false, "", MaskModelKind::Subject,
|
||||
"birefnet", 444473596ull, false, MaskModelKind::Subject,
|
||||
"https://huggingface.co/ZhengPeng7/BiRefNet/resolve/main/model.safetensors",
|
||||
"https://modelscope.cn/models/modelscope/BiRefNet/resolve/master/model.safetensors"},
|
||||
{"birefnet-lite", "birefnet-lite.safetensors",
|
||||
&dmsg::model_birefnet_lite_label, &dmsg::model_birefnet_lite_blurb,
|
||||
"birefnet", 177634392ull, false, "", MaskModelKind::Subject,
|
||||
"birefnet", 177634392ull, false, MaskModelKind::Subject,
|
||||
"https://huggingface.co/ZhengPeng7/BiRefNet_lite/resolve/main/model.safetensors",
|
||||
"https://modelscope.cn/models/1038lab/BiRefNet/resolve/master/"
|
||||
"BiRefNet_lite.safetensors"},
|
||||
#endif
|
||||
};
|
||||
return kCatalog;
|
||||
}
|
||||
|
||||
@@ -55,11 +55,6 @@ struct ModelEntry {
|
||||
const char* family; // "sam3" | "sam2" -- the licence unit
|
||||
uint64_t bytes; // expected download size, for the prompt
|
||||
bool text_prompts;// false = clicks/boxes only (SAM 2 has no text tower)
|
||||
// What `reference/scripts/mask.py` calls this checkpoint. Only the Python
|
||||
// fallback path uses it -- the built-in masker is handed a file -- but it
|
||||
// has to name the SAME model the user picked, or a run that falls back
|
||||
// quietly changes which network produced the masks.
|
||||
const char* legacy_name; // "" where the Python fallback has no equivalent
|
||||
MaskModelKind kind = MaskModelKind::Sam;
|
||||
const char* url = nullptr; // null: sam3.cpp's repository + `file`
|
||||
const char* mirror = nullptr; // null: the project's mirror, under `file`
|
||||
|
||||
+411
-55
@@ -22,6 +22,7 @@
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <set>
|
||||
|
||||
#ifdef SS_BUILD_SAM
|
||||
#include "nn/io/Image.h"
|
||||
@@ -89,6 +90,9 @@ void SegmentPanel::open(const PreviewSource& src, const MaskModelFiles& model) {
|
||||
_history.clear();
|
||||
_saved_msg.clear();
|
||||
_path.cancel();
|
||||
_pen.cancel();
|
||||
_pt_part = PenPart::None;
|
||||
_pt_anchor = -1;
|
||||
_path.set_livewire(nullptr);
|
||||
_livewire.reset();
|
||||
_livewire_key.clear();
|
||||
@@ -98,6 +102,7 @@ void SegmentPanel::open(const PreviewSource& src, const MaskModelFiles& model) {
|
||||
_frames.clear();
|
||||
_all_files.clear();
|
||||
_folders.clear();
|
||||
_cameras.clear();
|
||||
_frames_pending.clear();
|
||||
_all_files_pending.clear();
|
||||
_folders_pending.clear();
|
||||
@@ -157,6 +162,8 @@ void SegmentPanel::close() {
|
||||
_draw = DrawTool::Select;
|
||||
_tool.cancel();
|
||||
_path.cancel();
|
||||
_pen.cancel();
|
||||
_pt_part = PenPart::None;
|
||||
_path.set_livewire(nullptr);
|
||||
_livewire.reset();
|
||||
_livewire_key.clear();
|
||||
@@ -165,6 +172,7 @@ void SegmentPanel::close() {
|
||||
_frames.clear();
|
||||
_all_files.clear();
|
||||
_folders.clear();
|
||||
_cameras.clear();
|
||||
_frames_pending.clear();
|
||||
_all_files_pending.clear();
|
||||
_folders_pending.clear();
|
||||
@@ -207,9 +215,10 @@ void SegmentPanel::destroy_gl() {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
app::FrameMask SegmentPanel::resolved(const app::FrameStencil& s) const {
|
||||
app::FrameMask fm = s.mask;
|
||||
if (s.detect_border && _border.found) {
|
||||
fm.shapes.insert(fm.shapes.begin(), app::shrink_border(_border.shape, s.shrink));
|
||||
const app::CameraStencil& cs = s.for_camera(shown_camera());
|
||||
app::FrameMask fm = cs.mask;
|
||||
if (cs.detect_border && _border.found) {
|
||||
fm.shapes.insert(fm.shapes.begin(), app::shrink_border(_border.shape, cs.shrink));
|
||||
}
|
||||
return fm;
|
||||
}
|
||||
@@ -220,13 +229,64 @@ std::string SegmentPanel::camera_of(const std::string& file) const {
|
||||
.parent_path().generic_string();
|
||||
}
|
||||
|
||||
std::string SegmentPanel::file_camera(const std::string& file) const {
|
||||
const std::string sub = camera_of(file);
|
||||
if (_src.is_video || _src.look.packed_lenses < 2 || _folder_idx < 0 ||
|
||||
_folder_idx >= (int)_folders.size())
|
||||
return sub;
|
||||
const std::string& lens = _folders[(size_t)_folder_idx];
|
||||
return sub.empty() ? lens : lens + "/" + sub;
|
||||
}
|
||||
|
||||
std::string SegmentPanel::shown_camera() const {
|
||||
if (_src.is_video)
|
||||
return _folder_idx >= 0 && _folder_idx < (int)_folders.size()
|
||||
? _folders[(size_t)_folder_idx]
|
||||
: std::string();
|
||||
return _frames.empty() ? std::string()
|
||||
: camera_of(_frames[(size_t)_frame_idx].path);
|
||||
: file_camera(_frames[(size_t)_frame_idx].path);
|
||||
}
|
||||
|
||||
std::vector<std::string> SegmentPanel::camera_names() const {
|
||||
std::set<std::string> out;
|
||||
if (_src.is_video) {
|
||||
out.insert(_folders.begin(), _folders.end());
|
||||
} else {
|
||||
for (const std::string& f : _all_files) {
|
||||
const std::string sub = camera_of(f);
|
||||
if (_src.look.packed_lenses < 2) out.insert(sub);
|
||||
else
|
||||
for (const std::string& lens : _folders)
|
||||
out.insert(sub.empty() ? lens : lens + "/" + sub);
|
||||
}
|
||||
}
|
||||
return {out.begin(), out.end()};
|
||||
}
|
||||
|
||||
app::CameraStencil& SegmentPanel::scope(app::FrameStencil& s) {
|
||||
if (!s.per_camera()) return s;
|
||||
const std::string cam = shown_camera();
|
||||
auto it = s.cameras.find(cam);
|
||||
if (it == s.cameras.end())
|
||||
it = s.cameras.emplace(cam, static_cast<const app::CameraStencil&>(s)).first;
|
||||
return it->second;
|
||||
}
|
||||
|
||||
// On: every camera starts as a copy of the input's, so nothing changes until
|
||||
// one is drawn on. Off: the shown camera's becomes the input's.
|
||||
void SegmentPanel::set_per_camera(app::FrameStencil& s, bool on) {
|
||||
if (on) {
|
||||
for (const std::string& cam : _cameras)
|
||||
s.cameras.emplace(cam, static_cast<const app::CameraStencil&>(s));
|
||||
} else if (s.per_camera()) {
|
||||
const app::CameraStencil shown = s.for_camera(shown_camera());
|
||||
s.cameras.clear();
|
||||
static_cast<app::CameraStencil&>(s) = shown;
|
||||
}
|
||||
_shape_sel = -1;
|
||||
_drag_handle = -1;
|
||||
_pt_part = PenPart::None;
|
||||
_stencil_key.clear();
|
||||
}
|
||||
|
||||
void SegmentPanel::start_detect() {
|
||||
@@ -241,7 +301,7 @@ void SegmentPanel::start_detect() {
|
||||
_border_camera = shown_camera();
|
||||
std::vector<std::string> files;
|
||||
for (const std::string& f : _all_files)
|
||||
if (camera_of(f) == _border_camera) files.push_back(f);
|
||||
if (file_camera(f) == _border_camera) files.push_back(f);
|
||||
// The shrink is applied when the shape is used, so the slider costs no
|
||||
// second fit.
|
||||
app::BorderDetectOptions o;
|
||||
@@ -658,10 +718,11 @@ void SegmentPanel::draw_image(MaskSettings& settings, app::FrameStencil& stencil
|
||||
_frames.empty() ? PreviewFrame{} : _frames[(size_t)_frame_idx];
|
||||
const bool hovered = ImGui::IsItemHovered();
|
||||
const ImGuiIO& io = ImGui::GetIO();
|
||||
std::vector<app::MaskShape>& shapes = stencil.mask.shapes;
|
||||
std::vector<app::MaskShape>& shapes = scope(stencil).mask.shapes;
|
||||
if (_shape_sel >= (int)shapes.size()) _shape_sel = -1;
|
||||
|
||||
// The edge map belongs to one frame; a frame change drops it, and the
|
||||
// armed pen tool asks for this frame's.
|
||||
// armed livewire path asks for this frame's.
|
||||
const std::string frame_key = shown_frame_key();
|
||||
if (_livewire && _livewire_key != frame_key) {
|
||||
_livewire.reset();
|
||||
@@ -670,23 +731,41 @@ void SegmentPanel::draw_image(MaskSettings& settings, app::FrameStencil& stencil
|
||||
_path.cancel();
|
||||
}
|
||||
const bool path_mode = _draw == DrawTool::Path;
|
||||
const bool pen_mode = _draw == DrawTool::Pen;
|
||||
if (path_mode && !_livewire && !_livewiring.load()) start_livewire();
|
||||
|
||||
// Select: the selected shape owns the mouse over its handles and body, and
|
||||
// every other click prompts the model. An unselected shape (the fisheye
|
||||
// circle, usually) never eats a click.
|
||||
// The selected shape owns the mouse over its handles and body (Points: a pen
|
||||
// shape's anchors and handles too); under Select every other click prompts
|
||||
// the model, so an unselected shape (the fisheye circle, usually) eats none.
|
||||
const ImVec2 mouse = ImGui::GetMousePos();
|
||||
const float mu = (mouse.x - origin.x) / size.x;
|
||||
const float mv = (mouse.y - origin.y) / size.y;
|
||||
app::MaskShape* sel = _shape_sel >= 0 ? &shapes[(size_t)_shape_sel] : nullptr;
|
||||
bool pen_sel = sel && sel->kind == app::MaskShape::Kind::Bezier;
|
||||
// Whatever changed the list under a point drag (undo, the list) ended it.
|
||||
if (!pen_sel || _pt_anchor >= (int)(sel->pts.size() / 6)) {
|
||||
_pt_part = PenPart::None;
|
||||
_pt_pull = false;
|
||||
_pt_anchor = -1;
|
||||
}
|
||||
const bool selecting = _draw == DrawTool::Select || _draw == DrawTool::Points;
|
||||
// Ctrl is the vector editors' "direct selection for now" under the pen.
|
||||
const bool points_now =
|
||||
pen_sel && (_draw == DrawTool::Points ||
|
||||
(pen_mode && !_pen.in_progress() && (io.KeyCtrl || _pt_part != PenPart::None)));
|
||||
bool on_shape = false;
|
||||
if (_draw == DrawTool::Select && _shape_sel >= 0 && _shape_sel < (int)shapes.size()) {
|
||||
app::MaskShape& s = shapes[(size_t)_shape_sel];
|
||||
float hu[3], hv[3];
|
||||
const int n = stencil_handles(s, hu, hv);
|
||||
auto grab = [&](int handle) {
|
||||
_drag_handle = handle;
|
||||
_drag_before = shapes;
|
||||
_drag_before = stencil;
|
||||
_drag_before_key = app::format_mask_shapes(shapes);
|
||||
};
|
||||
if (points_now) on_shape = edit_points(stencil, *sel, mu, mv, size.x, size.y, hovered, edited);
|
||||
else if (pen_mode && pen_sel && !_pen.in_progress())
|
||||
on_shape = pen_on_shape(stencil, *sel, mu, mv, size.x, size.y, hovered, edited);
|
||||
if (selecting && sel && _pt_part == PenPart::None) {
|
||||
app::MaskShape& s = *sel;
|
||||
float hu[3], hv[3];
|
||||
const int n = stencil_handles(s, hu, hv);
|
||||
for (int i = 0; i < n; i++) {
|
||||
const ImVec2 p = to_screen(hu[i], hv[i]);
|
||||
// `near` is a macro in the Windows headers; do not name it that.
|
||||
@@ -697,7 +776,7 @@ void SegmentPanel::draw_image(MaskSettings& settings, app::FrameStencil& stencil
|
||||
dl->AddCircle(p, 6.0f, IM_COL32(30, 30, 30, 220), 0, 1.5f);
|
||||
if (hit && hovered && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) grab(i);
|
||||
}
|
||||
if (hovered && stencil_contains(s, mu, mv)) {
|
||||
if (hovered && !on_shape && stencil_contains(s, mu, mv)) {
|
||||
on_shape = true;
|
||||
if (_drag_handle == -1 && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
grab(kDragBody);
|
||||
@@ -705,7 +784,25 @@ void SegmentPanel::draw_image(MaskSettings& settings, app::FrameStencil& stencil
|
||||
_drag_from_v = mv;
|
||||
}
|
||||
}
|
||||
if (_drag_handle != -1) {
|
||||
}
|
||||
// Points picks what it clicks, the topmost shape first, and lets go on
|
||||
// empty picture; the press that picks also starts moving it.
|
||||
if (_draw == DrawTool::Points && hovered && !on_shape && _drag_handle == -1 &&
|
||||
_pt_part == PenPart::None && ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
_shape_sel = -1;
|
||||
_pt_anchor = -1;
|
||||
for (int k = (int)shapes.size() - 1; k >= 0; k--)
|
||||
if (stencil_contains(shapes[(size_t)k], mu, mv)) {
|
||||
_shape_sel = k;
|
||||
grab(kDragBody);
|
||||
_drag_from_u = mu;
|
||||
_drag_from_v = mv;
|
||||
on_shape = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (_drag_handle != -1 && _shape_sel >= 0) {
|
||||
app::MaskShape& s = shapes[(size_t)_shape_sel];
|
||||
if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
|
||||
if (_drag_handle == kDragBody) {
|
||||
stencil_move(s, mu - _drag_from_u, mv - _drag_from_v);
|
||||
@@ -718,14 +815,13 @@ void SegmentPanel::draw_image(MaskSettings& settings, app::FrameStencil& stencil
|
||||
} else {
|
||||
_drag_handle = -1;
|
||||
// A click that moved nothing is not a step to undo.
|
||||
if (app::format_mask_shapes(_drag_before) != app::format_mask_shapes(shapes)) {
|
||||
if (_drag_before_key != app::format_mask_shapes(shapes)) {
|
||||
_history.push(_drag_before);
|
||||
_shapes_edited = true;
|
||||
edited = true; // rerun once, on release
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// What any tool finishes becomes one shape at the end of the list.
|
||||
auto add_shape = [&](const app::MaskShape& n) {
|
||||
@@ -753,9 +849,9 @@ void SegmentPanel::draw_image(MaskSettings& settings, app::FrameStencil& stencil
|
||||
const bool enter = focused && (ImGui::IsKeyPressed(ImGuiKey_Enter, false) ||
|
||||
ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false));
|
||||
|
||||
bool tool_consumed = false;
|
||||
if (path_mode) {
|
||||
// Canvas pixels <-> frame pixels, for the two tools that keep frame pixels.
|
||||
mask::PathSpace sp;
|
||||
{
|
||||
const float tw = (float)_tex_w, th = (float)_tex_h;
|
||||
sp.to_frame = [size, tw, th](float x, float y, float& fx, float& fy) {
|
||||
fx = x / size.x * tw;
|
||||
@@ -765,6 +861,9 @@ void SegmentPanel::draw_image(MaskSettings& settings, app::FrameStencil& stencil
|
||||
x = fx / tw * size.x;
|
||||
y = fy / th * size.y;
|
||||
};
|
||||
}
|
||||
bool tool_consumed = false;
|
||||
if (path_mode) {
|
||||
_path.set_space(sp);
|
||||
_path.note_modifiers(io.KeyShift, io.KeyCtrl);
|
||||
std::vector<float> poly;
|
||||
@@ -779,6 +878,22 @@ void SegmentPanel::draw_image(MaskSettings& settings, app::FrameStencil& stencil
|
||||
add_shape(n);
|
||||
}
|
||||
mask::draw_path_overlay(dl, origin, _path);
|
||||
} else if (pen_mode) {
|
||||
_pen.set_space(sp);
|
||||
_pen.note_modifiers(io.KeyShift, io.KeyCtrl);
|
||||
_pen.note_space(ImGui::IsKeyDown(ImGuiKey_Space));
|
||||
std::vector<float> anchors;
|
||||
bool closed = _pen.update(in, anchors, tool_consumed);
|
||||
if (enter && !closed) closed = _pen.commit_pending(anchors);
|
||||
app::MaskShape n;
|
||||
if (closed && stencil_shape_from_pen(anchors, size.x, size.y,
|
||||
stroke_removes(_pen.mode_ctrl()), n)) {
|
||||
add_shape(n);
|
||||
// Kept picked, as a vector editor keeps the path it just drew.
|
||||
_shape_sel = (int)shapes.size() - 1;
|
||||
_pt_anchor = -1;
|
||||
}
|
||||
mask::draw_pen_overlay(dl, origin, _pen);
|
||||
} else if (_draw == DrawTool::Shape || _draw == DrawTool::Eraser) {
|
||||
_tool.set_brush_radius(_brush_pct * 0.01f * std::min(size.x, size.y));
|
||||
ShapeStroke st;
|
||||
@@ -790,20 +905,43 @@ void SegmentPanel::draw_image(MaskSettings& settings, app::FrameStencil& stencil
|
||||
_tool.draw_overlay(dl, origin);
|
||||
}
|
||||
|
||||
// Undo replaces the stencil, and `shapes` with it: it runs after drawing.
|
||||
// A new shape may have moved the list, so the pick is looked up again.
|
||||
int undo_request = 0; // 1 undo, 2 redo
|
||||
sel = _shape_sel >= 0 && _shape_sel < (int)shapes.size() ? &shapes[(size_t)_shape_sel] : nullptr;
|
||||
pen_sel = sel && sel->kind == app::MaskShape::Kind::Bezier;
|
||||
if (focused) {
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
|
||||
if (_path.in_progress()) _path.cancel();
|
||||
if (_pen.in_progress()) _pen.cancel();
|
||||
else if (_path.in_progress()) _path.cancel();
|
||||
else if (_tool.in_progress()) _tool.cancel();
|
||||
else if ((pen_mode || _draw == DrawTool::Points) && _shape_sel >= 0) _shape_sel = -1;
|
||||
else _draw = DrawTool::Select;
|
||||
}
|
||||
if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_Z, false)) {
|
||||
// A half-drawn polygon or path gives back its last point first.
|
||||
if (!io.KeyShift && path_mode && _path.in_progress()) _path.pop_anchor();
|
||||
if (!io.KeyShift && pen_mode && _pen.in_progress()) _pen.pop_anchor();
|
||||
else if (!io.KeyShift && path_mode && _path.in_progress()) _path.pop_anchor();
|
||||
else if (!io.KeyShift && _tool.in_progress() && _tool.id() == ToolId::Polygon)
|
||||
_tool.pop_point();
|
||||
else undo_shapes(stencil, io.KeyShift, edited);
|
||||
else undo_request = io.KeyShift ? 2 : 1;
|
||||
}
|
||||
if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_Y, false)) undo_request = 2;
|
||||
const bool erase_key = ImGui::IsKeyPressed(ImGuiKey_Backspace, false) ||
|
||||
ImGui::IsKeyPressed(ImGuiKey_Delete, false);
|
||||
if (erase_key && pen_mode && _pen.in_progress()) {
|
||||
_pen.pop_anchor();
|
||||
} else if (erase_key && _draw == DrawTool::Points && pen_sel && _pt_anchor >= 0 &&
|
||||
_pt_part == PenPart::None) {
|
||||
const app::FrameStencil before = stencil;
|
||||
if (pen_delete(*sel, _pt_anchor)) {
|
||||
_history.push(before);
|
||||
_stencil_key.clear();
|
||||
_shapes_edited = true;
|
||||
edited = true;
|
||||
_pt_anchor = -1;
|
||||
}
|
||||
}
|
||||
if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_Y, false)) undo_shapes(stencil, true, edited);
|
||||
}
|
||||
|
||||
// Clicks land in source-image pixels, which is what the model wants. Only
|
||||
@@ -830,6 +968,7 @@ void SegmentPanel::draw_image(MaskSettings& settings, app::FrameStencil& stencil
|
||||
ui::SetTooltip(dmsg::click_tooltip, {settings.current_object + 1});
|
||||
|
||||
// Outlines: the exact boundary, over an overlay that is only 512 px wide.
|
||||
std::vector<float> fed;
|
||||
for (size_t i = 0; i < shapes.size(); i++) {
|
||||
const app::MaskShape& s = shapes[i];
|
||||
const ImU32 col = (int)i == _shape_sel ? IM_COL32(255, 255, 255, 235)
|
||||
@@ -855,6 +994,20 @@ void SegmentPanel::draw_image(MaskSettings& settings, app::FrameStencil& stencil
|
||||
case app::MaskShape::Kind::Rect:
|
||||
dl->AddRect(to_screen(s.cx, s.cy), to_screen(s.rx, s.ry), col, 0.0f, 0, thick);
|
||||
break;
|
||||
case app::MaskShape::Kind::Bezier: {
|
||||
fed = s.pts;
|
||||
for (size_t k = 0; k + 1 < fed.size(); k += 2) {
|
||||
fed[k] *= size.x;
|
||||
fed[k + 1] *= size.y;
|
||||
}
|
||||
const size_t n = fed.size() / 6;
|
||||
mask::draw_bezier(dl, origin, fed.data(), n, /*closed=*/true, col, thick);
|
||||
// Its points wherever they can be edited: Points, and the pen.
|
||||
if ((int)i == _shape_sel && (_draw == DrawTool::Points || pen_mode))
|
||||
mask::draw_bezier_points(dl, origin, fed.data(), n, _pt_anchor,
|
||||
/*handles=*/_draw == DrawTool::Points || io.KeyCtrl);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -876,6 +1029,119 @@ void SegmentPanel::draw_image(MaskSettings& settings, app::FrameStencil& stencil
|
||||
IM_COL32(255, 255, 255, 255), 1.5f);
|
||||
}
|
||||
}
|
||||
if (undo_request) undo_shapes(stencil, undo_request == 2, edited);
|
||||
}
|
||||
|
||||
// Anchors and handles of the selected pen shape, all in normalized
|
||||
// coordinates; Shift's 45 degrees are taken in canvas pixels.
|
||||
bool SegmentPanel::edit_points(app::FrameStencil& stencil, app::MaskShape& s, float mu,
|
||||
float mv, float cw, float ch, bool hovered, bool& edited) {
|
||||
const ImGuiIO& io = ImGui::GetIO();
|
||||
if (_pt_part == PenPart::None) {
|
||||
const PenHit h = pen_hit(s, mu, mv, cw, ch, 6.0f, /*handles=*/true);
|
||||
if (!hovered || h.part == PenPart::None || h.part == PenPart::Segment) return false;
|
||||
if (ImGui::IsMouseClicked(ImGuiMouseButton_Left)) {
|
||||
const float* a = &s.pts[6 * (size_t)h.index];
|
||||
const int j = h.part == PenPart::In ? 0 : h.part == PenPart::Out ? 4 : 2;
|
||||
_pt_part = h.part;
|
||||
_pt_anchor = h.index;
|
||||
_pt_from[0] = a[j];
|
||||
_pt_from[1] = a[j + 1];
|
||||
_pt_press[0] = mu;
|
||||
_pt_press[1] = mv;
|
||||
_pt_moved = false;
|
||||
_pt_pull = false;
|
||||
_drag_before = stencil;
|
||||
_drag_before_key = app::format_mask_shapes(scope(stencil).mask.shapes);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (ImGui::IsMouseDown(ImGuiMouseButton_Left)) {
|
||||
const float* a = &s.pts[6 * (size_t)_pt_anchor];
|
||||
float x = (_pt_from[0] + mu - _pt_press[0]) * cw;
|
||||
float y = (_pt_from[1] + mv - _pt_press[1]) * ch;
|
||||
_pt_moved = _pt_moved || std::hypot((mu - _pt_press[0]) * cw,
|
||||
(mv - _pt_press[1]) * ch) >= mask::kPenDragStart;
|
||||
if (io.KeyShift) {
|
||||
const bool handle = _pt_part != PenPart::Anchor;
|
||||
mask::snap_45((handle ? a[2] : _pt_from[0]) * cw, (handle ? a[3] : _pt_from[1]) * ch,
|
||||
x, y);
|
||||
}
|
||||
if (_pt_pull) {
|
||||
// A corner pulled open: symmetric handles, as dragging a new anchor.
|
||||
if (_pt_moved) {
|
||||
float* w = &s.pts[6 * (size_t)_pt_anchor];
|
||||
w[4] = x / cw;
|
||||
w[5] = y / ch;
|
||||
w[0] = 2.0f * w[2] - w[4];
|
||||
w[1] = 2.0f * w[3] - w[5];
|
||||
}
|
||||
} else if (_pt_moved) {
|
||||
pen_move_point(s, _pt_anchor, _pt_part, x / cw, y / ch, cw, ch, io.KeyAlt);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
if (_pt_pull && !_pt_moved) pen_make_corner(s, _pt_anchor);
|
||||
_pt_part = PenPart::None;
|
||||
_pt_pull = false;
|
||||
if (_drag_before_key != app::format_mask_shapes(scope(stencil).mask.shapes)) {
|
||||
_history.push(_drag_before);
|
||||
_stencil_key.clear();
|
||||
_shapes_edited = true;
|
||||
edited = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// The pen over the picked pen shape, as vector editors have it: on its
|
||||
// outline it adds an anchor, on an anchor it deletes it, and with Alt it
|
||||
// turns an anchor into a corner, or pulls new handles out of it.
|
||||
bool SegmentPanel::pen_on_shape(app::FrameStencil& stencil, app::MaskShape& s, float mu,
|
||||
float mv, float cw, float ch, bool hovered, bool& edited) {
|
||||
const ImGuiIO& io = ImGui::GetIO();
|
||||
if (!hovered) return false;
|
||||
const PenHit h = pen_hit(s, mu, mv, cw, ch, 6.0f, /*handles=*/false);
|
||||
if (h.part == PenPart::None) return false;
|
||||
const bool alt = io.KeyAlt && h.part == PenPart::Anchor;
|
||||
ImDrawList* dl = ImGui::GetWindowDrawList();
|
||||
const ImVec2 at(ImGui::GetMousePos().x + 10.0f, ImGui::GetMousePos().y + 10.0f);
|
||||
const ImU32 badge = IM_COL32(255, 255, 255, 230);
|
||||
dl->AddLine(ImVec2(at.x - 4.0f, at.y), ImVec2(at.x + 4.0f, at.y), badge, 1.5f);
|
||||
if (h.part == PenPart::Segment)
|
||||
dl->AddLine(ImVec2(at.x, at.y - 4.0f), ImVec2(at.x, at.y + 4.0f), badge, 1.5f);
|
||||
if (alt) {
|
||||
dl->AddLine(ImVec2(at.x - 4.0f, at.y + 3.0f), ImVec2(at.x, at.y - 3.0f), badge, 1.5f);
|
||||
dl->AddLine(ImVec2(at.x, at.y - 3.0f), ImVec2(at.x + 4.0f, at.y + 3.0f), badge, 1.5f);
|
||||
}
|
||||
if (!ImGui::IsMouseClicked(ImGuiMouseButton_Left)) return true;
|
||||
if (alt) {
|
||||
// Settled on release by edit_points: a click makes a corner, a drag
|
||||
// pulls symmetric handles.
|
||||
const float* a = &s.pts[6 * (size_t)h.index];
|
||||
_pt_part = PenPart::Out;
|
||||
_pt_anchor = h.index;
|
||||
_pt_from[0] = a[2];
|
||||
_pt_from[1] = a[3];
|
||||
_pt_press[0] = mu;
|
||||
_pt_press[1] = mv;
|
||||
_pt_moved = false;
|
||||
_pt_pull = true;
|
||||
_drag_before = stencil;
|
||||
_drag_before_key = app::format_mask_shapes(scope(stencil).mask.shapes);
|
||||
return true;
|
||||
}
|
||||
const app::FrameStencil before = stencil;
|
||||
const bool changed = h.part == PenPart::Segment
|
||||
? (_pt_anchor = pen_insert(s, h.index, h.t)) >= 0
|
||||
: pen_delete(s, h.index);
|
||||
if (h.part == PenPart::Anchor) _pt_anchor = -1;
|
||||
if (changed) {
|
||||
_history.push(before);
|
||||
_stencil_key.clear();
|
||||
_shapes_edited = true;
|
||||
edited = true;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -887,17 +1153,18 @@ bool SegmentPanel::stroke_removes(bool ctrl) const {
|
||||
}
|
||||
|
||||
void SegmentPanel::change_shapes(app::FrameStencil& stencil, bool& edited) {
|
||||
_history.push(stencil.mask.shapes);
|
||||
_history.push(stencil);
|
||||
_stencil_key.clear();
|
||||
_shapes_edited = true;
|
||||
edited = true;
|
||||
}
|
||||
|
||||
void SegmentPanel::undo_shapes(app::FrameStencil& stencil, bool redo, bool& edited) {
|
||||
if (!(redo ? _history.redo(stencil.mask.shapes) : _history.undo(stencil.mask.shapes)))
|
||||
return;
|
||||
if (!(redo ? _history.redo(stencil) : _history.undo(stencil))) return;
|
||||
_shape_sel = -1;
|
||||
_drag_handle = -1;
|
||||
_pt_part = PenPart::None;
|
||||
_pt_anchor = -1;
|
||||
_stencil_key.clear();
|
||||
_shapes_edited = true;
|
||||
edited = true;
|
||||
@@ -911,18 +1178,33 @@ void SegmentPanel::draw_tools(app::FrameStencil& stencil, bool& edited) {
|
||||
if (_draw != t || _tool.id() != id) {
|
||||
_tool.cancel();
|
||||
_path.cancel();
|
||||
_pen.cancel();
|
||||
}
|
||||
_draw = t;
|
||||
if (t == DrawTool::Shape || t == DrawTool::Eraser) _tool.set_id(id);
|
||||
// The pick carries over between the tools that edit it.
|
||||
if (t != DrawTool::Select && t != DrawTool::Points && t != DrawTool::Pen)
|
||||
_shape_sel = -1;
|
||||
_drag_handle = -1;
|
||||
_pt_part = PenPart::None;
|
||||
};
|
||||
if (ui::KeyButton(dmsg::stencil_tool_select, w, "V", _draw == DrawTool::Select))
|
||||
pick(DrawTool::Select, ToolId::Navigate);
|
||||
ui::help_on_hover(dmsg::stencil_tool_select_help);
|
||||
ImGui::SameLine();
|
||||
if (ui::KeyButton(dmsg::stencil_tool_points, w, "A", _draw == DrawTool::Points))
|
||||
pick(DrawTool::Points, ToolId::Navigate);
|
||||
ui::help_on_hover(dmsg::stencil_tool_points_help);
|
||||
// The pen takes the polygon's place and its P: clicked, it draws one.
|
||||
for (int i = (int)ToolId::Box; i <= (int)ToolId::Brush; i++) {
|
||||
const ToolRow& row = tool_table()[i];
|
||||
ImGui::SameLine();
|
||||
if (row.id == ToolId::Polygon) {
|
||||
if (ui::KeyButton(mmsg::tool_pen, w, "P", _draw == DrawTool::Pen))
|
||||
pick(DrawTool::Pen, ToolId::Navigate);
|
||||
ui::help_on_hover(dmsg::stencil_tool_pen_help);
|
||||
continue;
|
||||
}
|
||||
if (ui::KeyButton(tool_label(row.id), w, row.key,
|
||||
_draw == DrawTool::Shape && _tool.id() == row.id))
|
||||
pick(DrawTool::Shape, row.id);
|
||||
@@ -961,6 +1243,10 @@ void SegmentPanel::draw_tools(app::FrameStencil& stencil, bool& edited) {
|
||||
ImGuiSliderFlags_Logarithmic | ImGuiSliderFlags_AlwaysClamp);
|
||||
ui::help_on_hover(dmsg::stencil_brush_size_help);
|
||||
}
|
||||
if (_draw == DrawTool::Pen && _pen.in_progress()) {
|
||||
ImGui::SameLine();
|
||||
ui::TextDisabled(mmsg::pen_anchors, {_pen.anchor_count()});
|
||||
}
|
||||
if (_draw == DrawTool::Path) {
|
||||
ImGui::SameLine();
|
||||
ui::TextDisabled(dmsg::stencil_path_anchors, {_path.anchor_count()});
|
||||
@@ -976,11 +1262,15 @@ void SegmentPanel::draw_tools(app::FrameStencil& stencil, bool& edited) {
|
||||
// The keys, while nothing is being typed and no stroke is half drawn.
|
||||
const ImGuiIO& io = ImGui::GetIO();
|
||||
if (!ImGui::IsWindowFocused(ImGuiFocusedFlags_RootAndChildWindows) || io.WantTextInput ||
|
||||
io.KeyCtrl || _tool.in_progress() || _path.in_progress())
|
||||
io.KeyCtrl || _tool.in_progress() || _path.in_progress() || _pen.in_progress() ||
|
||||
_pt_part != PenPart::None)
|
||||
return;
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_V, false)) pick(DrawTool::Select, ToolId::Navigate);
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_A, false)) pick(DrawTool::Points, ToolId::Navigate);
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_P, false)) pick(DrawTool::Pen, ToolId::Navigate);
|
||||
for (int i = (int)ToolId::Box; i <= (int)ToolId::Brush; i++) {
|
||||
const ToolRow& row = tool_table()[i];
|
||||
if (row.id == ToolId::Polygon) continue;
|
||||
if (ImGui::IsKeyPressed((ImGuiKey)row.imgui_key, false)) pick(DrawTool::Shape, row.id);
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_X, false)) pick(DrawTool::Eraser, ToolId::Brush);
|
||||
@@ -991,17 +1281,37 @@ void SegmentPanel::draw_tools(app::FrameStencil& stencil, bool& edited) {
|
||||
_brush_pct = std::min(25.0f, _brush_pct * 1.25f);
|
||||
}
|
||||
|
||||
// One list goes where the tools draw; a per-camera set goes on the cameras it
|
||||
// names, with separate areas turned on, and refuses an input that has none.
|
||||
void SegmentPanel::load_file(app::FrameStencil& s, const std::string& path) {
|
||||
std::vector<app::MaskShape> shapes;
|
||||
app::MaskSet set;
|
||||
std::string err;
|
||||
if (!load_stencil_preset(path, shapes, err)) {
|
||||
if (!load_stencil_preset(path, set, err)) {
|
||||
_saved_msg = spirula::i18n::format(dmsg::stencil_load_failed, {err});
|
||||
_saved_msg_err = true;
|
||||
return;
|
||||
}
|
||||
if (set.per_camera() && !_cameras.empty()) {
|
||||
std::string names;
|
||||
bool any = false;
|
||||
for (const auto& [camera, shapes] : set.cameras) {
|
||||
names += (names.empty() ? "" : ", ") + camera;
|
||||
any = any || std::find(_cameras.begin(), _cameras.end(), camera) != _cameras.end();
|
||||
}
|
||||
if (!any) {
|
||||
_saved_msg = spirula::i18n::format(dmsg::stencil_set_no_camera, {names});
|
||||
_saved_msg_err = true;
|
||||
return;
|
||||
}
|
||||
}
|
||||
bool edited = false;
|
||||
change_shapes(s, edited);
|
||||
s.mask.shapes = std::move(shapes);
|
||||
if (set.per_camera()) {
|
||||
if (!s.per_camera() && _cameras.size() > 1) set_per_camera(s, true);
|
||||
app::apply_mask_set(s, set);
|
||||
} else {
|
||||
scope(s).mask.shapes = std::move(set.shapes);
|
||||
}
|
||||
_shape_sel = -1;
|
||||
_drag_handle = -1;
|
||||
_saved_msg.clear();
|
||||
@@ -1017,19 +1327,25 @@ void SegmentPanel::draw_saved_areas(app::FrameStencil& s) {
|
||||
if (ImGui::BeginPopup("##stencilload")) {
|
||||
ui::SeparatorText(dmsg::stencil_saved_areas);
|
||||
for (const StencilPreset& p : _saved)
|
||||
if (ui::SelectableRaw(p.name, false)) load_file(s, p.path);
|
||||
if (ui::SelectableRaw(stencil_preset_label(p) + "##" + p.path, false))
|
||||
load_file(s, p.path);
|
||||
// Kept by earlier runs into this output folder, saved or not.
|
||||
if (!_in_dataset.empty()) {
|
||||
ui::SeparatorText(dmsg::stencil_in_dataset);
|
||||
for (const StencilPreset& p : _in_dataset)
|
||||
if (ui::SelectableRaw(p.name + "##ds" + p.path, false)) load_file(s, p.path);
|
||||
if (ui::SelectableRaw(stencil_preset_label(p) + "##ds" + p.path, false))
|
||||
load_file(s, p.path);
|
||||
}
|
||||
ImGui::Separator();
|
||||
if (ui::Selectable(dmsg::stencil_other_file)) _browse_requested = true;
|
||||
ImGui::EndPopup();
|
||||
}
|
||||
// With separate areas on, the set is the whole input's: a file per camera
|
||||
// when the cameras differ.
|
||||
const app::MaskSet to_save =
|
||||
s.per_camera() ? app::mask_set_of(s) : app::MaskSet{scope(s).mask.shapes, {}};
|
||||
ImGui::SameLine();
|
||||
ImGui::BeginDisabled(s.mask.shapes.empty());
|
||||
ImGui::BeginDisabled(to_save.empty());
|
||||
if (ui::SmallButton(dmsg::stencil_save)) ImGui::OpenPopup("##stencilsave");
|
||||
ImGui::EndDisabled();
|
||||
ui::help_on_hover_disabled(dmsg::stencil_save_help);
|
||||
@@ -1042,7 +1358,7 @@ void SegmentPanel::draw_saved_areas(app::FrameStencil& s) {
|
||||
ImGui::BeginDisabled(_save_name.empty());
|
||||
if ((ui::Button(mmsg::save) || enter) && !_save_name.empty()) {
|
||||
std::string path, err;
|
||||
if (save_stencil_preset(_save_name, s.mask.shapes, path, err)) {
|
||||
if (save_stencil_preset(_save_name, to_save, path, err)) {
|
||||
_saved_msg = spirula::i18n::format(dmsg::stencil_saved_as, {path});
|
||||
_saved_msg_err = false;
|
||||
} else {
|
||||
@@ -1069,13 +1385,34 @@ void SegmentPanel::draw_stencil(app::FrameStencil& s, bool& edited) {
|
||||
ui::Text(dmsg::stencil_section);
|
||||
ui::help_on_hover(dmsg::stencil_section_help);
|
||||
|
||||
bool detect = s.detect_border;
|
||||
if (ui::Checkbox(dmsg::stencil_border, &detect)) {
|
||||
s.detect_border = detect;
|
||||
// What is edited below: the input's, or the shown camera's own.
|
||||
app::CameraStencil* cs = &scope(s);
|
||||
const bool several = _cameras.size() > 1;
|
||||
if (several || s.per_camera()) {
|
||||
bool per = s.per_camera();
|
||||
if (ui::Checkbox(dmsg::stencil_per_camera, &per)) {
|
||||
// Undoable, but no drawn shape changes: a named set stays named.
|
||||
_history.push(s);
|
||||
edited = true;
|
||||
set_per_camera(s, per);
|
||||
cs = &scope(s);
|
||||
}
|
||||
ui::help_on_hover(dmsg::stencil_per_camera_help);
|
||||
if (s.per_camera()) {
|
||||
const std::string cam = shown_camera();
|
||||
ui::TextDisabled(dmsg::stencil_per_camera_editing, {cam.empty() ? std::string("/") : cam});
|
||||
}
|
||||
}
|
||||
|
||||
bool detect = cs->detect_border;
|
||||
if (ui::Checkbox(dmsg::stencil_border, &detect)) {
|
||||
_history.push(s);
|
||||
_stencil_key.clear();
|
||||
edited = true;
|
||||
cs->detect_border = detect;
|
||||
}
|
||||
ui::help_on_hover(dmsg::stencil_border_help);
|
||||
if (s.detect_border) {
|
||||
if (cs->detect_border) {
|
||||
// Lazily, here rather than off the checkbox: the option is on already
|
||||
// when the panel opens on an input that was set up before.
|
||||
if (!_listing.load() && !_detect_asked) start_detect();
|
||||
@@ -1085,14 +1422,17 @@ void SegmentPanel::draw_stencil(app::FrameStencil& s, bool& edited) {
|
||||
shown_camera() != _border_camera)
|
||||
start_detect();
|
||||
ImGui::Indent();
|
||||
float pct = s.shrink * 100.0f;
|
||||
// One undo step per drag, taken from the stencil as the drag found it.
|
||||
const app::FrameStencil before = s;
|
||||
float pct = cs->shrink * 100.0f;
|
||||
ImGui::SetNextItemWidth(-1);
|
||||
if (ui::SliderFloatRaw("##shrink", &pct, -10.0f, 30.0f, "%.1f%%",
|
||||
ImGuiSliderFlags_AlwaysClamp)) {
|
||||
s.shrink = pct / 100.0f;
|
||||
cs->shrink = pct / 100.0f;
|
||||
_stencil_key.clear();
|
||||
edited = true;
|
||||
}
|
||||
if (ImGui::IsItemActivated()) _history.push(before);
|
||||
ui::help_on_hover(dmsg::stencil_shrink_help);
|
||||
if (_detecting.load())
|
||||
ui::TextDisabled(dmsg::stencil_looking);
|
||||
@@ -1104,17 +1444,20 @@ void SegmentPanel::draw_stencil(app::FrameStencil& s, bool& edited) {
|
||||
start_detect();
|
||||
if (!_busy.load() && !_detecting.load() && !_listing.load() &&
|
||||
_border.found && shown_camera() == _border_camera) {
|
||||
if (ui::SmallButton(dmsg::stencil_edit_border) &&
|
||||
app::edit_detected_border(s, _border)) {
|
||||
_history.clear();
|
||||
_stencil_key.clear();
|
||||
_shapes_edited = true;
|
||||
edited = true;
|
||||
if (ui::SmallButton(dmsg::stencil_edit_border)) {
|
||||
// The ellipse fits one lens: on a many-camera input it is that
|
||||
// camera's, and the others keep their own fits.
|
||||
change_shapes(s, edited);
|
||||
if (several && !s.per_camera()) set_per_camera(s, true);
|
||||
cs = &scope(s);
|
||||
if (app::edit_detected_border(*cs, _border)) {
|
||||
_shape_sel = 0;
|
||||
_drag_handle = -1;
|
||||
_draw = DrawTool::Select;
|
||||
_tool.cancel();
|
||||
_path.cancel();
|
||||
_pen.cancel();
|
||||
}
|
||||
}
|
||||
ui::help_on_hover(dmsg::stencil_edit_border_help);
|
||||
}
|
||||
@@ -1126,13 +1469,15 @@ void SegmentPanel::draw_stencil(app::FrameStencil& s, bool& edited) {
|
||||
ui::help_on_hover(dmsg::stencil_shapes_help);
|
||||
draw_saved_areas(s);
|
||||
|
||||
for (size_t i = 0; i < s.mask.shapes.size(); i++) {
|
||||
app::MaskShape& sh = s.mask.shapes[i];
|
||||
std::vector<app::MaskShape>& shapes = scope(s).mask.shapes;
|
||||
for (size_t i = 0; i < shapes.size(); i++) {
|
||||
app::MaskShape& sh = shapes[i];
|
||||
ImGui::PushID((int)i);
|
||||
const spirula::i18n::Msg& kind_label =
|
||||
sh.kind == app::MaskShape::Kind::Rect ? dmsg::stencil_shape_box
|
||||
: sh.kind == app::MaskShape::Kind::Path ? dmsg::stencil_shape_path
|
||||
: sh.kind == app::MaskShape::Kind::Stroke ? dmsg::stencil_shape_stroke
|
||||
: sh.kind == app::MaskShape::Kind::Bezier ? dmsg::stencil_shape_curve
|
||||
: dmsg::stencil_shape_circle;
|
||||
const std::string label = spirula::i18n::format(kind_label, {(int)i + 1});
|
||||
// Toggling, not a plain radio: the selected shape swallows clicks on
|
||||
@@ -1141,30 +1486,40 @@ void SegmentPanel::draw_stencil(app::FrameStencil& s, bool& edited) {
|
||||
if (ui::RadioButtonRaw(label.c_str(), sel)) {
|
||||
_shape_sel = sel ? -1 : (int)i;
|
||||
_drag_handle = -1;
|
||||
_pt_part = PenPart::None;
|
||||
_pt_anchor = -1;
|
||||
// Moving it is the Select tool's; picking it is asking for that.
|
||||
_draw = DrawTool::Select;
|
||||
// The pen and Points edit it where they are.
|
||||
if (_draw != DrawTool::Points && _draw != DrawTool::Pen) _draw = DrawTool::Select;
|
||||
_tool.cancel();
|
||||
_path.cancel();
|
||||
_pen.cancel();
|
||||
}
|
||||
ImGui::SameLine();
|
||||
if (ui::SmallButton(sh.remove ? dmsg::stencil_removes_inside
|
||||
: dmsg::stencil_keeps_inside)) {
|
||||
change_shapes(s, edited);
|
||||
s.mask.shapes[i].remove = !s.mask.shapes[i].remove;
|
||||
shapes[i].remove = !shapes[i].remove;
|
||||
}
|
||||
ui::help_on_hover(dmsg::stencil_flip_help);
|
||||
ImGui::SameLine();
|
||||
if (ui::SmallButton(dmsg::remove)) {
|
||||
change_shapes(s, edited);
|
||||
s.mask.shapes.erase(s.mask.shapes.begin() + (long)i);
|
||||
shapes.erase(shapes.begin() + (long)i);
|
||||
_shape_sel = -1;
|
||||
_drag_handle = -1;
|
||||
_pt_part = PenPart::None;
|
||||
ImGui::PopID();
|
||||
break;
|
||||
}
|
||||
ImGui::PopID();
|
||||
}
|
||||
if (_shape_sel >= 0) ui::TextDisabled(dmsg::stencil_drag_hint);
|
||||
if (_shape_sel >= 0 && _shape_sel < (int)shapes.size()) {
|
||||
const bool pen_shape = shapes[(size_t)_shape_sel].kind == app::MaskShape::Kind::Bezier;
|
||||
if (pen_shape && _draw == DrawTool::Points) ui::TextDisabledWrapped(dmsg::stencil_points_hint);
|
||||
else if (pen_shape) ui::TextDisabledWrapped(dmsg::stencil_drag_hint_pen);
|
||||
else ui::TextDisabledWrapped(dmsg::stencil_drag_hint);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -1187,6 +1542,7 @@ void SegmentPanel::draw(MaskSettings& settings, app::FrameStencil& stencil) {
|
||||
_all_files = std::move(_all_files_pending);
|
||||
_folders = std::move(_folders_pending);
|
||||
_frames_ready = false;
|
||||
_cameras = camera_names();
|
||||
}
|
||||
}
|
||||
upload_preview();
|
||||
@@ -1238,7 +1594,7 @@ void SegmentPanel::draw(MaskSettings& settings, app::FrameStencil& stencil) {
|
||||
#ifndef SS_BUILD_SAM
|
||||
// No segmentation in this build, so the half of the panel that prompts a
|
||||
// model would be a row of dead controls. The shapes below need none.
|
||||
ui::TextWrappedRaw(backends().masking_note);
|
||||
ui::TextWrapped(dmsg::mask_objects_need_segmentation);
|
||||
ui::help_on_hover_raw(backends().masking_reason.c_str());
|
||||
#else
|
||||
// Polarity first, and the two fields below it are labelled by it: the same
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
//
|
||||
// The same panel edits the input's static stencil (app::FrameStencil): the
|
||||
// fitted fisheye border plus shapes drawn with the mask editor's tools
|
||||
// (StencilEdit.h). That half needs no model and works when there is none.
|
||||
// (StencilEdit.h), for the whole input or per camera. That half needs no
|
||||
// model and works when there is none.
|
||||
|
||||
#include "app/FrameMask.h"
|
||||
#include "app/gui/GlLoader.h"
|
||||
@@ -21,6 +22,7 @@
|
||||
#include "app/gui/edit/EditTool.h"
|
||||
#include "app/gui/mask/Livewire.h"
|
||||
#include "app/gui/mask/PathTool.h"
|
||||
#include "app/gui/mask/PenTool.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <memory>
|
||||
@@ -78,12 +80,28 @@ private:
|
||||
void draw_stencil(app::FrameStencil& stencil, bool& edited);
|
||||
void draw_tools(app::FrameStencil& stencil, bool& edited);
|
||||
void draw_saved_areas(app::FrameStencil& stencil);
|
||||
// Every change to the shapes goes through here, for undo.
|
||||
// A pen shape's anchors and handles under Points, or the pen with Ctrl:
|
||||
// hover, grab, drag, and the history step on release. True: the pointer
|
||||
// is on one of them, so the click is not the canvas's.
|
||||
bool edit_points(app::FrameStencil& stencil, app::MaskShape& shape, float mu, float mv,
|
||||
float cw, float ch, bool hovered, bool& edited);
|
||||
// The pen over the selected pen shape: add, delete or convert an anchor.
|
||||
bool pen_on_shape(app::FrameStencil& stencil, app::MaskShape& shape, float mu, float mv,
|
||||
float cw, float ch, bool hovered, bool& edited);
|
||||
// Every change to the stencil goes through here, for undo.
|
||||
void change_shapes(app::FrameStencil& stencil, bool& edited);
|
||||
void undo_shapes(app::FrameStencil& stencil, bool redo, bool& edited);
|
||||
// A plain stroke removes; Subtract, the eraser and Ctrl each flip it.
|
||||
bool stroke_removes(bool ctrl) const;
|
||||
|
||||
// What the tools edit: the input's stencil, or with separate areas on,
|
||||
// the shown camera's (a copy of the input's until it is drawn on).
|
||||
app::CameraStencil& scope(app::FrameStencil& stencil);
|
||||
void set_per_camera(app::FrameStencil& stencil, bool on);
|
||||
// The camera folders this input writes, keyed as shown_camera() keys them;
|
||||
// worked out when the listing lands, into _cameras.
|
||||
std::vector<std::string> camera_names() const;
|
||||
|
||||
// The stencil as it will be written: the shapes plus, when asked for, the
|
||||
// border this panel found, shrunk by the current amount.
|
||||
app::FrameMask resolved(const app::FrameStencil& stencil) const;
|
||||
@@ -91,9 +109,11 @@ private:
|
||||
// The settings hold every input's clicks; these are the ones on this picture.
|
||||
bool mine(const MaskClick& c) const { return c.source == _src.input; }
|
||||
|
||||
// Which camera folder a file of a photo input belongs to, keyed the way
|
||||
// the run keys them (DatasetPrep's StencilRaster). "" for a video.
|
||||
// A photo input's file's subfolder under the input. "" for a video.
|
||||
std::string camera_of(const std::string& file) const;
|
||||
// Where the run puts that file's frames (DatasetPrep's StencilRaster
|
||||
// keys): its subfolder, under the shown lens's cam<k>/ for packed photos.
|
||||
std::string file_camera(const std::string& file) const;
|
||||
|
||||
// The folder the shown frame lands in under the input's images: a 360
|
||||
// view, one lens of a multi-lens file, or the photo's own subfolder.
|
||||
@@ -104,6 +124,7 @@ private:
|
||||
PreviewSource _src;
|
||||
// What the run splits this input into; one empty name for one camera.
|
||||
std::vector<std::string> _folders;
|
||||
std::vector<std::string> _cameras;
|
||||
int _folder_idx = 0;
|
||||
std::vector<PreviewFrame> _frames;
|
||||
// Every image of a photo input; the border fit reads the ones sharing the
|
||||
@@ -137,17 +158,26 @@ private:
|
||||
static constexpr int kDragBody = -2;
|
||||
int _drag_handle = -1;
|
||||
float _drag_from_u = 0.0f, _drag_from_v = 0.0f;
|
||||
// A pen shape's point being dragged (Points, or the pen with Ctrl or
|
||||
// Alt), relative like the body: where it and the pointer were at the grab.
|
||||
PenPart _pt_part = PenPart::None;
|
||||
int _pt_anchor = -1; // also the picked anchor Delete removes
|
||||
float _pt_from[2] = {0.0f, 0.0f};
|
||||
float _pt_press[2] = {0.0f, 0.0f};
|
||||
bool _pt_moved = false;
|
||||
bool _pt_pull = false; // Alt on an anchor under the pen: new handles
|
||||
GLuint _stencil_tex = 0;
|
||||
std::string _stencil_key; // what _stencil_tex was built from
|
||||
|
||||
// ---- the drawing tools ----
|
||||
enum class DrawTool { Select, Shape, Eraser, Path };
|
||||
enum class DrawTool { Select, Points, Shape, Eraser, Path, Pen };
|
||||
DrawTool _draw = DrawTool::Select;
|
||||
EditTool _tool; // Box to Brush, for Shape and Eraser
|
||||
bool _subtract = false;
|
||||
float _brush_pct = 2.0f; // radius, % of the picture's shorter side
|
||||
StencilHistory _history;
|
||||
std::vector<app::MaskShape> _drag_before; // the shapes as a drag found them
|
||||
app::FrameStencil _drag_before; // the stencil as a drag found it
|
||||
std::string _drag_before_key; // and its edited shapes, spelled
|
||||
bool _shapes_edited = false;
|
||||
std::vector<StencilPreset> _saved; // refreshed when the load popup opens
|
||||
std::vector<StencilPreset> _in_dataset;
|
||||
@@ -157,6 +187,7 @@ private:
|
||||
std::string _saved_msg;
|
||||
bool _saved_msg_err = false;
|
||||
mask::PathTool _path;
|
||||
mask::PenTool _pen;
|
||||
std::unique_ptr<mask::Livewire> _livewire; // UI thread
|
||||
std::string _livewire_key; // frame it was built for
|
||||
std::unique_ptr<mask::Livewire> _livewire_pending; // guarded by _mu
|
||||
|
||||
@@ -301,11 +301,8 @@ void SfmRunner::take_masking(PrepJob& prep) {
|
||||
prep.mask_model_path = _live.prep.mask_model_path;
|
||||
prep.mask_detector_path = _live.prep.mask_detector_path;
|
||||
prep.mask_detector_threshold = _live.prep.mask_detector_threshold;
|
||||
prep.mask_model_name = _live.prep.mask_model_name;
|
||||
prep.force_external_masking = _live.prep.force_external_masking;
|
||||
prep.image_gamut = _live.prep.image_gamut;
|
||||
prep.image_is_linear = _live.prep.image_is_linear;
|
||||
prep.python_exe = _live.prep.python_exe;
|
||||
}
|
||||
|
||||
void SfmRunner::cancel() { _cancel = true; }
|
||||
@@ -904,8 +901,8 @@ void SfmRunner::run(SfmJob job) {
|
||||
for (size_t k = 0; k < now.args.size(); k++) {
|
||||
settings.push_back(now.args[k]);
|
||||
if (now.args[k] != "--manifest" || k + 1 >= now.args.size()) continue;
|
||||
// Dotted and prefixed, like .spirula_mask.py: the workspace
|
||||
// is the user's, and a plain manifest.yaml there could be theirs.
|
||||
// Dotted and prefixed: the workspace is the user's, and a
|
||||
// plain manifest.yaml there could be theirs.
|
||||
const fs::path mf = ws / ".spirula_manifest.yaml";
|
||||
std::ofstream(mf, std::ios::binary | std::ios::trunc) << now.args[++k];
|
||||
settings.push_back(mf.string());
|
||||
|
||||
+133
-8
@@ -2,6 +2,7 @@
|
||||
|
||||
#include "app/gui/StencilEdit.h"
|
||||
|
||||
#include "core/CubicBezier.h"
|
||||
#include "core/PolygonFill.h"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -25,6 +26,12 @@ float stroke_distance(const app::MaskShape& s, size_t i, size_t j, float u, floa
|
||||
return std::hypot(qx - t * dx, qy - t * dy);
|
||||
}
|
||||
|
||||
size_t anchor_count(const app::MaskShape& s) { return s.pts.size() / bezier::kAnchorFloats; }
|
||||
|
||||
float* anchor_at(app::MaskShape& s, int k) {
|
||||
return &s.pts[bezier::kAnchorFloats * (size_t)k];
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
bool stencil_shape_from_stroke(const ShapeStroke& s, float cw, float ch, bool remove,
|
||||
@@ -85,6 +92,34 @@ bool stencil_shape_from_stroke(const ShapeStroke& s, float cw, float ch, bool re
|
||||
return false;
|
||||
}
|
||||
|
||||
bool stencil_shape_from_pen(const std::vector<float>& anchors, float cw, float ch,
|
||||
bool remove, app::MaskShape& out) {
|
||||
out = app::MaskShape{};
|
||||
const size_t n = anchors.size() / bezier::kAnchorFloats;
|
||||
if (cw <= 0.0f || ch <= 0.0f || n < 2) return false;
|
||||
if (n == 2 && bezier::is_corner(&anchors[0], 1e-3f) &&
|
||||
bezier::is_corner(&anchors[bezier::kAnchorFloats], 1e-3f))
|
||||
return false;
|
||||
std::vector<float> outline;
|
||||
bezier::flatten_closed(anchors.data(), n, 1.0f, 1.0f, 0.25f, outline);
|
||||
float x0 = outline[0], x1 = x0, y0 = outline[1], y1 = y0;
|
||||
for (size_t i = 0; i + 1 < outline.size(); i += 2) {
|
||||
x0 = std::min(x0, outline[i]);
|
||||
x1 = std::max(x1, outline[i]);
|
||||
y0 = std::min(y0, outline[i + 1]);
|
||||
y1 = std::max(y1, outline[i + 1]);
|
||||
}
|
||||
if (x1 - x0 < 2.0f || y1 - y0 < 2.0f) return false;
|
||||
out.kind = Kind::Bezier;
|
||||
out.remove = remove;
|
||||
out.pts.resize(n * bezier::kAnchorFloats);
|
||||
for (size_t i = 0; i + 1 < out.pts.size(); i += 2) {
|
||||
out.pts[i] = anchors[i] / cw;
|
||||
out.pts[i + 1] = anchors[i + 1] / ch;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
int stencil_handles(const app::MaskShape& s, float u[3], float v[3]) {
|
||||
switch (s.kind) {
|
||||
case Kind::Ellipse:
|
||||
@@ -99,6 +134,7 @@ int stencil_handles(const app::MaskShape& s, float u[3], float v[3]) {
|
||||
return 3;
|
||||
case Kind::Path:
|
||||
case Kind::Stroke:
|
||||
case Kind::Bezier:
|
||||
return 0;
|
||||
}
|
||||
return 0;
|
||||
@@ -108,6 +144,11 @@ bool stencil_contains(const app::MaskShape& s, float u, float v) {
|
||||
switch (s.kind) {
|
||||
case Kind::Path:
|
||||
return polyfill::contains(s.pts.data(), s.pts.size() / 2, u, v);
|
||||
case Kind::Bezier: {
|
||||
std::vector<float> outline;
|
||||
bezier::flatten_closed(s.pts.data(), anchor_count(s), 1.0f, 1.0f, 1e-4f, outline);
|
||||
return polyfill::contains(outline.data(), outline.size() / 2, u, v);
|
||||
}
|
||||
case Kind::Stroke: {
|
||||
const size_t n = s.pts.size() / 2;
|
||||
if (n == 0 || s.rx <= 0.0f || s.ry <= 0.0f) return false;
|
||||
@@ -128,7 +169,7 @@ bool stencil_contains(const app::MaskShape& s, float u, float v) {
|
||||
}
|
||||
|
||||
void stencil_move(app::MaskShape& s, float du, float dv) {
|
||||
if (s.kind == Kind::Path || s.kind == Kind::Stroke) {
|
||||
if (s.kind == Kind::Path || s.kind == Kind::Stroke || s.kind == Kind::Bezier) {
|
||||
for (size_t i = 0; i + 1 < s.pts.size(); i += 2) {
|
||||
s.pts[i] += du;
|
||||
s.pts[i + 1] += dv;
|
||||
@@ -161,24 +202,108 @@ void stencil_move_handle(app::MaskShape& s, int handle, float u, float v) {
|
||||
else { s.rx = u; s.ry = v; }
|
||||
}
|
||||
|
||||
void StencilHistory::push(const std::vector<app::MaskShape>& before) {
|
||||
PenHit pen_hit(const app::MaskShape& s, float u, float v, float cw, float ch, float radius,
|
||||
bool handles) {
|
||||
PenHit hit;
|
||||
if (s.kind != Kind::Bezier) return hit;
|
||||
const size_t n = anchor_count(s);
|
||||
const float x = u * cw, y = v * ch, r2 = radius * radius;
|
||||
auto d2 = [&](size_t k, int j) {
|
||||
const float* a = &s.pts[bezier::kAnchorFloats * k];
|
||||
const float dx = a[j] * cw - x, dy = a[j + 1] * ch - y;
|
||||
return dx * dx + dy * dy;
|
||||
};
|
||||
float best = r2;
|
||||
if (handles)
|
||||
for (size_t k = 0; k < n; k++)
|
||||
for (int j : {0, 4}) {
|
||||
// A handle folded onto its point is the anchor, not a handle.
|
||||
const float* a = &s.pts[bezier::kAnchorFloats * k];
|
||||
if (std::hypot((a[j] - a[2]) * cw, (a[j + 1] - a[3]) * ch) < 0.5f) continue;
|
||||
const float d = d2(k, j);
|
||||
if (d <= best) {
|
||||
best = d;
|
||||
hit = {j == 0 ? PenPart::In : PenPart::Out, (int)k, 0.0f};
|
||||
}
|
||||
}
|
||||
if (hit.part != PenPart::None) return hit;
|
||||
for (size_t k = 0; k < n; k++) {
|
||||
const float d = d2(k, 2);
|
||||
if (d <= best) {
|
||||
best = d;
|
||||
hit = {PenPart::Anchor, (int)k, 0.0f};
|
||||
}
|
||||
}
|
||||
if (hit.part != PenPart::None) return hit;
|
||||
for (size_t k = 0; k < n; k++) {
|
||||
float c[8], t = 0.0f;
|
||||
bezier::segment(s.pts.data(), n, k, c);
|
||||
for (int j = 0; j < 8; j += 2) {
|
||||
c[j] *= cw;
|
||||
c[j + 1] *= ch;
|
||||
}
|
||||
const float d = bezier::nearest(c, x, y, t);
|
||||
if (d <= best) {
|
||||
best = d;
|
||||
hit = {PenPart::Segment, (int)k, t};
|
||||
}
|
||||
}
|
||||
return hit;
|
||||
}
|
||||
|
||||
void pen_move_point(app::MaskShape& s, int anchor, PenPart part, float u, float v, float cw,
|
||||
float ch, bool split) {
|
||||
if (s.kind != Kind::Bezier || anchor < 0 || (size_t)anchor >= anchor_count(s)) return;
|
||||
float* a = anchor_at(s, anchor);
|
||||
if (part == PenPart::Anchor) {
|
||||
bezier::move_anchor(a, u, v);
|
||||
} else if (part == PenPart::In || part == PenPart::Out) {
|
||||
const bool smooth = !split && bezier::is_smooth(a, cw, ch);
|
||||
bezier::set_handle(a, part == PenPart::Out, u, v, smooth, cw, ch);
|
||||
}
|
||||
}
|
||||
|
||||
bool pen_is_smooth(const app::MaskShape& s, int anchor, float cw, float ch) {
|
||||
if (s.kind != Kind::Bezier || anchor < 0 || (size_t)anchor >= anchor_count(s)) return false;
|
||||
return bezier::is_smooth(&s.pts[bezier::kAnchorFloats * (size_t)anchor], cw, ch);
|
||||
}
|
||||
|
||||
int pen_insert(app::MaskShape& s, int segment, float t) {
|
||||
if (s.kind != Kind::Bezier || segment < 0 || (size_t)segment >= anchor_count(s)) return -1;
|
||||
return (int)bezier::split(s.pts, (size_t)segment, std::clamp(t, 0.0f, 1.0f));
|
||||
}
|
||||
|
||||
bool pen_delete(app::MaskShape& s, int anchor) {
|
||||
if (s.kind != Kind::Bezier || anchor < 0 || (size_t)anchor >= anchor_count(s) ||
|
||||
anchor_count(s) <= 2)
|
||||
return false;
|
||||
bezier::erase_anchor(s.pts, (size_t)anchor);
|
||||
return true;
|
||||
}
|
||||
|
||||
void pen_make_corner(app::MaskShape& s, int anchor) {
|
||||
if (s.kind != Kind::Bezier || anchor < 0 || (size_t)anchor >= anchor_count(s)) return;
|
||||
bezier::make_corner(anchor_at(s, anchor));
|
||||
}
|
||||
|
||||
void StencilHistory::push(const app::FrameStencil& before) {
|
||||
_undo.push_back(before);
|
||||
if (_undo.size() > kMaxUndo) _undo.erase(_undo.begin());
|
||||
_redo.clear();
|
||||
}
|
||||
|
||||
bool StencilHistory::undo(std::vector<app::MaskShape>& shapes) {
|
||||
bool StencilHistory::undo(app::FrameStencil& stencil) {
|
||||
if (_undo.empty()) return false;
|
||||
_redo.push_back(std::move(shapes));
|
||||
shapes = std::move(_undo.back());
|
||||
_redo.push_back(std::move(stencil));
|
||||
stencil = std::move(_undo.back());
|
||||
_undo.pop_back();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool StencilHistory::redo(std::vector<app::MaskShape>& shapes) {
|
||||
bool StencilHistory::redo(app::FrameStencil& stencil) {
|
||||
if (_redo.empty()) return false;
|
||||
_undo.push_back(std::move(shapes));
|
||||
shapes = std::move(_redo.back());
|
||||
_undo.push_back(std::move(stencil));
|
||||
stencil = std::move(_redo.back());
|
||||
_redo.pop_back();
|
||||
return true;
|
||||
}
|
||||
|
||||
+38
-10
@@ -1,9 +1,9 @@
|
||||
#pragma once
|
||||
|
||||
// Editing a frame stencil's shapes by hand, with no ImGui: what a finished
|
||||
// tool stroke becomes, how a selected shape is hit, moved and resized, and
|
||||
// undo. SegmentPanel is the UI over it. A new shape kind (a pen tool's
|
||||
// curves, say) goes here and in app/FrameMaskSvg.h: docs/notes/frame-stencil.md.
|
||||
// tool stroke becomes, how a selected shape is hit, moved and resized, a pen
|
||||
// shape's points, and undo. SegmentPanel is the UI over it. A new shape kind
|
||||
// goes here and in app/FrameMaskSvg.h: docs/notes/frame-stencil.md.
|
||||
|
||||
#include "app/FrameMask.h"
|
||||
#include "app/gui/edit/SelectShape.h"
|
||||
@@ -17,26 +17,54 @@ namespace gui {
|
||||
bool stencil_shape_from_stroke(const ShapeStroke& s, float cw, float ch, bool remove,
|
||||
app::MaskShape& out);
|
||||
|
||||
// A pen path finished on that canvas: anchors of six floats in canvas pixels
|
||||
// (core/CubicBezier.h). False for fewer than two, or one that encloses nothing.
|
||||
bool stencil_shape_from_pen(const std::vector<float>& anchors, float cw, float ch,
|
||||
bool remove, app::MaskShape& out);
|
||||
|
||||
// Draggable points in normalized coordinates: the first moves the shape, the
|
||||
// rest resize it. Paths and strokes have only the first.
|
||||
// rest resize it. Paths, strokes and pen shapes have only the first.
|
||||
int stencil_handles(const app::MaskShape& s, float u[3], float v[3]);
|
||||
bool stencil_contains(const app::MaskShape& s, float u, float v);
|
||||
void stencil_move(app::MaskShape& s, float du, float dv);
|
||||
void stencil_move_handle(app::MaskShape& s, int handle, float u, float v);
|
||||
|
||||
// Snapshots of the whole list: a stencil is a handful of shapes.
|
||||
// ---- a pen shape's own points; (u, v) normalized, radii in canvas pixels ----
|
||||
|
||||
enum class PenPart { None, In, Anchor, Out, Segment };
|
||||
struct PenHit {
|
||||
PenPart part = PenPart::None;
|
||||
int index = -1; // the anchor, or the segment that starts at it
|
||||
float t = 0.0f; // along that segment
|
||||
};
|
||||
// Handles first (only with `handles`), then anchors, then segments: the
|
||||
// nearest within `radius` of each, in that order.
|
||||
PenHit pen_hit(const app::MaskShape& s, float u, float v, float cw, float ch, float radius,
|
||||
bool handles);
|
||||
// An anchor drags its handles along. A handle of a smooth anchor swings the
|
||||
// other to stay opposite, unless `split`, which leaves the anchor a cusp.
|
||||
void pen_move_point(app::MaskShape& s, int anchor, PenPart part, float u, float v, float cw,
|
||||
float ch, bool split);
|
||||
bool pen_is_smooth(const app::MaskShape& s, int anchor, float cw, float ch);
|
||||
// Returns the new anchor's index; the outline does not change.
|
||||
int pen_insert(app::MaskShape& s, int segment, float t);
|
||||
// Refused when it would leave fewer than two anchors.
|
||||
bool pen_delete(app::MaskShape& s, int anchor);
|
||||
void pen_make_corner(app::MaskShape& s, int anchor);
|
||||
|
||||
// Snapshots of the whole stencil: a handful of shapes, per camera at most.
|
||||
class StencilHistory {
|
||||
public:
|
||||
// Call with the shapes as they were BEFORE the change.
|
||||
void push(const std::vector<app::MaskShape>& before);
|
||||
bool undo(std::vector<app::MaskShape>& shapes);
|
||||
bool redo(std::vector<app::MaskShape>& shapes);
|
||||
// Call with the stencil as it was BEFORE the change.
|
||||
void push(const app::FrameStencil& before);
|
||||
bool undo(app::FrameStencil& stencil);
|
||||
bool redo(app::FrameStencil& stencil);
|
||||
bool can_undo() const { return !_undo.empty(); }
|
||||
bool can_redo() const { return !_redo.empty(); }
|
||||
void clear();
|
||||
|
||||
private:
|
||||
std::vector<std::vector<app::MaskShape>> _undo, _redo;
|
||||
std::vector<app::FrameStencil> _undo, _redo;
|
||||
};
|
||||
|
||||
} // namespace gui
|
||||
|
||||
+118
-56
@@ -7,11 +7,89 @@
|
||||
|
||||
#include <algorithm>
|
||||
#include <filesystem>
|
||||
#include <map>
|
||||
#include <set>
|
||||
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
namespace gui {
|
||||
|
||||
namespace {
|
||||
|
||||
// A name as a file stem: preset_file_name's spelling without its extension.
|
||||
std::string stem_for(const std::string& name) {
|
||||
return fs::path(preset_file_name(name)).stem().string();
|
||||
}
|
||||
|
||||
// Every set in `dir`: a file naming no camera on its own, and the files
|
||||
// naming one grouped by title.
|
||||
std::vector<StencilPreset> list_sets(const fs::path& dir) {
|
||||
std::vector<StencilPreset> out;
|
||||
std::map<std::string, size_t> by_title;
|
||||
std::vector<fs::path> files;
|
||||
std::error_code ec;
|
||||
for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec))
|
||||
if (it->is_regular_file(ec) && it->path().extension() == ".svg") files.push_back(it->path());
|
||||
std::sort(files.begin(), files.end());
|
||||
for (const fs::path& f : files) {
|
||||
std::vector<app::MaskShape> shapes;
|
||||
std::string title, camera, err;
|
||||
if (!app::load_mask_svg(f.string(), shapes, title, err, &camera)) continue;
|
||||
const std::string name = title.empty() ? f.stem().string() : title;
|
||||
if (camera.empty()) {
|
||||
out.push_back({name, f.string(), {}});
|
||||
continue;
|
||||
}
|
||||
const auto [it, fresh] = by_title.emplace(name, out.size());
|
||||
if (fresh) out.push_back({name, f.string(), {}});
|
||||
out[it->second].cameras.push_back(camera);
|
||||
}
|
||||
for (StencilPreset& p : out) std::sort(p.cameras.begin(), p.cameras.end());
|
||||
std::sort(out.begin(), out.end(), [](const StencilPreset& a, const StencilPreset& b) {
|
||||
return a.name != b.name ? a.name < b.name : a.cameras.size() < b.cameras.size();
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
// `set` into `dir` as <stem>.svg, or <stem>-<camera>.svg per camera with
|
||||
// shapes, all titled `title`. Appends the files written.
|
||||
bool write_set(const fs::path& dir, const std::string& stem, const app::MaskSet& set,
|
||||
const std::string& title, std::vector<std::string>& written, std::string& error) {
|
||||
std::error_code ec;
|
||||
fs::create_directories(dir, ec);
|
||||
if (!set.per_camera()) {
|
||||
const fs::path f = dir / (stem + ".svg");
|
||||
if (!app::save_mask_svg(f.string(), set.shapes, title, error)) return false;
|
||||
written.push_back(f.string());
|
||||
return true;
|
||||
}
|
||||
for (const auto& [camera, shapes] : set.cameras) {
|
||||
if (shapes.empty()) continue;
|
||||
const fs::path f = dir / (stem + "-" + stem_for(camera) + ".svg");
|
||||
if (!app::save_mask_svg(f.string(), shapes, title, error, camera)) return false;
|
||||
written.push_back(f.string());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// Whether any file `write_set` would make for `stem` is there already.
|
||||
bool stem_taken(const fs::path& dir, const std::string& stem, const app::MaskSet& set) {
|
||||
std::error_code ec;
|
||||
if (!set.per_camera()) return fs::exists(dir / (stem + ".svg"), ec);
|
||||
for (const auto& [camera, shapes] : set.cameras)
|
||||
if (fs::exists(dir / (stem + "-" + stem_for(camera) + ".svg"), ec)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
std::string stencil_preset_label(const StencilPreset& p) {
|
||||
if (p.cameras.empty()) return p.name;
|
||||
std::string cams;
|
||||
for (const std::string& c : p.cameras) cams += (cams.empty() ? "" : ", ") + c;
|
||||
return p.name + " [" + cams + "]";
|
||||
}
|
||||
|
||||
std::string stencil_preset_dir() {
|
||||
// The sibling of the dataset presets that name these.
|
||||
const fs::path d = fs::path(preset_dir(PresetKind::Dataset)).parent_path() / "stencil";
|
||||
@@ -20,35 +98,17 @@ std::string stencil_preset_dir() {
|
||||
return d.string();
|
||||
}
|
||||
|
||||
static std::string file_for(const std::string& name) {
|
||||
return fs::path(preset_file_name(name)).replace_extension(".svg").string();
|
||||
}
|
||||
std::vector<StencilPreset> list_stencil_presets() { return list_sets(stencil_preset_dir()); }
|
||||
|
||||
std::vector<StencilPreset> list_stencil_presets() {
|
||||
std::vector<StencilPreset> out;
|
||||
std::error_code ec;
|
||||
for (fs::directory_iterator it(stencil_preset_dir(), ec), end; !ec && it != end;
|
||||
it.increment(ec)) {
|
||||
if (!it->is_regular_file(ec) || it->path().extension() != ".svg") continue;
|
||||
std::vector<app::MaskShape> shapes;
|
||||
std::string title, err;
|
||||
if (!app::load_mask_svg(it->path().string(), shapes, title, err)) continue;
|
||||
out.push_back({title.empty() ? it->path().stem().string() : title, it->path().string()});
|
||||
}
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const StencilPreset& a, const StencilPreset& b) { return a.name < b.name; });
|
||||
return out;
|
||||
}
|
||||
|
||||
bool load_stencil_preset(const std::string& name_or_path, std::vector<app::MaskShape>& out,
|
||||
bool load_stencil_preset(const std::string& name_or_path, app::MaskSet& out,
|
||||
std::string& error) {
|
||||
std::string path;
|
||||
for (const StencilPreset& p : list_stencil_presets())
|
||||
if (p.name == name_or_path) path = p.path;
|
||||
if (path.empty() && p.name == name_or_path) path = p.path;
|
||||
std::error_code ec;
|
||||
if (path.empty() && fs::is_regular_file(name_or_path, ec)) path = name_or_path;
|
||||
if (path.empty()) {
|
||||
const fs::path by_file = fs::path(stencil_preset_dir()) / file_for(name_or_path);
|
||||
const fs::path by_file = fs::path(stencil_preset_dir()) / (stem_for(name_or_path) + ".svg");
|
||||
if (fs::is_regular_file(by_file, ec)) path = by_file.string();
|
||||
}
|
||||
if (path.empty()) {
|
||||
@@ -56,21 +116,32 @@ bool load_stencil_preset(const std::string& name_or_path, std::vector<app::MaskS
|
||||
return false;
|
||||
}
|
||||
std::string title;
|
||||
return app::load_mask_svg(path, out, title, error);
|
||||
return app::load_mask_svg_set(path, out, title, error);
|
||||
}
|
||||
|
||||
bool save_stencil_preset(const std::string& name, const std::vector<app::MaskShape>& shapes,
|
||||
std::string& path, std::string& error) {
|
||||
bool save_stencil_preset(const std::string& name, const app::MaskSet& set, std::string& path,
|
||||
std::string& error) {
|
||||
path.clear();
|
||||
for (const StencilPreset& p : list_stencil_presets())
|
||||
if (p.name == name) path = p.path;
|
||||
if (path.empty()) path = (fs::path(stencil_preset_dir()) / file_for(name)).string();
|
||||
return app::save_mask_svg(path, shapes, name, error);
|
||||
const fs::path dir = stencil_preset_dir();
|
||||
std::error_code ec;
|
||||
// Every file of the old set goes, so a set drawn once per camera and saved
|
||||
// again as one list leaves no stale camera file to be read back.
|
||||
for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec)) {
|
||||
if (it->path().extension() != ".svg") continue;
|
||||
std::vector<app::MaskShape> shapes;
|
||||
std::string title, err;
|
||||
if (app::load_mask_svg(it->path().string(), shapes, title, err) &&
|
||||
(title.empty() ? it->path().stem().string() : title) == name)
|
||||
fs::remove(it->path(), ec);
|
||||
}
|
||||
std::vector<std::string> written;
|
||||
if (!write_set(dir, stem_for(name), set, name, written, error)) return false;
|
||||
if (!written.empty()) path = written.front();
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<std::string> save_dataset_stencils(
|
||||
const std::string& workspace,
|
||||
const std::vector<std::pair<std::string, std::vector<app::MaskShape>>>& inputs,
|
||||
const std::string& workspace, const std::vector<std::pair<std::string, app::MaskSet>>& inputs,
|
||||
std::string& error) {
|
||||
std::vector<std::string> written;
|
||||
const fs::path dir = fs::path(workspace) / kDatasetStencilDir;
|
||||
@@ -78,41 +149,32 @@ std::vector<std::string> save_dataset_stencils(
|
||||
for (fs::directory_iterator it(dir, ec), end; !ec && it != end; it.increment(ec))
|
||||
if (it->path().extension() == ".svg") fs::remove(it->path(), ec);
|
||||
const std::string dataset = fs::path(workspace).filename().string();
|
||||
for (const auto& [input, shapes] : inputs) {
|
||||
if (shapes.empty()) continue;
|
||||
fs::create_directories(dir, ec);
|
||||
std::set<std::string> used;
|
||||
for (const auto& [input, set] : inputs) {
|
||||
if (set.empty()) continue;
|
||||
// The input's own name; a folder called images/ says little, so its parent's too.
|
||||
fs::path in = fs::path(input);
|
||||
if (!in.has_filename()) in = in.parent_path();
|
||||
std::string stem = in.stem().string();
|
||||
if (stem == "images" && in.has_parent_path()) stem = in.parent_path().filename().string() + "-" + stem;
|
||||
const std::string base = fs::path(preset_file_name(stem)).stem().string();
|
||||
fs::path file = dir / (base + ".svg");
|
||||
for (int k = 2; fs::exists(file, ec); k++)
|
||||
file = dir / (base + "-" + std::to_string(k) + ".svg");
|
||||
if (!app::save_mask_svg(file.string(), shapes, dataset + " - " + in.filename().string(),
|
||||
error))
|
||||
return written;
|
||||
written.push_back(file.string());
|
||||
const std::string base = stem_for(stem);
|
||||
std::string title = dataset + " - " + in.filename().string();
|
||||
std::string file = base;
|
||||
// Two inputs of one name get two stems, and two titles, or their
|
||||
// camera files would load back as one set.
|
||||
for (int k = 2; used.count(file) || stem_taken(dir, file, set); k++) {
|
||||
file = base + "-" + std::to_string(k);
|
||||
title = dataset + " - " + in.filename().string() + " (" + std::to_string(k) + ")";
|
||||
}
|
||||
used.insert(file);
|
||||
if (!write_set(dir, file, set, title, written, error)) return written;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
std::vector<StencilPreset> list_dataset_stencils(const std::string& workspace) {
|
||||
std::vector<StencilPreset> out;
|
||||
if (workspace.empty()) return out;
|
||||
std::error_code ec;
|
||||
for (fs::directory_iterator it(fs::path(workspace) / kDatasetStencilDir, ec), end;
|
||||
!ec && it != end; it.increment(ec)) {
|
||||
if (it->path().extension() != ".svg") continue;
|
||||
std::vector<app::MaskShape> shapes;
|
||||
std::string title, err;
|
||||
if (!app::load_mask_svg(it->path().string(), shapes, title, err)) continue;
|
||||
out.push_back({title.empty() ? it->path().stem().string() : title, it->path().string()});
|
||||
}
|
||||
std::sort(out.begin(), out.end(),
|
||||
[](const StencilPreset& a, const StencilPreset& b) { return a.name < b.name; });
|
||||
return out;
|
||||
if (workspace.empty()) return {};
|
||||
return list_sets(fs::path(workspace) / kDatasetStencilDir);
|
||||
}
|
||||
|
||||
} // namespace gui
|
||||
|
||||
+19
-15
@@ -1,9 +1,10 @@
|
||||
#pragma once
|
||||
|
||||
// Saved "drawn areas": a frame stencil's shapes as an SVG file
|
||||
// (app/FrameMaskSvg.h) in <config>/presets/stencil, listed by its <title>. A
|
||||
// dataset preset names one (DatasetSettings::frame_shapes), which is how a
|
||||
// batch run gets them. The fitted fisheye border is never in one.
|
||||
// Saved "drawn areas": a frame stencil's shapes as SVG (app/FrameMaskSvg.h)
|
||||
// in <config>/presets/stencil, listed by <title>. A set drawn per camera is
|
||||
// one file per camera sharing the title, each naming its camera. A dataset
|
||||
// preset names one (DatasetSettings::frame_shapes), which is how a batch run
|
||||
// gets them. The fitted fisheye border is never in one.
|
||||
|
||||
#include "app/FrameMask.h"
|
||||
|
||||
@@ -15,28 +16,31 @@ namespace gui {
|
||||
|
||||
struct StencilPreset {
|
||||
std::string name;
|
||||
std::string path;
|
||||
std::string path; // its file, or one of a per-camera set's
|
||||
std::vector<std::string> cameras; // a per-camera set's, sorted
|
||||
};
|
||||
// The name, and a per-camera set's cameras after it: "Rig [cam0, cam1]".
|
||||
std::string stencil_preset_label(const StencilPreset& p);
|
||||
|
||||
std::string stencil_preset_dir();
|
||||
std::vector<StencilPreset> list_stencil_presets();
|
||||
|
||||
// By the name the list shows, or by a path to any .svg.
|
||||
bool load_stencil_preset(const std::string& name_or_path, std::vector<app::MaskShape>& out,
|
||||
// By the name the list shows, or by a path to any .svg, with its set.
|
||||
bool load_stencil_preset(const std::string& name_or_path, app::MaskSet& out,
|
||||
std::string& error);
|
||||
// Replaces every file of a saved set of the same name. `path` is the first
|
||||
// file written.
|
||||
bool save_stencil_preset(const std::string& name, const app::MaskSet& set, std::string& path,
|
||||
std::string& error);
|
||||
// Replaces a saved one of the same name. `path` is the file written.
|
||||
bool save_stencil_preset(const std::string& name, const std::vector<app::MaskShape>& shapes,
|
||||
std::string& path, std::string& error);
|
||||
|
||||
// A run keeps each input's drawn shapes beside the dataset, so they outlive
|
||||
// the session that drew them even if nobody saved them.
|
||||
inline constexpr const char* kDatasetStencilDir = "frame_stencil";
|
||||
// (input path, shapes) per input; one SVG each, named after the input. The
|
||||
// folder is rewritten, so it holds exactly what this run drew. Returns the
|
||||
// files written; an input with no shapes writes none.
|
||||
// (input path, what it draws) per input: <input>.svg, or <input>-<camera>.svg
|
||||
// for each camera of a per-camera set. The folder is rewritten, so it holds
|
||||
// exactly what this run drew. Returns the files written; nothing drawn, none.
|
||||
std::vector<std::string> save_dataset_stencils(
|
||||
const std::string& workspace,
|
||||
const std::vector<std::pair<std::string, std::vector<app::MaskShape>>>& inputs,
|
||||
const std::string& workspace, const std::vector<std::pair<std::string, app::MaskSet>>& inputs,
|
||||
std::string& error);
|
||||
std::vector<StencilPreset> list_dataset_stencils(const std::string& workspace);
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
#include "app/gui/MaskPrompt.h"
|
||||
#include "app/gui/MaskSettings.h"
|
||||
#include "app/gui/Ui.h"
|
||||
#include "core/CubicBezier.h"
|
||||
#include "i18n/catalog/Dataset.h"
|
||||
#include "i18n/catalog/MaskEdit.h"
|
||||
|
||||
@@ -44,6 +45,16 @@ std::string one_decimal(double v) {
|
||||
return buf;
|
||||
}
|
||||
|
||||
// A closed pen path in pane pixels as the polygon commit_stroke fills,
|
||||
// flattened to a quarter of a MASK pixel whatever the zoom.
|
||||
ShapeStroke pen_stroke(const std::vector<float>& anchors, float pane_per_mask) {
|
||||
ShapeStroke s;
|
||||
s.kind = ShapeKind::Polygon;
|
||||
bezier::flatten_closed(anchors.data(), anchors.size() / bezier::kAnchorFloats, 1.0f, 1.0f,
|
||||
0.25f * pane_per_mask, s.pts);
|
||||
return s;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
void MaskSession::destroy_gl() {
|
||||
@@ -146,6 +157,7 @@ void MaskSession::pick_tool(ToolId t) {
|
||||
_tool.set_id(t);
|
||||
set_mode(CanvasMode::Shape);
|
||||
_path.cancel();
|
||||
_pen.cancel();
|
||||
}
|
||||
|
||||
// The eraser is the brush shape under a different paint mode, not a shape of
|
||||
@@ -155,11 +167,19 @@ void MaskSession::pick_eraser() {
|
||||
_tool.set_id(ToolId::Brush);
|
||||
set_mode(CanvasMode::Eraser);
|
||||
_path.cancel();
|
||||
_pen.cancel();
|
||||
}
|
||||
|
||||
void MaskSession::pick_path() {
|
||||
set_mode(CanvasMode::Path);
|
||||
_tool.cancel();
|
||||
_pen.cancel();
|
||||
}
|
||||
|
||||
void MaskSession::pick_pen() {
|
||||
set_mode(CanvasMode::Pen);
|
||||
_tool.cancel();
|
||||
_path.cancel();
|
||||
}
|
||||
|
||||
// A click here is a prompt, not a stroke: nothing half drawn may survive into it.
|
||||
@@ -167,6 +187,7 @@ void MaskSession::pick_sam() {
|
||||
set_mode(CanvasMode::Sam);
|
||||
_tool.cancel();
|
||||
_path.cancel();
|
||||
_pen.cancel();
|
||||
}
|
||||
|
||||
void MaskSession::draw_toolbar() {
|
||||
@@ -174,6 +195,12 @@ void MaskSession::draw_toolbar() {
|
||||
for (int i = (int)ToolId::Box; i <= (int)ToolId::Brush; i++) {
|
||||
const ToolRow& row = tool_table()[i];
|
||||
if (i != (int)ToolId::Box) ImGui::SameLine();
|
||||
// The pen takes the polygon's place and its P, as in a vector editor:
|
||||
// clicked, it draws one.
|
||||
if (row.id == ToolId::Polygon) {
|
||||
if (ui::KeyButton(msg::tool_pen, w, "P", pen_mode())) pick_pen();
|
||||
continue;
|
||||
}
|
||||
if (ui::KeyButton(tool_label(row.id), w, row.key,
|
||||
mode() == CanvasMode::Shape && _tool.id() == row.id))
|
||||
pick_tool(row.id);
|
||||
@@ -277,7 +304,9 @@ void MaskSession::draw_toolbar() {
|
||||
|
||||
// The window may not be narrower than any toolbar row, or that row's right
|
||||
// end clips unseen; _toolbar_w starts each frame at the tool row's width.
|
||||
bool MaskSession::shape_open() const { return _tool.in_progress() || _path.in_progress(); }
|
||||
bool MaskSession::shape_open() const {
|
||||
return _tool.in_progress() || _path.in_progress() || _pen.in_progress();
|
||||
}
|
||||
|
||||
void MaskSession::note_row_width() {
|
||||
_toolbar_w = std::max(_toolbar_w, ImGui::GetItemRectMax().x - ImGui::GetWindowPos().x +
|
||||
@@ -549,6 +578,19 @@ void MaskSession::draw_canvas() {
|
||||
_last_commit_ms = now_ms() - t0;
|
||||
}
|
||||
in_each_pane([&](const ImVec2& o) { draw_path_overlay(dl, o, _path); });
|
||||
} else if (pen_mode()) {
|
||||
_pen.set_space(path_space(m));
|
||||
_pen.note_modifiers(io.KeyShift, io.KeyCtrl);
|
||||
_pen.note_space(space);
|
||||
std::vector<float> anchors;
|
||||
bool consumed = false;
|
||||
if (_pen.update(in, anchors, consumed)) {
|
||||
const double t0 = now_ms();
|
||||
upload_rect(commit_stroke(pen_stroke(anchors, m.scale),
|
||||
paint_now(_pen.mode_shift(), _pen.mode_ctrl()), m));
|
||||
_last_commit_ms = now_ms() - t0;
|
||||
}
|
||||
in_each_pane([&](const ImVec2& o) { draw_pen_overlay(dl, o, _pen); });
|
||||
} else {
|
||||
ShapeStroke stroke;
|
||||
bool consumed = false;
|
||||
@@ -619,8 +661,10 @@ void MaskSession::handle_keys(const Mapping& m) {
|
||||
if (!io.KeyCtrl) {
|
||||
for (int i = (int)ToolId::Box; i <= (int)ToolId::Brush; i++) {
|
||||
const ToolRow& row = tool_table()[i];
|
||||
if (row.id == ToolId::Polygon) continue;
|
||||
if (ImGui::IsKeyPressed((ImGuiKey)row.imgui_key, false)) pick_tool(row.id);
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_P, false)) pick_pen();
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_I, false)) pick_path();
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_X, false)) pick_eraser();
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_G, false) && sam_available()) pick_sam();
|
||||
@@ -628,11 +672,13 @@ void MaskSession::handle_keys(const Mapping& m) {
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_Escape, false)) {
|
||||
if (sam_mode()) sam_cancel();
|
||||
else if (path_mode()) _path.cancel();
|
||||
else if (pen_mode()) _pen.cancel();
|
||||
else _tool.cancel();
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_Enter, false) ||
|
||||
ImGui::IsKeyPressed(ImGuiKey_KeypadEnter, false)) {
|
||||
const Paint mode = path_mode() ? paint_now(_path.mode_shift(), _path.mode_ctrl())
|
||||
: pen_mode() ? paint_now(_pen.mode_shift(), _pen.mode_ctrl())
|
||||
: paint_now(io.KeyShift, io.KeyCtrl);
|
||||
ShapeStroke s;
|
||||
bool pending = false;
|
||||
@@ -641,6 +687,10 @@ void MaskSession::handle_keys(const Mapping& m) {
|
||||
pending = _path.commit_pending(poly);
|
||||
s.kind = ShapeKind::Polygon;
|
||||
s.pts = std::move(poly);
|
||||
} else if (pen_mode()) {
|
||||
std::vector<float> anchors;
|
||||
pending = _pen.commit_pending(anchors);
|
||||
if (pending) s = pen_stroke(anchors, m.scale);
|
||||
} else {
|
||||
pending = _tool.commit_pending(s);
|
||||
}
|
||||
@@ -652,7 +702,11 @@ void MaskSession::handle_keys(const Mapping& m) {
|
||||
}
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_LeftBracket, true)) set_radius(step_brush(radius(), false));
|
||||
if (ImGui::IsKeyPressed(ImGuiKey_RightBracket, true)) set_radius(step_brush(radius(), true));
|
||||
if (pen_mode() && _pen.in_progress() &&
|
||||
(ImGui::IsKeyPressed(ImGuiKey_Backspace, false) || ImGui::IsKeyPressed(ImGuiKey_Delete, false)))
|
||||
_pen.pop_anchor();
|
||||
if (io.KeyCtrl && ImGui::IsKeyPressed(ImGuiKey_Z, false)) {
|
||||
if (!io.KeyShift && pen_mode() && _pen.in_progress() && _pen.pop_anchor()) return;
|
||||
if (!io.KeyShift && path_mode() && _path.in_progress() && _path.pop_anchor()) return;
|
||||
if (!io.KeyShift && !path_mode() && _tool.id() == ToolId::Polygon && _tool.in_progress() &&
|
||||
_tool.pop_point())
|
||||
@@ -753,6 +807,10 @@ void MaskSession::draw_status() {
|
||||
ui::Text(msg::path_anchors, {_path.anchor_count()});
|
||||
if (!_path.snapping()) ui::TextDisabledWrapped(msg::path_straight);
|
||||
}
|
||||
if (pen_mode()) {
|
||||
ui::TextDisabledWrapped(msg::hint_pen);
|
||||
ui::Text(msg::pen_anchors, {_pen.anchor_count()});
|
||||
}
|
||||
ui::TextDisabledWrapped(msg::hint_view);
|
||||
ui::TextDisabledWrapped(_view_mode == ViewMode::SideBySide ? msg::peek_hint_side
|
||||
: msg::peek_hint, {"Tab"});
|
||||
|
||||
@@ -432,6 +432,7 @@ void MaskSession::pump() {
|
||||
_livewire.reset();
|
||||
_path.set_livewire(nullptr);
|
||||
_path.cancel();
|
||||
_pen.cancel();
|
||||
_win_dirty = true;
|
||||
}
|
||||
|
||||
@@ -1654,7 +1655,7 @@ size_t MaskSession::sam_held_bytes() const {
|
||||
|
||||
void MaskSession::start_slideshow() {
|
||||
if (_slide_playing || frame_count() < 2 || _idx < 0 || !idle() || sam_work_pending() ||
|
||||
_path.in_progress())
|
||||
_pen.in_progress() || _path.in_progress())
|
||||
return;
|
||||
// Both want the same memory and never need it at once.
|
||||
sam_yield();
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "app/gui/mask/MaskWindow.h"
|
||||
#include "app/gui/mask/Livewire.h"
|
||||
#include "app/gui/mask/PathTool.h"
|
||||
#include "app/gui/mask/PenTool.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
@@ -51,7 +52,7 @@ struct SamOps {
|
||||
|
||||
// What a left click on the canvas does. One value rather than a flag per tool,
|
||||
// so no two can be on at once, whichever picker forgets what.
|
||||
enum class CanvasMode { Shape, Eraser, Path, Sam };
|
||||
enum class CanvasMode { Shape, Eraser, Path, Pen, Sam };
|
||||
|
||||
// The continuous inverse of to_stored (MaskDoc.h): a stored point to the
|
||||
// displayed frame.
|
||||
@@ -164,6 +165,7 @@ public:
|
||||
void set_mode(CanvasMode m) { _mode = m; }
|
||||
bool erasing() const { return _mode == CanvasMode::Eraser; }
|
||||
bool path_mode() const { return _mode == CanvasMode::Path; }
|
||||
bool pen_mode() const { return _mode == CanvasMode::Pen; }
|
||||
bool sam_mode() const { return _mode == CanvasMode::Sam; }
|
||||
// Off returns to the shapes, which is where every other picker leaves it.
|
||||
void set_erasing(bool on) { _mode = on ? CanvasMode::Eraser : CanvasMode::Shape; }
|
||||
@@ -305,6 +307,7 @@ public:
|
||||
float sam_click_y() const { return _sam_click_y; }
|
||||
float canvas_height() const { return _canvas_h; }
|
||||
int path_anchors() const { return _path.anchor_count(); }
|
||||
int pen_anchors() const { return _pen.anchor_count(); }
|
||||
// The editor's own drop margin (its MaskSettings), -1 before SAM was used.
|
||||
float sam_margin() const;
|
||||
|
||||
@@ -387,6 +390,7 @@ public:
|
||||
// a record of never-edited targets is 0 bytes, so 256 MB is unreachable.
|
||||
void set_propagate_byte_cap_for_test(size_t bytes) { _prop_byte_cap = bytes; }
|
||||
PathTool& path_for_test() { return _path; }
|
||||
PenTool& pen_for_test() { return _pen; }
|
||||
// A SAM job runs, its result waits for sam_pump(), or a margin re-apply
|
||||
// waits to start: propagate and Play wait for it, since Play
|
||||
// drops a waiting result. The job is asked through SamOps, as close() does.
|
||||
@@ -467,6 +471,7 @@ private:
|
||||
void pick_tool(ToolId t);
|
||||
void pick_eraser();
|
||||
void pick_path();
|
||||
void pick_pen();
|
||||
void pick_sam();
|
||||
void draw_toolbar();
|
||||
void draw_canvas();
|
||||
@@ -521,6 +526,7 @@ private:
|
||||
double _last_commit_ms = 0.0;
|
||||
|
||||
PathTool _path;
|
||||
PenTool _pen;
|
||||
std::unique_ptr<Livewire> _livewire; // the open frame's edge map, built on first use
|
||||
// Created on first use; its session is released by sam_yield() and a model
|
||||
// change, and close() releases it and then drops the object, clicks included.
|
||||
|
||||
@@ -3,9 +3,12 @@
|
||||
#include "app/gui/mask/PathOverlay.h"
|
||||
|
||||
#include "app/gui/mask/PathTool.h"
|
||||
#include "app/gui/mask/PenTool.h"
|
||||
#include "core/CubicBezier.h"
|
||||
|
||||
#include "imgui.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <vector>
|
||||
|
||||
namespace gui {
|
||||
@@ -36,5 +39,94 @@ void draw_path_overlay(ImDrawList* dl, const ImVec2& o, const PathTool& tool) {
|
||||
if (can_close) dl->AddCircle(first, 12.0f, IM_COL32(120, 255, 140, 200), 0, 2.0f);
|
||||
}
|
||||
|
||||
void draw_bezier(ImDrawList* dl, const ImVec2& o, const float* a, size_t n, bool closed,
|
||||
unsigned line_color, float thickness) {
|
||||
if (!dl || n == 0) return;
|
||||
std::vector<float> pts;
|
||||
for (size_t i = 0; i + (closed ? 0 : 1) < n; i++) {
|
||||
float c[8];
|
||||
bezier::segment(a, n, i, c);
|
||||
const int m = bezier::pieces(c, 0.3f);
|
||||
if (i == 0) dl->PathLineTo(ImVec2(o.x + c[0], o.y + c[1]));
|
||||
for (int k = 1; k <= m; k++) {
|
||||
float x, y;
|
||||
bezier::eval(c, (float)k / (float)m, x, y);
|
||||
dl->PathLineTo(ImVec2(o.x + x, o.y + y));
|
||||
}
|
||||
}
|
||||
dl->PathStroke(line_color, 0, thickness);
|
||||
}
|
||||
|
||||
void draw_bezier_points(ImDrawList* dl, const ImVec2& o, const float* a, size_t n, int active,
|
||||
bool handles) {
|
||||
if (!dl) return;
|
||||
const ImU32 ink = IM_COL32(30, 30, 30, 230);
|
||||
const ImU32 paper = IM_COL32(255, 255, 255, 240);
|
||||
const ImU32 lit = IM_COL32(70, 150, 255, 255);
|
||||
for (size_t k = 0; handles && k < n; k++) {
|
||||
const float* p = a + bezier::kAnchorFloats * k;
|
||||
const ImVec2 at(o.x + p[2], o.y + p[3]);
|
||||
for (int j : {0, 4}) {
|
||||
const ImVec2 h(o.x + p[j], o.y + p[j + 1]);
|
||||
if (std::hypot(h.x - at.x, h.y - at.y) < 1.0f) continue;
|
||||
dl->AddLine(at, h, IM_COL32(70, 150, 255, 200), 1.0f);
|
||||
dl->AddCircleFilled(h, 3.5f, (int)k == active ? lit : paper);
|
||||
dl->AddCircle(h, 3.5f, ink, 0, 1.0f);
|
||||
}
|
||||
}
|
||||
// Squares for anchors and circles for handles, the vector editors' shorthand.
|
||||
for (size_t k = 0; k < n; k++) {
|
||||
const float* p = a + bezier::kAnchorFloats * k;
|
||||
const ImVec2 lo(o.x + p[2] - 3.5f, o.y + p[3] - 3.5f), hi(lo.x + 7.0f, lo.y + 7.0f);
|
||||
dl->AddRectFilled(lo, hi, (int)k == active ? lit : paper);
|
||||
dl->AddRect(lo, hi, ink, 0.0f, 0, 1.0f);
|
||||
}
|
||||
}
|
||||
|
||||
void draw_pen_overlay(ImDrawList* dl, const ImVec2& o, const PenTool& tool) {
|
||||
if (!dl) return;
|
||||
const ImU32 line = IM_COL32(255, 190, 60, 230);
|
||||
std::vector<float> a;
|
||||
tool.overlay(a);
|
||||
const size_t n = a.size() / bezier::kAnchorFloats;
|
||||
if (n > 0) {
|
||||
draw_bezier(dl, o, a.data(), n, /*closed=*/false, line, 2.0f);
|
||||
float c[8];
|
||||
if (tool.preview(c)) {
|
||||
const int m = bezier::pieces(c, 0.3f);
|
||||
for (int k = 0; k <= m; k++) {
|
||||
float x, y;
|
||||
bezier::eval(c, (float)k / (float)m, x, y);
|
||||
dl->PathLineTo(ImVec2(o.x + x, o.y + y));
|
||||
}
|
||||
dl->PathStroke(IM_COL32(255, 190, 60, 140), 0, 1.5f);
|
||||
}
|
||||
draw_bezier_points(dl, o, a.data(), n, tool.active_anchor(), /*handles=*/true);
|
||||
}
|
||||
// The badge beside the pointer that says what a click does, where a
|
||||
// vector editor would change the cursor itself.
|
||||
const ImVec2 at(o.x + tool.pointer_x() + 10.0f, o.y + tool.pointer_y() + 10.0f);
|
||||
const ImU32 badge = IM_COL32(255, 255, 255, 230);
|
||||
switch (tool.cue()) {
|
||||
case PenTool::Cue::Close:
|
||||
dl->AddCircle(at, 4.0f, IM_COL32(120, 255, 140, 255), 0, 2.0f);
|
||||
if (n > 0) {
|
||||
const ImVec2 first(o.x + a[2], o.y + a[3]);
|
||||
dl->AddCircle(first, 9.0f, IM_COL32(120, 255, 140, 220), 0, 2.0f);
|
||||
}
|
||||
break;
|
||||
case PenTool::Cue::Retract:
|
||||
dl->AddLine(ImVec2(at.x - 4.0f, at.y + 3.0f), ImVec2(at.x, at.y - 3.0f), badge, 1.5f);
|
||||
dl->AddLine(ImVec2(at.x, at.y - 3.0f), ImVec2(at.x + 4.0f, at.y + 3.0f), badge, 1.5f);
|
||||
break;
|
||||
case PenTool::Cue::Edit:
|
||||
dl->AddTriangleFilled(ImVec2(at.x - 3.0f, at.y - 5.0f), ImVec2(at.x - 3.0f, at.y + 5.0f),
|
||||
ImVec2(at.x + 5.0f, at.y + 1.0f), badge);
|
||||
break;
|
||||
case PenTool::Cue::None:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace mask
|
||||
} // namespace gui
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
#pragma once
|
||||
|
||||
// Draws a PathTool over a canvas: the committed polyline, the live segment
|
||||
// to the cursor, the anchors, and the first anchor lit when a click would
|
||||
// close on it. The ImGui half of the pen tool; PathTool.cpp has none.
|
||||
// Draws the two pen tools over a canvas: PathTool's committed polyline and
|
||||
// live segment, PenTool's curves, anchors and handles, and the first anchor
|
||||
// lit when a click would close on it. The ImGui half of both; PathTool.cpp
|
||||
// and PenTool.cpp have none.
|
||||
|
||||
#include <cstddef>
|
||||
|
||||
struct ImDrawList;
|
||||
struct ImVec2;
|
||||
@@ -11,8 +14,18 @@ namespace gui {
|
||||
namespace mask {
|
||||
|
||||
class PathTool;
|
||||
class PenTool;
|
||||
|
||||
void draw_path_overlay(ImDrawList* dl, const ImVec2& origin, const PathTool& tool);
|
||||
void draw_pen_overlay(ImDrawList* dl, const ImVec2& origin, const PenTool& tool);
|
||||
|
||||
// Anchors (six floats each, canvas pixels from `origin`) as squares, the
|
||||
// `active` one filled, with their handles when `handles`. `closed` draws the
|
||||
// segment back to the first anchor too.
|
||||
void draw_bezier(ImDrawList* dl, const ImVec2& origin, const float* anchors, size_t n,
|
||||
bool closed, unsigned line_color, float thickness);
|
||||
void draw_bezier_points(ImDrawList* dl, const ImVec2& origin, const float* anchors, size_t n,
|
||||
int active, bool handles);
|
||||
|
||||
} // namespace mask
|
||||
} // namespace gui
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
// PenTool.cpp -- see PenTool.h.
|
||||
|
||||
#include "app/gui/mask/PenTool.h"
|
||||
|
||||
#include "core/CubicBezier.h"
|
||||
|
||||
#include <cmath>
|
||||
|
||||
namespace gui {
|
||||
namespace mask {
|
||||
|
||||
void snap_45(float ox, float oy, float& x, float& y) {
|
||||
const float dx = x - ox, dy = y - oy, len = std::hypot(dx, dy);
|
||||
if (!(len > 0.0f)) return;
|
||||
constexpr float kStep = 0.78539816f;
|
||||
const float a = std::round(std::atan2(dy, dx) / kStep) * kStep;
|
||||
x = ox + len * std::cos(a);
|
||||
y = oy + len * std::sin(a);
|
||||
}
|
||||
|
||||
void PenTool::to_frame(float x, float y, float& fx, float& fy) const {
|
||||
if (_space.to_frame) {
|
||||
_space.to_frame(x, y, fx, fy);
|
||||
} else {
|
||||
fx = x;
|
||||
fy = y;
|
||||
}
|
||||
}
|
||||
|
||||
void PenTool::from_frame(float fx, float fy, float& x, float& y) const {
|
||||
if (_space.from_frame) {
|
||||
_space.from_frame(fx, fy, x, y);
|
||||
} else {
|
||||
x = fx;
|
||||
y = fy;
|
||||
}
|
||||
}
|
||||
|
||||
int PenTool::anchor_count() const { return (int)(_a.size() / bezier::kAnchorFloats); }
|
||||
|
||||
bool PenTool::can_close() const {
|
||||
const int n = anchor_count();
|
||||
if (n >= 3) return true;
|
||||
return n == 2 && !(bezier::is_corner(anchor(0), 1e-3f) && bezier::is_corner(anchor(1), 1e-3f));
|
||||
}
|
||||
|
||||
// From the pointer to anchor k's float pair j (0 in, 2 point, 4 out), fed px.
|
||||
float PenTool::fed_distance(int k, int j) const {
|
||||
float x, y;
|
||||
from_frame(anchor(k)[j], anchor(k)[j + 1], x, y);
|
||||
return std::hypot(_cur[0] - x, _cur[1] - y);
|
||||
}
|
||||
|
||||
bool PenTool::near_first() const {
|
||||
return in_progress() && fed_distance(0, 2) <= kPathCloseRadius;
|
||||
}
|
||||
|
||||
bool PenTool::near_last() const {
|
||||
return in_progress() && fed_distance(anchor_count() - 1, 2) <= kPenPickRadius;
|
||||
}
|
||||
|
||||
// Handles before points, so a handle pulled short can still be caught.
|
||||
bool PenTool::hit_point(int& k, Part& part) const {
|
||||
float best = kPenPickRadius;
|
||||
k = -1;
|
||||
for (int i = 0; i < anchor_count(); i++)
|
||||
for (int j : {0, 4}) {
|
||||
// A handle folded onto its point is the anchor, not a handle.
|
||||
float hx, hy, px, py;
|
||||
from_frame(anchor(i)[j], anchor(i)[j + 1], hx, hy);
|
||||
from_frame(anchor(i)[2], anchor(i)[3], px, py);
|
||||
if (std::hypot(hx - px, hy - py) < 1.0f) continue;
|
||||
const float d = fed_distance(i, j);
|
||||
if (d <= best) {
|
||||
best = d;
|
||||
k = i;
|
||||
part = j == 0 ? Part::In : Part::Out;
|
||||
}
|
||||
}
|
||||
if (k >= 0) return true;
|
||||
best = kPenPickRadius;
|
||||
for (int i = 0; i < anchor_count(); i++) {
|
||||
const float d = fed_distance(i, 2);
|
||||
if (d <= best) {
|
||||
best = d;
|
||||
k = i;
|
||||
part = Part::Anchor;
|
||||
}
|
||||
}
|
||||
return k >= 0;
|
||||
}
|
||||
|
||||
bool PenTool::update(const ViewportInput& in, std::vector<float>& out, bool& consumed) {
|
||||
consumed = false;
|
||||
_cur[0] = in.x;
|
||||
_cur[1] = in.y;
|
||||
_hovered = in.hovered;
|
||||
_ctrl = in.ctrl;
|
||||
if (in.hovered && in.clicked) {
|
||||
consumed = true;
|
||||
Drag d;
|
||||
d.press[0] = d.last[0] = in.x;
|
||||
d.press[1] = d.last[1] = in.y;
|
||||
int k = -1;
|
||||
Part part = Part::Anchor;
|
||||
if (in_progress() && in.ctrl) {
|
||||
// Ctrl is the vector editors' "direct selection for now".
|
||||
if (hit_point(k, part)) {
|
||||
d.kind = DragKind::Point;
|
||||
d.anchor = k;
|
||||
d.part = part;
|
||||
const int j = part == Part::In ? 0 : part == Part::Out ? 4 : 2;
|
||||
from_frame(anchor(k)[j], anchor(k)[j + 1], d.origin[0], d.origin[1]);
|
||||
_drag = d;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
if (can_close() && near_first()) {
|
||||
d.kind = DragKind::Close;
|
||||
d.anchor = 0;
|
||||
_drag = d;
|
||||
return false;
|
||||
}
|
||||
if (near_last()) {
|
||||
d.kind = DragKind::Redrag;
|
||||
d.anchor = anchor_count() - 1;
|
||||
_drag = d;
|
||||
return false;
|
||||
}
|
||||
float x = in.x, y = in.y;
|
||||
if (in_progress() && in.shift) {
|
||||
float lx, ly;
|
||||
const float* l = anchor(anchor_count() - 1);
|
||||
from_frame(l[2], l[3], lx, ly);
|
||||
snap_45(lx, ly, x, y);
|
||||
}
|
||||
float fx, fy;
|
||||
to_frame(x, y, fx, fy);
|
||||
_a.insert(_a.end(), {fx, fy, fx, fy, fx, fy});
|
||||
d.kind = DragKind::Place;
|
||||
d.anchor = anchor_count() - 1;
|
||||
_drag = d;
|
||||
return false;
|
||||
}
|
||||
if (_drag.kind != DragKind::None) {
|
||||
if (in.down) {
|
||||
consumed = true;
|
||||
drag_to(in);
|
||||
return false;
|
||||
}
|
||||
const Drag d = _drag;
|
||||
_drag = Drag{};
|
||||
// A click on the anchor just placed takes its out-handle back, so the
|
||||
// next segment leaves it straight: the cusp every pen tool makes so.
|
||||
if (d.kind == DragKind::Redrag && !d.pulled) {
|
||||
float* a = anchor(d.anchor);
|
||||
a[4] = a[2];
|
||||
a[5] = a[3];
|
||||
}
|
||||
if (d.kind == DragKind::Close) return commit_pending(out);
|
||||
return false;
|
||||
}
|
||||
if (in_progress() && in.right_clicked) {
|
||||
consumed = true;
|
||||
return commit_pending(out);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void PenTool::drag_to(const ViewportInput& in) {
|
||||
Drag& d = _drag;
|
||||
if (d.anchor < 0 || d.anchor >= anchor_count()) return;
|
||||
float* a = anchor(d.anchor);
|
||||
float ax, ay;
|
||||
from_frame(a[2], a[3], ax, ay);
|
||||
float x = in.x, y = in.y, fx, fy;
|
||||
|
||||
if (d.kind == DragKind::Point) {
|
||||
// By the pointer's travel, so the grab point does not jump onto it.
|
||||
x = d.origin[0] + in.x - d.press[0];
|
||||
y = d.origin[1] + in.y - d.press[1];
|
||||
if (d.part == Part::Anchor) {
|
||||
if (in.shift) snap_45(d.origin[0], d.origin[1], x, y);
|
||||
to_frame(x, y, fx, fy);
|
||||
bezier::move_anchor(a, fx, fy);
|
||||
} else {
|
||||
if (in.shift) snap_45(ax, ay, x, y);
|
||||
to_frame(x, y, fx, fy);
|
||||
const bool smooth = !in.alt && bezier::is_smooth(a, 1.0f, 1.0f);
|
||||
bezier::set_handle(a, d.part == Part::Out, fx, fy, smooth, 1.0f, 1.0f);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (d.kind == DragKind::Place && _space_held) {
|
||||
to_frame(ax + in.x - d.last[0], ay + in.y - d.last[1], fx, fy);
|
||||
bezier::move_anchor(a, fx, fy);
|
||||
d.last[0] = in.x;
|
||||
d.last[1] = in.y;
|
||||
// The handles follow from here, not from where the button went down.
|
||||
d.press[0] = in.x;
|
||||
d.press[1] = in.y;
|
||||
return;
|
||||
}
|
||||
d.last[0] = in.x;
|
||||
d.last[1] = in.y;
|
||||
if (!d.pulled && std::hypot(in.x - d.press[0], in.y - d.press[1]) < kPenDragStart) return;
|
||||
d.pulled = true;
|
||||
d.split = d.split || in.alt;
|
||||
if (in.shift) snap_45(ax, ay, x, y);
|
||||
to_frame(x, y, fx, fy);
|
||||
const float mx = 2.0f * a[2] - fx, my = 2.0f * a[3] - fy;
|
||||
switch (d.kind) {
|
||||
case DragKind::Place:
|
||||
// Alt freezes the in-handle where it is and steers the out alone.
|
||||
a[4] = fx;
|
||||
a[5] = fy;
|
||||
if (!d.split) {
|
||||
a[0] = mx;
|
||||
a[1] = my;
|
||||
}
|
||||
break;
|
||||
case DragKind::Redrag:
|
||||
a[4] = fx;
|
||||
a[5] = fy;
|
||||
break;
|
||||
case DragKind::Close:
|
||||
// The in-handle shapes the closing segment; Alt keeps the first
|
||||
// segment as it was drawn.
|
||||
a[0] = mx;
|
||||
a[1] = my;
|
||||
if (!d.split) {
|
||||
a[4] = fx;
|
||||
a[5] = fy;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
bool PenTool::commit_pending(std::vector<float>& out) {
|
||||
out.clear();
|
||||
if (!can_close()) return false;
|
||||
out.resize(_a.size());
|
||||
for (size_t i = 0; i + 1 < _a.size(); i += 2) from_frame(_a[i], _a[i + 1], out[i], out[i + 1]);
|
||||
cancel();
|
||||
return true;
|
||||
}
|
||||
|
||||
void PenTool::cancel() {
|
||||
_a.clear();
|
||||
_drag = Drag{};
|
||||
}
|
||||
|
||||
bool PenTool::pop_anchor() {
|
||||
if (_a.empty()) return false;
|
||||
_a.resize(_a.size() - bezier::kAnchorFloats);
|
||||
_drag = Drag{};
|
||||
return true;
|
||||
}
|
||||
|
||||
void PenTool::note_modifiers(bool shift, bool ctrl) {
|
||||
if (in_progress()) return;
|
||||
_mode_shift = shift;
|
||||
_mode_ctrl = ctrl;
|
||||
}
|
||||
|
||||
PenTool::Cue PenTool::cue() const {
|
||||
if (!_hovered || !in_progress() || _drag.kind != DragKind::None) return Cue::None;
|
||||
int k;
|
||||
Part part;
|
||||
if (_ctrl) return hit_point(k, part) ? Cue::Edit : Cue::None;
|
||||
if (can_close() && near_first()) return Cue::Close;
|
||||
if (near_last()) return Cue::Retract;
|
||||
return Cue::None;
|
||||
}
|
||||
|
||||
void PenTool::overlay(std::vector<float>& anchors) const {
|
||||
anchors.resize(_a.size());
|
||||
for (size_t i = 0; i + 1 < _a.size(); i += 2)
|
||||
from_frame(_a[i], _a[i + 1], anchors[i], anchors[i + 1]);
|
||||
}
|
||||
|
||||
bool PenTool::preview(float c[8]) const {
|
||||
if (!in_progress() || _drag.kind != DragKind::None || !_hovered || _ctrl) return false;
|
||||
const float* l = anchor(anchor_count() - 1);
|
||||
from_frame(l[2], l[3], c[0], c[1]);
|
||||
from_frame(l[4], l[5], c[2], c[3]);
|
||||
c[4] = c[6] = _cur[0];
|
||||
c[5] = c[7] = _cur[1];
|
||||
return true;
|
||||
}
|
||||
|
||||
int PenTool::active_anchor() const {
|
||||
if (_drag.kind != DragKind::None) return _drag.anchor;
|
||||
return anchor_count() - 1;
|
||||
}
|
||||
|
||||
} // namespace mask
|
||||
} // namespace gui
|
||||
@@ -0,0 +1,96 @@
|
||||
#pragma once
|
||||
|
||||
// The pen, as vector editors have it: a click drops a corner, a drag pulls a
|
||||
// smooth anchor's handles, and the first anchor, Enter or a right click
|
||||
// closes the path into a cubic Bezier outline (core/CubicBezier.h). Fed one
|
||||
// ViewportInput a frame; keeps its points in frame pixels through PathSpace,
|
||||
// as PathTool does, so the view may zoom mid-path. No ImGui: PathOverlay.cpp
|
||||
// draws it. Keys and modifiers: docs/notes/mask-editor.md.
|
||||
|
||||
#include "app/gui/ViewportInput.h"
|
||||
#include "app/gui/mask/PathTool.h"
|
||||
|
||||
#include <vector>
|
||||
|
||||
namespace gui {
|
||||
namespace mask {
|
||||
|
||||
inline constexpr float kPenPickRadius = 7.0f; // fed pixels
|
||||
inline constexpr float kPenDragStart = 3.0f; // fed pixels before a press is a drag
|
||||
|
||||
// (x, y) turned about (ox, oy) to the nearest multiple of 45 degrees, its
|
||||
// distance kept: what Shift does to a point or a handle.
|
||||
void snap_45(float ox, float oy, float& x, float& y);
|
||||
|
||||
class PenTool {
|
||||
public:
|
||||
void set_space(const PathSpace& s) { _space = s; }
|
||||
bool in_progress() const { return !_a.empty(); }
|
||||
int anchor_count() const;
|
||||
// Three anchors, or two with a curve between them.
|
||||
bool can_close() const;
|
||||
|
||||
// One frame. True when the path closed: `out` is its anchors, six floats
|
||||
// each, in fed pixels. `consumed` says the left button was the tool's.
|
||||
bool update(const ViewportInput& in, std::vector<float>& out, bool& consumed);
|
||||
bool commit_pending(std::vector<float>& out);
|
||||
void cancel();
|
||||
bool pop_anchor();
|
||||
// A drag is pulling handles or moving a point; frame keys wait for it.
|
||||
bool dragging() const { return _drag.kind != DragKind::None; }
|
||||
|
||||
// PathTool's rule: mirrored while idle, frozen on the first anchor.
|
||||
void note_modifiers(bool shift, bool ctrl);
|
||||
bool mode_shift() const { return _mode_shift; }
|
||||
bool mode_ctrl() const { return _mode_ctrl; }
|
||||
// Space held mid-drag moves the new anchor rather than its handles.
|
||||
void note_space(bool held) { _space_held = held; }
|
||||
|
||||
// What a click would do where the pointer is, for the cursor's badge.
|
||||
enum class Cue { None, Close, Retract, Edit };
|
||||
Cue cue() const;
|
||||
|
||||
// For the overlay, all in fed pixels: every anchor, and the segment the
|
||||
// next click would add, from the last anchor to the pointer.
|
||||
void overlay(std::vector<float>& anchors) const;
|
||||
bool preview(float c[8]) const;
|
||||
// The anchor being dragged, or the last one; -1 with none.
|
||||
int active_anchor() const;
|
||||
float pointer_x() const { return _cur[0]; }
|
||||
float pointer_y() const { return _cur[1]; }
|
||||
|
||||
private:
|
||||
enum class DragKind { None, Place, Redrag, Close, Point };
|
||||
enum class Part { In, Anchor, Out };
|
||||
struct Drag {
|
||||
DragKind kind = DragKind::None;
|
||||
int anchor = -1;
|
||||
Part part = Part::Anchor;
|
||||
float press[2] = {0.0f, 0.0f}; // fed px
|
||||
float last[2] = {0.0f, 0.0f}; // fed px, for Space
|
||||
float origin[2] = {0.0f, 0.0f}; // fed px: where a grabbed point was
|
||||
bool pulled = false;
|
||||
bool split = false;
|
||||
};
|
||||
|
||||
void to_frame(float x, float y, float& fx, float& fy) const;
|
||||
void from_frame(float fx, float fy, float& x, float& y) const;
|
||||
float* anchor(int k) { return &_a[6 * (size_t)k]; }
|
||||
const float* anchor(int k) const { return &_a[6 * (size_t)k]; }
|
||||
float fed_distance(int k, int j) const;
|
||||
bool near_first() const;
|
||||
bool near_last() const;
|
||||
bool hit_point(int& k, Part& part) const;
|
||||
void drag_to(const ViewportInput& in);
|
||||
|
||||
PathSpace _space;
|
||||
std::vector<float> _a; // frame px, six per anchor
|
||||
Drag _drag;
|
||||
float _cur[2] = {0.0f, 0.0f}; // fed px
|
||||
bool _hovered = false, _ctrl = false;
|
||||
bool _space_held = false;
|
||||
bool _mode_shift = false, _mode_ctrl = false;
|
||||
};
|
||||
|
||||
} // namespace mask
|
||||
} // namespace gui
|
||||
@@ -1,13 +1,16 @@
|
||||
// dataset_prep_test -- the two places DatasetPrep (app/gui/DatasetPrep.h) meets
|
||||
// the mask editor's layer folder: a re-run re-applies hand corrections over
|
||||
// the masks it rewrites, and the camera scan never takes mask_edits/ for a
|
||||
// camera. Real DatasetPrep::run, no model: the re-mask is the frame stencil.
|
||||
// camera. Real DatasetPrep::run, no model: the re-mask is the frame stencil,
|
||||
// which is also checked per camera folder. Built without SS_BUILD_SAM, so it
|
||||
// also checks that asking for a model fails.
|
||||
|
||||
#include "app/FrameMask.h"
|
||||
#include "app/gui/DatasetPrep.h"
|
||||
#include "app/gui/mask/MaskLayer.h"
|
||||
#include "core/SourcePath.h"
|
||||
#include "external/stb_image_write.h"
|
||||
#include "i18n/catalog/Log.h"
|
||||
#include "i18n/catalog/MaskEdit.h"
|
||||
|
||||
#include <algorithm>
|
||||
@@ -163,11 +166,66 @@ void test_camera_scan_skips_mask_edits() {
|
||||
"is_mask_edits_folder: by name");
|
||||
}
|
||||
|
||||
// An input whose cameras have their own stencils: cam1's replaces the
|
||||
// input's for its frames, and cam0 keeps the input's.
|
||||
void test_per_camera_stencil() {
|
||||
const int W = 40, H = 20;
|
||||
const fs::path root = scratch("percamera");
|
||||
const fs::path photos = root / "photos", ws = root / "dataset";
|
||||
for (const char* f : {"f.jpg", "g.jpg"}) {
|
||||
write_jpg(photos / "cam0" / f, W, H, 0);
|
||||
write_jpg(photos / "cam1" / f, W, H, 1);
|
||||
}
|
||||
gui::PrepJob job;
|
||||
job.workspace = ws.string();
|
||||
job.photo_import = gui::PhotoImport::InPlace;
|
||||
gui::PrepInput in;
|
||||
in.path = photos.string();
|
||||
std::string err;
|
||||
app::parse_mask_shapes("-rect 0,0,0.5,1", in.stencil.mask.shapes, err);
|
||||
app::parse_mask_shapes("-rect 0.5,0,1,1", in.stencil.cameras["cam1"].mask.shapes, err);
|
||||
job.inputs = {in};
|
||||
gui::RunProgress prog;
|
||||
check(run_prep(job, prog, err), "per camera: run: " + err);
|
||||
std::vector<uint8_t> m0, m1;
|
||||
int w = 0, h = 0;
|
||||
const bool read = app::load_stencil((ws / "masks" / "cam0" / "f.png").string(), w, h, m0) &&
|
||||
app::load_stencil((ws / "masks" / "cam1" / "f.png").string(), w, h, m1) &&
|
||||
m0.size() == (size_t)W * H && m1.size() == m0.size();
|
||||
check(read, "per camera: a mask per camera folder");
|
||||
if (!read) return;
|
||||
check(at(m0, W, 5, 10) == 0 && at(m0, W, 35, 10) == 255,
|
||||
"per camera: cam0 has the input's shapes, the left half out");
|
||||
check(at(m1, W, 5, 10) == 255 && at(m1, W, 35, 10) == 0,
|
||||
"per camera: cam1 has its own, the right half out");
|
||||
}
|
||||
|
||||
void test_model_masking_needs_segmentation() {
|
||||
const fs::path root = scratch("nosam");
|
||||
const fs::path photos = root / "photos";
|
||||
for (int i = 0; i < 3; i++) write_jpg(photos / (std::string(1, (char)('a' + i)) + ".jpg"), 64, 48, i);
|
||||
gui::PrepJob job;
|
||||
job.workspace = (root / "dataset").string();
|
||||
job.photo_import = gui::PhotoImport::InPlace;
|
||||
job.mask_enable = true;
|
||||
job.mask_prompt = "person";
|
||||
gui::PrepInput in;
|
||||
in.path = photos.string();
|
||||
job.inputs = {in};
|
||||
gui::RunProgress prog;
|
||||
std::string err;
|
||||
const bool ran = run_prep(job, prog, err);
|
||||
check(!ran && err == spirula::i18n::msg::log::err_no_builtin_segmentation.get(),
|
||||
"no segmentation module: a model-masking run is refused: " + err);
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
test_rerun_reapplies_corrections();
|
||||
test_camera_scan_skips_mask_edits();
|
||||
test_per_camera_stencil();
|
||||
test_model_masking_needs_segmentation();
|
||||
std::printf("%s: %d failure(s)\n", SS_FILE, g_failures);
|
||||
return g_failures;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
#include "app/gui/mask/MaskSam.h"
|
||||
#include "app/gui/mask/MaskDoc.h"
|
||||
#include "app/gui/mask/PathTool.h"
|
||||
#include "app/gui/mask/PenTool.h"
|
||||
#include "app/gui/mask/MaskLayer.h"
|
||||
#include "app/gui/mask/MaskSession.h"
|
||||
#include "app/gui/mask/MaskSlideshow.h"
|
||||
@@ -3874,6 +3875,216 @@ void test_path_tool_space() {
|
||||
"closed polygon starts at the fed first anchor");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// The Bezier pen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
gui::ViewportInput held_at(float x, float y) {
|
||||
gui::ViewportInput in = at(x, y);
|
||||
in.down = true;
|
||||
return in;
|
||||
}
|
||||
|
||||
// Press, drag through (x, y) and let go there.
|
||||
void pen_drag(mk::PenTool& t, float px, float py, float x, float y,
|
||||
gui::ViewportInput mods = gui::ViewportInput{}) {
|
||||
std::vector<float> out;
|
||||
bool consumed;
|
||||
auto with = [&](gui::ViewportInput in) {
|
||||
in.shift = mods.shift;
|
||||
in.alt = mods.alt;
|
||||
in.ctrl = mods.ctrl;
|
||||
return in;
|
||||
};
|
||||
t.update(with(click_at(px, py)), out, consumed);
|
||||
t.update(with(held_at(0.5f * (px + x), 0.5f * (py + y))), out, consumed);
|
||||
t.update(with(held_at(x, y)), out, consumed);
|
||||
t.update(with(at(x, y)), out, consumed);
|
||||
}
|
||||
|
||||
void test_pen_tool_basic() {
|
||||
mk::PenTool t;
|
||||
std::vector<float> out, an;
|
||||
bool consumed = false;
|
||||
check(!t.update(at(10, 10), out, consumed) && !consumed && !t.in_progress(),
|
||||
"pen: a move does nothing while idle");
|
||||
check(!t.update(click_at(10, 10), out, consumed) && consumed && t.anchor_count() == 1,
|
||||
"pen: a click drops an anchor and is consumed");
|
||||
t.update(at(10, 10), out, consumed);
|
||||
t.overlay(an);
|
||||
check(same_points(an, {10, 10, 10, 10, 10, 10}, 1e-6f), "pen: a click is a corner");
|
||||
|
||||
// A drag pulls a smooth anchor: out under the pointer, in mirrored.
|
||||
pen_drag(t, 100, 10, 130, 20);
|
||||
t.overlay(an);
|
||||
check(t.anchor_count() == 2 && same_points({an.begin() + 6, an.end()},
|
||||
{70, 0, 100, 10, 130, 20}, 1e-4f),
|
||||
"pen: a drag makes a smooth anchor with mirrored handles");
|
||||
check(t.can_close(), "pen: two anchors with a curve between them can close");
|
||||
float c[8];
|
||||
t.update(at(60, 80), out, consumed);
|
||||
check(t.preview(c) && same_points({c, c + 8}, {100, 10, 130, 20, 60, 80, 60, 80}, 1e-4f),
|
||||
"pen: the preview runs from the last anchor's out-handle to the pointer");
|
||||
|
||||
// A drag shorter than kPenDragStart is still a click.
|
||||
pen_drag(t, 100, 100, 101, 101);
|
||||
t.overlay(an);
|
||||
check(t.anchor_count() == 3 && same_points({an.begin() + 12, an.end()},
|
||||
{100, 100, 100, 100, 100, 100}, 1e-6f),
|
||||
"pen: a 1.4 px wobble is a click, not a curve");
|
||||
t.update(at(12, 11), out, consumed);
|
||||
check(t.cue() == mk::PenTool::Cue::Close, "pen: over the first anchor the badge says close");
|
||||
check(!t.update(click_at(12, 11), out, consumed) && t.in_progress(),
|
||||
"pen: closing waits for the release");
|
||||
check(t.update(at(12, 11), out, consumed) && out.size() == 18 && !t.in_progress(),
|
||||
"pen: the release on the first anchor closes three anchors");
|
||||
check(same_points({out.begin() + 6, out.begin() + 12}, {70, 0, 100, 10, 130, 20}, 1e-4f),
|
||||
"pen: the closed path keeps the smooth anchor's handles");
|
||||
|
||||
// Two corners enclose nothing.
|
||||
t.update(click_at(10, 10), out, consumed);
|
||||
t.update(at(10, 10), out, consumed);
|
||||
t.update(click_at(50, 10), out, consumed);
|
||||
t.update(at(50, 10), out, consumed);
|
||||
check(!t.can_close() && !t.commit_pending(out) && t.in_progress(),
|
||||
"pen: two corners cannot close, and a refused commit keeps the path");
|
||||
check(!t.update(right_click_at(80, 80), out, consumed) && consumed && t.in_progress(),
|
||||
"pen: nor can a right click");
|
||||
t.update(click_at(50, 50), out, consumed);
|
||||
t.update(at(50, 50), out, consumed);
|
||||
check(t.update(right_click_at(80, 80), out, consumed) && out.size() == 18,
|
||||
"pen: a right click closes three");
|
||||
t.update(click_at(10, 10), out, consumed);
|
||||
t.update(at(10, 10), out, consumed);
|
||||
t.update(click_at(50, 10), out, consumed);
|
||||
t.update(at(50, 10), out, consumed);
|
||||
check(t.pop_anchor() && t.anchor_count() == 1 && t.pop_anchor() && !t.in_progress() &&
|
||||
!t.pop_anchor(),
|
||||
"pen: pop takes anchors back to empty");
|
||||
}
|
||||
|
||||
void test_pen_tool_modifiers() {
|
||||
mk::PenTool t;
|
||||
std::vector<float> out, an;
|
||||
bool consumed = false;
|
||||
gui::ViewportInput shift, alt, ctrl;
|
||||
shift.shift = true;
|
||||
alt.alt = true;
|
||||
ctrl.ctrl = true;
|
||||
|
||||
t.update(click_at(0, 0), out, consumed);
|
||||
t.update(at(0, 0), out, consumed);
|
||||
// Shift on a click: the new anchor turns about the last to 45 degrees.
|
||||
gui::ViewportInput sc = click_at(100, 90);
|
||||
sc.shift = true;
|
||||
t.update(sc, out, consumed);
|
||||
t.update(at(100, 90), out, consumed);
|
||||
t.overlay(an);
|
||||
const float r = std::hypot(100.0f, 90.0f) / std::sqrt(2.0f);
|
||||
check(same_points({an.begin() + 8, an.begin() + 10}, {r, r}, 1e-3f),
|
||||
"pen: Shift puts the click on the 45-degree line, at the same distance");
|
||||
t.cancel();
|
||||
|
||||
// Shift on a drag: the handle snaps.
|
||||
t.update(click_at(0, 0), out, consumed);
|
||||
t.update(at(0, 0), out, consumed);
|
||||
pen_drag(t, 100, 0, 150, 4, shift);
|
||||
t.overlay(an);
|
||||
check(std::fabs(an[11] - 0.0f) < 1e-3f && an[10] > 140.0f,
|
||||
"pen: Shift levels a handle drawn nearly level");
|
||||
t.cancel();
|
||||
|
||||
// Alt mid-drag: the in-handle stays where the drag had put it.
|
||||
t.update(click_at(0, 0), out, consumed);
|
||||
t.update(at(0, 0), out, consumed);
|
||||
t.update(click_at(100, 0), out, consumed);
|
||||
t.update(held_at(130, 0), out, consumed);
|
||||
gui::ViewportInput ah = held_at(100, 40);
|
||||
ah.alt = true;
|
||||
t.update(ah, out, consumed);
|
||||
t.update(at(100, 40), out, consumed);
|
||||
t.overlay(an);
|
||||
check(same_points({an.begin() + 6, an.end()}, {70, 0, 100, 0, 100, 40}, 1e-4f),
|
||||
"pen: Alt breaks the handles -- in stays at (70,0), out follows to (100,40)");
|
||||
|
||||
// A click on the last anchor takes its out-handle back.
|
||||
t.update(at(100, 1), out, consumed);
|
||||
check(t.cue() == mk::PenTool::Cue::Retract, "pen: over the last anchor the badge says so");
|
||||
t.update(click_at(100, 1), out, consumed);
|
||||
t.update(at(100, 1), out, consumed);
|
||||
t.overlay(an);
|
||||
check(same_points({an.begin() + 6, an.end()}, {70, 0, 100, 0, 100, 0}, 1e-4f),
|
||||
"pen: clicking the last anchor retracts its out-handle only");
|
||||
|
||||
// Ctrl+drag moves a point already placed, handles and all.
|
||||
t.update(click_at(50, 80), out, consumed);
|
||||
t.update(at(50, 80), out, consumed);
|
||||
gui::ViewportInput hover = at(1, 1);
|
||||
hover.ctrl = true;
|
||||
t.update(hover, out, consumed);
|
||||
check(t.cue() == mk::PenTool::Cue::Edit, "pen: Ctrl over an anchor offers to move it");
|
||||
pen_drag(t, 1, 1, 11, -9, ctrl);
|
||||
t.overlay(an);
|
||||
check(t.anchor_count() == 3 && same_points({an.begin(), an.begin() + 6},
|
||||
{10, -10, 10, -10, 10, -10}, 1e-4f),
|
||||
"pen: Ctrl+drag moved the first anchor and added none");
|
||||
// Ctrl+drag a smooth anchor's handle: the other swings opposite, keeping its length.
|
||||
t.cancel();
|
||||
t.update(click_at(0, 0), out, consumed);
|
||||
t.update(at(0, 0), out, consumed);
|
||||
pen_drag(t, 100, 0, 130, 0);
|
||||
t.update(click_at(50, 80), out, consumed);
|
||||
t.update(at(50, 80), out, consumed);
|
||||
pen_drag(t, 130, 0, 100, 40, ctrl);
|
||||
t.overlay(an);
|
||||
check(same_points({an.begin() + 6, an.begin() + 12}, {100, -30, 100, 0, 100, 40}, 1e-3f),
|
||||
"pen: moving one handle of a smooth anchor swings the other opposite");
|
||||
t.cancel();
|
||||
|
||||
// Space while dragging carries the new anchor; the handles resume from there.
|
||||
t.update(click_at(0, 0), out, consumed);
|
||||
t.update(at(0, 0), out, consumed);
|
||||
t.update(click_at(100, 0), out, consumed);
|
||||
t.note_space(true);
|
||||
t.update(held_at(110, 5), out, consumed);
|
||||
t.update(held_at(120, 10), out, consumed);
|
||||
t.note_space(false);
|
||||
t.update(held_at(150, 10), out, consumed);
|
||||
t.update(at(150, 10), out, consumed);
|
||||
t.overlay(an);
|
||||
check(same_points({an.begin() + 6, an.end()}, {90, 10, 120, 10, 150, 10}, 1e-4f),
|
||||
"pen: Space moved the anchor to (120,10), then the drag pulled handles from it");
|
||||
|
||||
// The paint mode latches on the first anchor, as the livewire path's does.
|
||||
t.cancel();
|
||||
t.note_modifiers(false, true);
|
||||
t.update(click_at(0, 0), out, consumed);
|
||||
t.note_modifiers(false, false);
|
||||
check(t.mode_ctrl(), "pen: Ctrl held on the first anchor is kept after it is released");
|
||||
}
|
||||
|
||||
void test_pen_tool_space() {
|
||||
// Fed = frame scaled 2x and offset: anchors report in fed pixels, and a
|
||||
// drag's handles are measured there too.
|
||||
mk::PenTool t;
|
||||
mk::PathSpace sp;
|
||||
sp.to_frame = [](float x, float y, float& fx, float& fy) { fx = (x - 5.0f) * 0.5f; fy = (y - 7.0f) * 0.5f; };
|
||||
sp.from_frame = [](float fx, float fy, float& x, float& y) { x = fx * 2.0f + 5.0f; y = fy * 2.0f + 7.0f; };
|
||||
t.set_space(sp);
|
||||
std::vector<float> out, an;
|
||||
bool consumed;
|
||||
t.update(click_at(25, 27), out, consumed);
|
||||
t.update(at(25, 27), out, consumed);
|
||||
pen_drag(t, 125, 27, 145, 27);
|
||||
t.update(click_at(125, 127), out, consumed);
|
||||
t.update(at(125, 127), out, consumed);
|
||||
t.overlay(an);
|
||||
check(same_points({an.begin() + 6, an.begin() + 12}, {105, 27, 125, 27, 145, 27}, 1e-3f),
|
||||
"pen: anchors and handles report in fed pixels");
|
||||
check(t.commit_pending(out) && same_points({out.begin(), out.begin() + 2}, {25, 27}, 1e-3f),
|
||||
"pen: the closed path comes back in fed pixels");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// stored -> displayed, continuous, is the inverse of to_stored for all
|
||||
// eight EXIF orientations
|
||||
@@ -4895,7 +5106,8 @@ void test_session_sam_blocker_clears_its_own_error() {
|
||||
void test_canvas_mode_is_exclusive() {
|
||||
mk::MaskSession s;
|
||||
const mk::CanvasMode all[] = {mk::CanvasMode::Shape, mk::CanvasMode::Eraser,
|
||||
mk::CanvasMode::Path, mk::CanvasMode::Sam};
|
||||
mk::CanvasMode::Path, mk::CanvasMode::Pen,
|
||||
mk::CanvasMode::Sam};
|
||||
bool exclusive = true;
|
||||
for (mk::CanvasMode a : all)
|
||||
for (mk::CanvasMode b : all) {
|
||||
@@ -4904,6 +5116,7 @@ void test_canvas_mode_is_exclusive() {
|
||||
exclusive = exclusive && s.mode() == b &&
|
||||
s.erasing() == (b == mk::CanvasMode::Eraser) &&
|
||||
s.path_mode() == (b == mk::CanvasMode::Path) &&
|
||||
s.pen_mode() == (b == mk::CanvasMode::Pen) &&
|
||||
s.sam_mode() == (b == mk::CanvasMode::Sam);
|
||||
}
|
||||
check(exclusive, "canvas mode: selecting any mode deselects every other");
|
||||
@@ -8551,6 +8764,9 @@ int main() {
|
||||
test_path_tool_mode_latch();
|
||||
test_path_tool_livewire();
|
||||
test_path_tool_space();
|
||||
test_pen_tool_basic();
|
||||
test_pen_tool_modifiers();
|
||||
test_pen_tool_space();
|
||||
test_to_displayed_float();
|
||||
test_livewire_corner_path();
|
||||
test_livewire_sign_alignment();
|
||||
|
||||
@@ -90,7 +90,6 @@ static void test_dataset_preset() {
|
||||
s.sfm.prep.mask_memory = true;
|
||||
s.sfm.prep.mask_detect_every = 4;
|
||||
s.sfm.prep.mask_memory_frames = 5;
|
||||
s.sfm.prep.force_external_masking = true;
|
||||
|
||||
s.sfm.geometry.enable = true;
|
||||
s.sfm.geometry.model = "moge2-vitl";
|
||||
@@ -207,7 +206,6 @@ static void test_dataset_preset() {
|
||||
CHECK_EQ(b.sfm.prep.mask_memory, s.sfm.prep.mask_memory);
|
||||
CHECK_EQ(b.sfm.prep.mask_detect_every, s.sfm.prep.mask_detect_every);
|
||||
CHECK_EQ(b.sfm.prep.mask_memory_frames, s.sfm.prep.mask_memory_frames);
|
||||
CHECK_EQ(b.sfm.prep.force_external_masking, s.sfm.prep.force_external_masking);
|
||||
|
||||
CHECK_EQ(b.sfm.geometry.enable, s.sfm.geometry.enable);
|
||||
CHECK_EQ(b.sfm.geometry.model, s.sfm.geometry.model);
|
||||
@@ -378,6 +376,23 @@ static void test_sanitize() {
|
||||
CHECK_EQ(gui::mesh_job_outputs(two)[0], std::string("C:/runs/one/mesh.ply"));
|
||||
}
|
||||
|
||||
// A key a newer build no longer knows is skipped, not an error.
|
||||
static void test_unknown_key_ignored() {
|
||||
const std::string path = (scratch() / "old_dataset.json").string();
|
||||
std::FILE* f = std::fopen(path.c_str(), "wb");
|
||||
CHECK(f != nullptr);
|
||||
if (!f) return;
|
||||
std::fputs("{\"spirula_preset\": 1, \"kind\": \"dataset\", \"name\": \"old\", "
|
||||
"\"settings\": {\"mask_enable\": true, "
|
||||
"\"force_external_masking\": true}}", f);
|
||||
std::fclose(f);
|
||||
try {
|
||||
CHECK(gui::load_dataset_preset(path).s.sfm.prep.mask_enable);
|
||||
} catch (const std::exception&) {
|
||||
CHECK(!"a preset with an unknown key must load");
|
||||
}
|
||||
}
|
||||
|
||||
// A preset of one kind must not load as another, whatever its name is.
|
||||
static void test_kinds_do_not_cross() {
|
||||
const std::string ds = (scratch() / "dataset.json").string();
|
||||
@@ -402,6 +417,7 @@ int main() {
|
||||
test_dataset_preset();
|
||||
test_mesh_preset();
|
||||
test_sanitize();
|
||||
test_unknown_key_ignored();
|
||||
test_kinds_do_not_cross();
|
||||
if (failures) {
|
||||
std::printf("preset_roundtrip_test: %d failure(s)\n", failures);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// stencil_edit_test -- app/gui/StencilEdit.h and StencilPreset.h with no GUI:
|
||||
// what each tool's stroke becomes, hit tests and moves, undo, and saving a
|
||||
// set of drawn areas and finding it again by name.
|
||||
// what each tool's stroke becomes, hit tests and moves, a pen shape's points,
|
||||
// undo, and saving a set of drawn areas and finding it again by name.
|
||||
|
||||
#include "app/gui/StencilEdit.h"
|
||||
#include "app/gui/StencilPreset.h"
|
||||
@@ -96,20 +96,102 @@ void test_hit_and_move() {
|
||||
check(near_(r.cx, 0.4f) && near_(r.ry, 0.55f), "rect: the centre handle moves it whole");
|
||||
}
|
||||
|
||||
std::vector<uint8_t> raster(const app::MaskShape& s, int W, int H) {
|
||||
app::FrameMask m;
|
||||
m.shapes.push_back(s);
|
||||
std::vector<uint8_t> out;
|
||||
std::string err;
|
||||
app::rasterize_frame_mask(m, W, H, out, err);
|
||||
return out;
|
||||
}
|
||||
|
||||
void test_pen() {
|
||||
// Four corners on a 400 x 200 canvas: (100,50) (300,50) (300,150) (100,150).
|
||||
std::vector<float> px;
|
||||
const float corners[] = {100, 50, 300, 50, 300, 150, 100, 150};
|
||||
for (int i = 0; i < 8; i += 2)
|
||||
px.insert(px.end(), {corners[i], corners[i + 1], corners[i], corners[i + 1],
|
||||
corners[i], corners[i + 1]});
|
||||
app::MaskShape s;
|
||||
check(gui::stencil_shape_from_pen(px, 400, 200, true, s) && s.kind == Kind::Bezier &&
|
||||
s.remove && s.pts.size() == 24 && near_(s.pts[2], 0.25f) && near_(s.pts[3], 0.25f),
|
||||
"pen: anchors normalized per axis, the remove flag passes through");
|
||||
const std::vector<float> two = {10, 10, 10, 10, 10, 10, 50, 50, 50, 50, 50, 50};
|
||||
check(!gui::stencil_shape_from_pen(two, 400, 200, true, s), "pen: two corners are no shape");
|
||||
check(gui::stencil_shape_from_pen({0, 30, 10, 10, 20, -10, 40, -10, 50, 10, 60, 30}, 400,
|
||||
200, false, s),
|
||||
"pen: two anchors with handles enclose a lens");
|
||||
gui::stencil_shape_from_pen(px, 400, 200, true, s);
|
||||
check(gui::stencil_contains(s, 0.5f, 0.5f) && !gui::stencil_contains(s, 0.2f, 0.5f),
|
||||
"pen: inside test");
|
||||
|
||||
// Hits: radii in canvas pixels.
|
||||
gui::PenHit h = gui::pen_hit(s, 0.25f + 3.0f / 400, 0.25f, 400, 200, 6.0f, true);
|
||||
check(h.part == gui::PenPart::Anchor && h.index == 0, "pen hit: 3 px from anchor 0");
|
||||
h = gui::pen_hit(s, 0.5f, 0.25f + 4.0f / 200, 400, 200, 6.0f, true);
|
||||
check(h.part == gui::PenPart::Segment && h.index == 0 && std::fabs(h.t - 0.5f) < 1e-3f,
|
||||
"pen hit: 4 px under the top edge's middle is segment 0 at t=0.5");
|
||||
h = gui::pen_hit(s, 0.5f, 0.5f, 400, 200, 6.0f, true);
|
||||
check(h.part == gui::PenPart::None, "pen hit: the middle is nothing");
|
||||
|
||||
// Insert keeps the outline; delete stops at two.
|
||||
const std::vector<uint8_t> before = raster(s, 120, 80);
|
||||
const int k = gui::pen_insert(s, 0, 0.5f);
|
||||
check(k == 1 && s.pts.size() == 30 && near_(s.pts[8], 0.5f) && near_(s.pts[9], 0.25f),
|
||||
"pen insert: a new anchor at the top edge's middle");
|
||||
check(raster(s, 120, 80) == before, "pen insert: the outline is unchanged");
|
||||
check(gui::pen_delete(s, 1) && s.pts.size() == 24 && raster(s, 120, 80) == before,
|
||||
"pen delete: back to the square");
|
||||
check(gui::pen_delete(s, 0) && gui::pen_delete(s, 0) && !gui::pen_delete(s, 0) &&
|
||||
s.pts.size() == 12,
|
||||
"pen delete: refused at two anchors");
|
||||
|
||||
// Handles: a smooth anchor's other handle swings opposite at its own
|
||||
// length in PIXELS; split leaves it; an anchor carries both.
|
||||
app::MaskShape c;
|
||||
c.kind = Kind::Bezier;
|
||||
c.pts = {0.4f, 0.5f, 0.5f, 0.5f, 0.6f, 0.5f, 0.8f, 0.8f, 0.8f, 0.8f, 0.8f, 0.8f};
|
||||
check(gui::pen_is_smooth(c, 0, 400, 200) && !gui::pen_is_smooth(c, 1, 400, 200),
|
||||
"pen: opposite handles are smooth, a corner is not");
|
||||
gui::pen_move_point(c, 0, gui::PenPart::Out, 0.5f, 0.3f, 400, 200, false);
|
||||
check(near_(c.pts[4], 0.5f) && near_(c.pts[5], 0.3f) && near_(c.pts[0], 0.5f) &&
|
||||
near_(c.pts[1], 0.5f + 40.0f / 200),
|
||||
"pen: out turned straight up, in swings straight down 40 px -- its own length");
|
||||
gui::pen_move_point(c, 0, gui::PenPart::In, 0.45f, 0.6f, 400, 200, true);
|
||||
check(near_(c.pts[4], 0.5f) && near_(c.pts[5], 0.3f) && !gui::pen_is_smooth(c, 0, 400, 200),
|
||||
"pen: split moves one handle alone and leaves a cusp");
|
||||
gui::pen_move_point(c, 0, gui::PenPart::Anchor, 0.6f, 0.6f, 400, 200, false);
|
||||
check(near_(c.pts[2], 0.6f) && near_(c.pts[3], 0.6f) && near_(c.pts[0], 0.55f) &&
|
||||
near_(c.pts[5], 0.4f),
|
||||
"pen: an anchor carries its handles");
|
||||
gui::pen_make_corner(c, 0);
|
||||
check(near_(c.pts[0], 0.6f) && near_(c.pts[5], 0.6f), "pen: a corner has its handles on it");
|
||||
gui::stencil_move(c, 0.1f, 0.0f);
|
||||
check(near_(c.pts[0], 0.7f) && near_(c.pts[6], 0.9f), "pen: a shape moves by all its points");
|
||||
}
|
||||
|
||||
void test_history() {
|
||||
gui::StencilHistory h;
|
||||
std::vector<app::MaskShape> shapes;
|
||||
app::FrameStencil st;
|
||||
app::MaskShape a;
|
||||
h.push(shapes);
|
||||
shapes.push_back(a);
|
||||
h.push(shapes);
|
||||
shapes.push_back(a);
|
||||
check(h.undo(shapes) && shapes.size() == 1, "undo takes back the last add");
|
||||
check(h.undo(shapes) && shapes.empty() && !h.can_undo(), "and the one before");
|
||||
check(h.redo(shapes) && shapes.size() == 1 && h.can_redo(), "redo puts it back");
|
||||
h.push(shapes);
|
||||
shapes.clear();
|
||||
h.push(st);
|
||||
st.mask.shapes.push_back(a);
|
||||
h.push(st);
|
||||
st.mask.shapes.push_back(a);
|
||||
check(h.undo(st) && st.mask.shapes.size() == 1, "undo takes back the last add");
|
||||
check(h.undo(st) && st.mask.shapes.empty() && !h.can_undo(), "and the one before");
|
||||
check(h.redo(st) && st.mask.shapes.size() == 1 && h.can_redo(), "redo puts it back");
|
||||
h.push(st);
|
||||
st.cameras["cam1"].mask.shapes.push_back(a);
|
||||
st.cameras["cam1"].detect_border = true;
|
||||
check(!h.can_redo(), "a new change drops what redo held");
|
||||
check(h.undo(st) && !st.per_camera(), "undo covers a camera's own stencil too");
|
||||
}
|
||||
|
||||
app::MaskSet one_list(std::vector<app::MaskShape> shapes) {
|
||||
app::MaskSet set;
|
||||
set.shapes = std::move(shapes);
|
||||
return set;
|
||||
}
|
||||
|
||||
void test_presets() {
|
||||
@@ -127,43 +209,96 @@ void test_presets() {
|
||||
shapes[0].remove = true;
|
||||
shapes[0].cx = 0.0f; shapes[0].cy = 0.9f; shapes[0].rx = 1.0f; shapes[0].ry = 1.0f;
|
||||
std::string path, err;
|
||||
check(gui::save_stencil_preset("Selfie stick", shapes, path, err), "save: " + err);
|
||||
check(gui::save_stencil_preset("Selfie stick", one_list(shapes), path, err), "save: " + err);
|
||||
check(fs::path(path).extension() == ".svg" &&
|
||||
fs::path(path).parent_path() == fs::path(gui::stencil_preset_dir()),
|
||||
"saved as an .svg in the stencil folder: " + path);
|
||||
const auto list = gui::list_stencil_presets();
|
||||
check(list.size() == 1 && list[0].name == "Selfie stick", "listed by its title");
|
||||
std::vector<app::MaskShape> back;
|
||||
check(gui::load_stencil_preset("Selfie stick", back, err) && back.size() == 1 &&
|
||||
back[0].kind == Kind::Rect && near_(back[0].cy, 0.9f),
|
||||
auto list = gui::list_stencil_presets();
|
||||
check(list.size() == 1 && list[0].name == "Selfie stick" && list[0].cameras.empty(),
|
||||
"listed by its title");
|
||||
app::MaskSet back;
|
||||
check(gui::load_stencil_preset("Selfie stick", back, err) && back.shapes.size() == 1 &&
|
||||
!back.per_camera() && back.shapes[0].kind == Kind::Rect &&
|
||||
near_(back.shapes[0].cy, 0.9f),
|
||||
"loaded back by name");
|
||||
check(gui::load_stencil_preset(path, back, err), "and by path");
|
||||
shapes.push_back(shapes[0]);
|
||||
check(gui::save_stencil_preset("Selfie stick", shapes, path, err) &&
|
||||
check(gui::save_stencil_preset("Selfie stick", one_list(shapes), path, err) &&
|
||||
gui::list_stencil_presets().size() == 1 &&
|
||||
gui::load_stencil_preset("Selfie stick", back, err) && back.size() == 2,
|
||||
gui::load_stencil_preset("Selfie stick", back, err) && back.shapes.size() == 2,
|
||||
"saving the same name replaces it");
|
||||
check(!gui::load_stencil_preset("nothing by this name", back, err),
|
||||
"an unknown name is refused");
|
||||
|
||||
// A run keeps its inputs' shapes beside the dataset, one file each.
|
||||
// A set drawn per camera: a file each, listed once, loaded back whole.
|
||||
app::MaskSet rig;
|
||||
rig.cameras["cam0"] = {shapes[0]};
|
||||
rig.cameras["cam1"] = shapes;
|
||||
err.clear();
|
||||
check(gui::save_stencil_preset("Rig", rig, path, err), "per camera: save: " + err);
|
||||
const fs::path dir = gui::stencil_preset_dir();
|
||||
check(fs::exists(dir / "rig-cam0.svg") && fs::exists(dir / "rig-cam1.svg") &&
|
||||
!fs::exists(dir / "rig.svg"),
|
||||
"per camera: rig-cam0.svg and rig-cam1.svg, no shared file");
|
||||
list = gui::list_stencil_presets();
|
||||
const gui::StencilPreset* entry = nullptr;
|
||||
for (const gui::StencilPreset& p : list)
|
||||
if (p.name == "Rig") entry = &p;
|
||||
check(list.size() == 2 && entry && entry->cameras == std::vector<std::string>{"cam0", "cam1"} &&
|
||||
gui::stencil_preset_label(*entry) == "Rig [cam0, cam1]",
|
||||
"per camera: one entry, its cameras named");
|
||||
check(gui::load_stencil_preset("Rig", back, err) && back.per_camera() &&
|
||||
back.cameras.size() == 2 && back.cameras["cam0"].size() == 1 &&
|
||||
back.cameras["cam1"].size() == 2 && back.shapes.empty(),
|
||||
"per camera: loaded by name, each camera its own");
|
||||
check(gui::load_stencil_preset((dir / "rig-cam1.svg").string(), back, err) &&
|
||||
back.cameras.size() == 2,
|
||||
"per camera: loading one file brings the other");
|
||||
check(gui::save_stencil_preset("Rig", one_list(shapes), path, err) &&
|
||||
!fs::exists(dir / "rig-cam0.svg") && !fs::exists(dir / "rig-cam1.svg") &&
|
||||
gui::load_stencil_preset("Rig", back, err) && !back.per_camera(),
|
||||
"per camera: saved again as one list, the camera files go");
|
||||
|
||||
// A run keeps its inputs' shapes beside the dataset: one file for an
|
||||
// input, one per camera when its cameras differ.
|
||||
const fs::path ws = root / "capture_dataset";
|
||||
fs::create_directories(ws / gui::kDatasetStencilDir, ec);
|
||||
{ std::ofstream stale((ws / gui::kDatasetStencilDir / "old.svg").string()); stale << "<svg/>"; }
|
||||
app::FrameStencil same, differ;
|
||||
same.cameras["cam0"].mask.shapes = shapes;
|
||||
same.cameras["cam1"].mask.shapes = shapes;
|
||||
differ.mask.shapes = shapes; // a fallback no camera uses
|
||||
differ.cameras["cam0"].mask.shapes = {shapes[0]};
|
||||
differ.cameras["cam1"].mask.shapes = shapes;
|
||||
err.clear();
|
||||
const auto written = gui::save_dataset_stencils(
|
||||
ws.string(),
|
||||
{{(ws / "images").string(), shapes}, {"/videos/front.mp4", {}}, {"/videos/back.mp4", shapes}},
|
||||
{{(ws / "images").string(), one_list(shapes)},
|
||||
{"/videos/front.mp4", app::MaskSet{}},
|
||||
{"/videos/back.insv", app::mask_set_of(same)},
|
||||
{"/videos/dual.insv", app::mask_set_of(differ)},
|
||||
{"/other/dual.insv", app::mask_set_of(differ)}},
|
||||
err);
|
||||
check(written.size() == 2 && err.empty(), "dataset stencils: one file per input that drew");
|
||||
std::string names;
|
||||
for (const std::string& f : written) names += fs::path(f).filename().string() + " ";
|
||||
check(written.size() == 6 && err.empty(),
|
||||
"dataset stencils: one per input, two per differing one: " + names);
|
||||
check(!fs::exists(ws / gui::kDatasetStencilDir / "old.svg"),
|
||||
"dataset stencils: the folder holds only this run's");
|
||||
check(names == "capture-dataset-images.svg back.svg dual-cam0.svg dual-cam1.svg "
|
||||
"dual-2-cam0.svg dual-2-cam1.svg ",
|
||||
"dataset stencils: cameras that agree share a file, a second dual gets its own stem");
|
||||
const auto kept = gui::list_dataset_stencils(ws.string());
|
||||
check(kept.size() == 2 && gui::load_stencil_preset(kept[0].path, back, err) && back.size() == 2,
|
||||
"dataset stencils: listed and loadable by path");
|
||||
check(fs::path(written[0]).filename() == "capture-dataset-images.svg" &&
|
||||
fs::path(written[1]).filename() == "back.svg",
|
||||
"dataset stencils: named after the input: " + fs::path(written[0]).filename().string());
|
||||
std::string labels;
|
||||
for (const gui::StencilPreset& p : kept) labels += gui::stencil_preset_label(p) + " | ";
|
||||
check(kept.size() == 4, "dataset stencils: four sets listed: " + labels);
|
||||
bool dual_back = false;
|
||||
for (const gui::StencilPreset& p : kept)
|
||||
if (p.cameras.size() == 2 && p.name == "capture_dataset - dual.insv")
|
||||
dual_back = gui::load_stencil_preset(p.path, back, err) &&
|
||||
back.cameras["cam0"].size() == 1 && back.cameras["cam1"].size() == 2;
|
||||
check(dual_back, "dataset stencils: the first dual's set loads back per camera, not mixed "
|
||||
"with the second's");
|
||||
check(gui::list_dataset_stencils("").empty(), "dataset stencils: no workspace, nothing listed");
|
||||
fs::remove_all(root, ec);
|
||||
}
|
||||
@@ -173,6 +308,7 @@ void test_presets() {
|
||||
int main() {
|
||||
test_from_stroke();
|
||||
test_hit_and_move();
|
||||
test_pen();
|
||||
test_history();
|
||||
test_presets();
|
||||
std::printf("%s: %d failure(s)\n", SS_FILE, g_failures);
|
||||
|
||||
@@ -1,15 +1,19 @@
|
||||
// frame_mask_test -- app/FrameMask.h shapes with no GUI: the even-odd fill
|
||||
// in core/PolygonFill.h against a ray cast, the path spelling round trip,
|
||||
// the path fill on a non-square frame, the ordered composition rule, brush
|
||||
// strokes, and the SVG file form (app/FrameMaskSvg.h).
|
||||
// strokes, pen curves, the SVG file form (app/FrameMaskSvg.h), and a
|
||||
// stencil resolved per camera folder.
|
||||
|
||||
#include "app/FrameMask.h"
|
||||
#include "app/FrameMaskSvg.h"
|
||||
#include "core/CubicBezier.h"
|
||||
#include "core/PolygonFill.h"
|
||||
#include "core/SourcePath.h"
|
||||
#include "external/stb_image_write.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <filesystem>
|
||||
#include <string>
|
||||
#include <utility>
|
||||
#include <vector>
|
||||
@@ -549,6 +553,219 @@ void test_border_adjustment() {
|
||||
"border: manually adjusted ellipse survives serialization");
|
||||
}
|
||||
|
||||
// A circle as four cubics (the 0.5523 handle length), set against the
|
||||
// ellipse it approximates to within 0.03% of its radius.
|
||||
app::MaskShape bezier_circle(float cx, float cy, float r, bool remove) {
|
||||
const float k = 0.5522847f * r;
|
||||
app::MaskShape s;
|
||||
s.kind = app::MaskShape::Kind::Bezier;
|
||||
s.remove = remove;
|
||||
s.pts = {cx - k, cy - r, cx, cy - r, cx + k, cy - r,
|
||||
cx + r, cy - k, cx + r, cy, cx + r, cy + k,
|
||||
cx + k, cy + r, cx, cy + r, cx - k, cy + r,
|
||||
cx - r, cy + k, cx - r, cy, cx - r, cy - k};
|
||||
return s;
|
||||
}
|
||||
|
||||
void test_bezier() {
|
||||
const int W = 200, H = 200;
|
||||
app::MaskShape e;
|
||||
e.kind = app::MaskShape::Kind::Ellipse;
|
||||
e.remove = true;
|
||||
e.cx = e.cy = 0.5f;
|
||||
e.rx = e.ry = 0.3f;
|
||||
const app::MaskShape c = bezier_circle(0.5f, 0.5f, 0.3f, true);
|
||||
const std::vector<uint8_t> a = raster({e}, W, H), b = raster({c}, W, H);
|
||||
size_t differ = 0, removed = 0;
|
||||
for (size_t i = 0; i < a.size(); i++) {
|
||||
differ += a[i] != b[i];
|
||||
removed += b[i] == 0;
|
||||
}
|
||||
// Pixel centres within the flattening tolerance of the edge may go either way.
|
||||
check(differ <= 40 && removed > 10000,
|
||||
"bezier: four cubics fill the circle they approximate (" + std::to_string(differ) +
|
||||
" of " + std::to_string(removed) + " pixels differ)");
|
||||
|
||||
// Corners only: the same polygon as a path, pixel for pixel.
|
||||
app::MaskShape square, path;
|
||||
square.kind = app::MaskShape::Kind::Bezier;
|
||||
path.kind = app::MaskShape::Kind::Path;
|
||||
square.remove = path.remove = true;
|
||||
const float corners[] = {0.2f, 0.1f, 0.7f, 0.3f, 0.4f, 0.9f};
|
||||
for (int k = 0; k < 3; k++) {
|
||||
const float x = corners[2 * k], y = corners[2 * k + 1];
|
||||
square.pts.insert(square.pts.end(), {x, y, x, y, x, y});
|
||||
path.pts.insert(path.pts.end(), {x, y});
|
||||
}
|
||||
check(raster({square}, 97, 61) == raster({path}, 97, 61),
|
||||
"bezier: straight segments fill exactly as the polygon does");
|
||||
|
||||
std::vector<app::MaskShape> back;
|
||||
std::string err;
|
||||
const std::string spec = app::format_mask_shapes({c, square});
|
||||
check(app::parse_mask_shapes(spec, back, err) && back.size() == 2 &&
|
||||
back[0].kind == app::MaskShape::Kind::Bezier && back[0].pts.size() == c.pts.size() &&
|
||||
raster(back, W, H) == raster({c, square}, W, H),
|
||||
"bezier: spelling round trip -- " + spec.substr(0, 40) + "...");
|
||||
check(!app::parse_mask_shapes("bezier 0,0,0.5,0.5,1,1", back, err) &&
|
||||
!app::parse_mask_shapes("bezier 0,0,0.5,0.5,1,1,0,0,0.5,0.5,1,1,0", back, err),
|
||||
"bezier: one anchor, or a count not a multiple of six, is refused");
|
||||
|
||||
// SVG: a pen shape writes C commands and reads back as the same curve.
|
||||
const std::string svg = app::write_mask_svg({c, square}, "curves");
|
||||
std::string title;
|
||||
err.clear();
|
||||
check(svg.find(" C") != std::string::npos && app::read_mask_svg(svg, back, title, err) &&
|
||||
back.size() == 2 && back[0].kind == app::MaskShape::Kind::Bezier &&
|
||||
back[1].kind == app::MaskShape::Kind::Bezier &&
|
||||
back[0].pts.size() == c.pts.size() && back[1].pts.size() == square.pts.size(),
|
||||
"bezier svg: both come back as curves with their anchor counts: " + err);
|
||||
float worst = 0.0f;
|
||||
for (size_t i = 0; i < back[0].pts.size() && i < c.pts.size(); i++)
|
||||
worst = std::max(worst, std::fabs(back[0].pts[i] - c.pts[i]));
|
||||
check(worst < 1e-5f, "bezier svg: every handle to within 1e-5 (" + std::to_string(worst) + ")");
|
||||
check(raster(back, W, H) == raster({c, square}, W, H), "bezier svg: identical raster");
|
||||
|
||||
// Hand-made: an open-ended C/L/Q outline, and a closed one that returns
|
||||
// to its start, whose last anchor merges into the first.
|
||||
const std::string hand =
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'>"
|
||||
"<path d='M10 10 C 20 0, 40 0, 50 10 L 50 50 Q 30 70 10 50 Z'/>"
|
||||
"<path d='M10 10 C 20 0 40 0 50 10 C 60 20 60 40 50 50 C 40 60 20 60 10 50 C 0 40 0 20 "
|
||||
"10 10 Z'/>"
|
||||
"</svg>";
|
||||
check(app::read_mask_svg(hand, back, title, err) && back.size() == 2 &&
|
||||
back[0].kind == app::MaskShape::Kind::Bezier && back[0].pts.size() == 24 &&
|
||||
back[1].kind == app::MaskShape::Kind::Bezier && back[1].pts.size() == 24,
|
||||
"bezier svg: hand-made curves keep their four anchors each: " + err);
|
||||
if (back.size() == 2 && back[1].pts.size() == 24)
|
||||
check(std::fabs(back[1].pts[0] - 0.0f) < 1e-6f && std::fabs(back[1].pts[1] - 0.2f) < 1e-6f,
|
||||
"bezier svg: the returning curve's last handle becomes the first anchor's in-handle");
|
||||
if (back.size() == 2 && back[0].pts.size() == 24)
|
||||
check(std::fabs(back[0].pts[18 + 0] - (0.1f + 2.0f / 3.0f * 0.2f)) < 1e-5f &&
|
||||
std::fabs(back[0].pts[18 + 1] - (0.5f + 2.0f / 3.0f * 0.2f)) < 1e-5f,
|
||||
"bezier svg: a quadratic is raised to the cubic with the same shape");
|
||||
}
|
||||
|
||||
void test_per_camera_stencil() {
|
||||
namespace fs = std::filesystem;
|
||||
const fs::path root = fs::temp_directory_path() / "spirula_frame_mask_test_cameras";
|
||||
std::error_code ec;
|
||||
fs::remove_all(root, ec);
|
||||
const int W = 20, H = 10;
|
||||
const std::vector<uint8_t> grey((size_t)W * H * 3, 128);
|
||||
for (const char* cam : {"cam0", "cam1", "cam2"}) {
|
||||
fs::create_directories(root / "images" / cam, ec);
|
||||
stbi_write_png((root / "images" / cam / "f.png").string().c_str(), W, H, 3, grey.data(),
|
||||
W * 3);
|
||||
}
|
||||
app::MaskShape left, right;
|
||||
left.kind = right.kind = app::MaskShape::Kind::Rect;
|
||||
left.remove = right.remove = true;
|
||||
left.cx = 0.0f; left.cy = 0.0f; left.rx = 0.5f; left.ry = 1.0f;
|
||||
right.cx = 0.5f; right.cy = 0.0f; right.rx = 1.0f; right.ry = 1.0f;
|
||||
|
||||
app::FrameStencilRun run;
|
||||
run.image_dir = (root / "images").string();
|
||||
run.mask_dir = (root / "masks").string();
|
||||
run.stencil.mask.shapes = {left};
|
||||
run.stencil.cameras["cam1"].mask.shapes = {right};
|
||||
run.stencil.cameras["cam2"] = app::CameraStencil{};
|
||||
check(&run.stencil.for_camera("cam0") == &run.stencil &&
|
||||
run.stencil.for_camera("cam1").mask.shapes.size() == 1,
|
||||
"per camera: a listed camera has its own, the rest share the input's");
|
||||
std::vector<std::string> seen;
|
||||
app::FrameStencilSinks sinks;
|
||||
sinks.camera = [&](const std::string& rel, int64_t) { seen.push_back(rel); };
|
||||
std::string err;
|
||||
const int64_t written = app::apply_frame_stencil(run, sinks, err);
|
||||
check(written == 2 && seen.size() == 3,
|
||||
"per camera: cam2's empty stencil writes nothing (" + std::to_string(written) + ")");
|
||||
auto kept = [&](const char* cam, int x) {
|
||||
int w = 0, h = 0;
|
||||
std::vector<uint8_t> m;
|
||||
if (!app::load_stencil((root / "masks" / cam / "f.png").string(), w, h, m)) return -1;
|
||||
return m[(size_t)(h / 2) * w + x] ? 1 : 0;
|
||||
};
|
||||
check(kept("cam0", 2) == 0 && kept("cam0", 17) == 1,
|
||||
"per camera: cam0 takes the input's shapes, the left half out");
|
||||
check(kept("cam1", 2) == 1 && kept("cam1", 17) == 0,
|
||||
"per camera: cam1 takes its own, the right half out");
|
||||
check(!fs::exists(root / "masks" / "cam2" / "f.png"), "per camera: cam2 has no mask");
|
||||
fs::remove_all(root, ec);
|
||||
}
|
||||
|
||||
// One file per camera: data-camera says whose it is, a file that names a
|
||||
// camera brings its set's others, and a stencil goes to a set and back.
|
||||
void test_camera_sets() {
|
||||
namespace fs = std::filesystem;
|
||||
app::MaskShape box;
|
||||
box.kind = app::MaskShape::Kind::Rect;
|
||||
box.remove = true;
|
||||
box.cx = 0.1f; box.cy = 0.1f; box.rx = 0.4f; box.ry = 0.4f;
|
||||
std::vector<app::MaskShape> back;
|
||||
std::string title, camera, err;
|
||||
const std::string one = app::write_mask_svg({box}, "Rig", "cam0/sub & more");
|
||||
check(app::read_mask_svg(one, back, title, err, &camera) && camera == "cam0/sub & more" &&
|
||||
title == "Rig" && back.size() == 1,
|
||||
"camera sets: data-camera round trip, '/' and '&' included");
|
||||
check(app::read_mask_svg(app::write_mask_svg({box}, "Rig"), back, title, err, &camera) &&
|
||||
camera.empty(),
|
||||
"camera sets: a file for every camera names none");
|
||||
|
||||
const fs::path dir = fs::temp_directory_path() / "spirula_frame_mask_test_sets";
|
||||
std::error_code ec;
|
||||
fs::remove_all(dir, ec);
|
||||
fs::create_directories(dir, ec);
|
||||
app::MaskShape wide = box;
|
||||
wide.rx = 0.9f;
|
||||
app::save_mask_svg((dir / "a-cam0.svg").string(), {box}, "Rig", err, "cam0");
|
||||
app::save_mask_svg((dir / "a-cam1.svg").string(), {box, wide}, "Rig", err, "cam1");
|
||||
app::save_mask_svg((dir / "other-cam2.svg").string(), {box}, "Other rig", err, "cam2");
|
||||
app::save_mask_svg((dir / "shared.svg").string(), {wide}, "Rig", err);
|
||||
app::MaskSet set;
|
||||
check(app::load_mask_svg_set((dir / "a-cam1.svg").string(), set, title, err) &&
|
||||
set.cameras.size() == 2 && set.cameras["cam0"].size() == 1 &&
|
||||
set.cameras["cam1"].size() == 2 && set.shapes.empty(),
|
||||
"camera sets: one file brings its set, not another title's nor a shared file");
|
||||
check(app::load_mask_svg_set((dir / "shared.svg").string(), set, title, err) &&
|
||||
!set.per_camera() && set.shapes.size() == 1,
|
||||
"camera sets: a file naming no camera stands alone");
|
||||
fs::remove_all(dir, ec);
|
||||
|
||||
app::FrameStencil st;
|
||||
st.detect_border = true;
|
||||
st.mask.shapes = {wide};
|
||||
set = app::MaskSet{};
|
||||
set.cameras["cam0"] = {box};
|
||||
set.cameras["cam1"] = {};
|
||||
app::apply_mask_set(st, set);
|
||||
check(st.mask.shapes.empty() && st.cameras.size() == 2 &&
|
||||
st.cameras["cam0"].mask.shapes.size() == 1 && st.cameras["cam1"].mask.shapes.empty() &&
|
||||
st.cameras["cam0"].detect_border,
|
||||
"camera sets: applied, each named camera its own, borders kept");
|
||||
app::FrameStencil ring;
|
||||
ring.detect_border = true;
|
||||
app::MaskShape lens;
|
||||
lens.kind = app::MaskShape::Kind::Ellipse;
|
||||
lens.cx = lens.cy = 0.5f;
|
||||
lens.rx = lens.ry = 0.45f;
|
||||
app::MaskSet edited;
|
||||
edited.cameras["cam0"] = {lens, box};
|
||||
edited.cameras["cam1"] = {box};
|
||||
app::apply_mask_set(ring, edited);
|
||||
check(!ring.cameras["cam0"].detect_border && ring.cameras["cam1"].detect_border,
|
||||
"camera sets: a list that keeps first turns its camera's fit off, the other keeps it");
|
||||
const app::MaskSet round = app::mask_set_of(st);
|
||||
check(round.per_camera() && round.cameras.size() == 2 &&
|
||||
round.cameras.at("cam0").size() == 1,
|
||||
"camera sets: cameras that differ stay per camera");
|
||||
st.cameras["cam1"].mask.shapes = {box};
|
||||
const app::MaskSet same = app::mask_set_of(st);
|
||||
check(!same.per_camera() && same.shapes.size() == 1,
|
||||
"camera sets: cameras that agree are one list");
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
int main() {
|
||||
@@ -562,6 +779,9 @@ int main() {
|
||||
test_svg_round_trip();
|
||||
test_svg_hand_made();
|
||||
test_border_adjustment();
|
||||
test_bezier();
|
||||
test_per_camera_stencil();
|
||||
test_camera_sets();
|
||||
std::printf("%s: %d failure(s)\n", SS_FILE, g_failures);
|
||||
return g_failures;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
#pragma once
|
||||
|
||||
// Closed cubic Bezier paths, the pen tool's shape: flattening, evaluation,
|
||||
// splitting and the nearest point. Host only and header-only, for the reason
|
||||
// PolygonFill.h is: app/FrameMask.cpp (CLI and GUI) and the GUI's pen tool
|
||||
// link nothing in common.
|
||||
//
|
||||
// A path is n anchors of kAnchorFloats each -- in-handle, point, out-handle as
|
||||
// x,y pairs -- and segment i runs from anchor i to anchor (i + 1) % n. A corner
|
||||
// has its handles on its point, which makes both segments at it straight.
|
||||
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <cstddef>
|
||||
#include <vector>
|
||||
|
||||
namespace bezier {
|
||||
|
||||
inline constexpr size_t kAnchorFloats = 6;
|
||||
// A flattened segment never has more than this many pieces.
|
||||
inline constexpr int kMaxPieces = 512;
|
||||
|
||||
// P0, P1, P2, P3 of segment `i`, as x,y pairs.
|
||||
inline void segment(const float* a, size_t n, size_t i, float c[8]) {
|
||||
const float* p = a + kAnchorFloats * i;
|
||||
const float* q = a + kAnchorFloats * ((i + 1) % n);
|
||||
c[0] = p[2]; c[1] = p[3];
|
||||
c[2] = p[4]; c[3] = p[5];
|
||||
c[4] = q[0]; c[5] = q[1];
|
||||
c[6] = q[2]; c[7] = q[3];
|
||||
}
|
||||
|
||||
inline void eval(const float c[8], float t, float& x, float& y) {
|
||||
const float u = 1.0f - t;
|
||||
const float b0 = u * u * u, b1 = 3.0f * u * u * t, b2 = 3.0f * u * t * t, b3 = t * t * t;
|
||||
x = b0 * c[0] + b1 * c[2] + b2 * c[4] + b3 * c[6];
|
||||
y = b0 * c[1] + b1 * c[3] + b2 * c[5] + b3 * c[7];
|
||||
}
|
||||
|
||||
// Wang's bound: this many equal steps in t keep every chord within `tol` of the curve.
|
||||
inline int pieces(const float c[8], float tol) {
|
||||
const float ax = c[0] - 2.0f * c[2] + c[4], ay = c[1] - 2.0f * c[3] + c[5];
|
||||
const float bx = c[2] - 2.0f * c[4] + c[6], by = c[3] - 2.0f * c[5] + c[7];
|
||||
const float m = std::max(std::hypot(ax, ay), std::hypot(bx, by));
|
||||
if (!(m > 0.0f) || !(tol > 0.0f)) return 1;
|
||||
const float n = std::ceil(std::sqrt(0.75f * m / tol));
|
||||
return n < 1.0f ? 1 : n > (float)kMaxPieces ? kMaxPieces : (int)n;
|
||||
}
|
||||
|
||||
// The closed outline as x,y pairs, anchor 0 first and not repeated at the
|
||||
// end, with every coordinate scaled by (sx, sy) before `tol` is measured.
|
||||
inline void flatten_closed(const float* a, size_t n, float sx, float sy, float tol,
|
||||
std::vector<float>& out) {
|
||||
out.clear();
|
||||
if (n == 0) return;
|
||||
for (size_t i = 0; i < n; i++) {
|
||||
float c[8];
|
||||
segment(a, n, i, c);
|
||||
for (int k = 0; k < 8; k += 2) {
|
||||
c[k] *= sx;
|
||||
c[k + 1] *= sy;
|
||||
}
|
||||
if (i == 0) out.insert(out.end(), {c[0], c[1]});
|
||||
const int m = pieces(c, tol);
|
||||
// The last piece ends on the next anchor, which the next segment
|
||||
// starts with -- or, for the closing segment, anchor 0.
|
||||
for (int k = 1; k < m || (k == m && i + 1 < n); k++) {
|
||||
float x, y;
|
||||
eval(c, (float)k / (float)m, x, y);
|
||||
out.insert(out.end(), {x, y});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Squared distance from (x, y) to segment `c`, and the t it is nearest at:
|
||||
// a coarse scan, then a bisection-style refinement around the best sample.
|
||||
inline float nearest(const float c[8], float x, float y, float& t) {
|
||||
constexpr int kScan = 48;
|
||||
auto d2 = [&](float s) {
|
||||
float px, py;
|
||||
eval(c, s, px, py);
|
||||
return (px - x) * (px - x) + (py - y) * (py - y);
|
||||
};
|
||||
t = 0.0f;
|
||||
float best = d2(0.0f);
|
||||
for (int k = 1; k <= kScan; k++) {
|
||||
const float s = (float)k / kScan, d = d2(s);
|
||||
if (d < best) { best = d; t = s; }
|
||||
}
|
||||
float step = 1.0f / kScan;
|
||||
for (int it = 0; it < 20; it++) {
|
||||
step *= 0.5f;
|
||||
const float lo = std::max(0.0f, t - step), hi = std::min(1.0f, t + step);
|
||||
const float dl = d2(lo), dh = d2(hi);
|
||||
if (dl < best) { best = dl; t = lo; }
|
||||
if (dh < best) { best = dh; t = hi; }
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
// de Casteljau at `t`: segment `i` becomes two with the same shape, joined by
|
||||
// a new smooth anchor inserted after anchor i. Returns the new anchor's index.
|
||||
inline size_t split(std::vector<float>& a, size_t i, float t) {
|
||||
const size_t n = a.size() / kAnchorFloats;
|
||||
float c[8];
|
||||
segment(a.data(), n, i, c);
|
||||
auto lerp = [t](float p, float q) { return p + (q - p) * t; };
|
||||
const float p01x = lerp(c[0], c[2]), p01y = lerp(c[1], c[3]);
|
||||
const float p12x = lerp(c[2], c[4]), p12y = lerp(c[3], c[5]);
|
||||
const float p23x = lerp(c[4], c[6]), p23y = lerp(c[5], c[7]);
|
||||
const float ax = lerp(p01x, p12x), ay = lerp(p01y, p12y);
|
||||
const float bx = lerp(p12x, p23x), by = lerp(p12y, p23y);
|
||||
const float mx = lerp(ax, bx), my = lerp(ay, by);
|
||||
const size_t j = (i + 1) % n;
|
||||
a[kAnchorFloats * i + 4] = p01x;
|
||||
a[kAnchorFloats * i + 5] = p01y;
|
||||
a[kAnchorFloats * j + 0] = p23x;
|
||||
a[kAnchorFloats * j + 1] = p23y;
|
||||
const float mid[kAnchorFloats] = {ax, ay, mx, my, bx, by};
|
||||
const size_t at = i + 1;
|
||||
a.insert(a.begin() + (long)(kAnchorFloats * at), mid, mid + kAnchorFloats);
|
||||
return at;
|
||||
}
|
||||
|
||||
// Whether an anchor's two handles both sit on its point.
|
||||
inline bool is_corner(const float* anchor, float eps = 1e-7f) {
|
||||
return std::fabs(anchor[0] - anchor[2]) <= eps && std::fabs(anchor[1] - anchor[3]) <= eps &&
|
||||
std::fabs(anchor[4] - anchor[2]) <= eps && std::fabs(anchor[5] - anchor[3]) <= eps;
|
||||
}
|
||||
|
||||
inline void make_corner(float* anchor) {
|
||||
anchor[0] = anchor[4] = anchor[2];
|
||||
anchor[1] = anchor[5] = anchor[3];
|
||||
}
|
||||
|
||||
// Both handles out and pointing opposite ways, within about a degree, once
|
||||
// x and y are scaled by (sx, sy) into square pixels.
|
||||
inline bool is_smooth(const float* anchor, float sx, float sy) {
|
||||
const float ix = (anchor[0] - anchor[2]) * sx, iy = (anchor[1] - anchor[3]) * sy;
|
||||
const float ox = (anchor[4] - anchor[2]) * sx, oy = (anchor[5] - anchor[3]) * sy;
|
||||
const float li = std::hypot(ix, iy), lo = std::hypot(ox, oy);
|
||||
if (!(li > 1e-6f) || !(lo > 1e-6f)) return false;
|
||||
return (ix * ox + iy * oy) / (li * lo) < -0.9998f;
|
||||
}
|
||||
|
||||
// The anchor's point to (x, y), its handles carried along.
|
||||
inline void move_anchor(float* anchor, float x, float y) {
|
||||
const float dx = x - anchor[2], dy = y - anchor[3];
|
||||
for (int j = 0; j < 6; j += 2) {
|
||||
anchor[j] += dx;
|
||||
anchor[j + 1] += dy;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle `out` (else in) to (x, y). With `keep_opposite` the other handle
|
||||
// swings to point the opposite way at its own length, measured after scaling
|
||||
// by (sx, sy); with no length of its own it is left alone.
|
||||
inline void set_handle(float* anchor, bool out, float x, float y, bool keep_opposite,
|
||||
float sx, float sy) {
|
||||
float* h = anchor + (out ? 4 : 0);
|
||||
float* o = anchor + (out ? 0 : 4);
|
||||
h[0] = x;
|
||||
h[1] = y;
|
||||
if (!keep_opposite) return;
|
||||
const float px = anchor[2], py = anchor[3];
|
||||
const float ol = std::hypot((o[0] - px) * sx, (o[1] - py) * sy);
|
||||
const float hx = (x - px) * sx, hy = (y - py) * sy, hl = std::hypot(hx, hy);
|
||||
if (!(ol > 1e-6f) || !(hl > 1e-6f)) return;
|
||||
o[0] = px - hx / hl * ol / sx;
|
||||
o[1] = py - hy / hl * ol / sy;
|
||||
}
|
||||
|
||||
// Drops anchor `k`; the two segments beside it become one between its
|
||||
// neighbours, which keep their handles.
|
||||
inline void erase_anchor(std::vector<float>& a, size_t k) {
|
||||
if ((k + 1) * kAnchorFloats > a.size()) return;
|
||||
a.erase(a.begin() + (long)(kAnchorFloats * k), a.begin() + (long)(kAnchorFloats * (k + 1)));
|
||||
}
|
||||
|
||||
} // namespace bezier
|
||||
+437
-113
@@ -3327,6 +3327,38 @@ SS_MSG(mask_enable_help,
|
||||
"en çok artıran tek şey budur. Kendi maskeleriyle gelen girdiler "
|
||||
"onları korur; bu ayar geri kalanlar içindir."));
|
||||
|
||||
SS_MSG(mask_objects_need_segmentation,
|
||||
EN("Removing objects needs a build with the segmentation module, and this "
|
||||
"one has none. Removing fixed areas of the frame still works."),
|
||||
JA("物体を取り除くには分割モジュール入りのビルドが必要ですが、このビルドには"
|
||||
"入っていません。画面の決まった位置を取り除くことはできます。"),
|
||||
ZH_HANS("移除物体需要包含分割模块的版本,而此版本没有。去掉画面中固定的区域"
|
||||
"仍然可用。"),
|
||||
ZH_HANT("移除物體需要包含分割模組的版本,而此版本沒有。去掉畫面中固定的區域"
|
||||
"仍然可用。"),
|
||||
KO("물체를 제거하려면 분할 모듈이 포함된 빌드가 필요한데, 이 빌드에는 "
|
||||
"없습니다. 화면에서 늘 같은 자리를 없애는 기능은 그대로 쓸 수 있습니다."),
|
||||
DE("Objekte entfernen braucht einen Build mit Segmentierungsmodul, und "
|
||||
"dieser hat keines. Feste Bereiche des Bildes lassen sich weiterhin "
|
||||
"entfernen."),
|
||||
FR("Retirer des objets demande une version avec le module de segmentation, "
|
||||
"et celle-ci ne l'a pas. Retirer les zones fixes de l'image reste "
|
||||
"possible."),
|
||||
ES("Quitar objetos requiere una compilación con el módulo de segmentación, "
|
||||
"y esta no lo tiene. Quitar las zonas fijas del fotograma sigue "
|
||||
"funcionando."),
|
||||
PT("Remover objetos exige uma versão com o módulo de segmentação, e esta "
|
||||
"não o tem. Tirar as áreas fixas do quadro continua funcionando."),
|
||||
IT("Rimuovere oggetti richiede una build con il modulo di segmentazione, e "
|
||||
"questa non ce l'ha. Togliere le zone fisse del fotogramma funziona "
|
||||
"comunque."),
|
||||
NL("Objecten verwijderen vraagt een build met de segmentatiemodule, en deze "
|
||||
"heeft er geen. Vaste gebieden van het beeld weghalen werkt nog steeds."),
|
||||
RU("Чтобы убирать объекты, нужна сборка с модулем сегментации, а в этой его "
|
||||
"нет. Убирать постоянные участки кадра по-прежнему можно."),
|
||||
TR("Nesneleri kaldırmak için bölütleme modülü içeren bir sürüm gerekir; bu "
|
||||
"sürümde yok. Karenin sabit alanlarını çıkarmak yine de çalışır."));
|
||||
|
||||
SS_MSG(mask_model,
|
||||
EN("Model"), JA("モデル"), ZH_HANS("模型"), ZH_HANT("模型"),
|
||||
KO("모델"), DE("Modell"), FR("Modèle"), ES("Modelo"),
|
||||
@@ -5946,19 +5978,51 @@ SS_MSG(stencil_edit_border,
|
||||
TR("Sınır elipsini düzenle"));
|
||||
|
||||
SS_MSG(stencil_edit_border_help,
|
||||
EN("Use the current border as a fixed editable ellipse for this input. Turns off automatic detection and starts a new shape edit history."),
|
||||
JA("現在の境界をこの入力用の固定された編集可能な楕円にします。自動検出を無効にし、図形の編集履歴をリセットします。"),
|
||||
ZH_HANS("将当前边界转为此输入的固定可编辑椭圆。关闭自动检测,并重置形状编辑历史。"),
|
||||
ZH_HANT("將目前邊界轉為此輸入的固定可編輯橢圓。關閉自動偵測,並重設形狀編輯歷史。"),
|
||||
KO("현재 경계를 이 입력의 고정된 편집 가능한 타원으로 사용합니다. 자동 감지를 끄고 도형 편집 기록을 초기화합니다."),
|
||||
DE("Verwendet den aktuellen Rand als feste, bearbeitbare Ellipse für diese Eingabe. Deaktiviert die automatische Erkennung und setzt den Formverlauf zurück."),
|
||||
FR("Utilise le bord actuel comme ellipse fixe modifiable pour cette entrée. Désactive la détection automatique et réinitialise l'historique des formes."),
|
||||
ES("Usa el borde actual como elipse fija editable para esta entrada. Desactiva la detección automática y reinicia el historial de formas."),
|
||||
PT("Usa a borda atual como elipse fixa editável para esta entrada. Desativa a detecção automática e reinicia o histórico de formas."),
|
||||
IT("Usa il bordo attuale come ellisse fissa modificabile per questo ingresso. Disattiva il rilevamento automatico e azzera la cronologia delle forme."),
|
||||
NL("Gebruikt de huidige rand als vaste, bewerkbare ellips voor deze invoer. Schakelt automatische detectie uit en wist de vormgeschiedenis."),
|
||||
RU("Использует текущую границу как фиксированный редактируемый эллипс для этого входа. Отключает автоопределение и сбрасывает историю фигур."),
|
||||
TR("Geçerli sınırı bu girdi için sabit, düzenlenebilir bir elips olarak kullanır. Otomatik algılamayı kapatır ve şekil düzenleme geçmişini sıfırlar."));
|
||||
EN("Use the current border as a fixed ellipse you can move and resize, and turn off "
|
||||
"automatic detection. On an input with several cameras the ellipse is this camera's "
|
||||
"only: separate areas for each camera are turned on, and the other lenses keep their "
|
||||
"own borders."),
|
||||
JA("現在の境界を、移動や大きさの変更ができる固定の楕円にし、自動検出を無効にします。複数の"
|
||||
"カメラを持つ入力では、カメラごとの範囲が有効になり、楕円はこのカメラだけのものに"
|
||||
"なります。ほかのレンズは自分の境界のままです。"),
|
||||
ZH_HANS("将当前边界转为可移动、可改变大小的固定椭圆,并关闭自动检测。对有多台相机的输入,"
|
||||
"会开启每台相机单独设置区域,椭圆只属于这台相机,其他镜头保留各自的边界。"),
|
||||
ZH_HANT("將目前邊界轉為可移動、可改變大小的固定橢圓,並關閉自動偵測。對有多台相機的輸入,"
|
||||
"會開啟每台相機分別設定區域,橢圓只屬於這台相機,其他鏡頭保留各自的邊界。"),
|
||||
KO("현재 경계를 옮기고 크기를 바꿀 수 있는 고정 타원으로 만들고 자동 감지를 끕니다. "
|
||||
"카메라가 여럿인 입력에서는 카메라마다 따로 영역 지정이 켜져 타원은 이 카메라에만 "
|
||||
"적용되고, 다른 렌즈는 각자의 경계를 유지합니다."),
|
||||
DE("Macht den aktuellen Rand zu einer festen Ellipse, die sich verschieben und skalieren "
|
||||
"lässt, und schaltet die automatische Erkennung aus. Bei einer Eingabe mit mehreren "
|
||||
"Kameras werden eigene Bereiche je Kamera eingeschaltet: Die Ellipse gilt nur für "
|
||||
"diese Kamera, die anderen Objektive behalten ihren eigenen Rand."),
|
||||
FR("Fait du bord actuel une ellipse fixe que l'on peut déplacer et redimensionner, et "
|
||||
"désactive la détection automatique. Sur une entrée à plusieurs caméras, les zones "
|
||||
"distinctes par caméra sont activées : l'ellipse ne vaut que pour cette caméra, et les "
|
||||
"autres objectifs gardent leur propre bord."),
|
||||
ES("Convierte el borde actual en una elipse fija que se puede mover y redimensionar, y "
|
||||
"desactiva la detección automática. En una entrada con varias cámaras se activan las "
|
||||
"zonas distintas para cada cámara: la elipse es solo de esta cámara y los demás "
|
||||
"objetivos conservan su propio borde."),
|
||||
PT("Transforma a borda atual numa elipse fixa que pode ser movida e redimensionada, e "
|
||||
"desativa a detecção automática. Numa entrada com várias câmeras, as áreas separadas "
|
||||
"para cada câmera são ativadas: a elipse é só desta câmera, e as outras lentes mantêm "
|
||||
"sua própria borda."),
|
||||
IT("Trasforma il bordo attuale in un'ellisse fissa che si può spostare e ridimensionare, e "
|
||||
"disattiva il rilevamento automatico. Su un ingresso con più fotocamere si attivano le "
|
||||
"aree separate per ogni fotocamera: l'ellisse vale solo per questa, e gli altri "
|
||||
"obiettivi mantengono il proprio bordo."),
|
||||
NL("Maakt van de huidige rand een vaste ellips die kan worden verplaatst en geschaald, en "
|
||||
"schakelt automatische detectie uit. Bij een invoer met meerdere camera's worden aparte "
|
||||
"gebieden per camera ingeschakeld: de ellips geldt alleen voor deze camera en de andere "
|
||||
"lenzen houden hun eigen rand."),
|
||||
RU("Превращает текущую границу в фиксированный эллипс, который можно двигать и менять в "
|
||||
"размере, и отключает автоопределение. Для входа с несколькими камерами включаются "
|
||||
"отдельные области для каждой камеры: эллипс относится только к этой камере, а другие "
|
||||
"объективы сохраняют свои границы."),
|
||||
TR("Geçerli sınırı taşınabilen ve boyutlandırılabilen sabit bir elipse çevirir ve otomatik "
|
||||
"algılamayı kapatır. Birden çok kameralı bir girdide her kamera için ayrı alanlar "
|
||||
"açılır: elips yalnızca bu kameranındır, diğer mercekler kendi sınırlarını korur."));
|
||||
|
||||
SS_MSG(stencil_looking,
|
||||
EN("Looking for the border..."),
|
||||
@@ -6200,48 +6264,88 @@ SS_MSG(stencil_save,
|
||||
|
||||
SS_MSG(stencil_save_help,
|
||||
EN("Keep what is drawn here, without the fitted lens circle, as an SVG file in "
|
||||
"normalized coordinates. A saved set can be loaded onto another input, and "
|
||||
"picked on the dataset screen for every input and for dataset presets."),
|
||||
"normalized coordinates. With separate areas for each camera and cameras that "
|
||||
"differ, each camera gets its own file, and loading the set puts each back on "
|
||||
"its camera. A saved set can be loaded onto another input, and picked on the "
|
||||
"dataset screen for every input and for dataset presets."),
|
||||
JA("ここで描いたもの(検出したレンズの円は含みません)を、正規化座標の SVG "
|
||||
"ファイルとして保存します。保存したものは別の入力に読み込めるほか、データ"
|
||||
"セット画面で全入力とデータセットのプリセットに使えます。"),
|
||||
"ファイルとして保存します。カメラごとに別の範囲を使い、カメラで内容が違うときは"
|
||||
"カメラごとにファイルを分け、読み込むとそれぞれのカメラに戻ります。保存したものは"
|
||||
"別の入力に読み込めるほか、データセット画面で全入力とデータセットのプリセットに"
|
||||
"使えます。"),
|
||||
ZH_HANS("把这里画的内容(不含检测到的镜头圆)保存为归一化坐标的 SVG 文件。"
|
||||
"保存后可以载入到其他输入,也可以在数据集界面中用于所有输入和数据集预设。"),
|
||||
"每台相机单独设置区域且各相机不同时,每台相机各存一个文件,载入时各自回到"
|
||||
"对应的相机。保存后可以载入到其他输入,也可以在数据集界面中用于所有输入和"
|
||||
"数据集预设。"),
|
||||
ZH_HANT("把這裡畫的內容(不含偵測到的鏡頭圓)儲存為正規化座標的 SVG 檔案。"
|
||||
"儲存後可以載入到其他輸入,也可以在資料集畫面中用於所有輸入和資料集預設。"),
|
||||
"每台相機分別設定區域且各相機不同時,每台相機各存一個檔案,載入時各自回到"
|
||||
"對應的相機。儲存後可以載入到其他輸入,也可以在資料集畫面中用於所有輸入和"
|
||||
"資料集預設。"),
|
||||
KO("여기서 그린 것을(찾아낸 렌즈 원은 빼고) 정규화 좌표의 SVG 파일로 저장합니다. "
|
||||
"저장한 것은 다른 입력에 불러올 수 있고, 데이터셋 화면에서 모든 입력과 데이터셋 "
|
||||
"프리셋에 쓸 수 있습니다."),
|
||||
"카메라마다 따로 영역을 지정했고 카메라끼리 다르면 카메라마다 파일을 따로 두고, "
|
||||
"불러오면 각자의 카메라로 돌아갑니다. 저장한 것은 다른 입력에 불러올 수 있고, "
|
||||
"데이터셋 화면에서 모든 입력과 데이터셋 프리셋에 쓸 수 있습니다."),
|
||||
DE("Das hier Gezeichnete ohne den erkannten Objektivkreis als SVG-Datei in "
|
||||
"normierten Koordinaten speichern. Gespeichertes lässt sich auf eine andere "
|
||||
"normierten Koordinaten speichern. Mit eigenen Bereichen je Kamera und "
|
||||
"unterschiedlichen Kameras bekommt jede Kamera eine eigene Datei, und beim Laden "
|
||||
"kommt jede wieder auf ihre Kamera. Gespeichertes lässt sich auf eine andere "
|
||||
"Eingabe laden und im Datensatz-Bildschirm für alle Eingaben und für "
|
||||
"Datensatz-Voreinstellungen wählen."),
|
||||
FR("Garder ce qui est dessiné ici, sans le cercle d'objectif détecté, dans un "
|
||||
"fichier SVG en coordonnées normalisées. Un ensemble enregistré se charge sur "
|
||||
"une autre entrée et se choisit dans l'écran du jeu de données pour toutes "
|
||||
"les entrées et pour les préréglages."),
|
||||
"fichier SVG en coordonnées normalisées. Avec des zones distinctes par caméra et "
|
||||
"des caméras qui diffèrent, chaque caméra a son propre fichier, et le chargement "
|
||||
"remet chacun sur sa caméra. Un ensemble enregistré se charge sur une autre "
|
||||
"entrée et se choisit dans l'écran du jeu de données pour toutes les entrées et "
|
||||
"pour les préréglages."),
|
||||
ES("Guardar lo dibujado aquí, sin el círculo de objetivo detectado, como archivo "
|
||||
"SVG en coordenadas normalizadas. Lo guardado se puede cargar en otra entrada "
|
||||
"y elegir en la pantalla del conjunto de datos para todas las entradas y los "
|
||||
"ajustes predefinidos."),
|
||||
"SVG en coordenadas normalizadas. Con zonas distintas para cada cámara y cámaras "
|
||||
"que difieren, cada cámara tiene su propio archivo, y al cargar el juego cada uno "
|
||||
"vuelve a su cámara. Lo guardado se puede cargar en otra entrada y elegir en la "
|
||||
"pantalla del conjunto de datos para todas las entradas y los ajustes "
|
||||
"predefinidos."),
|
||||
PT("Guardar o que foi desenhado aqui, sem o círculo de lente detectado, como "
|
||||
"arquivo SVG em coordenadas normalizadas. O que foi salvo pode ser carregado "
|
||||
"em outra entrada e escolhido na tela do conjunto de dados para todas as "
|
||||
"entradas e para as predefinições."),
|
||||
"arquivo SVG em coordenadas normalizadas. Com áreas separadas para cada câmera e "
|
||||
"câmeras diferentes, cada câmera tem seu próprio arquivo, e ao carregar o "
|
||||
"conjunto cada um volta à sua câmera. O que foi salvo pode ser carregado em outra "
|
||||
"entrada e escolhido na tela do conjunto de dados para todas as entradas e para "
|
||||
"as predefinições."),
|
||||
IT("Salva ciò che è disegnato qui, senza il cerchio dell'obiettivo rilevato, "
|
||||
"come file SVG in coordinate normalizzate. Un insieme salvato si carica su "
|
||||
"un altro input e si sceglie nella schermata del dataset per tutti gli input "
|
||||
"e per i preset."),
|
||||
"come file SVG in coordinate normalizzate. Con aree separate per ogni fotocamera "
|
||||
"e fotocamere diverse, ogni fotocamera ha il suo file, e caricando l'insieme "
|
||||
"ognuno torna sulla sua fotocamera. Un insieme salvato si carica su un altro "
|
||||
"input e si sceglie nella schermata del dataset per tutti gli input e per i "
|
||||
"preset."),
|
||||
NL("Bewaar wat hier getekend is, zonder de gevonden lenscirkel, als SVG-bestand "
|
||||
"in genormaliseerde coördinaten. Een opgeslagen set kun je op een andere "
|
||||
"invoer laden en op het datasetscherm kiezen voor alle invoer en voor "
|
||||
"in genormaliseerde coördinaten. Met aparte gebieden per camera en camera's die "
|
||||
"verschillen krijgt elke camera een eigen bestand, en bij het laden gaat elk "
|
||||
"terug naar zijn camera. Een opgeslagen set kan op een andere invoer worden "
|
||||
"geladen en op het datasetscherm worden gekozen voor alle invoer en voor "
|
||||
"datasetvoorinstellingen."),
|
||||
RU("Сохранить нарисованное здесь, без найденного круга объектива, в файл SVG в "
|
||||
"нормированных координатах. Сохранённое можно загрузить на другой вход и "
|
||||
"выбрать на экране набора данных для всех входов и для пресетов."),
|
||||
"нормированных координатах. При отдельных областях для каждой камеры и разных "
|
||||
"камерах у каждой камеры свой файл, и при загрузке набора каждый возвращается на "
|
||||
"свою камеру. Сохранённое можно загрузить на другой вход и выбрать на экране "
|
||||
"набора данных для всех входов и для пресетов."),
|
||||
TR("Burada çizileni, bulunan mercek dairesi olmadan, normalize koordinatlarda "
|
||||
"bir SVG dosyası olarak sakla. Kaydedilen, başka bir girdiye yüklenebilir ve "
|
||||
"veri kümesi ekranında tüm girdiler ve ön ayarlar için seçilebilir."));
|
||||
"bir SVG dosyası olarak sakla. Her kamera için ayrı alanlar açıkken kameralar "
|
||||
"farklıysa her kameranın kendi dosyası olur ve takım yüklenince her biri kendi "
|
||||
"kamerasına döner. Kaydedilen, başka bir girdiye yüklenebilir ve veri kümesi "
|
||||
"ekranında tüm girdiler ve ön ayarlar için seçilebilir."));
|
||||
|
||||
SS_MSG(stencil_set_no_camera,
|
||||
EN("These areas are for the cameras {0}, and this input has none of them."),
|
||||
JA("この範囲はカメラ {0} 用ですが、この入力にはそのどれもありません。"),
|
||||
ZH_HANS("这些区域是为相机 {0} 设置的,而此输入没有其中任何一台。"),
|
||||
ZH_HANT("這些區域是為相機 {0} 設定的,而此輸入沒有其中任何一台。"),
|
||||
KO("이 영역은 카메라 {0} 용인데, 이 입력에는 그중 어느 것도 없습니다."),
|
||||
DE("Diese Bereiche gehören zu den Kameras {0}, und diese Eingabe hat keine davon."),
|
||||
FR("Ces zones sont pour les caméras {0}, et cette entrée n'en a aucune."),
|
||||
ES("Estas zonas son para las cámaras {0}, y esta entrada no tiene ninguna."),
|
||||
PT("Estas áreas são para as câmeras {0}, e esta entrada não tem nenhuma delas."),
|
||||
IT("Queste aree sono per le fotocamere {0}, e questo input non ne ha nessuna."),
|
||||
NL("Deze gebieden zijn voor de camera's {0}, en deze invoer heeft er geen van."),
|
||||
RU("Эти области — для камер {0}, а у этого входа нет ни одной из них."),
|
||||
TR("Bu alanlar {0} kameraları için ve bu girdide bunların hiçbiri yok."));
|
||||
|
||||
SS_MSG(stencil_saved_as,
|
||||
EN("Saved: {0}"), JA("保存しました: {0}"), ZH_HANS("已保存:{0}"), ZH_HANT("已儲存:{0}"),
|
||||
@@ -6509,6 +6613,299 @@ SS_MSG(stencil_drag_hint,
|
||||
TR("Taşımak için resimde sürükleyin, boyutlandırmak için beyaz noktalarını "
|
||||
"sürükleyin."));
|
||||
|
||||
SS_MSG(stencil_shape_curve,
|
||||
EN("Pen shape {0}"), JA("ペン図形 {0}"), ZH_HANS("钢笔形状 {0}"), ZH_HANT("鋼筆形狀 {0}"),
|
||||
KO("펜 도형 {0}"), DE("Zeichenstift-Form {0}"), FR("Forme à la plume {0}"),
|
||||
ES("Forma de pluma {0}"), PT("Forma da caneta {0}"), IT("Forma a penna {0}"),
|
||||
NL("Penvorm {0}"), RU("Фигура пером {0}"), TR("Kalem şekli {0}"));
|
||||
|
||||
SS_MSG(stencil_tool_points,
|
||||
EN("Points"), JA("ポイント"), ZH_HANS("锚点"), ZH_HANT("錨點"),
|
||||
KO("포인트"), DE("Punkte"), FR("Points"),
|
||||
ES("Puntos"), PT("Pontos"), IT("Punti"),
|
||||
NL("Punten"), RU("Точки"), TR("Noktalar"));
|
||||
|
||||
SS_MSG(stencil_tool_points_help,
|
||||
EN("Direct selection: drag a pen shape's anchors (squares) and handles (circles). A "
|
||||
"handle of a smooth anchor turns the other with it; Alt+drag moves it alone, Shift "
|
||||
"keeps 45-degree steps, Delete removes the picked anchor. Click inside a shape to "
|
||||
"pick it; other shapes move and resize as under Select."),
|
||||
JA("ダイレクト選択: ペン図形の点(四角)とハンドル(丸)をドラッグします。なめらかな点の"
|
||||
"ハンドルは反対側も一緒に回り、Alt+ドラッグで片方だけ、Shift で 45 度刻みになります。"
|
||||
"Delete で選んだ点を削除します。図形の内側をクリックすると選べます。ほかの図形は"
|
||||
"「選択」と同じく移動と大きさの変更ができます。"),
|
||||
ZH_HANS("直接选择:拖动钢笔形状的锚点(方块)和手柄(圆点)。平滑锚点的一侧手柄会带着"
|
||||
"另一侧转动;Alt+拖动只移动一侧,Shift 以 45 度为步长,Delete 删除选中的锚点。"
|
||||
"在形状内部单击即可选中;其他形状与“选择”一样可移动和改变大小。"),
|
||||
ZH_HANT("直接選取:拖曳鋼筆形狀的錨點(方塊)和控制把手(圓點)。平滑錨點的一側把手會"
|
||||
"帶著另一側轉動;Alt+拖曳只移動一側,Shift 以 45 度為步長,Delete 刪除選取的"
|
||||
"錨點。在形狀內部點一下即可選取;其他形狀與「選取」一樣可移動和改變大小。"),
|
||||
KO("직접 선택: 펜 도형의 앵커(네모)와 핸들(동그라미)을 드래그합니다. 부드러운 앵커의 "
|
||||
"핸들은 반대쪽도 함께 돌고, Alt+드래그는 한쪽만 옮기며, Shift는 45도 단위로 맞춥니다. "
|
||||
"Delete는 고른 앵커를 지웁니다. 도형 안쪽을 클릭하면 고를 수 있고, 다른 도형은 "
|
||||
"선택 도구처럼 옮기거나 크기를 바꿉니다."),
|
||||
DE("Direktauswahl: die Anker (Quadrate) und Griffe (Kreise) einer Zeichenstift-Form "
|
||||
"ziehen. Der Griff eines glatten Ankers dreht den anderen mit; Alt+Ziehen bewegt ihn "
|
||||
"allein, Umschalt rastet in 45-Grad-Schritten ein, Entf löscht den gewählten Anker. "
|
||||
"Ein Klick in eine Form wählt sie; andere Formen werden wie unter Auswählen "
|
||||
"verschoben und skaliert."),
|
||||
FR("Sélection directe : faites glisser les ancres (carrés) et poignées (cercles) d'une "
|
||||
"forme à la plume. La poignée d'une ancre lisse fait tourner l'autre ; Alt+glisser la "
|
||||
"déplace seule, Maj force des pas de 45 degrés, Suppr retire l'ancre choisie. Un clic "
|
||||
"dans une forme la choisit ; les autres formes se déplacent et se redimensionnent "
|
||||
"comme avec Sélectionner."),
|
||||
ES("Selección directa: arrastre las anclas (cuadrados) y tiradores (círculos) de una "
|
||||
"forma de pluma. El tirador de un ancla suave gira el otro con él; Alt+arrastrar lo "
|
||||
"mueve solo, Mayús fija pasos de 45 grados y Supr quita el ancla elegida. Un clic "
|
||||
"dentro de una forma la elige; las demás se mueven y redimensionan como con "
|
||||
"Seleccionar."),
|
||||
PT("Seleção direta: arraste as âncoras (quadrados) e alças (círculos) de uma forma da "
|
||||
"caneta. A alça de uma âncora suave gira a outra junto; Alt+arrastar a move sozinha, "
|
||||
"Shift fixa passos de 45 graus e Delete remove a âncora escolhida. Um clique dentro "
|
||||
"de uma forma a escolhe; as outras se movem e redimensionam como em Selecionar."),
|
||||
IT("Selezione diretta: trascini gli ancoraggi (quadrati) e le maniglie (cerchi) di una "
|
||||
"forma a penna. La maniglia di un ancoraggio morbido fa ruotare l'altra; Alt+trascina "
|
||||
"la sposta da sola, Maiusc blocca a passi di 45 gradi, Canc toglie l'ancoraggio "
|
||||
"scelto. Un clic dentro una forma la sceglie; le altre si spostano e ridimensionano "
|
||||
"come con Seleziona."),
|
||||
NL("Directe selectie: sleep de ankers (vierkantjes) en grepen (rondjes) van een penvorm. "
|
||||
"De greep van een glad anker draait de andere mee; Alt+slepen beweegt hem alleen, "
|
||||
"Shift houdt stappen van 45 graden aan, Delete verwijdert het gekozen anker. Klik in "
|
||||
"een vorm om hem te kiezen; andere vormen verschuiven en schalen zoals bij Selecteren."),
|
||||
RU("Прямое выделение: перетаскивайте опорные точки (квадраты) и ручки (кружки) фигуры, "
|
||||
"нарисованной пером. Ручка гладкой точки поворачивает и вторую; Alt+перетаскивание "
|
||||
"двигает её одну, Shift держит шаг 45 градусов, Delete удаляет выбранную точку. "
|
||||
"Щелчок внутри фигуры выбирает её; остальные фигуры двигаются и меняют размер, как "
|
||||
"в «Выделении»."),
|
||||
TR("Doğrudan seçim: bir kalem şeklinin çapalarını (kareler) ve tutamaçlarını (daireler) "
|
||||
"sürükleyin. Düzgün bir çapanın tutamacı diğerini de döndürür; Alt+sürükleme onu tek "
|
||||
"başına taşır, Shift 45 derecelik adımlar tutar, Delete seçilen çapayı siler. Bir "
|
||||
"şeklin içine tıklamak onu seçer; diğer şekiller Seç aracındaki gibi taşınır ve "
|
||||
"boyutlanır."));
|
||||
|
||||
SS_MSG(stencil_tool_pen_help,
|
||||
EN("Click for a corner, drag for a curve, as in a vector editor. Shift keeps 45-degree "
|
||||
"steps; Alt while dragging moves one handle alone; Space while dragging moves the "
|
||||
"anchor; Ctrl+drag moves a point already placed. Click the first anchor, press Enter "
|
||||
"or right-click to close it; Backspace or Ctrl+Z takes an anchor back, Esc cancels. "
|
||||
"With a pen shape picked, click its outline to add an anchor, click an anchor to "
|
||||
"delete it, and Alt+click an anchor to make it a corner or Alt+drag it to pull new "
|
||||
"handles."),
|
||||
JA("ベクター編集ソフトと同じく、クリックで角、ドラッグで曲線の点を置きます。Shift で "
|
||||
"45 度刻み、ドラッグ中の Alt で片方のハンドルだけ、ドラッグ中の Space で点そのものを"
|
||||
"動かし、Ctrl+ドラッグで置いた点を動かせます。最初の点をクリックするか Enter か右"
|
||||
"クリックで閉じます。Backspace か Ctrl+Z で点を戻し、Esc で中止します。ペン図形を"
|
||||
"選んでいるときは、輪郭のクリックで点を追加、点のクリックで削除、点の Alt+クリックで"
|
||||
"角に、Alt+ドラッグで新しいハンドルを引き出します。"),
|
||||
ZH_HANS("与矢量绘图软件一样:点击放下角点,拖动放下曲线点。Shift 以 45 度为步长;拖动时按 "
|
||||
"Alt 只移动一侧手柄;拖动时按 Space 移动锚点本身;Ctrl+拖动移动已放下的点。点击"
|
||||
"第一个锚点、按 Enter 或右键闭合;Backspace 或 Ctrl+Z 撤回一个锚点,Esc 取消。"
|
||||
"选中钢笔形状时,点击轮廓添加锚点,点击锚点将其删除,Alt+点击锚点将其变为角点,"
|
||||
"Alt+拖动则拉出新手柄。"),
|
||||
ZH_HANT("與向量繪圖軟體一樣:點擊放下角點,拖曳放下曲線點。Shift 以 45 度為步長;拖曳時"
|
||||
"按 Alt 只移動一側控制把手;拖曳時按 Space 移動錨點本身;Ctrl+拖曳移動已放下的"
|
||||
"點。點擊第一個錨點、按 Enter 或右鍵閉合;Backspace 或 Ctrl+Z 收回一個錨點,Esc "
|
||||
"取消。選取鋼筆形狀時,點擊輪廓新增錨點,點擊錨點將其刪除,Alt+點擊錨點將其變為"
|
||||
"角點,Alt+拖曳則拉出新的控制把手。"),
|
||||
KO("벡터 편집기처럼 클릭하면 모서리, 드래그하면 곡선 앵커를 놓습니다. Shift는 45도 단위로 "
|
||||
"맞추고, 드래그 중 Alt는 핸들 한쪽만, 드래그 중 Space는 앵커 자체를 옮기며, Ctrl+"
|
||||
"드래그는 이미 놓은 점을 옮깁니다. 첫 앵커 클릭, Enter 또는 오른쪽 클릭으로 닫고, "
|
||||
"Backspace나 Ctrl+Z는 앵커를 되돌리며 Esc는 취소합니다. 펜 도형을 고른 상태에서는 "
|
||||
"윤곽을 클릭해 앵커를 더하고, 앵커를 클릭해 지우고, 앵커를 Alt+클릭해 모서리로 "
|
||||
"만들거나 Alt+드래그해 새 핸들을 뽑습니다."),
|
||||
DE("Klick setzt eine Ecke, Ziehen eine Kurve, wie in einem Vektorprogramm. Umschalt "
|
||||
"rastet in 45-Grad-Schritten ein; Alt beim Ziehen bewegt nur einen Griff, Leertaste "
|
||||
"beim Ziehen den Anker selbst; Strg+Ziehen verschiebt einen schon gesetzten Punkt. "
|
||||
"Erster Anker, Eingabe oder Rechtsklick schließt den Pfad; Rücktaste oder Strg+Z "
|
||||
"nimmt einen Anker zurück, Esc bricht ab. Ist eine Zeichenstift-Form gewählt, fügt "
|
||||
"ein Klick auf ihre Kontur einen Anker hinzu, ein Klick auf einen Anker löscht ihn, "
|
||||
"Alt+Klick macht ihn zur Ecke und Alt+Ziehen zieht neue Griffe heraus."),
|
||||
FR("Un clic pose un angle, un glissé une courbe, comme dans un logiciel vectoriel. Maj "
|
||||
"force des pas de 45 degrés ; Alt pendant le glissé déplace une seule poignée, Espace "
|
||||
"l'ancre elle-même ; Ctrl+glisser déplace un point déjà posé. La première ancre, "
|
||||
"Entrée ou un clic droit ferme le tracé ; Retour arrière ou Ctrl+Z retire une ancre, "
|
||||
"Échap annule. Une forme à la plume choisie, un clic sur son contour ajoute une ancre, "
|
||||
"un clic sur une ancre la supprime, Alt+clic en fait un angle et Alt+glisser en tire "
|
||||
"de nouvelles poignées."),
|
||||
ES("Un clic pone una esquina y un arrastre una curva, como en un editor vectorial. Mayús "
|
||||
"fija pasos de 45 grados; Alt al arrastrar mueve un solo tirador y Espacio el ancla; "
|
||||
"Ctrl+arrastrar mueve un punto ya puesto. La primera ancla, Intro o clic derecho cierra "
|
||||
"el trazado; Retroceso o Ctrl+Z quita un ancla, Esc cancela. Con una forma de pluma "
|
||||
"elegida, un clic en su contorno añade un ancla, un clic en un ancla la borra, Alt+clic "
|
||||
"la vuelve esquina y Alt+arrastrar saca tiradores nuevos."),
|
||||
PT("Um clique põe um canto e um arraste uma curva, como num editor vetorial. Shift fixa "
|
||||
"passos de 45 graus; Alt ao arrastar move uma só alça e Espaço a própria âncora; "
|
||||
"Ctrl+arrastar move um ponto já posto. A primeira âncora, Enter ou clique direito fecha "
|
||||
"o traçado; Backspace ou Ctrl+Z tira uma âncora, Esc cancela. Com uma forma da caneta "
|
||||
"escolhida, um clique no contorno acrescenta uma âncora, um clique numa âncora a "
|
||||
"apaga, Alt+clique a torna canto e Alt+arrastar puxa alças novas."),
|
||||
IT("Un clic mette un angolo, un trascinamento una curva, come in un editor vettoriale. "
|
||||
"Maiusc blocca a passi di 45 gradi; Alt durante il trascinamento muove una sola "
|
||||
"maniglia, Spazio l'ancoraggio stesso; Ctrl+trascina sposta un punto già messo. Il "
|
||||
"primo ancoraggio, Invio o il clic destro chiude il tracciato; Backspace o Ctrl+Z "
|
||||
"toglie un ancoraggio, Esc annulla. Con una forma a penna scelta, un clic sul contorno "
|
||||
"aggiunge un ancoraggio, un clic su un ancoraggio lo elimina, Alt+clic lo rende un "
|
||||
"angolo e Alt+trascina estrae nuove maniglie."),
|
||||
NL("Klik zet een hoek, slepen een kromme, zoals in een vectorprogramma. Shift houdt "
|
||||
"stappen van 45 graden aan; Alt tijdens het slepen beweegt één greep, Spatie het anker "
|
||||
"zelf; Ctrl+slepen verplaatst een al gezet punt. Het eerste anker, Enter of rechtsklik "
|
||||
"sluit het pad; Backspace of Ctrl+Z neemt een anker terug, Esc breekt af. Met een "
|
||||
"penvorm gekozen voegt een klik op de omtrek een anker toe, verwijdert een klik op een "
|
||||
"anker het, maakt Alt+klik er een hoek van en trekt Alt+slepen nieuwe grepen uit."),
|
||||
RU("Щелчок ставит угол, перетаскивание — кривую, как в векторном редакторе. Shift держит "
|
||||
"шаг 45 градусов; Alt при перетаскивании двигает одну ручку, пробел — саму точку; "
|
||||
"Ctrl+перетаскивание двигает уже поставленную точку. Первая точка, Enter или правая "
|
||||
"кнопка замыкают контур; Backspace или Ctrl+Z убирает точку, Esc отменяет. Когда "
|
||||
"выбрана фигура пером, щелчок по контуру добавляет точку, щелчок по точке удаляет её, "
|
||||
"Alt+щелчок делает её угловой, а Alt+перетаскивание вытягивает новые ручки."),
|
||||
TR("Bir vektör düzenleyicideki gibi tıklama bir köşe, sürükleme bir eğri bırakır. Shift "
|
||||
"45 derecelik adımlar tutar; sürüklerken Alt tek bir tutamacı, Boşluk çapanın "
|
||||
"kendisini taşır; Ctrl+sürükleme önceden konmuş bir noktayı taşır. İlk çapa, Enter "
|
||||
"veya sağ tık yolu kapatır; Backspace veya Ctrl+Z bir çapayı geri alır, Esc iptal "
|
||||
"eder. Bir kalem şekli seçiliyken dış hattına tıklamak çapa ekler, bir çapaya "
|
||||
"tıklamak onu siler, Alt+tıklama onu köşe yapar, Alt+sürükleme yeni tutamaçlar çeker."));
|
||||
|
||||
SS_MSG(stencil_per_camera,
|
||||
EN("Separate areas for each camera"),
|
||||
JA("カメラごとに別の範囲"),
|
||||
ZH_HANS("每台相机单独设置区域"),
|
||||
ZH_HANT("每台相機分別設定區域"),
|
||||
KO("카메라마다 따로 영역 지정"),
|
||||
DE("Eigene Bereiche je Kamera"),
|
||||
FR("Zones distinctes par caméra"),
|
||||
ES("Zonas distintas para cada cámara"),
|
||||
PT("Áreas separadas para cada câmera"),
|
||||
IT("Aree separate per ogni fotocamera"),
|
||||
NL("Aparte gebieden per camera"),
|
||||
RU("Отдельные области для каждой камеры"),
|
||||
TR("Her kamera için ayrı alanlar"));
|
||||
|
||||
SS_MSG(stencil_per_camera_help,
|
||||
EN("This input writes several cameras: the two lenses of a dual-fisheye file, the "
|
||||
"views of a 360 video, or subfolders of photos. Tick this to give each its own border "
|
||||
"setting and shapes. Every camera starts from the shared ones, and the camera picker "
|
||||
"(or the frame slider) chooses which one the tools edit. Untick to go back to one "
|
||||
"set, keeping the camera shown."),
|
||||
JA("この入力は複数のカメラを書き出します(デュアル魚眼ファイルの 2 つのレンズ、360 度"
|
||||
"動画の各ビュー、写真のサブフォルダーなど)。チェックすると、カメラごとに境界の設定と"
|
||||
"図形を持てます。どのカメラも共通の設定から始まり、カメラの選択(またはフレームの"
|
||||
"スライダー)でツールが編集するカメラを選びます。外すと、表示中のカメラの設定を"
|
||||
"残して 1 組に戻ります。"),
|
||||
ZH_HANS("这个输入会写出多台相机:双鱼眼文件的两个镜头、360 视频的各个视图,或照片的子"
|
||||
"文件夹。勾选后每台相机都有自己的边界设置和形状。每台相机都从共用的设置开始,"
|
||||
"相机选择(或帧滑块)决定工具编辑哪一台。取消勾选则回到一组设置,保留当前显示"
|
||||
"的相机的。"),
|
||||
ZH_HANT("這個輸入會寫出多台相機:雙魚眼檔案的兩個鏡頭、360 影片的各個視角,或照片的子"
|
||||
"資料夾。勾選後每台相機都有自己的邊界設定和形狀。每台相機都從共用的設定開始,"
|
||||
"相機選擇(或影格滑桿)決定工具編輯哪一台。取消勾選則回到一組設定,保留目前"
|
||||
"顯示的相機的。"),
|
||||
KO("이 입력은 여러 카메라를 씁니다. 듀얼 어안 파일의 두 렌즈, 360 영상의 각 뷰, 사진의 "
|
||||
"하위 폴더 같은 경우입니다. 체크하면 카메라마다 경계 설정과 도형을 따로 둡니다. 모든 "
|
||||
"카메라는 공통 설정에서 시작하고, 카메라 선택(또는 프레임 슬라이더)으로 도구가 편집할 "
|
||||
"카메라를 고릅니다. 체크를 풀면 보이는 카메라의 설정을 남기고 한 벌로 돌아갑니다."),
|
||||
DE("Diese Eingabe schreibt mehrere Kameras: die zwei Objektive einer Dual-Fisheye-Datei, "
|
||||
"die Ansichten eines 360-Videos oder Unterordner mit Fotos. Angehakt bekommt jede "
|
||||
"ihre eigene Randeinstellung und eigene Formen. Jede Kamera beginnt mit den "
|
||||
"gemeinsamen, und die Kameraauswahl (oder der Bildregler) bestimmt, welche die "
|
||||
"Werkzeuge bearbeiten. Abgewählt gilt wieder ein Satz, der der gezeigten Kamera."),
|
||||
FR("Cette entrée écrit plusieurs caméras : les deux objectifs d'un fichier double "
|
||||
"fisheye, les vues d'une vidéo 360 ou des sous-dossiers de photos. Cochez pour donner "
|
||||
"à chacune son propre réglage de bord et ses formes. Chaque caméra part des réglages "
|
||||
"communs, et le choix de caméra (ou le curseur d'image) désigne celle que les outils "
|
||||
"modifient. Décochez pour revenir à un seul jeu, celui de la caméra affichée."),
|
||||
ES("Esta entrada escribe varias cámaras: los dos objetivos de un archivo de doble ojo de "
|
||||
"pez, las vistas de un video 360 o subcarpetas de fotos. Márquelo para dar a cada una "
|
||||
"su propio ajuste de borde y sus formas. Cada cámara parte de las comunes, y el "
|
||||
"selector de cámara (o el deslizador de fotogramas) elige cuál editan las "
|
||||
"herramientas. Desmárquelo para volver a un solo juego, el de la cámara mostrada."),
|
||||
PT("Esta entrada grava várias câmeras: as duas lentes de um arquivo olho de peixe "
|
||||
"duplo, as vistas de um vídeo 360 ou subpastas de fotos. Marque para dar a cada uma "
|
||||
"sua própria configuração de borda e suas formas. Cada câmera parte das comuns, e o "
|
||||
"seletor de câmera (ou o controle de quadros) escolhe qual as ferramentas editam. "
|
||||
"Desmarque para voltar a um só conjunto, o da câmera mostrada."),
|
||||
IT("Questo ingresso scrive più fotocamere: i due obiettivi di un file doppio fisheye, le "
|
||||
"viste di un video 360 o sottocartelle di foto. Selezionandolo, ognuna ha la propria "
|
||||
"impostazione del bordo e le proprie forme. Ogni fotocamera parte da quelle comuni, "
|
||||
"e la scelta della fotocamera (o il cursore dei fotogrammi) indica quale modificano "
|
||||
"gli strumenti. Deselezionandolo si torna a un solo insieme, quello della fotocamera "
|
||||
"mostrata."),
|
||||
NL("Deze invoer schrijft meerdere camera's: de twee lenzen van een dubbel-fisheyebestand, "
|
||||
"de aanzichten van een 360-video of submappen met foto's. Aangevinkt krijgt elke "
|
||||
"camera een eigen randinstelling en eigen vormen. Elke camera begint met de gedeelde, "
|
||||
"en de camerakeuze (of de beeldschuif) bepaalt welke de gereedschappen bewerken. "
|
||||
"Uitgevinkt geldt weer één set: die van de getoonde camera."),
|
||||
RU("Этот вход даёт несколько камер: два объектива файла с двойным «рыбьим глазом», виды "
|
||||
"360-градусного видео или подпапки с фотографиями. Отметьте, чтобы у каждой были свои "
|
||||
"настройка границы и фигуры. Каждая камера начинает с общих, а выбор камеры (или "
|
||||
"ползунок кадров) определяет, какую правят инструменты. Снимите отметку, чтобы "
|
||||
"вернуться к одному набору — набору показанной камеры."),
|
||||
TR("Bu girdi birden çok kamera yazar: çift balıkgözü bir dosyanın iki merceği, 360 "
|
||||
"videonun görünümleri ya da fotoğraf alt klasörleri. İşaretlerseniz her birinin kendi "
|
||||
"sınır ayarı ve şekilleri olur. Her kamera ortak olanlardan başlar; kamera seçimi (ya "
|
||||
"da kare kaydırıcısı) araçların hangisini düzenleyeceğini seçer. İşareti kaldırınca, "
|
||||
"gösterilen kameranınki kalarak tek bir takıma dönülür."));
|
||||
|
||||
SS_MSG(stencil_per_camera_editing,
|
||||
EN("Editing camera: {0}"),
|
||||
JA("編集中のカメラ: {0}"),
|
||||
ZH_HANS("正在编辑的相机:{0}"),
|
||||
ZH_HANT("正在編輯的相機:{0}"),
|
||||
KO("편집 중인 카메라: {0}"),
|
||||
DE("Bearbeitete Kamera: {0}"),
|
||||
FR("Caméra modifiée : {0}"),
|
||||
ES("Cámara en edición: {0}"),
|
||||
PT("Câmera em edição: {0}"),
|
||||
IT("Fotocamera in modifica: {0}"),
|
||||
NL("Bewerkte camera: {0}"),
|
||||
RU("Редактируемая камера: {0}"),
|
||||
TR("Düzenlenen kamera: {0}"));
|
||||
|
||||
SS_MSG(stencil_drag_hint_pen,
|
||||
EN("Drag it on the picture to move it; Points (A) edits its anchors and handles."),
|
||||
JA("画像上でドラッグすると移動します。点とハンドルは「ポイント」(A)で編集します。"),
|
||||
ZH_HANS("在图片上拖动即可移动;锚点和手柄用“锚点”(A)编辑。"),
|
||||
ZH_HANT("在圖片上拖曳即可移動;錨點和控制把手用「錨點」(A)編輯。"),
|
||||
KO("사진 위에서 드래그하면 옮겨집니다. 앵커와 핸들은 포인트(A)로 편집합니다."),
|
||||
DE("Auf dem Bild ziehen, um sie zu verschieben; Punkte (A) bearbeitet Anker und Griffe."),
|
||||
FR("Faites-la glisser sur l'image pour la déplacer ; Points (A) modifie ses ancres et "
|
||||
"poignées."),
|
||||
ES("Arrástrela sobre la imagen para moverla; Puntos (A) edita sus anclas y tiradores."),
|
||||
PT("Arraste-a sobre a imagem para movê-la; Pontos (A) edita suas âncoras e alças."),
|
||||
IT("La trascini sull'immagine per spostarla; Punti (A) ne modifica ancoraggi e maniglie."),
|
||||
NL("Sleep hem op de afbeelding om hem te verplaatsen; Punten (A) bewerkt ankers en grepen."),
|
||||
RU("Перетащите её на изображении, чтобы сдвинуть; «Точки» (A) правят опорные точки и "
|
||||
"ручки."),
|
||||
TR("Taşımak için resim üzerinde sürükleyin; çapaları ve tutamaçları Noktalar (A) "
|
||||
"düzenler."));
|
||||
|
||||
SS_MSG(stencil_points_hint,
|
||||
EN("Drag its squares (anchors) and circles (handles); Alt+drag a handle to move it "
|
||||
"alone. Delete removes the picked anchor."),
|
||||
JA("四角(点)と丸(ハンドル)をドラッグします。Alt+ドラッグでハンドルを片方だけ動かし、"
|
||||
"Delete で選んだ点を削除します。"),
|
||||
ZH_HANS("拖动方块(锚点)和圆点(手柄);Alt+拖动手柄只移动这一侧。Delete 删除选中的"
|
||||
"锚点。"),
|
||||
ZH_HANT("拖曳方塊(錨點)和圓點(控制把手);Alt+拖曳把手只移動這一側。Delete 刪除選取"
|
||||
"的錨點。"),
|
||||
KO("네모(앵커)와 동그라미(핸들)를 드래그하세요. 핸들을 Alt+드래그하면 그쪽만 옮겨지고, "
|
||||
"Delete는 고른 앵커를 지웁니다."),
|
||||
DE("Quadrate (Anker) und Kreise (Griffe) ziehen; Alt+Ziehen bewegt einen Griff allein. "
|
||||
"Entf löscht den gewählten Anker."),
|
||||
FR("Faites glisser ses carrés (ancres) et cercles (poignées) ; Alt+glisser déplace une "
|
||||
"poignée seule. Suppr retire l'ancre choisie."),
|
||||
ES("Arrastre sus cuadrados (anclas) y círculos (tiradores); Alt+arrastrar mueve un "
|
||||
"tirador solo. Supr quita el ancla elegida."),
|
||||
PT("Arraste seus quadrados (âncoras) e círculos (alças); Alt+arrastar move uma alça "
|
||||
"sozinha. Delete remove a âncora escolhida."),
|
||||
IT("Trascini i quadrati (ancoraggi) e i cerchi (maniglie); Alt+trascina sposta una "
|
||||
"maniglia da sola. Canc toglie l'ancoraggio scelto."),
|
||||
NL("Sleep de vierkantjes (ankers) en rondjes (grepen); Alt+slepen beweegt één greep "
|
||||
"alleen. Delete verwijdert het gekozen anker."),
|
||||
RU("Перетаскивайте квадраты (опорные точки) и кружки (ручки); Alt+перетаскивание "
|
||||
"двигает одну ручку. Delete удаляет выбранную точку."),
|
||||
TR("Karelerini (çapalar) ve dairelerini (tutamaçlar) sürükleyin; Alt+sürükleme bir "
|
||||
"tutamacı tek başına taşır. Delete seçilen çapayı siler."));
|
||||
|
||||
SS_MSG(mask_border_enable,
|
||||
EN("Remove fixed areas of the frame"),
|
||||
JA("画面の決まった位置を取り除く"),
|
||||
@@ -9923,49 +10320,6 @@ SS_MSG(use_ffmpeg_always,
|
||||
RU("Эта сборка всегда использует ffmpeg для видео."),
|
||||
TR("Bu sürüm video için her zaman ffmpeg kullanır."));
|
||||
|
||||
SS_MSG(use_python_masking,
|
||||
EN("Mask with the external Python script"),
|
||||
JA("外部の Python スクリプトでマスクする"),
|
||||
ZH_HANS("用外部 Python 脚本做蒙版"),
|
||||
ZH_HANT("用外部 Python 指令稿做遮罩"),
|
||||
KO("외부 Python 스크립트로 마스킹"),
|
||||
DE("Mit dem externen Python-Skript maskieren"),
|
||||
FR("Masquer avec le script Python externe"),
|
||||
ES("Enmascarar con el script externo de Python"),
|
||||
PT("Mascarar com o script externo em Python"),
|
||||
IT("Mascherare con lo script Python esterno"),
|
||||
NL("Maskeren met het externe Python-script"),
|
||||
RU("Маскировать внешним скриптом Python"),
|
||||
TR("Harici Python betiğiyle maskele"));
|
||||
|
||||
SS_MSG(use_python_masking_help,
|
||||
EN("Use reference/scripts/mask.py through an external Python with "
|
||||
"lang-segment-anything, instead of the built-in segmentation."),
|
||||
JA("内蔵のセグメンテーションの代わりに、lang-segment-anything を入れた"
|
||||
"外部の Python で reference/scripts/mask.py を使います。"),
|
||||
ZH_HANS("不用内置分割,而是通过装有 lang-segment-anything 的外部 Python "
|
||||
"运行 reference/scripts/mask.py。"),
|
||||
ZH_HANT("不用內建分割,而是透過裝有 lang-segment-anything 的外部 Python "
|
||||
"執行 reference/scripts/mask.py。"),
|
||||
KO("내장 분할 대신, lang-segment-anything이 설치된 외부 Python으로 "
|
||||
"reference/scripts/mask.py를 실행합니다."),
|
||||
DE("reference/scripts/mask.py über ein externes Python mit "
|
||||
"lang-segment-anything benutzen statt der eingebauten Segmentierung."),
|
||||
FR("Utiliser reference/scripts/mask.py via un Python externe doté de "
|
||||
"lang-segment-anything, au lieu de la segmentation intégrée."),
|
||||
ES("Usar reference/scripts/mask.py mediante un Python externo con "
|
||||
"lang-segment-anything, en lugar de la segmentación integrada."),
|
||||
PT("Usar reference/scripts/mask.py por meio de um Python externo com "
|
||||
"lang-segment-anything, em vez da segmentação integrada."),
|
||||
IT("Usare reference/scripts/mask.py tramite un Python esterno con "
|
||||
"lang-segment-anything, invece della segmentazione integrata."),
|
||||
NL("reference/scripts/mask.py gebruiken via een externe Python met "
|
||||
"lang-segment-anything, in plaats van de ingebouwde segmentatie."),
|
||||
RU("Использовать reference/scripts/mask.py через внешний Python с "
|
||||
"lang-segment-anything вместо встроенной сегментации."),
|
||||
TR("Yerleşik bölütleme yerine, lang-segment-anything kurulu harici bir "
|
||||
"Python üzerinden reference/scripts/mask.py kullanır."));
|
||||
|
||||
// ===========================================================================
|
||||
// Advanced: external COLMAP
|
||||
//
|
||||
@@ -11296,36 +11650,6 @@ SS_MSG(ffmpeg_executable_help_always,
|
||||
RU("Используется для извлечения кадров из видео."),
|
||||
TR("Videodan kare çıkarmak için kullanılır."));
|
||||
|
||||
SS_MSG(python_executable,
|
||||
EN("python executable"),
|
||||
JA("python の実行ファイル"),
|
||||
ZH_HANS("python 可执行文件"),
|
||||
ZH_HANT("python 執行檔"),
|
||||
KO("python 실행 파일"),
|
||||
DE("python-Programmdatei"),
|
||||
FR("exécutable python"),
|
||||
ES("ejecutable de python"),
|
||||
PT("executável do python"),
|
||||
IT("eseguibile python"),
|
||||
NL("python-programma"),
|
||||
RU("исполняемый файл python"),
|
||||
TR("python çalıştırılabiliri"));
|
||||
|
||||
SS_MSG(python_executable_help,
|
||||
EN("Only used by the external masking script."),
|
||||
JA("外部のマスキングスクリプトだけが使います。"),
|
||||
ZH_HANS("只有外部的蒙版脚本会用到。"),
|
||||
ZH_HANT("只有外部的遮罩指令稿會用到。"),
|
||||
KO("외부 마스킹 스크립트만 사용합니다."),
|
||||
DE("Wird nur vom externen Maskierungsskript benutzt."),
|
||||
FR("Utilisé uniquement par le script de masquage externe."),
|
||||
ES("Solo lo usa el script externo de enmascarado."),
|
||||
PT("Usado apenas pelo script externo de mascaramento."),
|
||||
IT("Usato solo dallo script esterno di mascheratura."),
|
||||
NL("Wordt alleen gebruikt door het externe maskeerscript."),
|
||||
RU("Используется только внешним скриптом маскирования."),
|
||||
TR("Yalnızca harici maskeleme betiği kullanır."));
|
||||
|
||||
// ===========================================================================
|
||||
// The segmentation checkpoints (src/app/gui/ModelCache.cpp)
|
||||
//
|
||||
|
||||
@@ -213,21 +213,6 @@ SS_MSG(masks_combined_in_place,
|
||||
RU("Маски в {0} заменяются их сочетанием с маской кадра"),
|
||||
TR("{0} içindeki maskeler, kare maskesiyle birleştirilmiş hâlleriyle değiştirilir"));
|
||||
|
||||
SS_MSG(stage_masks_python,
|
||||
EN("Generating masks (external Python)"),
|
||||
JA("マスクを作成しています(外部のPython)"),
|
||||
ZH_HANS("正在生成蒙版(外部 Python)"),
|
||||
ZH_HANT("正在產生遮罩(外部 Python)"),
|
||||
KO("마스크를 만드는 중(외부 Python)"),
|
||||
DE("Masken werden erzeugt (externes Python)"),
|
||||
FR("Génération des masques (Python externe)"),
|
||||
ES("Generando las máscaras (Python externo)"),
|
||||
PT("Gerando as máscaras (Python externo)"),
|
||||
IT("Generazione delle maschere (Python esterno)"),
|
||||
NL("Maskers maken (extern Python)"),
|
||||
RU("Создание масок (внешний Python)"),
|
||||
TR("Maskeler oluşturuluyor (harici Python)"));
|
||||
|
||||
SS_MSG(stage_finding_features,
|
||||
EN("Finding features"),
|
||||
JA("特徴点を探しています"),
|
||||
@@ -3292,44 +3277,6 @@ SS_MSG(err_copy_failed,
|
||||
RU("Не удалось поместить {0} в {1} ({2})."),
|
||||
TR("{0}, {1} içine konulamadı ({2})."));
|
||||
|
||||
SS_MSG(err_clicks_need_builtin,
|
||||
EN("Clicked objects need the built-in segmentation; the external Python "
|
||||
"masker only understands text prompts. Turn off \"external masking\", or "
|
||||
"describe the object in words."),
|
||||
JA("クリックで選んだ対象には内蔵のセグメンテーションが必要です。外部の Python "
|
||||
"マスカーはテキストのプロンプトしか解釈できません。「外部マスク」をオフに"
|
||||
"するか、対象を言葉で説明してください。"),
|
||||
ZH_HANS("点击选中的目标需要内置分割;外部的 Python 遮罩器只认文本提示。"
|
||||
"请关闭“外部遮罩”,或用文字描述目标。"),
|
||||
ZH_HANT("點選選中的目標需要內建分割;外部的 Python 遮罩器只認文字提示。"
|
||||
"請關閉「外部遮罩」,或用文字描述目標。"),
|
||||
KO("클릭으로 고른 대상에는 내장 분할이 필요합니다. 외부 Python 마스커는 텍스트 "
|
||||
"프롬프트만 이해합니다. \"외부 마스킹\"을 끄거나 대상을 말로 설명하세요."),
|
||||
DE("Angeklickte Objekte brauchen die eingebaute Segmentierung; der externe "
|
||||
"Python-Masker versteht nur Textprompts. Schalten Sie \"externe "
|
||||
"Maskierung\" ab, oder beschreiben Sie das Objekt in Worten."),
|
||||
FR("Les objets cliqués ont besoin de la segmentation intégrée ; le masqueur "
|
||||
"Python externe ne comprend que des consignes textuelles. Désactivez le "
|
||||
"« masquage externe », ou décrivez l'objet avec des mots."),
|
||||
ES("Los objetos señalados con clic necesitan la segmentación integrada; el "
|
||||
"enmascarador externo de Python solo entiende indicaciones de texto. "
|
||||
"Desactiva el «enmascarado externo», o describe el objeto con palabras."),
|
||||
PT("Objetos clicados precisam da segmentação embutida; o mascarador externo "
|
||||
"em Python só entende comandos de texto. Desligue a \"máscara externa\", "
|
||||
"ou descreva o objeto em palavras."),
|
||||
IT("Gli oggetti cliccati richiedono la segmentazione integrata; il "
|
||||
"mascheratore Python esterno capisce solo prompt testuali. Disattivi la "
|
||||
"\"mascheratura esterna\", oppure descriva l'oggetto a parole."),
|
||||
NL("Aangeklikte objecten hebben de ingebouwde segmentatie nodig; de externe "
|
||||
"Python-masker begrijpt alleen tekstprompts. Zet \"extern maskeren\" uit, "
|
||||
"of beschrijf het object in woorden."),
|
||||
RU("Объекты, выбранные щелчком, требуют встроенной сегментации; внешний "
|
||||
"маскировщик на Python понимает только текстовые запросы. Отключите "
|
||||
"«внешнее маскирование» или опишите объект словами."),
|
||||
TR("Tıklanan nesneler yerleşik bölütlemeyi gerektirir; harici Python "
|
||||
"maskeleyici yalnızca metin istemlerini anlar. \"Harici maskeleme\"yi "
|
||||
"kapatın ya da nesneyi sözle anlatın."));
|
||||
|
||||
SS_MSG(err_no_images_to_mask,
|
||||
EN("There are no images to mask."),
|
||||
JA("マスクする画像がありません。"),
|
||||
@@ -3360,76 +3307,6 @@ SS_MSG(err_masking_failed_on,
|
||||
RU("Не удалось замаскировать {0}: {1}"),
|
||||
TR("{0} maskelenemedi: {1}"));
|
||||
|
||||
SS_MSG(err_python_missing,
|
||||
EN("Python was not found ('{0}'). External masking needs Python with the "
|
||||
"lang-segment-anything package. Set the Python path under Tool "
|
||||
"locations, or use the built-in segmentation."),
|
||||
JA("Python が見つかりません('{0}')。外部マスクには lang-segment-anything "
|
||||
"パッケージを入れた Python が必要です。「ツールの場所」で Python のパスを"
|
||||
"設定するか、内蔵のセグメンテーションを使ってください。"),
|
||||
ZH_HANS("找不到 Python('{0}')。外部遮罩需要装有 lang-segment-anything 包的 "
|
||||
"Python。请在“工具位置”中设置 Python 路径,或改用内置分割。"),
|
||||
ZH_HANT("找不到 Python('{0}')。外部遮罩需要裝有 lang-segment-anything 套件的 "
|
||||
"Python。請在「工具位置」中設定 Python 路徑,或改用內建分割。"),
|
||||
KO("Python 을 찾지 못했습니다('{0}'). 외부 마스킹에는 lang-segment-anything "
|
||||
"패키지가 있는 Python 이 필요합니다. \"도구 위치\"에서 Python 경로를 "
|
||||
"지정하거나 내장 분할을 쓰세요."),
|
||||
DE("Python wurde nicht gefunden ('{0}'). Externe Maskierung braucht Python "
|
||||
"mit dem Paket lang-segment-anything. Tragen Sie den Python-Pfad unter "
|
||||
"Werkzeugpfade ein, oder nutzen Sie die eingebaute Segmentierung."),
|
||||
FR("Python est introuvable ('{0}'). Le masquage externe a besoin de Python "
|
||||
"avec le paquet lang-segment-anything. Indiquez le chemin de Python sous "
|
||||
"Emplacements des outils, ou utilisez la segmentation intégrée."),
|
||||
ES("No se encontró Python ('{0}'). El enmascarado externo necesita Python "
|
||||
"con el paquete lang-segment-anything. Indica la ruta de Python en "
|
||||
"Ubicaciones de herramientas, o usa la segmentación integrada."),
|
||||
PT("O Python não foi encontrado ('{0}'). A máscara externa precisa de Python "
|
||||
"com o pacote lang-segment-anything. Informe o caminho do Python em "
|
||||
"Locais das ferramentas, ou use a segmentação embutida."),
|
||||
IT("Python non è stato trovato ('{0}'). La mascheratura esterna richiede "
|
||||
"Python con il pacchetto lang-segment-anything. Indichi il percorso di "
|
||||
"Python in Posizioni degli strumenti, oppure usi la segmentazione "
|
||||
"integrata."),
|
||||
NL("Python is niet gevonden ('{0}'). Extern maskeren heeft Python met het "
|
||||
"pakket lang-segment-anything nodig. Geef het Python-pad op onder "
|
||||
"Gereedschapslocaties, of gebruik de ingebouwde segmentatie."),
|
||||
RU("Python не найден ('{0}'). Внешнему маскированию нужен Python с пакетом "
|
||||
"lang-segment-anything. Укажите путь к Python в «Расположении "
|
||||
"инструментов» или используйте встроенную сегментацию."),
|
||||
TR("Python bulunamadı ('{0}'). Harici maskeleme, lang-segment-anything "
|
||||
"paketinin kurulu olduğu bir Python ister. Python yolunu Araç konumları "
|
||||
"altında belirtin ya da yerleşik bölütlemeyi kullanın."));
|
||||
|
||||
SS_MSG(err_cannot_write,
|
||||
EN("Cannot write {0}."),
|
||||
JA("{0} を書き出せません。"),
|
||||
ZH_HANS("无法写入 {0}。"),
|
||||
ZH_HANT("無法寫入 {0}。"),
|
||||
KO("{0} 을(를) 쓸 수 없습니다."),
|
||||
DE("{0} kann nicht geschrieben werden."),
|
||||
FR("Impossible d'écrire {0}."),
|
||||
ES("No se puede escribir {0}."),
|
||||
PT("Não é possível escrever {0}."),
|
||||
IT("Non è possibile scrivere {0}."),
|
||||
NL("{0} kan niet geschreven worden."),
|
||||
RU("Не удаётся записать {0}."),
|
||||
TR("{0} yazılamıyor."));
|
||||
|
||||
SS_MSG(err_mask_generation_failed,
|
||||
EN("Mask generation failed."),
|
||||
JA("マスクの生成に失敗しました。"),
|
||||
ZH_HANS("生成掩码失败。"),
|
||||
ZH_HANT("產生遮罩失敗。"),
|
||||
KO("마스크 생성에 실패했습니다."),
|
||||
DE("Die Maskenerzeugung ist fehlgeschlagen."),
|
||||
FR("La génération des masques a échoué."),
|
||||
ES("Falló la generación de máscaras."),
|
||||
PT("A geração de máscaras falhou."),
|
||||
IT("La generazione delle maschere non è riuscita."),
|
||||
NL("Het maken van de maskers is mislukt."),
|
||||
RU("Не удалось создать маски."),
|
||||
TR("Maske üretimi başarısız oldu."));
|
||||
|
||||
SS_MSG(err_mask_model_not_downloaded,
|
||||
EN("The masking model has not been downloaded yet. Get it under the masking "
|
||||
"options -- it is a one-time download -- and start the run again."),
|
||||
@@ -3495,101 +3372,6 @@ SS_MSG(err_geometry_model_not_downloaded,
|
||||
TR("Geometri modeli henüz indirilmedi. Derinlik ve normal seçeneklerinden "
|
||||
"getirin -- bir kez indirilir -- ve yeniden deneyin."));
|
||||
|
||||
SS_MSG(err_mask_missing_packages,
|
||||
EN("Mask generation failed: the Python packages are missing. Install "
|
||||
"lang-segment-anything (pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything, which needs CUDA PyTorch)."),
|
||||
JA("マスクの生成に失敗しました。Python パッケージが足りません。"
|
||||
"lang-segment-anything を入れてください(pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything。CUDA 版 PyTorch が必要です)。"),
|
||||
ZH_HANS("生成掩码失败:缺少 Python 包。请安装 lang-segment-anything"
|
||||
"(pip install git+https://github.com/luca-medeiros/lang-segment-anything,"
|
||||
"需要 CUDA 版 PyTorch)。"),
|
||||
ZH_HANT("產生遮罩失敗:缺少 Python 套件。請安裝 lang-segment-anything"
|
||||
"(pip install git+https://github.com/luca-medeiros/lang-segment-anything,"
|
||||
"需要 CUDA 版 PyTorch)。"),
|
||||
KO("마스크 생성에 실패했습니다: Python 패키지가 없습니다. "
|
||||
"lang-segment-anything 을 설치하세요(pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything, CUDA PyTorch 필요)."),
|
||||
DE("Die Maskenerzeugung ist fehlgeschlagen: die Python-Pakete fehlen. "
|
||||
"Installieren Sie lang-segment-anything (pip install git+https://"
|
||||
"github.com/luca-medeiros/lang-segment-anything, braucht CUDA-PyTorch)."),
|
||||
FR("La génération des masques a échoué : les paquets Python manquent. "
|
||||
"Installez lang-segment-anything (pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything, qui a besoin de PyTorch CUDA)."),
|
||||
ES("Falló la generación de máscaras: faltan los paquetes de Python. Instala "
|
||||
"lang-segment-anything (pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything, que necesita PyTorch con CUDA)."),
|
||||
PT("A geração de máscaras falhou: faltam os pacotes de Python. Instale o "
|
||||
"lang-segment-anything (pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything, que precisa de PyTorch com CUDA)."),
|
||||
IT("La generazione delle maschere non è riuscita: mancano i pacchetti "
|
||||
"Python. Installi lang-segment-anything (pip install git+https://"
|
||||
"github.com/luca-medeiros/lang-segment-anything, richiede PyTorch CUDA)."),
|
||||
NL("Het maken van de maskers is mislukt: de Python-pakketten ontbreken. "
|
||||
"Installeer lang-segment-anything (pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything, met CUDA-PyTorch)."),
|
||||
RU("Не удалось создать маски: отсутствуют пакеты Python. Установите "
|
||||
"lang-segment-anything (pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything, нужен PyTorch с CUDA)."),
|
||||
TR("Maske üretimi başarısız oldu: Python paketleri eksik. "
|
||||
"lang-segment-anything kurun (pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything, CUDA'lı PyTorch ister)."));
|
||||
|
||||
SS_MSG(err_mask_missing_packages_sam3,
|
||||
EN("Mask generation failed: the Python packages are missing. Install "
|
||||
"lang-segment-anything (pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything, which needs CUDA PyTorch), or for "
|
||||
"SAM 3: https://github.com/facebookresearch/sam3"),
|
||||
JA("マスクの生成に失敗しました。Python パッケージが足りません。"
|
||||
"lang-segment-anything を入れてください(pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything。CUDA 版 PyTorch が必要です)。"
|
||||
"SAM 3 の場合は https://github.com/facebookresearch/sam3 を参照。"),
|
||||
ZH_HANS("生成掩码失败:缺少 Python 包。请安装 lang-segment-anything"
|
||||
"(pip install git+https://github.com/luca-medeiros/lang-segment-anything,"
|
||||
"需要 CUDA 版 PyTorch);若用 SAM 3,见 "
|
||||
"https://github.com/facebookresearch/sam3"),
|
||||
ZH_HANT("產生遮罩失敗:缺少 Python 套件。請安裝 lang-segment-anything"
|
||||
"(pip install git+https://github.com/luca-medeiros/lang-segment-anything,"
|
||||
"需要 CUDA 版 PyTorch);若用 SAM 3,見 "
|
||||
"https://github.com/facebookresearch/sam3"),
|
||||
KO("마스크 생성에 실패했습니다: Python 패키지가 없습니다. "
|
||||
"lang-segment-anything 을 설치하세요(pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything, CUDA PyTorch 필요). SAM 3 은 "
|
||||
"https://github.com/facebookresearch/sam3 을 보세요."),
|
||||
DE("Die Maskenerzeugung ist fehlgeschlagen: die Python-Pakete fehlen. "
|
||||
"Installieren Sie lang-segment-anything (pip install git+https://"
|
||||
"github.com/luca-medeiros/lang-segment-anything, braucht CUDA-PyTorch), "
|
||||
"oder für SAM 3: https://github.com/facebookresearch/sam3"),
|
||||
FR("La génération des masques a échoué : les paquets Python manquent. "
|
||||
"Installez lang-segment-anything (pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything, qui a besoin de PyTorch CUDA), ou "
|
||||
"pour SAM 3 : https://github.com/facebookresearch/sam3"),
|
||||
ES("Falló la generación de máscaras: faltan los paquetes de Python. Instala "
|
||||
"lang-segment-anything (pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything, que necesita PyTorch con CUDA), o "
|
||||
"para SAM 3: https://github.com/facebookresearch/sam3"),
|
||||
PT("A geração de máscaras falhou: faltam os pacotes de Python. Instale o "
|
||||
"lang-segment-anything (pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything, que precisa de PyTorch com CUDA), "
|
||||
"ou para o SAM 3: https://github.com/facebookresearch/sam3"),
|
||||
IT("La generazione delle maschere non è riuscita: mancano i pacchetti "
|
||||
"Python. Installi lang-segment-anything (pip install git+https://"
|
||||
"github.com/luca-medeiros/lang-segment-anything, richiede PyTorch CUDA), "
|
||||
"oppure per SAM 3: https://github.com/facebookresearch/sam3"),
|
||||
NL("Het maken van de maskers is mislukt: de Python-pakketten ontbreken. "
|
||||
"Installeer lang-segment-anything (pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything, met CUDA-PyTorch), of voor SAM 3: "
|
||||
"https://github.com/facebookresearch/sam3"),
|
||||
RU("Не удалось создать маски: отсутствуют пакеты Python. Установите "
|
||||
"lang-segment-anything (pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything, нужен PyTorch с CUDA), либо для "
|
||||
"SAM 3: https://github.com/facebookresearch/sam3"),
|
||||
TR("Maske üretimi başarısız oldu: Python paketleri eksik. "
|
||||
"lang-segment-anything kurun (pip install git+https://github.com/"
|
||||
"luca-medeiros/lang-segment-anything, CUDA'lı PyTorch ister) ya da SAM 3 "
|
||||
"için: https://github.com/facebookresearch/sam3"));
|
||||
|
||||
SS_MSG(err_no_builtin_segmentation,
|
||||
EN("This build has no built-in segmentation (-DSS_BUILD_SAM=OFF)."),
|
||||
JA("このビルドには内蔵のセグメンテーションがありません(-DSS_BUILD_SAM=OFF)。"),
|
||||
@@ -3959,36 +3741,6 @@ SS_MSG(err_geometry_failed,
|
||||
TR("derinlik ve normal kestirimi başarısız oldu (günlüğe bakın). Yeniden "
|
||||
"kurmanın kendisi tamamlandı ve olduğu gibi eğitilebilir."));
|
||||
|
||||
SS_MSG(err_subject_needs_builtin,
|
||||
EN("BiRefNet runs only in the built-in masker; the external Python masker cannot "
|
||||
"load it. Turn off \"external masking\", or pick a SAM model."),
|
||||
JA("BiRefNet は内蔵のマスカーでしか動きません。外部の Python マスカーでは読み込め"
|
||||
"ません。「外部マスク」をオフにするか、SAM のモデルを選んでください。"),
|
||||
ZH_HANS("BiRefNet 只能在内置的蒙版程序中运行,外部 Python 蒙版程序无法加载它。"
|
||||
"请关闭“外部蒙版”,或选择一个 SAM 模型。"),
|
||||
ZH_HANT("BiRefNet 只能在內建的遮罩程式中執行,外部 Python 遮罩程式無法載入它。"
|
||||
"請關閉「外部遮罩」,或選擇一個 SAM 模型。"),
|
||||
KO("BiRefNet 은 내장 마스커에서만 돌아갑니다. 외부 Python 마스커는 이것을 불러오지 "
|
||||
"못합니다. [외부 마스크]를 끄거나 SAM 모델을 고르세요."),
|
||||
DE("BiRefNet läuft nur im eingebauten Maskierer; der externe Python-Maskierer kann "
|
||||
"es nicht laden. „Externe Maskierung“ ausschalten oder ein SAM-Modell wählen."),
|
||||
FR("BiRefNet ne fonctionne que dans le masqueur intégré ; le masqueur Python externe "
|
||||
"ne sait pas le charger. Désactivez « masquage externe » ou choisissez un modèle "
|
||||
"SAM."),
|
||||
ES("BiRefNet solo funciona en el enmascarador integrado; el enmascarador externo de "
|
||||
"Python no puede cargarlo. Desactive «enmascarado externo» o elija un modelo SAM."),
|
||||
PT("O BiRefNet só roda no mascarador integrado; o mascarador Python externo não "
|
||||
"consegue carregá-lo. Desative “mascaramento externo” ou escolha um modelo SAM."),
|
||||
IT("BiRefNet funziona solo nel mascheratore integrato; quello Python esterno non "
|
||||
"riesce a caricarlo. Disattivate «mascheratura esterna» o scegliete un modello "
|
||||
"SAM."),
|
||||
NL("BiRefNet draait alleen in de ingebouwde maskeerder; de externe Python-maskeerder "
|
||||
"kan het niet laden. Zet \"externe maskering\" uit of kies een SAM-model."),
|
||||
RU("BiRefNet работает только во встроенном маскировщике; внешний на Python не может "
|
||||
"его загрузить. Отключите «внешнее маскирование» или выберите модель SAM."),
|
||||
TR("BiRefNet yalnızca yerleşik maskeleyicide çalışır; dış Python maskeleyici onu "
|
||||
"yükleyemez. \"Dış maskeleme\"yi kapatın ya da bir SAM modeli seçin."));
|
||||
|
||||
} // namespace log
|
||||
} // namespace msg
|
||||
} // namespace i18n
|
||||
|
||||
@@ -869,6 +869,111 @@ SS_MSG(path_anchors,
|
||||
RU("Точек контура: {0}"),
|
||||
TR("Yol çapaları: {0}"));
|
||||
|
||||
// ===========================================================================
|
||||
// The pen (Bezier curves), with the names vector editors give the tool
|
||||
// ===========================================================================
|
||||
|
||||
SS_MSG(tool_pen,
|
||||
EN("Pen"), JA("ペン"), ZH_HANS("钢笔"), ZH_HANT("鋼筆"),
|
||||
KO("펜"), DE("Zeichenstift"), FR("Plume"),
|
||||
ES("Pluma"), PT("Caneta"), IT("Penna"),
|
||||
NL("Pen"), RU("Перо"), TR("Kalem"));
|
||||
|
||||
SS_MSG(hint_pen,
|
||||
EN("Click for a corner, drag for a curve. Shift keeps 45-degree steps; Alt while "
|
||||
"dragging moves one handle alone; Space while dragging moves the anchor. Click the "
|
||||
"last anchor to straighten the next segment; Ctrl+drag moves a point or handle "
|
||||
"already placed. Hold Ctrl (keep) or Shift+Ctrl (clear) for the first anchor. Click "
|
||||
"the first anchor, Enter or right-click closes it and paints its inside. Ctrl+Z or "
|
||||
"Backspace takes an anchor back; Esc cancels."),
|
||||
JA("クリックで角、ドラッグで曲線の点を置きます。Shift で 45 度刻み、ドラッグ中の Alt で"
|
||||
"片方のハンドルだけを、ドラッグ中の Space で点そのものを動かします。最後の点を"
|
||||
"クリックすると次の線分がまっすぐになり、Ctrl+ドラッグで置いた点やハンドルを"
|
||||
"動かせます。最初の点を置くときに Ctrl で保持、Shift+Ctrl でクリアになります。"
|
||||
"最初の点のクリック、Enter か右クリックで閉じて内側を塗ります。Ctrl+Z か "
|
||||
"Backspace で点を戻し、Esc で中止します。"),
|
||||
ZH_HANS("点击放下角点,拖动放下曲线点。Shift 以 45 度为步长;拖动时按 Alt 只移动一侧"
|
||||
"手柄;拖动时按 Space 移动锚点本身。点击最后一个锚点可让下一段变直;Ctrl+拖动"
|
||||
"可移动已放下的点或手柄。放下第一个锚点时按住 Ctrl 为保留,Shift+Ctrl 为清除。"
|
||||
"点击第一个锚点、按 Enter 或右键闭合并涂抹其内部。Ctrl+Z 或 Backspace 撤回"
|
||||
"一个锚点,Esc 取消。"),
|
||||
ZH_HANT("點擊放下角點,拖曳放下曲線點。Shift 以 45 度為步長;拖曳時按 Alt 只移動一側"
|
||||
"控制把手;拖曳時按 Space 移動錨點本身。點擊最後一個錨點可讓下一段變直;"
|
||||
"Ctrl+拖曳可移動已放下的點或控制把手。放下第一個錨點時按住 Ctrl 為保留,"
|
||||
"Shift+Ctrl 為清除。點擊第一個錨點、按 Enter 或右鍵閉合並塗抹其內部。Ctrl+Z "
|
||||
"或 Backspace 收回一個錨點,Esc 取消。"),
|
||||
KO("클릭하면 모서리, 드래그하면 곡선 앵커를 놓습니다. Shift는 45도 단위로 맞추고, "
|
||||
"드래그 중 Alt는 핸들 한쪽만, 드래그 중 Space는 앵커 자체를 옮깁니다. 마지막 앵커를 "
|
||||
"클릭하면 다음 선분이 곧게 이어지고, Ctrl+드래그로 이미 놓은 점이나 핸들을 옮깁니다. "
|
||||
"첫 앵커를 놓을 때 Ctrl은 유지, Shift+Ctrl은 지우기입니다. 첫 앵커 클릭, Enter 또는 "
|
||||
"오른쪽 클릭으로 닫고 안쪽을 칠합니다. Ctrl+Z나 Backspace는 앵커를 되돌리고 Esc는 "
|
||||
"취소합니다."),
|
||||
DE("Klick setzt eine Ecke, Ziehen einen Kurvenpunkt. Umschalt rastet in 45-Grad-"
|
||||
"Schritten ein; Alt beim Ziehen bewegt nur einen Griff, Leertaste beim Ziehen den "
|
||||
"Anker selbst. Ein Klick auf den letzten Anker macht den nächsten Abschnitt gerade; "
|
||||
"Strg+Ziehen verschiebt einen schon gesetzten Punkt oder Griff. Strg (behalten) oder "
|
||||
"Umschalt+Strg (löschen) beim ersten Anker halten. Erster Anker, Eingabe oder "
|
||||
"Rechtsklick schließt ihn und malt sein Inneres. Strg+Z oder Rücktaste nimmt einen "
|
||||
"Anker zurück, Esc bricht ab."),
|
||||
FR("Un clic pose un angle, un glissé un point de courbe. Maj force des pas de 45 degrés ; "
|
||||
"Alt pendant le glissé déplace une seule poignée, Espace déplace l'ancre elle-même. "
|
||||
"Un clic sur la dernière ancre rend le segment suivant droit ; Ctrl+glisser déplace "
|
||||
"un point ou une poignée déjà posé. Maintenez Ctrl (conserver) ou Maj+Ctrl (effacer) "
|
||||
"pour la première ancre. La première ancre, Entrée ou un clic droit le ferme et peint "
|
||||
"son intérieur. Ctrl+Z ou Retour arrière retire une ancre, Échap annule."),
|
||||
ES("Un clic pone una esquina y un arrastre, un punto de curva. Mayús fija pasos de 45 "
|
||||
"grados; Alt al arrastrar mueve un solo tirador y Espacio al arrastrar mueve el "
|
||||
"ancla. Un clic en la última ancla deja recto el siguiente segmento; Ctrl+arrastrar "
|
||||
"mueve un punto o tirador ya puesto. Mantenga Ctrl (conservar) o Mayús+Ctrl (borrar) "
|
||||
"para la primera ancla. La primera ancla, Intro o clic derecho lo cierra y pinta su "
|
||||
"interior. Ctrl+Z o Retroceso quita un ancla; Esc cancela."),
|
||||
PT("Um clique põe um canto e um arraste, um ponto de curva. Shift fixa passos de 45 "
|
||||
"graus; Alt ao arrastar move só uma alça e Espaço ao arrastar move a própria âncora. "
|
||||
"Um clique na última âncora deixa reto o segmento seguinte; Ctrl+arrastar move um "
|
||||
"ponto ou alça já posto. Segure Ctrl (manter) ou Shift+Ctrl (limpar) na primeira "
|
||||
"âncora. A primeira âncora, Enter ou clique direito o fecha e pinta seu interior. "
|
||||
"Ctrl+Z ou Backspace tira uma âncora; Esc cancela."),
|
||||
IT("Un clic mette un angolo, un trascinamento un punto di curva. Maiusc blocca a passi "
|
||||
"di 45 gradi; Alt durante il trascinamento muove una sola maniglia, Spazio muove "
|
||||
"l'ancoraggio stesso. Un clic sull'ultimo ancoraggio rende dritto il segmento "
|
||||
"successivo; Ctrl+trascina sposta un punto o una maniglia già messi. Tenga premuto "
|
||||
"Ctrl (mantieni) o Maiusc+Ctrl (cancella) per il primo ancoraggio. Il primo "
|
||||
"ancoraggio, Invio o il clic destro lo chiude e ne dipinge l'interno. Ctrl+Z o "
|
||||
"Backspace toglie un ancoraggio; Esc annulla."),
|
||||
NL("Klik zet een hoek, slepen een kromme. Shift houdt stappen van 45 graden aan; Alt "
|
||||
"tijdens het slepen beweegt één greep, Spatie tijdens het slepen het anker zelf. Klik "
|
||||
"op het laatste anker om het volgende stuk recht te maken; Ctrl+slepen verplaatst een "
|
||||
"al gezet punt of greep. Houd Ctrl (behouden) of Shift+Ctrl (wissen) bij het eerste "
|
||||
"anker. Het eerste anker, Enter of rechtsklik sluit het en schildert de binnenkant. "
|
||||
"Ctrl+Z of Backspace neemt een anker terug; Esc breekt af."),
|
||||
RU("Щелчок ставит угол, перетаскивание — точку кривой. Shift держит шаг 45 градусов; "
|
||||
"Alt при перетаскивании двигает только одну ручку, пробел — саму точку. Щелчок по "
|
||||
"последней точке выпрямляет следующий отрезок; Ctrl+перетаскивание двигает уже "
|
||||
"поставленную точку или ручку. Держите Ctrl (сохранить) или Shift+Ctrl (стереть) для "
|
||||
"первой точки. Первая точка, Enter или правая кнопка замыкают контур и закрашивают "
|
||||
"внутренность. Ctrl+Z или Backspace убирает точку, Esc отменяет."),
|
||||
TR("Tıklama bir köşe, sürükleme bir eğri noktası bırakır. Shift 45 derecelik adımlar "
|
||||
"tutar; sürüklerken Alt tek bir tutamacı, Boşluk çapanın kendisini taşır. Son çapaya "
|
||||
"tıklamak sonraki parçayı düz yapar; Ctrl+sürükleme önceden konmuş bir noktayı ya da "
|
||||
"tutamacı taşır. İlk çapa için Ctrl (tut) veya Shift+Ctrl (temizle) basılı tutun. İlk "
|
||||
"çapa, Enter veya sağ tık onu kapatır ve içini boyar. Ctrl+Z veya Backspace bir "
|
||||
"çapayı geri alır; Esc iptal eder."));
|
||||
|
||||
SS_MSG(pen_anchors,
|
||||
EN("Pen anchors: {0}"),
|
||||
JA("ペンの点: {0}"),
|
||||
ZH_HANS("钢笔锚点:{0}"),
|
||||
ZH_HANT("鋼筆錨點:{0}"),
|
||||
KO("펜 앵커: {0}"),
|
||||
DE("Zeichenstift-Anker: {0}"),
|
||||
FR("Ancres de la plume : {0}"),
|
||||
ES("Anclas de la pluma: {0}"),
|
||||
PT("Âncoras da caneta: {0}"),
|
||||
IT("Ancoraggi della penna: {0}"),
|
||||
NL("Penankers: {0}"),
|
||||
RU("Точек пера: {0}"),
|
||||
TR("Kalem çapaları: {0}"));
|
||||
|
||||
// ===========================================================================
|
||||
// SAM assist
|
||||
// ===========================================================================
|
||||
|
||||
+27
-13
@@ -1402,41 +1402,55 @@ SS_MSG(mh_out,
|
||||
SS_MSG(mh_shape,
|
||||
EN("use these shapes instead of looking for a border; ';' separates them "
|
||||
"and a leading '-' cuts one out again. A path ending in .svg reads the "
|
||||
"shapes the GUI saves"),
|
||||
"shapes the GUI saves; a file saved for one camera brings the rest of its "
|
||||
"set, each for its own camera folder"),
|
||||
JA("枠を探す代わりにこの図形を使います。';' で区切り、先頭の '-' はその"
|
||||
"図形の内側を逆に取り除きます。.svg で終わるパスは GUI が保存した"
|
||||
"図形を読み込みます"),
|
||||
"図形を読み込みます。カメラ 1 台分として保存したファイルは、同じ組の"
|
||||
"ほかのファイルも読み込み、それぞれ自分のカメラのフォルダーに使います"),
|
||||
ZH_HANS("用这些图形,而不是去找边框;用 ';' 分隔,开头的 '-' 表示反过来去掉"
|
||||
"该图形内部。以 .svg 结尾的路径会读取 GUI 保存的图形"),
|
||||
"该图形内部。以 .svg 结尾的路径会读取 GUI 保存的图形;为某一台相机保存的"
|
||||
"文件会连同同组的其他文件一起读取,各自用于自己的相机文件夹"),
|
||||
ZH_HANT("用這些圖形,而不是去找邊框;用 ';' 分隔,開頭的 '-' 表示反過來去掉"
|
||||
"該圖形內部。以 .svg 結尾的路徑會讀取 GUI 儲存的圖形"),
|
||||
"該圖形內部。以 .svg 結尾的路徑會讀取 GUI 儲存的圖形;為某一台相機儲存的"
|
||||
"檔案會連同同組的其他檔案一起讀取,各自用於自己的相機資料夾"),
|
||||
KO("테두리를 찾는 대신 이 도형을 씁니다. ';' 로 나누고, 앞의 '-' 는 그 "
|
||||
"도형 안쪽을 도로 없앱니다. .svg 로 끝나는 경로는 GUI 가 저장한 "
|
||||
"도형을 읽습니다"),
|
||||
"도형을 읽습니다. 카메라 한 대용으로 저장한 파일은 같은 묶음의 나머지 "
|
||||
"파일도 함께 읽어 각자 자기 카메라 폴더에 씁니다"),
|
||||
DE("diese Formen benutzen statt einen Rand zu suchen; ';' trennt sie, ein "
|
||||
"vorangestelltes '-' schneidet eine wieder heraus. Ein Pfad auf .svg "
|
||||
"liest die Formen, die die GUI speichert"),
|
||||
"liest die Formen, die die GUI speichert; eine für eine Kamera gespeicherte "
|
||||
"Datei bringt den Rest ihres Satzes mit, jede für ihren eigenen Kameraordner"),
|
||||
FR("utiliser ces formes au lieu de chercher un bord ; ';' les sépare, un "
|
||||
"'-' en tête en retire une au contraire. Un chemin en .svg lit les "
|
||||
"formes que l'interface enregistre"),
|
||||
"formes que l'interface enregistre ; un fichier enregistré pour une caméra "
|
||||
"amène le reste de son ensemble, chacun pour son propre dossier de caméra"),
|
||||
ES("usar estas formas en vez de buscar un borde; ';' las separa y un '-' "
|
||||
"delante recorta una en lugar de conservarla. Una ruta terminada en .svg "
|
||||
"lee las formas que guarda la interfaz"),
|
||||
"lee las formas que guarda la interfaz; un archivo guardado para una cámara "
|
||||
"trae el resto de su juego, cada uno para su propia carpeta de cámara"),
|
||||
PT("usar estas formas em vez de procurar uma borda; ';' as separa e um '-' "
|
||||
"à frente recorta uma em vez de mantê-la. Um caminho terminado em .svg "
|
||||
"lê as formas que a interface salva"),
|
||||
"lê as formas que a interface salva; um arquivo salvo para uma câmera "
|
||||
"traz o resto do seu conjunto, cada um para a sua pasta de câmera"),
|
||||
IT("usare queste forme invece di cercare un bordo; ';' le separa e un '-' "
|
||||
"davanti ne ritaglia una invece di tenerla. Un percorso che termina in "
|
||||
".svg legge le forme salvate dall'interfaccia"),
|
||||
".svg legge le forme salvate dall'interfaccia; un file salvato per una "
|
||||
"fotocamera porta con sé il resto del suo insieme, ognuno per la propria "
|
||||
"cartella di fotocamera"),
|
||||
NL("deze vormen gebruiken in plaats van een rand te zoeken; ';' scheidt "
|
||||
"ze, een '-' ervoor snijdt er juist een weg. Een pad op .svg leest de "
|
||||
"vormen die de GUI opslaat"),
|
||||
"vormen die de GUI opslaat; een bestand dat voor één camera is opgeslagen "
|
||||
"brengt de rest van zijn set mee, elk voor zijn eigen cameramap"),
|
||||
RU("взять эти фигуры вместо поиска края; ';' разделяет их, а '-' в начале "
|
||||
"наоборот вырезает фигуру. Путь, оканчивающийся на .svg, читает фигуры, "
|
||||
"сохранённые в интерфейсе"),
|
||||
"сохранённые в интерфейсе; файл, сохранённый для одной камеры, подтягивает "
|
||||
"остальные файлы своего набора, каждый для своей папки камеры"),
|
||||
TR("kenar aramak yerine bu biçimleri kullan; ';' ayırır, baştaki '-' ise "
|
||||
"biçimin içini tersine keser. .svg ile biten bir yol, arayüzün kaydettiği "
|
||||
"biçimleri okur"));
|
||||
"biçimleri okur; tek bir kamera için kaydedilmiş bir dosya, takımının geri "
|
||||
"kalanını da getirir, her biri kendi kamera klasörü için"));
|
||||
|
||||
SS_MSG(mh_shrink,
|
||||
EN("pull the found boundary inwards, as a fraction of its radius "
|
||||
|
||||
+4
-5
@@ -32,8 +32,8 @@ way in; that tree is now read-only and this is upstream.
|
||||
|
||||
- **Vulkan only**, like `src/sfm/`. There is no CUDA path here and there will
|
||||
not be one; the module is built by default only for `SS_BACKEND=vulkan`
|
||||
(`SS_BUILD_SAM`), and a CUDA build keeps the `reference/scripts/mask.py`
|
||||
subprocess.
|
||||
(`SS_BUILD_SAM`). A build without it has no model-based masking; the
|
||||
fixed-area stencil (`src/app/FrameMask.h`) needs no model and still works.
|
||||
- **Nothing model-agnostic belongs here.** Tensors, ops, the Vulkan runtime and
|
||||
host image I/O are `src/nn/`'s. If you find yourself adding a general kernel
|
||||
under `sam/`, it belongs one directory down — that separation is what lets a
|
||||
@@ -130,9 +130,8 @@ user sees in the preview is what gets written.
|
||||
at all. Both reach the same union, margin and polarity as SAM 3's matches.
|
||||
|
||||
The text half matches `reference/scripts/mask.py`, defaults included, so a dataset masked
|
||||
either way is the same dataset. Clicks have no counterpart there —
|
||||
lang-segment-anything takes words and nothing else — and the GUI says so rather
|
||||
than dropping them.
|
||||
either way is the same dataset. Clicks have no counterpart there:
|
||||
lang-segment-anything takes words and nothing else.
|
||||
|
||||
### What is not a prompt
|
||||
|
||||
|
||||
@@ -104,14 +104,14 @@ if [ "$order" = yes ]; then echo "ok the slideshow branch sits between the can
|
||||
else echo "FAIL the slideshow branch sits between the canvas fill and !_doc"; FAILS=$((FAILS + 1)); fi
|
||||
has 1 'if (!fresh && input) {'
|
||||
has 1 '_slide_fresh = false;'
|
||||
if command grep -qF '_path.in_progress())' src/app/gui/mask/MaskSession.cpp &&
|
||||
if command grep -qF '_pen.in_progress() || _path.in_progress())' src/app/gui/mask/MaskSession.cpp &&
|
||||
command grep -qF 'bool animating() const { return _compare.animating() || _mask_editor.animating(); }' src/app/gui/GuiApp.h; then
|
||||
echo "ok start_slideshow refuses a pen path; GuiApp::animating() asks the editor"
|
||||
else echo "FAIL start_slideshow refuses a pen path; GuiApp::animating() asks the editor"; FAILS=$((FAILS + 1)); fi
|
||||
# Row D, the frame keys and the navigation row stand down for the
|
||||
# worker, a half-drawn shape or pen path, and play; help shows when greyed;
|
||||
# M / Shift+M sit inside the keys' guard; the key list is the slider's tooltip.
|
||||
has 1 'bool MaskSession::shape_open() const { return _tool.in_progress() || _path.in_progress(); }'
|
||||
has 1 'return _tool.in_progress() || _path.in_progress() || _pen.in_progress();'
|
||||
find=$(awk '/if \(ui::Button\(msg::find_first\)\) go_to\(0\);/{print p; exit} {p=$0}' "$F")
|
||||
if [ "$(printf '%s' "$find" | sed 's/^ *//')" = 'ImGui::BeginDisabled(!idle() || _slide_playing || shape_open());' ]; then
|
||||
echo "ok row D's find buttons wait for the worker, a half-drawn shape and play"
|
||||
|
||||
Reference in New Issue
Block a user