diff --git a/AGENTS.md b/AGENTS.md index a3e902b5f..69b365ff1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -85,6 +85,8 @@ src/ ├── engine/ Engine*.cpp/.h — the training engine │ (process-global singleton) ├── data/ DataManager (image cache / prefetch / warp), +│ │ E57Reader (laser scans: points + registered images), +│ │ PointCloudFile (E57 / LAS / PLY points, streamed) │ │ Region.h / LabelField.h / RegionProgram.h (regions │ │ of space with an inside test on host and device, │ │ and the labelled seed field that gives every point @@ -150,6 +152,17 @@ src/ │ ├── FrameExtract.{h,cpp} video -> sharp frames (`spirula sam extract` also │ │ masks them in the same pass; the GUI masks after), │ │ decoded by Vulkan Video or ffmpeg (FrameDecode.h) +│ ├── E57Dataset.{h,cpp} an E57 scan written out as a Nerfstudio dataset +│ │ with no SfM (`spirula e57`) -- docs/datasets.md +│ ├── LidarAlign.{h,cpp} a reconstruction fitted onto a laser scan (Sim3: +│ │ anchors, then point-to-plane ICP), and scans in +│ │ frames of their own placed through it +│ ├── LidarDataset.{h,cpp} the dataset that makes (`spirula lidar`, the +│ │ dataset screen's Align step): one COLMAP model in +│ │ the scan frame, scan seeds with tracks, depth + +│ │ normals -- docs/notes/lidar-alignment.md +│ ├── ScanDepth.{h,cpp} depth, normal and colour maps of a camera, +│ │ rendered from a scan's points │ ├── Pano360.{h,cpp} a 360 camera's own frame layout (the GoPro MAX │ │ .360 EAC packing) and the views a dataset wants │ │ out of it -- one implementation, both decode paths diff --git a/cmake/SsApps.cmake b/cmake/SsApps.cmake index e0ea6d9a9..01f3ff6e3 100644 --- a/cmake/SsApps.cmake +++ b/cmake/SsApps.cmake @@ -99,6 +99,17 @@ list(APPEND SS_TOOL_DEFS SS_TOOL_PARTITION=1) list(APPEND SS_TOOL_SOURCES ${SS_SRC}/app/cli/mesh_main.cpp) list(APPEND SS_TOOL_DEFS SS_TOOL_MESH=1) +# ---- a dataset from an E57 laser scan: host only, both backends ---- +list(APPEND SS_TOOL_SOURCES + ${SS_SRC}/app/cli/e57_main.cpp + ${SS_SRC}/app/cli/lidar_main.cpp + ${SS_SRC}/app/E57Dataset.cpp + ${SS_SRC}/app/LidarAlign.cpp + ${SS_SRC}/app/LidarDataset.cpp + ${SS_SRC}/app/ScanDepth.cpp + ${SS_SRC}/app/DepthPng.cpp) +list(APPEND SS_TOOL_DEFS SS_TOOL_E57=1) + if(SS_BUILD_SFM) # ---- structure from motion ---- # The SfM module carries its own Vulkan context and SPIR-V and shares @@ -365,6 +376,27 @@ add_executable(packed_lens_test ${SS_SRC}/app/Pano360.cpp) ss_configure_app(packed_lens_test) +# A scan written out as a dataset, then read back by the trainer's parser. +add_executable(e57_dataset_test + ${SS_SRC}/app/tests/e57_dataset_test.cpp + ${SS_SRC}/app/E57Dataset.cpp + ${SS_SRC}/app/ScanDepth.cpp + ${SS_SRC}/app/DepthPng.cpp) +ss_configure_app(e57_dataset_test) + +add_executable(scan_depth_test + ${SS_SRC}/app/tests/scan_depth_test.cpp + ${SS_SRC}/app/ScanDepth.cpp) +ss_configure_app(scan_depth_test) + +add_executable(lidar_align_test + ${SS_SRC}/app/tests/lidar_align_test.cpp + ${SS_SRC}/app/LidarAlign.cpp + ${SS_SRC}/app/E57Dataset.cpp + ${SS_SRC}/app/ScanDepth.cpp + ${SS_SRC}/app/DepthPng.cpp) +ss_configure_app(lidar_align_test) + # The stencil shapes, spelling and fill, with no GUI: FrameMask.cpp is compiled # into the CLI too, so this must link without imgui. add_executable(frame_mask_test diff --git a/docs/datasets.md b/docs/datasets.md index 285ec73d0..e47477a5e 100644 --- a/docs/datasets.md +++ b/docs/datasets.md @@ -17,6 +17,9 @@ implementation, shared by the CLI trainer, the GUI and the WASM viewer. The point cloud is optional in every format: a dataset without one, or with an empty one, parses to poses alone and the trainer seeds it at random (below). +An E57 laser scan is not read in place: it is written out once as a Nerfstudio +dataset (below, "E57 laser scans"), which then opens like any other. + Default subdirectory names: `images/`, `masks/`, `depths/`, `normals/`. The COLMAP reconstruction directory is auto-detected over `{sparse/0, colmap/sparse/0, sparse, colmap, .}` unless `recon_dir` is set. @@ -877,6 +880,173 @@ is on ONE scale, because a clip that moves twice as much as its neighbour is exactly why it took the frames off it. A folder of photographs is a row that says it has no motion rather than a gap in the list. +## E57 laser scans + +Two ways in. On the GUI's dataset screen a laser scan (E57, LAS or PLY) is an +input like photos and videos -- **Add LiDAR scan...**, or drop the file: the +reconstruction runs, then is aligned with the scan (rotation, translation and +scale) and written as a COLMAP model whose seed points are the scan's, with +tracks; an E57 that carries photographs can be the only input. +[notes/lidar-alignment.md](notes/lidar-alignment.md) is that path. + +`spirula e57 []` is the other: no reconstruction at all, +the scanner's registered images, poses and point cloud written straight out +as a Nerfstudio dataset. It is what the rest of this section describes, and +the scanner-pose half of it is what the dataset screen falls back to for +photographs its reconstruction cannot place (a tripod's cube faces). + +Tested so far with scans from a Leica BLK360 tripod scanner and an XGRIDS +Lixel handheld scanner on macOS -- the two the camera conventions below were +measured on -- and from a Matterport Pro3 on Windows. + +| file | from the scan | +|---|---| +| `images/` | each usable image's JPEG or PNG, copied byte for byte (PNG preferred when both are there) | +| `masks/` | an image's `imageMask`, when it has one: any non-zero pixel is kept | +| `transforms.json` | per-frame intrinsics and OpenGL camera-to-world, in full double precision | +| `sparse_pc.ply` | the point clouds of every scan, posed into one frame and thinned (or all of them, `--points all`); `double` coordinates, since a geo-referenced scan sits millions of metres from its origin | +| `depths/`, `normals/` | what the laser measured behind every pixel (below); `--no-depth` leaves them out | +| `gauge.txt` | `oriented 1`, `metric 1` (below) | + +`data/E57Reader.h` is the reader, with no libE57Format and no Xerces behind +it. The XML section goes through `data/Xml.h`; the binary sections are read +through the 1020-of-1024-byte pages (the page checksums are not verified), and +the point records through the bit-packed bytestreams of the compressed vectors. +`bitPackCodec` is the only codec the standard defines, and a file naming any +other is refused. A value runs on from one data packet into the next, so each +field's chunks are concatenated rather than decoded packet by packet. Points +flagged invalid (`cartesianInvalidState` / `sphericalInvalidState` non-zero), +and points at exactly the scanner's origin, which writers use for empty cells, +are dropped. Colour is normalised by the scan's `colorLimits`, or failing that +by the field's own range; a scan with no colour is seeded grey from its +intensity. + +### Which images become cameras + +| representation | camera | +|---|---| +| `pinholeRepresentation` | `PINHOLE`, `fx = focalLength / pixelWidth`, `fy = focalLength / pixelHeight`, `cx = principalPointX + 0.5` | +| `sphericalRepresentation` | `EQUIRECTANGULAR`, when it covers the whole sphere (360 x 180 degrees, within 1%) | +| `cylindricalRepresentation` | skipped: the engine has no cylindrical camera | +| `visualReferenceRepresentation` | skipped: it carries no calibration | + +An image without a pose, or without a JPEG or PNG, is skipped too, and the run +says which and why. `--no-pinhole` / `--no-panoramas` (two checkboxes in the +GUI) leave a kind out. + +### The camera conventions, measured + +The standard does not pin down the axes of an image's own frame, so they were +measured on real scans from two devices: a Leica BLK360 tripod scanner (pinhole +cube faces) and an XGRIDS Lixel handheld scanner (panoramas). Each file's +coloured points were projected into its images under all 48 signed axis +permutations, and the permutation whose colours matched the image was kept: + +- **Pinhole**: the image frame is OpenGL's -- x right, y up, looking down -z, + well clear of every other permutation on every image tested. +- **Spherical**: the centre column looks along +x, +z is up, and columns run + clockwise seen from above. The margin is narrower, because the points come + from one merged SLAM cloud, so the ones hidden from a panorama are projected + into it too. +- **Principal point**: pixel centres on integers. Shifting the sample by half a + pixel either way raises the error, so `+0.5` gives the engine's convention. + +`e57_dataset_test` holds the parser and the engine's camera math to these: +through `transforms.json` and back, every camera puts a world point on the +pixel the E57 file has it at, to 0.002 px. + +### The alignment check + +Every conversion repeats that measurement on the file at hand, since another +exporter could read the standard differently. For each kind of image, six are +compared with the scan: the front-most points are rendered into a 320 px map of +each and the Pearson correlation of their colour with the photo's is taken -- +exposure-blind -- under the assumed camera orientation and the 23 other turns +of its axes. Another orientation replaces the assumed one only when it +correlates 0.1 better and at 0.25 or more; the run then says so, and so does +the screen. On both devices' scans the assumed orientation won by a wide +margin, and `e57_dataset_test` feeds it a photo stored with OpenCV's axes and +expects it turned back. A scan without colour cannot be checked, and the run +says that too. + +### Depth and normal maps + +`app/ScanDepth.h` renders each image's depth from a draw of up to 40M points, +bucketed into 2 m cells so a pinhole face projects only the cells in front of +it. A point covers the pixels its share of the surface does -- the cell's +spacing, `edge / sqrt(count)`, at its range, at most 3 px either side -- so a +sparse surface still hides the one behind it; then a pixel much further than +its window's nearest is dropped and the small holes a sparse surface leaves are +filled from four or more neighbours on one surface (a straight edge has three, +so a silhouette does not creep into the sky). Normals are taken from that depth +with the engine's own stencil (`points_to_normal`), in the camera's OpenCV +frame, facing it. + +The maps are 1600 px on the long side, so every face of one camera shares a +size and batches without the trainer resampling across the holes. Depth is +16-bit millimetres, 0 where the scan says nothing (the sky, and anything past +65.5 m); a normal is `127.5 + 127.5 n`, black where there is none -- the two +"no ground truth" sentinels `spirula geometry` writes. Pinhole maps hold depth +along the optical axis and panoramas depth along the ray, and a dataset that +mixes them follows the trainer's own majority vote (`resolve_ray_depth`). The +normals are used by default (`normal_supervision_weight` 0.01); depth needs +`--depth-supervision-weight`, and the loss is scale-and-shift invariant, so +the laser's metres constrain shape rather than distance. `scan_depth_test` +checks a plane, a sparse patch in front of it and a sphere around a panorama. + +On a tripod scan they improved the structure of held-out faces (SSIM) a +little and left PSNR where it was: those faces are limited by how few +viewpoints the stations give, not by the geometry. + +A face has depth only where the scan has the surface: a cropped export, or a +face looking past the scan's reach, leaves the rest at 0. + +### The frame + +E57 is in metres by definition, and scanners level their frame: on both +devices' scans the floor and ceiling are flat in z to the centimetre, where a +frame tilted a few degrees spreads them over many. So `gauge.txt` says +`oriented 1` and `metric 1`, and the viewer takes +Z as up rather than guessing +up from the cameras -- which would be wrong here: a handheld scanner's panorama +camera leans by several degrees, and a tripod station can too. + +### Seed points + +A scan holds tens of millions of points, bunched around each station. They are +thinned to about `--points` (default 500,000, half the trainer's default +`cap_max`, so the densifier has room to add detail), one per occupied voxel. +`--points all` ("Use every point") keeps them all, 27 bytes each on disk; the +trainer still keeps at most `cap_max` of them, drawn at random. +Each voxel keeps the mean of its points, and the voxel edge is found by +bisection so the count lands within 2% under the target. What is thinned is a +uniform draw of eight times the target (at least 8M points, at most 40M), which +bounds the memory. + +Voxels rather than a random draw, because a random draw keeps the density +bias: most of a terrestrial scan's points are within a few metres of a station. + +### Masks + +`spirula e57` writes an image's `imageMask`, when it has one, to `masks/`. +On the dataset screen the scan's photographs are inputs of their own, masked +like any other photo folder. + +### What a scan does not give you + +- **The tripod.** The downward face of a tripod station can be a black disc where + the scanner painted out itself and the tripod, and nothing in the masking + stack targets a fixed region in some images only. The correction editor's + propagate can copy one hand-drawn disc to each downward face. +- **The sky.** An upward face is sky with no points behind it. With the default + black background the trainer paints it with bright splats close to the + station, which then hang in front of every other face of that station; + `background_mode sh` (a learned skybox) is the setting for outdoor scans, and + by far the larger gain on held-out faces. +- **Viewpoints.** A station is one centre of projection for all of its faces, + so a tripod scan has as many viewpoints as stations -- far fewer than a + walk-around video gives -- and a held-out face is a view nothing near it was + trained on. + ## Preprocessing tools `reference/scripts/` holds standalone Python utilities that produce these diff --git a/docs/notes/gui-automation.md b/docs/notes/gui-automation.md index fea2c5300..a62322b15 100644 --- a/docs/notes/gui-automation.md +++ b/docs/notes/gui-automation.md @@ -122,6 +122,7 @@ sharing an id. | `/ui/key` | `keys=Ctrl+Shift+A` | | `/ui/text` | focus, select all, type `value=`, `enter=0` to leave it open | | `/ui/wait` | `frames=` | +| `/ui/drop` | `paths=`, one per line: what dropping them on the window does | | `/ui/screenshot` | `width=` to downscale (area average), `format=jpg`, `quality=`, `path=` to write it server-side | Display coordinates are not framebuffer pixels on a HiDPI screen; `/ui/state` diff --git a/docs/notes/lidar-alignment.md b/docs/notes/lidar-alignment.md new file mode 100644 index 000000000..caf4e4e24 --- /dev/null +++ b/docs/notes/lidar-alignment.md @@ -0,0 +1,247 @@ +# Aligning a reconstruction with a laser scan + +A laser scan (E57, LAS or PLY) is an input of the dataset screen, next to +photos and videos, and there may be several. The reconstruction runs as +usual; then `spirula lidar` moves it into the scans' frame with a similarity +-- rotation, translation and scale, any extrinsics -- and writes it back as +one COLMAP model whose seed points are the scans', each with a track of the +images that see it. Depth and normal maps are rendered from the scans in +place of the monocular geometry step. When the scans' own photographs are +the only images, no reconstruction runs at all (below). + +Code: `data/PointCloudFile.h` (the reader), `app/LidarAlign.h` (the fit and +the scans' frames), `app/LidarDataset.h` (the dataset), +`app/cli/lidar_main.cpp` (`spirula lidar`), `app/gui/LidarStep.h` and +`app/gui/LidarSources.cpp` (the GUI). + +## The pipeline + +``` +scan.e57 ──► photographs + their scan poses ─┐ (anchors.json) +scan.las ──► views rendered from where the ├─► SfM ─► spirula lidar ─► sparse/0 in the scan frame + scanner stood (no photographs) │ (fit, merge, + depths/ normals/ +photos / videos ─────────────────────────────┘ seeds, tracks) +``` + +1. **Anchors.** An image whose pose in the scan frame is known. They come + from the scan's own photographs (`extract_e57_anchors`), or, for a scan + that has none, from views of the scan rendered where its scanner stood + (`render_anchor_views`: E57 scan poses, a PLY `camera` element, or a + `.trajectory.las` beside the cloud, one position every 2 m). They + are recorded in `/lidar/anchors.json` (OpenCV camera-to-world in + the frame of the file named by `scan`, plus the COLMAP camera for + photographs). In the GUI, photographs become inputs of their own under + `lidar/photos/`, so they are imported and masked like any other photo + folder; rendered views are written straight into `images/scan_views/` + after the frames step -- for a scan without photographs, unless another + scan in its frame has them. +2. **SfM** runs over everything, except when the scans' photographs are the + only images and the scans share a frame by their files (below): then they + keep the scanner's poses and the step is `--scanner-poses` directly ("Use + the scanner's camera poses as they are", on by default; untick it for a + scanner whose camera poses are worse than a reconstruction would be). A + reconstruction that fails is not the end of a run whose scan has + photographs either: those keep the scanner's poses. +3. **The fit, per model** (`sparse/0`, `sparse/1`, ...): each model with an + anchor is fitted on its own, so a capture that fragments still lands whole. + - Rotation: the chordal mean of `R_scan R_model^T` over the anchors that + agree with the best-supported one to 3 degrees. + - Scale and translation: RANSAC over pairs of anchors standing further + apart than the inlier tolerance, then least squares with the rotation + held. Anchors that all stand in one place (one station's faces) leave the + scale open, and it comes from the scan's depth (`scale_from_depth`): the + median, over the model points the anchor images see, of the scan's range + along each point's ray over the model's. ICP from a guessed 1 lands + wherever it lands, unchecked. + - Point-to-plane ICP of the model's points (tracks of 3+) against the scan + thinned to 2M points: a 7-parameter Gauss-Newton step, Geman-McClure + weights whose sigma shrinks from 3x the median distance to + max(1.5 voxel, 1 cm, 3 MAD). Refused when it moves the anchors further + than 5% of their spread (and 5x their own disagreement). +4. **One model** in the scan frame: the models merged with fresh ids, rendered + views dropped, and every scan photograph the reconstruction did not place + added at its scanner pose. An image whose own points mostly miss the + scan's surface (under half within 5% + 20 cm, of 20 or more with scan + depth behind them) is left out after the maps are rendered: the + reconstruction misplaced it, and the fit cannot move part of a model. +5. **Seeds with tracks.** The scan thinned by voxels to `--points` (500k). + For every image the scan is rendered into a 1600 px depth map through the + image's real lens (the parser's camera, distortion included); a seed is + *seen* by the image when it lies on that depth to 3% + 5 cm. Each seed + keeps 12 of the images that see it, chosen by a hash so they spread and a + rerun keeps the same ones; their pixels are appended to `images.bin` as + observations. The model's own points survive only where the scan has + nothing (the majority of their observations land on a pixel with no scan + depth): sky, and what is past the scanner's range. + A scan without colour (XGRIDS' LAS is point format 1) takes its colours + from the photographs, through the masks. +6. **Depth and normals** from the same maps: 16-bit millimetres and + `127.5 + 127.5 n`, ray depth when most cameras are split into faces + (`resolve_ray_depth`'s vote), as PR 101's E57 converter wrote them. They + are blank wherever the image's mask takes it out (to the edge of every map + pixel a masked pixel touches), and no scan point is seen through a masked + pixel: a passer-by in front of a wall is not given the wall's depth. + +## Several scans + +Scan files are in one frame when their files say so: `scans_share_frame` +holds them to it when no two put a scanner in one place and at most one has +no station of its own (a registered export like Oxford's 19 RTC360 files +places every setup apart; two mobile scans, or two unregistered setups at +their own origins, do not). `--scan-frames shared|separate` overrides it. + +Files that are not are placed through their images, before the fit: + +1. `group_scan_frames`: in each model, the scans whose anchors one anchor fit + holds (a majority of each scan's) are in one frame; the rest are fitted + again. Scans no model saw are left out, unless all the others turned out + to be in one frame -- then nothing says they are not, and they join it. +2. The frame with the most registered anchors is the dataset's. Each model + is fitted to that frame's anchors and clouds alone (anchors, then ICP). +3. `place_frames`: every other frame through the fitted model holding most + of its anchors, at that model's scale -- so the move is rigid, as two + metric scans are related. Clouds and anchors are moved into the first + frame, and the run goes on as for one scan. `lidar_alignment.json`'s + `scans` records where each cloud went. + +`sparse/0/gauge.txt` says `oriented 1`, `metric 1`. + +**Files.** The result is built in `sparse.lidar_tmp/` and only swapped in once +complete, so a run killed half way (the GUI cancels by killing the child) +leaves the dataset as it found it. Then the numbered models it started from +are moved to `sparse_unaligned/` (never deleted; a rerun reads them there), +and the result becomes `sparse/0`. A model written flat into `sparse/` (an +export's) is read where it is and never touched; `sparse/0` goes beside it, +and the parser prefers it. A model it cannot read (COLMAP text) is refused and +left alone. `sparse/0/lidar_alignment.json` holds the transforms, a signature +of the inputs (a rerun with the same ones is skipped) and a checksum of the +`images.bin` it wrote, so a new reconstruction that overwrites `sparse/0` but +leaves the marker is recognised. Models that share images (the mapper allows +20) keep each image once, in the larger model. A reconstruction that fails in +the GUI runs the step with `--scanner-poses`, so an earlier run's model is not +taken for it. Every case above was exercised on the FJD workspace +(kill, rerun, model replaced, text model, duplicated model, flat export). + +## Formats + +- **E57**: PR 101's reader. Scan poses are stations when there are several or + one is not the identity (a mobile scan's single pose says nothing). +- **LAS** 1.0-1.4, point formats 0-10, uncompressed. Colour is 16-bit by the + standard but writers disagree on scaling, so the shift is taken from 64 + spans of the file. LAZ is refused with a message. +- **PLY**, ascii or binary, streamed. A `camera` element (PCL, ETH3D) gives + the scanner's position; NavVis writes one at the origin, which is where its + walk began. Dropped on the dataset screen, or together with + photos, videos or other scans, a PLY whose header is neither a splat's nor + a mesh's is a scan; alone anywhere else it opens in the viewer. +- **XGRIDS** (LCC Studio `developer_data/`): `raw/` (the original fisheye and + OpenCV lenses) and `perspective/` (pinhole crops) are COLMAP datasets in the + LAS's frame. "Add dataset..." on the export, `developer_data/` or `raw/` + takes `raw/` in place, adds the LAS, ticks "already in the scan's frame" + (`--mode keep`) and reads its masks flipped: they are white where the + operator is. + +## Measurements (2026-10-04, RTX 5070) + +| capture | anchors | anchor fit | after ICP | notes | +|---|---|---|---|---| +| FJD Trion P2 E57 (51 panoramas) + X5 `.insv` every frame (224) | 51 photos | 0.16 deg / 3.5 cm | 1.3 cm to the surface | 62 s, 1.3 GB, 275 depth maps | +| FJD LAS (no photos) + the same `.insv` | 7 of 32 views on `trajectory.las` | 0.16 deg / 1.1 cm | 0.8 cm | insv poses within 2.2 cm / 0.19 deg of the row above | +| ETH3D courtyard, DSLR + merged FARO PLY (no photos) | 32 of 32 views | 0.14 deg / 2.0 cm | vs ground truth 3.0 cm / 0.11 deg | best rigid fit of that model: 3.2 cm | +| ETH3D office, same | 12 of 32 views | 0.24 deg / 1.1 cm | vs ground truth 1.0 cm / 0.45 deg | best rigid fit of that model: 0.75 cm / 0.52 deg | +| Leica BLK360 E57 alone (30 faces) | SfM 0/30 | -- | -- | every face at its scanner pose; 76% of seeds tracked | +| Matterport lobby E57 alone (54 faces) | SfM 2/54 | 0.11 deg / 1.1 cm | -- | 52 at scanner poses; 84% tracked | +| XGRIDS export (2138 images, LAS 83M points) | -- (`keep`) | -- | -- | 9 min 22 s (17 min 50 s before the LOD below), 2.8 GB | +| Oxford Spires: 525 handheld images + 19 RTC360 E57s (108 faces, 462M points) | 26 of 108 faces | 0.59 deg / 36 cm | 12.8 cm | 82 faces left out (below); handheld images' colour correlation with a station's scan 0.58, the dataset's own reference poses 0.08 | +| Oxford Spires: the 19 E57s alone, GUI (2026-10-05) | -- (scanner's poses) | -- | -- | no SfM; 108 of 108 faces, 84% of seeds tracked, 46 s for the step | +| NavVis M6 (LaMAR HGE session): 1884 rig images + its 127M-point PLY, GUI (2026-10-05) | 6 of 16 views, all at the one `camera` | scale from depth 24.875 (best Sim3 to the truth: 24.849) | 2.7 cm | model 1 (539 images) half misplaced by the SfM: 206 of its 253 wrong images left out, none of its 280 right ones; the kept are 1.9 cm from the session's poses (median). Model 0 (1046, consistent) has no anchor and is lost | +| FJD LAS + a copy turned 35 deg and moved 8 m, as PLY, with the insv model (2026-10-05) | 4 + 3 of 32 views | 0.30 deg / 2.3 cm | 0.7 cm | the copy placed through its 3 views to 0.36 deg / 4.9 cm of the truth | + +**Partitioning** the XGRIDS export into four (`spirula partition split +--parts 4`): with the export's own SfM tracks (64k points) the view graph is +159 pieces and no split is made; with the scan's points and their tracks +(527k, 1.27M covisible camera pairs) it splits into 4 parts with 32% of the +covisibility cut, in 4.5 s. + +**The scanner's own poses on FJD**: the 11 X5 panoramas in the second E57, +reconstructed with the insv frames and aligned by the 51 internal-camera +anchors only, land 2.0 cm / 0.32 deg (median) from the poses FJD recorded for +them -- its camera-to-scanner calibration is sound. + +**Training** (7k steps, every 8th image held out, the FJD E57 + insv dataset): +held-out PSNR 17.27 / SSIM 0.533 with the scan's seeds, normals and depth, +17.37 / 0.526 with the same images on the unaligned SfM model and its own +points -- within this capture's run-to-run noise (about 1 dB). + +Pose truth for FJD: SfM of the 51 panoramas alone agrees with their E57 poses +to 0.15 deg / 2.2 cm (measured 2026-09-28), and the photo-anchored and +LAS-anchored routes agree with each other as above. Overlays of the scan's +colours on insv frames line up (AC unit, pipe, brick courses). + +## What was tried + +- **Centres-only Umeyama** on the anchors: 1.9 deg off the anchors' own + rotations on the FJD walk, which is nearly a line -- the rotation about it + is free. Pose-based (rotations first): 0.16 deg. Kept the latter. +- **The misplaced-image check's tolerance** (NavVis model 1 against the + session's poses; FJD's two runs, all placed right): 3% + 5 cm, the seeds' + test, drops 216 of 253 wrong images but 5 of FJD's 15 right ones (its X5 + cloud is sparse); 5% + 20 cm at half drops 206 and no right one anywhere. + Spreading "misplaced" along covisibility found 10 more; not kept. +- **ICP convergence basin** (ETH3D courtyard, from the ground-truth Sim3 + perturbed): converges from ~5 deg / 10% scale / 10% of the scene's size, + fails at ~10 deg. Anchors land well inside it; ICP alone from nothing does + not. +- **Rendered views inside the SfM bend it.** With 32 renders in the + reconstruction, the best rigid fit of the DSLR cameras to ground truth is + 3.2 cm (courtyard) and 0.52 deg (office), against 1.0 cm / 0.08 deg for the + DSLR images alone. Using the renders only to place the model, then moving + a photos-only model there (by shared cameras) and running ICP: 0.86 cm. Not + done in the tool: it needs a second SfM, or the renders localized into the + first. **Bundle-adjusting the joint model again without the renders** (`sfm + ba`, Huber) does not help (3.04 cm / 0.42 deg): it stays in the same basin. +- **Refining an XGRIDS export with ICP** (`--mode refine`): moves it 0.38 deg, + 3 cm, scale 1.0004. Whether that is better cannot be told: the LAS has no + colour, and intensity against the photographs correlates at 0.23 either way + (better in 22 of 43 images). `keep` is the default for exports. +- **Oxford's 36 cm is anchor noise, not drift.** Faces of one station share + an optical centre, yet the SfM placed them up to 0.5 m apart: they matched + the small handheld images with 23-810 points each. A per-camera rigid ICP + of each camera's neighbourhood against the scan (prototype) found nothing + to correct (1 mm median). So the photographs the reconstruction did not + place are put at their scanner poses only when the anchors agree with the + fit to 5 cm and 0.5 deg; otherwise they are left out and the run says so. +- **Level of detail in the depth renderer**: a cell whose points are closer + than 0.7 px is drawn from every k-th point. Maps are identical on FJD + (median difference 0 mm, 0.15% of pixels over 5 cm). + +## Not done yet + +- **A model with no anchor is lost**, however good. On NavVis the views at + the PLY's one camera registered only into the second model; the largest + (1046 images, 99% of it held by one similarity to the truth) was dropped. + A trajectory beside the cloud (`.trajectory.ply`) gives views along + the walk; ICP from nothing does not converge (below). The check against + the scan also keeps 47 of NavVis's misplaced images: a chunk whose points + land on a surface, only the wrong one. + +- **Registering clouds to each other.** A scan in a frame of its own is + placed only through its images in the reconstruction, to their accuracy + (the row above); one whose images the reconstruction did not place is left + out. Cloud-to-cloud ICP would refine the first and place the second: + `place_frames` is where its placements would come from, and the frames' + moves are applied in one place in `write_lidar_dataset`. The model's own + ICP afterwards moves the model, not the frames placed through it. + +- **Refinement from the scanner's poses as priors** (the popular request): + `anchors.json` is already the per-image pose file it needs. It would be a + `PriorSource` (`sfm/core/PriorSource.h`) giving each anchor a `PriorCentre` + and `PriorRotation`, so the poses constrain BA rather than only placing the + result; TLS cube faces, which SfM cannot reconstruct alone, are where it + matters. +- Localizing rendered views into a photos-only model (above), and a BA with + the scan as a constraint (model points pulled onto its surface), which would + also correct the reconstruction's own drift rather than only its pose. +- A colourless scan cannot render useful views (intensity is not what a photo + sees), so it needs photographs or `keep`. +- The COLMAP engine of the dataset screen does not run the step. diff --git a/src/app/E57Dataset.cpp b/src/app/E57Dataset.cpp new file mode 100644 index 000000000..ff8dd4123 --- /dev/null +++ b/src/app/E57Dataset.cpp @@ -0,0 +1,734 @@ +#include "app/E57Dataset.h" + +#include "app/DepthPng.h" +#include "app/ScanDepth.h" +#include "core/CameraModel.h" +#include "data/JsonWrite.h" +#include "data/SparseEdit.h" +#include "external/stb_image.h" +#include "external/stb_image_write.h" +#include "i18n/Message.h" +#include "i18n/catalog/E57.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +namespace e57 = spirula::e57; +namespace emsg = spirula::i18n::msg::e57; +using spirula::i18n::format; + +namespace app { +namespace { + +constexpr double kPi = 3.14159265358979323846; + +// OpenGL camera axes (columns) in an E57 image's own frame, measured by +// projecting real scans' coloured points into their images: a pinhole +// frame is OpenGL's; a panorama's centre looks along +x with +z up. +constexpr double kPinholeAxes[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1}; +constexpr double kSphericalAxes[9] = {0, 0, -1, -1, 0, 0, 0, 1, 0}; + +// A uniform draw of 8x the target, within these bounds, is what gets thinned: +// memory stays bounded and the sparse far parts of a scan keep most points. +constexpr int64_t kPreThinFloor = 8000000; +constexpr int64_t kPreThinCeiling = 40000000; + +// The depth maps are rendered from a draw of at most this many points, 15 bytes +// each once bucketed; fewer leave a speckle of holes in a dense 1600 px face. +constexpr int64_t kDepthCloudCap = 40000000; +// A 90-degree face at 1600 px samples 0.06 degrees, about what a terrestrial +// scan resolves at 10 m. Every face of one size shares a map size, which lets +// the trainer batch them without resampling across the holes. +constexpr int kDepthMapSide = 1600; + +// The alignment check: a few images per kind, small maps, a million points. +constexpr int kCheckImages = 6; +constexpr int kCheckSide = 320; +constexpr int64_t kCheckPoints = 1000000; +// Another camera orientation replaces the assumed one only when it matches the +// photos by this much more, and well enough to believe at all. +constexpr double kCheckMargin = 0.1; +constexpr double kCheckFloor = 0.25; + +uint64_t splitmix64(uint64_t x) { + x += 0x9E3779B97F4A7C15ull; + x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9ull; + x = (x ^ (x >> 27)) * 0x94D049BB133111EBull; + return x ^ (x >> 31); +} + +std::string file_stem(const e57::Image& im, size_t index, size_t count) { + int width = 4; + for (size_t n = count; n >= 10000; n /= 10) width++; + char head[32]; + std::snprintf(head, sizeof head, "%0*zu", width, index); + std::string s = head; + const std::string& name = im.name.empty() ? im.guid : im.name; + if (!name.empty()) s += '_'; + for (char c : name) { + if (s.size() >= 64) break; + s += (std::isalnum((unsigned char)c) || c == '-') ? c : '_'; + } + return s; +} + +void write_file(const fs::path& path, const std::vector& bytes) { + std::ofstream f(path, std::ios::binary | std::ios::trunc); + f.write(reinterpret_cast(bytes.data()), (std::streamsize)bytes.size()); + if (!f) throw std::runtime_error("cannot write " + path.string()); +} + +struct Frame { + size_t index; + E57Camera cam; + std::string stem; +}; + +// The image file a frame is written as, in the dataset and in the preview frames. +std::string image_name(const e57::Image& im, const std::string& stem) { + return stem + (im.png.empty() ? ".jpg" : ".png"); +} + +// The files of `dir` this run did not write: an earlier run's, of images this +// one leaves out, which masking would otherwise walk as frames. +void remove_others(const fs::path& dir, const std::set& keep) { + std::error_code ec; + for (const auto& e : fs::directory_iterator(dir, ec)) + if (e.is_regular_file(ec) && !keep.count(e.path().filename().string())) + fs::remove(e.path(), ec); +} + +// E57 marks a valid pixel with any non-zero value; the trainer keeps one from +// 128 up. +void write_mask(const std::vector& png, const fs::path& path) { + int w = 0, h = 0, c = 0; + unsigned char* px = stbi_load_from_memory(png.data(), (int)png.size(), &w, &h, &c, 1); + if (!px) throw std::runtime_error("E57: an image mask is not a readable PNG"); + for (int64_t i = 0; i < (int64_t)w * h; i++) px[i] = px[i] ? 255 : 0; + const bool ok = stbi_write_png(path.string().c_str(), w, h, 1, px, w) != 0; + stbi_image_free(px); + if (!ok) throw std::runtime_error("cannot write " + path.string()); +} + +std::string skip_line(E57Skip why, const std::string& name, const e57::Image& im) { + switch (why) { + case E57Skip::NoPose: return format(emsg::skip_no_pose, {name}); + case E57Skip::NoPixels: return format(emsg::skip_no_pixels, {name}); + case E57Skip::Cylindrical: return format(emsg::skip_cylindrical, {name}); + case E57Skip::Uncalibrated: return format(emsg::skip_uncalibrated, {name}); + case E57Skip::BadPinhole: return format(emsg::skip_bad_pinhole, {name}); + case E57Skip::PartialPanorama: { + char w[16], h[16]; + std::snprintf(w, sizeof w, "%.0f", im.pixel_width * (double)im.width * 180.0 / kPi); + std::snprintf(h, sizeof h, "%.0f", im.pixel_height * (double)im.height * 180.0 / kPi); + return format(emsg::skip_partial_panorama, {name, w, h}); + } + case E57Skip::None: break; + } + return ""; +} + +// Which images become cameras, in file order; `log`, when given, hears why the +// others do not. +std::vector plan_frames(const std::vector& images, bool pinhole, + bool spherical, + const std::function& log) { + std::vector frames; + for (size_t i = 0; i < images.size(); i++) { + const e57::Image& im = images[i]; + E57Camera cam; + const E57Skip why = e57_camera(im, cam); + const std::string stem = file_stem(im, i, images.size()); + if (why != E57Skip::None) { + if (log) log(skip_line(why, stem, im)); + continue; + } + if (im.projection == e57::Projection::Pinhole ? !pinhole : !spherical) continue; + frames.push_back({i, cam, stem}); + } + return frames; +} + +std::string json_matrix(const double c2w[12]) { + std::string s = "["; + for (int r = 0; r < 4; r++) { + s += r ? ", [" : "["; + for (int c = 0; c < 4; c++) { + const double v = r < 3 ? c2w[r * 4 + c] : (c == 3 ? 1.0 : 0.0); + s += (c ? ", " : "") + json_number_exact(v); + } + s += "]"; + } + return s + "]"; +} + +// A point is drawn when a hash of its serial number falls under the threshold, +// so a smaller draw is a subset of a larger one and a run is repeatable. +uint64_t draw_threshold(int64_t cap, int64_t total) { + if (total <= cap) return std::numeric_limits::max(); + return (uint64_t)std::min(std::ldexp((double)cap / (double)total, 64), + 18446744073709549568.0); +} + +// The 24 rotations that permute and flip a camera's axes, identity first. +std::vector> axis_rotations() { + std::vector> out; + int perm[3] = {0, 1, 2}; + do { + for (int flip = 0; flip < 8; flip++) { + std::array m{}; + for (int r = 0; r < 3; r++) m[r * 3 + perm[r]] = (flip >> r & 1) ? -1.0 : 1.0; + const double det = m[0] * (m[4] * m[8] - m[5] * m[7]) - + m[1] * (m[3] * m[8] - m[5] * m[6]) + + m[2] * (m[3] * m[7] - m[4] * m[6]); + if (det > 0) out.push_back(m); + } + } while (std::next_permutation(perm, perm + 3)); + return out; +} + +// c2w <- c2w * [Q 0; 0 1], in the camera's OpenGL axes. +void turn_camera(E57Camera& c, const std::array& q) { + for (int r = 0; r < 3; r++) { + double row[3]; + for (int col = 0; col < 3; col++) + row[col] = c.c2w[r * 4] * q[col] + c.c2w[r * 4 + 1] * q[3 + col] + + c.c2w[r * 4 + 2] * q[6 + col]; + for (int col = 0; col < 3; col++) c.c2w[r * 4 + col] = row[col]; + } +} + +// `c` at `scale` of its size, OpenCV axes (OpenGL's with y and z negated). +MapCamera map_camera(const E57Camera& c, double scale) { + MapCamera m; + const bool pinhole = std::strcmp(c.model, "PINHOLE") == 0; + m.model = (int)(pinhole ? CameraModelType::PINHOLE : CameraModelType::EQUIRECTANGULAR); + m.width = std::max(1, (int)std::lround((double)c.width * scale)); + m.height = std::max(1, (int)std::lround((double)c.height * scale)); + const double sx = (double)m.width / (double)c.width, sy = (double)m.height / (double)c.height; + m.fx = c.fx * sx; m.fy = c.fy * sy; m.cx = c.cx * sx; m.cy = c.cy * sy; + for (int r = 0; r < 3; r++) { + m.c2w[r * 4] = c.c2w[r * 4]; + m.c2w[r * 4 + 1] = -c.c2w[r * 4 + 1]; + m.c2w[r * 4 + 2] = -c.c2w[r * 4 + 2]; + m.c2w[r * 4 + 3] = c.c2w[r * 4 + 3]; + } + return m; +} + +// Rec. 709 luma of an image file, box-filtered down to `w` x `h`. +bool image_luma(const std::string& path, int w, int h, std::vector& out) { + int iw = 0, ih = 0, ic = 0; + unsigned char* px = stbi_load(path.c_str(), &iw, &ih, &ic, 3); + if (!px) return false; + out.assign((size_t)w * h, 0.0f); + std::vector count((size_t)w * h, 0); + for (int y = 0; y < ih; y++) + for (int x = 0; x < iw; x++) { + const unsigned char* p = px + ((size_t)y * iw + x) * 3; + const size_t k = (size_t)(y * h / ih) * w + (size_t)(x * w / iw); + out[k] += 0.2126f * p[0] + 0.7152f * p[1] + 0.0722f * p[2]; + count[k]++; + } + stbi_image_free(px); + for (size_t k = 0; k < out.size(); k++) out[k] /= std::max(count[k], 1); + return true; +} + +// Pearson correlation of the front-most points' colour with the photo's, over +// the pixels a point lands in; NaN when too few do. Exposure-blind by design. +double color_correlation(const ScanCloud& cloud, const MapCamera& cam, + const std::vector& luma) { + std::vector index; + std::vector depth; + render_front(cloud, cam, cam.model != (int)CameraModelType::PINHOLE, index, depth); + double n = 0, sx = 0, sy = 0, sxx = 0, syy = 0, sxy = 0; + for (size_t k = 0; k < index.size(); k++) { + if (index[k] == UINT32_MAX) continue; + const uint8_t* c = &cloud.rgb[(size_t)index[k] * 3]; + const double x = 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2], y = luma[k]; + n++; sx += x; sy += y; sxx += x * x; syy += y * y; sxy += x * y; + } + if (n < 200) return std::numeric_limits::quiet_NaN(); + const double vx = sxx - sx * sx / n, vy = syy - sy * sy / n; + if (!(vx > 0 && vy > 0)) return std::numeric_limits::quiet_NaN(); + return (sxy - sx * sy / n) / std::sqrt(vx * vy); +} + +std::string two_places(double v) { + char b[16]; + std::snprintf(b, sizeof b, "%.2f", v); + return b; +} + +void write_normal_png(const fs::path& path, const std::vector& n, int w, int h) { + // Black is the trainer's "no normal here"; a unit normal is byte/127.5 - 1. + std::vector px(n.size(), 0); + for (size_t k = 0; k * 3 < n.size(); k++) { + const float* v = &n[k * 3]; + if (v[0] * v[0] + v[1] * v[1] + v[2] * v[2] < 0.25f) continue; + for (int c = 0; c < 3; c++) + px[k * 3 + c] = (uint8_t)std::lround(std::clamp(127.5f + 127.5f * v[c], 0.0f, 255.0f)); + } + if (!stbi_write_png(path.string().c_str(), w, h, 3, px.data(), w * 3)) + throw std::runtime_error("cannot write " + path.string()); +} + +} // namespace + + +std::string default_e57_dataset_dir(const std::string& input) { + return fs::path(input).replace_extension("").string(); +} + +E57Skip e57_camera(const e57::Image& im, E57Camera& out) { + if (im.projection == e57::Projection::Cylindrical) return E57Skip::Cylindrical; + if (im.projection == e57::Projection::Reference) return E57Skip::Uncalibrated; + if (!im.has_pose) return E57Skip::NoPose; + if (im.jpeg.empty() && im.png.empty()) return E57Skip::NoPixels; + const bool pinhole = im.projection == e57::Projection::Pinhole; + if (im.width <= 0 || im.height <= 0) + return pinhole ? E57Skip::BadPinhole : E57Skip::PartialPanorama; + out.width = im.width; + out.height = im.height; + const double* axes = kPinholeAxes; + if (pinhole) { + if (!(im.focal_length > 0 && im.pixel_width > 0 && im.pixel_height > 0)) + return E57Skip::BadPinhole; + out.model = "PINHOLE"; + out.fx = im.focal_length / im.pixel_width; + out.fy = im.focal_length / im.pixel_height; + // E57 puts pixel centres on integers. + out.cx = im.principal_x + 0.5; + out.cy = im.principal_y + 0.5; + } else { + // The engine's panorama is the whole sphere; a band of one is not. + const double span_w = im.pixel_width * (double)im.width / (2 * kPi); + const double span_h = im.pixel_height * (double)im.height / kPi; + if (std::fabs(span_w - 1) > 0.01 || std::fabs(span_h - 1) > 0.01) + return E57Skip::PartialPanorama; + out.model = "EQUIRECTANGULAR"; + out.fx = (double)im.width / (2 * kPi); + out.fy = (double)im.height / kPi; + out.cx = (double)im.width / 2; + out.cy = (double)im.height / 2; + axes = kSphericalAxes; + } + double R[9]; + im.pose.rotation(R); + for (int r = 0; r < 3; r++) { + for (int c = 0; c < 3; c++) + out.c2w[r * 4 + c] = R[r * 3] * axes[c] + R[r * 3 + 1] * axes[3 + c] + + R[r * 3 + 2] * axes[6 + c]; + out.c2w[r * 4 + 3] = im.pose.t[r]; + } + return E57Skip::None; +} + +double thin_to_voxels(std::vector& xyz, std::vector& rgb, + int64_t target) { + const size_t n = xyz.size() / 3; + if (target <= 0 || (int64_t)n <= target) return 0.0; + double lo[3], hi[3]; + for (int a = 0; a < 3; a++) { + lo[a] = std::numeric_limits::infinity(); + hi[a] = -lo[a]; + } + for (size_t i = 0; i < n; i++) + for (int a = 0; a < 3; a++) { + lo[a] = std::min(lo[a], xyz[i * 3 + a]); + hi[a] = std::max(hi[a], xyz[i * 3 + a]); + } + const double extent = std::max({hi[0] - lo[0], hi[1] - lo[1], hi[2] - lo[2], 1e-9}); + + constexpr uint64_t kCells = (uint64_t(1) << 21) - 1; // 21 bits a key axis + std::vector keys(n); + auto make_keys = [&](double edge) { + const double inv = 1.0 / edge; + for (size_t i = 0; i < n; i++) { + uint64_t k = 0; + for (int a = 0; a < 3; a++) + k = (k << 21) | std::min(kCells, (uint64_t)((xyz[i * 3 + a] - lo[a]) * inv)); + keys[i] = k; + } + }; + auto occupied = [&](double edge) { + make_keys(edge); + std::sort(keys.begin(), keys.end()); + return (int64_t)(std::unique(keys.begin(), keys.end()) - keys.begin()); + }; + + // The count falls as the voxel grows; bisect its edge in log space and + // settle within 2% under the target. + double fine = extent / (double)kCells, coarse = extent; + if (occupied(fine) <= target) { + coarse = fine; + } else { + for (int it = 0; it < 60 && coarse / fine > 1.0001; it++) { + const double edge = std::sqrt(fine * coarse); + const int64_t count = occupied(edge); + if (count > target) { + fine = edge; + } else { + coarse = edge; + if ((double)count >= 0.98 * (double)target) break; + } + } + } + + make_keys(coarse); + std::vector> order(n); + for (size_t i = 0; i < n; i++) order[i] = {keys[i], (uint32_t)i}; + std::sort(order.begin(), order.end()); + std::vector out_xyz; + std::vector out_rgb; + for (size_t b = 0; b < n;) { + size_t e = b; + double p[3] = {0, 0, 0}, c[3] = {0, 0, 0}; + for (; e < n && order[e].first == order[b].first; e++) { + const size_t i = order[e].second; + for (int a = 0; a < 3; a++) { + p[a] += xyz[i * 3 + a]; + c[a] += rgb[i * 3 + a]; + } + } + const double inv = 1.0 / (double)(e - b); + for (int a = 0; a < 3; a++) { + out_xyz.push_back(p[a] * inv); + out_rgb.push_back((uint8_t)std::lround(c[a] * inv)); + } + b = e; + } + xyz.swap(out_xyz); + rgb.swap(out_rgb); + return coarse; +} + +E57DatasetResult write_e57_dataset(const E57DatasetOptions& opt, + const std::function& log, + const std::atomic* cancel) { + E57DatasetResult res; + auto cancelled = [&] { return cancel && cancel->load(); }; + e57::Reader reader(opt.input); + const std::vector& scans = reader.scans(); + const std::vector& images = reader.images(); + + int64_t n_pinhole = 0, n_spherical = 0; + for (const e57::Image& im : images) { + n_pinhole += im.projection == e57::Projection::Pinhole; + n_spherical += im.projection == e57::Projection::Spherical; + } + log(format(emsg::summary, {(long long)scans.size(), (long long)reader.total_points(), + (long long)images.size()})); + log(format(emsg::summary_images, + {(long long)n_pinhole, (long long)n_spherical, + (long long)images.size() - n_pinhole - n_spherical})); + + // Which images become cameras is settled before anything is written, so a + // scan with nothing to train on leaves no folder behind. + std::vector frames = plan_frames(images, opt.pinhole, opt.spherical, log); + if (frames.empty()) throw std::runtime_error(emsg::err_no_images.get()); + + const fs::path out(opt.output); + std::error_code ec; + if (!opt.overwrite && fs::is_directory(out, ec) && !fs::is_empty(out, ec)) + throw std::runtime_error(format(emsg::err_not_empty, {out.string()})); + // Masks are made of these images, by the scanner or by masking, and a new + // run makes its own; corrections live in mask_edits/ and are re-applied. + fs::remove_all(out / "masks", ec); + fs::create_directories(out / "images", ec); + if (ec) throw std::runtime_error("cannot create " + (out / "images").string()); + std::vector image_paths(frames.size()), mask_paths(frames.size()); + std::set written; + for (size_t k = 0; k < frames.size(); k++) { + if (cancelled()) { res.cancelled = true; return res; } + log(format(emsg::progress_images, {(long long)k + 1, (long long)frames.size()})); + const e57::Image& im = images[frames[k].index]; + const std::string name = image_name(im, frames[k].stem); + image_paths[k] = "images/" + name; + written.insert(name); + write_file(out / image_paths[k], reader.read_blob(im.png.empty() ? im.jpeg : im.png)); + if (!im.mask.empty()) { + fs::create_directories(out / "masks", ec); + mask_paths[k] = "masks/" + frames[k].stem + ".png"; + write_mask(reader.read_blob(im.mask), out / mask_paths[k]); + } + } + remove_others(out / "images", written); + + // One pass over the points feeds everything that needs them: the seed + // cloud, the cloud the depth maps are rendered from, and the check. + const int64_t total = reader.total_points(); + const bool seeds = opt.all_points || opt.seed_points > 0; + const uint64_t seed_below = opt.all_points + ? std::numeric_limits::max() + : draw_threshold(std::clamp(8 * opt.seed_points, kPreThinFloor, + kPreThinCeiling), total); + const uint64_t depth_below = + draw_threshold(opt.depth_maps ? kDepthCloudCap : kCheckPoints, total); + std::vector seed_xyz, depth_xyz; + std::vector seed_rgb, depth_rgb; + uint64_t serial = 0; + for (size_t s = 0; s < scans.size(); s++) { + log(format(emsg::progress_points, {(long long)s + 1, (long long)scans.size()})); + const bool whole = reader.read_points(scans[s], [&](const e57::Point* p, size_t n) { + for (size_t i = 0; i < n; i++) { + const uint64_t h = splitmix64(serial++); + if (seeds && h <= seed_below) { + seed_xyz.insert(seed_xyz.end(), p[i].xyz, p[i].xyz + 3); + seed_rgb.insert(seed_rgb.end(), p[i].rgb, p[i].rgb + 3); + } + if (h <= depth_below) { + depth_xyz.insert(depth_xyz.end(), p[i].xyz, p[i].xyz + 3); + depth_rgb.insert(depth_rgb.end(), p[i].rgb, p[i].rgb + 3); + } + } + }, cancel); + if (!whole) { res.cancelled = true; return res; } + } + const ScanCloud cloud = build_scan_cloud(depth_xyz, depth_rgb, 2.0); + std::vector().swap(depth_xyz); + std::vector().swap(depth_rgb); + + bool any_color = false; + for (const e57::Scan& sc : scans) any_color = any_color || sc.has_color; + if (cloud.size() == 0) { + res.check = E57Check::NotRun; + } else if (!any_color) { + res.check = E57Check::NoColor; + log(emsg::check_no_color.get()); + } else { + log(emsg::check_running.get()); + std::vector sub_xyz; + std::vector sub_rgb; + const size_t step = std::max(1, cloud.size() / (size_t)kCheckPoints); + for (size_t i = 0; i < cloud.size(); i += step) + for (int a = 0; a < 3; a++) { + sub_xyz.push_back(cloud.origin[a] + cloud.xyz[i * 3 + a]); + sub_rgb.push_back(cloud.rgb[i * 3 + a]); + } + const ScanCloud probe_cloud = build_scan_cloud(sub_xyz, sub_rgb, 2.0); + const std::vector> turns = axis_rotations(); + res.check = E57Check::Agree; + for (int kind = 0; kind < 2; kind++) { + std::vector members; + for (size_t k = 0; k < frames.size(); k++) + if ((std::strcmp(frames[k].cam.model, "PINHOLE") == 0) == (kind == 0)) + members.push_back(k); + if (members.empty()) continue; + struct Probe { + E57Camera cam; + double scale; + std::vector luma; + }; + std::vector probes; + const size_t n_probe = std::min(kCheckImages, members.size()); + for (size_t j = 0; j < n_probe; j++) { + const size_t k = members[j * members.size() / n_probe]; + const E57Camera& c = frames[k].cam; + Probe pr{c, std::min(1.0, (double)kCheckSide / (double)std::max(c.width, c.height)), {}}; + const MapCamera m = map_camera(c, pr.scale); + if (image_luma((out / image_paths[k]).string(), m.width, m.height, pr.luma)) + probes.push_back(std::move(pr)); + } + if (probes.empty()) continue; + std::vector score(turns.size(), -1.0); +#pragma omp parallel for schedule(dynamic, 1) + for (int t = 0; t < (int)turns.size(); t++) { + double sum = 0; + int n = 0; + for (const Probe& pr : probes) { + E57Camera c = pr.cam; + turn_camera(c, turns[(size_t)t]); + const double v = color_correlation(probe_cloud, map_camera(c, pr.scale), pr.luma); + if (v == v) { sum += v; n++; } + } + if (n) score[(size_t)t] = sum / n; + } + const size_t best = (size_t)(std::max_element(score.begin(), score.end()) - score.begin()); + if (best != 0 && score[best] >= kCheckFloor && score[best] - score[0] >= kCheckMargin) { + for (size_t k : members) turn_camera(frames[k].cam, turns[best]); + log(format(kind == 0 ? emsg::check_fixed_pinhole : emsg::check_fixed_panorama, + {two_places(score[0]), two_places(score[best])})); + res.check = E57Check::Fixed; + } else if (score[0] >= kCheckFloor) { + log(format(emsg::check_ok, {two_places(score[0])})); + } else { + log(format(emsg::check_unsure, {two_places(score[0])})); + if (res.check != E57Check::Fixed) res.check = E57Check::Unsure; + } + } + } + + std::vector depth_paths(frames.size()), normal_paths(frames.size()); + if (opt.depth_maps && cloud.size() > 0) { + fs::create_directories(out / "depths", ec); + fs::create_directories(out / "normals", ec); + int64_t n_spherical = 0; + for (const Frame& f : frames) n_spherical += std::strcmp(f.cam.model, "PINHOLE") != 0; + // The trainer reads every map of a dataset one way, by majority + // (TrainerCore.cpp resolve_ray_depth); a panorama's split needs ray depth. + const bool ray = n_spherical * 2 > (int64_t)frames.size(); + const int n = (int)frames.size(); + std::mutex mu; + std::exception_ptr failure; + int64_t done = 0; +#pragma omp parallel for schedule(dynamic, 1) + for (int k = 0; k < n; k++) { + if (cancelled()) continue; + { + std::lock_guard lk(mu); + if (failure) continue; + } + try { + const E57Camera& c = frames[(size_t)k].cam; + const MapCamera m = map_camera( + c, std::min(1.0, (double)kDepthMapSide / (double)std::max(c.width, c.height))); + std::vector depth, normal; + render_depth_normal(cloud, m, ray, depth, normal); + // 16 bits of millimetres end at 65.5 m; further is left as no + // data rather than clamped onto a wall at that range. + std::vector mm(depth.size(), 0); + for (size_t j = 0; j < depth.size(); j++) { + const double v = depth[j] * 1000.0; + if (depth[j] > 0 && v <= 65535.0) + mm[j] = (uint16_t)std::max(1, std::lround(v)); + } + const std::string dp = "depths/" + frames[(size_t)k].stem + ".png"; + const std::string np = "normals/" + frames[(size_t)k].stem + ".png"; + if (!save_depth_png16((out / dp).string(), mm.data(), m.width, m.height)) + throw std::runtime_error("cannot write " + (out / dp).string()); + write_normal_png(out / np, normal, m.width, m.height); + std::lock_guard lk(mu); + depth_paths[(size_t)k] = dp; + normal_paths[(size_t)k] = np; + log(format(emsg::progress_depth, {(long long)++done, (long long)n})); + } catch (...) { + std::lock_guard lk(mu); + if (!failure) failure = std::current_exception(); + } + } + if (failure) std::rethrow_exception(failure); + if (cancelled()) { res.cancelled = true; return res; } + res.depth_maps = done; + std::set maps; + for (const Frame& f : frames) maps.insert(f.stem + ".png"); + remove_others(out / "depths", maps); + remove_others(out / "normals", maps); + } else { + fs::remove_all(out / "depths", ec); + fs::remove_all(out / "normals", ec); + } + + if (seeds && !seed_xyz.empty()) { + if (!opt.all_points) { + log(format(emsg::thinning, {(long long)serial, (long long)opt.seed_points})); + res.voxel = thin_to_voxels(seed_xyz, seed_rgb, opt.seed_points); + } + res.seed_points = (int64_t)seed_xyz.size() / 3; + // Double precision: a geo-referenced scan sits millions of metres out. + spirula::write_ply_points((out / "sparse_pc.ply").string(), seed_xyz.data(), + seed_rgb.data(), res.seed_points, nullptr, nullptr, true); + if (res.voxel > 0) { + char edge[32]; + std::snprintf(edge, sizeof edge, "%.3g", res.voxel); + log(format(emsg::seed_done, {(long long)res.seed_points, edge})); + } else { + log(format(emsg::seed_all, {(long long)res.seed_points})); + } + } + + JsonWriter w; + w.object(); + if (res.seed_points > 0) w.field("ply_file_path", "sparse_pc.ply"); + w.key("frames").array(); + for (size_t k = 0; k < frames.size(); k++) { + const E57Camera& c = frames[k].cam; + w.object(); + w.field("file_path", image_paths[k]); + if (!mask_paths[k].empty()) w.field("mask_path", mask_paths[k]); + if (!depth_paths[k].empty()) w.field("depth_file_path", depth_paths[k]); + if (!normal_paths[k].empty()) w.field("normal_file_path", normal_paths[k]); + w.field("camera_model", c.model); + w.field_raw("fl_x", json_number_exact(c.fx)); + w.field_raw("fl_y", json_number_exact(c.fy)); + w.field_raw("cx", json_number_exact(c.cx)); + w.field_raw("cy", json_number_exact(c.cy)); + w.field("w", (long long)c.width); + w.field("h", (long long)c.height); + w.field_raw("transform_matrix", json_matrix(c.c2w)); + w.end(); + } + w.end(); + w.end(); + { + std::ofstream f(out / "transforms.json", std::ios::trunc); + f << w.str(); + if (!f) throw std::runtime_error("cannot write " + (out / "transforms.json").string()); + } + { + // E57 is metres by definition, and scanners level their frame + // (docs/datasets.md, "The frame"). + std::ofstream f(out / "gauge.txt", std::ios::trunc); + f << "# What this dataset's frame means, from an E57 scan.\n" + "oriented 1\nmetric 1\nup e57\nscale e57\n"; + } + res.images = (int64_t)frames.size(); + log(format(emsg::done, {out.string(), (long long)res.images, (long long)res.seed_points})); + return res; +} + +bool extract_e57_images(const std::string& input, const std::string& images_dir, + bool pinhole, bool spherical, const std::atomic* cancel) { + e57::Reader reader(input); + const std::vector frames = plan_frames(reader.images(), pinhole, spherical, {}); + std::error_code ec; + fs::remove_all(images_dir, ec); + fs::create_directories(images_dir, ec); + if (ec) throw std::runtime_error("cannot create " + images_dir); + for (const Frame& f : frames) { + if (cancel && cancel->load()) return false; + const e57::Image& im = reader.images()[f.index]; + write_file(fs::path(images_dir) / image_name(im, f.stem), + reader.read_blob(im.png.empty() ? im.jpeg : im.png)); + } + return true; +} + +E57Preview read_e57_preview(const std::string& path, int64_t max_points, + const std::atomic* cancel) { + E57Preview p; + e57::Reader reader(path); + for (const e57::Image& im : reader.images()) { + E57Camera c; + if (e57_camera(im, c) == E57Skip::None) p.cameras.push_back(c); + } + const uint64_t below = draw_threshold(max_points, reader.total_points()); + uint64_t serial = 0; + for (const e57::Scan& s : reader.scans()) { + const bool whole = reader.read_points(s, [&](const e57::Point* pt, size_t n) { + for (size_t i = 0; i < n; i++) { + if (splitmix64(serial++) > below) continue; + p.xyz.insert(p.xyz.end(), pt[i].xyz, pt[i].xyz + 3); + p.rgb.insert(p.rgb.end(), pt[i].rgb, pt[i].rgb + 3); + } + }, cancel); + if (!whole) return {}; + } + return p; +} + +} // namespace app diff --git a/src/app/E57Dataset.h b/src/app/E57Dataset.h new file mode 100644 index 000000000..dfd512439 --- /dev/null +++ b/src/app/E57Dataset.h @@ -0,0 +1,87 @@ +#pragma once + +// A laser scan written out as a dataset the trainer opens: the images an E57 +// file carries, their poses, and its point cloud thinned into seed points, in +// the Nerfstudio layout. No reconstruction runs -- the scanner placed every +// image. docs/datasets.md "E57 laser scans". + +#include "data/E57Reader.h" + +#include +#include +#include +#include +#include + +namespace app { + +struct E57DatasetOptions { + std::string input; + std::string output; + int64_t seed_points = 500000; // 0 writes no cloud + bool all_points = false; // every point a seed, nothing thinned + bool depth_maps = true; // depths/ and normals/ from the scan + bool pinhole = true; + bool spherical = true; + bool overwrite = false; // write into a folder that is not empty +}; + +// What comparing the photos with the coloured points found. +enum class E57Check { NotRun, NoColor, Agree, Fixed, Unsure }; + +struct E57DatasetResult { + int64_t images = 0; + int64_t seed_points = 0; + int64_t depth_maps = 0; + double voxel = 0; // metres; 0 when every point was kept + E57Check check = E57Check::NotRun; + bool cancelled = false; +}; + +// Every line it prints goes to `log`, already translated. Throws on a file it +// cannot read or a scan with no usable image. +E57DatasetResult write_e57_dataset(const E57DatasetOptions& opt, + const std::function& log, + const std::atomic* cancel = nullptr); + +// Beside the scan, named after it. +std::string default_e57_dataset_dir(const std::string& input); + +// Why an image does not become a camera. +enum class E57Skip { None, NoPose, NoPixels, Cylindrical, Uncalibrated, + PartialPanorama, BadPinhole }; + +// One image as transforms.json spells it: OpenGL camera-to-world, row-major +// 3x4, in the file frame; pixel centres at +0.5. +struct E57Camera { + const char* model = ""; // "PINHOLE" or "EQUIRECTANGULAR" + double c2w[12] = {}; + double fx = 0, fy = 0, cx = 0, cy = 0; + int64_t width = 0, height = 0; +}; +E57Skip e57_camera(const spirula::e57::Image& im, E57Camera& out); + +// The images a dataset from `input` holds, under the same names, into +// `images_dir` (emptied first): the frames masking is tried on before the +// dataset exists. False when `cancel` stopped it. +bool extract_e57_images(const std::string& input, const std::string& images_dir, + bool pinhole, bool spherical, + const std::atomic* cancel = nullptr); + +// What the E57 screen previews: every image that would become a camera, and a +// uniform draw of about `max_points` points, in the file frame. Empty when +// `cancel` stopped it. +struct E57Preview { + std::vector cameras; + std::vector xyz; + std::vector rgb; +}; +E57Preview read_e57_preview(const std::string& path, int64_t max_points, + const std::atomic* cancel = nullptr); + +// Thins the cloud in place to at most `target` points, one per occupied voxel +// (the mean of its points). Returns the voxel edge, 0 when nothing was merged. +double thin_to_voxels(std::vector& xyz, std::vector& rgb, + int64_t target); + +} // namespace app diff --git a/src/app/LidarAlign.cpp b/src/app/LidarAlign.cpp new file mode 100644 index 000000000..8a06489bb --- /dev/null +++ b/src/app/LidarAlign.cpp @@ -0,0 +1,602 @@ +#include "app/LidarAlign.h" + +#include "app/E57Dataset.h" +#include "app/ScanDepth.h" +#include "core/CameraModel.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace app::lidar { +namespace { + +using sfm::Mat3; +using sfm::Vec3; + +constexpr double kPi = 3.14159265358979323846; +constexpr int kNormalNeighbours = 12; +// Anchors agree with a hypothesis when their rotation is this close; E57 image +// poses and SfM rotations agree to ~0.2 deg on the FJD walk. +constexpr double kAnchorRotTol = 3.0 * kPi / 180.0; + +Vec3 col(const Mat3& R, int c) { return {R[c], R[3 + c], R[6 + c]}; } + +std::string stem(const std::string& s) { + const size_t dot = s.find_last_of('.'), slash = s.find_last_of('/'); + if (dot == std::string::npos || (slash != std::string::npos && dot < slash)) return s; + return s.substr(0, dot); +} + +double angle_between(const Mat3& A, const Mat3& B) { + const Mat3 D = sfm::mul(sfm::transpose(A), B); + return std::acos(std::clamp((D[0] + D[4] + D[8] - 1.0) / 2.0, -1.0, 1.0)); +} + +Mat3 chordal_mean(const std::vector& Rs) { + Mat3 acc{}; + for (const Mat3& R : Rs) + for (int k = 0; k < 9; k++) acc[k] += R[k]; + return sfm::nearestRotation(acc); +} + +double median(std::vector v) { + if (v.empty()) return 0; + std::nth_element(v.begin(), v.begin() + v.size() / 2, v.end()); + return v[v.size() / 2]; +} + +// Symmetric 3x3 -> eigenvector of the smallest eigenvalue, and the +// eigenvalues ascending (Jacobi; a covariance is small and well conditioned). +void smallest_eigen(double a[6], double n[3], double ev[3]) { + double A[3][3] = {{a[0], a[1], a[2]}, {a[1], a[3], a[4]}, {a[2], a[4], a[5]}}; + double V[3][3] = {{1, 0, 0}, {0, 1, 0}, {0, 0, 1}}; + for (int sweep = 0; sweep < 12; sweep++) { + const double off = A[0][1] * A[0][1] + A[0][2] * A[0][2] + A[1][2] * A[1][2]; + if (off < 1e-30) break; + for (int p = 0; p < 2; p++) + for (int q = p + 1; q < 3; q++) { + if (std::fabs(A[p][q]) < 1e-300) continue; + const double th = 0.5 * (A[q][q] - A[p][p]) / A[p][q]; + const double t = (th >= 0 ? 1.0 : -1.0) / (std::fabs(th) + std::sqrt(th * th + 1)); + const double c = 1 / std::sqrt(t * t + 1), s = t * c; + for (int k = 0; k < 3; k++) { + const double akp = A[k][p], akq = A[k][q]; + A[k][p] = c * akp - s * akq; + A[k][q] = s * akp + c * akq; + } + for (int k = 0; k < 3; k++) { + const double apk = A[p][k], aqk = A[q][k]; + A[p][k] = c * apk - s * aqk; + A[q][k] = s * apk + c * aqk; + } + for (int k = 0; k < 3; k++) { + const double vkp = V[k][p], vkq = V[k][q]; + V[k][p] = c * vkp - s * vkq; + V[k][q] = s * vkp + c * vkq; + } + } + } + int order[3] = {0, 1, 2}; + std::sort(order, order + 3, [&](int i, int j) { return A[i][i] < A[j][j]; }); + for (int k = 0; k < 3; k++) { + ev[k] = A[order[k]][order[k]]; + n[k] = V[k][order[0]]; + } +} + +// Solves the 7x7 normal equations H x = g in place (Gaussian elimination with +// partial pivoting); false when singular. +bool solve7(double H[7][7], double g[7], double x[7]) { + double M[7][8]; + for (int r = 0; r < 7; r++) { + for (int c = 0; c < 7; c++) M[r][c] = H[r][c]; + M[r][7] = g[r]; + } + for (int c = 0; c < 7; c++) { + int piv = c; + for (int r = c + 1; r < 7; r++) + if (std::fabs(M[r][c]) > std::fabs(M[piv][c])) piv = r; + if (std::fabs(M[piv][c]) < 1e-18) return false; + for (int k = 0; k < 8; k++) std::swap(M[c][k], M[piv][k]); + for (int r = 0; r < 7; r++) { + if (r == c) continue; + const double f = M[r][c] / M[c][c]; + for (int k = c; k < 8; k++) M[r][k] -= f * M[c][k]; + } + } + for (int r = 0; r < 7; r++) x[r] = M[r][7] / M[r][r]; + return true; +} + +Mat3 rotation_from_vector(const Vec3& w) { + const double th = w.norm(); + if (th < 1e-15) return sfm::mat3Identity(); + const Vec3 k = w * (1.0 / th); + const Mat3 K = sfm::crossMatrix(k); + const Mat3 K2 = sfm::mul(K, K); + Mat3 R = sfm::mat3Identity(); + for (int i = 0; i < 9; i++) R[i] += std::sin(th) * K[i] + (1 - std::cos(th)) * K2[i]; + return R; +} + +} // namespace + +AlignCloud make_align_cloud(std::vector& xyz, std::vector& rgb, + int64_t target) { + AlignCloud c; + c.voxel = thin_to_voxels(xyz, rgb, target); + const size_t n = xyz.size() / 3; + double lo[3] = {1e300, 1e300, 1e300}, hi[3] = {-1e300, -1e300, -1e300}; + for (size_t i = 0; i < n; i++) + for (int a = 0; a < 3; a++) { + lo[a] = std::min(lo[a], xyz[i * 3 + a]); + hi[a] = std::max(hi[a], xyz[i * 3 + a]); + } + for (int a = 0; a < 3; a++) c.origin[a] = n ? 0.5 * (lo[a] + hi[a]) : 0.0; + c.xyz.resize(n * 3); + for (size_t i = 0; i < n; i++) + for (int a = 0; a < 3; a++) c.xyz[i * 3 + a] = (float)(xyz[i * 3 + a] - c.origin[a]); + c.rgb = std::move(rgb); + std::vector().swap(xyz); + c.tree = std::make_unique(c.xyz.data(), (int64_t)n); + c.normal.assign(n * 3, 0.0f); +#pragma omp parallel for schedule(dynamic, 4096) + for (int64_t i = 0; i < (int64_t)n; i++) { + float d2[kNormalNeighbours]; + int32_t idx[kNormalNeighbours]; + const int k = c.tree->query(&c.xyz[(size_t)i * 3], -1, kNormalNeighbours, d2, idx); + if (k < 6) continue; + double m[3] = {0, 0, 0}; + for (int j = 0; j < k; j++) + for (int a = 0; a < 3; a++) m[a] += c.xyz[(size_t)idx[j] * 3 + a]; + for (double& v : m) v /= k; + double cov[6] = {0, 0, 0, 0, 0, 0}; + for (int j = 0; j < k; j++) { + const float* p = &c.xyz[(size_t)idx[j] * 3]; + const double d[3] = {p[0] - m[0], p[1] - m[1], p[2] - m[2]}; + cov[0] += d[0] * d[0]; cov[1] += d[0] * d[1]; cov[2] += d[0] * d[2]; + cov[3] += d[1] * d[1]; cov[4] += d[1] * d[2]; cov[5] += d[2] * d[2]; + } + double nv[3], ev[3]; + smallest_eigen(cov, nv, ev); + // A surface: the thinnest axis well below the middle one. + if (!(ev[1] > 0) || ev[0] > 0.3 * ev[1]) continue; + for (int a = 0; a < 3; a++) c.normal[(size_t)i * 3 + a] = (float)nv[a]; + } + return c; +} + +int find_view(const ModelGeometry& m, const std::string& name) { + const std::string s = stem(name); + for (size_t i = 0; i < m.views.size(); i++) + if (m.views[i].name == name || stem(m.views[i].name) == s) return (int)i; + return -1; +} + +AnchorFit fit_anchors(const ModelGeometry& m, const std::vector& anchors, + double scale) { + AnchorFit fit; + struct Pair { Mat3 Rm, Rs; Vec3 cm, cs; size_t anchor; }; + std::vector pairs; + for (const Anchor& a : anchors) { + const int v = find_view(m, a.name); + if (v < 0) continue; + Pair p; + p.anchor = (size_t)(&a - anchors.data()); + p.Rm = m.views[(size_t)v].R; + p.cm = m.views[(size_t)v].centre; + p.Rs = a.R; + p.cs = a.centre; + pairs.push_back(p); + } + fit.registered = (int)pairs.size(); + if (pairs.empty()) return fit; + + // Rotation: the model-to-scan turn each anchor implies, R_scan R_model^T; + // the one most of them agree with, averaged over those. + std::vector turns(pairs.size()); + for (size_t i = 0; i < pairs.size(); i++) + turns[i] = sfm::mul(pairs[i].Rs, sfm::transpose(pairs[i].Rm)); + size_t best = 0, best_n = 0; + for (size_t i = 0; i < turns.size(); i++) { + size_t n = 0; + for (size_t j = 0; j < turns.size(); j++) + n += angle_between(turns[i], turns[j]) < kAnchorRotTol; + if (n > best_n) { best_n = n; best = i; } + } + std::vector rot_in; + std::vector agree; + for (size_t j = 0; j < turns.size(); j++) + if (angle_between(turns[best], turns[j]) < kAnchorRotTol) { + rot_in.push_back(j); + agree.push_back(turns[j]); + } + const Mat3 R = chordal_mean(agree); + + // Scale and translation from the centres, the rotation fixed: RANSAC over + // pairs of anchors that stand apart, then least squares over the inliers. + double spread = 0; + for (size_t a : rot_in) + for (size_t b : rot_in) spread = std::max(spread, (pairs[a].cs - pairs[b].cs).norm()); + const double tol = std::max(0.05, 0.02 * spread); + auto inliers_of = [&](double s, const Vec3& t) { + std::vector in; + for (size_t k : rot_in) { + const Vec3 p = sfm::mul(R, pairs[k].cm) * s + t; + if ((p - pairs[k].cs).norm() < tol) in.push_back(k); + } + return in; + }; + double s = scale; + Vec3 t; + std::vector in; + if (scale > 0) { + for (size_t k : rot_in) { + std::vector cand = inliers_of(s, pairs[k].cs - sfm::mul(R, pairs[k].cm) * s); + if (cand.size() > in.size()) in = cand; + } + Vec3 sum; + for (size_t k : in) sum = sum + (pairs[k].cs - sfm::mul(R, pairs[k].cm) * s); + t = sum * (1.0 / (double)in.size()); + fit.scale_known = true; + } else { + for (size_t ia = 0; ia < rot_in.size(); ia++) + for (size_t ib = ia + 1; ib < rot_in.size(); ib++) { + const Pair& A = pairs[rot_in[ia]]; + const Pair& B = pairs[rot_in[ib]]; + const double dm = (A.cm - B.cm).norm(), ds = (A.cs - B.cs).norm(); + // Two anchors closer than the tolerance say nothing of the + // size (one station's faces would make it 0). + if (ds < std::max(0.25 * spread, tol) || dm <= 0) continue; + const double sc = ds / dm; + const Vec3 tc = A.cs - sfm::mul(R, A.cm) * sc; + std::vector cand = inliers_of(sc, tc); + if (cand.size() > in.size()) { in = cand; s = sc; t = tc; } + } + fit.scale_known = in.size() >= 2 && s > 0; + Vec3 mm, ms; + if (!fit.scale_known) in = rot_in; + for (size_t k : in) { mm = mm + pairs[k].cm; ms = ms + pairs[k].cs; } + mm = mm * (1.0 / in.size()); + ms = ms * (1.0 / in.size()); + if (!fit.scale_known) { + // Every anchor at one place (a single station's faces): the turn and + // the place are known, the size is not. + s = 1.0; + } else { + double num = 0, den = 0; + for (size_t k : in) { + const Vec3 a = sfm::mul(R, pairs[k].cm - mm), b = pairs[k].cs - ms; + num += a.dot(b); + den += a.dot(a); + } + if (den > 0 && num > 0) s = num / den; + } + t = ms - sfm::mul(R, mm) * s; + } + fit.T.scale = s; + fit.T.R = R; + fit.T.t = t; + fit.inliers = (int)in.size(); + std::vector re, ce; + for (size_t k : in) { + re.push_back(angle_between(sfm::mul(R, pairs[k].Rm), pairs[k].Rs) * 180.0 / kPi); + ce.push_back((sfm::mul(R, pairs[k].cm) * s + t - pairs[k].cs).norm()); + } + fit.rot_err_deg = median(re); + fit.centre_err_m = median(ce); + for (size_t k : in) fit.used.push_back(pairs[k].anchor); + fit.ok = fit.inliers >= 1; + return fit; +} + +double scale_from_depth(const ModelGeometry& m, const std::vector& anchors, + const AnchorFit& fit, const ScanCloud& scan, int64_t* points) { + // A cube of 90-degree faces around each anchor covers every ray it sees. + constexpr int kSide = 256, kMaxAnchors = 8, kMinRatios = 20; + const Vec3 kForward[6] = {{1, 0, 0}, {-1, 0, 0}, {0, 1, 0}, {0, -1, 0}, {0, 0, 1}, {0, 0, -1}}; + std::vector ratio; + int used = 0; + for (size_t ai : fit.used) { + if (used == kMaxAnchors) break; + const int vi = find_view(m, anchors[ai].name); + if (vi < 0 || m.views[(size_t)vi].seen.empty()) continue; + used++; + const ModelGeometry::View& v = m.views[(size_t)vi]; + const Vec3 c = anchors[ai].centre; + for (const Vec3& fw : kForward) { + const Vec3 up = std::fabs(fw.z) > 0.5 ? Vec3{0, 1, 0} : Vec3{0, 0, 1}; + const Vec3 right = fw.cross(up).normalized(); + const Vec3 down = fw.cross(right); + MapCamera cam; + cam.model = (int)CameraModelType::PINHOLE; + cam.width = cam.height = kSide; + cam.fx = cam.fy = cam.cx = cam.cy = kSide / 2.0; + const Vec3 axes[3] = {right, down, fw}; + for (int r = 0; r < 3; r++) { + for (int k = 0; k < 3; k++) { + const Vec3& a = axes[k]; + cam.c2w[r * 4 + k] = r == 0 ? a.x : r == 1 ? a.y : a.z; + } + cam.c2w[r * 4 + 3] = r == 0 ? c.x : r == 1 ? c.y : c.z; + } + std::vector depth, normal; + render_depth_normal(scan, cam, /*ray_depth=*/true, depth, normal); + for (int p : v.seen) { + const Vec3 d = sfm::mul(fit.T.R, m.points[(size_t)p] - v.centre); + const double len = d.norm(), z = d.dot(fw); + if (!(len > 0) || z <= 0) continue; + const double u = d.dot(right) / z, w = d.dot(down) / z; + if (std::fabs(u) > 1 || std::fabs(w) > 1) continue; + const int x = std::min(kSide - 1, (int)(cam.fx * u + cam.cx)); + const int y = std::min(kSide - 1, (int)(cam.fy * w + cam.cy)); + const float ds = depth[(size_t)y * kSide + x]; + if (ds > 0) ratio.push_back(ds / len); + } + } + } + if (points) *points = (int64_t)ratio.size(); + if ((int)ratio.size() < kMinRatios) return 0; + return median(ratio); +} + +sfm::Sim3 refine_icp(const ModelGeometry& m, const AlignCloud& cloud, + const sfm::Sim3& init, IcpStats* stats) { + // Points seen from three or more images; the weight saturates at ten. + std::vector pts; + std::vector wt; + for (size_t i = 0; i < m.points.size(); i++) { + if (m.track[i] < 3) continue; + pts.push_back(m.points[i]); + wt.push_back((double)std::min(m.track[i], 10)); + } + const Vec3 o{cloud.origin[0], cloud.origin[1], cloud.origin[2]}; + // Work in the cloud's local frame: X = s R p + t - o. + double s = init.scale; + Mat3 R = init.R; + Vec3 t = init.t - o; + const int64_t n = (int64_t)pts.size(); + std::vector res(n), dist(n); + std::vector nn(n); + double sigma = -1; + const double sigma_min = std::max(0.01, 1.5 * cloud.voxel); + IcpStats st; + st.points = n; + if (n < 20 || cloud.size() == 0) { + if (stats) *stats = st; + return init; + } + for (int it = 0; it < 60; it++) { +#pragma omp parallel for schedule(dynamic, 2048) + for (int64_t i = 0; i < n; i++) { + const Vec3 X = sfm::mul(R, pts[(size_t)i]) * s + t; + const float q[3] = {(float)X.x, (float)X.y, (float)X.z}; + float d2; + int32_t j; + nn[(size_t)i] = cloud.tree->query(q, -1, 1, &d2, &j) ? j : -1; + dist[(size_t)i] = std::sqrt(d2); + if (nn[(size_t)i] >= 0) { + const float* nv = &cloud.normal[(size_t)j * 3]; + const float* c = &cloud.xyz[(size_t)j * 3]; + res[(size_t)i] = (float)(nv[0] * (X.x - c[0]) + nv[1] * (X.y - c[1]) + + nv[2] * (X.z - c[2])); + } + } + if (sigma < 0) { + std::vector d(dist.begin(), dist.end()); + sigma = std::max(sigma_min, 3.0 * median(d)); + } + double H[7][7] = {}, g[7] = {}; + std::vector absr; + int64_t used = 0; + for (int64_t i = 0; i < n; i++) { + const int32_t j = nn[(size_t)i]; + if (j < 0) continue; + const float* nv = &cloud.normal[(size_t)j * 3]; + if (nv[0] == 0 && nv[1] == 0 && nv[2] == 0) continue; + if (dist[(size_t)i] > 3 * sigma) continue; + const double r = res[(size_t)i]; + // Geman-McClure: a correspondence fades out past sigma. + const double k = sigma * sigma / (sigma * sigma + r * r); + const double w = wt[(size_t)i] * k * k; + const Vec3 X = sfm::mul(R, pts[(size_t)i]) * s + t; + const Vec3 nvec{nv[0], nv[1], nv[2]}; + const Vec3 xn = X.cross(nvec); + const double J[7] = {xn.x, xn.y, xn.z, nvec.x, nvec.y, nvec.z, nvec.dot(X)}; + for (int a = 0; a < 7; a++) { + g[a] -= w * J[a] * r; + for (int b = 0; b < 7; b++) H[a][b] += w * J[a] * J[b]; + } + absr.push_back(std::fabs(r)); + used++; + } + if (used < 20) break; + for (int a = 0; a < 7; a++) H[a][a] += 1e-9; + double x[7]; + if (!solve7(H, g, x)) break; + st.iterations = it + 1; + const Mat3 dR = rotation_from_vector({x[0], x[1], x[2]}); + const double ds = 1.0 + x[6]; + s *= ds; + R = sfm::mul(dR, R); + t = sfm::mul(dR, t) * ds + Vec3{x[3], x[4], x[5]}; + const double med = median(absr); + st.median_m = med; + int64_t close = 0; + for (double v : absr) close += v < sigma; + st.inlier_frac = (double)close / (double)n; + const double step = std::sqrt(x[0] * x[0] + x[1] * x[1] + x[2] * x[2]) + std::fabs(x[6]); + const double next = std::max({sigma * 0.7, sigma_min, 3.0 * 1.4826 * med}); + if (step < 1e-7 && next >= sigma) break; + sigma = next; + } + if (stats) *stats = st; + sfm::Sim3 out; + out.scale = s; + out.R = R; + out.t = t + o; + return out; +} + +std::vector scan_distances(const ModelGeometry& m, const AlignCloud& cloud, + const sfm::Sim3& T, double cap) { + const std::vector& pts = m.points; + const Vec3 o{cloud.origin[0], cloud.origin[1], cloud.origin[2]}; + std::vector out(pts.size(), std::numeric_limits::quiet_NaN()); +#pragma omp parallel for schedule(dynamic, 2048) + for (int64_t i = 0; i < (int64_t)pts.size(); i++) { + const Vec3 X = sfm::transformPoint(T, pts[(size_t)i]) - o; + const float q[3] = {(float)X.x, (float)X.y, (float)X.z}; + float d2; + if (cloud.tree->query(q, -1, 1, &d2) && std::sqrt(d2) <= cap) + out[(size_t)i] = std::sqrt(d2); + } + return out; +} + +std::string describe(const sfm::Sim3& T) { + // Rotation angle, scale and translation, for the log. + const double ang = std::acos(std::clamp((T.R[0] + T.R[4] + T.R[8] - 1.0) / 2.0, -1.0, 1.0)); + char b[160]; + std::snprintf(b, sizeof b, "scale %.6g, rotation %.2f deg, translation (%.3f, %.3f, %.3f)", + T.scale, ang * 180.0 / kPi, T.t.x, T.t.y, T.t.z); + return b; +} + +ScanFrames group_scan_frames(const std::vector& models, + const std::vector& anchors, + const std::vector& scan_of, int scans) { + std::vector parent((size_t)scans); + std::iota(parent.begin(), parent.end(), 0); + auto root = [&](int k) { + while (parent[(size_t)k] != k) k = parent[(size_t)k] = parent[(size_t)parent[(size_t)k]]; + return k; + }; + std::vector support((size_t)scans, 0); + for (const ModelGeometry& m : models) { + std::vector left; + std::vector left_scan; + for (size_t i = 0; i < anchors.size(); i++) { + if (scan_of[i] < 0 || find_view(m, anchors[i].name) < 0) continue; + left.push_back(anchors[i]); + left_scan.push_back(scan_of[i]); + support[(size_t)scan_of[i]]++; + } + // The scans most of whose anchors one fit holds are one frame; the + // rest are fitted again, until none are left. + while (!left.empty()) { + const AnchorFit fit = fit_anchors(m, left); + if (!fit.ok) break; + std::map> held; // scan -> (inliers, anchors) + for (int sc : left_scan) held[sc].second++; + for (size_t k : fit.used) held[left_scan[k]].first++; + std::vector joined; + int most = -1; + for (const auto& [sc, n] : held) { + if (n.first > 0 && 2 * n.first >= n.second) joined.push_back(sc); + if (most < 0 || n.first > held[most].first) most = sc; + } + if (joined.empty()) joined.push_back(most); + for (int sc : joined) parent[(size_t)root(sc)] = root(joined[0]); + std::vector rest; + std::vector rest_scan; + for (size_t k = 0; k < left.size(); k++) { + if (std::find(joined.begin(), joined.end(), left_scan[k]) != joined.end()) continue; + rest.push_back(left[k]); + rest_scan.push_back(left_scan[k]); + } + left.swap(rest); + left_scan.swap(rest_scan); + } + } + std::map by_root; + for (int k = 0; k < scans; k++) + if (support[(size_t)k] > 0) by_root[root(k)] += support[(size_t)k]; + std::vector> order; + for (const auto& [r, n] : by_root) order.push_back({-n, r}); + std::sort(order.begin(), order.end()); + ScanFrames out; + out.frame.assign((size_t)scans, -1); + if (order.size() <= 1) { + out.frame.assign((size_t)scans, 0); + out.count = 1; + return out; + } + for (int k = 0; k < scans; k++) { + if (support[(size_t)k] == 0) continue; + for (size_t f = 0; f < order.size(); f++) + if (order[f].second == root(k)) out.frame[(size_t)k] = (int)f; + } + out.count = (int)order.size(); + return out; +} + +bool scans_share_frame(const std::vector>& stations) { + if (stations.size() <= 1) return true; + // A file without a station of its own stands for one at its origin. + std::vector> at; + int bare = 0; + for (const auto& file : stations) { + at.emplace_back(); + for (const spirula::cloud::Station& st : file) + at.back().push_back({st.origin[0], st.origin[1], st.origin[2]}); + if (file.empty()) { + bare++; + at.back().push_back({0, 0, 0}); + } + } + if (bare > 1) return false; + constexpr double kSamePlace = 0.01; // metres + for (size_t i = 0; i < at.size(); i++) + for (size_t j = i + 1; j < at.size(); j++) + for (const Vec3& a : at[i]) + for (const Vec3& b : at[j]) + if ((a - b).norm() < kSamePlace) return false; + return true; +} + +std::vector place_frames(const std::vector& models, + const std::vector>& fits, + const std::vector& anchors, + const std::vector& scan_of, + const ScanFrames& frames) { + std::vector out((size_t)frames.count); + if (out.empty()) return out; + out[0].placed = true; + auto frame_of = [&](size_t i) { + return scan_of[i] < 0 ? -1 : frames.frame[(size_t)scan_of[i]]; + }; + for (int f = 1; f < frames.count; f++) { + std::vector best; + int best_m = -1; + for (size_t m = 0; m < models.size(); m++) { + if (!fits[m]) continue; + std::vector mine; + for (size_t i = 0; i < anchors.size(); i++) + if (frame_of(i) == f && find_view(models[m], anchors[i].name) >= 0) + mine.push_back(anchors[i]); + if (mine.size() > best.size()) { + best.swap(mine); + best_m = (int)m; + } + } + if (best_m < 0) continue; + const sfm::Sim3& T0 = *fits[(size_t)best_m]; + const AnchorFit fit = fit_anchors(models[(size_t)best_m], best, T0.scale); + if (!fit.ok) continue; + FramePlacement& p = out[(size_t)f]; + p.placed = true; + p.T = sfm::composeSim3(T0, sfm::invertSim3(fit.T)); + p.T.scale = 1.0; + p.model = best_m; + p.anchors = fit.inliers; + } + return out; +} + +} // namespace app::lidar diff --git a/src/app/LidarAlign.h b/src/app/LidarAlign.h new file mode 100644 index 000000000..5831ebbd0 --- /dev/null +++ b/src/app/LidarAlign.h @@ -0,0 +1,140 @@ +#pragma once + +// Fitting a reconstruction onto a laser scan: the similarity (rotation, +// translation, scale -- any extrinsics) that takes the model's frame to the +// scan's. Seeded from anchors -- images whose pose in the scan frame is known -- +// and refined by point-to-plane ICP of the model's points against the scan's +// surface. docs/notes/lidar-alignment.md has the measurements behind it. + +#include "data/Knn.h" +#include "data/PointCloudFile.h" +#include "sfm/core/Pose.h" + +#include +#include +#include +#include +#include + +namespace app { +struct ScanCloud; +} + +namespace app::lidar { + +// The scan as the alignment measures against it: thinned to one point a voxel, +// float offsets from `origin`, with unit normals (zero where the neighbourhood +// is no surface). +struct AlignCloud { + double origin[3] = {0, 0, 0}; + std::vector xyz, normal; + std::vector rgb; + double voxel = 0; + std::unique_ptr tree; + size_t size() const { return xyz.size() / 3; } +}; + +// Consumes `xyz` (file frame) and `rgb`. +AlignCloud make_align_cloud(std::vector& xyz, std::vector& rgb, + int64_t target); + +// What the alignment reads of a reconstruction: where its cameras are and its +// points, each with the number of images that see it. +struct ModelGeometry { + struct View { + std::string name; + sfm::Mat3 R = sfm::mat3Identity(); // camera-to-world, OpenCV axes + sfm::Vec3 centre; + std::vector seen; // indices into `points` + }; + std::vector views; + std::vector points; + std::vector track; +}; + +// A camera whose pose in the scan frame is known: an E57 image, or a view +// rendered from the scan. OpenCV axes, camera-to-world. +struct Anchor { + std::string name; // relative to the image folder + sfm::Mat3 R = sfm::mat3Identity(); + sfm::Vec3 centre; // in `scan`'s own frame + std::string scan; // the file it came from; "" = not recorded + bool rendered = false; // a view of the scan, not a photograph: never trained on + // Its COLMAP camera, when known: what places it where the reconstruction + // could not. -1 = none. + int camera_model = -1; + int64_t width = 0, height = 0; + std::vector params; +}; + +// The view that `name` is, with or without its extension; -1 if none. +int find_view(const ModelGeometry& m, const std::string& name); + +struct AnchorFit { + bool ok = false; + sfm::Sim3 T; // model frame -> scan frame + int registered = 0, inliers = 0; + bool scale_known = false; // the inlying anchors stand in more than one place + double rot_err_deg = 0; // medians over the inliers + double centre_err_m = 0; + std::vector used; // the inliers, as indices into `anchors` +}; +// `scale` > 0 holds the scale rather than fitting it: a metric model placing +// a scan of its own, which then moves rigidly. +AnchorFit fit_anchors(const ModelGeometry& m, const std::vector& anchors, + double scale = 0); + +struct IcpStats { + double median_m = 0; // |point-to-plane| over the points it kept + double inlier_frac = 0; // of the points it tried + int64_t points = 0; + int iterations = 0; +}; +// The scale anchors in one place leave open: each model point an anchor sees +// lies along the same ray in the scan, so the median ratio of the scan's range +// there to the model's. 0 when too few were measured; `points` says how many. +double scale_from_depth(const ModelGeometry& m, const std::vector& anchors, + const AnchorFit& fit, const app::ScanCloud& scan, + int64_t* points = nullptr); + +sfm::Sim3 refine_icp(const ModelGeometry& m, const AlignCloud& cloud, + const sfm::Sim3& init, IcpStats* stats = nullptr); + +// Distance from each model point, through `T`, to the nearest scan point; NaN +// past `cap` metres. +std::vector scan_distances(const ModelGeometry& m, const AlignCloud& cloud, + const sfm::Sim3& T, double cap); + +std::string describe(const sfm::Sim3& T); + +// Whether scan files share one frame by their own word: one registration +// writes its stations apart. Two files with no station of their own (mobile +// scans, unregistered setups) or with stations in one place are separate. +bool scans_share_frame(const std::vector>& stations); + +// Which scans share a frame by the reconstruction: in each model, those whose +// anchors (`scan_of` says whose) one fit holds. Frames by support, 0 the most; +// a scan no model saw is -1, or joins the only frame there is. +struct ScanFrames { + std::vector frame; // per scan + int count = 0; +}; +ScanFrames group_scan_frames(const std::vector& models, + const std::vector& anchors, + const std::vector& scan_of, int scans); + +// Each frame's rigid move into frame 0, through the models already fitted to +// frame 0 (`fits[m]`, model -> frame 0): from the fitted model holding most of +// the frame's anchors, at that model's scale. Frame 0 is placed as it is. +struct FramePlacement { + bool placed = false; + sfm::Sim3 T; // this frame -> frame 0, scale 1 + int model = -1, anchors = 0; // what placed it +}; +std::vector place_frames(const std::vector& models, + const std::vector>& fits, + const std::vector& anchors, + const std::vector& scan_of, + const ScanFrames& frames); + +} // namespace app::lidar diff --git a/src/app/LidarDataset.cpp b/src/app/LidarDataset.cpp new file mode 100644 index 000000000..96146e334 --- /dev/null +++ b/src/app/LidarDataset.cpp @@ -0,0 +1,1587 @@ +#include "app/LidarDataset.h" + +#include "app/DepthPng.h" +#include "app/E57Dataset.h" +#include "app/ScanDepth.h" +#include "core/CameraModel.h" +#include "data/CameraMath.h" +#include "data/DatasetParser.h" +#include "data/ImageProbe.h" +#include "data/Json.h" +#include "data/JsonWrite.h" +#include "data/PointCloudFile.h" +#include "external/stb_image.h" +#include "external/stb_image_write.h" +#include "i18n/Message.h" +#include "i18n/catalog/Lidar.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +namespace lmsg = spirula::i18n::msg::lidar; +using spirula::i18n::format; +using sfm::Mat3; +using sfm::Vec3; + +namespace app::lidar { +namespace { + +constexpr double kPi = 3.14159265358979323846; +constexpr const char* kAlignedMarker = "lidar_alignment.json"; +// Part of the signature: raised with a fix that changes the result, so a +// dataset aligned before it is done again rather than reused. +constexpr int kAlignVersion = 2; +constexpr const char* kUnalignedDir = "sparse_unaligned"; + +// The one draw of the scan everything else is taken from: the depth maps' +// cloud (15 bytes a point once bucketed; fewer leave holes in a 1600 px map), +// and random subsets of it for the alignment and the seed points. +constexpr int64_t kDrawCap = 40000000; +constexpr int64_t kAlignDraw = 8000000; +constexpr int64_t kAlignTarget = 2000000; +constexpr int kMapSide = 1600; +// A scan point is seen by an image when it lies on the depth that image's map +// holds there, to this much. +constexpr double kSeenRatio = 0.03; +constexpr double kSeenSlack = 0.05; // metres +// ... and an image's own points lie on it, looser for triangulated ones. With +// under half of them on it, an image is misplaced: on the NavVis HGE session, +// 206 of the 253 a broken model put metres off, none of its 280 placed right. +constexpr double kOnRatio = 0.05; +constexpr double kOnSlack = 0.2; // metres +constexpr int64_t kOnMinPoints = 20; +// The surface fit is refused when it leaves the anchors further than this, +// in the scene's own size (the anchors' spread). +constexpr double kAnchorDrift = 0.05; +// How closely the anchors must agree with the fit for the scanner's poses to +// stand in for the photographs the reconstruction did not place. +constexpr double kPlaceCentre = 0.05; // metres, median +constexpr double kPlaceRot = 0.5; // degrees, median + +uint64_t splitmix64(uint64_t x) { + x += 0x9E3779B97F4A7C15ull; + x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9ull; + x = (x ^ (x >> 27)) * 0x94D049BB133111EBull; + return x ^ (x >> 31); +} + +uint64_t draw_threshold(int64_t cap, int64_t total) { + if (total <= cap) return std::numeric_limits::max(); + return (uint64_t)std::min(std::ldexp((double)cap / (double)total, 64), + 18446744073709549568.0); +} + +std::string fixed(double v, int places) { + char b[48]; + std::snprintf(b, sizeof b, "%.*f", places, v); + return b; +} + +// ================ +// COLMAP binary, cameras passed through untouched +// ================ + +int colmap_num_params(int model) { + static const int kParams[18] = {3, 4, 4, 5, 8, 8, 12, 5, 4, 5, 12, 16, 4, 5, 3, 4, 0, 2}; + if (model < 0 || model > 17 || (model == 16)) throw std::runtime_error( + "COLMAP camera model " + std::to_string(model) + " is not one this reads"); + return kParams[model]; +} + +struct ColCamera { + int32_t model = 0; + uint64_t width = 0, height = 0; + std::vector params; +}; +struct ColImage { + double q[4] = {1, 0, 0, 0}; // world -> camera, w x y z + double t[3] = {0, 0, 0}; + uint32_t camera = 0; + std::string name; + std::vector xy; // [2n] + std::vector point; // [n] +}; +struct ColPoint { + double xyz[3]; + uint8_t rgb[3]; + double error = 0; + std::vector track; // [2n]: image id, point2D index +}; +struct ColModel { + std::map cameras; + std::map images; + std::map points; +}; + +template T rd(std::ifstream& f) { + T v{}; + f.read((char*)&v, sizeof v); + if (!f) throw std::runtime_error("a COLMAP file ends early"); + return v; +} +template void wr(std::ofstream& f, T v) { f.write((const char*)&v, sizeof v); } + +ColModel read_model(const fs::path& dir) { + ColModel m; + { + std::ifstream f(dir / "cameras.bin", std::ios::binary); + if (!f) throw std::runtime_error("cannot read " + (dir / "cameras.bin").string()); + const uint64_t n = rd(f); + for (uint64_t i = 0; i < n; i++) { + const uint32_t id = rd(f); + ColCamera& c = m.cameras[id]; + c.model = rd(f); + c.width = rd(f); + c.height = rd(f); + c.params.resize((size_t)colmap_num_params(c.model)); + for (double& p : c.params) p = rd(f); + } + } + { + std::ifstream f(dir / "images.bin", std::ios::binary); + if (!f) throw std::runtime_error("cannot read " + (dir / "images.bin").string()); + const uint64_t n = rd(f); + for (uint64_t i = 0; i < n; i++) { + const uint32_t id = rd(f); + ColImage& im = m.images[id]; + for (double& v : im.q) v = rd(f); + for (double& v : im.t) v = rd(f); + im.camera = rd(f); + char ch; + while (f.get(ch) && ch != '\0') im.name += ch; + const uint64_t np = rd(f); + im.xy.resize(np * 2); + im.point.resize(np); + for (uint64_t k = 0; k < np; k++) { + im.xy[k * 2] = rd(f); + im.xy[k * 2 + 1] = rd(f); + im.point[k] = rd(f); + } + } + } + { + std::ifstream f(dir / "points3D.bin", std::ios::binary); + if (!f) return m; + const uint64_t n = rd(f); + for (uint64_t i = 0; i < n; i++) { + const uint64_t id = rd(f); + ColPoint& p = m.points[id]; + for (double& v : p.xyz) v = rd(f); + for (uint8_t& c : p.rgb) c = rd(f); + p.error = rd(f); + const uint64_t tl = rd(f); + p.track.resize(tl * 2); + for (uint32_t& v : p.track) v = rd(f); + } + } + return m; +} + +void write_model(const ColModel& m, const fs::path& dir) { + fs::create_directories(dir); + { + std::ofstream f(dir / "cameras.bin", std::ios::binary | std::ios::trunc); + wr(f, m.cameras.size()); + for (const auto& [id, c] : m.cameras) { + wr(f, id); + wr(f, c.model); + wr(f, c.width); + wr(f, c.height); + for (double p : c.params) wr(f, p); + } + if (!f) throw std::runtime_error("cannot write " + (dir / "cameras.bin").string()); + } + { + std::ofstream f(dir / "images.bin", std::ios::binary | std::ios::trunc); + wr(f, m.images.size()); + for (const auto& [id, im] : m.images) { + wr(f, id); + for (double v : im.q) wr(f, v); + for (double v : im.t) wr(f, v); + wr(f, im.camera); + f.write(im.name.c_str(), (std::streamsize)im.name.size() + 1); + wr(f, im.point.size()); + for (size_t k = 0; k < im.point.size(); k++) { + wr(f, im.xy[k * 2]); + wr(f, im.xy[k * 2 + 1]); + wr(f, im.point[k]); + } + } + if (!f) throw std::runtime_error("cannot write " + (dir / "images.bin").string()); + } + { + std::ofstream f(dir / "points3D.bin", std::ios::binary | std::ios::trunc); + wr(f, m.points.size()); + for (const auto& [id, p] : m.points) { + wr(f, id); + for (double v : p.xyz) wr(f, v); + f.write((const char*)p.rgb, 3); + wr(f, p.error); + wr(f, p.track.size() / 2); + for (uint32_t v : p.track) wr(f, v); + } + if (!f) throw std::runtime_error("cannot write " + (dir / "points3D.bin").string()); + } +} + +Mat3 quat_to_R(const double q[4]) { + const double w = q[0], x = q[1], y = q[2], z = q[3]; + return {1 - 2 * (y * y + z * z), 2 * (x * y - w * z), 2 * (x * z + w * y), + 2 * (x * y + w * z), 1 - 2 * (x * x + z * z), 2 * (y * z - w * x), + 2 * (x * z - w * y), 2 * (y * z + w * x), 1 - 2 * (x * x + y * y)}; +} + +void R_to_quat(const Mat3& R, double q[4]) { + const double tr = R[0] + R[4] + R[8]; + if (tr > 0) { + const double s = 0.5 / std::sqrt(tr + 1.0); + q[0] = 0.25 / s; + q[1] = (R[7] - R[5]) * s; q[2] = (R[2] - R[6]) * s; q[3] = (R[3] - R[1]) * s; + } else if (R[0] > R[4] && R[0] > R[8]) { + const double s = 2.0 * std::sqrt(1.0 + R[0] - R[4] - R[8]); + q[0] = (R[7] - R[5]) / s; q[1] = 0.25 * s; + q[2] = (R[1] + R[3]) / s; q[3] = (R[2] + R[6]) / s; + } else if (R[4] > R[8]) { + const double s = 2.0 * std::sqrt(1.0 + R[4] - R[0] - R[8]); + q[0] = (R[2] - R[6]) / s; q[1] = (R[1] + R[3]) / s; + q[2] = 0.25 * s; q[3] = (R[5] + R[7]) / s; + } else { + const double s = 2.0 * std::sqrt(1.0 + R[8] - R[0] - R[4]); + q[0] = (R[3] - R[1]) / s; q[1] = (R[2] + R[6]) / s; + q[2] = (R[5] + R[7]) / s; q[3] = 0.25 * s; + } + if (q[0] < 0) for (int k = 0; k < 4; k++) q[k] = -q[k]; +} + +ModelGeometry geometry_of(const ColModel& m) { + ModelGeometry g; + std::map index; + for (const auto& [id, p] : m.points) { + index[id] = (int)g.points.size(); + g.points.push_back({p.xyz[0], p.xyz[1], p.xyz[2]}); + g.track.push_back((int)(p.track.size() / 2)); + } + for (const auto& [id, im] : m.images) { + ModelGeometry::View v; + v.name = im.name; + const Mat3 Rw2c = quat_to_R(im.q); + v.R = sfm::transpose(Rw2c); + v.centre = sfm::mul(v.R, Vec3{im.t[0], im.t[1], im.t[2]}) * -1.0; + for (uint64_t pid : im.point) { + const auto it = pid == ~0ull ? index.end() : index.find(pid); + if (it != index.end()) v.seen.push_back(it->second); + } + g.views.push_back(v); + } + return g; +} + +// x' = s R x + t on every pose and point. +void transform_model(ColModel& m, const sfm::Sim3& T) { + for (auto& [id, im] : m.images) { + sfm::Pose p{quat_to_R(im.q), {im.t[0], im.t[1], im.t[2]}}; + const sfm::Pose q = sfm::transformPose(T, p); + R_to_quat(q.R, im.q); + im.t[0] = q.t.x; im.t[1] = q.t.y; im.t[2] = q.t.z; + } + for (auto& [id, p] : m.points) { + const Vec3 x = sfm::transformPoint(T, {p.xyz[0], p.xyz[1], p.xyz[2]}); + p.xyz[0] = x.x; p.xyz[1] = x.y; p.xyz[2] = x.z; + } +} + +// The models of a run: sparse/ itself, or its numbered subfolders in order. +std::vector model_dirs(const fs::path& root) { + std::vector out; + std::error_code ec; + if (fs::exists(root / "cameras.bin", ec)) return {root}; + std::vector> numbered; + for (const auto& e : fs::directory_iterator(root, ec)) { + const std::string n = e.path().filename().string(); + if (!e.is_directory(ec) || n.empty() || + !std::all_of(n.begin(), n.end(), [](char c) { return c >= '0' && c <= '9'; })) + continue; + if (fs::exists(e.path() / "cameras.bin", ec) && fs::exists(e.path() / "images.bin", ec)) + numbered.push_back({std::stoi(n), e.path()}); + } + std::sort(numbered.begin(), numbered.end()); + for (auto& [k, p] : numbered) out.push_back(p); + return out; +} + +// sparse/ subfolders that hold a binary model, in order. +std::vector numbered_models(const fs::path& root) { + std::vector> found; + std::error_code ec; + for (const auto& e : fs::directory_iterator(root, ec)) { + const std::string n = e.path().filename().string(); + if (!e.is_directory(ec) || n.empty() || + !std::all_of(n.begin(), n.end(), [](char c) { return c >= '0' && c <= '9'; })) + continue; + if (fs::exists(e.path() / "cameras.bin", ec) && fs::exists(e.path() / "images.bin", ec)) + found.push_back({std::stoi(n), e.path()}); + } + std::sort(found.begin(), found.end()); + std::vector out; + for (auto& [k, p] : found) out.push_back(p); + return out; +} + +// `name` under `root`, or the file beside it with the same stem; "" if none. +std::string resolve_image(const fs::path& root, const std::string& name) { + std::error_code ec; + if (fs::exists(root / name, ec)) return name; + const fs::path rel(name); + const fs::path dir = root / rel.parent_path(); + for (const auto& e : fs::directory_iterator(dir, ec)) + if (e.is_regular_file(ec) && e.path().stem() == rel.stem()) + return (rel.parent_path() / e.path().filename()).generic_string(); + return ""; +} + +bool holds_files(const fs::path& dir) { + std::error_code ec; + for (fs::recursive_directory_iterator it(dir, ec), end; it != end && !ec; it.increment(ec)) + if (it->is_regular_file(ec)) return true; + return false; +} + +std::string stem_of(const std::string& s) { + const size_t dot = s.find_last_of('.'), slash = s.find_last_of('/'); + if (dot == std::string::npos || (slash != std::string::npos && dot < slash)) return s; + return s.substr(0, dot); +} + +void write_normal_png(const fs::path& path, const std::vector& n, int w, int h) { + std::vector px(n.size(), 0); + for (size_t k = 0; k * 3 < n.size(); k++) { + const float* v = &n[k * 3]; + if (v[0] * v[0] + v[1] * v[1] + v[2] * v[2] < 0.25f) continue; + for (int c = 0; c < 3; c++) + px[k * 3 + c] = (uint8_t)std::lround(std::clamp(127.5f + 127.5f * v[c], 0.0f, 255.0f)); + } + if (!stbi_write_png(path.string().c_str(), w, h, 3, px.data(), w * 3)) + throw std::runtime_error("cannot write " + path.string()); +} + +// Images whose name (by stem) is in `drop` taken out, with their observations; +// a point left with fewer than two goes too. +void remove_images(ColModel& m, const std::set& drop) { + for (auto it = m.images.begin(); it != m.images.end();) { + if (!drop.count(stem_of(it->second.name))) { ++it; continue; } + for (uint64_t pid : it->second.point) { + auto p = m.points.find(pid); + if (p == m.points.end()) continue; + std::vector& tr = p->second.track; + for (size_t k = 0; k < tr.size();) + if (tr[k] == it->first) tr.erase(tr.begin() + (long)k, tr.begin() + (long)k + 2); + else k += 2; + } + it = m.images.erase(it); + } + for (auto it = m.points.begin(); it != m.points.end();) { + if (it->second.track.size() >= 4) { ++it; continue; } + for (size_t k = 0; k < it->second.track.size(); k += 2) { + auto im = m.images.find(it->second.track[k]); + if (im != m.images.end() && it->second.track[k + 1] < im->second.point.size()) + im->second.point[it->second.track[k + 1]] = ~0ull; + } + it = m.points.erase(it); + } +} + +uint64_t fnv1a(const std::string& s) { + uint64_t h = 1469598103934665603ull; + for (char c : s) h = (h ^ (uint8_t)c) * 1099511628211ull; + return h; +} + +// FNV-1a of a file: what tells the model this step wrote from one a new +// reconstruction wrote over it. +uint64_t file_hash(const fs::path& p) { + std::ifstream f(p, std::ios::binary); + uint64_t h = 1469598103934665603ull; + std::vector buf(1 << 20); + while (f) { + f.read(buf.data(), (std::streamsize)buf.size()); + for (std::streamsize i = 0; i < f.gcount(); i++) + h = (h ^ (uint8_t)buf[(size_t)i]) * 1099511628211ull; + } + return h; +} + +std::string hex(uint64_t v) { + char b[24]; + std::snprintf(b, sizeof b, "%016llx", (unsigned long long)v); + return b; +} + +// Everything the result depends on but the reconstruction: the clouds as +// files, the anchors, the options. +std::string signature(const DatasetOptions& opt, const std::string& anchors_path) { + std::string s = "v" + std::to_string(kAlignVersion) + ";"; + std::error_code ec; + for (const std::string& c : opt.clouds) { + s += c + ":" + std::to_string(fs::file_size(c, ec)) + ":" + + std::to_string((long long)fs::last_write_time(c, ec).time_since_epoch().count()) + ";"; + } + s += "anchors:" + (fs::exists(anchors_path, ec) ? hex(file_hash(anchors_path)) : "none") + ";"; + s += "mode:" + std::to_string((int)opt.mode) + ";seeds:" + std::to_string(opt.seed_points) + + (opt.all_points ? "all" : "") + ";depth:" + std::to_string(opt.depth_maps) + + ";cap:" + std::to_string(opt.track_cap) + ";gaps:" + std::to_string(opt.sfm_points_in_gaps) + + ";flip:" + std::to_string(opt.flip_masks) + ";images:" + opt.image_dir + + ";masks:" + opt.mask_dir + ";frames:" + std::to_string((int)opt.frames) + + ";scanner:" + std::to_string(opt.scanner_poses_only); + return hex(fnv1a(s)); +} + +// One scan point's best `cap` observations, by a hash of (point, image) so the +// images kept are spread evenly and a rerun keeps the same ones. +struct Seen { + uint32_t hash; + uint32_t image; + float x, y; +}; + +} // namespace + +// ================ +// Anchors +// ================ + +std::vector read_anchors(const std::string& path) { + std::vector out; + const JsonValue j = json_parse_file(path); + const JsonValue* list = j.find("anchors"); + if (!list || !list->is_array()) return out; + for (const JsonValue& a : list->arr) { + const JsonValue* c2w = a.find("c2w"); + const JsonValue* name = a.find("name"); + if (!c2w || !name || c2w->arr.size() != 3) continue; + Anchor an; + an.name = name->as_string(); + double t[3]; + bool whole = true; + for (int r = 0; r < 3; r++) whole = whole && c2w->arr[(size_t)r].arr.size() == 4; + if (!whole) continue; + for (int r = 0; r < 3; r++) { + for (int c = 0; c < 3; c++) an.R[r * 3 + c] = c2w->arr[(size_t)r].arr[(size_t)c].as_double(); + t[r] = c2w->arr[(size_t)r].arr[3].as_double(); + } + an.R = sfm::nearestRotation(an.R); + an.centre = {t[0], t[1], t[2]}; + if (const JsonValue* sc = a.find("scan")) an.scan = sc->as_string(); + if (const JsonValue* r = a.find("rendered")) an.rendered = r->as_bool(); + if (const JsonValue* c = a.find("camera")) { + an.camera_model = (int)c->get_double("model", -1); + an.width = (int64_t)c->get_double("width", 0); + an.height = (int64_t)c->get_double("height", 0); + if (const JsonValue* ps = c->find("params")) + for (const JsonValue& v : ps->arr) an.params.push_back(v.as_double()); + } + out.push_back(an); + } + return out; +} + +void write_anchors(const std::string& path, const std::vector& anchors) { + JsonWriter w; + w.object(); + w.field("frame", "scan"); + w.key("anchors").array(); + for (const Anchor& a : anchors) { + w.object(); + w.field("name", a.name); + std::string m = "["; + const double t[3] = {a.centre.x, a.centre.y, a.centre.z}; + for (int r = 0; r < 3; r++) { + m += r ? ", [" : "["; + for (int c = 0; c < 4; c++) + m += (c ? ", " : "") + json_number_exact(c < 3 ? a.R[r * 3 + c] : t[r]); + m += "]"; + } + w.field_raw("c2w", m + "]"); + if (!a.scan.empty()) w.field("scan", a.scan); + if (a.rendered) w.field("rendered", true); + if (a.camera_model >= 0) { + w.key("camera").object(); + w.field("model", a.camera_model); + w.field("width", (long long)a.width); + w.field("height", (long long)a.height); + std::string ps = "["; + for (size_t k = 0; k < a.params.size(); k++) + ps += (k ? ", " : "") + json_number_exact(a.params[k]); + w.field_raw("params", ps + "]"); + w.end(); + } + w.end(); + } + w.end(); + w.end(); + fs::create_directories(fs::path(path).parent_path()); + std::ofstream f(path, std::ios::trunc); + f << w.str(); + if (!f) throw std::runtime_error("cannot write " + path); +} + +ExtractedPhotos extract_e57_anchors(const std::string& e57_path, const std::string& images_dir, + const std::string& subdir, const std::string& anchors_path) { + spirula::e57::Reader reader(e57_path); + std::vector anchors; + std::error_code ec; + if (fs::exists(anchors_path, ec)) anchors = read_anchors(anchors_path); + // A rerun replaces what this subfolder held. + const std::string prefix = subdir + "/"; + anchors.erase(std::remove_if(anchors.begin(), anchors.end(), + [&](const Anchor& a) { return a.name.rfind(prefix, 0) == 0; }), + anchors.end()); + const auto& images = reader.images(); + int64_t n_pin = 0, n_sph = 0; + for (const auto& im : images) { + E57Camera c; + if (e57_camera(im, c) != E57Skip::None) continue; + (std::strcmp(c.model, "PINHOLE") == 0 ? n_pin : n_sph)++; + } + // Pinhole faces and panoramas are different cameras to the reconstruction. + const bool split = n_pin > 0 && n_sph > 0; + ExtractedPhotos got; + got.split = split; + std::vector focals, factors; + for (size_t i = 0; i < images.size(); i++) { + const auto& im = images[i]; + E57Camera c; + if (e57_camera(im, c) != E57Skip::None) continue; + const bool pin = std::strcmp(c.model, "PINHOLE") == 0; + char stem[32]; + std::snprintf(stem, sizeof stem, "%05zu", i); + std::string rel = subdir + "/"; + if (split) rel += pin ? "pinhole/" : "panorama/"; + rel += std::string(stem) + (im.png.empty() ? ".jpg" : ".png"); + const fs::path out = fs::path(images_dir) / rel; + fs::create_directories(out.parent_path(), ec); + const spirula::e57::Blob& blob = im.png.empty() ? im.jpeg : im.png; + // An unchanged file is left alone, so a rerun does not look like new frames. + if (fs::file_size(out, ec) != blob.length || ec) { + const std::vector bytes = reader.read_blob(blob); + std::ofstream f(out, std::ios::binary | std::ios::trunc); + f.write((const char*)bytes.data(), (std::streamsize)bytes.size()); + if (!f) throw std::runtime_error("cannot write " + out.string()); + } + Anchor a; + a.name = rel; + a.scan = e57_path; + // transforms.json's OpenGL axes -> OpenCV's. + for (int r = 0; r < 3; r++) { + a.R[r * 3] = c.c2w[r * 4]; + a.R[r * 3 + 1] = -c.c2w[r * 4 + 1]; + a.R[r * 3 + 2] = -c.c2w[r * 4 + 2]; + } + a.centre = {c.c2w[3], c.c2w[7], c.c2w[11]}; + a.width = c.width; + a.height = c.height; + if (pin) { + a.camera_model = 1; // COLMAP PINHOLE + a.params = {c.fx, c.fy, c.cx, c.cy}; + focals.push_back(0.5 * (c.fx + c.fy)); + factors.push_back(0.5 * (c.fx + c.fy) / (double)c.width); + } else { + a.camera_model = 17; // EQUIRECTANGULAR, (w, h) + a.params = {(double)c.width, (double)c.height}; + } + anchors.push_back(a); + (pin ? got.pinhole : got.panorama)++; + } + write_anchors(anchors_path, anchors); + if (!focals.empty()) { + std::nth_element(focals.begin(), focals.begin() + focals.size() / 2, focals.end()); + got.focal = focals[focals.size() / 2]; + std::nth_element(factors.begin(), factors.begin() + factors.size() / 2, factors.end()); + got.focal_factor = factors[factors.size() / 2]; + } + return got; +} + +namespace { + +// Where to stand: the scanner's own stations, else a trajectory file beside the +// cloud thinned to one position every `step` metres. +std::vector view_positions(const std::vector& clouds, bool& tripod) { + std::vector out; + tripod = false; + for (const std::string& c : clouds) { + spirula::cloud::Reader r(c); + for (const auto& st : r.info().stations) out.push_back({st.origin[0], st.origin[1], st.origin[2]}); + } + if (!out.empty()) { tripod = true; return out; } + constexpr double kStep = 2.0; + for (const std::string& c : clouds) { + fs::path traj = fs::path(c); + traj.replace_extension(".trajectory" + fs::path(c).extension().string()); + std::error_code ec; + if (!fs::exists(traj, ec)) continue; + spirula::cloud::Reader r(traj.string()); + r.read([&](const spirula::e57::Point* p, size_t n) { + for (size_t i = 0; i < n; i++) { + const Vec3 x{p[i].xyz[0], p[i].xyz[1], p[i].xyz[2]}; + if (out.empty() || (x - out.back()).norm() >= kStep) out.push_back(x); + } + }); + } + return out; +} + +// The views of `clouds` together, named `/__` +// and, when they are one cloud's, saying so; -1 when cancelled. +int64_t render_views_of(const std::vector& clouds, + const std::string& prefix, const std::string& images_dir, + const std::string& subdir, std::vector& anchors, + const std::function& log, + const std::atomic* cancel) { + bool tripod = false; + const std::vector where = view_positions(clouds, tripod); + if (where.empty()) return 0; + std::vector xyz; + std::vector rgb; + int64_t total = 0; + for (const std::string& c : clouds) total += spirula::cloud::Reader(c).info().points; + const uint64_t below = draw_threshold(kDrawCap, total); + uint64_t serial = 0; + for (const std::string& c : clouds) { + log(format(lmsg::reading_cloud, {c})); + spirula::cloud::Reader r(c); + if (!r.read([&](const spirula::e57::Point* p, size_t n) { + for (size_t i = 0; i < n; i++) { + if (splitmix64(serial++) > below) continue; + xyz.insert(xyz.end(), p[i].xyz, p[i].xyz + 3); + rgb.insert(rgb.end(), p[i].rgb, p[i].rgb + 3); + } + }, cancel)) + return -1; + } + const ScanCloud cloud = build_scan_cloud(xyz, rgb, 2.0); + std::vector().swap(xyz); + std::vector().swap(rgb); + + // Level views all round, and a ring tilted up for the facades above a + // tripod; a walk's positions are many, so each gets fewer. + const int yaws = tripod ? 8 : 4; + const std::vector pitches = tripod ? std::vector{0.0, 30.0} + : std::vector{0.0}; + struct View { Vec3 c; Mat3 R; std::string name; }; + std::vector views; + for (size_t si = 0; si < where.size(); si++) + for (double pitch : pitches) + for (int yi = 0; yi < yaws; yi++) { + const double yaw = 2 * kPi * yi / yaws, pt = pitch * kPi / 180.0; + const Vec3 fw{std::cos(pt) * std::cos(yaw), std::cos(pt) * std::sin(yaw), std::sin(pt)}; + const Vec3 right = fw.cross({0, 0, 1}).normalized(); + const Vec3 down = fw.cross(right); + View v; + v.c = where[si]; + v.R = {right.x, down.x, fw.x, right.y, down.y, fw.y, right.z, down.z, fw.z}; + char name[64]; + std::snprintf(name, sizeof name, "%04zu_%02d_%03d.jpg", si, (int)pitch, + yi * 360 / yaws); + v.name = subdir + "/" + prefix + name; + views.push_back(v); + } + constexpr int kSide = 1024; + int64_t written = 0; + std::mutex mu; +#pragma omp parallel for schedule(dynamic, 1) + for (int k = 0; k < (int)views.size(); k++) { + if (cancel && cancel->load()) continue; + const View& v = views[(size_t)k]; + MapCamera m; + m.model = (int)CameraModelType::PINHOLE; + m.width = m.height = kSide; + m.fx = m.fy = kSide / 2.0; + m.cx = m.cy = kSide / 2.0; + for (int r = 0; r < 3; r++) { + for (int c = 0; c < 3; c++) m.c2w[r * 4 + c] = v.R[r * 3 + c]; + m.c2w[r * 4 + 3] = r == 0 ? v.c.x : r == 1 ? v.c.y : v.c.z; + } + std::vector px; + render_color(cloud, m, px); + // A view mostly of nothing (into the sky, or inside a wall) is no anchor. + int64_t lit = 0; + for (size_t j = 0; j < px.size(); j += 3) lit += px[j] | px[j + 1] | px[j + 2]; + if (lit < (int64_t)kSide * kSide / 4) continue; + const fs::path out = fs::path(images_dir) / v.name; + if (!stbi_write_jpg(out.string().c_str(), kSide, kSide, 3, px.data(), 95)) continue; + Anchor a; + a.name = v.name; + a.R = v.R; + a.centre = v.c; + a.scan = clouds.size() == 1 ? clouds[0] : ""; + a.rendered = true; + std::lock_guard lk(mu); + anchors.push_back(a); + written++; + } + return written; +} + +} // namespace + +int64_t render_anchor_views(const std::vector& clouds, bool each_scan, + const std::string& images_dir, const std::string& subdir, + const std::string& anchors_path, + const std::function& log, + const std::atomic* cancel) { + std::vector anchors; + std::error_code ec; + if (fs::exists(anchors_path, ec)) anchors = read_anchors(anchors_path); + const std::string prefix = subdir + "/"; + anchors.erase(std::remove_if(anchors.begin(), anchors.end(), + [&](const Anchor& a) { return a.name.rfind(prefix, 0) == 0; }), + anchors.end()); + fs::remove_all(fs::path(images_dir) / subdir, ec); + fs::create_directories(fs::path(images_dir) / subdir, ec); + int64_t written = 0; + if (each_scan) { + for (size_t k = 0; k < clouds.size(); k++) { + char head[32]; + std::snprintf(head, sizeof head, "%02zu_", k); + const int64_t n = render_views_of({clouds[k]}, head, images_dir, subdir, anchors, + log, cancel); + if (n < 0) return 0; + written += n; + } + } else { + written = render_views_of(clouds, "", images_dir, subdir, anchors, log, cancel); + if (written < 0) return 0; + } + std::sort(anchors.begin(), anchors.end(), + [](const Anchor& a, const Anchor& b) { return a.name < b.name; }); + write_anchors(anchors_path, anchors); + return written; +} + +bool scans_share_frame(const std::vector& clouds) { + std::vector> stations; + for (const std::string& c : clouds) stations.push_back(spirula::cloud::Reader(c).info().stations); + return scans_share_frame(stations); +} + +namespace { + +struct ModelFit { + bool ok = false; + sfm::Sim3 T; // model -> scan frame + AnchorFit anchors; // when `mode` used them + IcpStats icp; +}; + +// One model into the anchors' frame: by the anchors, then the surface (Auto); +// the surface alone from where it is (Refine); or as it is (Keep). `probe` is +// the scan the size comes from when the anchors stand in one place. +ModelFit fit_model(const ModelGeometry& g, const std::vector& anchors, + const AlignCloud& cloud, const ScanCloud& probe, AlignMode mode, + int64_t index, const std::function& log) { + ModelFit f; + if (mode == AlignMode::Refine) { + f.T = refine_icp(g, cloud, f.T, &f.icp); + if (f.icp.iterations > 0) + log(format(lmsg::model_icp, {fixed(f.icp.median_m, 3), + fixed(100.0 * f.icp.inlier_frac, 1)})); + f.ok = true; + return f; + } + if (mode == AlignMode::Keep) { + const std::vector d = scan_distances(g, cloud, f.T, 1.0); + std::vector v; + for (float x : d) if (x == x) v.push_back(x); + std::nth_element(v.begin(), v.begin() + v.size() / 2, v.end()); + log(format(lmsg::model_kept, {(long long)index, fixed(v.empty() ? 0.0 : v[v.size() / 2], 3)})); + f.ok = true; + return f; + } + f.anchors = fit_anchors(g, anchors); + if (!f.anchors.ok) { + log(format(lmsg::model_dropped, {(long long)index})); + return f; + } + // ICP from a guessed size of 1 lands anywhere, and nothing could tell. + if (!f.anchors.scale_known) { + int64_t n = 0; + const double s = scale_from_depth(g, anchors, f.anchors, probe, &n); + if (s > 0) { + f.anchors = fit_anchors(g, anchors, s); + log(format(lmsg::scale_from_depth, {fixed(s, 6), (long long)n})); + } + } + log(format(lmsg::model_anchors, {(long long)f.anchors.registered, (long long)f.anchors.inliers, + fixed(f.anchors.rot_err_deg, 2), + fixed(f.anchors.centre_err_m, 3)})); + f.T = f.anchors.T; + f.ok = true; + if (mode != AlignMode::Auto) return f; + const sfm::Sim3 Ti = refine_icp(g, cloud, f.T, &f.icp); + // The fit to the surface should keep the anchors where they were. + double spread = 0, drift = 0; + int n = 0; + for (const Anchor& a : anchors) { + const int v = find_view(g, a.name); + if (v < 0) continue; + const Vec3 c0 = sfm::transformPoint(f.T, g.views[(size_t)v].centre); + const Vec3 c1 = sfm::transformPoint(Ti, g.views[(size_t)v].centre); + drift += (c1 - c0).norm(); + n++; + for (const Anchor& b : anchors) spread = std::max(spread, (a.centre - b.centre).norm()); + } + drift = n ? drift / n : 0; + if (f.icp.iterations == 0) { + // Too few points to fit a surface to: the anchors' fit stands. + } else if (!(Ti.scale > 0) || + (f.anchors.scale_known && drift > std::max(0.1, kAnchorDrift * spread) && + drift > 5 * std::max(f.anchors.centre_err_m, 0.02))) { + log(format(lmsg::model_icp_rejected, {fixed(drift, 3)})); + } else { + f.T = Ti; + log(format(lmsg::model_icp, {fixed(f.icp.median_m, 3), + fixed(100.0 * f.icp.inlier_frac, 1)})); + } + return f; +} + +} // namespace + +// ================ +// The dataset +// ================ + +DatasetResult write_lidar_dataset(const DatasetOptions& opt, + const std::function& log, + const std::atomic* cancel) { + DatasetResult res; + auto cancelled = [&] { return cancel && cancel->load(); }; + const fs::path ds(opt.dataset); + std::error_code ec; + + const std::string anchors_path = + opt.anchors.empty() ? (ds / "lidar" / "anchors.json").string() : opt.anchors; + std::vector anchors; + if (fs::exists(anchors_path, ec)) anchors = read_anchors(anchors_path); + if (!anchors.empty()) log(format(lmsg::anchors_read, {(long long)anchors.size()})); + bool placeable = false; + for (const Anchor& a : anchors) placeable = placeable || (a.camera_model >= 0 && !a.rendered); + + // Nothing moves until the result is complete in sparse.lidar_tmp/: a run + // killed half way leaves the dataset as it was. sparse/0 is ours only + // while its images.bin is the one we wrote. + const fs::path sparse = ds / "sparse", kept = ds / kUnalignedDir; + const fs::path marker = sparse / "0" / kAlignedMarker; + JsonValue last; + if (fs::exists(marker, ec)) { + try { + last = json_parse_file(marker.string()); + } catch (const std::exception&) { + } + } + const JsonValue* last_hash = last.find("images_bin"); + const bool ours = fs::exists(marker, ec) && + (!last_hash || last_hash->as_string() == + hex(file_hash(sparse / "0" / "images.bin"))); + // An export's model written flat into sparse/ is read where it is and + // never moved; numbered models not yet aligned are set aside at the end. + const bool flat = fs::exists(sparse / "cameras.bin", ec); + std::vector fresh = numbered_models(sparse); + if (ours) fresh.erase(std::remove(fresh.begin(), fresh.end(), sparse / "0"), fresh.end()); + std::vector sources; + if (!fresh.empty()) sources = fresh; + else if (fs::exists(kept, ec)) sources = model_dirs(kept); + else if (flat) sources = {sparse}; + else if (!ours && fs::exists(sparse, ec) && holds_files(sparse)) + throw std::runtime_error(format(lmsg::err_unreadable_model, {sparse.string()})); + if (opt.scanner_poses_only) sources.clear(); + const std::string sig = signature(opt, anchors_path); + if (ours && fresh.empty() && !opt.overwrite) { + const JsonValue* last_sig = last.find("signature"); + if (last_sig && last_sig->as_string() == sig) { + log(format(lmsg::reused, {(sparse / "0").string()})); + res.images = (int64_t)read_model(sparse / "0").images.size(); + return res; + } + } + std::vector models; + for (const fs::path& d : sources) models.push_back(read_model(d)); + if (models.empty() && !placeable) + throw std::runtime_error(format(lmsg::err_no_model, {ds.string()})); + res.models = (int)models.size(); + std::vector geoms; + for (const ColModel& m : models) geoms.push_back(geometry_of(m)); + + // ---- which frame each cloud is in ---- + // An anchor that names no cloud of this run leaves no way to tell, and + // keeps them all in one. + const size_t n_clouds = opt.clouds.size(); + std::vector scan_of(anchors.size(), -1); + { + std::vector canon; + for (const std::string& c : opt.clouds) canon.push_back(fs::weakly_canonical(c, ec)); + for (size_t i = 0; i < anchors.size(); i++) { + if (anchors[i].scan.empty()) continue; + const fs::path a = fs::weakly_canonical(anchors[i].scan, ec); + for (size_t k = 0; k < n_clouds && scan_of[i] < 0; k++) + if (a == canon[k]) scan_of[i] = (int)k; + } + } + bool one_frame = n_clouds <= 1 || opt.frames == ScanFrameMode::Shared || + opt.mode == AlignMode::Keep || opt.mode == AlignMode::Refine || + std::find(scan_of.begin(), scan_of.end(), -1) != scan_of.end(); + if (!one_frame && opt.frames == ScanFrameMode::Auto) one_frame = scans_share_frame(opt.clouds); + ScanFrames groups; + groups.frame.assign(n_clouds, 0); + groups.count = 1; + std::vector> scan_placement(n_clouds, sfm::Sim3{}); + if (!one_frame) { + groups = group_scan_frames(geoms, anchors, scan_of, (int)n_clouds); + if (groups.count == 1) log(lmsg::frames_agree.get()); + } + + // ---- one pass over the clouds ---- + int64_t total = 0; + bool any_color = false; + std::vector> readers; + for (size_t k = 0; k < n_clouds; k++) { + readers.push_back(groups.frame[k] < 0 + ? nullptr + : std::make_unique(opt.clouds[k])); + if (!readers.back()) continue; + total += readers.back()->info().points; + any_color = any_color || readers.back()->info().has_color; + } + const uint64_t draw_below = draw_threshold(kDrawCap, total); + std::vector draw_xyz, all_xyz; + std::vector draw_rgb, all_rgb; + // Where each cloud's points end in the draw (and in all_xyz). + std::vector draw_end(n_clouds, 0), all_end(n_clouds, 0); + uint64_t serial = 0; + for (size_t k = 0; k < readers.size(); k++) { + if (readers[k]) { + log(format(lmsg::reading_cloud, {opt.clouds[k]})); + const bool whole = readers[k]->read([&](const spirula::e57::Point* p, size_t n) { + for (size_t i = 0; i < n; i++) { + if (opt.all_points) { + all_xyz.insert(all_xyz.end(), p[i].xyz, p[i].xyz + 3); + all_rgb.insert(all_rgb.end(), p[i].rgb, p[i].rgb + 3); + } + if (splitmix64(serial++) > draw_below) continue; + draw_xyz.insert(draw_xyz.end(), p[i].xyz, p[i].xyz + 3); + draw_rgb.insert(draw_rgb.end(), p[i].rgb, p[i].rgb + 3); + } + }, cancel); + if (!whole) { res.cancelled = true; return res; } + } + draw_end[k] = draw_xyz.size() / 3; + all_end[k] = all_xyz.size() / 3; + } + readers.clear(); + + // Random subsets of the draw (of the clouds `only` marks): every k-th by + // a hash, so they are repeatable. + auto subset = [&](int64_t want, std::vector& xyz, std::vector& rgb, + uint64_t salt, const std::vector* only = nullptr) { + int64_t n = 0; + for (size_t k = 0, begin = 0; k < n_clouds; begin = draw_end[k++]) + if (!only || (*only)[k]) n += (int64_t)(draw_end[k] - begin); + const uint64_t below = draw_threshold(want, n); + for (size_t k = 0, begin = 0; k < n_clouds; begin = draw_end[k++]) { + if (only && !(*only)[k]) continue; + for (size_t i = begin; i < draw_end[k]; i++) { + if (splitmix64(i ^ salt) > below) continue; + xyz.insert(xyz.end(), &draw_xyz[i * 3], &draw_xyz[i * 3] + 3); + rgb.insert(rgb.end(), &draw_rgb[i * 3], &draw_rgb[i * 3] + 3); + } + } + }; + + // ---- every frame moved into the first's ---- + // The models fitted to frame 0 alone place each other frame, rigidly, + // through its anchors in them; a frame none of them sees is left out. + if (groups.count > 1) { + std::vector first(n_clouds, 0); + for (size_t k = 0; k < n_clouds; k++) first[k] = groups.frame[k] == 0; + std::vector f_xyz; + std::vector f_rgb; + subset(kAlignDraw, f_xyz, f_rgb, 0x51ED270B, &first); + const ScanCloud probe0 = build_scan_cloud(f_xyz, f_rgb, 2.0); + const AlignCloud cloud0 = make_align_cloud(f_xyz, f_rgb, kAlignTarget); + std::vector anchors0; + for (size_t i = 0; i < anchors.size(); i++) + if (groups.frame[(size_t)scan_of[i]] == 0) anchors0.push_back(anchors[i]); + std::vector> fits(models.size()); + const auto quiet = [](const std::string&) {}; + for (size_t mi = 0; mi < models.size(); mi++) { + if (cancelled()) { res.cancelled = true; return res; } + const ModelFit f = + fit_model(geoms[mi], anchors0, cloud0, probe0, opt.mode, (int64_t)mi, quiet); + if (f.ok) fits[mi] = f.T; + } + const std::vector placed = + place_frames(geoms, fits, anchors, scan_of, groups); + auto kept = [&](int f) { return f >= 0 && placed[(size_t)f].placed; }; + for (size_t k = 0; k < n_clouds; k++) { + const int f = groups.frame[k]; + const std::string name = fs::path(opt.clouds[k]).filename().string(); + if (f == 0) { + log(format(lmsg::frame_reference, {name})); + } else if (!kept(f)) { + log(format(lmsg::frame_left_out, {name})); + } else { + const sfm::Sim3& T = placed[(size_t)f].T; + const double ang = std::acos(std::clamp((T.R[0] + T.R[4] + T.R[8] - 1.0) / 2.0, -1.0, 1.0)); + log(format(lmsg::frame_placed, {name, (long long)placed[(size_t)f].anchors, + fixed(ang * 180.0 / kPi, 2), fixed(T.t.norm(), 3)})); + } + } + // In place: a cloud's points only ever move down the arrays. + auto move = [&](std::vector& xyz, std::vector& rgb, + std::vector& end) { + size_t w = 0; + for (size_t k = 0, begin = 0; k < n_clouds; k++) { + const int f = groups.frame[k]; + const size_t stop = end[k]; + for (size_t i = begin; i < stop && kept(f); i++, w++) { + const Vec3 x = sfm::transformPoint(placed[(size_t)f].T, + {xyz[i * 3], xyz[i * 3 + 1], xyz[i * 3 + 2]}); + xyz[w * 3] = x.x; xyz[w * 3 + 1] = x.y; xyz[w * 3 + 2] = x.z; + for (int c = 0; c < 3; c++) rgb[w * 3 + c] = rgb[i * 3 + c]; + } + begin = stop; + end[k] = w; + } + xyz.resize(w * 3); + rgb.resize(w * 3); + }; + move(draw_xyz, draw_rgb, draw_end); + if (opt.all_points) move(all_xyz, all_rgb, all_end); + std::vector moved; + for (size_t i = 0; i < anchors.size(); i++) { + const int f = groups.frame[(size_t)scan_of[i]]; + if (!kept(f)) continue; + Anchor a = anchors[i]; + const sfm::Sim3& T = placed[(size_t)f].T; + a.R = sfm::mul(T.R, a.R); + a.centre = sfm::transformPoint(T, a.centre); + moved.push_back(a); + } + anchors.swap(moved); + scan_placement.assign(n_clouds, std::nullopt); + for (size_t k = 0; k < n_clouds; k++) + if (kept(groups.frame[k])) scan_placement[k] = placed[(size_t)groups.frame[k]].T; + } + if (draw_xyz.empty()) throw std::runtime_error(lmsg::err_no_points.get()); + + std::vector a_xyz; + std::vector a_rgb; + subset(kAlignDraw, a_xyz, a_rgb, 0x51ED270B); + const ScanCloud probe = build_scan_cloud(a_xyz, a_rgb, 2.0); + const AlignCloud cloud = make_align_cloud(a_xyz, a_rgb, kAlignTarget); + log(format(lmsg::cloud_read, {(long long)serial, (long long)cloud.size(), + fixed(cloud.voxel, 3)})); + + // ---- align each model ---- + std::vector aligned; + std::vector transforms(models.size()); + double worst_centre = 0, worst_rot = 0; + double best_residual = 0; + size_t largest = 0; + for (size_t mi = 0; mi < models.size(); mi++) { + if (cancelled()) { res.cancelled = true; return res; } + const ModelGeometry& g = geoms[mi]; + if (g.views.empty()) continue; + log(format(lmsg::model_head, {(long long)mi, (long long)g.views.size(), + (long long)g.points.size()})); + const ModelFit f = fit_model(g, anchors, cloud, probe, opt.mode, (int64_t)mi, log); + if (!f.ok) continue; + if (f.anchors.ok) { + worst_centre = std::max(worst_centre, f.anchors.centre_err_m); + worst_rot = std::max(worst_rot, f.anchors.rot_err_deg); + } + const sfm::Sim3& T = f.T; + const double ang = std::acos(std::clamp((T.R[0] + T.R[4] + T.R[8] - 1.0) / 2.0, -1.0, 1.0)); + log(format(lmsg::model_transform, {fixed(T.scale, 6), fixed(ang * 180.0 / kPi, 3), + fixed(T.t.x, 3), fixed(T.t.y, 3), fixed(T.t.z, 3)})); + transforms[mi] = T; + if (aligned.empty() || g.views.size() > models[largest].images.size()) { + largest = mi; + best_residual = f.icp.median_m; + } + aligned.push_back((int)mi); + } + geoms.clear(); + res.aligned = (int)aligned.size(); + if (aligned.empty() && !placeable) throw std::runtime_error(lmsg::err_none_aligned.get()); + + // ---- one model in the scan's frame ---- + ColModel out; + uint32_t next_cam = 1, next_img = 1; + uint64_t next_pt = 1; + std::set merged_names; + for (int mi : aligned) { + ColModel& m = models[(size_t)mi]; + // Models may share images; the larger one, earlier, keeps them. + std::set dup; + for (const auto& [id, im] : m.images) + if (merged_names.count(stem_of(im.name))) dup.insert(stem_of(im.name)); + remove_images(m, dup); + for (const auto& [id, im] : m.images) merged_names.insert(stem_of(im.name)); + transform_model(m, transforms[(size_t)mi]); + std::map cam_id, img_id; + for (auto& [id, c] : m.cameras) { cam_id[id] = next_cam; out.cameras[next_cam++] = c; } + for (auto& [id, im] : m.images) img_id[id] = next_img++; + std::map pt_id; + for (auto& [id, p] : m.points) pt_id[id] = next_pt++; + for (auto& [id, im] : m.images) { + ColImage n = im; + n.camera = cam_id[im.camera]; + for (uint64_t& p : n.point) + if (p != ~0ull) { + auto it = pt_id.find(p); + p = it == pt_id.end() ? ~0ull : it->second; + } + out.images[img_id[id]] = std::move(n); + } + for (auto& [id, p] : m.points) { + ColPoint n = p; + for (size_t k = 0; k < n.track.size(); k += 2) n.track[k] = img_id[n.track[k]]; + out.points[pt_id[id]] = std::move(n); + } + } + models.clear(); + // The scan's own renders anchored the fit; they are not photographs. + std::set rendered; + for (const Anchor& a : anchors) + if (a.rendered) rendered.insert(stem_of(a.name)); + remove_images(out, rendered); + // Photographs the reconstruction did not place go where the scanner says, + // unless the anchors disagree with the fit (Oxford: faces of one station + // 0.5 m apart in the SfM) and they would not line up with it. + const fs::path image_root = fs::path(opt.image_dir).is_absolute() + ? fs::path(opt.image_dir) : ds / opt.image_dir; + { + std::set have; + for (const auto& [id, im] : out.images) have.insert(stem_of(im.name)); + std::map, uint32_t> cams; + int64_t placed = 0, skipped = 0; + for (const Anchor& a : anchors) { + if (a.rendered || a.camera_model < 0 || have.count(stem_of(a.name))) continue; + if (!aligned.empty() && (worst_centre > kPlaceCentre || worst_rot > kPlaceRot)) { + skipped++; + continue; + } + // The import may have re-encoded it (a PNG becomes a JPEG). + const std::string file = resolve_image(image_root, a.name); + if (file.empty()) { + skipped++; + continue; + } + std::vector key = a.params; + key.push_back(a.camera_model); + key.push_back((double)a.width); + key.push_back((double)a.height); + auto it = cams.find(key); + if (it == cams.end()) { + ColCamera c; + c.model = a.camera_model; + c.width = (uint64_t)a.width; + c.height = (uint64_t)a.height; + c.params = a.params; + out.cameras[next_cam] = c; + it = cams.emplace(key, next_cam++).first; + } + ColImage im; + im.camera = it->second; + im.name = file; + const Mat3 Rw2c = sfm::transpose(a.R); + R_to_quat(Rw2c, im.q); + const Vec3 t = sfm::mul(Rw2c, a.centre) * -1.0; + im.t[0] = t.x; im.t[1] = t.y; im.t[2] = t.z; + out.images[next_img++] = std::move(im); + placed++; + } + if (placed) log(format(lmsg::placed_by_scanner, {(long long)placed})); + if (skipped) + log(format(lmsg::not_placed, {(long long)skipped, fixed(worst_centre, 3)})); + } + res.images = (int64_t)out.images.size(); + log(format(lmsg::aligned_summary, {(long long)res.aligned, (long long)res.models, + (long long)res.images})); + res.residual_m = best_residual; + const fs::path tmp = ds / "sparse.lidar_tmp"; + const fs::path model_dir = tmp / "0"; + fs::remove_all(tmp, ec); + write_model(out, model_dir); + + // ---- the cameras as the trainer reads them ---- + DatasetParserConfig pc; + pc.recon_dir = "sparse.lidar_tmp/0"; + pc.image_dir = opt.image_dir; + pc.mask_dir = opt.mask_dir; + pc.probe_image_size = probe_image_size; + const ParsedDataset pd = parse_colmap_dataset(ds.string(), pc); + const fs::path image_canon = fs::weakly_canonical(image_root, ec); + std::map by_stem; + for (const auto& [id, im] : out.images) by_stem[stem_of(im.name)] = id; + struct Frame { + uint32_t image; + size_t parsed; + std::string rel; + }; + std::vector frames; + for (size_t i = 0; i < pd.image_filenames.size(); i++) { + std::string rel = fs::weakly_canonical(pd.image_filenames[i], ec) + .lexically_relative(image_canon).generic_string(); + auto it = by_stem.find(stem_of(rel)); + if (it != by_stem.end()) frames.push_back({it->second, i, rel}); + } + int64_t n_ray = 0; + for (const Frame& f : frames) + n_ray += camhost::splits_to_pinhole_faces(pd.camera_models[f.parsed], pd.widths[f.parsed], + pd.heights[f.parsed], pd.intrins[f.parsed * 4], + pd.intrins[f.parsed * 4 + 1]); + const bool ray = n_ray * 2 > (int64_t)frames.size(); + + // ---- the seed points ---- + std::vector seed_xyz; + std::vector seed_rgb; + double voxel = 0; + if (opt.all_points) { + seed_xyz.swap(all_xyz); + seed_rgb.swap(all_rgb); + } else if (opt.seed_points > 0) { + subset(std::clamp(8 * opt.seed_points, 8000000, kDrawCap), seed_xyz, seed_rgb, + 0x5EED5EED); + voxel = thin_to_voxels(seed_xyz, seed_rgb, opt.seed_points); + } + const size_t n_seed = seed_xyz.size() / 3; + const ScanCloud depth_cloud = build_scan_cloud(draw_xyz, draw_rgb, 2.0); + std::vector().swap(draw_xyz); + std::vector().swap(draw_rgb); + if (!any_color && n_seed) log(lmsg::colorizing.get()); + + // ---- per image: depth and normals, who sees which scan point, and + // whether the scan has anything where the model's own points are ---- + // Every point of a big scan with full tracks would not fit in memory. + const int cap = (int)std::clamp( + std::min(opt.track_cap, (int64_t)(2e9 / 16) / std::max(1, (int64_t)n_seed)), + 1, 64); + std::vector seen(n_seed * (size_t)cap, Seen{UINT32_MAX, 0, 0, 0}); + std::vector seen_n(n_seed, 0); + std::vector color_sum(any_color ? 0 : n_seed * 4, 0); + constexpr size_t kStripes = 4096; + std::vector stripes(kStripes); + std::map> gap_votes; // point -> (no scan, scan) + std::set off_scan; // images their points disown + std::mutex mu; + std::exception_ptr failure; + int64_t done = 0; + if (opt.depth_maps) { + fs::create_directories(ds / "depths", ec); + fs::create_directories(ds / "normals", ec); + } + const int nf = (int)frames.size(); +#pragma omp parallel for schedule(dynamic, 1) + for (int fi = 0; fi < nf; fi++) { + if (cancelled()) continue; + { + std::lock_guard lk(mu); + if (failure) continue; + } + try { + const Frame& f = frames[(size_t)fi]; + const ColImage& im = out.images.at(f.image); + const size_t pi = f.parsed; + const int W = pd.widths[pi], H = pd.heights[pi]; + const double scale = std::min(1.0, (double)kMapSide / (double)std::max(W, H)); + MapCamera m; + m.model = pd.camera_models[pi]; + m.tier = pd.camera_distortions[pi]; + for (int k = 0; k < 8; k++) m.dist[k] = pd.dist_coeffs[pi * 8 + k]; + m.width = std::max(1, (int)std::lround(W * scale)); + m.height = std::max(1, (int)std::lround(H * scale)); + const double sx = (double)m.width / W, sy = (double)m.height / H; + m.fx = pd.intrins[pi * 4] * sx; + m.fy = pd.intrins[pi * 4 + 1] * sy; + m.cx = pd.intrins[pi * 4 + 2] * sx; + m.cy = pd.intrins[pi * 4 + 3] * sy; + const Mat3 Rw2c = quat_to_R(im.q); + const Mat3 Rc2w = sfm::transpose(Rw2c); + const Vec3 C = sfm::mul(Rc2w, Vec3{im.t[0], im.t[1], im.t[2]}) * -1.0; + for (int r = 0; r < 3; r++) { + for (int c = 0; c < 3; c++) m.c2w[r * 4 + c] = Rc2w[r * 3 + c]; + m.c2w[r * 4 + 3] = r == 0 ? C.x : r == 1 ? C.y : C.z; + } + std::vector depth, normal; + render_depth_normal(depth_cloud, m, ray, depth, normal); + // What the mask takes out (a passer-by in front of a wall) the scan + // cannot speak for: blank in the maps and seen by nothing, to the + // edge of every map pixel it touches. + std::vector keep, hidden; + int mw = 0, mh = 0; + if (!pd.mask_filenames.empty() && !pd.mask_filenames[pi].empty()) { + int mc = 0; + if (unsigned char* mk = stbi_load(pd.mask_filenames[pi].c_str(), &mw, &mh, &mc, 1)) { + keep.resize((size_t)mw * mh); + for (size_t j = 0; j < keep.size(); j++) keep[j] = (mk[j] >= 128) != opt.flip_masks; + stbi_image_free(mk); + } + } + if (!keep.empty()) { + hidden.assign(depth.size(), 0); + const double kx = (double)m.width / mw, ky = (double)m.height / mh; + for (int y = 0; y < mh; y++) + for (int x = 0; x < mw; x++) { + if (keep[(size_t)y * mw + x]) continue; + const int x1 = std::min(m.width, std::max((int)(x * kx) + 1, (int)std::ceil((x + 1) * kx))); + const int y1 = std::min(m.height, std::max((int)(y * ky) + 1, (int)std::ceil((y + 1) * ky))); + for (int v = (int)(y * ky); v < y1; v++) + for (int u = (int)(x * kx); u < x1; u++) hidden[(size_t)v * m.width + u] = 1; + } + for (size_t j = 0; j < hidden.size(); j++) { + if (!hidden[j]) continue; + depth[j] = 0; + normal[j * 3] = normal[j * 3 + 1] = normal[j * 3 + 2] = 0; + } + } + auto depth_at = [&](double px, double py) { + const int x = (int)px, y = (int)py; + if (x < 0 || y < 0 || x >= m.width || y >= m.height) return -1.0f; + const size_t j = (size_t)y * m.width + x; + return !hidden.empty() && hidden[j] ? -1.0f : depth[j]; + }; + // The model's points this image sees: does the scan have a surface + // there, and are they on it? Mostly off it, the image is not where + // the model put it, and it gives the dataset nothing. + std::vector> votes; + int64_t measured = 0, on = 0; + for (size_t k = 0; k < im.point.size(); k++) { + if (im.point[k] == ~0ull) continue; + const float d = depth_at(im.xy[k * 2] * sx, im.xy[k * 2 + 1] * sy); + if (d < 0) continue; + votes.push_back({im.point[k], d > 0}); + if (!(d > 0)) continue; + const double* x = out.points.at(im.point[k]).xyz; + const Vec3 p = sfm::mul(Rw2c, Vec3{x[0], x[1], x[2]} - C); + measured++; + on += std::fabs((ray ? p.norm() : p.z) - d) <= kOnRatio * d + kOnSlack; + } + if (measured >= kOnMinPoints && 2 * on < measured) { + std::lock_guard lk(mu); + off_scan.insert(f.image); + log(format(lmsg::progress_maps, {(long long)++done, (long long)nf})); + continue; + } + const std::string stem = stem_of(f.rel); + if (opt.depth_maps) { + std::vector mm(depth.size(), 0); + for (size_t j = 0; j < depth.size(); j++) { + const double v = depth[j] * 1000.0; + if (depth[j] > 0 && v <= 65535.0) + mm[j] = (uint16_t)std::max(1, std::lround(v)); + } + const fs::path dp = ds / "depths" / (stem + ".png"); + const fs::path np = ds / "normals" / (stem + ".png"); + fs::create_directories(dp.parent_path()); + fs::create_directories(np.parent_path()); + if (!save_depth_png16(dp.string(), mm.data(), m.width, m.height)) + throw std::runtime_error("cannot write " + dp.string()); + write_normal_png(np, normal, m.width, m.height); + } + // The scan points this image sees. + unsigned char* rgb = nullptr; + int iw = 0, ih = 0, ic = 0; + if (!any_color && n_seed) rgb = stbi_load(pd.image_filenames[pi].c_str(), &iw, &ih, &ic, 3); + const Vec3 o{depth_cloud.origin[0], depth_cloud.origin[1], depth_cloud.origin[2]}; + for (size_t s = 0; s < n_seed; s++) { + const Vec3 X{seed_xyz[s * 3], seed_xyz[s * 3 + 1], seed_xyz[s * 3 + 2]}; + const Vec3 p = sfm::mul(Rw2c, X - C); + const double range = p.norm(); + if (m.model == (int)CameraModelType::PINHOLE && p.z < 0.01) continue; + const double pr[3] = {p.x, p.y, p.z}; + double uv[2]; + if (!camhost::project_ray(pr, m.model, m.tier, m.dist, uv)) continue; + const double px = m.fx * uv[0] + m.cx, py = m.fy * uv[1] + m.cy; + const float d = depth_at(px, py); + if (!(d > 0)) continue; + const double want = ray ? range : p.z; + if (std::fabs(want - d) > kSeenRatio * d + kSeenSlack) continue; + const float ox = (float)(px / sx), oy = (float)(py / sy); + const uint32_t h = (uint32_t)splitmix64(((uint64_t)s << 24) ^ f.image); + std::lock_guard lk(stripes[s % kStripes]); + Seen* slot = &seen[s * (size_t)cap]; + uint8_t& cnt = seen_n[s]; + if (cnt < 255) cnt++; + int worst = 0; + for (int k = 1; k < cap; k++) + if (slot[k].hash > slot[worst].hash) worst = k; + if (h < slot[worst].hash) slot[worst] = Seen{h, f.image, ox, oy}; + if (rgb) { + const int x = std::clamp((int)ox, 0, iw - 1), y = std::clamp((int)oy, 0, ih - 1); + if (keep.empty() || keep[(size_t)std::min(mh - 1, y * mh / ih) * mw + + std::min(mw - 1, x * mw / iw)]) { + const unsigned char* c = rgb + ((size_t)y * iw + x) * 3; + uint32_t* acc = &color_sum[s * 4]; + acc[0] += c[0]; acc[1] += c[1]; acc[2] += c[2]; acc[3]++; + } + } + } + if (rgb) stbi_image_free(rgb); + (void)o; + std::lock_guard lk(mu); + for (auto& [id, has] : votes) (has ? gap_votes[id].second : gap_votes[id].first)++; + res.depth_maps += opt.depth_maps; + log(format(lmsg::progress_maps, {(long long)++done, (long long)nf})); + } catch (...) { + std::lock_guard lk(mu); + if (!failure) failure = std::current_exception(); + } + } + if (failure) std::rethrow_exception(failure); + if (cancelled()) { res.cancelled = true; return res; } + if (!off_scan.empty()) { + std::set stems; + for (uint32_t id : off_scan) stems.insert(stem_of(out.images.at(id).name)); + remove_images(out, stems); + res.images = (int64_t)out.images.size(); + log(format(lmsg::images_off_scan, {(long long)off_scan.size()})); + } + + // ---- the final model: the reconstruction's points where the scan has + // none, then the scan's points with their tracks ---- + if (n_seed > 0 || !opt.sfm_points_in_gaps) { + for (auto it = out.points.begin(); it != out.points.end();) { + const auto v = gap_votes.find(it->first); + const bool gap = v != gap_votes.end() && v->second.first > v->second.second; + if (opt.sfm_points_in_gaps && gap) { ++it; continue; } + for (size_t k = 0; k < it->second.track.size(); k += 2) { + auto im = out.images.find(it->second.track[k]); + if (im != out.images.end() && it->second.track[k + 1] < im->second.point.size()) + im->second.point[it->second.track[k + 1]] = ~0ull; + } + it = out.points.erase(it); + } + } + res.sfm_points = (int64_t)out.points.size(); + int64_t observed = 0, observations = 0; + for (size_t s = 0; s < n_seed; s++) { + ColPoint p; + for (int a = 0; a < 3; a++) p.xyz[a] = seed_xyz[s * 3 + a]; + if (any_color) { + for (int a = 0; a < 3; a++) p.rgb[a] = seed_rgb[s * 3 + a]; + } else { + const uint32_t* acc = &color_sum[s * 4]; + for (int a = 0; a < 3; a++) + p.rgb[a] = acc[3] ? (uint8_t)(acc[a] / acc[3]) : seed_rgb[s * 3 + a]; + } + const uint64_t id = next_pt++; + const Seen* slot = &seen[s * (size_t)cap]; + for (int k = 0; k < cap; k++) { + if (slot[k].hash == UINT32_MAX || off_scan.count(slot[k].image)) continue; + ColImage& im = out.images[slot[k].image]; + p.track.push_back(slot[k].image); + p.track.push_back((uint32_t)im.point.size()); + im.xy.push_back(slot[k].x); + im.xy.push_back(slot[k].y); + im.point.push_back(id); + observations++; + } + observed += !p.track.empty(); + out.points[id] = std::move(p); + } + res.seed_points = (int64_t)n_seed; + log(format(lmsg::seed_summary, {(long long)n_seed, fixed(voxel, 3), (long long)res.sfm_points})); + if (n_seed) + log(format(lmsg::tracks_summary, {fixed(100.0 * observed / (double)n_seed, 1), + (long long)observations})); + write_model(out, model_dir); + + { + std::ofstream f(model_dir / "gauge.txt", std::ios::trunc); + f << "# The frame of the laser scan this model was aligned to.\n" + "oriented 1\nmetric 1\nup scan\nscale scan\n"; + } + { + JsonWriter w; + w.object(); + w.key("clouds").array(); + for (const std::string& c : opt.clouds) w.value(c); + w.end(); + w.field("mode", opt.mode == AlignMode::Keep ? "keep" + : opt.mode == AlignMode::Refine ? "refine" + : opt.mode == AlignMode::Anchors ? "anchors" : "auto"); + w.key("scans").array(); + for (size_t k = 0; k < n_clouds; k++) { + w.object(); + w.field("cloud", opt.clouds[k]); + w.field("placed", scan_placement[k].has_value()); + if (const std::optional& T = scan_placement[k]) { + std::string r = "["; + for (int j = 0; j < 9; j++) r += (j ? ", " : "") + json_number_exact(T->R[j]); + w.field_raw("rotation", r + "]"); + w.field_raw("translation", "[" + json_number_exact(T->t.x) + ", " + + json_number_exact(T->t.y) + ", " + + json_number_exact(T->t.z) + "]"); + } + w.end(); + } + w.end(); + w.key("models").array(); + for (int mi : aligned) { + const sfm::Sim3& T = transforms[(size_t)mi]; + w.object(); + w.field("index", mi); + w.field_raw("scale", json_number_exact(T.scale)); + std::string r = "["; + for (int k = 0; k < 9; k++) r += (k ? ", " : "") + json_number_exact(T.R[k]); + w.field_raw("rotation", r + "]"); + w.field_raw("translation", "[" + json_number_exact(T.t.x) + ", " + + json_number_exact(T.t.y) + ", " + + json_number_exact(T.t.z) + "]"); + w.end(); + } + w.end(); + w.field("residual_m", res.residual_m); + w.field("signature", sig); + w.field("images_bin", hex(file_hash(model_dir / "images.bin"))); + w.end(); + std::ofstream f(model_dir / kAlignedMarker, std::ios::trunc); + f << w.str(); + } + // The result is complete: set the reconstruction it came from aside (never + // an export's flat files), then put it in place. + if (!fresh.empty()) { + fs::remove_all(kept, ec); + fs::create_directories(kept, ec); + for (const fs::path& d : fresh) { + fs::rename(d, kept / d.filename(), ec); + if (ec) throw std::runtime_error("cannot move " + d.string() + ": " + ec.message()); + } + } else if (ours) { + fs::remove_all(sparse / "0", ec); + } + fs::create_directories(sparse, ec); + fs::rename(model_dir, sparse / "0", ec); + if (ec) throw std::runtime_error("cannot write " + (sparse / "0").string() + ": " + ec.message()); + fs::remove_all(tmp, ec); + log(format(lmsg::done, {ds.string(), (long long)res.images, + (long long)out.points.size()})); + return res; +} + +} // namespace app::lidar diff --git a/src/app/LidarDataset.h b/src/app/LidarDataset.h new file mode 100644 index 000000000..ae9853507 --- /dev/null +++ b/src/app/LidarDataset.h @@ -0,0 +1,89 @@ +#pragma once + +// A reconstruction moved into a laser scan's frame and given the scan's +// geometry: every model of the run aligned (LidarAlign.h) and merged into one +// COLMAP model at sparse/0, the scan's points as its seed points -- each with +// the images that see it as its track, so partitioning works on them -- and +// depth and normal maps rendered from the scan. docs/notes/lidar-alignment.md. + +#include "app/LidarAlign.h" + +#include +#include +#include +#include +#include + +namespace app::lidar { + +enum class AlignMode { + Auto, // anchors, then ICP + Anchors, // the anchors' fit alone + Keep, // the model is already in the scan's frame (an XGRIDS export) + Refine, // close to it already: ICP from where it is +}; + +// Whether the clouds share one frame: by their files' word +// (scans_share_frame), or as the caller knows. +enum class ScanFrameMode { Auto, Shared, Separate }; + +struct DatasetOptions { + std::string dataset; // the reconstruction, and where maps go + std::string image_dir = "images"; // relative to `dataset`, or absolute + std::string mask_dir = "masks"; // the same; the maps are blank where they mask + std::vector clouds; // .e57 / .las / .ply + ScanFrameMode frames = ScanFrameMode::Auto; + std::string anchors; // "" = /lidar/anchors.json, if there + AlignMode mode = AlignMode::Auto; + int64_t seed_points = 500000; // 0 keeps the model's own points only + bool all_points = false; + bool depth_maps = true; + int track_cap = 12; // images listed per scan point + bool sfm_points_in_gaps = true; // keep model points where the scan has none + bool flip_masks = false; // the dataset's masks paint what to remove + bool overwrite = false; // redo a result whose inputs have not changed + bool scanner_poses_only = false; // the reconstruction failed: use none of it +}; + +struct DatasetResult { + int models = 0, aligned = 0; + int64_t images = 0, seed_points = 0, sfm_points = 0, depth_maps = 0; + double residual_m = 0; // median point-to-plane, largest model + bool cancelled = false; +}; + +// In place: the reconstruction it starts from is kept at sparse_unaligned/, +// which a second run reads again; nothing changes until the result is ready. +// Throws on what it cannot read. +DatasetResult write_lidar_dataset(const DatasetOptions& opt, + const std::function& log, + const std::atomic* cancel = nullptr); + +// The images of an E57 file as anchors: written under `images_dir`/`subdir` +// (in pinhole/ and panorama/ below it when it has both), their poses added to +// `anchors_path`. +struct ExtractedPhotos { + int64_t pinhole = 0, panorama = 0; + double focal = 0; // the pinholes' median, pixels + double focal_factor = 0; // ... over their width + bool split = false; +}; +ExtractedPhotos extract_e57_anchors(const std::string& e57_path, const std::string& images_dir, + const std::string& subdir, const std::string& anchors_path); + +// Views rendered from where the scanner stood (E57 scan poses, a PLY `camera`, +// a `.trajectory.las` beside it), recorded as anchors; `each_scan` renders +// each cloud alone, in its own frame. Returns how many. +int64_t render_anchor_views(const std::vector& clouds, bool each_scan, + const std::string& images_dir, const std::string& subdir, + const std::string& anchors_path, + const std::function& log, + const std::atomic* cancel = nullptr); + +// scans_share_frame() of the files' own stations. +bool scans_share_frame(const std::vector& clouds); + +std::vector read_anchors(const std::string& path); +void write_anchors(const std::string& path, const std::vector& anchors); + +} // namespace app::lidar diff --git a/src/app/Main.cpp b/src/app/Main.cpp index bf7abe5e2..2644ebe1b 100644 --- a/src/app/Main.cpp +++ b/src/app/Main.cpp @@ -1,12 +1,6 @@ -// The one entry point -- see app/Tools.h for why there is only one. -// -// spirula the window -// spirula the window, opening what was named -// spirula sfm auto ... structure from motion -// spirula train ... the trainer -// spirula sam segment ... segmentation -// spirula geometry ... depth and normals for a dataset -// spirula mesh ... mesh extraction +// The one entry point -- see app/Tools.h for why there is only one. The +// subcommands are the tools() table below; `spirula` alone is the window, and +// `spirula ` is the window opening what was named. // // A first argument that is not a subcommand goes to the GUI untouched, so // "Open with" from a file manager and a shell alias both land on the right @@ -84,6 +78,10 @@ const std::vector& tools() { #ifdef SS_TOOL_MESH {app::kToolMesh, &cmsg::tool_mesh, spirula_mesh_main}, #endif +#ifdef SS_TOOL_E57 + {app::kToolE57, &cmsg::tool_e57, spirula_e57_main}, + {app::kToolLidar, &cmsg::tool_lidar, spirula_lidar_main}, +#endif #ifdef SS_TOOL_ENCODE {app::kToolEncode, &cmsg::tool_encode, spirula_encode_main}, #endif diff --git a/src/app/ScanDepth.cpp b/src/app/ScanDepth.cpp new file mode 100644 index 000000000..d51a96b82 --- /dev/null +++ b/src/app/ScanDepth.cpp @@ -0,0 +1,328 @@ +#include "app/ScanDepth.h" + +#include "core/CameraModel.h" +#include "data/CameraMath.h" + +#include +#include +#include +#include + +namespace app { +namespace { + +constexpr float kNone = std::numeric_limits::infinity(); + +// A far point seen through a gap in a nearer surface is further than its +// window's nearest by more than this; a floor at grazing incidence 30 m out +// changes by ~3% across the window of a 1600 px map. +constexpr float kBleedRatio = 1.05f; +constexpr float kBleedSlack = 0.01f; // metres +// A hole is filled only from neighbours on one surface, and only from four or +// more of the eight: a straight edge has three, so a silhouette does not creep +// into the sky behind it. Four passes close the holes a sparse surface leaves. +constexpr float kFillSpread = 1.05f; +constexpr int kFillNeighbours = 4; +constexpr int kFillPasses = 4; +// A normal is taken only where its stencil stays on one surface. +constexpr float kNormalSpread = 0.1f; +// A point's footprint, in pixels either side, is at most this: past it a +// silhouette grows into the background by more than it hides. +constexpr int kMaxFootprint = 3; +// Pixels between a cell's neighbouring points below which it is thinned. +constexpr double kLodSpacing = 0.7; + +} // namespace + + +ScanCloud build_scan_cloud(const std::vector& xyz, const std::vector& rgb, + double cell_edge) { + ScanCloud c; + const size_t n = xyz.size() / 3; + if (n == 0) return c; + double lo[3], hi[3]; + for (int a = 0; a < 3; a++) { + lo[a] = std::numeric_limits::infinity(); + hi[a] = -lo[a]; + } + for (size_t i = 0; i < n; i++) + for (int a = 0; a < 3; a++) { + lo[a] = std::min(lo[a], xyz[i * 3 + a]); + hi[a] = std::max(hi[a], xyz[i * 3 + a]); + } + for (int a = 0; a < 3; a++) c.origin[a] = 0.5 * (lo[a] + hi[a]); + + constexpr uint64_t kAxis = (uint64_t(1) << 21) - 1; + std::vector> order(n); + for (size_t i = 0; i < n; i++) { + uint64_t k = 0; + for (int a = 0; a < 3; a++) + k = (k << 21) | std::min(kAxis, (uint64_t)((xyz[i * 3 + a] - lo[a]) / cell_edge)); + order[i] = {k, (uint32_t)i}; + } + std::sort(order.begin(), order.end()); + c.xyz.resize(n * 3); + c.rgb.resize(n * 3); + for (size_t j = 0; j < n; j++) { + const size_t i = order[j].second; + for (int a = 0; a < 3; a++) { + c.xyz[j * 3 + a] = (float)(xyz[i * 3 + a] - c.origin[a]); + c.rgb[j * 3 + a] = rgb[i * 3 + a]; + } + } + for (size_t b = 0; b < n;) { + size_t e = b; + float mn[3] = {kNone, kNone, kNone}, mx[3] = {-kNone, -kNone, -kNone}; + for (; e < n && order[e].first == order[b].first; e++) + for (int a = 0; a < 3; a++) { + mn[a] = std::min(mn[a], c.xyz[e * 3 + a]); + mx[a] = std::max(mx[a], c.xyz[e * 3 + a]); + } + ScanCloud::Cell cell; + cell.begin = (uint32_t)b; + cell.end = (uint32_t)e; + float r2 = 0; + for (int a = 0; a < 3; a++) { + cell.center[a] = 0.5f * (mn[a] + mx[a]); + r2 += 0.25f * (mx[a] - mn[a]) * (mx[a] - mn[a]); + } + cell.radius = std::sqrt(r2); + // A surface crossing a cube of edge `e` holds ~e^2 of area. + cell.spacing = (float)(cell_edge / std::sqrt((double)(e - b))); + c.cells.push_back(cell); + b = e; + } + return c; +} + +void render_front(const ScanCloud& cloud, const MapCamera& cam, bool ray_depth, + std::vector& index, std::vector& depth) { + const int W = cam.width, H = cam.height; + index.assign((size_t)W * H, UINT32_MAX); + std::vector best((size_t)W * H, kNone); + + // p_cam = R^T (origin + local - t) + const double* M = cam.c2w; + double R[9], b[3], d[3]; + for (int r = 0; r < 3; r++) + for (int c = 0; c < 3; c++) R[r * 3 + c] = M[c * 4 + r]; + for (int a = 0; a < 3; a++) d[a] = cloud.origin[a] - M[a * 4 + 3]; + for (int r = 0; r < 3; r++) b[r] = R[r * 3] * d[0] + R[r * 3 + 1] * d[1] + R[r * 3 + 2] * d[2]; + + const bool pinhole = cam.model == (int)CameraModelType::PINHOLE; + const bool wraps = cam.model == (int)CameraModelType::EQUIRECTANGULAR; + // Barrel distortion shows more than the undistorted frame does. + const double widen = cam.tier ? 1.5 : 1.0; + const double tx = widen * std::max(cam.cx, W - cam.cx) / cam.fx; + const double ty = widen * std::max(cam.cy, H - cam.cy) / cam.fy; + const double sx = std::sqrt(1 + tx * tx), sy = std::sqrt(1 + ty * ty); + for (const ScanCloud::Cell& cell : cloud.cells) { + const float* q = cell.center; + double c[3]; + for (int r = 0; r < 3; r++) + c[r] = R[r * 3] * q[0] + R[r * 3 + 1] * q[1] + R[r * 3 + 2] * q[2] + b[r]; + const double rad = cell.radius; + if (pinhole) { + if (c[2] < -rad) continue; + if (c[0] - tx * c[2] > rad * sx || -c[0] - tx * c[2] > rad * sx) continue; + if (c[1] - ty * c[2] > rad * sy || -c[1] - ty * c[2] > rad * sy) continue; + } + // A cell far enough away that its points are several to a pixel is + // drawn from every k-th of them: the front-most per pixel barely moves, + // and the 2138-image XGRIDS set spent most of its time there. + const double nearest = std::max(0.05, std::sqrt(c[0] * c[0] + c[1] * c[1] + c[2] * c[2]) - rad); + const double px_spacing = cam.fx * cell.spacing / nearest; + const uint32_t stride = px_spacing >= kLodSpacing ? 1u + : (uint32_t)std::min(64.0, std::floor(kLodSpacing * kLodSpacing / (px_spacing * px_spacing))); + for (uint32_t i = cell.begin; i < cell.end; i += stride) { + const float* q = &cloud.xyz[(size_t)i * 3]; + double p[3]; + for (int r = 0; r < 3; r++) + p[r] = R[r * 3] * q[0] + R[r * 3 + 1] * q[1] + R[r * 3 + 2] * q[2] + b[r]; + if (pinhole && p[2] < 0.01) continue; + double uv[2]; + if (!camhost::project_ray(p, cam.model, cam.tier, cam.dist, uv)) + continue; + const double px = cam.fx * uv[0] + cam.cx, py = cam.fy * uv[1] + cam.cy; + if (!(px >= 0 && py >= 0 && px < W && py < H)) continue; + const double range = std::sqrt(p[0] * p[0] + p[1] * p[1] + p[2] * p[2]); + const float dist = ray_depth ? (float)range : (float)p[2]; + const int r = std::min(kMaxFootprint, + (int)(0.5 * cam.fx * cell.spacing / range)); + const int x0 = (int)px, y0 = (int)py; + for (int y = std::max(0, y0 - r); y <= std::min(H - 1, y0 + r); y++) + for (int dx = -r; dx <= r; dx++) { + int x = x0 + dx; + if (wraps) x = (x % W + W) % W; + else if (x < 0 || x >= W) continue; + const size_t k = (size_t)y * W + (size_t)x; + if (dist < best[k]) { + best[k] = dist; + index[k] = i; + } + } + } + } + depth.resize(best.size()); + for (size_t k = 0; k < best.size(); k++) depth[k] = best[k] == kNone ? 0.0f : best[k]; +} + +void render_color(const ScanCloud& cloud, const MapCamera& cam, std::vector& rgb) { + std::vector index; + std::vector depth; + render_front(cloud, cam, false, index, depth); + const int W = cam.width, H = cam.height; + rgb.assign((size_t)W * H * 3, 0); + // A far point seen through a gap in a nearer surface is not what a camera + // there would see. + std::vector rows((size_t)W * H); + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) { + float m = kNone; + for (int k = std::max(0, x - 2); k <= std::min(W - 1, x + 2); k++) { + const float v = depth[(size_t)y * W + k]; + if (v > 0) m = std::min(m, v); + } + rows[(size_t)y * W + x] = m; + } + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) { + float m = kNone; + for (int k = std::max(0, y - 2); k <= std::min(H - 1, y + 2); k++) + m = std::min(m, rows[(size_t)k * W + x]); + const size_t j = (size_t)y * W + x; + if (depth[j] > m * kBleedRatio + kBleedSlack) { + depth[j] = 0; + index[j] = UINT32_MAX; + } + } + std::vector col((size_t)W * H * 3, 0.0f); + for (size_t k = 0; k < index.size(); k++) + if (index[k] != UINT32_MAX) + for (int c = 0; c < 3; c++) col[k * 3 + c] = cloud.rgb[(size_t)index[k] * 3 + c]; + for (int pass = 0; pass < kFillPasses; pass++) { + std::vector d2 = depth, c2 = col; + for (int y = 1; y < H - 1; y++) + for (int x = 1; x < W - 1; x++) { + const size_t k = (size_t)y * W + x; + if (depth[k] > 0) continue; + float lo = kNone, hi = 0, sum = 0, cs[3] = {0, 0, 0}; + int n = 0; + for (int dy = -1; dy <= 1; dy++) + for (int dx = -1; dx <= 1; dx++) { + const size_t j = (size_t)(y + dy) * W + (x + dx); + if (!(depth[j] > 0)) continue; + lo = std::min(lo, depth[j]); + hi = std::max(hi, depth[j]); + sum += depth[j]; + for (int c = 0; c < 3; c++) cs[c] += col[j * 3 + c]; + n++; + } + if (n >= kFillNeighbours && hi <= lo * kFillSpread) { + d2[k] = sum / n; + for (int c = 0; c < 3; c++) c2[k * 3 + c] = cs[c] / n; + } + } + depth.swap(d2); + col.swap(c2); + } + for (size_t k = 0; k < col.size(); k++) rgb[k] = (uint8_t)std::lround(col[k]); +} + +void render_depth_normal(const ScanCloud& cloud, const MapCamera& cam, bool ray_depth, + std::vector& depth, std::vector& normal) { + std::vector index; + render_front(cloud, cam, ray_depth, index, depth); + const int W = cam.width, H = cam.height; + const bool wraps = cam.model == (int)CameraModelType::EQUIRECTANGULAR; + const int r = std::max(1, (int)std::lround(std::max(W, H) / 800.0)); + auto col = [&](int x) { return wraps ? (x % W + W) % W : std::clamp(x, 0, W - 1); }; + auto at = [&](const std::vector& m, int x, int y) { + return m[(size_t)std::clamp(y, 0, H - 1) * W + col(x)]; + }; + + // Nearest depth over the window, 0 counted as nothing, rows then columns. + std::vector rows((size_t)W * H), nearest((size_t)W * H); + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) { + float m = kNone; + for (int k = -r; k <= r; k++) { + const float v = at(depth, x + k, y); + if (v > 0) m = std::min(m, v); + } + rows[(size_t)y * W + x] = m; + } + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) { + float m = kNone; + for (int k = -r; k <= r; k++) m = std::min(m, at(rows, x, y + k)); + nearest[(size_t)y * W + x] = m; + } + for (size_t k = 0; k < depth.size(); k++) + if (depth[k] > nearest[k] * kBleedRatio + kBleedSlack) depth[k] = 0; + + for (int pass = 0; pass < kFillPasses; pass++) { + std::vector filled = depth; + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) { + if (depth[(size_t)y * W + x] > 0) continue; + float lo = kNone, hi = 0, sum = 0; + int n = 0; + for (int dy = -1; dy <= 1; dy++) + for (int dx = -1; dx <= 1; dx++) { + if (y + dy < 0 || y + dy >= H) continue; + const float v = at(depth, x + dx, y + dy); + if (!(v > 0)) continue; + lo = std::min(lo, v); + hi = std::max(hi, v); + sum += v; + n++; + } + if (n >= kFillNeighbours && hi <= lo * kFillSpread) + filled[(size_t)y * W + x] = sum / n; + } + depth.swap(filled); + } + + std::vector rays((size_t)W * H * 3); + for (int y = 0; y < H; y++) + for (int x = 0; x < W; x++) { + double ray[3] = {0, 0, 0}; + camhost::generate_ray((x + 0.5 - cam.cx) / cam.fx, (y + 0.5 - cam.cy) / cam.fy, + cam.model, cam.tier, cam.dist, ray); + // Planar depth scales the ray to z = 1. + const double s = ray_depth ? 1.0 : (ray[2] > 1e-6 ? 1.0 / ray[2] : 0.0); + for (int a = 0; a < 3; a++) rays[((size_t)y * W + x) * 3 + a] = (float)(ray[a] * s); + } + auto point = [&](int x, int y, float out[3]) { + const size_t k = (size_t)y * W + col(x); + for (int a = 0; a < 3; a++) out[a] = rays[k * 3 + a] * depth[k]; + }; + normal.assign((size_t)W * H * 3, 0.0f); + for (int y = r; y < H - r; y++) + for (int x = wraps ? 0 : r; x < (wraps ? W : W - r); x++) { + const float d0 = depth[(size_t)y * W + x]; + if (!(d0 > 0)) continue; + const float ds[4] = {at(depth, x - r, y), at(depth, x + r, y), + at(depth, x, y - r), at(depth, x, y + r)}; + bool ok = true; + for (float v : ds) ok = ok && v > 0 && std::fabs(v - d0) <= kNormalSpread * d0; + if (!ok) continue; + float p[4][3], c[3]; + point(x - r, y, p[0]); + point(x + r, y, p[1]); + point(x, y - r, p[2]); + point(x, y + r, p[3]); + point(x, y, c); + const float dx[3] = {p[1][0] - p[0][0], p[1][1] - p[0][1], p[1][2] - p[0][2]}; + const float dy[3] = {p[2][0] - p[3][0], p[2][1] - p[3][1], p[2][2] - p[3][2]}; + float n[3] = {dx[1] * dy[2] - dx[2] * dy[1], dx[2] * dy[0] - dx[0] * dy[2], + dx[0] * dy[1] - dx[1] * dy[0]}; + const float len = std::sqrt(n[0] * n[0] + n[1] * n[1] + n[2] * n[2]); + if (!(len > 0)) continue; + const float face = n[0] * c[0] + n[1] * c[1] + n[2] * c[2] > 0 ? -1.0f : 1.0f; + for (int a = 0; a < 3; a++) normal[((size_t)y * W + x) * 3 + a] = face * n[a] / len; + } +} + +} // namespace app diff --git a/src/app/ScanDepth.h b/src/app/ScanDepth.h new file mode 100644 index 000000000..dbada117e --- /dev/null +++ b/src/app/ScanDepth.h @@ -0,0 +1,61 @@ +#pragma once + +// Depth and normal maps of a camera, rendered from a laser scan's points: the +// front-most point per pixel, then the far points that showed through gaps in +// a nearer surface removed and pinholes filled. Normals are taken from that +// depth with the engine's own stencil (shaders/pixel_wise.slang +// points_to_normal), so they mean what the trainer's depth normals mean. + +#include +#include + +namespace app { + +// Points as float offsets from `origin`, sorted into cubic cells so a pinhole +// camera projects only the cells in front of it. +struct ScanCloud { + struct Cell { + uint32_t begin = 0, end = 0; + float center[3] = {0, 0, 0}; + float radius = 0; + float spacing = 0; // between neighbouring points, as if on one surface + }; + double origin[3] = {0, 0, 0}; + std::vector xyz; // [N*3] + std::vector rgb; // [N*3] + std::vector cells; + size_t size() const { return xyz.size() / 3; } +}; + +ScanCloud build_scan_cloud(const std::vector& xyz, const std::vector& rgb, + double cell_edge); + +// A camera at the map's own resolution: OpenCV-axis camera-to-world, row-major +// 3x4, pixel centres at +0.5, `model` a CameraModelType and `tier` the +// CameraDistortionType `dist` is in. +struct MapCamera { + int model = 0; + int tier = 0; + float dist[8] = {}; + double c2w[12] = {}; + double fx = 0, fy = 0, cx = 0, cy = 0; + int width = 0, height = 0; +}; + +// The front-most point per pixel: its index (UINT32_MAX where none) and its +// distance (along the ray when `ray_depth`, else the optical axis). Points +// cover their share of the surface, so a sparse one still hides what is behind. +void render_front(const ScanCloud& cloud, const MapCamera& cam, bool ray_depth, + std::vector& index, std::vector& depth); + +// What the scan looks like from `cam`: its colours, front-most first, with the +// gaps a sparse surface leaves filled from neighbours on that surface; black +// where it has nothing. +void render_color(const ScanCloud& cloud, const MapCamera& cam, std::vector& rgb); + +// Metres, 0 where the scan says nothing; unit normals in the camera's frame, +// facing it, all zero where none. +void render_depth_normal(const ScanCloud& cloud, const MapCamera& cam, bool ray_depth, + std::vector& depth, std::vector& normal); + +} // namespace app diff --git a/src/app/Tools.h b/src/app/Tools.h index 58cc84764..5bfdf5b3d 100644 --- a/src/app/Tools.h +++ b/src/app/Tools.h @@ -56,6 +56,8 @@ constexpr const char* kToolSam = "sam"; constexpr const char* kToolGeometry = "geometry"; constexpr const char* kToolGui = "gui"; constexpr const char* kToolEncode = "encode"; +constexpr const char* kToolE57 = "e57"; +constexpr const char* kToolLidar = "lidar"; constexpr const char* kToolPartition = "partition"; } // namespace app @@ -78,6 +80,10 @@ int spirula_geometry_main(int argc, char** argv); #ifdef SS_TOOL_GUI int spirula_gui_main(int argc, char** argv); #endif +#ifdef SS_TOOL_E57 +int spirula_e57_main(int argc, char** argv); +int spirula_lidar_main(int argc, char** argv); +#endif #ifdef SS_TOOL_ENCODE int spirula_encode_main(int argc, char** argv); #endif diff --git a/src/app/cli/e57_main.cpp b/src/app/cli/e57_main.cpp new file mode 100644 index 000000000..cd28cb542 --- /dev/null +++ b/src/app/cli/e57_main.cpp @@ -0,0 +1,128 @@ +// spirula e57 -- an E57 laser scan written out as a training dataset +// (app/E57Dataset.h). The GUI's "Create Dataset from E57" runs this as a child +// process and reads its progress lines back, so every line is a catalog Msg. + +#include "app/E57Dataset.h" +#include "app/Tools.h" +#include "i18n/Message.h" +#include "i18n/catalog/Data.h" +#include "i18n/catalog/E57.h" + +#include +#include +#include +#include +#include + +namespace { + +namespace E = spirula::i18n::msg::e57; +using spirula::i18n::format; + +// Half the default cap_max, which leaves the densifier room to add detail. +constexpr long long kDefaultPoints = 500000; + +void help_row(const char* flags, const std::string& text, int col = 22) { + std::string left = std::string(" ") + flags; + if (spirula::i18n::display_width(left) >= col + 4) { + std::fprintf(stderr, "%s\n", left.c_str()); + left.clear(); + } + left = spirula::i18n::pad_to(left, col + 4); + for (const std::string& line : spirula::i18n::wrap(text, 86 - col - 4)) { + std::fprintf(stderr, "%s%s\n", left.c_str(), line.c_str()); + left.assign((size_t)col + 4, ' '); + } +} + +void usage() { + const std::string prog = app::program_name(); + std::fprintf(stderr, "%s -- %s\n\n", prog.c_str(), E::tagline.get()); + std::fprintf(stderr, " %s [] [options]\n\n", prog.c_str()); + for (const std::string& l : spirula::i18n::wrap(E::usage_about.get(), 80)) + std::fprintf(stderr, " %s\n", l.c_str()); + std::fprintf(stderr, "\n%s\n", E::head_options.get()); + help_row("--points |all", format(E::opt_points, {kDefaultPoints})); + help_row("--no-depth", E::opt_no_depth.get()); + help_row("--no-pinhole", E::opt_no_pinhole.get()); + help_row("--no-panoramas", E::opt_no_panoramas.get()); + help_row("--overwrite", E::opt_overwrite.get()); + help_row("--info", E::opt_info.get()); +} + +void say(const std::string& line) { + std::printf("%s\n", line.c_str()); + std::fflush(stdout); +} + +int info(const std::string& path) { + spirula::e57::Reader reader(path); + const auto& scans = reader.scans(); + const auto& images = reader.images(); + long long pinhole = 0, spherical = 0; + for (const auto& im : images) { + pinhole += im.projection == spirula::e57::Projection::Pinhole; + spherical += im.projection == spirula::e57::Projection::Spherical; + } + say(format(E::summary, {(long long)scans.size(), (long long)reader.total_points(), + (long long)images.size()})); + say(format(E::summary_images, {pinhole, spherical, + (long long)images.size() - pinhole - spherical})); + for (size_t i = 0; i < scans.size(); i++) + say(format(E::info_scan, {(long long)i, scans[i].name, (long long)scans[i].count})); + return 0; +} + +} // namespace + +int spirula_e57_main(int argc, char** argv) { + app::set_program_name(argc > 0 ? argv[0] : nullptr, "spirula e57"); + app::E57DatasetOptions opt; + opt.seed_points = kDefaultPoints; + bool list_only = false; + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + if (a == "--help" || a == "-h") { usage(); return 0; } + else if (a == "--no-pinhole") opt.pinhole = false; + else if (a == "--no-panoramas") opt.spherical = false; + else if (a == "--no-depth") opt.depth_maps = false; + else if (a == "--overwrite") opt.overwrite = true; + else if (a == "--info") list_only = true; + else if (a == "--points" && i + 1 < argc) { + const char* v = argv[++i]; + if (std::strcmp(v, "all") == 0) { + opt.all_points = true; + continue; + } + char* end = nullptr; + const long long n = std::strtoll(v, &end, 10); + if (end == v || *end != '\0' || n < 0) { + std::fprintf(stderr, "%s\n", format(E::err_bad_number, {a, v}).c_str()); + return 2; + } + opt.seed_points = n; + } else if (!a.empty() && a[0] == '-') { + std::fprintf(stderr, "%s\n\n", format(E::err_unknown_option, {a}).c_str()); + usage(); + return 2; + } else if (opt.input.empty()) opt.input = a; + else if (opt.output.empty()) opt.output = a; + else { usage(); return 2; } + } + if (opt.input.empty()) { + std::fprintf(stderr, "%s\n", format(E::err_no_input, {app::program_name()}).c_str()); + return 2; + } + const char* error_word = spirula::i18n::msg::data::word_error.get(); + try { + if (list_only) return info(opt.input); + if (opt.output.empty()) opt.output = app::default_e57_dataset_dir(opt.input); + const app::E57DatasetResult r = app::write_e57_dataset(opt, say); + if (r.cancelled) return 1; + say(format(E::next_step, {"spirula train --data \"" + opt.output + "\""})); + } catch (const std::exception& e) { + std::fprintf(stderr, "%s %s\n", error_word, e.what()); + return 1; + } + return 0; +} diff --git a/src/app/cli/lidar_main.cpp b/src/app/cli/lidar_main.cpp new file mode 100644 index 000000000..4302f21c5 --- /dev/null +++ b/src/app/cli/lidar_main.cpp @@ -0,0 +1,188 @@ +// spirula lidar -- a reconstruction aligned with a laser scan and given its +// geometry (app/LidarDataset.h). The dataset screen's Align step runs this as a +// child process and reads its progress lines back, so every line is a Msg. + +#include "app/LidarDataset.h" +#include "app/Tools.h" +#include "data/PointCloudFile.h" +#include "i18n/Message.h" +#include "i18n/catalog/Data.h" +#include "i18n/catalog/Lidar.h" + +#include +#include +#include +#include +#include +#include + +namespace { + +namespace L = spirula::i18n::msg::lidar; +using spirula::i18n::format; + +constexpr long long kDefaultPoints = 500000; +constexpr int kDefaultTrackCap = 12; + +void help_row(const char* flags, const std::string& text, int col = 22) { + std::string left = std::string(" ") + flags; + if (spirula::i18n::display_width(left) >= col + 4) { + std::fprintf(stderr, "%s\n", left.c_str()); + left.clear(); + } + left = spirula::i18n::pad_to(left, col + 4); + for (const std::string& line : spirula::i18n::wrap(text, 86 - col - 4)) { + std::fprintf(stderr, "%s%s\n", left.c_str(), line.c_str()); + left.assign((size_t)col + 4, ' '); + } +} + +void usage() { + const std::string prog = app::program_name(); + std::fprintf(stderr, "%s -- %s\n\n", prog.c_str(), L::tagline.get()); + std::fprintf(stderr, " %s --cloud [options]\n", prog.c_str()); + std::fprintf(stderr, " %s --extract []\n", + prog.c_str()); + std::fprintf(stderr, " %s --info \n\n", prog.c_str()); + for (const std::string& l : spirula::i18n::wrap(L::usage_about.get(), 80)) + std::fprintf(stderr, " %s\n", l.c_str()); + std::fprintf(stderr, "\n"); + for (const std::string& l : spirula::i18n::wrap(L::usage_extract.get(), 80)) + std::fprintf(stderr, " %s\n", l.c_str()); + std::fprintf(stderr, "\n%s\n", L::head_options.get()); + help_row("--cloud ", L::opt_cloud.get()); + help_row("--mode ", L::opt_mode.get()); + help_row("--points |all", format(L::opt_points, {kDefaultPoints})); + help_row("--track-cap ", format(L::opt_track_cap, {(long long)kDefaultTrackCap})); + help_row("--no-depth", L::opt_no_depth.get()); + help_row("--no-gap-points", L::opt_no_gaps.get()); + help_row("--anchors ", L::opt_anchors.get()); + help_row("--image-dir ", L::opt_image_dir.get()); + help_row("--mask-dir ", L::opt_mask_dir.get()); + help_row("--scan-frames ", L::opt_scan_frames.get()); + help_row("--flip-masks", L::opt_flip_masks.get()); + help_row("--overwrite", L::opt_overwrite.get()); + help_row("--scanner-poses", L::opt_scanner_poses.get()); + help_row("--render-anchors", L::opt_render.get()); + help_row("--info ", L::opt_info.get()); +} + +void say(const std::string& line) { + std::printf("%s\n", line.c_str()); + std::fflush(stdout); +} + +int info(const std::string& path) { + spirula::cloud::Reader r(path); + static const char* kFormat[] = {"E57", "LAS", "PLY"}; + say(format(L::info_cloud, {path, kFormat[(int)r.info().format], (long long)r.info().points, + r.info().has_color ? L::word_yes.get() : L::word_no.get(), + (long long)r.info().stations.size()})); + return 0; +} + +} // namespace + +int spirula_lidar_main(int argc, char** argv) { + app::set_program_name(argc > 0 ? argv[0] : nullptr, "spirula lidar"); + app::lidar::DatasetOptions opt; + opt.seed_points = kDefaultPoints; + opt.track_cap = kDefaultTrackCap; + const char* error_word = spirula::i18n::msg::data::word_error.get(); + auto bad = [&](const std::string& flag, const std::string& v) { + std::fprintf(stderr, "%s\n", format(L::err_bad_value, {flag, v}).c_str()); + return 2; + }; + try { + for (int i = 1; i < argc; ++i) { + const std::string a = argv[i]; + const bool has_value = i + 1 < argc; + if (a == "--help" || a == "-h") { usage(); return 0; } + if (a == "--info" && has_value) return info(argv[i + 1]); + if (a == "--extract" && i + 2 < argc) { + const std::string ds = argv[i + 2]; + const std::string sub = i + 3 < argc + ? argv[i + 3] : std::filesystem::path(argv[i + 1]).stem().string(); + const app::lidar::ExtractedPhotos x = app::lidar::extract_e57_anchors( + argv[i + 1], (std::filesystem::path(ds) / "images").string(), sub, + (std::filesystem::path(ds) / "lidar" / "anchors.json").string()); + say(format(L::extracted, {(long long)(x.pinhole + x.panorama), (long long)x.pinhole, + (long long)x.panorama})); + return 0; + } + if (a == "--render-anchors" && i + 2 < argc) { + std::vector clouds; + std::string ds; + for (int k = i + 1; k < argc; k++) { + const std::string v = argv[k]; + if (spirula::cloud::is_cloud_path(v)) clouds.push_back(v); + else ds = v; + } + const int64_t n = app::lidar::render_anchor_views( + clouds, !app::lidar::scans_share_frame(clouds), + (std::filesystem::path(ds) / "images").string(), "scan_views", + (std::filesystem::path(ds) / "lidar" / "anchors.json").string(), say); + say(format(L::rendered_views, {(long long)n})); + return n > 0 ? 0 : 1; + } + if (a == "--cloud" && has_value) opt.clouds.push_back(argv[++i]); + else if (a == "--anchors" && has_value) opt.anchors = argv[++i]; + else if (a == "--image-dir" && has_value) opt.image_dir = argv[++i]; + else if (a == "--mask-dir" && has_value) opt.mask_dir = argv[++i]; + else if (a == "--scan-frames" && has_value) { + const std::string v = argv[++i]; + if (v == "auto") opt.frames = app::lidar::ScanFrameMode::Auto; + else if (v == "shared") opt.frames = app::lidar::ScanFrameMode::Shared; + else if (v == "separate") opt.frames = app::lidar::ScanFrameMode::Separate; + else return bad(a, v); + } + else if (a == "--no-depth") opt.depth_maps = false; + else if (a == "--no-gap-points") opt.sfm_points_in_gaps = false; + else if (a == "--flip-masks") opt.flip_masks = true; + else if (a == "--overwrite") opt.overwrite = true; + else if (a == "--scanner-poses") opt.scanner_poses_only = true; + else if (a == "--mode" && has_value) { + const std::string v = argv[++i]; + if (v == "auto") opt.mode = app::lidar::AlignMode::Auto; + else if (v == "anchors") opt.mode = app::lidar::AlignMode::Anchors; + else if (v == "keep") opt.mode = app::lidar::AlignMode::Keep; + else if (v == "refine") opt.mode = app::lidar::AlignMode::Refine; + else return bad(a, v); + } else if (a == "--points" && has_value) { + const char* v = argv[++i]; + if (std::strcmp(v, "all") == 0) { opt.all_points = true; continue; } + char* end = nullptr; + const long long n = std::strtoll(v, &end, 10); + if (end == v || *end != '\0' || n < 0) return bad(a, v); + opt.seed_points = n; + } else if (a == "--track-cap" && has_value) { + const char* v = argv[++i]; + char* end = nullptr; + const long n = std::strtol(v, &end, 10); + if (end == v || *end != '\0' || n < 1 || n > 64) return bad(a, v); + opt.track_cap = (int)n; + } else if (!a.empty() && a[0] == '-') { + std::fprintf(stderr, "%s\n\n", format(L::err_unknown_option, {a}).c_str()); + usage(); + return 2; + } else if (spirula::cloud::is_cloud_path(a) || spirula::cloud::is_laz_path(a)) { + opt.clouds.push_back(a); + } else if (opt.dataset.empty()) { + opt.dataset = a; + } else { + usage(); + return 2; + } + } + if (opt.dataset.empty() || opt.clouds.empty()) { + std::fprintf(stderr, "%s\n", format(L::err_no_input, {app::program_name()}).c_str()); + return 2; + } + const app::lidar::DatasetResult r = app::lidar::write_lidar_dataset(opt, say); + if (r.cancelled) return 1; + } catch (const std::exception& e) { + std::fprintf(stderr, "%s %s\n", error_word, e.what()); + return 1; + } + return 0; +} diff --git a/src/app/gui/Automation.cpp b/src/app/gui/Automation.cpp index b991f5df9..0af12c62f 100644 --- a/src/app/gui/Automation.cpp +++ b/src/app/gui/Automation.cpp @@ -48,7 +48,7 @@ struct Item { // is applied per frame, which is what keeps a move and the click after it in // separate frames -- the hover test between them is what makes the click land. struct Step { - enum class Kind { None, MousePos, MouseButton, Wheel, Key, Text, Focus }; + enum class Kind { None, MousePos, MouseButton, Wheel, Key, Text, Focus, Drop }; Kind kind = Kind::None; float x = 0, y = 0; int button = 0; @@ -68,6 +68,7 @@ struct State { std::condition_variable cv; std::vector building; // GUI thread only, the frame in flight + std::vector dropped; // GUI thread only, until take_drop() std::vector published; // guarded by mu uint64_t frame_no = 0; // guarded by mu float display_w = 0, display_h = 0, fb_scale = 1; @@ -582,6 +583,15 @@ HttpResponse handle_text(const HttpRequest& r) { return finish(r, enqueue(steps)); } +// Files dropped on the window, '\n'-separated in `paths=`. +HttpResponse handle_drop(const HttpRequest& r) { + Step d; + d.kind = Step::Kind::Drop; + d.text = r.get("paths"); + if (d.text.empty()) return err_json(400, "paths= is empty"); + return finish(r, enqueue({d, Step{}})); +} + HttpResponse handle_wait(const HttpRequest& r) { const int frames = std::max(0, std::min(600, r.get_int("frames", 2))); std::vector steps((size_t)frames + 1, Step{}); @@ -684,6 +694,12 @@ namespace automation { bool armed() { return st().armed; } +std::vector take_drop() { + std::vector out; + out.swap(st().dropped); + return out; +} + void set_state_source(std::function f) { st().state_source = std::move(f); } @@ -716,6 +732,7 @@ void arm() { s.http.route("/ui/key", guard(handle_key)); s.http.route("/ui/text", guard(handle_text)); s.http.route("/ui/wait", guard(handle_wait)); + s.http.route("/ui/drop", guard(handle_drop)); s.http.route("/ui/screenshot", guard(handle_screenshot)); try { @@ -775,6 +792,13 @@ bool begin_frame() { io.AddInputCharactersUTF8(step.text.c_str()); break; case Step::Kind::Focus: io.AddFocusEvent(true); break; + case Step::Kind::Drop: + for (size_t a = 0, b; a < step.text.size(); a = b + 1) { + b = step.text.find('\n', a); + if (b == std::string::npos) b = step.text.size(); + if (b > a) s.dropped.push_back(step.text.substr(a, b - a)); + } + break; } if (popped) { diff --git a/src/app/gui/Automation.h b/src/app/gui/Automation.h index 2a19e0f65..7b323b58c 100644 --- a/src/app/gui/Automation.h +++ b/src/app/gui/Automation.h @@ -11,6 +11,7 @@ #include #include +#include namespace gui { namespace automation { @@ -31,6 +32,10 @@ void set_state_source(std::function f); // frame loop at its busy rate for the length of a script. bool begin_frame(); +// A drop the client asked for (/ui/drop), taken once after begin_frame() and +// handed on as GLFW hands a real one; empty when none came. +std::vector take_drop(); + // After the draw data has been rendered, with the GL context current: // publishes the frame's item table and fills a pending screenshot request. void end_frame(int fb_w, int fb_h); diff --git a/src/app/gui/DatasetPlan.cpp b/src/app/gui/DatasetPlan.cpp index 5ba2af9d1..287b88582 100644 --- a/src/app/gui/DatasetPlan.cpp +++ b/src/app/gui/DatasetPlan.cpp @@ -637,9 +637,12 @@ std::vector geometry_kinds(const GeometryJob& g) { PlanJob plan_job(const SfmJob& job) { PlanJob p; p.prep = job.prep; + p.reconstruct = !job.lidar.scanner_only; p.model = model_fields(job); p.mask_features = job.mask_features; p.geometry = job.geometry; + // The scans give the run its depth and normals. + if (job.lidar.enabled()) p.geometry.enable = false; return p; } @@ -732,6 +735,7 @@ DatasetPlan plan_dataset(const PlanJob& job, const WorkspaceState& ws, const bool masks_feed = masks_reach_features(job.prep, job.mask_features); if (fixed(Step::Model)) { md = (*done)[Step::Model]; + } else if (!job.reconstruct) { } else if (!ws.model) { set(md, Act::Run); } else if (req.redo_model) { diff --git a/src/app/gui/DatasetPlan.h b/src/app/gui/DatasetPlan.h index fe234f74b..6405b1a73 100644 --- a/src/app/gui/DatasetPlan.h +++ b/src/app/gui/DatasetPlan.h @@ -41,6 +41,7 @@ bool masks_in_dataset(const PrepJob& job); // One shape for both engines. struct PlanJob { PrepJob prep; + bool reconstruct = true; // false: the laser scans' poses place the images StepFields model; bool mask_features = true; GeometryJob geometry; diff --git a/src/app/gui/FilmReel.cpp b/src/app/gui/FilmReel.cpp index 4b555bdd7..0dde2b5b0 100644 --- a/src/app/gui/FilmReel.cpp +++ b/src/app/gui/FilmReel.cpp @@ -10,6 +10,7 @@ #include namespace dmsg = spirula::i18n::msg::dataset; +namespace fs = std::filesystem; namespace gui { @@ -314,4 +315,106 @@ void FilmReel::destroy_gl() { _tex_pts.clear(); } +// --------------------------------------------------------------------------- +// OutputWatch +// --------------------------------------------------------------------------- + +OutputWatch::OutputWatch(FilmReel* reel, fs::path dir, fs::path images, + fs::path depths, bool fresh_only) + : _reel(reel), _dir(std::move(dir)), _images(std::move(images)), + _depths(std::move(depths)) { + if (!_reel || !fresh_only) return; + for (const std::string& f : listing()) { + std::error_code ec; + _before[f] = fs::last_write_time(f, ec); + } +} + +std::vector OutputWatch::listing() const { + std::vector out; + std::error_code ec; + for (fs::recursive_directory_iterator + it(_dir, fs::directory_options::skip_permission_denied, ec), end; + !ec && it != end; it.increment(ec)) + if (it->is_regular_file(ec)) out.push_back(it->path().string()); + return out; +} + +void OutputWatch::poll(bool flush) { + if (!_reel) return; + const auto now = std::chrono::steady_clock::now(); + if (!flush && now - _last < std::chrono::seconds(1)) return; + _last = now; + + publish(_ripe); + _ripe.clear(); + + std::vector found; + for (std::string& f : listing()) { + if (_seen.count(f)) continue; + const auto was = _before.find(f); + std::error_code ec; + if (was != _before.end() && fs::last_write_time(f, ec) == was->second) continue; + found.push_back(std::move(f)); + } + std::sort(found.begin(), found.end()); + + if (!flush) { + _ripe = std::move(found); + return; + } + publish(found); +} + +// The reel follows the newest picture it HOLDS, not the newest frame +// registered (FilmReel::draw), so the last of a batch is read here or the +// slider never leaves the first one it managed to load. +void OutputWatch::publish(const std::vector& batch) { + const bool want_pixels = !batch.empty() && _reel->wants(); + for (size_t i = 0; i < batch.size(); i++) { + _seen.insert(batch[i]); + FilmFrame f; + f.name = fs::path(batch[i]).filename().string(); + f.panels = row_for(batch[i]); + if (want_pixels && i + 1 == batch.size()) _reel->add_loaded(f); + else _reel->add(f); + } +} + +// The photograph, the normal map and the depth map of one frame. The map +// is what was found; the other two are derived from its path, since the +// three trees mirror each other (spirula geometry writes them that way). +std::vector OutputWatch::row_for(const std::string& map) const { + std::error_code ec; + fs::path rel = fs::relative(map, _dir, ec); + if (ec || rel.empty()) rel = fs::path(map).filename(); + std::vector out; + if (!_images.empty()) { + // The extension is the capture's, not the map's. + for (const char* ext : {".jpg", ".jpeg", ".png", ".webp", ".tif", + ".tiff", ".bmp", ".exr", ".JPG", ".PNG"}) { + fs::path p = _images / rel; + p.replace_extension(ext); + if (!fs::is_regular_file(p, ec)) { + p = _images / rel.filename(); + p.replace_extension(ext); + } + if (fs::is_regular_file(p, ec)) { + out.push_back({p.string(), false}); + break; + } + } + } + const bool map_is_depth = _dir.filename() == "depths"; + out.push_back({map, map_is_depth}); + if (!map_is_depth && !_depths.empty()) { + // Not probed: the writer pool may not have reached it yet, and a + // panel that fails to load is skipped when the row is read. + fs::path d = _depths / rel; + d.replace_extension(".png"); + out.push_back({d.string(), true}); + } + return out; +} + } // namespace gui diff --git a/src/app/gui/FilmReel.h b/src/app/gui/FilmReel.h index ac4dbd1c7..d80cf315f 100644 --- a/src/app/gui/FilmReel.h +++ b/src/app/gui/FilmReel.h @@ -19,7 +19,10 @@ #include #include #include +#include +#include #include +#include #include #include #include @@ -145,4 +148,33 @@ class FilmReel { mutable bool _ticked = false; }; +// The pictures a child process writes, onto a reel as they appear. The child +// says nothing about them, so the folder is what is watched. +class OutputWatch { +public: + // A row is a file of `dir` with its photograph under `images` and its depth + // map under `depths`, either path empty for none. `fresh_only` holds back + // what `dir` has now until it is written again: an earlier run's output. + OutputWatch(FilmReel* reel, std::filesystem::path dir, + std::filesystem::path images, std::filesystem::path depths, + bool fresh_only = false); + + // One scan of lag before a file is shown: what appeared this tick may + // still be half written, and a truncated PNG reads as a failure rather + // than as a race. `flush` gives that up, for the end of the run. + void poll(bool flush = false); + +private: + std::vector listing() const; + void publish(const std::vector& batch); + std::vector row_for(const std::string& map) const; + + FilmReel* _reel; + std::filesystem::path _dir, _images, _depths; + std::set _seen; + std::map _before; + std::vector _ripe; + std::chrono::steady_clock::time_point _last{}; +}; + } // namespace gui diff --git a/src/app/gui/GeometryRunner.cpp b/src/app/gui/GeometryRunner.cpp index ab86225f0..f8321443a 100644 --- a/src/app/gui/GeometryRunner.cpp +++ b/src/app/gui/GeometryRunner.cpp @@ -19,10 +19,8 @@ #endif #include -#include #include #include -#include namespace fs = std::filesystem; namespace dmsg = spirula::i18n::msg::dataset; @@ -52,101 +50,6 @@ std::string trim_right(const std::string& s) { return s.substr(0, n); } -// The maps the child has written, onto the reel as they appear. The child -// says nothing about them, so the folder is what is watched. -class OutputWatch { -public: - OutputWatch(FilmReel* reel, fs::path dir, fs::path images, fs::path depths) - : _reel(reel), _dir(std::move(dir)), _images(std::move(images)), - _depths(std::move(depths)) {} - - // One scan of lag before a file is shown: what appeared this tick may - // still be half written, and a truncated PNG reads as a failure rather - // than as a race. `flush` gives that up, for the end of the run. - void poll(bool flush = false) { - if (!_reel) return; - const auto now = std::chrono::steady_clock::now(); - if (!flush && now - _last < std::chrono::seconds(1)) return; - _last = now; - - publish(_ripe); - _ripe.clear(); - - std::vector found; - std::error_code ec; - for (fs::recursive_directory_iterator - it(_dir, fs::directory_options::skip_permission_denied, ec), end; - !ec && it != end; it.increment(ec)) - if (it->is_regular_file(ec) && !_seen.count(it->path().string())) - found.push_back(it->path().string()); - std::sort(found.begin(), found.end()); - - if (!flush) { - _ripe = std::move(found); - return; - } - publish(found); - } - -private: - // The reel follows the newest picture it HOLDS, not the newest frame - // registered (FilmReel::draw), so the last of a batch is read here or the - // slider never leaves the first one it managed to load. - void publish(const std::vector& batch) { - const bool want_pixels = !batch.empty() && _reel->wants(); - for (size_t i = 0; i < batch.size(); i++) { - _seen.insert(batch[i]); - FilmFrame f; - f.name = fs::path(batch[i]).filename().string(); - f.panels = row_for(batch[i]); - if (want_pixels && i + 1 == batch.size()) _reel->add_loaded(f); - else _reel->add(f); - } - } - - // The photograph, the normal map and the depth map of one frame. The map - // is what was found; the other two are derived from its path, since the - // three trees mirror each other (spirula geometry writes them that way). - std::vector row_for(const std::string& map) const { - std::error_code ec; - fs::path rel = fs::relative(map, _dir, ec); - if (ec || rel.empty()) rel = fs::path(map).filename(); - std::vector out; - if (!_images.empty()) { - // The extension is the capture's, not the map's. - for (const char* ext : {".jpg", ".jpeg", ".png", ".webp", ".tif", - ".tiff", ".bmp", ".exr", ".JPG", ".PNG"}) { - fs::path p = _images / rel; - p.replace_extension(ext); - if (!fs::is_regular_file(p, ec)) { - p = _images / rel.filename(); - p.replace_extension(ext); - } - if (fs::is_regular_file(p, ec)) { - out.push_back({p.string(), false}); - break; - } - } - } - const bool map_is_depth = _dir.filename() == "depths"; - out.push_back({map, map_is_depth}); - if (!map_is_depth && !_depths.empty()) { - // Not probed: the writer pool may not have reached it yet, and a - // panel that fails to load is skipped when the row is read. - fs::path d = _depths / rel; - d.replace_extension(".png"); - out.push_back({d.string(), true}); - } - return out; - } - - FilmReel* _reel; - fs::path _dir, _images, _depths; - std::set _seen; - std::vector _ripe; - std::chrono::steady_clock::time_point _last{}; -}; - } // namespace const std::vector& geometry_models() { diff --git a/src/app/gui/GuiApp.cpp b/src/app/gui/GuiApp.cpp index bd1bfb031..a52c998b8 100644 --- a/src/app/gui/GuiApp.cpp +++ b/src/app/gui/GuiApp.cpp @@ -25,10 +25,12 @@ #include "i18n/Locale.h" #include "i18n/catalog/Brand.h" #include "i18n/catalog/Dataset.h" +#include "i18n/catalog/Lidar.h" #include "i18n/catalog/Geometry.h" #include "data/SparseEdit.h" #include "i18n/catalog/Edit.h" #include "i18n/catalog/Gui.h" +#include "i18n/catalog/Log.h" #include "i18n/catalog/MaskEdit.h" #include "i18n/catalog/Partition.h" #include "i18n/catalog/Render.h" @@ -62,6 +64,7 @@ #include #include #include +#include #include #include #include @@ -74,6 +77,7 @@ namespace msg = spirula::i18n::msg::gui; namespace emsg = spirula::i18n::msg::edit; namespace fld = spirula::i18n::msg::field; namespace dmsg = spirula::i18n::msg::dataset; +namespace ldmsg = spirula::i18n::msg::lidar; namespace gmsg = spirula::i18n::msg::geometry; namespace tmsg = spirula::i18n::msg::train; namespace rmsg = spirula::i18n::msg::render; @@ -931,7 +935,7 @@ void GuiApp::apply_dataset_settings(const DatasetSettings& in) { _use_found_masks = s.use_found_masks; _border_enable = s.border_enable; _frame_shapes = s.frame_shapes; - apply_frame_shapes(); + apply_frame_shapes(_sources, _frame_shapes); _resume = s.sfm.prep.resume; _photo_import = s.sfm.prep.photo_import; _flip_found_masks = s.sfm.prep.flip_found_masks; @@ -1649,6 +1653,7 @@ bool GuiApp::launch_batch_dataset(BatchTask& task, const BatchRow& row) { // A reconstruction brings up a Vulkan device of its own and wants the // VRAM the last training run is still holding. _runner.release_engine(); + clear_lidar_sources(); _sources = sources; _workspace = _workspace_auto = workspace; apply_dataset_settings(settings); @@ -1942,8 +1947,57 @@ static std::vector recent_models(const RecentList& r) { return out; } +// Videos and folders of photos out of a drop; the rest is logged. +std::vector GuiApp::raw_inputs(const std::vector& paths) { + std::error_code ec; + std::vector sources; + for (const std::string& path : paths) { + const fs::path p(path); + if (fs::is_directory(p, ec)) { + if (folder_has_images(path)) sources.push_back(path); + else log(i18n::format(dmsg::log_drop_no_images, {path})); + } else if (fs::is_regular_file(p, ec)) { + if (is_video_path(path)) sources.push_back(path); + else log(i18n::format(dmsg::log_drop_unsupported, {path})); + } + } + return sources; +} + void GuiApp::handle_drop(const std::vector& paths) { std::error_code ec; + // A laser scan is an input of the dataset screen wherever it lands, and so + // is a point-cloud PLY there or dropped with photos, videos or scans; a + // lone one elsewhere is to be looked at. + std::vector scans, clouds, rest; + for (const std::string& p : paths) { + const bool file = fs::is_regular_file(p, ec); + if (file && is_lidar_drop(p)) scans.push_back(p); + else if (file && ply_is_point_cloud(p)) clouds.push_back(p); + else rest.push_back(p); + } + const bool raw_beside = std::any_of(rest.begin(), rest.end(), [&](const std::string& p) { + return fs::is_directory(p, ec) ? folder_has_images(p) : is_video_path(p); + }); + if (_screen == Screen::NewDataset || raw_beside || !scans.empty()) + scans.insert(scans.end(), clouds.begin(), clouds.end()); + if (!scans.empty()) { + if (native_work_busy()) { + if (training_busy()) log(dmsg::log_drop_while_training.get()); + return; + } + close_native_previews(); + close_splat(); + if (_screen != Screen::NewDataset) { + clear_sources(); + clear_lidar_sources(); + _screen = Screen::NewDataset; + } + const std::vector sources = raw_inputs(rest); + if (!sources.empty()) add_sources(sources, /*replace=*/false); + add_lidar_sources(scans); + return; + } // The mesh screen is asking two specific questions -- which model, and // which photos -- so a drop there ANSWERS one of them instead of // navigating away. Dropping a splat .ply on a screen whose first field @@ -2074,6 +2128,13 @@ void GuiApp::handle_drop(const std::vector& paths) { request_open_splat(paths[0]); return; } + // An XGRIDS export: its dataset and its LiDAR map, on the dataset screen. + if (paths.size() == 1 && fs::is_directory(paths[0], ec) && !native_work_busy() && + add_xgrids_export(paths[0])) { + close_splat(); + _screen = Screen::NewDataset; + return; + } // A reconstruction that is not a trainable dataset -- a bare `sparse/`, // one of its models, or a folder holding one without the images -- is // something to look at and clean up, not something to train on. @@ -2125,17 +2186,7 @@ void GuiApp::handle_drop(const std::vector& paths) { } // Everything else is raw input: videos, and folders of photos. - std::vector sources; - for (const std::string& path : paths) { - const fs::path p(path); - if (fs::is_directory(p, ec)) { - if (folder_has_images(path)) sources.push_back(path); - else log(i18n::format(dmsg::log_drop_no_images, {path})); - } else if (fs::is_regular_file(p, ec)) { - if (is_video_path(path)) sources.push_back(path); - else log(i18n::format(dmsg::log_drop_unsupported, {path})); - } - } + const std::vector sources = raw_inputs(paths); if (sources.empty()) return; if (native_work_busy()) { if (training_busy()) log(dmsg::log_drop_while_training.get()); @@ -2211,7 +2262,10 @@ void GuiApp::refresh_sources() { // and stays put while the inputs still name it: a run filling it does not // make it somebody else's, and a fresh _2 would mean starting over. if (_workspace.empty() || _workspace == _workspace_auto) { - if (!workspace_named_by(_sources, _workspace)) + // A scan alone is a dataset of its photographs, written beside it. + if (_sources.empty() && !_lidar.empty()) + _workspace = fs::path(_lidar[0].path).replace_extension("").string() + "_dataset"; + else if (!workspace_named_by(_sources, _workspace)) _workspace = default_workspace(_sources); _workspace_auto = _workspace; } @@ -2347,19 +2401,9 @@ bool GuiApp::add_sources(const std::vector& paths, bool replace) { close_native_previews(); close_splat(); if (replace && !inputs.empty()) { - _sources.clear(); - _source_path_edits.clear(); - _mask_preview_input = 0; - // A capture dropped again is restored from again (restore_from_record). - _restored_ws.clear(); - // A different capture is a different job, and the geometry options - // are remembered nowhere: a run must never quietly cost an hour of - // inference nobody asked for. (Not mid-run: that would disable it.) - if (!dataset_busy()) _geometry = GeometryJob{}; - // ... and a different capture is a different colour space. - _sfm_job.image_gamut.clear(); - _sfm_job.image_is_linear.reset(); - _color_space_touched = false; + clear_sources(); + // Started from elsewhere, it is a new dataset, scans and all. + if (_screen != Screen::NewDataset) clear_lidar_sources(); } const size_t first_new = _sources.size(); for (const std::string& path : inputs) { @@ -2374,7 +2418,7 @@ bool GuiApp::add_sources(const std::vector& paths, bool replace) { _border_enable = true; if (in.stencil.empty()) in.stencil.detect_border = true; } - apply_frame_shapes(first_new); + apply_frame_shapes(_sources, _frame_shapes, first_new); for (const std::string& masks : mask_folders) { if (attach_mask_folder(_sources, masks)) log(i18n::format(dmsg::log_masks_attached, {masks})); @@ -2391,8 +2435,28 @@ bool GuiApp::add_sources(const std::vector& paths, bool replace) { return true; } +void GuiApp::clear_sources() { + _sources.clear(); + _source_path_edits.clear(); + _mask_preview_input = 0; + // A capture dropped again is restored from again (restore_from_record). + _restored_ws.clear(); + // A different capture is a different job, and the geometry options + // are remembered nowhere: a run must never quietly cost an hour of + // inference nobody asked for. (Not mid-run: that would disable it.) + if (!dataset_busy()) _geometry = GeometryJob{}; + // ... and a different capture is a different colour space. + _sfm_job.image_gamut.clear(); + _sfm_job.image_is_linear.reset(); + _color_space_touched = false; +} + void GuiApp::add_existing_dataset(const std::string& dir) { - if (dir.empty()) return; + if (dir.empty() || add_xgrids_export(dir)) return; + add_dataset_folder(dir); +} + +void GuiApp::add_dataset_folder(const std::string& dir) { // The ordinary route already lands on the right pair for the usual // layout: resolve_photo_folder picks images/ out of the folder and // refresh_sources makes its parent -- this folder -- the output. @@ -2419,17 +2483,18 @@ void GuiApp::save_run_stencils() { for (const std::string& f : written) log(i18n::format(dmsg::stencil_autosaved, {f})); } -void GuiApp::apply_frame_shapes(size_t first_input) { - if (_frame_shapes.empty()) return; +void GuiApp::apply_frame_shapes(std::vector& inputs, std::string& preset, + size_t first_input) { + if (preset.empty()) return; app::MaskSet set; std::string err; - if (!load_stencil_preset(_frame_shapes, set, err)) { + if (!load_stencil_preset(preset, set, err)) { log(i18n::format(dmsg::stencil_load_failed, {err})); - _frame_shapes.clear(); + preset.clear(); return; } - for (size_t i = first_input; i < _sources.size(); i++) - app::apply_mask_set(_sources[i].stencil, set); + for (size_t i = first_input; i < inputs.size(); i++) + app::apply_mask_set(inputs[i].stencil, set); } // A folder of EXRs, or of TIFFs with an ICC profile, declares its own colour @@ -2480,6 +2545,7 @@ const char* GuiApp::dir_key(PickAction a, FileDialog::Mode m) { case PickAction::MeshSource: return "model"; case PickAction::StencilFile: return "stencil"; case PickAction::SeedPointcloud: return "seed_pointcloud"; + case PickAction::LidarSource: return "lidar"; case PickAction::RenderProjectSave: case PickAction::RenderProjectOpen: return "render_project"; case PickAction::RenderOutput: return "render_output"; @@ -2611,6 +2677,11 @@ void GuiApp::handle_dialog_result(const std::vector& paths) { case PickAction::MeshPhotos: _mesh_job.data_dir = path; break; + case PickAction::LidarSource: + if (_pick_lidar < 0) add_lidar_sources(paths); + else if (!path.empty()) replace_lidar_source((size_t)_pick_lidar, path); + _pick_lidar = -1; + break; case PickAction::MeshOutput: // Only the folder is picked; the file name keeps whatever // set_mesh_source derived, as the preset save dialog does. @@ -3599,6 +3670,7 @@ void GuiApp::open_reconstruction(const std::string& workspace) { for (const PrepInput& in : decode_record_inputs(read_dataset_record(workspace).inputs).rows) if (!in.path.empty() && fs::exists(fs::u8path(in.path), ec)) inputs.push_back(in.path); _redo_frames = _redo_masks = _redo_model = _redo_geometry = false; + clear_lidar_sources(); if (!inputs.empty()) { add_sources(inputs, /*replace=*/true); _workspace = workspace; @@ -3608,9 +3680,7 @@ void GuiApp::open_reconstruction(const std::string& workspace) { } else { close_native_previews(); close_splat(); - _sources.clear(); - _source_path_edits.clear(); - _mask_preview_input = 0; + clear_sources(); refresh_sources(); _workspace = workspace; } @@ -3717,16 +3787,32 @@ 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) return false; + return mask_model_missing(_mask_enable, _sources); +} + +bool GuiApp::mask_model_missing(bool enable, const std::vector& inputs) const { + if (!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(); - for (const PrepInput& s : _sources) + bool all_bring_masks = !inputs.empty(); + for (const PrepInput& s : inputs) all_bring_masks = all_bring_masks && !s.mask_dir.empty(); if (all_bring_masks) return false; return selected_mask_model().empty(); } +void GuiApp::draw_model_fetch(FileDownload& dl, const Msg& missing, const Msg& get, + const std::function& request) { + ImGui::SameLine(); + ui::TextDisabled(missing); + ImGui::SameLine(); + if (dl.state() == FileDownload::State::Running) + ui::ProgressBarRaw(std::max(dl.progress(), 0.0f), ImVec2(px(200.0f), 0), + dl.status().c_str()); + else if (ui::Button(get)) + request(); +} + const WorkspaceState& GuiApp::workspace_state() { std::string key = _workspace; for (const PrepInput& s : _sources) key += '\n' + s.path + '\n' + s.mask_dir; @@ -3747,6 +3833,30 @@ const WorkspaceState& GuiApp::workspace_state() { // The panel edits one set of fields; each runner gets its own struct because // their remaining options do not overlap. This is the one place they are // copied across, so a field cannot be set on the screen and silently not run. +void GuiApp::fill_masking(PrepJob& prep) const { + // A click-only pick hides the prompt boxes, so what is left in them is not run. + const MaskModelFiles mask_model = selected_mask_model(); + prep.mask_prompt = mask_model.text ? _mask.prompt : ""; + prep.mask_negative_prompt = mask_model.text ? _mask.negative_prompt : ""; + prep.mask_feature_prompt = mask_model.text ? _mask.feature_prompt : ""; + prep.mask_keep_subject = _mask.keep_subject; + prep.mask_max_image_size = _mask.max_image_size; + prep.mask_dilate_ratio = _mask.boundary_ratio(); + prep.mask_threshold = _mask.threshold; + prep.mask_nms = _mask.nms; + prep.mask_memory = _mask_memory; + prep.mask_detect_every = _mask_detect_every; + prep.mask_memory_frames = _mask_memory_frames; + prep.mask_clicks = _mask.clicks; + prep.mask_model_path = mask_model.model; + prep.mask_detector_path = mask_model.detector; + prep.mask_detector_threshold = _mask.box_threshold; + // The one frozen choice, re-applied here because a job is rebuilt from + // panel state and would otherwise drop it. Empty before the freeze, which + // is exactly what an unstarted job wants. + prep.device = _native_device_uuid; +} + void GuiApp::sync_dataset_jobs() { PrepJob prep; prep.inputs = _sources; @@ -3769,28 +3879,11 @@ void GuiApp::sync_dataset_jobs() { prep.mask_enable = _mask_enable; prep.flip_found_masks = _use_found_masks && _flip_found_masks; prep.photo_import = _photo_import; - // A click-only pick hides the prompt boxes, so what is left in them is not run. - const MaskModelFiles mask_model = selected_mask_model(); - prep.mask_prompt = mask_model.text ? _mask.prompt : ""; - prep.mask_negative_prompt = mask_model.text ? _mask.negative_prompt : ""; - prep.mask_feature_prompt = mask_model.text ? _mask.feature_prompt : ""; - prep.mask_keep_subject = _mask.keep_subject; - prep.mask_max_image_size = _mask.max_image_size; - prep.mask_dilate_ratio = _mask.boundary_ratio(); - prep.mask_threshold = _mask.threshold; - prep.mask_nms = _mask.nms; - prep.mask_memory = _mask_memory; - prep.mask_detect_every = _mask_detect_every; - prep.mask_memory_frames = _mask_memory_frames; - prep.mask_clicks = _mask.clicks; - prep.mask_model_path = mask_model.model; - prep.mask_detector_path = mask_model.detector; - prep.mask_detector_threshold = _mask.box_threshold; - // 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. - prep.device = _native_device_uuid; + if (!_lidar.empty() && !_lidar_in_frame && prep.photo_import == PhotoImport::InPlace) + prep.photo_import = PhotoImport::ConvertJpeg; + fill_masking(prep); _sfm_job.prep = prep; + _sfm_job.lidar = lidar_job(); _colmap_job.inputs = prep.inputs; _colmap_job.workspace = prep.workspace; @@ -4191,30 +4284,46 @@ void GuiApp::draw_dataset_source() { edited = true; } if (edited) refresh_sources(); - + draw_lidar_rows(path_w, one_line); + + // The ways in, on one line while they fit: four labels run off a narrow + // panel in several languages. + const float right = ImGui::GetCursorScreenPos().x + ImGui::GetContentRegionAvail().x; + auto same_line_if_fits = [&](const Msg& next, bool button) { + const ImGuiStyle& st = ImGui::GetStyle(); + const float w = ImGui::CalcTextSize(next.get()).x + (button ? st.FramePadding.x * 2 : 0); + if (ImGui::GetItemRectMax().x + st.ItemSpacing.x + w <= right) ImGui::SameLine(); + }; if (ui::Button(dmsg::add_video)) { open_pick(PickAction::SourceVideo, msg::pick_videos.get(), FileDialog::Mode::File, video_dialog_filters(), "", /*multi_select=*/true); } ui::help_on_hover(dmsg::add_video_help); - ImGui::SameLine(); + same_line_if_fits(dmsg::add_photos, true); if (ui::Button(dmsg::add_photos)) { open_pick(PickAction::SourceImages, msg::pick_photo_folder.get(), FileDialog::Mode::Folder); } ui::help_on_hover(dmsg::add_photos_help); - if (_sources.empty()) { - ImGui::SameLine(); - ui::TextDisabled(dmsg::no_input_yet); - } - // A row of its own: a finished dataset is not raw input, and three button - // labels in a row run off the edge of a narrow panel in several languages. + same_line_if_fits(dmsg::add_dataset, true); if (ui::Button(dmsg::add_dataset)) { open_pick(PickAction::SourceDataset, msg::pick_existing_dataset.get(), FileDialog::Mode::Folder); } ui::help_on_hover(dmsg::add_dataset_help); + same_line_if_fits(ldmsg::add_scan, true); + if (ui::Button(ldmsg::add_scan)) { + _pick_lidar = -1; + open_pick(PickAction::LidarSource, ldmsg::pick_scan.get(), FileDialog::Mode::File, + {".e57", ".las", ".ply"}, "", /*multi_select=*/true); + } + ui::help_on_hover(ldmsg::add_scan_help); + if (_sources.empty() && _lidar.empty()) { + same_line_if_fits(dmsg::no_input_yet, false); + ui::TextDisabled(dmsg::no_input_yet); + } + draw_lidar_options(); // Masks that came WITH the photos are adopted automatically, which is // right for a prepared capture and wrong for a folder whose masks/ happens @@ -4235,8 +4344,11 @@ void GuiApp::draw_dataset_source() { ui::help_on_hover(dmsg::flip_found_masks_help); ImGui::Unindent(); } + // A scan's photographs or views join the images, so they cannot be + // read where they are. photo_import_combo(&_photo_import, - _sources.size() > 1 || (!_sources.empty() && _sources[0].heif)); + _sources.size() > 1 || (!_sources.empty() && _sources[0].heif) || + (!_lidar.empty() && !_lidar_in_frame)); } ImGui::SetNextItemWidth(px(-220.0f)); @@ -5118,24 +5230,39 @@ void GuiApp::open_mask_preview() { _mask_enable ? selected_mask_model() : MaskModelFiles{}); } -void GuiApp::draw_masking_options() { +GuiApp::MaskingPanel GuiApp::dataset_masking_panel() { + MaskingPanel p; + p.inputs = &_sources; + p.enable = &_mask_enable; + p.border = &_border_enable; + p.features = &_mask_features; + p.preview_input = &_mask_preview_input; + p.frame_shapes = &_frame_shapes; + p.preview_ready = _source_probes_ready; + p.preview_wait = &dmsg::sensors_reading; + p.open_preview = [this] { open_mask_preview(); }; + return p; +} + +void GuiApp::draw_masking_options(const MaskingPanel& p) { + std::vector& inputs = *p.inputs; // Masks that came with an input need no checkbox and no model: they are // already the answer. Say so where the question is asked, because the // alternative is a user turning masking on to "make sure" and waiting // twenty minutes for masks they already had. int with_masks = 0; - for (const PrepInput& s : _sources) + for (const PrepInput& s : inputs) if (!s.mask_dir.empty()) with_masks++; if (with_masks > 0) { - if (with_masks == (int)_sources.size()) + if (with_masks == (int)inputs.size()) ui::TextColoredWrapped(kOk, dmsg::masks_found_all); else ui::TextColoredWrapped(kOk, dmsg::masks_found_some, - {with_masks, (int)_sources.size()}); + {with_masks, (int)inputs.size()}); } ImGui::BeginDisabled(!backends().builtin_masking); - ui::Checkbox(dmsg::mask_enable, &_mask_enable); + ui::Checkbox(dmsg::mask_enable, p.enable); ImGui::EndDisabled(); if (backends().builtin_masking) { ui::help_on_hover(dmsg::mask_enable_help); @@ -5146,28 +5273,29 @@ void GuiApp::draw_masking_options() { // 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) { + if (ui::Checkbox(dmsg::mask_border_enable, p.border) && *p.border && p.fit_lens_border) { // 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, unless it is a GoPro's sphere, whose views have no border. - for (PrepInput& in : _sources) + for (PrepInput& in : inputs) 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) { + std::string& frame_shapes = *p.frame_shapes; + if (*p.border) { ImGui::Indent(); ImGui::SetNextItemWidth(px(240.0f)); const std::string shown = - _frame_shapes.empty() ? dmsg::stencil_areas_per_input.get() : _frame_shapes; + frame_shapes.empty() ? dmsg::stencil_areas_per_input.get() : frame_shapes; if (ui::BeginCombo(dmsg::stencil_areas_preset, shown.c_str())) { if (ImGui::IsWindowAppearing()) _frame_shapes_list = list_stencil_presets(); - if (ui::Selectable(dmsg::stencil_areas_per_input, _frame_shapes.empty())) - _frame_shapes.clear(); - for (const StencilPreset& p : _frame_shapes_list) - if (ui::SelectableRaw(stencil_preset_label(p), p.name == _frame_shapes)) { - _frame_shapes = p.name; - apply_frame_shapes(); + if (ui::Selectable(dmsg::stencil_areas_per_input, frame_shapes.empty())) + frame_shapes.clear(); + for (const StencilPreset& sp : _frame_shapes_list) + if (ui::SelectableRaw(stencil_preset_label(sp), sp.name == frame_shapes)) { + frame_shapes = sp.name; + apply_frame_shapes(inputs, frame_shapes); } ImGui::EndCombo(); } @@ -5178,17 +5306,17 @@ void GuiApp::draw_masking_options() { // Asked wherever the dataset ends up with masks at all, including ones // that arrived with the photographs: what it decides is the reconstruction, // not whether they are written. - if (_mask_enable || _border_enable || with_masks > 0) { - ui::Checkbox(dmsg::mask_for_features, &_mask_features); + if (p.features && (*p.enable || *p.border || with_masks > 0)) { + ui::Checkbox(dmsg::mask_for_features, p.features); ui::help_on_hover(dmsg::mask_for_features_help); } - if (!_mask_enable && !_border_enable) return; + if (!*p.enable && !*p.border) return; ImGui::Indent(); const ModelEntry* entry = find_model(_model_id); - if (_mask_enable) { + if (*p.enable) { const ModelEntry* before = entry; draw_mask_model_picker(_model_id, &_mask_detector_id, _download, [this] { request_model_download(_model_id, _mask_detector_id); @@ -5220,7 +5348,7 @@ void GuiApp::draw_masking_options() { } ui::help_on_hover(dmsg::mask_forget_clicks_help); const std::string unprompted = - inputs_without_clicks(_sources, _mask.clicks); + inputs_without_clicks(inputs, _mask.clicks); if (!unprompted.empty() && (_mask.prompt.empty() || !selected_mask_model().text)) ui::TextColoredWrapped(kWarn, dmsg::mask_inputs_need_clicks, {unprompted}); @@ -5228,33 +5356,33 @@ void GuiApp::draw_masking_options() { } const bool mask_preview_busy = native_work_busy(); - const bool mask_preview_blocked = mask_preview_busy || !_source_probes_ready; + const bool mask_preview_blocked = mask_preview_busy || !p.preview_ready; ImGui::BeginDisabled(mask_preview_blocked); - if (ui::Button(dmsg::mask_try)) open_mask_preview(); + if (ui::Button(dmsg::mask_try)) p.open_preview(); ImGui::EndDisabled(); ui::help_on_hover_disabled( mask_preview_busy ? dmsg::step_locked - : !_source_probes_ready ? dmsg::sensors_reading : dmsg::mask_try_help); + : !p.preview_ready && p.preview_wait ? *p.preview_wait : dmsg::mask_try_help); // Which input it opens, and so which input a NEW click prompts; the ones // already made stay with their own (MaskClick::source). - if (_sources.size() > 1) { + if (inputs.size() > 1) { ImGui::SameLine(); ImGui::SetNextItemWidth(px(220.0f)); std::vector names; - for (const PrepInput& s : _sources) names.push_back(s.subdir.c_str()); - int pick = _mask_preview_input; + for (const PrepInput& s : inputs) names.push_back(s.subdir.c_str()); + int pick = *p.preview_input; if (ui::ComboRaw(ui::detail::label(dmsg::mask_on_input), &pick, names.data(), (int)names.size()) && - pick != _mask_preview_input) { - _mask_preview_input = pick; + pick != *p.preview_input) { + *p.preview_input = pick; // An open panel is showing the old input's frames and editing its // stencil; point both at the new one. - if (_segment.is_open()) open_mask_preview(); + if (_segment.is_open()) p.open_preview(); } ui::help_on_hover(dmsg::mask_on_input_help); } - if (!_mask_enable) { + if (!*p.enable) { ImGui::Unindent(); return; } @@ -5356,7 +5484,7 @@ void GuiApp::draw_masking_options() { // The rest is the memory bank, which photos never get. bool any_video = false; - for (const PrepInput& s : _sources) any_video = any_video || s.is_video; + for (const PrepInput& s : inputs) any_video = any_video || s.is_video; if (any_video) { // A clicked object means nothing on any frame but its own without // the bank, so it forces the option on and the box shows what will @@ -5583,6 +5711,7 @@ const Msg& step_name(Stage s) { case Stage::Features: return dmsg::step_features; case Stage::Matching: return dmsg::step_matching; case Stage::Mapping: return dmsg::step_mapping; + case Stage::Align: return spirula::i18n::msg::lidar::step_align; case Stage::Geometry: return dmsg::step_geometry; case Stage::Finishing: return dmsg::step_finishing; } @@ -5598,25 +5727,26 @@ ImVec4 step_color(StageStatus st) { } } -} // namespace +// A run's steps as a row, in the order it takes them, the running one with its +// own bar under it. This is what the log used to be the only view of. +struct RunStep { + Stage stage; + const Msg* name; +}; -// The six steps as a row, the running one with its own bar under it. This is -// what the log used to be the only view of. -void GuiApp::draw_dataset_steps() { - RunProgress* prog = dataset_steps(); +void draw_run_steps(const RunProgress& prog, const std::vector& steps) { Stage running = Stage::Frames; bool any = false; - for (int i = 0; i < kNumStages; i++) { - const Stage s = (Stage)i; - const StageProgress p = prog->stage(s); + for (size_t i = 0; i < steps.size(); i++) { + const StageProgress p = prog.stage(steps[i].stage); if (i) { ImGui::SameLine(0.0f, px(6.0f)); ui::TextDisabledRaw(">"); ImGui::SameLine(0.0f, px(6.0f)); } - ui::TextColored(step_color(p.status), step_name(s)); + ui::TextColored(step_color(p.status), *steps[i].name); if (p.status == StageStatus::Running) { - running = s; + running = steps[i].stage; any = true; } } @@ -5624,7 +5754,7 @@ void GuiApp::draw_dataset_steps() { // A step that can say how far through it is gets a bar; one that cannot // (the mapper counts registrations, not a total) gets its own sentence. if (any) { - const StageProgress p = prog->stage(running); + const StageProgress p = prog.stage(running); if (p.fraction >= 0.0f) ui::ProgressBarRaw(p.fraction, ImVec2(-1, 0), p.detail.empty() ? nullptr : p.detail.c_str()); @@ -5633,6 +5763,47 @@ void GuiApp::draw_dataset_steps() { } } +// The views that have something to show, as a row of choices; the one drawn +// follows `implied`, the running step's, until the user picks one. -1 when +// none has anything. +int draw_view_tabs(const Msg* const* names, const bool* avail, int n, int implied, + int& pinned) { + int tab = pinned >= 0 ? pinned : (implied >= 0 ? implied : 0); + if (!avail[tab]) { + tab = -1; + for (int i = 0; i < n && tab < 0; i++) + if (avail[i]) tab = i; + } + bool first = true; + for (int i = 0; i < n; i++) { + if (!avail[i]) continue; + if (!first) ImGui::SameLine(); + first = false; + if (ui::RadioButton(*names[i], tab == i)) { + tab = i; + // Picking one pins it: following the run is the default, not the + // rule, and a user who opened the match map to look at a seam + // should not lose it the moment mapping starts. + pinned = i; + } + } + return tab; +} + +} // namespace + +void GuiApp::draw_dataset_steps() { + std::vector all; + for (int i = 0; i < kNumStages; i++) { + // Only a run with a laser scan has the step at all. + if ((Stage)i == Stage::Align && _lidar.empty() && + dataset_steps()->stage(Stage::Align).status == StageStatus::Pending) + continue; + all.push_back({(Stage)i, &step_name((Stage)i)}); + } + draw_run_steps(*dataset_steps(), all); +} + // How much the view changed along every input, as the adaptive pass measures // it. One row per input in the order they were given, so a folder of photos // among the clips is a row that says it has no motion rather than a gap. @@ -5715,6 +5886,7 @@ int GuiApp::preview_for_stage() { case Stage::Masks: return 1; case Stage::Features: return 2; case Stage::Matching: return 3; + case Stage::Align: case Stage::Geometry: return 5; case Stage::Mapping: case Stage::Finishing: return 4; @@ -5848,29 +6020,11 @@ void GuiApp::draw_dataset_preview(float height) { const int implied = preview_for_stage(); if (dataset_busy() && implied >= 0) _preview_last_stage = implied; - int tab = _preview_tab >= 0 ? _preview_tab : (implied >= 0 ? implied : 0); - if (!avail[tab]) { - for (int i = 0; i < 7; i++) - if (avail[i]) { tab = i; break; } - } - const Msg* names[7] = {&dmsg::view_frames, &dmsg::view_masks, &dmsg::view_features, &dmsg::view_matrix, &dmsg::view_model, &dmsg::view_geometry, &dmsg::view_motion}; - bool first = true; - for (int i = 0; i < 7; i++) { - if (!avail[i]) continue; - if (!first) ImGui::SameLine(); - first = false; - if (ui::RadioButton(*names[i], tab == i)) { - tab = i; - // Picking one pins it: following the run is the default, not the - // rule, and a user who opened the match map to look at a seam - // should not lose it the moment mapping starts. - _preview_tab = i; - } - } + const int tab = draw_view_tabs(names, avail, 7, implied, _preview_tab); if (tab == 3) ui::help_on_hover(dmsg::matrix_help); if (tab == 6) ui::help_on_hover(dmsg::frame_spacing_help); @@ -7069,11 +7223,12 @@ void GuiApp::draw_dataset_form(float height, bool running) { draw_dataset_basics(); ImGui::Spacing(); ImGui::BeginDisabled(dataset_locked(Stage::Masks)); - draw_masking_options(); + draw_masking_options(dataset_masking_panel()); ImGui::EndDisabled(); ImGui::Spacing(); - ImGui::BeginDisabled(dataset_locked(Stage::Geometry)); + // A laser scan gives the run its depth and normals. + ImGui::BeginDisabled(dataset_locked(Stage::Geometry) || !_lidar.empty()); draw_geometry_options(); ImGui::EndDisabled(); ImGui::Spacing(); @@ -7093,7 +7248,7 @@ void GuiApp::draw_dataset_form(float height, bool running) { // ---- run / status ---- const float action_y0 = ImGui::GetCursorPosY(); ImGui::Spacing(); - bool input_missing = _sources.empty() || _workspace.empty(); + bool input_missing = (_sources.empty() && _lidar.empty()) || _workspace.empty(); for (const PrepInput& s : _sources) input_missing = input_missing || s.path.empty(); const bool ready = !running && !input_missing && _source_probes_ready; @@ -7125,21 +7280,18 @@ void GuiApp::draw_dataset_form(float height, bool running) { FileDownload& dl = need_mask_model ? _download : need_feat_model ? _feat_download.current() : _geom_download.current(); - ImGui::SameLine(); - ui::TextDisabled(need_mask_model ? dmsg::mask_model_first + draw_model_fetch(dl, + need_mask_model ? dmsg::mask_model_first : need_feat_model ? dmsg::feat_model_first - : dmsg::geom_model_first); - ImGui::SameLine(); - if (dl.state() == FileDownload::State::Running) - ui::ProgressBarRaw(std::max(dl.progress(), 0.0f), - ImVec2(px(200.0f), 0), dl.status().c_str()); - else if (ui::Button(need_mask_model ? dmsg::mask_get_model - : need_feat_model ? dmsg::feat_get_model - : dmsg::geom_get_model)) { - if (need_mask_model) request_model_download(_model_id, _mask_detector_id); - else if (need_feat_model) request_feature_download(); - else request_geometry_download(); - } + : dmsg::geom_model_first, + need_mask_model ? dmsg::mask_get_model + : need_feat_model ? dmsg::feat_get_model + : dmsg::geom_get_model, + [&] { + if (need_mask_model) request_model_download(_model_id, _mask_detector_id); + else if (need_feat_model) request_feature_download(); + else request_geometry_download(); + }); } if (ready) { draw_dataset_rerun(workspace_state()); diff --git a/src/app/gui/GuiApp.h b/src/app/gui/GuiApp.h index 0fb3045eb..2f7c1946c 100644 --- a/src/app/gui/GuiApp.h +++ b/src/app/gui/GuiApp.h @@ -23,6 +23,7 @@ #include "app/gui/PairPreview.h" #include "app/gui/SfmProgress.h" #include "app/gui/Layout.h" +#include "app/gui/LidarStep.h" #include "app/gui/MeshRunner.h" #include "app/gui/ModelCache.h" #include "app/gui/RecentList.h" @@ -35,13 +36,15 @@ #include "app/gui/TrainPreset.h" #include "app/gui/TrainRunner.h" #include "app/gui/ViewportPanel.h" +#include "data/PointCloudFile.h" +#include #include #include #include +#include #include #include -#include #include #include #include @@ -118,7 +121,7 @@ class GuiApp { MeshSource, MeshPhotos, MeshOutput, AddSplatFile, SplatFolder, EditSaveFile, EditSaveFolder, RenderProjectSave, RenderProjectOpen, RenderOutput, RenderAddModel, StencilFile, SeedPointcloud, - ConfigPath + ConfigPath, LidarSource }; // Which reconstruction back end the New Dataset screen runs. enum class Engine { BuiltIn, Colmap }; @@ -312,6 +315,10 @@ class GuiApp { // find them. `detector_id` is ignored for an entry that takes none. void request_model_download(const std::string& id, const std::string& detector_id); bool mask_model_missing() const; + bool mask_model_missing(bool enable, const std::vector& inputs) const; + // What is missing and the button that fetches it, or its download's bar. + void draw_model_fetch(FileDownload& dl, const spirula::i18n::Msg& missing, + const spirula::i18n::Msg& get, const std::function& request); bool license_accepted(const std::string& family) const; // ---- screens ---- @@ -353,7 +360,23 @@ class GuiApp { // path (an image header, or one `ffmpeg -i`) and remembered. bool input_pixel_size(const std::string& path, bool is_video, int& w, int& h); - void draw_masking_options(); + // What the masking options edit: the inputs and switches it is handed. + struct MaskingPanel { + std::vector* inputs = nullptr; + bool* enable = nullptr; + bool* border = nullptr; + bool* features = nullptr; // null: no reconstruction to keep them from + int* preview_input = nullptr; + std::string* frame_shapes = nullptr; + bool fit_lens_border = true; // ticking the stencil fits a fisheye rim + bool preview_ready = true; // frames to try the mask on + const spirula::i18n::Msg* preview_wait = nullptr; // why not, when not + std::function open_preview; + }; + MaskingPanel dataset_masking_panel(); + void draw_masking_options(const MaskingPanel& p); + // The job's mask_* fields from the shared settings; `mask_enable` excepted. + void fill_masking(PrepJob& prep) const; void draw_geometry_options(); // Opens the geometry preview on the output folder when it already holds a // reconstruction -- the only case where the real cameras are known -- and @@ -414,6 +437,7 @@ class GuiApp { // folder itself the output, so the run adds to it instead of building a // copy beside it. void add_existing_dataset(const std::string& dir); + void add_dataset_folder(const std::string& dir); // Every option back to what a freshly picked input would have given it. // The inputs, the output folder and the mask prompt are not options. void reset_recon_options(); @@ -443,6 +467,27 @@ class GuiApp { // the picker, the drop handler and the "mesh this run" shortcut. void set_mesh_source(const std::string& path); void start_meshing(); + // LiDAR scans on the dataset screen (LidarSources.cpp): rows laid out as + // the inputs' above them, and the switches a run with them has. + void draw_lidar_rows(float path_w, bool one_line); + void draw_lidar_options(); + void add_lidar_sources(const std::vector& paths); + void replace_lidar_source(size_t i, const std::string& path); + void clear_lidar_sources(); + // An XGRIDS export picked as a dataset: its fisheye model, its LAS, its + // masks the other way round. False when `dir` is no such export. + bool add_xgrids_export(const std::string& dir); + // E57 / LAS / LAZ by name; a PLY says in its header, and is a scan where + // a dataset is being put together and something to look at elsewhere. + static bool is_lidar_drop(const std::string& path); + std::vector raw_inputs(const std::vector& paths); + static bool ply_is_point_cloud(const std::string& path); + // The scans' photographs are the only images and could keep their poses. + bool scanner_poses_offered() const; + LidarJob lidar_job() const; + // The input list emptied for a different capture, with what was decided + // about it; the scans stay. + void clear_sources(); // Would the run about to start actually get cameras? Resolves the dataset // the way the child does -- the folder typed here, else the `data` entry // in the run's config.json -- so the screen can warn BEFORE the run that @@ -690,6 +735,21 @@ class GuiApp { std::string _mesh_data_probe_key; bool _mesh_data_probe_found = false; + // ---- laser scans the dataset is aligned with (LidarSources.cpp) ---- + struct LidarInput { + std::string path; + std::string edit; // the path box, until it is committed + int64_t points = 0; // as the header declares + int64_t photos = 0; // images an E57 carries with poses + std::vector stations; + }; + bool read_lidar_input(const std::string& path, LidarInput& in); + std::vector _lidar; + int _pick_lidar = -1; // the row a Browse... replaces; -1 adds + bool _lidar_photos = true; // the scans' photographs join the reconstruction + bool _lidar_in_frame = false; // the model is already in the scans' frame + bool _lidar_keep_poses = true; // ... or are all there is: no reconstruction + // Dataset creation. Both runners exist; only one runs, chosen by _engine // (and forced when only one is available). Engine _engine = Engine::BuiltIn; @@ -787,7 +847,8 @@ class GuiApp { // once the panel edits what it drew, since the name no longer says what is. std::string _frame_shapes; std::vector _frame_shapes_list; // read when the picker opens - void apply_frame_shapes(size_t first_input = 0); + void apply_frame_shapes(std::vector& inputs, std::string& preset, + size_t first_input = 0); void save_run_stencils(); MaskSettings _mask; SegmentPanel _segment; diff --git a/src/app/gui/GuiMain.cpp b/src/app/gui/GuiMain.cpp index 376802382..453d9e2d0 100644 --- a/src/app/gui/GuiMain.cpp +++ b/src/app/gui/GuiMain.cpp @@ -294,6 +294,7 @@ int spirula_gui_main(int argc, char** argv) { // After the backend's own mouse update, so an injected position // is the later event and wins for this frame. const bool scripted = gui::automation::begin_frame(); + if (const auto d = gui::automation::take_drop(); !d.empty()) app.handle_drop(d); ImGui::NewFrame(); app.frame(); const bool busy = ui_busy() || scripted || app.animating(); diff --git a/src/app/gui/LidarSources.cpp b/src/app/gui/LidarSources.cpp new file mode 100644 index 000000000..7a8b4367f --- /dev/null +++ b/src/app/gui/LidarSources.cpp @@ -0,0 +1,195 @@ +// Laser scans on the dataset screen: the files, what they hold, and the +// switches a run with them has (app/gui/LidarStep.h does the work). + +#include "app/gui/GuiApp.h" + +#include "app/LidarDataset.h" +#include "app/gui/Ui.h" +#include "checkpoint/SplatPly.h" +#include "data/PointCloudFile.h" +#include "i18n/catalog/Dataset.h" +#include "i18n/catalog/Lidar.h" +#include "mesh/MeshImport.h" + +#include +#include +#include + +namespace fs = std::filesystem; +namespace dmsg = spirula::i18n::msg::dataset; +namespace ldmsg = spirula::i18n::msg::lidar; +using spirula::i18n::format; + +namespace gui { +namespace { + +const ImVec4 kWarnColor(0.95f, 0.75f, 0.30f, 1.0f); + +std::string lower_ext(const std::string& path) { + std::string ext = fs::path(path).extension().string(); + for (char& c : ext) c = (char)std::tolower((unsigned char)c); + return ext; +} + +} // namespace + +bool GuiApp::is_lidar_drop(const std::string& path) { + const std::string ext = lower_ext(path); + return ext == ".e57" || ext == ".las" || ext == ".laz"; +} + +bool GuiApp::ply_is_point_cloud(const std::string& path) { + return lower_ext(path) == ".ply" && !spirula::is_splat_ply(path) && + !meshing::ply_is_mesh(path); +} + +bool GuiApp::read_lidar_input(const std::string& path, LidarInput& in) { + std::error_code ec; + in = LidarInput{}; + in.path = in.edit = fs::absolute(path, ec).string(); + try { + spirula::cloud::Reader r(in.path); + if (r.e57()) + for (const auto& im : r.e57()->images()) in.photos += im.has_pose; + in.points = r.info().points; + in.stations = r.info().stations; + } catch (const std::exception& e) { + log(format(ldmsg::scan_unreadable, {in.path, std::string(e.what())})); + return false; + } + return true; +} + +void GuiApp::add_lidar_sources(const std::vector& paths) { + for (const std::string& p : paths) { + if (p.empty()) continue; + LidarInput in; + if (!read_lidar_input(p, in)) continue; + if (std::none_of(_lidar.begin(), _lidar.end(), + [&](const LidarInput& l) { return l.path == in.path; })) + _lidar.push_back(std::move(in)); + } + refresh_sources(); +} + +void GuiApp::replace_lidar_source(size_t i, const std::string& path) { + LidarInput in; + if (i >= _lidar.size()) return; + if (!read_lidar_input(path, in)) { + _lidar[i].edit = _lidar[i].path; + return; + } + _lidar[i] = std::move(in); + refresh_sources(); +} + +void GuiApp::clear_lidar_sources() { + _lidar.clear(); + _lidar_photos = true; + _lidar_in_frame = false; + _lidar_keep_poses = true; +} + +bool GuiApp::add_xgrids_export(const std::string& dir) { + // LCC Studio's developer_data/: raw/ (the original lenses) and perspective/ + // (pinhole crops of them), each a COLMAP dataset, beside the LiDAR map. + fs::path root(dir); + std::error_code ec; + if (fs::is_directory(root / "developer_data", ec)) root /= "developer_data"; + if (root.filename() == "raw" || root.filename() == "perspective") root = root.parent_path(); + if (!fs::is_directory(root / "raw" / "sparse", ec) || + !fs::is_directory(root / "raw" / "images", ec)) + return false; + std::vector clouds; + for (const auto& e : fs::directory_iterator(root, ec)) { + if (e.is_regular_file(ec) && lower_ext(e.path().string()) == ".las") + clouds.push_back(e.path().string()); + } + if (clouds.empty()) return false; + add_dataset_folder((root / "raw").string()); + add_lidar_sources(clouds); + _lidar_in_frame = true; + _use_found_masks = true; + _flip_found_masks = true; + // Nothing may be written into the export: its photos and masks are read + // where they are (any other import folds the masks into the dataset's + // masks/, which here is theirs), and no stencil is merged into them. + _photo_import = PhotoImport::InPlace; + _border_enable = false; + log(ldmsg::xgrids_found.get()); + return true; +} + +bool GuiApp::scanner_poses_offered() const { + if (!_sources.empty() || _lidar.empty()) return false; + std::vector> stations; + bool photos = false; + for (const LidarInput& l : _lidar) { + stations.push_back(l.stations); + photos = photos || l.photos > 0; + } + return photos && app::lidar::scans_share_frame(stations); +} + +LidarJob GuiApp::lidar_job() const { + LidarJob j; + for (const LidarInput& l : _lidar) j.clouds.push_back(l.path); + // With nothing else, the scans' photographs are the dataset: always used, + // and there is no other model to be in their frame. + j.scan_photos = _lidar_photos || _sources.empty(); + j.in_frame = _lidar_in_frame && !_sources.empty(); + j.scanner_only = _lidar_keep_poses && scanner_poses_offered(); + j.flip_masks = _use_found_masks && _flip_found_masks; + return j; +} + +void GuiApp::draw_lidar_rows(float path_w, bool one_line) { + int remove = -1; + ImGui::PushID("lidar"); + for (size_t i = 0; i < _lidar.size(); i++) { + ImGui::PushID((int)i); + ImGui::SetNextItemWidth(path_w); + ui::InputTextRaw("##in", &_lidar[i].edit); + if (ImGui::IsItemDeactivatedAfterEdit() && _lidar[i].edit != _lidar[i].path) + replace_lidar_source(i, _lidar[i].edit); + ImGui::SameLine(); + if (ui::Button(dmsg::browse)) { + _pick_lidar = (int)i; + open_pick(PickAction::LidarSource, ldmsg::pick_scan.get(), FileDialog::Mode::File, + {".e57", ".las", ".ply"}); + } + ImGui::SameLine(); + if (ui::Button(dmsg::remove)) remove = (int)i; + if (one_line) ImGui::SameLine(); + ui::Text(ldmsg::scan_row, {(long long)_lidar[i].points, (long long)_lidar[i].photos}); + ImGui::PopID(); + } + ImGui::PopID(); + if (remove >= 0 && !dataset_busy()) { + _lidar.erase(_lidar.begin() + remove); + refresh_sources(); + } +} + +void GuiApp::draw_lidar_options() { + if (_lidar.empty()) return; + bool photos = false; + for (const LidarInput& l : _lidar) photos = photos || l.photos > 0; + if (scanner_poses_offered()) { + ui::Checkbox(ldmsg::keep_scanner_poses, &_lidar_keep_poses); + ui::help_on_hover(ldmsg::keep_scanner_poses_help); + } else if (photos && !_sources.empty() && !_lidar_in_frame) { + ui::Checkbox(ldmsg::use_scan_photos, &_lidar_photos); + ui::help_on_hover(ldmsg::use_scan_photos_help); + } + if (!_sources.empty()) { + ui::Checkbox(ldmsg::scan_in_frame, &_lidar_in_frame); + ui::help_on_hover(ldmsg::scan_in_frame_help); + } + if (effective_engine() != Engine::BuiltIn) + ui::TextColoredWrapped(kWarnColor, ldmsg::scan_needs_builtin); + else + ui::TextDisabled(ldmsg::scan_replaces_geometry); +} + +} // namespace gui diff --git a/src/app/gui/LidarStep.cpp b/src/app/gui/LidarStep.cpp new file mode 100644 index 000000000..060b772cd --- /dev/null +++ b/src/app/gui/LidarStep.cpp @@ -0,0 +1,178 @@ +#include "app/gui/LidarStep.h" + +#include "app/AppPaths.h" +#include "app/LidarDataset.h" +#include "app/gui/Subprocess.h" +#include "data/PointCloudFile.h" +#include "i18n/Locale.h" +#include "i18n/catalog/Lidar.h" +#include "i18n/catalog/Log.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +namespace lmsg = spirula::i18n::msg::log; +namespace ldmsg = spirula::i18n::msg::lidar; +using spirula::i18n::format; + +namespace gui { +namespace { + +constexpr const char* kScanPrefix = "scan_"; +constexpr const char* kViewsDir = "scan_views"; +// The rendered views' lens: 1024 px square, 90 degrees across. +constexpr const char* kViewsFocal = "512"; + +std::string trim_right(const std::string& s) { + size_t n = s.size(); + while (n && (s[n - 1] == ' ' || s[n - 1] == '\t' || s[n - 1] == '\r')) n--; + return s.substr(0, n); +} + +std::string folder_name(const std::string& cloud) { + std::string s = kScanPrefix; + for (char c : fs::path(cloud).stem().string()) + s += (std::isalnum((unsigned char)c) || c == '-') ? c : '_'; + return s; +} + +} // namespace + +bool add_scan_photo_inputs(const LidarJob& job, const std::string& workspace, + std::vector& inputs, LidarPrep& out, std::string& error) { + const fs::path root = fs::path(workspace) / "lidar"; + const fs::path anchors = root / "anchors.json"; + std::error_code ec; + // Written afresh every run, so a scan taken off the list leaves nothing. + fs::remove(anchors, ec); + if (!job.enabled() || job.in_frame || !job.scan_photos) return true; + try { + std::set used; + for (const std::string& c : job.clouds) { + spirula::cloud::Reader r(c); + if (!r.e57() || r.e57()->images().empty()) continue; + std::string sub = folder_name(c); + for (int k = 2; used.count(sub); k++) sub = folder_name(c) + "_" + std::to_string(k); + used.insert(sub); + const app::lidar::ExtractedPhotos x = app::lidar::extract_e57_anchors( + c, (root / "photos").string(), sub, anchors.string()); + auto add = [&](const std::string& rel, bool pinhole) { + PrepInput in; + in.path = (root / "photos" / rel).string(); + in.subdir = rel; + in.camera_model = pinhole ? "pinhole" : "equirectangular"; + in.focal_factor = pinhole ? (float)x.focal_factor : 0.0f; + // A walk's panoramas are taken in order; a tripod's faces are not. + in.sequential = !pinhole; + inputs.push_back(in); + }; + if (x.pinhole) add(x.split ? sub + "/pinhole" : sub, true); + if (x.panorama) add(x.split ? sub + "/panorama" : sub, false); + out.photos += x.pinhole + x.panorama; + if (x.pinhole + x.panorama > 0) out.photographed.push_back(c); + } + } catch (const std::exception& e) { + error = e.what(); + return false; + } + return true; +} + +bool render_scan_views(const LidarJob& job, const std::string& workspace, + const std::string& image_dir, RunProgress& prog, + const std::atomic& cancel, LidarPrep& out, std::string& error) { + std::error_code ec; + const fs::path rel = fs::weakly_canonical(image_dir, ec) + .lexically_relative(fs::weakly_canonical(workspace, ec)); + if (rel.empty() || *rel.begin() == "..") return true; + // Views of an earlier run would otherwise be reconstructed as photographs. + fs::remove_all(fs::path(image_dir) / kViewsDir, ec); + if (!job.enabled() || job.in_frame) return true; + auto log = [&](const std::string& s) { prog.note(s, false); }; + try { + const bool shared = app::lidar::scans_share_frame(job.clouds); + std::vector clouds; + for (const std::string& c : job.clouds) + if (std::find(out.photographed.begin(), out.photographed.end(), c) == + out.photographed.end()) + clouds.push_back(c); + if (clouds.empty() || (shared && out.photos > 0)) return true; + const fs::path anchors = fs::path(workspace) / "lidar" / "anchors.json"; + out.views = app::lidar::render_anchor_views(clouds, !shared, image_dir, kViewsDir, + anchors.string(), log, &cancel); + } catch (const std::exception& e) { + error = e.what(); + return false; + } + if (cancel.load()) return false; + log(format(ldmsg::rendered_views, {(long long)out.views})); + if (out.views > 0) + out.sfm_args.insert(out.sfm_args.end(), + {"--camera-model", std::string(kViewsDir) + "=pinhole", + "--focal", std::string(kViewsDir) + "=" + kViewsFocal}); + return true; +} + +bool run_lidar_step(const LidarJob& job, const std::string& dataset, + const std::string& image_dir, RunProgress& prog, FilmReel* reel, + const std::atomic& cancel, std::string& error) { + if (app::exe_path().empty()) { + error = lmsg::err_no_exe_path.get(); + return false; + } + prog.enter(Stage::Align, ldmsg::stage_align.get()); + std::vector argv = { + app::exe_path(), "--lang", spirula::i18n::code(spirula::i18n::current()), + "lidar", dataset, "--image-dir", image_dir.empty() ? std::string("images") : image_dir, + }; + for (const std::string& c : job.clouds) { + argv.push_back("--cloud"); + argv.push_back(c); + } + if (!job.mask_dir.empty()) argv.insert(argv.end(), {"--mask-dir", job.mask_dir}); + if (job.in_frame) argv.insert(argv.end(), {"--mode", "keep"}); + if (job.flip_masks) argv.push_back("--flip-masks"); + if (job.scanner_poses_only) argv.push_back("--scanner-poses"); + std::string cmd; + for (const std::string& a : argv) cmd += (cmd.empty() ? "$ " : " ") + a; + prog.note(cmd, true); + + const fs::path root(dataset); + OutputWatch watch(reel, root / "normals", image_dir, root / "depths", /*fresh_only=*/true); + const int rc = run_process(argv, "", [&](const std::string& raw) { + const std::string line = trim_right(raw); + if (line.empty()) return; + std::vector got; + if (spirula::i18n::scan(ldmsg::progress_maps, line, got) && got.size() >= 2) { + prog.count(Stage::Align, std::atoll(got[0].c_str()), std::atoll(got[1].c_str())); + prog.detail(Stage::Align, line); + watch.poll(); + return; + } + prog.note(line, false); + watch.poll(); + }, cancel); + watch.poll(/*flush=*/true); + if (rc == kCancelled) { + error = lmsg::err_cancelled.get(); + return false; + } + if (rc == kSpawnFailed) { + error = format(ldmsg::err_spawn, {argv[0]}); + return false; + } + if (rc != 0) { + error = ldmsg::err_failed.get(); + return false; + } + prog.mark(Stage::Align, StageStatus::Done); + return true; +} + +} // namespace gui diff --git a/src/app/gui/LidarStep.h b/src/app/gui/LidarStep.h new file mode 100644 index 000000000..cb69c5c8c --- /dev/null +++ b/src/app/gui/LidarStep.h @@ -0,0 +1,54 @@ +#pragma once + +// The laser-scan half of a dataset run (docs/notes/lidar-alignment.md): before +// the reconstruction, the scans' photographs -- or, for a scan that has none, +// views rendered from where it was taken -- go in beside the frames with their +// poses as anchors; after it, `spirula lidar` moves the model into the scans' +// frame and renders depth and normals from them, in place of the geometry step. + +#include "app/gui/DatasetPrep.h" +#include "app/gui/FilmReel.h" +#include "app/gui/PrepProgress.h" + +#include +#include +#include + +namespace gui { + +struct LidarJob { + std::vector clouds; // .e57 / .las / .ply + bool scan_photos = true; // an E57's photographs join the reconstruction + bool in_frame = false; // the model is already in the scans' frame + bool scanner_only = false; // its photographs are all the images: no reconstruction + std::string mask_dir; // the dataset's masks, "" for none + bool flip_masks = false; // the dataset's masks paint what to remove + bool scanner_poses_only = false; // no reconstruction, or one that failed: use none of it + bool enabled() const { return !clouds.empty(); } +}; + +struct LidarPrep { + std::vector sfm_args; // the rendered views' lens, for `spirula sfm` + int64_t photos = 0; // scanner photographs with known poses + int64_t views = 0; // views rendered from the scans + std::vector photographed; // the clouds those photographs came from +}; + +// Before the frames: each scan's photographs, extracted under +// /lidar/photos/ with their poses recorded as anchors, become +// inputs of their own, so they are imported and masked like any other. +bool add_scan_photo_inputs(const LidarJob& job, const std::string& workspace, + std::vector& inputs, LidarPrep& out, std::string& error); + +// After the frames: views of the scans without photographs rendered into +// `image_dir`/scan_views as anchors, unless one sharing their frame has them, +// and only when that folder is inside `workspace` (else it is the user's). +bool render_scan_views(const LidarJob& job, const std::string& workspace, + const std::string& image_dir, RunProgress& prog, + const std::atomic& cancel, LidarPrep& out, std::string& error); + +bool run_lidar_step(const LidarJob& job, const std::string& dataset, + const std::string& image_dir, RunProgress& prog, FilmReel* reel, + const std::atomic& cancel, std::string& error); + +} // namespace gui diff --git a/src/app/gui/PrepProgress.h b/src/app/gui/PrepProgress.h index c2fed89be..37b237fdc 100644 --- a/src/app/gui/PrepProgress.h +++ b/src/app/gui/PrepProgress.h @@ -21,9 +21,9 @@ namespace gui { // The steps a dataset run goes through, in order. Both engines report through // the same list; a step a run does not need is never entered. enum class Stage { - Frames, Masks, Features, Matching, Mapping, Geometry, Finishing + Frames, Masks, Features, Matching, Mapping, Align, Geometry, Finishing }; -inline constexpr int kNumStages = 7; +inline constexpr int kNumStages = 8; enum class StageStatus { Pending, Running, Done, Skipped, Failed }; diff --git a/src/app/gui/SfmRunner.cpp b/src/app/gui/SfmRunner.cpp index 96ed3b57e..5b07a3755 100644 --- a/src/app/gui/SfmRunner.cpp +++ b/src/app/gui/SfmRunner.cpp @@ -9,6 +9,7 @@ #include #include "i18n/Locale.h" +#include "i18n/catalog/Lidar.h" #include "i18n/catalog/Log.h" #include "app/AppPaths.h" @@ -809,6 +810,14 @@ void SfmRunner::run(SfmJob job) { } // ---- 1. frames and masks ------------------------------------------ + // The scans' photographs are inputs too: their poses in the scan are + // what places the model in it. + LidarPrep lidar; + { + std::string err; + if (!add_scan_photo_inputs(job.lidar, ws.string(), job.prep.inputs, lidar, err)) + return fail(err); + } PrepResult prep; { DatasetPrep dp(&_prog, _films, _cancel); @@ -849,15 +858,42 @@ void SfmRunner::run(SfmJob job) { log(lmsg::one_camera_per_folder.get()); job.camera_mode = 1; } + // A scan without photographs is matched through views of it. + if (job.lidar.enabled()) { + std::string err; + if (!render_scan_views(job.lidar, ws.string(), prep.image_dir, _prog, _cancel, + lidar, err)) + return fail(_cancel.load() ? lmsg::err_cancelled.get() : err); + } + // A reconstruction that fails is not the end of a run whose scans + // carry photographs: those keep the scanner's poses. + const bool scanner_stands_in = job.lidar.enabled() && lidar.photos > 0; + // ... and when they are all the images there are, none is run: a + // scanner's cube faces mostly will not reconstruct (Matterport: 26 of + // 630), and its poses are what its points are in. + const bool scanner_only = job.lidar.scanner_only && lidar.photos > 0; + if (scanner_only) { + log(spirula::i18n::msg::lidar::scanner_poses_used.get(), false); + job.lidar.scanner_poses_only = true; + } // ---- 2. reconstruction -------------------------------------------- take_reconstruction(job); - plan = plan_dataset(plan_job(job), prior, rec, req, &plan, Step::Model); - say(Step::Model); - const bool reuse_model = !makes(plan[Step::Model].act); + if (!lidar.sfm_args.empty()) job.camera_mode = std::max(job.camera_mode, 1); + PlanJob planned = plan_job(job); + // The rendered views' lens makes the model as much as the panel does. + if (!lidar.sfm_args.empty()) { + std::string views; + for (const std::string& a : lidar.sfm_args) views += (views.empty() ? "" : " ") + a; + planned.model.push_back({"scan_views", "", views}); + } + plan = plan_dataset(planned, prior, rec, req, &plan, Step::Model); + if (!scanner_only) say(Step::Model); + const bool reuse_model = scanner_only || !makes(plan[Step::Model].act); if (!reuse_model) { - const std::vector now = recon_args(job, prep); - record.begin(Step::Model, model_fields(job)); + std::vector now = recon_args(job, prep); + now.insert(now.end(), lidar.sfm_args.begin(), lidar.sfm_args.end()); + record.begin(Step::Model, planned.model); set_stage(Stage::Features, lmsg::stage_reconstructing_features.get()); // What features/ and matches.bin are still worth is the run's own // decision, per stage and per file (sfm/core/Resume.h). The @@ -948,6 +984,10 @@ void SfmRunner::run(SfmJob job) { } if (_partial) log(lmsg::sfm_partial.get()); if (_not_metric) log(lmsg::sfm_not_metric.get()); + } else if (rc != 0 && scanner_stands_in) { + log(spirula::i18n::msg::lidar::sfm_failed_scanner_poses.get(), false); + // Whatever sparse/ holds is an earlier run's, not this one's. + job.lidar.scanner_poses_only = true; } else if (rc != 0) { return fail(gpu_failure ? fmt(lmsg::err_recon_gpu, @@ -958,12 +998,28 @@ void SfmRunner::run(SfmJob job) { // Only for a run that reconstructed: a reused model may be a // transforms.json or a Metashape export, which has no sparse/ at all. - if (!reuse_model && !has_model(ws / "sparse")) + const bool modelled = !job.lidar.scanner_poses_only && + (reuse_model || has_model(ws / "sparse")); + if (!modelled && !scanner_stands_in) return fail(lmsg::err_no_reconstruction.get()); - if (!reuse_model) record.finish(Step::Model); + if (!reuse_model && modelled) record.finish(Step::Model); + + // ---- 3. the laser scans: alignment, then their depth and normals ---- + if (job.lidar.enabled()) { + // Masks folded into masks/ are already the right way round; ones + // read where they lie keep their own convention. + job.lidar.flip_masks = prep.mask_dir_flipped; + job.lidar.mask_dir = prep.mask_dir; + std::string err; + if (!run_lidar_step(job.lidar, ws.string(), prep.image_dir, _prog, + _films.geometry, _cancel, err)) + return fail(err); + } - // ---- 3. depth and normals ------------------------------------------- + // ---- 4. depth and normals ------------------------------------------- take_geometry(job); + // The scans gave the run its depth and normals. + if (job.lidar.enabled()) job.geometry.enable = false; plan = plan_dataset(plan_job(job), prior, rec, req, &plan, Step::Geometry); say(Step::Geometry); if (makes(plan[Step::Geometry].act)) { @@ -977,7 +1033,7 @@ void SfmRunner::run(SfmJob job) { record.finish(Step::Geometry); } - // ---- 4. tidy up ---------------------------------------------------- + // ---- 5. tidy up ---------------------------------------------------- // Swept by sweep_intermediates(), not here: the screen reads them // after the run ends. Only ones this run produced. { diff --git a/src/app/gui/SfmRunner.h b/src/app/gui/SfmRunner.h index a676edab1..c73fd3238 100644 --- a/src/app/gui/SfmRunner.h +++ b/src/app/gui/SfmRunner.h @@ -26,6 +26,7 @@ #endif #include "app/gui/FilmReel.h" #include "app/gui/GeometryRunner.h" +#include "app/gui/LidarStep.h" #include "app/gui/PrepProgress.h" #include "i18n/catalog/Dataset.h" @@ -111,6 +112,8 @@ struct SfmJob { // ... and so is this: the depth and normal maps are written after the // reconstruction, from the dataset it produced, whichever engine made it. GeometryJob geometry; + // Laser scans the model is aligned with; they replace the geometry step. + LidarJob lidar; // ---- reconstruction ---- // What the user asked to redo or keep; the plan (DatasetPlan.h) decides diff --git a/src/app/gui/tests/dataset_plan_test.cpp b/src/app/gui/tests/dataset_plan_test.cpp index 450351a5c..c92173843 100644 --- a/src/app/gui/tests/dataset_plan_test.cpp +++ b/src/app/gui/tests/dataset_plan_test.cpp @@ -140,6 +140,15 @@ int main() { "the same settings reuse every step"); expect(p[Step::Geometry].act == Act::None, "geometry off is not a step"); } + { + SfmJob scans = made; + scans.lidar.clouds = {"/scan.e57"}; + scans.lidar.scanner_only = true; + scans.geometry.enable = true; + const DatasetPlan p = plan(scans); + expect(p[Step::Model].act == Act::None && p[Step::Geometry].act == Act::None, + "a scan's photographs at its poses: no reconstruction, no geometry step"); + } { SfmJob j = made; j.geometry.enable = true; diff --git a/src/app/tests/e57_dataset_test.cpp b/src/app/tests/e57_dataset_test.cpp new file mode 100644 index 000000000..41f93ced4 --- /dev/null +++ b/src/app/tests/e57_dataset_test.cpp @@ -0,0 +1,323 @@ +// e57_dataset_test -- app/E57Dataset.h end to end: a synthetic E57 file is +// written out as a dataset, read back by the trainer's own parser, and every +// camera must put a world point on the pixel the E57 file says it lands on. +// The E57 side below is the convention measured on real scanner files +// (docs/datasets.md "E57 laser scans"); the other side is the engine's. + +#include "app/E57Dataset.h" +#include "app/ScanDepth.h" +#include "core/CameraModel.h" +#include "data/CameraMath.h" +#include "data/DatasetParser.h" +#include "data/SceneTransform.h" +#include "data/tests/E57TestFile.h" +#include "external/stb_image_write.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +constexpr double kPi = 3.14159265358979323846; +int g_failures = 0; + +void check(bool ok, const std::string& what) { + std::printf("%s %s\n", ok ? "ok " : "FAIL", what.c_str()); + if (!ok) g_failures++; +} + +void rotation(const double q[4], double R[9]) { + const double w = q[0], x = q[1], y = q[2], z = q[3]; + const double n = std::sqrt(w*w + x*x + y*y + z*z); + spirula::e57::Pose p; + for (int i = 0; i < 4; i++) p.q[i] = q[i] / n; + p.rotation(R); +} + +// Local = R^T (p - t). +void to_local(const double R[9], const double t[3], const double p[3], double out[3]) { + const double d[3] = {p[0] - t[0], p[1] - t[1], p[2] - t[2]}; + for (int c = 0; c < 3; c++) out[c] = R[c]*d[0] + R[3 + c]*d[1] + R[6 + c]*d[2]; +} + +struct Cam { + double q[4], t[3]; +}; + +uint8_t green8(int g10) { return (uint8_t)(g10 / 1023.0 * 255.0 + 0.5); } + +// A room of coloured 1 m tiles: texture enough for the alignment check to tell +// a camera's true orientation from the other 23. +std::vector tiled_room(std::mt19937& rng, int n) { + std::uniform_real_distribution u(0.0, 1.0); + std::vector pts; + for (int i = 0; i < n; i++) { + double p[3] = {-4 + 8 * u(rng), -4 + 8 * u(rng), -1.5 + 4 * u(rng)}; + const int face = (int)(u(rng) * 6); + p[face / 2] = face / 2 == 2 ? (face % 2 ? 2.5 : -1.5) : (face % 2 ? 4.0 : -4.0); + const int t = ((int)std::floor(p[0] + 8) * 7 + (int)std::floor(p[1] + 8) * 13 + + (int)std::floor(p[2] + 8) * 3 + face * 5) & 7; + pts.push_back({std::round(p[0] * 1e4) / 1e4, (double)(float)p[1], p[2], + t & 1 ? 230 : 25, t & 2 ? 950 : 90, t & 4 ? 210 : 35, 0}); + } + return pts; +} + +// What the camera `R_gl` (OpenGL axes, columns) at `t` sees of the room, as PNG. +std::string render_png(const std::vector& pts, const double R_gl[9], + const double t[3], int side, double f) { + std::vector xyz; + std::vector rgb; + for (const auto& p : pts) { + xyz.insert(xyz.end(), {p.x, p.y, p.z}); + rgb.insert(rgb.end(), {(uint8_t)p.r, green8(p.g), (uint8_t)p.b}); + } + const app::ScanCloud cloud = app::build_scan_cloud(xyz, rgb, 2.0); + app::MapCamera cam; + cam.model = (int)CameraModelType::PINHOLE; + cam.width = cam.height = side; + cam.fx = cam.fy = f; + cam.cx = cam.cy = side / 2.0; + for (int r = 0; r < 3; r++) { + cam.c2w[r * 4] = R_gl[r * 3]; + cam.c2w[r * 4 + 1] = -R_gl[r * 3 + 1]; + cam.c2w[r * 4 + 2] = -R_gl[r * 3 + 2]; + cam.c2w[r * 4 + 3] = t[r]; + } + std::vector index; + std::vector depth; + app::render_front(cloud, cam, false, index, depth); + std::vector px((size_t)side * side * 3, 128); + for (size_t k = 0; k < index.size(); k++) + if (index[k] != UINT32_MAX) + for (int c = 0; c < 3; c++) px[k * 3 + c] = cloud.rgb[(size_t)index[k] * 3 + c]; + std::string out; + stbi_write_png_to_func( + [](void* ctx, void* data, int size) { + static_cast(ctx)->append(static_cast(data), (size_t)size); + }, + &out, side, side, 3, px.data(), side * 3); + return out; +} + +// One E57 of the room with one photo whose stored pose is `R_file`; what the +// converter made of it, and the camera the trainer then reads. +app::E57Check convert_room(const fs::path& dir, const std::vector& room, + const std::string& png, const double R_file[9], const double t[3], + double R_out[9]) { + e57test::Spec spec; + spec.points = room; + spec.blob = png; + double q[4]; + spirula::rotation_to_quaternion(R_file, q); + spec.images.push_back( + "" + e57test::pose_xml(q, t) + + "{PNGBLOB}" + "160160" + "0.01" + "0.00010.0001" + "79.5" + "79.5"); + fs::remove_all(dir); + fs::create_directories(dir); + std::ofstream((dir / "room.e57").string(), std::ios::binary) << e57test::build(spec); + app::E57DatasetOptions opt; + opt.input = (dir / "room.e57").string(); + opt.output = (dir / "dataset").string(); + opt.seed_points = 1000; + opt.depth_maps = false; + const app::E57DatasetResult res = app::write_e57_dataset(opt, [](const std::string&) {}); + DatasetParserConfig cfg; + const ParsedDataset ds = parse_dataset(opt.output, cfg, ""); + for (int r = 0; r < 3; r++) + for (int c = 0; c < 3; c++) R_out[r * 3 + c] = ds.c2w[r * 4 + c]; + return res.check; +} + +} // namespace + +int main() { + const Cam pin{{0.9, 0.1, -0.3, 0.2}, {1.0, -0.5, 1.5}}; + const Cam pano{{0.7, 0.05, 0.1, -0.6}, {-0.8, 0.4, 1.2}}; + const double f = 0.004, pw = 5e-6, ph = 4e-6, ppx = 319.5, ppy = 239.5; + const int pan_w = 200, pan_h = 100; + + e57test::Spec spec; + std::mt19937 rng(11); + std::uniform_real_distribution u(-5.0, 5.0); + for (int i = 0; i < 4000; i++) + spec.points.push_back({std::round(u(rng) * 1e4) / 1e4, (double)(float)u(rng), u(rng), + 100, 400, 200, 0}); + spec.blob = "\xff\xd8 not really a JPEG \xff\xd9"; + auto num = [](double v) { return e57test::num(v); }; + spec.images.push_back( + "" + e57test::pose_xml(pin.q, pin.t) + + "{BLOB}" + "640480" + "" + num(f) + "" + "" + num(pw) + "" + "" + num(ph) + "" + "" + num(ppx) + "" + "" + num(ppy) + "" + ""); + spec.images.push_back( + "" + e57test::pose_xml(pano.q, pano.t) + + "{BLOB}" + "200100" + "" + num(2 * kPi / pan_w) + "" + "" + num(kPi / pan_h) + "" + ""); + spec.images.push_back( + "" + e57test::pose_xml(pano.q, pano.t) + + "{BLOB}" + "20050" + ""); + spec.images.push_back( + "" + "{BLOB}" + "640480" + ""); + + const fs::path dir = fs::temp_directory_path() / "e57_dataset_test"; + fs::remove_all(dir); + fs::create_directories(dir); + const std::string e57_path = (dir / "scan.e57").string(); + std::ofstream(e57_path, std::ios::binary) << e57test::build(spec); + + app::E57DatasetOptions opt; + opt.input = e57_path; + opt.output = (dir / "dataset").string(); + opt.seed_points = 1000; + std::vector lines; + const app::E57DatasetResult res = + app::write_e57_dataset(opt, [&](const std::string& l) { lines.push_back(l); }); + check(res.images == 2, "the pinhole and the panorama become cameras, the rest are skipped"); + check(res.seed_points > 900 && res.seed_points <= 1000 && res.voxel > 0, + "4000 points thinned to about 1000 (" + std::to_string(res.seed_points) + ")"); + + bool refused = false; + try { + app::write_e57_dataset(opt, [](const std::string&) {}); + } catch (const std::runtime_error&) { + refused = true; + } + check(refused, "a folder that is not empty needs --overwrite"); + opt.overwrite = true; + check(app::write_e57_dataset(opt, [](const std::string&) {}).images == 2, "--overwrite"); + + DatasetParserConfig cfg; + const ParsedDataset ds = parse_dataset(opt.output, cfg, ""); + check(ds.num_cameras == 2, "the parser reads both cameras"); + check(ds.camera_models.size() == 2 && + ds.camera_models[0] == (int)CameraModelType::PINHOLE && + ds.camera_models[1] == (int)CameraModelType::EQUIRECTANGULAR, "camera models"); + check(ds.gauge_oriented && ds.gauge_metric, "gauge.txt: +Z up, metres"); + check(ds.points.num() == res.seed_points, "the seed cloud"); + + // Every camera must send a world point to the pixel its E57 image has it at. + const float zero[kCameraDistortionParams] = {}; + double worst[2] = {0, 0}; + int seen[2] = {0, 0}; + std::uniform_real_distribution w(-4.0, 4.0); + for (int j = 0; j < 2; j++) { + const Cam& c = j == 0 ? pin : pano; + double R[9]; + rotation(c.q, R); + const float* m = &ds.c2w[j * 12]; + const double Rgl[9] = {m[0], m[1], m[2], m[4], m[5], m[6], m[8], m[9], m[10]}; + const double tgl[3] = {m[3], m[7], m[11]}; + const float* k = &ds.intrins[j * 4]; + for (int i = 0; i < 2000; i++) { + const double p[3] = {w(rng), w(rng), w(rng)}; + double e[3], g[3]; + to_local(R, c.t, p, e); + to_local(Rgl, tgl, p, g); + double want[2]; + if (j == 0) { + if (e[2] > -0.5) continue; // behind the camera: it looks down -z + want[0] = ppx + 0.5 + f / pw * e[0] / -e[2]; + want[1] = ppy + 0.5 + f / ph * -e[1] / -e[2]; + } else { + const double az = std::atan2(e[1], e[0]); + if (std::fabs(az) > 3.0) continue; // stay off the seam + const double el = std::atan2(e[2], std::hypot(e[0], e[1])); + want[0] = pan_w / 2.0 - az / (2 * kPi / pan_w); + want[1] = pan_h / 2.0 - el / (kPi / pan_h); + } + const double cv[3] = {g[0], -g[1], -g[2]}; + double uv[2]; + if (!camhost::project_ray(cv, ds.camera_models[j], (int)CameraDistortionType::None, + zero, uv)) + continue; + const double got[2] = {k[0] * uv[0] + k[2], k[1] * uv[1] + k[3]}; + worst[j] = std::max({worst[j], std::fabs(got[0] - want[0]), + std::fabs(got[1] - want[1])}); + seen[j]++; + } + } + check(seen[0] > 100 && worst[0] < 1e-2, + "pinhole: engine pixel == E57 pixel (worst " + std::to_string(worst[0]) + " px)"); + check(seen[1] > 1000 && worst[1] < 1e-3, + "panorama: engine pixel == E57 pixel (worst " + std::to_string(worst[1]) + " px)"); + + std::vector xyz = {0, 0, 0, 1, 1, 1}; + std::vector rgb = {10, 20, 30, 40, 50, 60}; + check(app::thin_to_voxels(xyz, rgb, 2) == 0.0 && xyz.size() == 6, + "a cloud already under the target is left alone"); + xyz.assign(8 * 3, 2.5); + rgb.assign(8 * 3, 0); + for (int i = 0; i < 8; i++) rgb[i * 3] = (uint8_t)(i * 10); + xyz.insert(xyz.end(), {9, 9, 9}); + rgb.insert(rgb.end(), {1, 2, 3}); + app::thin_to_voxels(xyz, rgb, 2); + check(xyz.size() == 6 && (rgb[0] == 35 || rgb[3] == 35), + "coincident points merge into their mean"); + + // A camera looking across the room, 0.7 rad of yaw and a little down. + { + std::mt19937 room_rng(5); + const std::vector room = tiled_room(room_rng, 300000); + const double yaw = 0.7, pitch = -0.2; + const double fwd[3] = {std::cos(pitch) * std::cos(yaw), std::cos(pitch) * std::sin(yaw), + std::sin(pitch)}; + double right[3] = {fwd[1], -fwd[0], 0.0}; + const double rl = std::hypot(right[0], right[1]); + for (double& v : right) v /= rl; + const double up[3] = {right[1] * fwd[2] - right[2] * fwd[1], + right[2] * fwd[0] - right[0] * fwd[2], + right[0] * fwd[1] - right[1] * fwd[0]}; + const double R_true[9] = {right[0], up[0], -fwd[0], right[1], up[1], -fwd[1], + right[2], up[2], -fwd[2]}; + const double t_cam[3] = {0.3, -0.2, 0.4}; + const std::string png = render_png(room, R_true, t_cam, 160, 100.0); + // The same camera stored with OpenCV's axes, as an exporter that got the + // convention wrong would: y and z turned half way round. + const double R_cv[9] = {R_true[0], -R_true[1], -R_true[2], R_true[3], -R_true[4], + -R_true[5], R_true[6], -R_true[7], -R_true[8]}; + double R_got[9]; + auto worst = [&](const double* a) { + double w = 0; + for (int k = 0; k < 9; k++) w = std::max(w, std::fabs(a[k] - R_true[k])); + return w; + }; + const app::E57Check right_way = convert_room(dir / "room_ok", room, png, R_true, t_cam, R_got); + check(right_way == app::E57Check::Agree && worst(R_got) < 1e-5, + "alignment check: a correctly posed photo is left alone"); + const app::E57Check wrong_way = convert_room(dir / "room_cv", room, png, R_cv, t_cam, R_got); + check(wrong_way == app::E57Check::Fixed && worst(R_got) < 1e-5, + "alignment check: a photo posed with the wrong axes is turned back"); + } + + fs::remove_all(dir); + std::printf(g_failures ? "\n%d FAILED\n" : "\nall passed\n", g_failures); + return g_failures ? 1 : 0; +} diff --git a/src/app/tests/lidar_align_test.cpp b/src/app/tests/lidar_align_test.cpp new file mode 100644 index 000000000..045c7e364 --- /dev/null +++ b/src/app/tests/lidar_align_test.cpp @@ -0,0 +1,342 @@ +// lidar_align_test -- app/LidarAlign.h and data/PointCloudFile.h against a +// synthetic room whose similarity to the "reconstruction" is known: the anchor +// fit recovers it exactly (also from anchors on one line, where centres alone +// cannot), scans in frames of their own are told apart and placed through +// their images, ICP recovers it from a few degrees and percent off, and LAS / +// PLY files written here read back with their colours and scanner stations. + +#include "app/LidarAlign.h" +#include "app/ScanDepth.h" +#include "data/PointCloudFile.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using sfm::Mat3; +using sfm::Vec3; + +namespace { + +int g_failures = 0; + +void check(bool ok, const std::string& what) { + std::printf("%s %s\n", ok ? "ok " : "FAIL", what.c_str()); + if (!ok) g_failures++; +} + +Mat3 axis_angle(Vec3 a, double deg) { + a = a.normalized(); + const double t = deg * 3.14159265358979323846 / 180.0; + const Mat3 K = sfm::crossMatrix(a), K2 = sfm::mul(K, K); + Mat3 R = sfm::mat3Identity(); + for (int i = 0; i < 9; i++) R[i] += std::sin(t) * K[i] + (1 - std::cos(t)) * K2[i]; + return R; +} + +double rot_deg(const Mat3& A, const Mat3& B) { + const Mat3 D = sfm::mul(sfm::transpose(A), B); + return std::acos(std::fmax(-1.0, std::fmin(1.0, (D[0] + D[4] + D[8] - 1) / 2))) * 180.0 / + 3.14159265358979323846; +} + +// A 10 x 8 x 3 m room with two boxes on the floor, 4 cm between points. +std::vector room() { + std::vector p; + const double s = 0.04; + for (double x = 0; x <= 10; x += s) + for (double y = 0; y <= 8; y += s) { p.push_back({x, y, 0}); p.push_back({x, y, 3}); } + for (double z = 0; z <= 3; z += s) { + for (double x = 0; x <= 10; x += s) { p.push_back({x, 0, z}); p.push_back({x, 8, z}); } + for (double y = 0; y <= 8; y += s) { p.push_back({0, y, z}); p.push_back({10, y, z}); } + } + for (double u = 0; u <= 1; u += s) + for (double v = 0; v <= 1; v += s) { + p.push_back({2 + u, 2 + v, 1}); p.push_back({2 + u, 2, v}); p.push_back({2, 2 + u, v}); + p.push_back({7 + u * 0.5, 5 + v, 1.5}); p.push_back({7 + u * 0.5, 5, v * 1.5}); + } + return p; +} + +template void put(std::ofstream& f, T v) { f.write((const char*)&v, sizeof v); } + +} // namespace + +int main() { + const std::vector scan = room(); + // model -> scan: x_s = s R x_m + t + sfm::Sim3 T; + T.scale = 3.7; + T.R = axis_angle({0.3, -0.5, 0.8}, 40); + T.t = {5, -2, 1}; + const sfm::Sim3 inv = sfm::invertSim3(T); + + // ---- the anchor fit ---- + auto views_on = [&](const std::vector& where, app::lidar::ModelGeometry& g, + std::vector& anchors) { + for (size_t i = 0; i < where.size(); i++) { + app::lidar::Anchor a; + a.name = "scan/" + std::to_string(i) + ".jpg"; + a.R = axis_angle({0.1 * i, 1, 0.2}, 25.0 * i); + a.centre = where[i]; + anchors.push_back(a); + app::lidar::ModelGeometry::View v; + v.name = "scan/" + std::to_string(i) + ".png"; // the extension may differ + v.R = sfm::mul(inv.R, a.R); + v.centre = sfm::transformPoint(inv, a.centre); + g.views.push_back(v); + } + }; + { + app::lidar::ModelGeometry g; + std::vector anchors; + views_on({{2, 3, 1.5}, {5, 6, 1.4}, {8, 2, 1.6}, {4, 1, 1.5}, {6, 4, 1.5}}, g, anchors); + // One anchor whose recorded pose is wrong by 20 degrees. + anchors[3].R = sfm::mul(axis_angle({0, 0, 1}, 20), anchors[3].R); + const app::lidar::AnchorFit f = app::lidar::fit_anchors(g, anchors); + check(f.ok && f.registered == 5 && f.inliers == 4, "anchor fit: the wrong anchor is left out"); + check(std::fabs(f.T.scale / T.scale - 1) < 1e-9 && rot_deg(f.T.R, T.R) < 1e-6 && + (f.T.t - T.t).norm() < 1e-6, + "anchor fit: the similarity recovered exactly"); + } + { + app::lidar::ModelGeometry g; + std::vector anchors; + views_on({{1, 4, 1.5}, {3, 4, 1.5}, {5, 4, 1.5}, {7, 4, 1.5}}, g, anchors); + const app::lidar::AnchorFit f = app::lidar::fit_anchors(g, anchors); + check(f.scale_known && rot_deg(f.T.R, T.R) < 1e-6 && (f.T.t - T.t).norm() < 1e-6, + "anchor fit: anchors on one line still fix the rotation about it"); + } + { + app::lidar::ModelGeometry g; + std::vector anchors; + views_on({{4, 4, 1.5}, {4, 4, 1.5}}, g, anchors); + const app::lidar::AnchorFit f = app::lidar::fit_anchors(g, anchors); + check(f.ok && !f.scale_known && rot_deg(f.T.R, T.R) < 1e-6, + "anchor fit: one station gives the turn, not the scale"); + } + // One station's views, which the reconstruction placed a little apart: + // pairs of them must not make a scale of their 0 m over a few mm. + { + app::lidar::ModelGeometry g; + std::vector anchors; + views_on({{4, 4, 1.5}, {4, 4, 1.5}, {4, 4, 1.5}, {4, 4, 1.5}}, g, anchors); + for (size_t i = 0; i < g.views.size(); i++) + g.views[i].centre = g.views[i].centre + Vec3{0.001 * (double)i, -0.002 * (double)i, 0}; + const app::lidar::AnchorFit f = app::lidar::fit_anchors(g, anchors); + check(f.ok && !f.scale_known && f.T.scale == 1.0, + "anchor fit: one station with jitter leaves the scale open, not 0"); + + // The scan's depth then gives it: the room seen from the station. + std::vector xyz; + std::vector rgb; + for (const Vec3& p : scan) { + xyz.insert(xyz.end(), {p.x, p.y, p.z}); + rgb.insert(rgb.end(), {128, 128, 128}); + } + const app::ScanCloud sc = app::build_scan_cloud(xyz, rgb, 2.0); + for (size_t i = 0; i < scan.size(); i += 5) { + if (scan[i].z > 0.01 && scan[i].z < 2.99 && scan[i].x > 0.01 && scan[i].x < 9.99 && + scan[i].y > 0.01 && scan[i].y < 7.99) + continue; // the boxes: some of them are hidden from the station + g.views[0].seen.push_back((int)g.points.size()); + g.points.push_back(sfm::transformPoint(inv, scan[i])); + g.track.push_back(3); + } + int64_t n = 0; + const double s = app::lidar::scale_from_depth(g, anchors, f, sc, &n); + std::printf(" depth scale %.5f (true %.5f) from %lld points\n", s, T.scale, (long long)n); + check(std::fabs(s / T.scale - 1) < 0.01, "depth scale: the size the station alone cannot give"); + } + + { + app::lidar::ModelGeometry g; + std::vector anchors; + views_on({{2, 3, 1.5}, {5, 6, 1.4}, {8, 2, 1.6}}, g, anchors); + const app::lidar::AnchorFit f = app::lidar::fit_anchors(g, anchors, T.scale); + check(f.ok && f.scale_known && f.T.scale == T.scale && rot_deg(f.T.R, T.R) < 1e-6 && + (f.T.t - T.t).norm() < 1e-6 && f.used.size() == 3, + "anchor fit: the scale held, rotation and translation recovered"); + } + + // ---- scans in frames of their own ---- + // Scans 0 and 1 in the frame the model is fitted to; scan 2 in its own, + // which G2 takes there; scan 3 has no image in the model. + sfm::Sim3 G2; + G2.R = axis_angle({0, 0, 1}, 30); + G2.t = {12, -5, 0.3}; + auto scans_model = [&](bool own, app::lidar::ModelGeometry& g, + std::vector& anchors, std::vector& scan_of) { + const sfm::Sim3 G2inv = sfm::invertSim3(G2); + const std::vector> where = { + {{1, 1, 1.5}, 0}, {{3, 2, 1.5}, 0}, {{5, 1, 1.6}, 1}, {{6, 3, 1.5}, 1}, + {{8, 6, 1.5}, 2}, {{9, 7, 1.4}, 2}, {{7, 7, 1.5}, 2}}; + for (const auto& [c, scan] : where) { + app::lidar::Anchor a; + a.name = "s" + std::to_string(anchors.size()) + ".jpg"; + a.R = axis_angle({0.2, 1, 0.1}, 17.0 * (double)anchors.size()); + a.centre = c; + app::lidar::ModelGeometry::View v; + v.name = a.name; + v.R = sfm::mul(inv.R, a.R); + v.centre = sfm::transformPoint(inv, a.centre); + g.views.push_back(v); + if (own && scan == 2) { + a.R = sfm::mul(G2inv.R, a.R); + a.centre = sfm::transformPoint(G2inv, a.centre); + } + anchors.push_back(a); + scan_of.push_back(scan); + } + }; + { + app::lidar::ModelGeometry g; + std::vector anchors; + std::vector scan_of; + scans_model(true, g, anchors, scan_of); + const app::lidar::ScanFrames fr = app::lidar::group_scan_frames({g}, anchors, scan_of, 4); + check(fr.count == 2 && fr.frame == std::vector({0, 0, 1, -1}), + "frames: the scan of its own told apart; the unseen one in neither"); + const std::vector> fits = {T}; + const std::vector placed = + app::lidar::place_frames({g}, fits, anchors, scan_of, fr); + check(placed.size() == 2 && placed[0].placed && placed[1].placed && + placed[1].anchors == 3 && placed[1].T.scale == 1.0 && + rot_deg(placed[1].T.R, G2.R) < 1e-6 && (placed[1].T.t - G2.t).norm() < 1e-6, + "frames: placed rigidly through its images, at the model's scale"); + const std::vector> none = {std::nullopt}; + check(!app::lidar::place_frames({g}, none, anchors, scan_of, fr)[1].placed, + "frames: not placed without a model fitted to the first"); + } + { + app::lidar::ModelGeometry g; + std::vector anchors; + std::vector scan_of; + scans_model(false, g, anchors, scan_of); + const app::lidar::ScanFrames fr = app::lidar::group_scan_frames({g}, anchors, scan_of, 4); + check(fr.count == 1 && fr.frame == std::vector({0, 0, 0, 0}), + "frames: scans whose images agree share one, and the unseen one joins it"); + } + { + auto at = [](double x, double y, double z) { + spirula::cloud::Station s; + s.origin[0] = x; s.origin[1] = y; s.origin[2] = z; + return std::vector{s}; + }; + const std::vector bare; + check(app::lidar::scans_share_frame({bare}) && + app::lidar::scans_share_frame({at(1, 2, 0), at(10, 2, 0), bare}) && + !app::lidar::scans_share_frame({bare, bare}) && + !app::lidar::scans_share_frame({at(4, 4, 0), at(4, 4, 0.005)}) && + !app::lidar::scans_share_frame({bare, at(0, 0, 0)}), + "frames: files share one when their stations stand apart, and one at most " + "has none"); + } + + // ---- ICP ---- + { + std::vector xyz; + std::vector rgb; + for (const Vec3& p : scan) { + xyz.insert(xyz.end(), {p.x + 1000.0, p.y + 2000.0, p.z}); + rgb.insert(rgb.end(), {128, 128, 128}); + } + app::lidar::AlignCloud cloud = app::lidar::make_align_cloud(xyz, rgb, 100000); + sfm::Sim3 Tw = T; + Tw.t = Tw.t + Vec3{1000, 2000, 0}; + const sfm::Sim3 invw = sfm::invertSim3(Tw); + // Model points: a sample of the room, 5 mm of noise in metres. + app::lidar::ModelGeometry g; + std::mt19937 rng(7); + std::normal_distribution noise(0, 0.005); + for (size_t i = 0; i < scan.size(); i += 7) { + const Vec3 p = scan[i] + Vec3{1000 + noise(rng), 2000 + noise(rng), noise(rng)}; + g.points.push_back(sfm::transformPoint(invw, p)); + g.track.push_back(5); + } + sfm::Sim3 start = Tw; + start.scale *= 1.03; + start.R = sfm::mul(axis_angle({1, 1, 0}, 3), start.R); + start.t = start.t + Vec3{0.1, -0.08, 0.05}; + app::lidar::IcpStats st; + const sfm::Sim3 got = app::lidar::refine_icp(g, cloud, start, &st); + // Where a model point at the room's far corner lands, against the truth. + const Vec3 corner = sfm::transformPoint(invw, {1010, 2008, 3}); + const double err = (sfm::transformPoint(got, corner) - sfm::transformPoint(Tw, corner)).norm(); + std::printf(" icp: %d iterations, scale %.5f (true %.5f), %.4f deg, corner %.4f m\n", + st.iterations, got.scale, Tw.scale, rot_deg(got.R, Tw.R), err); + check(err < 0.01 && rot_deg(got.R, Tw.R) < 0.05, "ICP: recovered from 3 deg, 3%, 13 cm off"); + } + + // ---- LAS and PLY ---- + const fs::path dir = fs::temp_directory_path() / "lidar_align_test"; + fs::create_directories(dir); + { + // Format 2 (RGB), colours written as 8-bit values in 16-bit fields. + const fs::path p = dir / "a.las"; + std::ofstream f(p, std::ios::binary); + char h[227] = {}; + std::memcpy(h, "LASF", 4); + h[24] = 1; h[25] = 2; + const uint16_t hs = 227; std::memcpy(h + 94, &hs, 2); + const uint32_t off = 227; std::memcpy(h + 96, &off, 4); + h[104] = 2; + const uint16_t rl = 26; std::memcpy(h + 105, &rl, 2); + const uint32_t n = 3; std::memcpy(h + 107, &n, 4); + const double sc[3] = {0.001, 0.001, 0.001}, of[3] = {500000, 4000000, 0}; + std::memcpy(h + 131, sc, 24); + std::memcpy(h + 155, of, 24); + f.write(h, sizeof h); + for (int i = 0; i < 3; i++) { + put(f, 1000 * i); put(f, -2000 * i); put(f, 500); + put(f, 7); + for (int k = 0; k < 6; k++) put(f, 0); + put(f, (uint16_t)(10 * i)); put(f, 200); put(f, 255); + } + f.close(); + spirula::cloud::Reader r(p.string()); + std::vector pts; + r.read([&](const spirula::e57::Point* q, size_t k) { pts.insert(pts.end(), q, q + k); }); + check(r.info().has_color && pts.size() == 3 && + std::fabs(pts[2].xyz[0] - 500002.0) < 1e-9 && + std::fabs(pts[2].xyz[1] - 3999996.0) < 1e-9 && pts[2].rgb[0] == 20 && + pts[2].rgb[2] == 255, + "LAS: offsets, scales and 8-bit colours in 16-bit fields"); + } + { + const fs::path p = dir / "b.ply"; + std::ofstream f(p, std::ios::binary); + f << "ply\nformat binary_little_endian 1.0\nelement vertex 2\nproperty double x\n" + "property double y\nproperty double z\nproperty uchar red\nproperty uchar green\n" + "property uchar blue\nelement camera 2\n"; + const char* names[12] = {"view_px", "view_py", "view_pz", "x_axisx", "x_axisy", "x_axisz", + "y_axisx", "y_axisy", "y_axisz", "z_axisx", "z_axisy", "z_axisz"}; + for (const char* nm : names) f << "property float " << nm << "\n"; + f << "end_header\n"; + for (int i = 0; i < 2; i++) { + put(f, 1.5 + i); put(f, 2.5); put(f, 3.5); + put(f, 1); put(f, 2); put(f, 3); + } + for (int c = 0; c < 2; c++) { + const float v[12] = {(float)(4 + c), 5, 6, 1, 0, 0, 0, 1, 0, 0, 0, 1}; + f.write((const char*)v, sizeof v); + } + f.close(); + spirula::cloud::Reader r(p.string()); + std::vector pts; + r.read([&](const spirula::e57::Point* q, size_t k) { pts.insert(pts.end(), q, q + k); }); + check(pts.size() == 2 && pts[1].xyz[0] == 2.5 && pts[1].rgb[2] == 3 && + r.info().stations.size() == 2 && r.info().stations[1].origin[0] == 5, + "PLY: points, colours and both scanner stations"); + } + fs::remove_all(dir); + std::printf("%s\n", g_failures ? "FAILED" : "all passed"); + return g_failures ? 1 : 0; +} diff --git a/src/app/tests/scan_depth_test.cpp b/src/app/tests/scan_depth_test.cpp new file mode 100644 index 000000000..88684270e --- /dev/null +++ b/src/app/tests/scan_depth_test.cpp @@ -0,0 +1,124 @@ +// scan_depth_test -- app/ScanDepth.h against surfaces whose depth is known: a +// plane in front of a pinhole camera (planar and ray depth, a normal facing +// it), a sparse near patch that the far plane must not show through, and a +// sphere around a panorama camera (ray depth, normals towards its centre). + +#include "app/ScanDepth.h" +#include "core/CameraModel.h" +#include "data/CameraMath.h" + +#include +#include +#include +#include + +namespace { + +int g_failures = 0; + +void check(bool ok, const std::string& what) { + std::printf("%s %s\n", ok ? "ok " : "FAIL", what.c_str()); + if (!ok) g_failures++; +} + +void add(std::vector& xyz, std::vector& rgb, double x, double y, double z) { + xyz.insert(xyz.end(), {x, y, z}); + rgb.insert(rgb.end(), {128, 128, 128}); +} + +} // namespace + +int main() { + // Far from the origin, as a geo-referenced scan is. + const double t[3] = {500000.0, 4000000.0, 30.0}; + std::vector xyz; + std::vector rgb; + for (int i = -600; i <= 600; i++) + for (int j = -600; j <= 600; j++) add(xyz, rgb, t[0] + i * 0.01, t[1] + j * 0.01, t[2] + 5.0); + // A 0.6 m patch at 2 m, 5 cm between points: sparse enough for the plane + // behind to show through the gaps before filtering. + for (int i = -6; i <= 6; i++) + for (int j = -6; j <= 6; j++) add(xyz, rgb, t[0] + 1.0 + i * 0.05, t[1] + j * 0.05, t[2] + 2.0); + const app::ScanCloud cloud = app::build_scan_cloud(xyz, rgb, 2.0); + + app::MapCamera cam; + cam.model = (int)CameraModelType::PINHOLE; + cam.width = cam.height = 200; + cam.fx = cam.fy = 200; + cam.cx = cam.cy = 100; + const double c2w[12] = {1, 0, 0, t[0], 0, 1, 0, t[1], 0, 0, 1, t[2]}; // looks down +z + std::copy(c2w, c2w + 12, cam.c2w); + + std::vector depth, normal; + app::render_depth_normal(cloud, cam, false, depth, normal); + // The patch covers x in [0.7, 1.3] m and y in [-0.3, 0.3] m at z = 2: + // columns 170..230 (the map ends at 200), rows 70..130. + int plane_bad = 0, plane_n = 0, patch_leak = 0, normal_bad = 0; + for (int y = 5; y < 195; y++) + for (int x = 5; x < 195; x++) { + const float d = depth[(size_t)y * 200 + x]; + const bool in_patch = x >= 174 && y >= 74 && y <= 126; + if (in_patch) { + patch_leak += d > 3.0f; + continue; + } + if (x >= 163 && y >= 63 && y <= 137) continue; // the patch's edge + plane_n++; + plane_bad += std::fabs(d - 5.0f) > 1e-3f; + const float* n = &normal[((size_t)y * 200 + x) * 3]; + normal_bad += std::fabs(n[2] + 1.0f) > 1e-3f; + } + check(plane_bad == 0, "planar depth of the plane is 5 m everywhere (" + + std::to_string(plane_bad) + " of " + std::to_string(plane_n) + " off)"); + check(normal_bad == 0, "its normal faces the camera, (0, 0, -1)"); + check(patch_leak == 0, "the plane does not show through the sparse patch (" + + std::to_string(patch_leak) + " pixels leak)"); + + app::render_depth_normal(cloud, cam, true, depth, normal); + const double u = (10 + 0.5 - 100) / 200.0, v = (20 + 0.5 - 100) / 200.0; + const double want = 5.0 * std::sqrt(1 + u * u + v * v); + // Within the 2.5 cm a pixel spans there: the front-most point is the one + // nearest the axis, a few millimetres short of the pixel centre's ray. + check(std::fabs(depth[20 * 200 + 10] - want) < 1e-2, + "ray depth off axis is 5 m / cos(angle)"); + + // A sphere of 3 m around a panorama camera. + xyz.clear(); + rgb.clear(); + const int n_sphere = 2000000; + for (int i = 0; i < n_sphere; i++) { + const double z = 1.0 - 2.0 * (i + 0.5) / n_sphere; + const double r = std::sqrt(1.0 - z * z), a = i * 2.399963229728653; + add(xyz, rgb, t[0] + 3 * r * std::cos(a), t[1] + 3 * r * std::sin(a), t[2] + 3 * z); + } + const app::ScanCloud sphere = app::build_scan_cloud(xyz, rgb, 2.0); + app::MapCamera pano; + pano.model = (int)CameraModelType::EQUIRECTANGULAR; + pano.width = 400; + pano.height = 200; + pano.fx = pano.width / (2 * 3.14159265358979323846); + pano.fy = pano.height / 3.14159265358979323846; + pano.cx = 200; + pano.cy = 100; + std::copy(c2w, c2w + 12, pano.c2w); + app::render_depth_normal(sphere, pano, true, depth, normal); + int covered = 0, off = 0, facing = 0; + for (int y = 10; y < 190; y++) + for (int x = 0; x < 400; x++) { + const size_t k = (size_t)y * 400 + x; + if (!(depth[k] > 0)) continue; + covered++; + off += std::fabs(depth[k] - 3.0f) > 2e-3f; + double ray[3]; + camhost::generate_ray((x + 0.5 - pano.cx) / pano.fx, (y + 0.5 - pano.cy) / pano.fy, + pano.model, 0, nullptr, ray); + const float* nn = &normal[k * 3]; + facing += nn[0] * ray[0] + nn[1] * ray[1] + nn[2] * ray[2] < -0.99; + } + check(covered == 180 * 400, "the panorama sees the sphere in every pixel"); + check(off == 0, "its ray depth is 3 m everywhere"); + check(facing > covered * 99 / 100, "its normals point back at the camera"); + + std::printf(g_failures ? "\n%d FAILED\n" : "\nall passed\n", g_failures); + return g_failures ? 1 : 0; +} diff --git a/src/data/DatasetParser.h b/src/data/DatasetParser.h index c5320c2fe..b571f8ac1 100644 --- a/src/data/DatasetParser.h +++ b/src/data/DatasetParser.h @@ -403,6 +403,10 @@ std::vector train_subset(int64_t n, const std::vector& nam // validation_fraction partition of 0..N-1 into ds.train_indices/val_indices. void assign_val_split(ParsedDataset& ds, float validation_fraction); +// gauge.txt in `dir` (sfm/Pipeline.h writes one, and so does an E57 import): +// what the frame is worth. Absent, both flags stay false. +void read_gauge(const std::string& dir, ParsedDataset& ds); + // Auxiliary mask/depth/normal discovery by filename convention. "" when // `rel_name` is empty or would leave `aux_dir`. std::string find_aux_file(const std::string& aux_dir, const std::string& rel_name, diff --git a/src/data/E57Reader.cpp b/src/data/E57Reader.cpp new file mode 100644 index 000000000..35b445836 --- /dev/null +++ b/src/data/E57Reader.cpp @@ -0,0 +1,481 @@ +#include "data/E57Reader.h" + +#include "data/Xml.h" + +#include +#include +#include +#include +#include +#include + +namespace spirula::e57 { +namespace { + +constexpr uint64_t kCrcBytes = 4; +constexpr uint8_t kBlobSection = 0; +constexpr uint8_t kPointsSection = 1; +constexpr uint8_t kIndexPacket = 0, kDataPacket = 1, kEmptyPacket = 2; + +[[noreturn]] void fail(const std::string& why) { + throw std::runtime_error("E57: " + why); +} + +uint64_t le64(const uint8_t* p) { uint64_t v; std::memcpy(&v, p, 8); return v; } +uint32_t le32(const uint8_t* p) { uint32_t v; std::memcpy(&v, p, 4); return v; } +uint16_t le16(const uint8_t* p) { return (uint16_t)(p[0] | (p[1] << 8)); } + +std::string trimmed(const std::string& s) { + const size_t b = s.find_first_not_of(" \t\r\n"); + if (b == std::string::npos) return ""; + return s.substr(b, s.find_last_not_of(" \t\r\n") - b + 1); +} + +std::string text_of(const XmlNode* n) { return n ? trimmed(n->text) : ""; } + +// E57 writes a zero as an empty element, so only a MISSING one falls back. +double number(const XmlNode* n, double fallback = 0.0) { + if (!n) return fallback; + const std::string s = trimmed(n->text); + if (s.empty()) return 0.0; + char* end = nullptr; + const double v = std::strtod(s.c_str(), &end); + if (end == s.c_str()) fail("<" + n->tag + "> is not a number: '" + s + "'"); + return v; +} + +bool attr_int(const XmlNode& n, const char* key, int64_t& out) { + const std::string* a = n.attr(key); + if (!a) return false; + // A float spelling ("2.55e+02") is legal for an integer-valued attribute. + char* end = nullptr; + const long long v = std::strtoll(a->c_str(), &end, 10); + if (end && *end == '\0') { out = v; return true; } + out = (int64_t)std::llround(std::strtod(a->c_str(), nullptr)); + return true; +} + +bool attr_double(const XmlNode& n, const char* key, double& out) { + const std::string* a = n.attr(key); + if (!a) return false; + out = std::strtod(a->c_str(), nullptr); + return true; +} + +Pose read_pose(const XmlNode* p) { + Pose out; + if (!p) return out; + if (const XmlNode* r = p->find("rotation")) { + double q[4] = {number(r->find("w"), 1.0), number(r->find("x")), + number(r->find("y")), number(r->find("z"))}; + const double len = std::sqrt(q[0]*q[0] + q[1]*q[1] + q[2]*q[2] + q[3]*q[3]); + if (!(len > 1e-12)) fail("a pose has a zero rotation quaternion"); + for (int i = 0; i < 4; i++) out.q[i] = q[i] / len; + } + if (const XmlNode* t = p->find("translation")) { + out.t[0] = number(t->find("x")); + out.t[1] = number(t->find("y")); + out.t[2] = number(t->find("z")); + } + return out; +} + +Blob read_blob_ref(const XmlNode* n) { + Blob b; + if (!n) return b; + int64_t off = 0, len = 0; + if (!attr_int(*n, "fileOffset", off) || !attr_int(*n, "length", len) || + off < 0 || len < 0) + fail("<" + n->tag + "> has no valid fileOffset/length"); + b.offset = (uint64_t)off; + b.length = (uint64_t)len; + return b; +} + +Field read_field(const XmlNode& n) { + Field f; + f.name = n.tag; + const std::string* type = n.attr("type"); + if (!type) fail("point field '" + n.tag + "' has no type"); + if (*type == "Float") { + const std::string* prec = n.attr("precision"); + f.kind = Field::Kind::Float; + f.bits = (prec && *prec == "single") ? 32 : 64; + const bool has_lo = attr_double(n, "minimum", f.lo); + const bool has_hi = attr_double(n, "maximum", f.hi); + f.ranged = has_lo && has_hi; + return f; + } + if (*type != "Integer" && *type != "ScaledInteger") + fail("point field '" + n.tag + "' has unsupported type " + *type); + f.kind = *type == "Integer" ? Field::Kind::Integer : Field::Kind::ScaledInteger; + int64_t mn = std::numeric_limits::min(); + int64_t mx = std::numeric_limits::max(); + const bool has_lo = attr_int(n, "minimum", mn); + const bool has_hi = attr_int(n, "maximum", mx); + f.ranged = has_lo && has_hi; + if (mx < mn) fail("point field '" + n.tag + "' has maximum < minimum"); + uint64_t span = (uint64_t)mx - (uint64_t)mn; + f.bits = 0; + while (span) { f.bits++; span >>= 1; } + f.minimum = mn; + if (f.kind == Field::Kind::ScaledInteger) { + attr_double(n, "scale", f.scale); + attr_double(n, "offset", f.offset); + } + f.lo = (double)mn * f.scale + f.offset; + f.hi = (double)mx * f.scale + f.offset; + return f; +} + +int field_index(const Scan& s, const char* name) { + for (size_t i = 0; i < s.fields.size(); i++) + if (s.fields[i].name == name) return (int)i; + return -1; +} + +// A value range: the scan's declared limits, else the field's own, else what +// an unranged field of that kind conventionally holds. +void value_range(const XmlNode* limits, const char* lo_key, const char* hi_key, + const Field& f, double integer_hi, double& lo, double& hi) { + if (limits && limits->find(lo_key) && limits->find(hi_key)) { + lo = number(limits->find(lo_key)); + hi = number(limits->find(hi_key)); + } else if (f.ranged) { + lo = f.lo; + hi = f.hi; + } else { + lo = 0; + hi = f.kind == Field::Kind::Float ? 1.0 : integer_hi; + } + if (!(hi > lo)) hi = lo + 1; +} + +Scan read_scan(const XmlNode& n) { + Scan s; + s.name = text_of(n.find("name")); + s.guid = text_of(n.find("guid")); + s.pose = read_pose(n.find("pose")); + const XmlNode* pts = n.find("points"); + if (!pts) fail("scan '" + s.name + "' has no points"); + int64_t off = 0; + if (!attr_int(*pts, "fileOffset", off) || !attr_int(*pts, "recordCount", s.count) || + off < 0 || s.count < 0) + fail("scan '" + s.name + "' has no valid fileOffset/recordCount"); + s.section = (uint64_t)off; + const XmlNode* proto = pts->find("prototype"); + if (!proto) fail("scan '" + s.name + "' has no prototype"); + for (const XmlNode& c : proto->children) s.fields.push_back(read_field(c)); + // bitPackCodec is the only codec the standard defines, and the default. + if (const XmlNode* codecs = pts->find("codecs")) + for (const XmlNode& c : codecs->children) + for (const XmlNode& k : c.children) + if (k.tag != "inputs" && k.tag != "bitPackCodec") + fail("scan '" + s.name + "' uses unsupported codec " + k.tag); + + const int r = field_index(s, "colorRed"), g = field_index(s, "colorGreen"), + b = field_index(s, "colorBlue"); + s.has_color = r >= 0 && g >= 0 && b >= 0; + if (s.has_color) { + const XmlNode* lim = n.find("colorLimits"); + value_range(lim, "colorRedMinimum", "colorRedMaximum", s.fields[r], 255, + s.color_lo[0], s.color_hi[0]); + value_range(lim, "colorGreenMinimum", "colorGreenMaximum", s.fields[g], 255, + s.color_lo[1], s.color_hi[1]); + value_range(lim, "colorBlueMinimum", "colorBlueMaximum", s.fields[b], 255, + s.color_lo[2], s.color_hi[2]); + } + const int in = field_index(s, "intensity"); + s.has_intensity = in >= 0; + if (s.has_intensity) + value_range(n.find("intensityLimits"), "intensityMinimum", "intensityMaximum", + s.fields[in], 1, s.intensity_lo, s.intensity_hi); + return s; +} + +Image read_image(const XmlNode& n) { + Image im; + im.name = text_of(n.find("name")); + im.guid = text_of(n.find("guid")); + im.scan_guid = text_of(n.find("associatedData3DGuid")); + const XmlNode* pose = n.find("pose"); + im.has_pose = pose != nullptr; + im.pose = read_pose(pose); + static const std::pair kReps[] = { + {"pinholeRepresentation", Projection::Pinhole}, + {"sphericalRepresentation", Projection::Spherical}, + {"cylindricalRepresentation", Projection::Cylindrical}, + {"visualReferenceRepresentation", Projection::Reference}, + }; + for (const auto& [tag, proj] : kReps) { + const XmlNode* r = n.find(tag); + if (!r) continue; + im.projection = proj; + im.jpeg = read_blob_ref(r->find("jpegImage")); + im.png = read_blob_ref(r->find("pngImage")); + im.mask = read_blob_ref(r->find("imageMask")); + im.width = (int64_t)number(r->find("imageWidth")); + im.height = (int64_t)number(r->find("imageHeight")); + im.focal_length = number(r->find("focalLength")); + im.pixel_width = number(r->find("pixelWidth")); + im.pixel_height = number(r->find("pixelHeight")); + im.principal_x = number(r->find("principalPointX")); + im.principal_y = number(r->find("principalPointY")); + break; + } + return im; +} + +// One field's bytestream. Values are bit-packed LSB first and run on from one +// data packet into the next, so each packet's chunk is appended to the rest. +struct BitStream { + std::vector buf; + size_t end = 0; + uint64_t bit = 0; + + void append(const uint8_t* p, size_t n) { + const size_t drop = (size_t)(bit >> 3); + if (drop) { + std::memmove(buf.data(), buf.data() + drop, end - drop); + end -= drop; + bit -= (uint64_t)drop * 8; + } + // Zero tail: take() loads nine bytes whatever the width. + buf.resize(end + n + 16); + std::memcpy(buf.data() + end, p, n); + end += n; + std::memset(buf.data() + end, 0, 16); + } + uint64_t available(int bits) const { + if (bits == 0) return std::numeric_limits::max(); + return ((uint64_t)end * 8 - bit) / (uint64_t)bits; + } + uint64_t take(int bits) { + const uint8_t* p = buf.data() + (bit >> 3); + const int shift = (int)(bit & 7); + uint64_t v = le64(p) >> shift; + if (shift + bits > 64) v |= (uint64_t)p[8] << (64 - shift); + if (bits < 64) v &= (uint64_t(1) << bits) - 1; + bit += (uint64_t)bits; + return v; + } +}; + +double decode(const Field& f, BitStream& s) { + if (f.kind == Field::Kind::Float) { + if (f.bits == 32) { + const uint32_t u = (uint32_t)s.take(32); + float x; + std::memcpy(&x, &u, 4); + return x; + } + const uint64_t u = s.take(64); + double x; + std::memcpy(&x, &u, 8); + return x; + } + const int64_t v = (int64_t)(s.take(f.bits) + (uint64_t)f.minimum); + return f.kind == Field::Kind::ScaledInteger ? (double)v * f.scale + f.offset + : (double)v; +} + +uint8_t to_byte(double v, double lo, double hi) { + const double t = (v - lo) / (hi - lo) * 255.0; + if (!(t > 0.0)) return 0; + return t >= 255.0 ? (uint8_t)255 : (uint8_t)(t + 0.5); +} + +} // namespace + + +void Pose::rotation(double R[9]) const { + const double w = q[0], x = q[1], y = q[2], z = q[3]; + R[0] = 1 - 2*(y*y + z*z); R[1] = 2*(x*y - w*z); R[2] = 2*(x*z + w*y); + R[3] = 2*(x*y + w*z); R[4] = 1 - 2*(x*x + z*z); R[5] = 2*(y*z - w*x); + R[6] = 2*(x*z - w*y); R[7] = 2*(y*z + w*x); R[8] = 1 - 2*(x*x + y*y); +} + +void Pose::apply(const double in[3], double out[3]) const { + double R[9]; + rotation(R); + for (int r = 0; r < 3; r++) + out[r] = R[r*3]*in[0] + R[r*3 + 1]*in[1] + R[r*3 + 2]*in[2] + t[r]; +} + + +Reader::Reader(const std::string& path) : _path(path) { + _file.open(path, std::ios::binary); + if (!_file) fail("cannot open " + path); + uint8_t h[48]; + if (!_file.read(reinterpret_cast(h), sizeof h)) + fail(path + " is too short to be an E57 file"); + if (std::memcmp(h, "ASTM-E57", 8) != 0) fail(path + " is not an E57 file"); + if (le32(h + 8) != 1) + fail(path + ": unsupported format version " + std::to_string(le32(h + 8))); + _length = le64(h + 16); + const uint64_t xml_offset = le64(h + 24), xml_length = le64(h + 32); + _page = le64(h + 40); + if (_page <= kCrcBytes || _page > (uint64_t(1) << 24)) + fail(path + ": bad page size " + std::to_string(_page)); + _file.seekg(0, std::ios::end); + const uint64_t actual = (uint64_t)_file.tellg(); + if (actual < _length) + fail(path + " is truncated: " + std::to_string(actual) + " of " + + std::to_string(_length) + " bytes"); + + std::string xml(xml_length, '\0'); + read_logical(xml_offset, xml.data(), xml.size()); + const XmlNode root = xml_parse(xml); + if (root.tag != "e57Root") fail(path + ": the XML section has no e57Root"); + if (const XmlNode* d = root.find("data3D")) + for (const XmlNode& c : d->children) _scans.push_back(read_scan(c)); + if (const XmlNode* d = root.find("images2D")) + for (const XmlNode& c : d->children) _images.push_back(read_image(c)); +} + +int64_t Reader::total_points() const { + int64_t n = 0; + for (const Scan& s : _scans) n += s.count; + return n; +} + +uint64_t Reader::to_logical(uint64_t physical) const { + return physical / _page * (_page - kCrcBytes) + physical % _page; +} + +uint64_t Reader::to_physical(uint64_t logical) const { + const uint64_t payload = _page - kCrcBytes; + return logical / payload * _page + logical % payload; +} + +void Reader::read_logical(uint64_t physical, void* dst, size_t n) { + const uint64_t payload = _page - kCrcBytes; + if (physical % _page >= payload) fail("an offset points into a page checksum"); + if (n == 0) return; + // One physical read for the whole span; the checksums are skipped in memory. + const uint64_t last = to_physical(to_logical(physical) + n - 1); + if (last >= _length) fail("a section runs past the end of " + _path); + _scratch.resize(last - physical + 1); + _file.clear(); + _file.seekg((std::streamoff)physical); + if (!_file.read(reinterpret_cast(_scratch.data()), + (std::streamsize)_scratch.size())) + fail("read failed in " + _path); + uint8_t* out = static_cast(dst); + size_t got = 0, at = 0; + uint64_t in_page = physical % _page; + while (got < n) { + const size_t take = (size_t)std::min(payload - in_page, n - got); + std::memcpy(out + got, _scratch.data() + at, take); + got += take; + at += take + kCrcBytes; + in_page = 0; + } +} + +std::vector Reader::read_blob(const Blob& b) { + uint8_t h[16]; + read_logical(b.offset, h, sizeof h); + if (h[0] != kBlobSection) fail("a blob does not point at a blob section"); + if (b.length + sizeof h > le64(h + 8)) fail("a blob is longer than its section"); + std::vector out((size_t)b.length); + read_logical(to_physical(to_logical(b.offset) + sizeof h), out.data(), out.size()); + return out; +} + +bool Reader::read_points(const Scan& scan, + const std::function& sink, + const std::atomic* cancel) { + enum Role { X, Y, Z, Range, Azimuth, Elevation, Red, Green, Blue, Intensity, + CartInvalid, SphInvalid, ColorInvalid, kRoles }; + static const char* const kNames[kRoles] = { + "cartesianX", "cartesianY", "cartesianZ", "sphericalRange", + "sphericalAzimuth", "sphericalElevation", "colorRed", "colorGreen", + "colorBlue", "intensity", "cartesianInvalidState", + "sphericalInvalidState", "isColorInvalid"}; + int idx[kRoles]; + for (int r = 0; r < kRoles; r++) idx[r] = field_index(scan, kNames[r]); + const bool cartesian = idx[X] >= 0 && idx[Y] >= 0 && idx[Z] >= 0; + if (!cartesian && (idx[Range] < 0 || idx[Azimuth] < 0 || idx[Elevation] < 0)) + fail("scan '" + scan.name + "' has neither cartesian nor spherical coordinates"); + const int invalid = cartesian ? idx[CartInvalid] : idx[SphInvalid]; + + uint8_t h[32]; + read_logical(scan.section, h, sizeof h); + if (h[0] != kPointsSection) + fail("scan '" + scan.name + "' does not point at a compressed-vector section"); + const uint64_t end = to_logical(scan.section) + le64(h + 8); + uint64_t pos = to_logical(le64(h + 16)); + + double R[9]; + scan.pose.rotation(R); + const size_t nf = scan.fields.size(); + std::vector streams(nf); + std::vector packet; + std::vector v(nf); + std::vector batch; + int64_t left = scan.count; + while (left > 0 && pos < end) { + if (cancel && cancel->load()) return false; + uint8_t ph[4]; + read_logical(to_physical(pos), ph, sizeof ph); + const uint64_t plen = (uint64_t)le16(ph + 2) + 1; + if (ph[0] == kDataPacket) { + packet.resize((size_t)plen); + read_logical(to_physical(pos), packet.data(), packet.size()); + if (plen < 6 || le16(&packet[4]) != nf) + fail("scan '" + scan.name + "': a data packet does not match the prototype"); + size_t at = 6 + 2 * nf; + for (size_t k = 0; k < nf; k++) { + const size_t len = le16(&packet[6 + 2 * k]); + if (at + len > plen) fail("scan '" + scan.name + "': a data packet overruns"); + streams[k].append(packet.data() + at, len); + at += len; + } + uint64_t n = (uint64_t)left; + for (size_t k = 0; k < nf; k++) + n = std::min(n, streams[k].available(scan.fields[k].bits)); + batch.clear(); + for (uint64_t i = 0; i < n; i++) { + for (size_t k = 0; k < nf; k++) v[k] = decode(scan.fields[k], streams[k]); + if (invalid >= 0 && v[invalid] != 0) continue; + double p[3]; + if (cartesian) { + p[0] = v[idx[X]]; p[1] = v[idx[Y]]; p[2] = v[idx[Z]]; + } else { + const double r = v[idx[Range]], az = v[idx[Azimuth]]; + const double el = v[idx[Elevation]]; + p[0] = r * std::cos(el) * std::cos(az); + p[1] = r * std::cos(el) * std::sin(az); + p[2] = r * std::sin(el); + } + // The scanner's own origin: what writers put in unflagged empty cells. + if (p[0] == 0 && p[1] == 0 && p[2] == 0) continue; + Point& o = batch.emplace_back(); + for (int r = 0; r < 3; r++) + o.xyz[r] = R[r*3]*p[0] + R[r*3 + 1]*p[1] + R[r*3 + 2]*p[2] + scan.pose.t[r]; + const bool color_ok = idx[ColorInvalid] < 0 || v[idx[ColorInvalid]] == 0; + if (scan.has_color && color_ok) { + for (int c = 0; c < 3; c++) + o.rgb[c] = to_byte(v[idx[Red + c]], scan.color_lo[c], scan.color_hi[c]); + } else { + const uint8_t g = scan.has_intensity + ? to_byte(v[idx[Intensity]], scan.intensity_lo, scan.intensity_hi) + : (uint8_t)200; + o.rgb[0] = o.rgb[1] = o.rgb[2] = g; + } + } + left -= (int64_t)n; + if (!batch.empty()) sink(batch.data(), batch.size()); + } else if (ph[0] != kIndexPacket && ph[0] != kEmptyPacket) { + fail("scan '" + scan.name + "': unknown packet type " + std::to_string(ph[0])); + } + pos += plen; + } + if (left > 0) + fail("scan '" + scan.name + "' ended after " + std::to_string(scan.count - left) + + " of " + std::to_string(scan.count) + " points"); + return true; +} + +} // namespace spirula::e57 diff --git a/src/data/E57Reader.h b/src/data/E57Reader.h new file mode 100644 index 000000000..d99cf3ed1 --- /dev/null +++ b/src/data/E57Reader.h @@ -0,0 +1,104 @@ +#pragma once + +// E57Reader -- the ASTM E2807 laser-scan container: its scans (point clouds, +// each with a pose) and the images registered to them. Read-only, no external +// dependencies. The format notes, and the camera conventions measured on real +// files, are docs/datasets.md "E57 laser scans". + +#include +#include +#include +#include +#include +#include + +namespace spirula::e57 { + +// p_file = R(q) p_local + t, q = (w, x, y, z), metres. +struct Pose { + double q[4] = {1, 0, 0, 0}; + double t[3] = {0, 0, 0}; + void rotation(double R[9]) const; // row-major + void apply(const double in[3], double out[3]) const; +}; + +// `offset` is the PHYSICAL file offset of the blob's section. +struct Blob { + uint64_t offset = 0; + uint64_t length = 0; + bool empty() const { return length == 0; } +}; + +enum class Projection { Pinhole, Spherical, Cylindrical, Reference }; + +struct Image { + std::string name, guid, scan_guid; + bool has_pose = false; + Pose pose; + Projection projection = Projection::Reference; + int64_t width = 0, height = 0; + Blob jpeg, png, mask; + // Pinhole: focal length and pixel pitch in metres, principal point in + // pixels with pixel centres on integers. Spherical: pitch in radians. + double focal_length = 0, pixel_width = 0, pixel_height = 0; + double principal_x = 0, principal_y = 0; +}; + +struct Field { + enum class Kind { Float, Integer, ScaledInteger }; + std::string name; + Kind kind = Kind::Float; + int bits = 64; // bit-pack width; a Float is 32 or 64 + int64_t minimum = 0; // Integer / ScaledInteger raw range + double scale = 1, offset = 0; + double lo = 0, hi = 0; // declared value range, for normalising + bool ranged = false; +}; + +struct Scan { + std::string name, guid; + Pose pose; + uint64_t section = 0; // physical offset of the points section + int64_t count = 0; + std::vector fields; + bool has_color = false, has_intensity = false; + double color_lo[3] = {0, 0, 0}, color_hi[3] = {255, 255, 255}; + double intensity_lo = 0, intensity_hi = 1; +}; + +// In the FILE frame. Colourless scans carry their intensity as grey, or 200. +struct Point { + double xyz[3]; + uint8_t rgb[3]; +}; + +class Reader { +public: + explicit Reader(const std::string& path); // throws std::runtime_error + + const std::vector& scans() const { return _scans; } + const std::vector& images() const { return _images; } + int64_t total_points() const; + + std::vector read_blob(const Blob& b); + + // Every valid point of `scan`, in batches. False when `cancel` stopped it. + bool read_points(const Scan& scan, + const std::function& sink, + const std::atomic* cancel = nullptr); + +private: + void read_logical(uint64_t physical, void* dst, size_t n); + uint64_t to_logical(uint64_t physical) const; + uint64_t to_physical(uint64_t logical) const; + + std::ifstream _file; + std::string _path; + uint64_t _page = 1024; + uint64_t _length = 0; + std::vector _scratch; + std::vector _scans; + std::vector _images; +}; + +} // namespace spirula::e57 diff --git a/src/data/PointCloudFile.cpp b/src/data/PointCloudFile.cpp new file mode 100644 index 000000000..64435d052 --- /dev/null +++ b/src/data/PointCloudFile.cpp @@ -0,0 +1,474 @@ +#include "data/PointCloudFile.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace spirula::cloud { +namespace { + +constexpr size_t kBatch = 1 << 18; + +[[noreturn]] void fail(const std::string& path, const std::string& why) { + throw std::runtime_error(path + ": " + why); +} + +std::string lower_ext(const std::string& path) { + const size_t dot = path.find_last_of('.'); + const size_t slash = path.find_last_of("/\\"); + if (dot == std::string::npos || (slash != std::string::npos && dot < slash)) return ""; + std::string e = path.substr(dot); + for (char& c : e) c = (char)std::tolower((unsigned char)c); + return e; +} + +template T rd(const uint8_t* p) { T v; std::memcpy(&v, p, sizeof v); return v; } + +} // namespace + +bool is_cloud_path(const std::string& path) { + const std::string e = lower_ext(path); + return e == ".e57" || e == ".las" || e == ".ply"; +} + +bool is_laz_path(const std::string& path) { return lower_ext(path) == ".laz"; } + +// ================ +// LAS +// ================ + +// ASPRS LAS 1.0-1.4, point formats 0-10, uncompressed. Colour is 16-bit by the +// standard, but writers disagree on whether they scaled 8-bit values up, so +// the range is taken from a sample of the file. +struct Reader::Las { + std::ifstream f; + uint64_t offset = 0, count = 0; + uint32_t record = 0; + int format = 0; + double scale[3] = {1, 1, 1}, shift[3] = {0, 0, 0}; + int rgb_at = -1; + int intensity_shift = 8; // 16-bit -> byte + int rgb_shift = 8; +}; + +namespace { + +int las_rgb_offset(int format) { + switch (format) { + case 2: return 20; + case 3: case 5: return 28; + case 7: case 8: case 10: return 30; + default: return -1; + } +} + +int las_min_record(int format) { + static const int kSize[11] = {20, 28, 26, 34, 57, 63, 30, 36, 38, 59, 67}; + return format >= 0 && format <= 10 ? kSize[format] : 0; +} + +// The smallest right shift that brings `max` into a byte. +int shift_for(uint32_t max) { + int s = 0; + while ((max >> s) > 255) s++; + return s; +} + +} // namespace + +// ================ +// PLY +// ================ + +struct Reader::Ply { + struct Prop { + std::string name, type, count_type; // count_type set for a list + int size = 0; + }; + struct Element { + std::string name; + int64_t count = 0; + std::vector props; + int stride = -1; // bytes a record, -1 with a list + }; + std::ifstream f; + enum class Enc { Ascii, Little, Big } enc = Enc::Little; + uint64_t body = 0; // byte offset after end_header + std::vector elements; + int vertex = -1; +}; + +namespace { + +int ply_size(const std::string& t) { + if (t == "char" || t == "uchar" || t == "int8" || t == "uint8") return 1; + if (t == "short" || t == "ushort" || t == "int16" || t == "uint16") return 2; + if (t == "int" || t == "uint" || t == "int32" || t == "uint32" || t == "float" || + t == "float32") return 4; + if (t == "double" || t == "float64") return 8; + return 0; +} + +double ply_value(const uint8_t* p, const std::string& t, bool big) { + uint8_t b[8]; + const int n = ply_size(t); + for (int i = 0; i < n; i++) b[i] = big ? p[n - 1 - i] : p[i]; + if (t == "char" || t == "int8") return (double)rd(b); + if (t == "uchar" || t == "uint8") return (double)b[0]; + if (t == "short" || t == "int16") return (double)rd(b); + if (t == "ushort" || t == "uint16") return (double)rd(b); + if (t == "int" || t == "int32") return (double)rd(b); + if (t == "uint" || t == "uint32") return (double)rd(b); + if (t == "float" || t == "float32") return (double)rd(b); + return rd(b); +} + +// A colour channel's value as a byte, by its type's range. +uint8_t ply_color(double v, const std::string& t) { + double s = 1.0; + if (t == "float" || t == "float32" || t == "double" || t == "float64") s = 255.0; + else if (t == "ushort" || t == "uint16") s = 255.0 / 65535.0; + return (uint8_t)std::clamp(std::lround(v * s), 0L, 255L); +} + +} // namespace + +Reader::Reader(const std::string& path) : _path(path) { + if (is_laz_path(path)) + fail(path, "LAZ is compressed LAS; decompress it to .las first (laszip, PDAL)"); + const std::string ext = lower_ext(path); + if (ext == ".e57") { + _info.format = Format::E57; + _e57 = std::make_unique(path); + _info.points = _e57->total_points(); + for (const e57::Scan& s : _e57->scans()) _info.has_color = _info.has_color || s.has_color; + // A tripod project's scans each stand somewhere (its reference scan + // at the identity); a mobile scan's one identity pose says nothing. + const bool several = _e57->scans().size() > 1; + for (const e57::Scan& s : _e57->scans()) { + Station st; + s.pose.rotation(st.R); + for (int a = 0; a < 3; a++) st.origin[a] = s.pose.t[a]; + const bool identity = st.origin[0] == 0 && st.origin[1] == 0 && + st.origin[2] == 0 && s.pose.q[0] == 1; + if ((several || !identity) && s.count > 0) _info.stations.push_back(st); + } + return; + } + if (ext == ".las") { + _info.format = Format::Las; + _las = std::make_unique(); + Las& L = *_las; + L.f.open(path, std::ios::binary); + if (!L.f) fail(path, "cannot open"); + uint8_t h[375] = {}; + L.f.read((char*)h, sizeof h); + if (L.f.gcount() < 227 || std::memcmp(h, "LASF", 4) != 0) fail(path, "not a LAS file"); + const int minor = h[25]; + L.offset = rd(h + 96); + if (h[104] & 0xC0) fail(path, "the points are LAZ-compressed; decompress them first"); + L.format = h[104] & 0x3F; + L.record = rd(h + 105); + L.count = rd(h + 107); + if (minor >= 4 && L.f.gcount() >= 255) { + const uint64_t n64 = rd(h + 247); + if (n64) L.count = n64; + } + if (L.format > 10 || (int)L.record < las_min_record(L.format)) + fail(path, "point format " + std::to_string(L.format) + " is not one LAS defines"); + for (int a = 0; a < 3; a++) { + L.scale[a] = rd(h + 131 + 8 * a); + L.shift[a] = rd(h + 155 + 8 * a); + } + L.rgb_at = las_rgb_offset(L.format); + _info.points = (int64_t)L.count; + _info.has_color = L.rgb_at >= 0; + // Ranges from up to 64 spans of 1024 records across the file. + uint32_t max_rgb = 0, max_i = 0; + std::vector buf((size_t)L.record * 1024); + const uint64_t spans = std::min(64, (L.count + 1023) / 1024); + for (uint64_t s = 0; s < spans; s++) { + const uint64_t first = L.count * s / std::max(spans, 1); + const uint64_t n = std::min(1024, L.count - first); + L.f.clear(); + L.f.seekg((std::streamoff)(L.offset + first * L.record)); + L.f.read((char*)buf.data(), (std::streamsize)(n * L.record)); + const uint64_t got = (uint64_t)L.f.gcount() / L.record; + for (uint64_t i = 0; i < got; i++) { + const uint8_t* p = &buf[i * L.record]; + max_i = std::max(max_i, rd(p + 12)); + if (L.rgb_at >= 0) + for (int c = 0; c < 3; c++) + max_rgb = std::max(max_rgb, rd(p + L.rgb_at + 2 * c)); + } + } + L.rgb_shift = shift_for(max_rgb); + L.intensity_shift = shift_for(max_i); + // A file whose colour fields are all zero carries no colour. + if (L.rgb_at >= 0 && max_rgb == 0) _info.has_color = false; + return; + } + if (ext == ".ply") { + _info.format = Format::Ply; + _ply = std::make_unique(); + Ply& P = *_ply; + P.f.open(path, std::ios::binary); + if (!P.f) fail(path, "cannot open"); + std::string line; + if (!std::getline(P.f, line) || line.rfind("ply", 0) != 0) fail(path, "not a PLY file"); + while (std::getline(P.f, line)) { + if (!line.empty() && line.back() == '\r') line.pop_back(); + std::istringstream ss(line); + std::string key; + ss >> key; + if (key == "format") { + std::string enc; + ss >> enc; + P.enc = enc == "ascii" ? Ply::Enc::Ascii + : enc == "binary_big_endian" ? Ply::Enc::Big : Ply::Enc::Little; + } else if (key == "element") { + Ply::Element e; + ss >> e.name >> e.count; + P.elements.push_back(e); + } else if (key == "property" && !P.elements.empty()) { + Ply::Prop pr; + std::string t; + ss >> t; + if (t == "list") { + ss >> pr.count_type >> pr.type >> pr.name; + } else { + pr.type = t; + ss >> pr.name; + pr.size = ply_size(t); + if (!pr.size) fail(path, "unknown PLY property type '" + t + "'"); + } + P.elements.back().props.push_back(pr); + } else if (key == "end_header") { + break; + } + } + if (!P.f) fail(path, "the PLY header has no end_header"); + P.body = (uint64_t)P.f.tellg(); + for (size_t i = 0; i < P.elements.size(); i++) { + Ply::Element& e = P.elements[i]; + int stride = 0; + for (const Ply::Prop& pr : e.props) { + if (!pr.count_type.empty()) { stride = -1; break; } + stride += pr.size; + } + e.stride = stride; + if (e.name == "vertex") P.vertex = (int)i; + } + if (P.vertex < 0) fail(path, "the PLY file has no vertex element"); + const Ply::Element& v = P.elements[(size_t)P.vertex]; + bool x = false, y = false, z = false, r = false; + for (const Ply::Prop& pr : v.props) { + x |= pr.name == "x"; y |= pr.name == "y"; z |= pr.name == "z"; + r |= pr.name == "red" || pr.name == "r" || pr.name == "diffuse_red"; + } + if (!(x && y && z)) fail(path, "the PLY vertices have no x, y, z"); + _info.points = v.count; + _info.has_color = r; + // The scanner's viewpoint a PCL-written PLY carries after its points; + // reachable without reading them only when every element before it is + // fixed-size binary. + bool seekable = P.enc != Ply::Enc::Ascii; + uint64_t at = P.body; + for (const Ply::Element& e : P.elements) { + if (e.name == "camera" && seekable && e.count > 0 && e.stride > 0) { + std::vector rec((size_t)e.stride); + P.f.clear(); + P.f.seekg((std::streamoff)at); + for (int64_t ci = 0; ci < e.count && P.f.read((char*)rec.data(), (std::streamsize)rec.size()); ci++) { + double val[12] = {}; + static const char* kNames[12] = { + "view_px", "view_py", "view_pz", "x_axisx", "x_axisy", "x_axisz", + "y_axisx", "y_axisy", "y_axisz", "z_axisx", "z_axisy", "z_axisz"}; + int found = 0, off = 0; + for (const Ply::Prop& pr : e.props) { + for (int k = 0; k < 12; k++) + if (pr.name == kNames[k]) { + val[k] = ply_value(&rec[(size_t)off], pr.type, + P.enc == Ply::Enc::Big); + found++; + } + off += pr.size; + } + if (found != 12) break; + Station st; + for (int a = 0; a < 3; a++) { + st.origin[a] = val[a]; + for (int c = 0; c < 3; c++) st.R[a * 3 + c] = val[3 + 3 * c + a]; + } + _info.stations.push_back(st); + } + } + if (e.stride < 0) seekable = false; + at += (uint64_t)std::max(e.stride, 0) * (uint64_t)e.count; + } + return; + } + fail(path, "not a point cloud this reads (.e57, .las or .ply)"); +} + +Reader::~Reader() = default; + +bool Reader::read(const std::function& sink, + const std::atomic* cancel) { + if (_e57) { + for (const e57::Scan& s : _e57->scans()) + if (!_e57->read_points(s, sink, cancel)) return false; + return true; + } + std::vector batch; + batch.reserve(kBatch); + if (_las) { + Las& L = *_las; + L.f.clear(); + L.f.seekg((std::streamoff)L.offset); + std::vector buf((size_t)L.record * kBatch); + uint64_t left = L.count; + while (left > 0) { + if (cancel && cancel->load()) return false; + const uint64_t n = std::min(left, kBatch); + L.f.read((char*)buf.data(), (std::streamsize)(n * L.record)); + const uint64_t got = (uint64_t)L.f.gcount() / L.record; + if (got == 0) fail(_path, "the file ends before its last point"); + batch.clear(); + for (uint64_t i = 0; i < got; i++) { + const uint8_t* p = &buf[i * L.record]; + e57::Point& o = batch.emplace_back(); + for (int a = 0; a < 3; a++) + o.xyz[a] = (double)rd(p + 4 * a) * L.scale[a] + L.shift[a]; + if (_info.has_color) { + for (int c = 0; c < 3; c++) + o.rgb[c] = (uint8_t)std::min( + 255, rd(p + L.rgb_at + 2 * c) >> L.rgb_shift); + } else { + const uint8_t g = (uint8_t)std::min( + 255, rd(p + 12) >> L.intensity_shift); + o.rgb[0] = o.rgb[1] = o.rgb[2] = g; + } + } + sink(batch.data(), batch.size()); + left -= got; + } + return true; + } + Ply& P = *_ply; + P.f.clear(); + P.f.seekg((std::streamoff)P.body); + const bool big = P.enc == Ply::Enc::Big; + for (size_t ei = 0; ei < P.elements.size(); ei++) { + const Ply::Element& e = P.elements[ei]; + const bool is_vertex = (int)ei == P.vertex; + if (!is_vertex) { + if (P.enc != Ply::Enc::Ascii && e.stride >= 0) { + P.f.seekg((std::streamoff)(e.stride * e.count), std::ios::cur); + continue; + } + // A list (or ASCII) element ahead of the vertices: walk it. + for (int64_t i = 0; i < e.count; i++) { + if (P.enc == Ply::Enc::Ascii) { + std::string skip; + std::getline(P.f, skip); + continue; + } + for (const Ply::Prop& pr : e.props) { + if (pr.count_type.empty()) { + P.f.seekg(pr.size, std::ios::cur); + continue; + } + uint8_t c[8]; + const int cs = ply_size(pr.count_type); + P.f.read((char*)c, cs); + const int64_t n = (int64_t)ply_value(c, pr.count_type, big); + P.f.seekg((std::streamoff)(n * ply_size(pr.type)), std::ios::cur); + } + } + continue; + } + int px = -1, py = -1, pz = -1, pr = -1, pg = -1, pb = -1; + std::vector offs(e.props.size()); + int off = 0; + for (size_t k = 0; k < e.props.size(); k++) { + const std::string& n = e.props[k].name; + offs[k] = off; + off += e.props[k].size; + if (n == "x") px = (int)k; + else if (n == "y") py = (int)k; + else if (n == "z") pz = (int)k; + else if (n == "red" || n == "r" || n == "diffuse_red") pr = (int)k; + else if (n == "green" || n == "g" || n == "diffuse_green") pg = (int)k; + else if (n == "blue" || n == "b" || n == "diffuse_blue") pb = (int)k; + } + const bool color = pr >= 0 && pg >= 0 && pb >= 0; + if (P.enc != Ply::Enc::Ascii && e.stride < 0) + fail(_path, "PLY vertices with list properties are not read"); + std::vector buf(P.enc == Ply::Enc::Ascii ? 0 : (size_t)e.stride * kBatch); + int64_t left = e.count; + while (left > 0) { + if (cancel && cancel->load()) return false; + const int64_t n = std::min(left, (int64_t)kBatch); + batch.clear(); + if (P.enc == Ply::Enc::Ascii) { + std::string line; + std::vector v(e.props.size()); + for (int64_t i = 0; i < n; i++) { + if (!std::getline(P.f, line)) fail(_path, "the file ends before its last point"); + std::istringstream ss(line); + for (double& x : v) ss >> x; + e57::Point& o = batch.emplace_back(); + o.xyz[0] = v[(size_t)px]; o.xyz[1] = v[(size_t)py]; o.xyz[2] = v[(size_t)pz]; + if (color) { + const int ch[3] = {pr, pg, pb}; + for (int c = 0; c < 3; c++) + o.rgb[c] = ply_color(v[(size_t)ch[c]], e.props[(size_t)ch[c]].type); + } else { + o.rgb[0] = o.rgb[1] = o.rgb[2] = 200; + } + } + } else { + P.f.read((char*)buf.data(), (std::streamsize)(n * e.stride)); + if (P.f.gcount() != (std::streamsize)(n * e.stride)) + fail(_path, "the file ends before its last point"); + for (int64_t i = 0; i < n; i++) { + const uint8_t* rec = &buf[(size_t)(i * e.stride)]; + auto val = [&](int k) { + return ply_value(rec + offs[(size_t)k], e.props[(size_t)k].type, big); + }; + e57::Point& o = batch.emplace_back(); + o.xyz[0] = val(px); o.xyz[1] = val(py); o.xyz[2] = val(pz); + if (color) { + const int ch[3] = {pr, pg, pb}; + for (int c = 0; c < 3; c++) + o.rgb[c] = ply_color(val(ch[c]), e.props[(size_t)ch[c]].type); + } else { + o.rgb[0] = o.rgb[1] = o.rgb[2] = 200; + } + } + } + // Non-finite and origin-only records are what writers use for + // empty cells. + size_t w = 0; + for (size_t i = 0; i < batch.size(); i++) { + const double* q = batch[i].xyz; + if (!std::isfinite(q[0]) || !std::isfinite(q[1]) || !std::isfinite(q[2])) continue; + if (q[0] == 0 && q[1] == 0 && q[2] == 0) continue; + batch[w++] = batch[i]; + } + batch.resize(w); + if (!batch.empty()) sink(batch.data(), batch.size()); + left -= n; + } + return true; + } + return true; +} + +} // namespace spirula::cloud diff --git a/src/data/PointCloudFile.h b/src/data/PointCloudFile.h new file mode 100644 index 000000000..78b645fb8 --- /dev/null +++ b/src/data/PointCloudFile.h @@ -0,0 +1,62 @@ +#pragma once + +// A laser scan's points, whichever file they came in -- E57, LAS or PLY -- +// streamed in batches in the file's own frame, so a 100M-point cloud never has +// to fit in memory. docs/notes/lidar-alignment.md. + +#include "data/E57Reader.h" + +#include +#include +#include +#include +#include +#include + +namespace spirula::cloud { + +enum class Format { E57, Las, Ply }; + +// Where a scanner stood, when the file says so: a structured E57 scan's pose, +// or the `camera` element a PCL / ETH3D PLY carries. Row-major R, metres. +struct Station { + double origin[3] = {0, 0, 0}; + double R[9] = {1, 0, 0, 0, 1, 0, 0, 0, 1}; +}; + +struct Info { + Format format = Format::Ply; + int64_t points = 0; // as declared; invalid ones are dropped on read + bool has_color = false; // otherwise the points are intensity grey, or 200 + std::vector stations; +}; + +class Reader { +public: + explicit Reader(const std::string& path); // throws std::runtime_error + ~Reader(); + + const Info& info() const { return _info; } + // The E57 file behind this reader, for its images; null for LAS and PLY. + e57::Reader* e57() { return _e57.get(); } + + // Every point, in batches; false when `cancel` stopped it. + bool read(const std::function& sink, + const std::atomic* cancel = nullptr); + +private: + struct Las; + struct Ply; + std::string _path; + Info _info; + std::unique_ptr _e57; + std::unique_ptr _las; + std::unique_ptr _ply; +}; + +// By extension: .e57, .las, .ply (any case). LAZ is recognised so the caller +// can say why it is refused. +bool is_cloud_path(const std::string& path); +bool is_laz_path(const std::string& path); + +} // namespace spirula::cloud diff --git a/src/data/SparseEdit.cpp b/src/data/SparseEdit.cpp index 38fcc236b..59f1fecf9 100644 --- a/src/data/SparseEdit.cpp +++ b/src/data/SparseEdit.cpp @@ -664,7 +664,7 @@ SparseStats read_sparse_stats(const std::string& dataset_dir) { void write_ply_points(const std::string& path, const double* xyz, const uint8_t* rgb, int64_t n, const uint8_t* keep, - const Sim3* moved) { + const Sim3* moved, bool double_xyz) { int64_t kept = n; if (keep) { kept = 0; @@ -676,15 +676,20 @@ void write_ply_points(const std::string& path, const double* xyz, if (!f) throw std::runtime_error("cannot write " + path); f << "ply\nformat binary_little_endian 1.0\n"; f << "element vertex " << kept << "\n"; - f << "property float x\nproperty float y\nproperty float z\n"; + const char* type = double_xyz ? "double" : "float"; + f << "property " << type << " x\nproperty " << type << " y\nproperty " << type << " z\n"; f << "property uchar red\nproperty uchar green\nproperty uchar blue\n"; f << "end_header\n"; for (int64_t i = 0; i < n; i++) { if (keep && !keep[i]) continue; double q[3] = {xyz[i * 3], xyz[i * 3 + 1], xyz[i * 3 + 2]}; if (moved) moved->apply(&xyz[i * 3], q); - const float p[3] = {(float)q[0], (float)q[1], (float)q[2]}; - f.write(reinterpret_cast(p), sizeof p); + if (double_xyz) { + f.write(reinterpret_cast(q), sizeof q); + } else { + const float p[3] = {(float)q[0], (float)q[1], (float)q[2]}; + f.write(reinterpret_cast(p), sizeof p); + } const uint8_t c[3] = {rgb ? rgb[i * 3] : (uint8_t)200, rgb ? rgb[i * 3 + 1] : (uint8_t)200, rgb ? rgb[i * 3 + 2] : (uint8_t)200}; diff --git a/src/data/SparseEdit.h b/src/data/SparseEdit.h index de144ccab..82b4d2966 100644 --- a/src/data/SparseEdit.h +++ b/src/data/SparseEdit.h @@ -84,8 +84,9 @@ struct SparseStats { SparseStats read_sparse_stats(const std::string& dataset_dir); // A plain point cloud, for anything that has no reconstruction behind it. +// `double_xyz` keeps a cloud far from its origin to the millimetre. void write_ply_points(const std::string& path, const double* xyz, const uint8_t* rgb, int64_t n, const uint8_t* keep, - const Sim3* moved = nullptr); + const Sim3* moved = nullptr, bool double_xyz = false); } // namespace spirula diff --git a/src/data/parsers/ColmapParser.cpp b/src/data/parsers/ColmapParser.cpp index 21f5389bf..5f35cc308 100644 --- a/src/data/parsers/ColmapParser.cpp +++ b/src/data/parsers/ColmapParser.cpp @@ -103,23 +103,6 @@ int colmap_model_id(const std::string& name) { } // namespace -// gauge.txt beside the model (sfm/Pipeline.h): what the frame is worth. Absent -// for every reconstruction not made here, and then the answer is "nothing". -void read_gauge(const std::string& recon_dir, ParsedDataset& ds) { - std::ifstream f(recon_dir + "/gauge.txt"); - if (!f) return; - // Line at a time, so a comment with an odd number of words cannot shift - // every key onto the wrong value. - std::string line; - while (std::getline(f, line)) { - std::istringstream in(line); - std::string key, value; - if (!(in >> key >> value) || key[0] == '#') continue; - if (key == "oriented") ds.gauge_oriented = value == "1"; - else if (key == "metric") ds.gauge_metric = value == "1"; - } -} - // Whether an editor save has replaced any of the model's files. static bool has_edit_originals(const std::string& recon_dir) { std::error_code ec; @@ -886,7 +869,7 @@ ParsedDataset parse_colmap_dataset(const std::string& dataset_dir, ds.center = center; ds.center_mode = dsparse::kCenterModeNames[(int)center_mode]; ds.points = std::move(points); - read_gauge(recon_dir, ds); + dsparse::read_gauge(recon_dir, ds); ds.edited_in_place = has_edit_originals(recon_dir); ds.camera_models.reserve(N); ds.camera_distortions.reserve(N); diff --git a/src/data/parsers/DatasetCommon.cpp b/src/data/parsers/DatasetCommon.cpp index 3836547c9..e1d7624db 100644 --- a/src/data/parsers/DatasetCommon.cpp +++ b/src/data/parsers/DatasetCommon.cpp @@ -12,7 +12,9 @@ #include #include #include +#include #include +#include #include namespace fs = std::filesystem; @@ -125,7 +127,9 @@ double compute_normalized_transform(const double* c2w, int64_t n, max_abs = std::max(max_abs, std::abs(R[r][0]*d[0] + R[r][1]*d[1] + R[r][2]*d[2])); } - double scale_factor = 1.0 / std::max(max_abs, 1e-12); + // Cameras all in one place (one photo, a tripod's turn) have no size to + // fit, and 1/1e-12 leaves train_to_normalized singular in float. + const double scale_factor = max_abs > 1e-9 ? 1.0 / max_abs : 1.0; // T_n_from_camera = scale * [R_align | -R_align @ center] for (int r = 0; r < 3; r++) { @@ -630,4 +634,19 @@ void fit_camera_resolution(const DatasetParserConfig& cfg, W = tw; H = th; } +void read_gauge(const std::string& dir, ParsedDataset& ds) { + std::ifstream f(dir + "/gauge.txt"); + if (!f) return; + // Line at a time, so a comment with an odd number of words cannot shift + // every key onto the wrong value. + std::string line; + while (std::getline(f, line)) { + std::istringstream in(line); + std::string key, value; + if (!(in >> key >> value) || key[0] == '#') continue; + if (key == "oriented") ds.gauge_oriented = value == "1"; + else if (key == "metric") ds.gauge_metric = value == "1"; + } +} + } // namespace dsparse diff --git a/src/data/parsers/NerfstudioParser.cpp b/src/data/parsers/NerfstudioParser.cpp index 1f1506588..ecd4ca205 100644 --- a/src/data/parsers/NerfstudioParser.cpp +++ b/src/data/parsers/NerfstudioParser.cpp @@ -404,6 +404,7 @@ ParsedDataset parse_nerfstudio_dataset(const std::string& dataset_dir, " does not exist"); JsonValue meta = json_parse_file(transforms_path.string()); ParsedDataset ds = parse_nerfstudio_meta(meta, dataset_dir, cfg); + dsparse::read_gauge(dataset_dir, ds); std::error_code ec; ds.edited_in_place = fs::exists(transforms_path.string() + ".orig", ec); return ds; diff --git a/src/data/tests/E57TestFile.h b/src/data/tests/E57TestFile.h new file mode 100644 index 000000000..f7f2d4de9 --- /dev/null +++ b/src/data/tests/E57TestFile.h @@ -0,0 +1,198 @@ +#pragma once + +// A synthetic E57 file for data/E57Reader.h and app/E57Dataset.h tests: paged +// with zero checksums (the reader does not verify them), one scan whose fields +// have odd bit widths and whose streams are cut unevenly across packets, so +// values straddle packet boundaries, and whatever images a test describes. + +#include +#include +#include +#include +#include +#include +#include + +namespace e57test { + +// cartesianX is ScaledInteger at 1e-4 m, so `x` is taken to that grid. +struct TestPoint { + double x, y, z; + int r, g, b; // red and blue 0..255, green 0..1023 + int invalid; // cartesianInvalidState, 0..2 +}; + +struct Spec { + std::vector points; + double scan_q[4] = {1, 0, 0, 0}; + double scan_t[3] = {0, 0, 0}; + // Each is the inside of one of images2D; "{BLOB}" becomes a + // and "{PNGBLOB}" a element pointing at `blob`. + std::vector images; + std::string blob; +}; + +inline uint64_t to_physical(uint64_t logical) { + return logical / 1020 * 1024 + logical % 1020; +} + +struct Bits { + std::vector bytes; + uint64_t bit = 0; + void put(uint64_t v, int n) { + for (int i = 0; i < n; i++, bit++) { + if ((bit >> 3) >= bytes.size()) bytes.push_back(0); + if ((v >> i) & 1) bytes[bit >> 3] |= (uint8_t)(1u << (bit & 7)); + } + } +}; + +template +void append(std::string& s, T v) { s.append(reinterpret_cast(&v), sizeof v); } + +inline std::string num(double v) { + char b[40]; + std::snprintf(b, sizeof b, "%.17g", v); + return b; +} + +// The whole file, ready to write. +inline std::string build(const Spec& spec) { + std::string L(48, '\0'); // logical bytes; the header is filled last + + Bits streams[7]; + for (const TestPoint& p : spec.points) { + streams[0].put((uint64_t)(std::llround(p.x * 1e4) + 2000000), 22); + const float y = (float)p.y; + uint32_t yu; std::memcpy(&yu, &y, 4); + streams[1].put(yu, 32); + uint64_t zu; std::memcpy(&zu, &p.z, 8); + streams[2].put(zu, 64); + streams[3].put((uint64_t)p.r, 8); + streams[4].put((uint64_t)p.g, 10); + streams[5].put((uint64_t)p.b, 8); + streams[6].put((uint64_t)p.invalid, 2); + } + + const uint64_t section = L.size(); + L.append(32, '\0'); + const uint64_t data_start = L.size(); + // Uneven cuts on purpose, and enough of them to keep each packet under + // the 64 KiB a packet length can say. + size_t total = 0; + for (const Bits& b : streams) total += b.bytes.size(); + const int packets = std::max(3, (int)(total / 30000) + 1); + std::vector cuts = {0.0}; + for (int k = 1; k < packets; k++) cuts.push_back((k - (k % 2 ? 0.63 : 0.0)) / packets); + cuts.push_back(1.0); + for (int k = 0; k < packets; k++) { + if (k == packets - 1) { // an empty packet between two data packets + L.push_back(2); L.push_back(0); append(L, 3); + } + std::string body; + uint16_t lens[7]; + for (int f = 0; f < 7; f++) { + const size_t n = streams[f].bytes.size(); + const size_t a = (size_t)(n * cuts[k]); + const size_t b = k == packets - 1 ? n : (size_t)(n * cuts[k + 1]); + lens[f] = (uint16_t)(b - a); + body.append(reinterpret_cast(streams[f].bytes.data()) + a, b - a); + } + const size_t len = 6 + 2 * 7 + body.size(); + L.push_back(1); L.push_back(0); + append(L, (uint16_t)(len - 1)); + append(L, 7); + for (uint16_t n : lens) append(L, n); + L += body; + } + const uint64_t section_len = L.size() - section; + L[section] = 1; + std::memcpy(&L[section + 8], §ion_len, 8); + const uint64_t data_phys = to_physical(data_start); + std::memcpy(&L[section + 16], &data_phys, 8); + + const uint64_t blob_at = L.size(); + L.push_back(0); L.append(7, '\0'); + append(L, 16 + spec.blob.size()); + L += spec.blob; + + const std::string blob_ref = " type=\"Blob\" fileOffset=\"" + + std::to_string(to_physical(blob_at)) + "\" length=\"" + + std::to_string(spec.blob.size()) + "\"/>"; + std::string images; + for (std::string im : spec.images) { + size_t at = im.find("{BLOB}"); + if (at != std::string::npos) im.replace(at, 6, "" + im + "\n"; + } + const double* q = spec.scan_q; + const double* t = spec.scan_t; + const std::string xml = + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "\n" + "" + "255" + "1023" + "255" + "\n" + "" + "" + num(q[0]) + "" + num(q[1]) + + "" + num(q[2]) + "" + num(q[3]) + "" + "" + num(t[0]) + + "" + num(t[1]) + "" + num(t[2]) + "" + "\n" + "\n" + "" + "" + "" + "" + "" + "" + "" + "" + "\n" + "\n" + "\n" + images + + "\n"; + const uint64_t xml_at = L.size(); + L += xml; + + while (L.size() % 1020) L.push_back('\0'); + const uint64_t physical = L.size() / 1020 * 1024; + std::memcpy(&L[0], "ASTM-E57", 8); + const uint32_t major = 1, minor = 0; + std::memcpy(&L[8], &major, 4); + std::memcpy(&L[12], &minor, 4); + std::memcpy(&L[16], &physical, 8); + const uint64_t xml_phys = to_physical(xml_at), xml_len = xml.size(), page = 1024; + std::memcpy(&L[24], &xml_phys, 8); + std::memcpy(&L[32], &xml_len, 8); + std::memcpy(&L[40], &page, 8); + + std::string file; + for (size_t p = 0; p < L.size(); p += 1020) { + file.append(L, p, 1020); + file.append(4, '\0'); + } + return file; +} + +// An image's element. +inline std::string pose_xml(const double q[4], const double t[3]) { + return "" + + num(q[0]) + "" + num(q[1]) + "" + + num(q[2]) + "" + num(q[3]) + + "" + + num(t[0]) + "" + num(t[1]) + "" + + num(t[2]) + ""; +} + +} // namespace e57test diff --git a/src/data/tests/e57_reader_test.cpp b/src/data/tests/e57_reader_test.cpp new file mode 100644 index 000000000..4de5cfb8c --- /dev/null +++ b/src/data/tests/e57_reader_test.cpp @@ -0,0 +1,138 @@ +// e57_reader_test -- data/E57Reader.h against a synthetic file whose every +// value is known: bit-packed fields of odd widths cut across packets, a pose, +// invalid points, colour limits, a blob that spans pages, and files that are +// not E57 or are cut short. + +#include "data/E57Reader.h" +#include "data/tests/E57TestFile.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +namespace e57 = spirula::e57; + +namespace { + +int g_failures = 0; + +void check(bool ok, const std::string& what) { + std::printf("%s %s\n", ok ? "ok " : "FAIL", what.c_str()); + if (!ok) g_failures++; +} + +std::string write_file(const std::string& name, const std::string& bytes) { + const fs::path p = fs::temp_directory_path() / name; + std::ofstream(p, std::ios::binary) << bytes; + return p.string(); +} + +bool throws(const std::string& path) { + try { + e57::Reader r(path); + } catch (const std::runtime_error&) { + return true; + } + return false; +} + +} // namespace + +int main() { + std::mt19937 rng(7); + std::uniform_real_distribution coord(-150.0, 150.0); + std::uniform_int_distribution byte(0, 255), ten(0, 1023), state(0, 9); + + e57test::Spec spec; + for (int i = 0; i < 3000; i++) { + e57test::TestPoint p; + p.x = std::round(coord(rng) * 1e4) / 1e4; + p.y = (double)(float)coord(rng); + p.z = coord(rng); + p.r = byte(rng); p.g = ten(rng); p.b = byte(rng); + p.invalid = state(rng) == 0 ? 2 : 0; + spec.points.push_back(p); + } + // 90 degrees about +z, then a translation. + spec.scan_q[0] = std::sqrt(0.5); spec.scan_q[3] = std::sqrt(0.5); + spec.scan_t[0] = 1000.0; spec.scan_t[1] = -2.0; spec.scan_t[2] = 0.5; + for (int i = 0; i < 3000; i++) spec.blob.push_back((char)(i * 7 + 3)); + const double q[4] = {1, 0, 0, 0}, t[3] = {1, 2, 3}; + spec.images.push_back( + "" + "" + + e57test::pose_xml(q, t) + + "{BLOB}" + "640480" + "0.004" + "5e-064e-06" + "319.5" + ""); + spec.images.push_back( + "" + "{BLOB}" + "200100" + "0.031415926535897934" + "0.031415926535897934" + ""); + + const std::string bytes = e57test::build(spec); + const std::string path = write_file("e57_reader_test.e57", bytes); + e57::Reader reader(path); + + check(reader.scans().size() == 1 && reader.images().size() == 2, "one scan, two images"); + const e57::Scan& scan = reader.scans()[0]; + check(scan.count == 3000 && reader.total_points() == 3000, "record count"); + check(scan.fields.size() == 7 && scan.fields[0].bits == 22 && scan.fields[4].bits == 10 && + scan.fields[6].bits == 2 && scan.fields[1].bits == 32, "bit widths from the prototype"); + check(scan.has_color && scan.color_hi[1] == 1023, "colour limits"); + + std::vector got; + const bool whole = reader.read_points(scan, [&](const e57::Point* p, size_t n) { + got.insert(got.end(), p, p + n); + }); + std::vector valid; + for (const auto& p : spec.points) if (!p.invalid) valid.push_back(p); + check(whole && got.size() == valid.size(), "invalid points dropped, the rest all read"); + double worst = 0; + int color_bad = 0; + for (size_t i = 0; i < std::min(got.size(), valid.size()); i++) { + const e57test::TestPoint& p = valid[i]; + // R(90 deg about z) (x, y, z) = (-y, x, z) + const double want[3] = {-p.y + 1000.0, p.x - 2.0, p.z + 0.5}; + for (int k = 0; k < 3; k++) worst = std::max(worst, std::fabs(got[i].xyz[k] - want[k])); + const int g = (int)std::lround(p.g / 1023.0 * 255.0); + if (got[i].rgb[0] != p.r || std::abs(got[i].rgb[1] - g) > 1 || got[i].rgb[2] != p.b) + color_bad++; + } + check(worst < 1e-9, "positions, posed into the file frame (worst " + std::to_string(worst) + ")"); + check(color_bad == 0, "colours, green scaled from 0..1023"); + + const e57::Image& pin = reader.images()[0]; + check(pin.projection == e57::Projection::Pinhole && pin.width == 640 && pin.height == 480 && + pin.focal_length == 0.004 && pin.principal_x == 319.5 && pin.principal_y == 0.0 && + pin.has_pose && pin.pose.t[2] == 3.0 && pin.scan_guid == "scan-0", + "pinhole calibration, an empty element read as zero"); + check(reader.images()[1].projection == e57::Projection::Spherical && + !reader.images()[1].has_pose, "spherical image without a pose"); + const std::vector blob = reader.read_blob(pin.jpeg); + check(std::string(blob.begin(), blob.end()) == spec.blob, "blob across page boundaries"); + + std::atomic stop{true}; + check(!reader.read_points(scan, [](const e57::Point*, size_t) {}, &stop), "cancel"); + + check(throws(write_file("e57_reader_bad.e57", "not an e57 file at all, not even close...")), + "a file that is not E57 throws"); + check(throws(write_file("e57_reader_cut.e57", bytes.substr(0, bytes.size() - 1024))), + "a truncated file throws"); + + std::printf(g_failures ? "\n%d FAILED\n" : "\nall passed\n", g_failures); + return g_failures ? 1 : 0; +} diff --git a/src/i18n/catalog/Cli.h b/src/i18n/catalog/Cli.h index f3a792846..b5048340d 100644 --- a/src/i18n/catalog/Cli.h +++ b/src/i18n/catalog/Cli.h @@ -108,6 +108,43 @@ SS_MSG(tool_geometry, NL("diepte en oppervlaktenormalen van een dataset schatten"), RU("оценить глубину и нормали поверхности набора данных"), TR("bir veri kümesinin derinliğini ve yüzey normallerini kestir")); +SS_MSG(tool_lidar, + EN("align a reconstruction with a laser scan (E57, LAS, PLY) and add its geometry"), + JA("再構成をレーザースキャン(E57、LAS、PLY)に位置合わせし、その形状を加える"), + ZH_HANS("把重建与激光扫描(E57、LAS、PLY)对齐,并加入其几何信息"), + ZH_HANT("把重建與雷射掃描(E57、LAS、PLY)對齊,並加入其幾何資訊"), + KO("재구성을 레이저 스캔(E57, LAS, PLY)에 정렬하고 그 형상을 추가"), + DE("eine Rekonstruktion an einem Laserscan (E57, LAS, PLY) ausrichten und seine " + "Geometrie hinzufügen"), + FR("aligner une reconstruction sur un scan laser (E57, LAS, PLY) et y ajouter sa " + "géométrie"), + ES("alinear una reconstrucción con un escaneo láser (E57, LAS, PLY) y añadir su " + "geometría"), + PT("alinhar uma reconstrução a uma varredura a laser (E57, LAS, PLY) e acrescentar " + "sua geometria"), + IT("allineare una ricostruzione a una scansione laser (E57, LAS, PLY) e aggiungerne " + "la geometria"), + NL("een reconstructie uitlijnen op een laserscan (E57, LAS, PLY) en de geometrie " + "ervan toevoegen"), + RU("совместить реконструкцию с лазерным сканом (E57, LAS, PLY) и добавить его " + "геометрию"), + TR("bir yeniden oluşturmayı lazer taramasıyla (E57, LAS, PLY) hizala ve " + "geometrisini ekle")); + +SS_MSG(tool_e57, + EN("make a training dataset from an E57 laser scan"), + JA("E57 レーザースキャンから学習用データセットを作る"), + ZH_HANS("从 E57 激光扫描生成训练数据集"), + ZH_HANT("從 E57 雷射掃描產生訓練資料集"), + KO("E57 레이저 스캔으로 학습용 데이터셋 만들기"), + DE("einen Trainingsdatensatz aus einem E57-Laserscan erstellen"), + FR("créer un jeu de données d'entraînement à partir d'un scan laser E57"), + ES("crear un conjunto de datos de entrenamiento a partir de un escaneo láser E57"), + PT("criar um conjunto de dados de treino a partir de uma varredura a laser E57"), + IT("creare un set di dati di addestramento da una scansione laser E57"), + NL("een trainingsdataset maken uit een E57-laserscan"), + RU("создать набор данных для обучения из лазерного скана E57"), + TR("bir E57 lazer taramasından eğitim veri kümesi oluştur")); SS_MSG(tool_partition, EN("split a reconstruction into parts to train separately, and merge them"), JA("再構成を別々に学習するパートに分割し、あとで結合する"), diff --git a/src/i18n/catalog/E57.h b/src/i18n/catalog/E57.h new file mode 100644 index 000000000..854f4ec54 --- /dev/null +++ b/src/i18n/catalog/E57.h @@ -0,0 +1,1153 @@ +#pragma once + +// What `spirula e57` says -- its --help and every line a run prints, which the +// GUI's E57 screen reads back through i18n::scan() -- and that screen's copy. +// +// Flag names and file names stay as they are in every language. + +#include "i18n/BeginCatalog.h" + +namespace spirula { +namespace i18n { +namespace msg { +namespace e57 { + +// =========================================================================== +// Command line +// =========================================================================== + +SS_MSG(tagline, + EN("A training dataset from an E57 laser scan, without a reconstruction"), + JA("E57 レーザースキャンから、再構成なしで学習用データセットを作ります"), + ZH_HANS("无需重建,直接从 E57 激光扫描生成训练数据集"), + ZH_HANT("無需重建,直接從 E57 雷射掃描產生訓練資料集"), + KO("재구성 없이 E57 레이저 스캔으로 학습용 데이터셋을 만듭니다"), + DE("Ein Trainingsdatensatz aus einem E57-Laserscan, ohne Rekonstruktion"), + FR("Un jeu de données d'entraînement tiré d'un scan laser E57, sans " + "reconstruction"), + ES("Un conjunto de datos de entrenamiento a partir de un escaneo láser E57, " + "sin reconstrucción"), + PT("Um conjunto de dados de treino a partir de uma varredura a laser E57, " + "sem reconstrução"), + IT("Un set di dati di addestramento da una scansione laser E57, senza " + "ricostruzione"), + NL("Een trainingsdataset uit een E57-laserscan, zonder reconstructie"), + RU("Набор данных для обучения из лазерного скана E57, без реконструкции"), + TR("Yeniden oluşturma yapmadan, bir E57 lazer taramasından eğitim veri " + "kümesi")); + +SS_MSG(usage_about, + EN("Writes the images the scan carries, their poses and a thinned copy of " + "its point cloud as a dataset the trainer opens directly: images/, " + "transforms.json and sparse_pc.ply. The folder defaults to one beside " + "the scan, named after it."), + JA("スキャンに含まれる画像とその姿勢、間引いた点群を、トレーナーがそのまま" + "開けるデータセット(images/、transforms.json、sparse_pc.ply)として" + "書き出します。フォルダを指定しなければ、スキャンの隣に同じ名前で作ります。"), + ZH_HANS("把扫描中的图像、它们的位姿和精简后的点云写成训练器可直接打开的数据集:" + "images/、transforms.json 和 sparse_pc.ply。默认文件夹位于扫描文件旁," + "与其同名。"), + ZH_HANT("把掃描中的影像、它們的位姿和精簡後的點雲寫成訓練器可直接開啟的資料集:" + "images/、transforms.json 和 sparse_pc.ply。預設資料夾位於掃描檔旁," + "與其同名。"), + KO("스캔에 담긴 이미지와 그 자세, 솎아 낸 포인트 클라우드를 트레이너가 바로 " + "여는 데이터셋(images/, transforms.json, sparse_pc.ply)으로 씁니다. " + "폴더를 지정하지 않으면 스캔 옆에 같은 이름으로 만듭니다."), + DE("Schreibt die Bilder des Scans, ihre Posen und eine ausgedünnte Kopie " + "seiner Punktwolke als Datensatz, den der Trainer direkt öffnet: " + "images/, transforms.json und sparse_pc.ply. Ohne Angabe liegt der " + "Ordner neben dem Scan und trägt seinen Namen."), + FR("Écrit les images du scan, leurs poses et une copie allégée de son nuage " + "de points sous forme d'un jeu de données que l'entraînement ouvre " + "directement : images/, transforms.json et sparse_pc.ply. Par défaut, le " + "dossier est placé à côté du scan et porte son nom."), + ES("Escribe las imágenes del escaneo, sus poses y una copia aligerada de su " + "nube de puntos como un conjunto de datos que el entrenador abre " + "directamente: images/, transforms.json y sparse_pc.ply. Por defecto, la " + "carpeta queda junto al escaneo y lleva su nombre."), + PT("Grava as imagens da varredura, suas poses e uma cópia reduzida da nuvem " + "de pontos como um conjunto de dados que o treinador abre diretamente: " + "images/, transforms.json e sparse_pc.ply. Por padrão, a pasta fica ao " + "lado da varredura, com o mesmo nome."), + IT("Scrive le immagini della scansione, le loro pose e una copia sfoltita " + "della nuvola di punti come un set di dati che l'addestratore apre " + "direttamente: images/, transforms.json e sparse_pc.ply. Per impostazione " + "predefinita la cartella sta accanto alla scansione, con lo stesso nome."), + NL("Schrijft de beelden van de scan, hun poses en een uitgedunde kopie van " + "de puntenwolk als een dataset die de trainer direct opent: images/, " + "transforms.json en sparse_pc.ply. Standaard komt de map naast de scan, " + "met dezelfde naam."), + RU("Записывает изображения скана, их позы и прореженную копию облака точек " + "как набор данных, который тренажёр открывает сразу: images/, " + "transforms.json и sparse_pc.ply. По умолчанию папка создаётся рядом со " + "сканом и называется так же."), + TR("Taramadaki görüntüleri, pozlarını ve nokta bulutunun seyreltilmiş bir " + "kopyasını eğiticinin doğrudan açtığı bir veri kümesi olarak yazar: " + "images/, transforms.json ve sparse_pc.ply. Varsayılan klasör taramanın " + "yanında, onunla aynı adla oluşturulur.")); + +SS_MSG(head_options, + EN("Options"), JA("オプション"), ZH_HANS("选项"), ZH_HANT("選項"), + KO("옵션"), DE("Optionen"), FR("Options"), ES("Opciones"), PT("Opções"), + IT("Opzioni"), NL("Opties"), RU("Параметры"), TR("Seçenekler")); + +SS_MSG(opt_points, + EN("Thin the point cloud to about this many seed points, one per voxel. " + "`all` keeps every point; 0 writes no cloud. Default: {0}"), + JA("点群をボクセルごとに 1 点へ間引き、初期点をおよそこの数にします。`all` " + "なら全点を残し、0 なら点群を書き出しません。既定: {0}"), + ZH_HANS("按体素把点云精简到约这么多个初始点,每个体素一个。`all` 表示保留" + "全部点,0 表示不写点云。默认:{0}"), + ZH_HANT("按體素把點雲精簡到約這麼多個初始點,每個體素一個。`all` 表示保留" + "全部點,0 表示不寫點雲。預設:{0}"), + KO("포인트 클라우드를 복셀마다 한 점씩 솎아 초기 점을 대략 이만큼으로 " + "만듭니다. `all`이면 모든 점을 남기고, 0이면 클라우드를 쓰지 않습니다. " + "기본값: {0}"), + DE("Die Punktwolke auf etwa so viele Startpunkte ausdünnen, einer je Voxel. " + "`all` behält alle Punkte, 0 schreibt keine Wolke. Standard: {0}"), + FR("Alléger le nuage à environ ce nombre de points de départ, un par voxel. " + "`all` garde tous les points ; 0 n'écrit aucun nuage. Par défaut : {0}"), + ES("Aligerar la nube hasta unos tantos puntos iniciales, uno por vóxel. " + "`all` conserva todos los puntos; 0 no escribe ninguna nube. Por " + "defecto: {0}"), + PT("Reduzir a nuvem a cerca de tantos pontos iniciais, um por voxel. `all` " + "mantém todos os pontos; 0 não grava nuvem. Padrão: {0}"), + IT("Sfoltire la nuvola a circa questo numero di punti iniziali, uno per " + "voxel. `all` conserva tutti i punti; 0 non scrive alcuna nuvola. " + "Predefinito: {0}"), + NL("De puntenwolk uitdunnen tot ongeveer zoveel beginpunten, één per voxel. " + "`all` houdt alle punten; 0 schrijft geen wolk. Standaard: {0}"), + RU("Проредить облако примерно до стольких начальных точек, по одной на " + "воксель. `all` сохраняет все точки, 0 -- облако не записывается. По " + "умолчанию: {0}"), + TR("Nokta bulutunu voksel başına bir nokta olacak şekilde yaklaşık bu kadar " + "başlangıç noktasına seyrelt. `all` tüm noktaları tutar; 0 bulut yazmaz. " + "Varsayılan: {0}")); + +SS_MSG(opt_no_pinhole, + EN("Leave out the pinhole images."), + JA("ピンホール画像を含めません。"), + ZH_HANS("不包含针孔图像。"), + ZH_HANT("不包含針孔影像。"), + KO("핀홀 이미지를 빼고 만듭니다."), + DE("Die Lochkamerabilder weglassen."), + FR("Laisser de côté les images sténopé."), + ES("Omitir las imágenes estenopeicas."), + PT("Deixar de fora as imagens pinhole."), + IT("Escludere le immagini stenopeiche."), + NL("De pinhole-beelden weglaten."), + RU("Не включать изображения камеры-обскуры."), + TR("İğne deliği görüntülerini dışarıda bırak.")); + +SS_MSG(opt_no_panoramas, + EN("Leave out the spherical panoramas."), + JA("球面パノラマを含めません。"), + ZH_HANS("不包含球面全景图。"), + ZH_HANT("不包含球面全景影像。"), + KO("구면 파노라마를 빼고 만듭니다."), + DE("Die sphärischen Panoramen weglassen."), + FR("Laisser de côté les panoramas sphériques."), + ES("Omitir las panorámicas esféricas."), + PT("Deixar de fora os panoramas esféricos."), + IT("Escludere i panorami sferici."), + NL("De bolvormige panorama's weglaten."), + RU("Не включать сферические панорамы."), + TR("Küresel panoramaları dışarıda bırak.")); + +SS_MSG(opt_overwrite, + EN("Write into a folder that is not empty. The dataset's own files are " + "replaced; nothing else is removed."), + JA("空でないフォルダにも書き込みます。データセットのファイルは置き換えますが、" + "それ以外は削除しません。"), + ZH_HANS("允许写入非空文件夹。数据集自己的文件会被替换,其他内容不会删除。"), + ZH_HANT("允許寫入非空資料夾。資料集自己的檔案會被取代,其他內容不會刪除。"), + KO("비어 있지 않은 폴더에도 씁니다. 데이터셋 자체의 파일은 바꾸고, 다른 것은 " + "지우지 않습니다."), + DE("In einen nicht leeren Ordner schreiben. Die Dateien des Datensatzes " + "werden ersetzt, sonst wird nichts entfernt."), + FR("Écrire dans un dossier qui n'est pas vide. Les fichiers du jeu de " + "données sont remplacés ; rien d'autre n'est supprimé."), + ES("Escribir en una carpeta que no está vacía. Los archivos del conjunto se " + "reemplazan; no se borra nada más."), + PT("Gravar numa pasta que não está vazia. Os arquivos do conjunto são " + "substituídos; nada mais é removido."), + IT("Scrivere in una cartella non vuota. I file del set di dati vengono " + "sostituiti; nient'altro viene rimosso."), + NL("In een map schrijven die niet leeg is. De bestanden van de dataset " + "worden vervangen; verder wordt niets verwijderd."), + RU("Писать в непустую папку. Файлы набора данных заменяются, остальное не " + "удаляется."), + TR("Boş olmayan bir klasöre yaz. Veri kümesinin kendi dosyaları değiştirilir; " + "başka hiçbir şey silinmez.")); + +SS_MSG(opt_info, + EN("List what the file holds and write nothing."), + JA("ファイルの中身を一覧にするだけで、何も書き出しません。"), + ZH_HANS("只列出文件内容,不写任何东西。"), + ZH_HANT("只列出檔案內容,不寫任何東西。"), + KO("파일 내용만 나열하고 아무것도 쓰지 않습니다."), + DE("Auflisten, was die Datei enthält, und nichts schreiben."), + FR("Lister le contenu du fichier sans rien écrire."), + ES("Listar lo que contiene el archivo sin escribir nada."), + PT("Listar o que o arquivo contém, sem gravar nada."), + IT("Elencare il contenuto del file senza scrivere nulla."), + NL("Tonen wat het bestand bevat en niets schrijven."), + RU("Показать содержимое файла и ничего не записывать."), + TR("Dosyanın içeriğini listele ve hiçbir şey yazma.")); + +SS_MSG(err_no_input, + EN("Name an .e57 file. `{0} --help` describes the options."), + JA(".e57 ファイルを指定してください。オプションは `{0} --help` にあります。"), + ZH_HANS("请指定一个 .e57 文件。`{0} --help` 说明了各选项。"), + ZH_HANT("請指定一個 .e57 檔案。`{0} --help` 說明了各選項。"), + KO(".e57 파일을 지정하세요. 옵션은 `{0} --help`에 있습니다."), + DE("Eine .e57-Datei angeben. `{0} --help` beschreibt die Optionen."), + FR("Indiquez un fichier .e57. `{0} --help` décrit les options."), + ES("Indique un archivo .e57. `{0} --help` describe las opciones."), + PT("Indique um arquivo .e57. `{0} --help` descreve as opções."), + IT("Indicare un file .e57. `{0} --help` descrive le opzioni."), + NL("Geef een .e57-bestand op. `{0} --help` beschrijft de opties."), + RU("Укажите файл .e57. Параметры описаны в `{0} --help`."), + TR("Bir .e57 dosyası belirtin. Seçenekler `{0} --help` içinde.")); + +SS_MSG(err_unknown_option, + EN("Unknown option: {0}"), JA("不明なオプション: {0}"), + ZH_HANS("未知选项:{0}"), ZH_HANT("未知選項:{0}"), KO("알 수 없는 옵션: {0}"), + DE("Unbekannte Option: {0}"), FR("Option inconnue : {0}"), + ES("Opción desconocida: {0}"), PT("Opção desconhecida: {0}"), + IT("Opzione sconosciuta: {0}"), NL("Onbekende optie: {0}"), + RU("Неизвестный параметр: {0}"), TR("Bilinmeyen seçenek: {0}")); + +SS_MSG(err_bad_number, + EN("{0} takes a whole number, not '{1}'"), + JA("{0} には整数を指定してください('{1}' ではなく)"), + ZH_HANS("{0} 需要整数,而不是“{1}”"), + ZH_HANT("{0} 需要整數,而不是「{1}」"), + KO("{0}에는 정수가 필요합니다('{1}'가 아니라)"), + DE("{0} erwartet eine ganze Zahl, nicht '{1}'"), + FR("{0} attend un nombre entier, pas « {1} »"), + ES("{0} espera un número entero, no '{1}'"), + PT("{0} espera um número inteiro, não '{1}'"), + IT("{0} richiede un numero intero, non '{1}'"), + NL("{0} verwacht een geheel getal, niet '{1}'"), + RU("{0} ожидает целое число, а не «{1}»"), + TR("{0} bir tam sayı bekler, '{1}' değil")); + +SS_MSG(opt_no_depth, + EN("Do not write depth and normal maps from the scan."), + JA("スキャンから深度マップと法線マップを書き出しません。"), + ZH_HANS("不从扫描写入深度图和法线图。"), + ZH_HANT("不從掃描寫入深度圖和法線圖。"), + KO("스캔에서 깊이 맵과 법선 맵을 쓰지 않습니다."), + DE("Keine Tiefen- und Normalenkarten aus dem Scan schreiben."), + FR("Ne pas écrire de cartes de profondeur et de normales tirées du scan."), + ES("No escribir mapas de profundidad y normales del escaneo."), + PT("Não gravar mapas de profundidade e normais da varredura."), + IT("Non scrivere mappe di profondità e normali dalla scansione."), + NL("Geen diepte- en normaalkaarten uit de scan schrijven."), + RU("Не записывать карты глубины и нормалей из скана."), + TR("Taramadan derinlik ve normal haritası yazma.")); + +// =========================================================================== +// What a run prints (the GUI scans progress_images / progress_points) +// =========================================================================== + +SS_MSG(summary, + EN("Scans: {0} Points: {1} Images: {2}"), + JA("スキャン: {0} 点: {1} 画像: {2}"), + ZH_HANS("扫描:{0} 点:{1} 图像:{2}"), + ZH_HANT("掃描:{0} 點:{1} 影像:{2}"), + KO("스캔: {0} 점: {1} 이미지: {2}"), + DE("Scans: {0} Punkte: {1} Bilder: {2}"), + FR("Scans : {0} Points : {1} Images : {2}"), + ES("Escaneos: {0} Puntos: {1} Imágenes: {2}"), + PT("Varreduras: {0} Pontos: {1} Imagens: {2}"), + IT("Scansioni: {0} Punti: {1} Immagini: {2}"), + NL("Scans: {0} Punten: {1} Beelden: {2}"), + RU("Сканов: {0} Точек: {1} Изображений: {2}"), + TR("Tarama: {0} Nokta: {1} Görüntü: {2}")); + +SS_MSG(summary_images, + EN("Pinhole images: {0} Panoramas: {1} Other: {2}"), + JA("ピンホール画像: {0} パノラマ: {1} その他: {2}"), + ZH_HANS("针孔图像:{0} 全景图:{1} 其他:{2}"), + ZH_HANT("針孔影像:{0} 全景影像:{1} 其他:{2}"), + KO("핀홀 이미지: {0} 파노라마: {1} 기타: {2}"), + DE("Lochkamerabilder: {0} Panoramen: {1} Andere: {2}"), + FR("Images sténopé : {0} Panoramas : {1} Autres : {2}"), + ES("Imágenes estenopeicas: {0} Panorámicas: {1} Otras: {2}"), + PT("Imagens pinhole: {0} Panoramas: {1} Outras: {2}"), + IT("Immagini stenopeiche: {0} Panorami: {1} Altre: {2}"), + NL("Pinhole-beelden: {0} Panorama's: {1} Overig: {2}"), + RU("Изображений обскуры: {0} Панорам: {1} Прочих: {2}"), + TR("İğne deliği görüntüsü: {0} Panorama: {1} Diğer: {2}")); + +SS_MSG(info_scan, + EN("Scan {0}: {1} Points: {2}"), + JA("スキャン {0}: {1} 点: {2}"), + ZH_HANS("扫描 {0}:{1} 点:{2}"), + ZH_HANT("掃描 {0}:{1} 點:{2}"), + KO("스캔 {0}: {1} 점: {2}"), + DE("Scan {0}: {1} Punkte: {2}"), + FR("Scan {0} : {1} Points : {2}"), + ES("Escaneo {0}: {1} Puntos: {2}"), + PT("Varredura {0}: {1} Pontos: {2}"), + IT("Scansione {0}: {1} Punti: {2}"), + NL("Scan {0}: {1} Punten: {2}"), + RU("Скан {0}: {1} Точек: {2}"), + TR("Tarama {0}: {1} Nokta: {2}")); + +SS_MSG(progress_images, + EN("Writing images: {0}/{1}"), + JA("画像を書き出し中: {0}/{1}"), + ZH_HANS("正在写入图像:{0}/{1}"), + ZH_HANT("正在寫入影像:{0}/{1}"), + KO("이미지 쓰는 중: {0}/{1}"), + DE("Bilder werden geschrieben: {0}/{1}"), + FR("Écriture des images : {0}/{1}"), + ES("Escribiendo imágenes: {0}/{1}"), + PT("Gravando imagens: {0}/{1}"), + IT("Scrittura delle immagini: {0}/{1}"), + NL("Beelden schrijven: {0}/{1}"), + RU("Запись изображений: {0}/{1}"), + TR("Görüntüler yazılıyor: {0}/{1}")); + +SS_MSG(progress_points, + EN("Reading scans: {0}/{1}"), + JA("スキャンを読み込み中: {0}/{1}"), + ZH_HANS("正在读取扫描:{0}/{1}"), + ZH_HANT("正在讀取掃描:{0}/{1}"), + KO("스캔 읽는 중: {0}/{1}"), + DE("Scans werden gelesen: {0}/{1}"), + FR("Lecture des scans : {0}/{1}"), + ES("Leyendo escaneos: {0}/{1}"), + PT("Lendo varreduras: {0}/{1}"), + IT("Lettura delle scansioni: {0}/{1}"), + NL("Scans lezen: {0}/{1}"), + RU("Чтение сканов: {0}/{1}"), + TR("Taramalar okunuyor: {0}/{1}")); + +SS_MSG(thinning, + EN("Thinning the point cloud. Points read: {0} Target: {1}"), + JA("点群を間引いています。読み込んだ点: {0} 目標: {1}"), + ZH_HANS("正在精简点云。已读取的点:{0} 目标:{1}"), + ZH_HANT("正在精簡點雲。已讀取的點:{0} 目標:{1}"), + KO("포인트 클라우드를 솎는 중. 읽은 점: {0} 목표: {1}"), + DE("Punktwolke wird ausgedünnt. Gelesene Punkte: {0} Ziel: {1}"), + FR("Allègement du nuage de points. Points lus : {0} Cible : {1}"), + ES("Aligerando la nube de puntos. Puntos leídos: {0} Objetivo: {1}"), + PT("Reduzindo a nuvem de pontos. Pontos lidos: {0} Alvo: {1}"), + IT("Sfoltimento della nuvola di punti. Punti letti: {0} Obiettivo: {1}"), + NL("Puntenwolk uitdunnen. Gelezen punten: {0} Doel: {1}"), + RU("Прореживание облака точек. Прочитано точек: {0} Цель: {1}"), + TR("Nokta bulutu seyreltiliyor. Okunan nokta: {0} Hedef: {1}")); + +SS_MSG(seed_done, + EN("Seed points: {0} Voxel: {1} m"), + JA("初期点: {0} ボクセル: {1} m"), + ZH_HANS("初始点:{0} 体素:{1} m"), + ZH_HANT("初始點:{0} 體素:{1} m"), + KO("초기 점: {0} 복셀: {1} m"), + DE("Startpunkte: {0} Voxel: {1} m"), + FR("Points de départ : {0} Voxel : {1} m"), + ES("Puntos iniciales: {0} Vóxel: {1} m"), + PT("Pontos iniciais: {0} Voxel: {1} m"), + IT("Punti iniziali: {0} Voxel: {1} m"), + NL("Beginpunten: {0} Voxel: {1} m"), + RU("Начальных точек: {0} Воксель: {1} м"), + TR("Başlangıç noktası: {0} Voksel: {1} m")); + +SS_MSG(seed_all, + EN("Seed points: {0}, every point of the scan"), + JA("初期点: {0}(スキャンの全点)"), + ZH_HANS("初始点:{0}(扫描中的全部点)"), + ZH_HANT("初始點:{0}(掃描中的全部點)"), + KO("초기 점: {0}(스캔의 모든 점)"), + DE("Startpunkte: {0}, alle Punkte des Scans"), + FR("Points de départ : {0}, tous les points du scan"), + ES("Puntos iniciales: {0}, todos los puntos del escaneo"), + PT("Pontos iniciais: {0}, todos os pontos da varredura"), + IT("Punti iniziali: {0}, tutti i punti della scansione"), + NL("Beginpunten: {0}, alle punten van de scan"), + RU("Начальных точек: {0}, все точки скана"), + TR("Başlangıç noktası: {0}, taramanın tüm noktaları")); + +SS_MSG(progress_depth, + EN("Writing depth maps: {0}/{1}"), + JA("深度マップを書き出し中: {0}/{1}"), + ZH_HANS("正在写入深度图:{0}/{1}"), + ZH_HANT("正在寫入深度圖:{0}/{1}"), + KO("깊이 맵 쓰는 중: {0}/{1}"), + DE("Tiefenkarten werden geschrieben: {0}/{1}"), + FR("Écriture des cartes de profondeur : {0}/{1}"), + ES("Escribiendo mapas de profundidad: {0}/{1}"), + PT("Gravando mapas de profundidade: {0}/{1}"), + IT("Scrittura delle mappe di profondità: {0}/{1}"), + NL("Dieptekaarten schrijven: {0}/{1}"), + RU("Запись карт глубины: {0}/{1}"), + TR("Derinlik haritaları yazılıyor: {0}/{1}")); + +SS_MSG(check_running, + EN("Checking that the photos line up with the scan..."), + JA("写真とスキャンが一致しているか確認しています..."), + ZH_HANS("正在检查照片与扫描是否对齐..."), + ZH_HANT("正在檢查照片與掃描是否對齊..."), + KO("사진이 스캔과 맞는지 확인하는 중..."), + DE("Es wird geprüft, ob die Fotos zum Scan passen ..."), + FR("Vérification de l'alignement des photos sur le scan..."), + ES("Comprobando que las fotos coinciden con el escaneo..."), + PT("Verificando se as fotos coincidem com a varredura..."), + IT("Verifica che le foto corrispondano alla scansione..."), + NL("Controleren of de foto's op de scan aansluiten..."), + RU("Проверка совпадения фотографий со сканом..."), + TR("Fotoğrafların taramayla örtüştüğü denetleniyor...")); + +SS_MSG(check_no_color, + EN("The scan has no colour, so its photos cannot be checked against it."), + JA("スキャンに色がないため、写真と照合できません。"), + ZH_HANS("扫描没有颜色,无法用它核对照片。"), + ZH_HANT("掃描沒有顏色,無法用它核對照片。"), + KO("스캔에 색이 없어 사진과 대조할 수 없습니다."), + DE("Der Scan hat keine Farbe, daher lassen sich die Fotos nicht mit ihm " + "abgleichen."), + FR("Le scan n'a pas de couleur ; ses photos ne peuvent pas être vérifiées."), + ES("El escaneo no tiene color, así que sus fotos no se pueden comprobar con " + "él."), + PT("A varredura não tem cor, então as fotos não podem ser conferidas com ela."), + IT("La scansione non ha colore, quindi le foto non possono essere verificate."), + NL("De scan heeft geen kleur, dus de foto's kunnen er niet mee worden " + "vergeleken."), + RU("Скан не содержит цвета, поэтому сверить с ним фотографии нельзя."), + TR("Taramada renk yok; bu yüzden fotoğraflar onunla karşılaştırılamaz.")); + +SS_MSG(check_ok, + EN("The photos line up with the scan. Colour correlation: {0}"), + JA("写真はスキャンと一致しています。色の相関: {0}"), + ZH_HANS("照片与扫描对齐。颜色相关性:{0}"), + ZH_HANT("照片與掃描對齊。顏色相關性:{0}"), + KO("사진이 스캔과 맞습니다. 색 상관계수: {0}"), + DE("Die Fotos passen zum Scan. Farbkorrelation: {0}"), + FR("Les photos sont alignées sur le scan. Corrélation des couleurs : {0}"), + ES("Las fotos coinciden con el escaneo. Correlación de color: {0}"), + PT("As fotos coincidem com a varredura. Correlação de cor: {0}"), + IT("Le foto corrispondono alla scansione. Correlazione del colore: {0}"), + NL("De foto's sluiten aan op de scan. Kleurcorrelatie: {0}"), + RU("Фотографии совпадают со сканом. Корреляция цвета: {0}"), + TR("Fotoğraflar taramayla örtüşüyor. Renk korelasyonu: {0}")); + +SS_MSG(check_fixed_pinhole, + EN("The pinhole images face another way than the E57 convention says; " + "corrected. Colour correlation: {0} -> {1}"), + JA("ピンホール画像の向きが E57 の規約と違っていたため、修正しました。色の相関: " + "{0} -> {1}"), + ZH_HANS("针孔图像的朝向与 E57 约定不同,已更正。颜色相关性:{0} -> {1}"), + ZH_HANT("針孔影像的朝向與 E57 約定不同,已更正。顏色相關性:{0} -> {1}"), + KO("핀홀 이미지의 방향이 E57 규약과 달라 바로잡았습니다. 색 상관계수: " + "{0} -> {1}"), + DE("Die Lochkamerabilder sind anders ausgerichtet, als die E57-Konvention " + "sagt; korrigiert. Farbkorrelation: {0} -> {1}"), + FR("Les images sténopé sont orientées autrement que ne le prévoit la " + "convention E57 ; corrigé. Corrélation des couleurs : {0} -> {1}"), + ES("Las imágenes estenopeicas están orientadas de otra forma que la " + "convención E57; corregido. Correlación de color: {0} -> {1}"), + PT("As imagens pinhole estão orientadas de outra forma que a convenção E57; " + "corrigido. Correlação de cor: {0} -> {1}"), + IT("Le immagini stenopeiche sono orientate diversamente dalla convenzione " + "E57; corretto. Correlazione del colore: {0} -> {1}"), + NL("De pinhole-beelden zijn anders gericht dan de E57-conventie zegt; " + "gecorrigeerd. Kleurcorrelatie: {0} -> {1}"), + RU("Изображения обскуры ориентированы не так, как требует соглашение E57; " + "исправлено. Корреляция цвета: {0} -> {1}"), + TR("İğne deliği görüntüleri E57 kuralından farklı yöne bakıyor; düzeltildi. " + "Renk korelasyonu: {0} -> {1}")); + +SS_MSG(check_fixed_panorama, + EN("The panoramas face another way than the E57 convention says; corrected. " + "Colour correlation: {0} -> {1}"), + JA("パノラマの向きが E57 の規約と違っていたため、修正しました。色の相関: " + "{0} -> {1}"), + ZH_HANS("全景图的朝向与 E57 约定不同,已更正。颜色相关性:{0} -> {1}"), + ZH_HANT("全景影像的朝向與 E57 約定不同,已更正。顏色相關性:{0} -> {1}"), + KO("파노라마의 방향이 E57 규약과 달라 바로잡았습니다. 색 상관계수: {0} -> {1}"), + DE("Die Panoramen sind anders ausgerichtet, als die E57-Konvention sagt; " + "korrigiert. Farbkorrelation: {0} -> {1}"), + FR("Les panoramas sont orientés autrement que ne le prévoit la convention " + "E57 ; corrigé. Corrélation des couleurs : {0} -> {1}"), + ES("Las panorámicas están orientadas de otra forma que la convención E57; " + "corregido. Correlación de color: {0} -> {1}"), + PT("Os panoramas estão orientados de outra forma que a convenção E57; " + "corrigido. Correlação de cor: {0} -> {1}"), + IT("I panorami sono orientati diversamente dalla convenzione E57; corretto. " + "Correlazione del colore: {0} -> {1}"), + NL("De panorama's zijn anders gericht dan de E57-conventie zegt; " + "gecorrigeerd. Kleurcorrelatie: {0} -> {1}"), + RU("Панорамы ориентированы не так, как требует соглашение E57; исправлено. " + "Корреляция цвета: {0} -> {1}"), + TR("Panoramalar E57 kuralından farklı yöne bakıyor; düzeltildi. Renk " + "korelasyonu: {0} -> {1}")); + +SS_MSG(check_unsure, + EN("Could not confirm that the photos line up with the scan (colour " + "correlation {0}). Look at the preview before training."), + JA("写真がスキャンと一致しているか確認できませんでした(色の相関 {0})。学習の" + "前にプレビューを確認してください。"), + ZH_HANS("无法确认照片与扫描对齐(颜色相关性 {0})。训练前请先查看预览。"), + ZH_HANT("無法確認照片與掃描對齊(顏色相關性 {0})。訓練前請先查看預覽。"), + KO("사진이 스캔과 맞는지 확인하지 못했습니다(색 상관계수 {0}). 학습 전에 " + "미리보기를 확인하세요."), + DE("Es ließ sich nicht bestätigen, dass die Fotos zum Scan passen " + "(Farbkorrelation {0}). Vor dem Training die Vorschau ansehen."), + FR("Impossible de confirmer l'alignement des photos sur le scan (corrélation " + "des couleurs {0}). Vérifiez l'aperçu avant l'entraînement."), + ES("No se pudo confirmar que las fotos coinciden con el escaneo (correlación " + "de color {0}). Revise la vista previa antes de entrenar."), + PT("Não foi possível confirmar que as fotos coincidem com a varredura " + "(correlação de cor {0}). Confira a pré-visualização antes de treinar."), + IT("Impossibile confermare che le foto corrispondano alla scansione " + "(correlazione del colore {0}). Controllare l'anteprima prima " + "dell'addestramento."), + NL("Kon niet bevestigen dat de foto's op de scan aansluiten (kleurcorrelatie " + "{0}). Bekijk de voorvertoning vóór het trainen."), + RU("Не удалось подтвердить, что фотографии совпадают со сканом (корреляция " + "цвета {0}). Проверьте предпросмотр перед обучением."), + TR("Fotoğrafların taramayla örtüştüğü doğrulanamadı (renk korelasyonu {0}). " + "Eğitimden önce önizlemeye bakın.")); + +SS_MSG(skip_no_pose, + EN("Skipped {0}: the image has no pose."), + JA("{0} を飛ばしました: 画像に姿勢がありません。"), + ZH_HANS("已跳过 {0}:图像没有位姿。"), + ZH_HANT("已略過 {0}:影像沒有位姿。"), + KO("{0} 건너뜀: 이미지에 자세가 없습니다."), + DE("{0} übersprungen: Das Bild hat keine Pose."), + FR("{0} ignorée : l'image n'a pas de pose."), + ES("{0} omitida: la imagen no tiene pose."), + PT("{0} ignorada: a imagem não tem pose."), + IT("{0} saltata: l'immagine non ha una posa."), + NL("{0} overgeslagen: het beeld heeft geen pose."), + RU("{0} пропущено: у изображения нет позы."), + TR("{0} atlandı: görüntünün pozu yok.")); + +SS_MSG(skip_no_pixels, + EN("Skipped {0}: the image carries neither a JPEG nor a PNG."), + JA("{0} を飛ばしました: 画像に JPEG も PNG も含まれていません。"), + ZH_HANS("已跳过 {0}:图像既没有 JPEG 也没有 PNG。"), + ZH_HANT("已略過 {0}:影像既沒有 JPEG 也沒有 PNG。"), + KO("{0} 건너뜀: 이미지에 JPEG도 PNG도 없습니다."), + DE("{0} übersprungen: Das Bild enthält weder JPEG noch PNG."), + FR("{0} ignorée : l'image ne contient ni JPEG ni PNG."), + ES("{0} omitida: la imagen no contiene ni JPEG ni PNG."), + PT("{0} ignorada: a imagem não contém JPEG nem PNG."), + IT("{0} saltata: l'immagine non contiene né JPEG né PNG."), + NL("{0} overgeslagen: het beeld bevat geen JPEG en geen PNG."), + RU("{0} пропущено: в изображении нет ни JPEG, ни PNG."), + TR("{0} atlandı: görüntüde ne JPEG ne de PNG var.")); + +SS_MSG(skip_cylindrical, + EN("Skipped {0}: cylindrical images are not supported."), + JA("{0} を飛ばしました: 円筒画像には対応していません。"), + ZH_HANS("已跳过 {0}:不支持柱面图像。"), + ZH_HANT("已略過 {0}:不支援柱面影像。"), + KO("{0} 건너뜀: 원통형 이미지는 지원하지 않습니다."), + DE("{0} übersprungen: Zylinderbilder werden nicht unterstützt."), + FR("{0} ignorée : les images cylindriques ne sont pas prises en charge."), + ES("{0} omitida: las imágenes cilíndricas no son compatibles."), + PT("{0} ignorada: imagens cilíndricas não são suportadas."), + IT("{0} saltata: le immagini cilindriche non sono supportate."), + NL("{0} overgeslagen: cilindrische beelden worden niet ondersteund."), + RU("{0} пропущено: цилиндрические изображения не поддерживаются."), + TR("{0} atlandı: silindirik görüntüler desteklenmiyor.")); + +SS_MSG(skip_uncalibrated, + EN("Skipped {0}: the image has no calibration."), + JA("{0} を飛ばしました: 画像にキャリブレーションがありません。"), + ZH_HANS("已跳过 {0}:图像没有标定。"), + ZH_HANT("已略過 {0}:影像沒有校正。"), + KO("{0} 건너뜀: 이미지에 보정 정보가 없습니다."), + DE("{0} übersprungen: Das Bild hat keine Kalibrierung."), + FR("{0} ignorée : l'image n'a pas d'étalonnage."), + ES("{0} omitida: la imagen no tiene calibración."), + PT("{0} ignorada: a imagem não tem calibração."), + IT("{0} saltata: l'immagine non ha una calibrazione."), + NL("{0} overgeslagen: het beeld heeft geen kalibratie."), + RU("{0} пропущено: у изображения нет калибровки."), + TR("{0} atlandı: görüntünün kalibrasyonu yok.")); + +SS_MSG(skip_bad_pinhole, + EN("Skipped {0}: its pinhole calibration is incomplete."), + JA("{0} を飛ばしました: ピンホールのキャリブレーションが不完全です。"), + ZH_HANS("已跳过 {0}:针孔标定不完整。"), + ZH_HANT("已略過 {0}:針孔校正不完整。"), + KO("{0} 건너뜀: 핀홀 보정 정보가 불완전합니다."), + DE("{0} übersprungen: Die Lochkamera-Kalibrierung ist unvollständig."), + FR("{0} ignorée : son étalonnage sténopé est incomplet."), + ES("{0} omitida: su calibración estenopeica está incompleta."), + PT("{0} ignorada: a calibração pinhole está incompleta."), + IT("{0} saltata: la calibrazione stenopeica è incompleta."), + NL("{0} overgeslagen: de pinhole-kalibratie is onvolledig."), + RU("{0} пропущено: калибровка обскуры неполная."), + TR("{0} atlandı: iğne deliği kalibrasyonu eksik.")); + +SS_MSG(skip_partial_panorama, + EN("Skipped {0}: the panorama covers {1} x {2} degrees, not the whole " + "sphere."), + JA("{0} を飛ばしました: パノラマの範囲が {1} x {2} 度で、全球ではありません。"), + ZH_HANS("已跳过 {0}:全景图只覆盖 {1} x {2} 度,不是整个球面。"), + ZH_HANT("已略過 {0}:全景影像只涵蓋 {1} x {2} 度,不是整個球面。"), + KO("{0} 건너뜀: 파노라마가 {1} x {2}도만 덮고 있어 구 전체가 아닙니다."), + DE("{0} übersprungen: Das Panorama deckt {1} x {2} Grad ab, nicht die " + "ganze Kugel."), + FR("{0} ignorée : le panorama couvre {1} x {2} degrés, pas la sphère " + "entière."), + ES("{0} omitida: la panorámica cubre {1} x {2} grados, no la esfera " + "entera."), + PT("{0} ignorada: o panorama cobre {1} x {2} graus, não a esfera inteira."), + IT("{0} saltata: il panorama copre {1} x {2} gradi, non l'intera sfera."), + NL("{0} overgeslagen: het panorama beslaat {1} x {2} graden, niet de hele " + "bol."), + RU("{0} пропущено: панорама охватывает {1} x {2} градусов, а не всю сферу."), + TR("{0} atlandı: panorama tüm küreyi değil, {1} x {2} dereceyi kapsıyor.")); + +SS_MSG(err_no_images, + EN("The scan has no image a dataset can use. Splats are trained on " + "photographs; a point cloud alone is not enough."), + JA("スキャンにデータセットで使える画像がありません。スプラットは写真から" + "学習するので、点群だけでは足りません。"), + ZH_HANS("扫描中没有数据集可用的图像。高斯点是用照片训练的,只有点云不够。"), + ZH_HANT("掃描中沒有資料集可用的影像。高斯點是用照片訓練的,只有點雲不夠。"), + KO("스캔에 데이터셋이 쓸 수 있는 이미지가 없습니다. 스플랫은 사진으로 " + "학습하므로 포인트 클라우드만으로는 부족합니다."), + DE("Der Scan enthält kein Bild, das ein Datensatz nutzen kann. Splats " + "werden an Fotos trainiert; eine Punktwolke allein genügt nicht."), + FR("Le scan ne contient aucune image utilisable par un jeu de données. Les " + "splats s'entraînent sur des photos ; un nuage de points seul ne suffit " + "pas."), + ES("El escaneo no tiene ninguna imagen que un conjunto de datos pueda usar. " + "Los splats se entrenan con fotografías; una nube de puntos sola no " + "basta."), + PT("A varredura não tem nenhuma imagem que um conjunto de dados possa usar. " + "Os splats são treinados com fotografias; uma nuvem de pontos sozinha não " + "basta."), + IT("La scansione non ha immagini utilizzabili da un set di dati. Gli splat " + "si addestrano su fotografie; una nuvola di punti da sola non basta."), + NL("De scan bevat geen beeld dat een dataset kan gebruiken. Splats worden " + "getraind op foto's; een puntenwolk alleen is niet genoeg."), + RU("В скане нет изображений, пригодных для набора данных. Сплаты обучаются " + "на фотографиях, одного облака точек недостаточно."), + TR("Taramada bir veri kümesinin kullanabileceği görüntü yok. Splat'ler " + "fotoğraflarla eğitilir; yalnızca nokta bulutu yetmez.")); + +SS_MSG(err_not_empty, + EN("{0} is not empty. --overwrite writes into it anyway."), + JA("{0} は空ではありません。--overwrite を付けると書き込みます。"), + ZH_HANS("{0} 不是空的。加上 --overwrite 仍会写入。"), + ZH_HANT("{0} 不是空的。加上 --overwrite 仍會寫入。"), + KO("{0}이(가) 비어 있지 않습니다. --overwrite를 주면 그래도 씁니다."), + DE("{0} ist nicht leer. Mit --overwrite wird trotzdem hineingeschrieben."), + FR("{0} n'est pas vide. --overwrite y écrit quand même."), + ES("{0} no está vacía. --overwrite escribe en ella de todos modos."), + PT("{0} não está vazia. --overwrite grava nela mesmo assim."), + IT("{0} non è vuota. --overwrite ci scrive comunque."), + NL("{0} is niet leeg. --overwrite schrijft er toch in."), + RU("{0} не пуста. С --overwrite запись всё равно выполняется."), + TR("{0} boş değil. --overwrite yine de içine yazar.")); + +SS_MSG(done, + EN("Dataset written: {0} Images: {1} Seed points: {2}"), + JA("データセットを書き出しました: {0} 画像: {1} 初期点: {2}"), + ZH_HANS("数据集已写入:{0} 图像:{1} 初始点:{2}"), + ZH_HANT("資料集已寫入:{0} 影像:{1} 初始點:{2}"), + KO("데이터셋을 썼습니다: {0} 이미지: {1} 초기 점: {2}"), + DE("Datensatz geschrieben: {0} Bilder: {1} Startpunkte: {2}"), + FR("Jeu de données écrit : {0} Images : {1} Points de départ : {2}"), + ES("Conjunto de datos escrito: {0} Imágenes: {1} Puntos iniciales: {2}"), + PT("Conjunto de dados gravado: {0} Imagens: {1} Pontos iniciais: {2}"), + IT("Set di dati scritto: {0} Immagini: {1} Punti iniziali: {2}"), + NL("Dataset geschreven: {0} Beelden: {1} Beginpunten: {2}"), + RU("Набор данных записан: {0} Изображений: {1} Начальных точек: {2}"), + TR("Veri kümesi yazıldı: {0} Görüntü: {1} Başlangıç noktası: {2}")); + +SS_MSG(next_step, + EN("Train on it with: {0}"), + JA("学習するには: {0}"), + ZH_HANS("用它训练:{0}"), + ZH_HANT("用它訓練:{0}"), + KO("학습하려면: {0}"), + DE("Training darauf mit: {0}"), + FR("Pour l'entraîner : {0}"), + ES("Para entrenar con él: {0}"), + PT("Para treinar com ele: {0}"), + IT("Per addestrarlo: {0}"), + NL("Trainen met: {0}"), + RU("Обучение на нём: {0}"), + TR("Bununla eğitmek için: {0}")); + +// =========================================================================== +// The GUI screen +// =========================================================================== + +SS_MSG(title, + EN("Create Dataset from E57"), + JA("E57 からデータセットを作成"), + ZH_HANS("从 E57 创建数据集"), + ZH_HANT("從 E57 建立資料集"), + KO("E57로 데이터셋 만들기"), + DE("Datensatz aus E57 erstellen"), + FR("Créer un jeu de données à partir d'un E57"), + ES("Crear un conjunto de datos a partir de un E57"), + PT("Criar um conjunto de dados a partir de um E57"), + IT("Crea un set di dati da un E57"), + NL("Dataset maken uit E57"), + RU("Создание набора данных из E57"), + TR("E57'den veri kümesi oluştur")); + +SS_MSG(intro, + EN("A laser scanner that also takes photos has already placed every one of " + "them. This writes them out as a dataset, with their poses and a thinned " + "copy of the scan's point cloud to start the splats from, so training " + "needs no reconstruction."), + JA("写真も撮るレーザースキャナーは、すべての写真の位置をすでに求めています。" + "ここでは写真をその姿勢と、スプラットの出発点になる間引いた点群と一緒に" + "データセットとして書き出すので、再構成なしで学習できます。"), + ZH_HANS("同时拍照的激光扫描仪已经确定了每张照片的位置。这里把照片连同位姿和" + "精简后的点云(作为高斯点的起点)写成数据集,训练无需重建。"), + ZH_HANT("同時拍照的雷射掃描儀已經確定了每張照片的位置。這裡把照片連同位姿和" + "精簡後的點雲(作為高斯點的起點)寫成資料集,訓練無需重建。"), + KO("사진도 찍는 레이저 스캐너는 이미 모든 사진의 위치를 알고 있습니다. 여기서는 " + "사진을 자세, 그리고 스플랫의 출발점이 될 솎아 낸 포인트 클라우드와 함께 " + "데이터셋으로 써서, 재구성 없이 학습할 수 있게 합니다."), + DE("Ein Laserscanner, der auch fotografiert, hat jedes Foto bereits " + "platziert. Hier werden sie mit ihren Posen und einer ausgedünnten Kopie " + "der Punktwolke als Startpunkte für die Splats in einen Datensatz " + "geschrieben, sodass das Training keine Rekonstruktion braucht."), + FR("Un scanner laser qui prend aussi des photos les a déjà toutes placées. " + "Elles sont écrites ici en jeu de données, avec leurs poses et une copie " + "allégée du nuage de points pour démarrer les splats : l'entraînement se " + "passe de reconstruction."), + ES("Un escáner láser que también toma fotos ya ha situado cada una de " + "ellas. Aquí se escriben como un conjunto de datos, con sus poses y una " + "copia aligerada de la nube de puntos para iniciar los splats, así que el " + "entrenamiento no necesita reconstrucción."), + PT("Um scanner a laser que também tira fotos já posicionou cada uma delas. " + "Aqui elas são gravadas como um conjunto de dados, com suas poses e uma " + "cópia reduzida da nuvem de pontos para iniciar os splats, de modo que o " + "treino dispensa reconstrução."), + IT("Uno scanner laser che scatta anche foto le ha già posizionate tutte. " + "Qui vengono scritte come set di dati, con le loro pose e una copia " + "sfoltita della nuvola di punti da cui far partire gli splat, così " + "l'addestramento non richiede ricostruzione."), + NL("Een laserscanner die ook foto's maakt, heeft elke foto al geplaatst. " + "Hier worden ze als dataset geschreven, met hun poses en een uitgedunde " + "kopie van de puntenwolk om de splats mee te beginnen, zodat de training " + "geen reconstructie nodig heeft."), + RU("Лазерный сканер, который ещё и фотографирует, уже знает, где снят каждый " + "кадр. Здесь снимки записываются как набор данных вместе с позами и " + "прореженной копией облака точек, с которой начинаются сплаты, поэтому " + "обучению не нужна реконструкция."), + TR("Fotoğraf da çeken bir lazer tarayıcı her fotoğrafın yerini zaten " + "belirlemiştir. Burada fotoğraflar pozlarıyla ve splat'lerin başlangıcı " + "olacak seyreltilmiş nokta bulutuyla birlikte bir veri kümesine yazılır; " + "eğitim yeniden oluşturma gerektirmez.")); + +SS_MSG(source, + EN("E57 file"), JA("E57 ファイル"), ZH_HANS("E57 文件"), ZH_HANT("E57 檔案"), + KO("E57 파일"), DE("E57-Datei"), FR("Fichier E57"), ES("Archivo E57"), + PT("Arquivo E57"), IT("File E57"), NL("E57-bestand"), RU("Файл E57"), + TR("E57 dosyası")); + +SS_MSG(pick_source, + EN("Choose an E57 File"), JA("E57 ファイルを選択"), ZH_HANS("选择 E57 文件"), + ZH_HANT("選擇 E57 檔案"), KO("E57 파일 선택"), DE("E57-Datei wählen"), + FR("Choisir un fichier E57"), ES("Elegir un archivo E57"), + PT("Escolher um arquivo E57"), IT("Scegli un file E57"), + NL("Kies een E57-bestand"), RU("Выберите файл E57"), TR("Bir E57 dosyası seç")); + +SS_MSG(output, + EN("Dataset folder"), JA("データセットのフォルダ"), ZH_HANS("数据集文件夹"), + ZH_HANT("資料集資料夾"), KO("데이터셋 폴더"), DE("Datensatzordner"), + FR("Dossier du jeu de données"), ES("Carpeta del conjunto de datos"), + PT("Pasta do conjunto de dados"), IT("Cartella del set di dati"), + NL("Datasetmap"), RU("Папка набора данных"), TR("Veri kümesi klasörü")); + +SS_MSG(pick_output, + EN("Choose the Dataset Folder"), JA("データセットのフォルダを選択"), + ZH_HANS("选择数据集文件夹"), ZH_HANT("選擇資料集資料夾"), + KO("데이터셋 폴더 선택"), DE("Datensatzordner wählen"), + FR("Choisir le dossier du jeu de données"), + ES("Elegir la carpeta del conjunto de datos"), + PT("Escolher a pasta do conjunto de dados"), + IT("Scegli la cartella del set di dati"), NL("Kies de datasetmap"), + RU("Выберите папку набора данных"), TR("Veri kümesi klasörünü seç")); + +SS_MSG(output_not_empty, + EN("This folder is not empty. The dataset's own files in it will be " + "replaced; nothing else is removed."), + JA("このフォルダは空ではありません。中のデータセットのファイルは置き換わりますが、" + "それ以外は削除しません。"), + ZH_HANS("此文件夹不是空的。其中数据集自己的文件会被替换,其他内容不会删除。"), + ZH_HANT("此資料夾不是空的。其中資料集自己的檔案會被取代,其他內容不會刪除。"), + KO("이 폴더는 비어 있지 않습니다. 안의 데이터셋 파일은 바뀌지만 다른 것은 " + "지우지 않습니다."), + DE("Dieser Ordner ist nicht leer. Die Dateien des Datensatzes darin werden " + "ersetzt, sonst wird nichts entfernt."), + FR("Ce dossier n'est pas vide. Les fichiers du jeu de données qu'il contient " + "seront remplacés ; rien d'autre n'est supprimé."), + ES("Esta carpeta no está vacía. Los archivos del conjunto que contiene se " + "reemplazarán; no se borra nada más."), + PT("Esta pasta não está vazia. Os arquivos do conjunto nela serão " + "substituídos; nada mais é removido."), + IT("Questa cartella non è vuota. I file del set di dati al suo interno " + "verranno sostituiti; nient'altro viene rimosso."), + NL("Deze map is niet leeg. De bestanden van de dataset erin worden " + "vervangen; verder wordt niets verwijderd."), + RU("Эта папка не пуста. Файлы набора данных в ней будут заменены, остальное " + "не удаляется."), + TR("Bu klasör boş değil. İçindeki veri kümesi dosyaları değiştirilecek; " + "başka hiçbir şey silinmez.")); + +SS_MSG(read_failed, + EN("This file could not be read: {0}"), + JA("このファイルを読み込めませんでした: {0}"), + ZH_HANS("无法读取此文件:{0}"), + ZH_HANT("無法讀取此檔案:{0}"), + KO("이 파일을 읽을 수 없습니다: {0}"), + DE("Diese Datei konnte nicht gelesen werden: {0}"), + FR("Impossible de lire ce fichier : {0}"), + ES("No se pudo leer este archivo: {0}"), + PT("Não foi possível ler este arquivo: {0}"), + IT("Impossibile leggere questo file: {0}"), + NL("Dit bestand kon niet worden gelezen: {0}"), + RU("Не удалось прочитать файл: {0}"), + TR("Bu dosya okunamadı: {0}")); + +SS_MSG(points, + EN("Seed points"), JA("初期点"), ZH_HANS("初始点"), ZH_HANT("初始點"), + KO("초기 점"), DE("Startpunkte"), FR("Points de départ"), + ES("Puntos iniciales"), PT("Pontos iniciais"), IT("Punti iniziali"), + NL("Beginpunten"), RU("Начальные точки"), TR("Başlangıç noktaları")); + +SS_MSG(points_help, + EN("The scan's point cloud is thinned to about this many points, one per " + "voxel, so the splats start evenly over everything the scanner saw " + "rather than crowded around each station. The trainer's own maximum " + "still applies. 0 writes no cloud."), + JA("スキャンの点群をボクセルごとに 1 点へ間引いて、およそこの数にします。" + "スプラットは各設置点のまわりに偏らず、スキャナーが見た範囲全体から均等に" + "始まります。トレーナー側の上限はそのまま効きます。0 なら点群を書き出し" + "ません。"), + ZH_HANS("把扫描点云按体素精简到约这么多个点(每个体素一个),让高斯点均匀地" + "从扫描仪看到的所有地方开始,而不是挤在每个测站周围。训练器自身的上限" + "仍然有效。0 表示不写点云。"), + ZH_HANT("把掃描點雲按體素精簡到約這麼多個點(每個體素一個),讓高斯點均勻地" + "從掃描儀看到的所有地方開始,而不是擠在每個測站周圍。訓練器自身的上限" + "仍然有效。0 表示不寫點雲。"), + KO("스캔 포인트 클라우드를 복셀마다 한 점씩 솎아 대략 이만큼으로 만듭니다. " + "그래서 스플랫이 각 설치 지점 주변에 몰리지 않고 스캐너가 본 모든 곳에서 " + "고르게 시작합니다. 트레이너 자체의 최대값은 그대로 적용됩니다. 0이면 " + "클라우드를 쓰지 않습니다."), + DE("Die Punktwolke des Scans wird auf etwa so viele Punkte ausgedünnt, " + "einer je Voxel, damit die Splats gleichmäßig überall beginnen, wo der " + "Scanner hingesehen hat, statt sich um jeden Standpunkt zu drängen. Das " + "Maximum des Trainers gilt weiterhin. 0 schreibt keine Wolke."), + FR("Le nuage de points du scan est allégé à environ ce nombre de points, un " + "par voxel, pour que les splats partent uniformément de tout ce que le " + "scanner a vu plutôt que de s'entasser autour de chaque station. Le " + "maximum de l'entraînement reste appliqué. 0 n'écrit aucun nuage."), + ES("La nube de puntos del escaneo se aligera hasta unos tantos puntos, uno " + "por vóxel, para que los splats empiecen de forma uniforme en todo lo que " + "vio el escáner en vez de amontonarse alrededor de cada estación. El " + "máximo del entrenador sigue vigente. 0 no escribe ninguna nube."), + PT("A nuvem de pontos da varredura é reduzida a cerca de tantos pontos, um " + "por voxel, para que os splats comecem de forma uniforme em tudo o que o " + "scanner viu, em vez de se amontoarem em volta de cada estação. O máximo " + "do treinador continua valendo. 0 não grava nuvem."), + IT("La nuvola di punti della scansione viene sfoltita a circa questo numero " + "di punti, uno per voxel, così gli splat partono in modo uniforme da " + "tutto ciò che lo scanner ha visto invece di ammassarsi attorno a ogni " + "stazione. Il massimo dell'addestratore resta valido. 0 non scrive " + "alcuna nuvola."), + NL("De puntenwolk van de scan wordt uitgedund tot ongeveer zoveel punten, " + "één per voxel, zodat de splats gelijkmatig beginnen overal waar de " + "scanner keek in plaats van rond elke opstelling samen te klonteren. Het " + "maximum van de trainer blijft gelden. 0 schrijft geen wolk."), + RU("Облако точек скана прореживается примерно до стольких точек, по одной на " + "воксель, чтобы сплаты начинались равномерно везде, куда смотрел сканер, " + "а не скапливались вокруг каждой станции. Собственный максимум тренажёра " + "по-прежнему действует. 0 -- облако не записывается."), + TR("Taramanın nokta bulutu voksel başına bir nokta olacak şekilde yaklaşık " + "bu kadar noktaya seyreltilir; böylece splat'ler her istasyonun çevresinde " + "yığılmak yerine tarayıcının gördüğü her yerden eşit başlar. Eğiticinin " + "kendi üst sınırı yine geçerlidir. 0 bulut yazmaz.")); + +SS_MSG(use_pinhole, + EN("Pinhole images"), JA("ピンホール画像"), ZH_HANS("针孔图像"), + ZH_HANT("針孔影像"), KO("핀홀 이미지"), DE("Lochkamerabilder"), + FR("Images sténopé"), ES("Imágenes estenopeicas"), PT("Imagens pinhole"), + IT("Immagini stenopeiche"), NL("Pinhole-beelden"), + RU("Изображения обскуры"), TR("İğne deliği görüntüleri")); + +SS_MSG(use_pinhole_help, + EN("Photos from the scanner's camera, often six per station that together " + "cover the sphere."), + JA("スキャナーのカメラで撮った写真です。設置点ごとに 6 枚で全球を覆うことが" + "よくあります。"), + ZH_HANS("扫描仪相机拍的照片,常常每个测站六张,合起来覆盖整个球面。"), + ZH_HANT("掃描儀相機拍的照片,常常每個測站六張,合起來涵蓋整個球面。"), + KO("스캐너 카메라로 찍은 사진으로, 설치 지점마다 여섯 장이 모여 구 전체를 덮는 " + "경우가 많습니다."), + DE("Fotos der Scannerkamera, oft sechs je Standpunkt, die zusammen die " + "ganze Kugel abdecken."), + FR("Photos de la caméra du scanner, souvent six par station qui couvrent " + "ensemble toute la sphère."), + ES("Fotos de la cámara del escáner, a menudo seis por estación que juntas " + "cubren toda la esfera."), + PT("Fotos da câmera do scanner, muitas vezes seis por estação que juntas " + "cobrem a esfera inteira."), + IT("Foto della fotocamera dello scanner, spesso sei per stazione che " + "insieme coprono l'intera sfera."), + NL("Foto's van de scannercamera, vaak zes per opstelling die samen de hele " + "bol bedekken."), + RU("Снимки камеры сканера, часто по шесть на станцию, вместе покрывающие " + "всю сферу."), + TR("Tarayıcının kamerasıyla çekilen fotoğraflar; çoğu zaman istasyon başına " + "altı tane, birlikte tüm küreyi kaplar.")); + +SS_MSG(use_panoramas, + EN("Panoramas"), JA("パノラマ"), ZH_HANS("全景图"), ZH_HANT("全景影像"), + KO("파노라마"), DE("Panoramen"), FR("Panoramas"), ES("Panorámicas"), + PT("Panoramas"), IT("Panorami"), NL("Panorama's"), RU("Панорамы"), + TR("Panoramalar")); + +SS_MSG(use_panoramas_help, + EN("Spherical 360-degree images. Training splits each into six cube faces " + "unless that option is turned off."), + JA("360 度の球面画像です。学習ではその設定を切らない限り、各画像を 6 つの" + "キューブ面に分割します。"), + ZH_HANS("360 度球面图像。除非关闭该选项,训练时会把每张拆成六个立方体面。"), + ZH_HANT("360 度球面影像。除非關閉該選項,訓練時會把每張拆成六個立方體面。"), + KO("360도 구면 이미지입니다. 그 옵션을 끄지 않는 한, 학습은 각각을 여섯 개의 " + "큐브 면으로 나눕니다."), + DE("Sphärische 360-Grad-Bilder. Das Training teilt jedes in sechs " + "Würfelflächen, sofern diese Option nicht abgeschaltet ist."), + FR("Images sphériques à 360 degrés. L'entraînement découpe chacune en six " + "faces de cube, sauf si cette option est désactivée."), + ES("Imágenes esféricas de 360 grados. El entrenamiento divide cada una en " + "seis caras de cubo, salvo que esa opción esté desactivada."), + PT("Imagens esféricas de 360 graus. O treino divide cada uma em seis faces " + "de cubo, a menos que essa opção esteja desligada."), + IT("Immagini sferiche a 360 gradi. L'addestramento divide ciascuna in sei " + "facce di cubo, a meno che l'opzione sia disattivata."), + NL("Bolvormige 360-gradenbeelden. De training splitst elk in zes " + "kubusvlakken, tenzij die optie uit staat."), + RU("Сферические изображения на 360 градусов. Обучение делит каждое на шесть " + "граней куба, если эта настройка не выключена."), + TR("360 derecelik küresel görüntüler. Bu seçenek kapatılmadıkça eğitim her " + "birini altı küp yüzüne böler.")); + +SS_MSG(use_all_points, + EN("Use every point"), JA("すべての点を使う"), ZH_HANS("使用全部点"), + ZH_HANT("使用全部點"), KO("모든 점 사용"), DE("Alle Punkte verwenden"), + FR("Utiliser tous les points"), ES("Usar todos los puntos"), + PT("Usar todos os pontos"), IT("Usa tutti i punti"), + NL("Alle punten gebruiken"), RU("Использовать все точки"), + TR("Tüm noktaları kullan")); + +SS_MSG(use_all_points_help, + EN("Seed with the scan's whole point cloud instead of a thinned copy. A " + "large scan makes a large file, and the trainer still keeps at most its " + "Maximum splats of them, drawn at random, so raise that too."), + JA("間引いたコピーではなく、スキャンの点群全体を初期点にします。大きなスキャン" + "ではファイルも大きくなります。トレーナーはスプラット数の上限までしか残さず、" + "残りを無作為に捨てるので、その値も上げてください。"), + ZH_HANS("用扫描的完整点云而不是精简副本作为初始点。大扫描会生成大文件,而且训练" + "器最多只随机保留“泼溅数量上限”个点,所以也请调高该值。"), + ZH_HANT("用掃描的完整點雲而不是精簡副本作為初始點。大掃描會產生大檔案,而且訓練" + "器最多只隨機保留「潑濺數量上限」個點,所以也請調高該值。"), + KO("솎아 낸 사본 대신 스캔의 전체 포인트 클라우드를 초기 점으로 씁니다. 큰 " + "스캔은 큰 파일이 되고, 트레이너는 최대 스플랫 수까지만 무작위로 남기므로 " + "그 값도 올리세요."), + DE("Mit der ganzen Punktwolke des Scans statt einer ausgedünnten Kopie " + "beginnen. Ein großer Scan ergibt eine große Datei, und der Trainer behält " + "davon höchstens seine Höchstzahl der Splats, zufällig gezogen – diese " + "also ebenfalls erhöhen."), + FR("Partir du nuage de points complet du scan plutôt que d'une copie " + "allégée. Un grand scan donne un grand fichier, et l'entraînement n'en " + "garde au plus que son nombre maximal de splats, tirés au hasard : " + "augmentez-le aussi."), + ES("Empezar con la nube de puntos completa del escaneo en vez de una copia " + "aligerada. Un escaneo grande produce un archivo grande, y el entrenador " + "solo conserva como mucho su número máximo de splats, elegidos al azar, " + "así que súbalo también."), + PT("Começar com a nuvem de pontos completa da varredura em vez de uma cópia " + "reduzida. Uma varredura grande gera um arquivo grande, e o treinador " + "mantém no máximo o seu número máximo de splats, sorteados, então " + "aumente-o também."), + IT("Partire dall'intera nuvola di punti della scansione invece che da una " + "copia sfoltita. Una scansione grande produce un file grande, e " + "l'addestratore ne tiene al massimo il suo numero massimo di splat, " + "estratti a caso: aumentalo anche."), + NL("Beginnen met de volledige puntenwolk van de scan in plaats van een " + "uitgedunde kopie. Een grote scan geeft een groot bestand, en de trainer " + "houdt er hoogstens zijn maximum aantal splats van over, willekeurig " + "gekozen, dus verhoog dat ook."), + RU("Начинать с полного облака точек скана, а не с прореженной копии. " + "Большой скан даёт большой файл, а тренажёр оставит из него не больше " + "своего максимума сплатов, выбранных случайно, так что увеличьте и его."), + TR("İnceltilmiş bir kopya yerine taramanın tüm nokta bulutuyla başla. Büyük " + "bir tarama büyük bir dosya üretir ve eğitici bunlardan en fazla splat " + "sayısı kadarını rastgele tutar; onu da artırın.")); + +SS_MSG(depth_maps, + EN("Depth and normal maps from the scan"), + JA("スキャンから深度マップと法線マップ"), + ZH_HANS("由扫描生成深度图和法线图"), + ZH_HANT("由掃描產生深度圖和法線圖"), + KO("스캔으로 깊이 맵과 법선 맵 만들기"), + DE("Tiefen- und Normalenkarten aus dem Scan"), + FR("Cartes de profondeur et de normales tirées du scan"), + ES("Mapas de profundidad y normales del escaneo"), + PT("Mapas de profundidade e normais da varredura"), + IT("Mappe di profondità e normali dalla scansione"), + NL("Diepte- en normaalkaarten uit de scan"), + RU("Карты глубины и нормалей из скана"), + TR("Taramadan derinlik ve normal haritaları")); + +SS_MSG(depth_maps_help, + EN("Renders what the laser measured behind each pixel into depths/ and " + "normals/, where the trainer's depth and normal supervision read it. " + "Pixels the scan did not reach, the sky among them, are left without."), + JA("レーザーが各画素の奥で測った距離を depths/ と normals/ に書き出します。" + "トレーナーの深度・法線の教師信号はここから読みます。スキャンが届かなかった" + "画素(空など)には何も入れません。"), + ZH_HANS("把激光在每个像素后方测得的结果渲染到 depths/ 和 normals/,训练器的" + "深度与法线监督从这里读取。扫描未覆盖的像素(包括天空)留空。"), + ZH_HANT("把雷射在每個像素後方測得的結果渲染到 depths/ 和 normals/,訓練器的" + "深度與法線監督從這裡讀取。掃描未涵蓋的像素(包括天空)留空。"), + KO("레이저가 각 픽셀 뒤에서 잰 값을 depths/와 normals/에 그려 넣습니다. " + "트레이너의 깊이·법선 지도 학습이 여기서 읽습니다. 스캔이 닿지 않은 " + "픽셀(하늘 등)은 비워 둡니다."), + DE("Rendert, was der Laser hinter jedem Pixel gemessen hat, nach depths/ " + "und normals/, wo die Tiefen- und Normalenüberwachung des Trainers liest. " + "Pixel, die der Scan nicht erreicht hat, darunter der Himmel, bleiben leer."), + FR("Rend ce que le laser a mesuré derrière chaque pixel dans depths/ et " + "normals/, où la supervision de profondeur et de normales de " + "l'entraînement le lit. Les pixels que le scan n'a pas atteints, dont le " + "ciel, restent vides."), + ES("Renderiza lo que el láser midió detrás de cada píxel en depths/ y " + "normals/, de donde leen la supervisión de profundidad y de normales del " + "entrenador. Los píxeles que el escaneo no alcanzó, entre ellos el cielo, " + "quedan vacíos."), + PT("Renderiza o que o laser mediu atrás de cada pixel em depths/ e normals/, " + "de onde a supervisão de profundidade e de normais do treinador lê. Os " + "pixels que a varredura não alcançou, entre eles o céu, ficam vazios."), + IT("Rende ciò che il laser ha misurato dietro ogni pixel in depths/ e " + "normals/, da cui leggono la supervisione di profondità e normali " + "dell'addestratore. I pixel che la scansione non ha raggiunto, cielo " + "compreso, restano vuoti."), + NL("Rendert wat de laser achter elke pixel mat naar depths/ en normals/, " + "waar de diepte- en normaalsupervisie van de trainer het leest. Pixels " + "die de scan niet bereikte, waaronder de lucht, blijven leeg."), + RU("Записывает в depths/ и normals/ то, что лазер измерил за каждым " + "пикселем; оттуда их читает контроль глубины и нормалей тренажёра. " + "Пиксели, до которых скан не дошёл, в том числе небо, остаются пустыми."), + TR("Lazerin her pikselin ardında ölçtüğünü depths/ ve normals/ içine işler; " + "eğiticinin derinlik ve normal denetimi buradan okur. Taramanın " + "ulaşmadığı pikseller, gökyüzü dahil, boş bırakılır.")); + +SS_MSG(preview_frames, + EN("Extracting the photos to try the mask on..."), + JA("マスクを試すために写真を取り出しています..."), + ZH_HANS("正在提取照片以试用遮罩..."), + ZH_HANT("正在擷取相片以試用遮罩..."), + KO("마스크를 시험할 사진을 꺼내는 중..."), + DE("Fotos zum Ausprobieren der Maske werden entpackt ..."), + FR("Extraction des photos pour essayer le masque..."), + ES("Extrayendo las fotos para probar la máscara..."), + PT("Extraindo as fotos para testar a máscara..."), + IT("Estrazione delle foto per provare la maschera..."), + NL("Foto's uitpakken om het masker te proberen..."), + RU("Извлечение фотографий для пробы маски..."), + TR("Maskeyi denemek için fotoğraflar çıkarılıyor...")); + +SS_MSG(preview_loading, + EN("Reading the scan for the preview..."), + JA("プレビュー用にスキャンを読み込んでいます..."), + ZH_HANS("正在读取扫描以生成预览..."), + ZH_HANT("正在讀取掃描以產生預覽..."), + KO("미리보기를 위해 스캔을 읽는 중..."), + DE("Scan wird für die Vorschau gelesen ..."), + FR("Lecture du scan pour l'aperçu..."), + ES("Leyendo el escaneo para la vista previa..."), + PT("Lendo a varredura para a pré-visualização..."), + IT("Lettura della scansione per l'anteprima..."), + NL("Scan lezen voor de voorvertoning..."), + RU("Чтение скана для предпросмотра..."), + TR("Önizleme için tarama okunuyor...")); + +// The step of the run that reads the scans' points and checks the photos +// against them, in the dataset screen's row of steps. +SS_MSG(step_scans, + EN("Scans"), JA("スキャン"), ZH_HANS("扫描"), ZH_HANT("掃描"), KO("스캔"), + DE("Scans"), FR("Scans"), ES("Escaneos"), PT("Varreduras"), + IT("Scansioni"), NL("Scans"), RU("Сканы"), TR("Taramalar")); + +SS_MSG(finished, + EN("Dataset created: {0}"), JA("データセットを作成しました: {0}"), + ZH_HANS("数据集已创建:{0}"), ZH_HANT("資料集已建立:{0}"), + KO("데이터셋을 만들었습니다: {0}"), DE("Datensatz erstellt: {0}"), + FR("Jeu de données créé : {0}"), ES("Conjunto de datos creado: {0}"), + PT("Conjunto de dados criado: {0}"), IT("Set di dati creato: {0}"), + NL("Dataset gemaakt: {0}"), RU("Набор данных создан: {0}"), + TR("Veri kümesi oluşturuldu: {0}")); + +SS_MSG(failed, + EN("Creating the dataset failed."), JA("データセットを作成できませんでした。"), + ZH_HANS("创建数据集失败。"), ZH_HANT("建立資料集失敗。"), + KO("데이터셋을 만들지 못했습니다."), DE("Der Datensatz konnte nicht erstellt werden."), + FR("La création du jeu de données a échoué."), + ES("No se pudo crear el conjunto de datos."), + PT("Não foi possível criar o conjunto de dados."), + IT("Creazione del set di dati non riuscita."), + NL("De dataset kon niet worden gemaakt."), + RU("Не удалось создать набор данных."), TR("Veri kümesi oluşturulamadı.")); + +SS_MSG(not_masked, + EN("The dataset was written to {0}, but not masked. It trains as it is, " + "without masks."), + JA("データセットは {0} に書き出しましたが、マスクはかけていません。このままマスク" + "なしで学習できます。"), + ZH_HANS("数据集已写入 {0},但未加遮罩。它可以按原样在没有遮罩的情况下训练。"), + ZH_HANT("資料集已寫入 {0},但未加遮罩。它可以照原樣在沒有遮罩的情況下訓練。"), + KO("데이터셋은 {0}에 썼지만 마스킹하지 않았습니다. 마스크 없이 그대로 학습할 수 " + "있습니다."), + DE("Der Datensatz wurde nach {0} geschrieben, aber nicht maskiert. Er lässt " + "sich so, ohne Masken, trainieren."), + FR("Le jeu de données a été écrit dans {0}, mais pas masqué. Il s'entraîne " + "tel quel, sans masques."), + ES("El conjunto de datos se escribió en {0}, pero sin enmascarar. Se puede " + "entrenar tal cual, sin máscaras."), + PT("O conjunto de dados foi gravado em {0}, mas não foi mascarado. Ele pode " + "ser treinado como está, sem máscaras."), + IT("Il set di dati è stato scritto in {0}, ma non mascherato. Si può " + "addestrare così com'è, senza maschere."), + NL("De dataset is naar {0} geschreven, maar niet gemaskeerd. Hij traint " + "zoals hij is, zonder maskers."), + RU("Набор данных записан в {0}, но без масок. Его можно обучать как есть."), + TR("Veri kümesi {0} konumuna yazıldı ancak maskelenmedi. Olduğu gibi, " + "maskesiz eğitilebilir.")); + +SS_MSG(no_source, + EN("choose an E57 file first"), JA("先に E57 ファイルを選んでください"), + ZH_HANS("请先选择 E57 文件"), ZH_HANT("請先選擇 E57 檔案"), + KO("먼저 E57 파일을 고르세요"), DE("zuerst eine E57-Datei wählen"), + FR("choisissez d'abord un fichier E57"), ES("elija primero un archivo E57"), + PT("escolha primeiro um arquivo E57"), IT("scegli prima un file E57"), + NL("kies eerst een E57-bestand"), RU("сначала выберите файл E57"), + TR("önce bir E57 dosyası seçin")); + +} // namespace e57 +} // namespace msg +} // namespace i18n +} // namespace spirula + +#include "i18n/EndCatalog.h" diff --git a/src/i18n/catalog/Lidar.h b/src/i18n/catalog/Lidar.h new file mode 100644 index 000000000..0e29091df --- /dev/null +++ b/src/i18n/catalog/Lidar.h @@ -0,0 +1,1649 @@ +#pragma once + +// What `spirula lidar` says -- its --help and every line a run prints, which +// the dataset screen's Align step reads back through i18n::scan(). + +#include "i18n/BeginCatalog.h" + +namespace spirula { +namespace i18n { +namespace msg { +namespace lidar { + +SS_MSG(tagline, + EN("Align a reconstruction with a laser scan, and add the scan's geometry"), + JA("再構成をレーザースキャンに位置合わせし、スキャンの形状を加えます"), + ZH_HANS("把重建与激光扫描对齐,并加入扫描的几何信息"), + ZH_HANT("把重建與雷射掃描對齊,並加入掃描的幾何資訊"), + KO("재구성을 레이저 스캔에 정렬하고 스캔의 형상을 더합니다"), + DE("Eine Rekonstruktion an einem Laserscan ausrichten und die Geometrie des " + "Scans hinzufügen"), + FR("Aligner une reconstruction sur un scan laser et y ajouter la géométrie du " + "scan"), + ES("Alinear una reconstrucción con un escaneo láser y añadir la geometría del " + "escaneo"), + PT("Alinhar uma reconstrução a uma varredura a laser e acrescentar a geometria " + "da varredura"), + IT("Allineare una ricostruzione a una scansione laser e aggiungere la geometria " + "della scansione"), + NL("Een reconstructie uitlijnen op een laserscan en de geometrie van de scan " + "toevoegen"), + RU("Совместить реконструкцию с лазерным сканом и добавить геометрию скана"), + TR("Bir yeniden oluşturmayı lazer taramasıyla hizala ve taramanın geometrisini " + "ekle")); + +SS_MSG(usage_about, + EN("Moves the reconstruction in the dataset folder into the scan's frame -- rotation, " + "translation and scale, fitted to the scan's images where they were part of the " + "reconstruction and then to its surface -- and writes it back as one COLMAP model " + "whose seed points are the scan's own, each listing the images that see it. Depth " + "and normal maps are rendered from the scan for every image. The reconstruction it " + "started from is kept in sparse_unaligned/."), + JA("データセットフォルダの再構成をスキャンの座標系へ移します。回転・平行移動・" + "スケールは、スキャンの画像が再構成に含まれていればまずその画像に、続いて" + "スキャンの表面に合わせます。結果は 1 つの COLMAP モデルとして書き戻し、その" + "初期点はスキャン自身の点で、各点にはその点を見ている画像を記録します。深度" + "マップと法線マップは、すべての画像についてスキャンからレンダリングします。" + "元の再構成は sparse_unaligned/ に残します。"), + ZH_HANS("把数据集文件夹中的重建移到扫描的坐标系中——旋转、平移和缩放先拟合到" + "参与了重建的扫描图像,再拟合到扫描的表面——然后写回为一个 COLMAP 模型," + "其初始点就是扫描自己的点,每个点都列出能看到它的图像。深度图和法线图会" + "为每张图像从扫描渲染。原来的重建保留在 sparse_unaligned/ 中。"), + ZH_HANT("把資料集資料夾中的重建移到掃描的座標系中——旋轉、平移和縮放先擬合到" + "參與了重建的掃描影像,再擬合到掃描的表面——然後寫回為一個 COLMAP 模型," + "其初始點就是掃描自己的點,每個點都列出能看到它的影像。深度圖和法線圖會" + "為每張影像從掃描渲染。原來的重建保留在 sparse_unaligned/ 中。"), + KO("데이터셋 폴더의 재구성을 스캔의 좌표계로 옮깁니다. 회전, 이동, 스케일은 " + "스캔의 이미지가 재구성에 들어 있으면 먼저 그 이미지에, 이어서 스캔 표면에 " + "맞춥니다. 결과는 하나의 COLMAP 모델로 다시 쓰며, 그 초기 점은 스캔 자신의 " + "점이고 각 점에는 그 점을 보는 이미지가 기록됩니다. 깊이 맵과 법선 맵은 모든 " + "이미지에 대해 스캔에서 렌더링합니다. 원래 재구성은 sparse_unaligned/에 " + "남겨 둡니다."), + DE("Verschiebt die Rekonstruktion im Datensatzordner in das Bezugssystem des " + "Scans -- Rotation, Translation und Maßstab, angepasst an die Bilder des Scans, " + "soweit sie Teil der Rekonstruktion waren, und dann an seine Oberfläche -- und " + "schreibt sie als ein COLMAP-Modell zurück, dessen Startpunkte die des Scans " + "sind, jeder mit den Bildern, die ihn sehen. Tiefen- und Normalenkarten werden " + "für jedes Bild aus dem Scan gerendert. Die Ausgangsrekonstruktion bleibt in " + "sparse_unaligned/ erhalten."), + FR("Déplace la reconstruction du dossier du jeu de données dans le repère du scan " + "-- rotation, translation et échelle, ajustées sur les images du scan lorsqu'elles " + "faisaient partie de la reconstruction, puis sur sa surface -- et la réécrit en un " + "seul modèle COLMAP dont les points de départ sont ceux du scan, chacun listant " + "les images qui le voient. Des cartes de profondeur et de normales sont rendues " + "depuis le scan pour chaque image. La reconstruction de départ est conservée dans " + "sparse_unaligned/."), + ES("Mueve la reconstrucción de la carpeta del conjunto de datos al sistema de " + "referencia del escaneo -- rotación, traslación y escala, ajustadas a las imágenes " + "del escaneo cuando formaban parte de la reconstrucción y después a su superficie " + "-- y la vuelve a escribir como un único modelo COLMAP cuyos puntos iniciales son " + "los del propio escaneo, cada uno con la lista de imágenes que lo ven. Se " + "renderizan mapas de profundidad y normales desde el escaneo para cada imagen. La " + "reconstrucción de partida se conserva en sparse_unaligned/."), + PT("Move a reconstrução da pasta do conjunto de dados para o referencial da " + "varredura -- rotação, translação e escala, ajustadas às imagens da varredura " + "quando faziam parte da reconstrução e depois à sua superfície -- e a grava de " + "volta como um único modelo COLMAP cujos pontos iniciais são os da própria " + "varredura, cada um listando as imagens que o veem. Mapas de profundidade e " + "normais são renderizados a partir da varredura para cada imagem. A reconstrução " + "de partida é mantida em sparse_unaligned/."), + IT("Sposta la ricostruzione della cartella del set di dati nel sistema di " + "riferimento della scansione -- rotazione, traslazione e scala, adattate alle " + "immagini della scansione quando facevano parte della ricostruzione e poi alla " + "sua superficie -- e la riscrive come un unico modello COLMAP i cui punti " + "iniziali sono quelli della scansione stessa, ciascuno con l'elenco delle " + "immagini che lo vedono. Le mappe di profondità e normali vengono renderizzate " + "dalla scansione per ogni immagine. La ricostruzione di partenza resta in " + "sparse_unaligned/."), + NL("Verplaatst de reconstructie in de datasetmap naar het assenstelsel van de scan " + "-- rotatie, translatie en schaal, gepast op de beelden van de scan voor zover die " + "deel uitmaakten van de reconstructie en daarna op het oppervlak ervan -- en " + "schrijft haar terug als één COLMAP-model waarvan de beginpunten die van de scan " + "zelf zijn, elk met de beelden die het zien. Diepte- en normaalkaarten worden " + "voor elk beeld uit de scan gerenderd. De oorspronkelijke reconstructie blijft " + "bewaard in sparse_unaligned/."), + RU("Переносит реконструкцию из папки набора данных в систему координат скана -- " + "поворот, сдвиг и масштаб подгоняются по изображениям скана, если они входили в " + "реконструкцию, а затем по его поверхности -- и записывает её обратно как одну " + "модель COLMAP, начальные точки которой -- точки самого скана, и у каждой " + "указаны изображения, которые её видят. Карты глубины и нормалей рендерятся из " + "скана для каждого изображения. Исходная реконструкция сохраняется в " + "sparse_unaligned/."), + TR("Veri kümesi klasöründeki yeniden oluşturmayı taramanın koordinat sistemine " + "taşır -- dönme, öteleme ve ölçek, taramanın görüntüleri yeniden oluşturmaya " + "dahilse önce onlara, sonra taramanın yüzeyine uydurulur -- ve sonucu, başlangıç " + "noktaları taramanın kendi noktaları olan tek bir COLMAP modeli olarak geri " + "yazar; her nokta onu gören görüntüleri listeler. Derinlik ve normal haritaları " + "her görüntü için taramadan işlenir. Başlangıçtaki yeniden oluşturma " + "sparse_unaligned/ içinde saklanır.")); + +SS_MSG(usage_extract, + EN("--extract writes the images an E57 file carries into a dataset's image folder and " + "records their poses, so a reconstruction that includes them can be aligned by them."), + JA("--extract は E57 ファイルに含まれる画像をデータセットの画像フォルダに書き出し、" + "その姿勢を記録します。これで、それらの画像を含む再構成をその画像によって" + "位置合わせできます。"), + ZH_HANS("--extract 把 E57 文件中的图像写入数据集的图像文件夹并记录它们的位姿," + "这样包含这些图像的重建就能借助它们对齐。"), + ZH_HANT("--extract 把 E57 檔案中的影像寫入資料集的影像資料夾並記錄它們的位姿," + "這樣包含這些影像的重建就能藉助它們對齊。"), + KO("--extract는 E57 파일에 담긴 이미지를 데이터셋의 이미지 폴더에 쓰고 그 자세를 " + "기록합니다. 그러면 이 이미지를 포함한 재구성을 이 이미지로 정렬할 수 " + "있습니다."), + DE("--extract schreibt die Bilder einer E57-Datei in den Bildordner eines " + "Datensatzes und hält ihre Posen fest, sodass eine Rekonstruktion, die sie " + "enthält, an ihnen ausgerichtet werden kann."), + FR("--extract écrit les images que contient un fichier E57 dans le dossier " + "d'images d'un jeu de données et enregistre leurs poses, pour qu'une " + "reconstruction qui les inclut puisse être alignée grâce à elles."), + ES("--extract escribe las imágenes que lleva un archivo E57 en la carpeta de " + "imágenes de un conjunto de datos y guarda sus poses, para que una " + "reconstrucción que las incluya pueda alinearse con ellas."), + PT("--extract grava as imagens que um arquivo E57 contém na pasta de imagens de " + "um conjunto de dados e registra suas poses, para que uma reconstrução que as " + "inclua possa ser alinhada por elas."), + IT("--extract scrive le immagini contenute in un file E57 nella cartella delle " + "immagini di un set di dati e ne registra le pose, così una ricostruzione che le " + "include può essere allineata grazie a esse."), + NL("--extract schrijft de beelden die een E57-bestand bevat naar de beeldenmap van " + "een dataset en legt hun poses vast, zodat een reconstructie die ze bevat erop " + "kan worden uitgelijnd."), + RU("--extract записывает изображения из файла E57 в папку изображений набора " + "данных и сохраняет их позы, чтобы реконструкцию, в которую они входят, можно " + "было совместить по ним."), + TR("--extract, bir E57 dosyasındaki görüntüleri bir veri kümesinin görüntü " + "klasörüne yazar ve pozlarını kaydeder; böylece bu görüntüleri içeren bir yeniden " + "oluşturma onlara göre hizalanabilir.")); + +SS_MSG(head_options, + EN("Options:"), JA("オプション:"), ZH_HANS("选项:"), ZH_HANT("選項:"), + KO("옵션:"), DE("Optionen:"), FR("Options :"), ES("Opciones:"), PT("Opções:"), + IT("Opzioni:"), NL("Opties:"), RU("Параметры:"), TR("Seçenekler:")); + +SS_MSG(opt_cloud, + EN("A point cloud in the scan's frame (.e57, .las, .ply); repeat for several"), + JA("スキャンの座標系にある点群(.e57、.las、.ply)。複数あれば繰り返し指定します"), + ZH_HANS("位于扫描坐标系中的点云(.e57、.las、.ply);多个时重复指定"), + ZH_HANT("位於掃描座標系中的點雲(.e57、.las、.ply);多個時重複指定"), + KO("스캔 좌표계의 포인트 클라우드(.e57, .las, .ply). 여러 개면 반복해서 " + "지정합니다"), + DE("Eine Punktwolke im Bezugssystem des Scans (.e57, .las, .ply); für mehrere " + "wiederholen"), + FR("Un nuage de points dans le repère du scan (.e57, .las, .ply) ; à répéter pour " + "en donner plusieurs"), + ES("Una nube de puntos en el sistema de referencia del escaneo (.e57, .las, " + ".ply); repítalo para varias"), + PT("Uma nuvem de pontos no referencial da varredura (.e57, .las, .ply); repita " + "para várias"), + IT("Una nuvola di punti nel sistema di riferimento della scansione (.e57, .las, " + ".ply); ripetere per più nuvole"), + NL("Een puntenwolk in het assenstelsel van de scan (.e57, .las, .ply); herhaal " + "voor meerdere"), + RU("Облако точек в системе координат скана (.e57, .las, .ply); для нескольких " + "повторите"), + TR("Taramanın koordinat sistemindeki bir nokta bulutu (.e57, .las, .ply); birden " + "çok için tekrarlayın")); + +SS_MSG(opt_mode, + EN("auto (default) fits the anchors, then the surface; anchors fits the anchors alone; " + "keep leaves a model that is already in the scan's frame where it is; refine fits " + "the surface from where the model is"), + JA("auto(既定)はアンカー画像に合わせてから表面に合わせます。anchors はアンカー" + "画像だけに合わせます。keep はすでにスキャンの座標系にあるモデルをそのままに" + "します。refine はモデルの今の位置から表面に合わせます"), + ZH_HANS("auto(默认)先拟合锚定图像,再拟合表面;anchors 只拟合锚定图像;keep " + "让已经处于扫描坐标系中的模型保持原位;refine 从模型当前的位置开始拟合" + "表面"), + ZH_HANT("auto(預設)先擬合錨定影像,再擬合表面;anchors 只擬合錨定影像;keep " + "讓已經位於掃描座標系中的模型保持原位;refine 從模型目前的位置開始擬合" + "表面"), + KO("auto(기본값)는 앵커 이미지에 맞춘 뒤 표면에 맞춥니다. anchors는 앵커 " + "이미지에만 맞춥니다. keep은 이미 스캔 좌표계에 있는 모델을 그대로 둡니다. " + "refine은 모델의 현재 위치에서 시작해 표면에 맞춥니다"), + DE("auto (Standard) passt an die Ankerbilder an, dann an die Oberfläche; anchors " + "passt nur an die Ankerbilder an; keep lässt ein Modell, das schon im " + "Bezugssystem des Scans liegt, wo es ist; refine passt ab der aktuellen Lage des " + "Modells an die Oberfläche an"), + FR("auto (par défaut) ajuste sur les images d'ancrage, puis sur la surface ; " + "anchors ajuste sur les images d'ancrage seules ; keep laisse en place un modèle " + "déjà dans le repère du scan ; refine ajuste sur la surface à partir de la " + "position actuelle du modèle"), + ES("auto (por defecto) ajusta a las imágenes ancla y luego a la superficie; " + "anchors ajusta solo a las imágenes ancla; keep deja donde está un modelo que ya " + "está en el sistema de referencia del escaneo; refine ajusta a la superficie " + "desde donde está el modelo"), + PT("auto (padrão) ajusta às imagens âncora e depois à superfície; anchors ajusta " + "só às imagens âncora; keep deixa onde está um modelo que já está no " + "referencial da varredura; refine ajusta à superfície a partir de onde o modelo " + "está"), + IT("auto (predefinito) adatta alle immagini di ancoraggio, poi alla superficie; " + "anchors adatta solo alle immagini di ancoraggio; keep lascia dov'è un modello " + "che è già nel sistema di riferimento della scansione; refine adatta alla " + "superficie partendo da dove si trova il modello"), + NL("auto (standaard) past op de ankerbeelden en daarna op het oppervlak; anchors " + "past alleen op de ankerbeelden; keep laat een model dat al in het assenstelsel " + "van de scan staat waar het is; refine past op het oppervlak vanaf waar het " + "model staat"), + RU("auto (по умолчанию) подгоняет по опорным изображениям, затем по поверхности; " + "anchors -- только по опорным изображениям; keep оставляет на месте модель, " + "которая уже в системе координат скана; refine подгоняет по поверхности от " + "текущего положения модели"), + TR("auto (varsayılan) önce çapa görüntülerine, sonra yüzeye uydurur; anchors " + "yalnızca çapa görüntülerine uydurur; keep, zaten taramanın koordinat sisteminde " + "olan bir modeli yerinde bırakır; refine, modelin bulunduğu yerden başlayarak " + "yüzeye uydurur")); + +SS_MSG(opt_points, + EN("Seed points kept from the scan, thinned by voxels (default {0}); all keeps every " + "one"), + JA("スキャンから残す初期点の数。ボクセルで間引きます(既定 {0})。all なら全点を" + "残します"), + ZH_HANS("从扫描保留的初始点数,按体素精简(默认 {0});all 表示全部保留"), + ZH_HANT("從掃描保留的初始點數,按體素精簡(預設 {0});all 表示全部保留"), + KO("스캔에서 남길 초기 점 수로, 복셀로 솎아 냅니다(기본값 {0}). all이면 모두 " + "남깁니다"), + DE("Aus dem Scan behaltene Startpunkte, nach Voxeln ausgedünnt (Standard {0}); " + "all behält alle"), + FR("Points de départ gardés du scan, allégés par voxels (par défaut {0}) ; all " + "les garde tous"), + ES("Puntos iniciales que se conservan del escaneo, aligerados por vóxeles (por " + "defecto {0}); all los conserva todos"), + PT("Pontos iniciais mantidos da varredura, reduzidos por voxels (padrão {0}); all " + "mantém todos"), + IT("Punti iniziali tenuti dalla scansione, sfoltiti per voxel (predefinito {0}); " + "all li tiene tutti"), + NL("Beginpunten die uit de scan behouden blijven, uitgedund per voxel (standaard " + "{0}); all houdt ze allemaal"), + RU("Начальные точки, оставляемые из скана, прореженные по вокселям (по умолчанию " + "{0}); all оставляет все"), + TR("Taramadan tutulan başlangıç noktaları, voksellerle seyreltilir (varsayılan " + "{0}); all hepsini tutar")); + +SS_MSG(opt_track_cap, + EN("Images listed in a scan point's track, at most (default {0})"), + JA("1 つのスキャン点に記録する画像の最大数(既定 {0})"), + ZH_HANS("每个扫描点最多列出的图像数(默认 {0})"), + ZH_HANT("每個掃描點最多列出的影像數(預設 {0})"), + KO("스캔 점 하나에 기록할 이미지의 최대 수(기본값 {0})"), + DE("Höchstzahl der Bilder, die zu einem Scanpunkt aufgeführt werden (Standard {0})"), + FR("Nombre maximal d'images listées pour un point du scan (par défaut {0})"), + ES("Máximo de imágenes listadas para un punto del escaneo (por defecto {0})"), + PT("Máximo de imagens listadas para um ponto da varredura (padrão {0})"), + IT("Numero massimo di immagini elencate per un punto della scansione (predefinito " + "{0})"), + NL("Hoogstens zoveel beelden vermeld bij één scanpunt (standaard {0})"), + RU("Наибольшее число изображений, указываемых для одной точки скана (по умолчанию " + "{0})"), + TR("Bir tarama noktası için listelenen en fazla görüntü sayısı (varsayılan {0})")); + +SS_MSG(opt_no_depth, + EN("Do not render depth and normal maps"), + JA("深度マップと法線マップをレンダリングしません"), + ZH_HANS("不渲染深度图和法线图"), + ZH_HANT("不渲染深度圖和法線圖"), + KO("깊이 맵과 법선 맵을 렌더링하지 않습니다"), + DE("Keine Tiefen- und Normalenkarten rendern"), + FR("Ne pas rendre de cartes de profondeur et de normales"), + ES("No renderizar mapas de profundidad y normales"), + PT("Não renderizar mapas de profundidade e normais"), + IT("Non renderizzare mappe di profondità e normali"), + NL("Geen diepte- en normaalkaarten renderen"), + RU("Не рендерить карты глубины и нормалей"), + TR("Derinlik ve normal haritalarını oluşturma")); + +SS_MSG(opt_no_gaps, + EN("Drop the reconstruction's own points, even where the scan has nothing"), + JA("スキャンに点がない場所でも、再構成自身の点を使いません"), + ZH_HANS("丢弃重建自己的点,即使扫描在那里没有点"), + ZH_HANT("捨棄重建自己的點,即使掃描在那裡沒有點"), + KO("스캔에 아무것도 없는 곳에서도 재구성 자체의 점을 버립니다"), + DE("Die eigenen Punkte der Rekonstruktion verwerfen, auch wo der Scan nichts hat"), + FR("Écarter les points propres de la reconstruction, même là où le scan n'a rien"), + ES("Descartar los puntos propios de la reconstrucción, incluso donde el escaneo no " + "tiene nada"), + PT("Descartar os pontos da própria reconstrução, mesmo onde a varredura não tem " + "nada"), + IT("Scartare i punti propri della ricostruzione, anche dove la scansione non ha " + "nulla"), + NL("De eigen punten van de reconstructie weglaten, ook waar de scan niets heeft"), + RU("Отбросить собственные точки реконструкции, даже там, где в скане ничего нет"), + TR("Taramanın hiçbir şey içermediği yerlerde bile yeniden oluşturmanın kendi " + "noktalarını at")); + +SS_MSG(opt_anchors, + EN("The anchors file (default: lidar/anchors.json in the dataset)"), + JA("アンカー画像のファイル(既定: データセット内の lidar/anchors.json)"), + ZH_HANS("锚定图像文件(默认:数据集中的 lidar/anchors.json)"), + ZH_HANT("錨定影像檔案(預設:資料集中的 lidar/anchors.json)"), + KO("앵커 이미지 파일(기본값: 데이터셋 안의 lidar/anchors.json)"), + DE("Die Datei der Ankerbilder (Standard: lidar/anchors.json im Datensatz)"), + FR("Le fichier des images d'ancrage (par défaut : lidar/anchors.json dans le jeu " + "de données)"), + ES("El archivo de imágenes ancla (por defecto: lidar/anchors.json en el conjunto " + "de datos)"), + PT("O arquivo das imagens âncora (padrão: lidar/anchors.json no conjunto de " + "dados)"), + IT("Il file delle immagini di ancoraggio (predefinito: lidar/anchors.json nel set " + "di dati)"), + NL("Het bestand met ankerbeelden (standaard: lidar/anchors.json in de dataset)"), + RU("Файл опорных изображений (по умолчанию: lidar/anchors.json в наборе данных)"), + TR("Çapa görüntüleri dosyası (varsayılan: veri kümesindeki lidar/anchors.json)")); + +SS_MSG(opt_image_dir, + EN("Where the images are: relative to the dataset (default images) or absolute"), + JA("画像の場所。データセットからの相対パス(既定 images)か絶対パス"), + ZH_HANS("图像所在位置:相对于数据集的路径(默认 images)或绝对路径"), + ZH_HANT("影像所在位置:相對於資料集的路徑(預設 images)或絕對路徑"), + KO("이미지 위치: 데이터셋 기준 상대 경로(기본값 images) 또는 절대 경로"), + DE("Wo die Bilder liegen: relativ zum Datensatz (Standard images) oder absolut"), + FR("Emplacement des images : relatif au jeu de données (par défaut images) ou " + "absolu"), + ES("Dónde están las imágenes: relativo al conjunto de datos (por defecto images) o " + "absoluto"), + PT("Onde estão as imagens: relativo ao conjunto de dados (padrão images) ou " + "absoluto"), + IT("Dove sono le immagini: relativo al set di dati (predefinito images) o assoluto"), + NL("Waar de beelden staan: relatief aan de dataset (standaard images) of absoluut"), + RU("Где лежат изображения: относительно набора данных (по умолчанию images) или " + "абсолютный путь"), + TR("Görüntülerin yeri: veri kümesine göre göreli (varsayılan images) ya da mutlak")); + +SS_MSG(opt_mask_dir, + EN("Where the masks are: relative to the dataset (default masks) or absolute. Depth " + "and normals are left blank where they take the image out"), + JA("マスクの場所。データセットからの相対パス(既定 masks)か絶対パス。マスクが" + "画像を除く部分では、深度と法線を空のままにします"), + ZH_HANS("蒙版所在位置:相对于数据集的路径(默认 masks)或绝对路径。蒙版去除图像的" + "地方,深度和法线留空"), + ZH_HANT("遮罩所在位置:相對於資料集的路徑(預設 masks)或絕對路徑。遮罩去除影像的" + "地方,深度和法線留空"), + KO("마스크 위치: 데이터셋 기준 상대 경로(기본값 masks) 또는 절대 경로. 마스크가 " + "이미지를 빼는 곳은 깊이와 법선을 비워 둡니다"), + DE("Wo die Masken liegen: relativ zum Datensatz (Standard masks) oder absolut. Wo " + "sie das Bild ausblenden, bleiben Tiefe und Normalen leer"), + FR("Emplacement des masques : relatif au jeu de données (par défaut masks) ou " + "absolu. Là où ils retirent l'image, la profondeur et les normales restent vides"), + ES("Dónde están las máscaras: relativo al conjunto de datos (por defecto masks) o " + "absoluto. Donde quitan la imagen, la profundidad y las normales quedan vacías"), + PT("Onde estão as máscaras: relativo ao conjunto de dados (padrão masks) ou " + "absoluto. Onde elas retiram a imagem, a profundidade e as normais ficam vazias"), + IT("Dove sono le maschere: relativo al set di dati (predefinito masks) o assoluto. " + "Dove tolgono l'immagine, profondità e normali restano vuote"), + NL("Waar de maskers staan: relatief aan de dataset (standaard masks) of absoluut. " + "Waar ze het beeld weglaten, blijven diepte en normalen leeg"), + RU("Где лежат маски: относительно набора данных (по умолчанию masks) или " + "абсолютный путь. Там, где маска убирает изображение, глубина и нормали остаются " + "пустыми"), + TR("Maskelerin yeri: veri kümesine göre göreli (varsayılan masks) ya da mutlak. " + "Maskenin görüntüyü çıkardığı yerlerde derinlik ve normaller boş kalır")); + +SS_MSG(opt_scan_frames, + EN("Whether the clouds share one frame: `shared` (one registration wrote them), " + "`separate` (each has its own, and is placed through its images in the " + "reconstruction), or `auto`, the default: shared when no two files put their " + "scanner in one place"), + JA("点群が 1 つの座標系を共有するかどうか。`shared`(1 回のレジストレーションで" + "書き出されたもの)、`separate`(それぞれ独自の座標系を持ち、再構成内の画像を" + "通して配置する)、または既定の `auto`(スキャナーの位置が同じになるファイルの" + "組がなければ共有とみなす)"), + ZH_HANS("这些点云是否共享一个坐标系:`shared`(由同一次配准写出)、`separate`" + "(各有自己的坐标系,通过它在重建中的图像来放置),或默认的 `auto`:" + "没有两个文件把扫描仪放在同一处时视为共享"), + ZH_HANT("這些點雲是否共享一個座標系:`shared`(由同一次對位寫出)、`separate`" + "(各有自己的座標系,透過它在重建中的影像來放置),或預設的 `auto`:" + "沒有兩個檔案把掃描儀放在同一處時視為共享"), + KO("포인트 클라우드들이 하나의 좌표계를 공유하는지: `shared`(한 번의 정합으로 " + "내보낸 것), `separate`(각자 좌표계가 있고 재구성 안의 이미지를 통해 배치), " + "또는 기본값 `auto`: 스캐너를 같은 곳에 두는 두 파일이 없으면 공유로 봄"), + DE("Ob die Punktwolken ein Bezugssystem teilen: `shared` (eine Registrierung hat " + "sie geschrieben), `separate` (jede hat ihr eigenes und wird über ihre Bilder in " + "der Rekonstruktion platziert) oder `auto`, der Standard: geteilt, wenn keine zwei " + "Dateien ihren Scanner an dieselbe Stelle setzen"), + FR("Si les nuages partagent un repère : `shared` (une seule consolidation les a " + "écrits), `separate` (chacun a le sien et est placé par ses images dans la " + "reconstruction), ou `auto`, par défaut : partagé quand deux fichiers ne placent " + "jamais leur scanner au même endroit"), + ES("Si las nubes comparten un sistema de referencia: `shared` (las escribió un solo " + "registro), `separate` (cada una tiene el suyo y se sitúa mediante sus imágenes " + "en la reconstrucción) o `auto`, por defecto: compartido cuando no hay dos " + "archivos que pongan su escáner en el mismo sitio"), + PT("Se as nuvens compartilham um referencial: `shared` (um único registro as " + "gravou), `separate` (cada uma tem o seu e é posicionada pelas suas imagens na " + "reconstrução) ou `auto`, o padrão: compartilhado quando não há dois arquivos que " + "ponham o scanner no mesmo lugar"), + IT("Se le nuvole condividono un sistema di riferimento: `shared` (le ha scritte " + "un'unica registrazione), `separate` (ognuna ha il proprio e viene posizionata " + "tramite le sue immagini nella ricostruzione) o `auto`, il predefinito: condiviso " + "quando nessuna coppia di file mette lo scanner nello stesso punto"), + NL("Of de puntenwolken één assenstelsel delen: `shared` (één registratie schreef ze), " + "`separate` (elk heeft een eigen en wordt geplaatst via zijn beelden in de " + "reconstructie) of `auto`, de standaard: gedeeld als geen twee bestanden hun " + "scanner op dezelfde plek zetten"), + RU("Общая ли у облаков система координат: `shared` (их записала одна регистрация), " + "`separate` (у каждого своя, и оно размещается по своим изображениям в " + "реконструкции) или `auto`, по умолчанию: общая, если никакие два файла не ставят " + "сканер в одно место"), + TR("Bulutların tek bir koordinat sistemini paylaşıp paylaşmadığı: `shared` (tek bir " + "kayıt yazdı), `separate` (her birinin kendi sistemi var ve yeniden oluşturmadaki " + "görüntüleriyle yerleştirilir) ya da varsayılan `auto`: hiçbir iki dosya " + "tarayıcıyı aynı yere koymuyorsa paylaşılır")); + +SS_MSG(opt_flip_masks, + EN("The dataset's masks are white where an image is NOT kept"), + JA("データセットのマスクは、画像を残さない部分が白です"), + ZH_HANS("数据集的蒙版在图像不保留的地方为白色"), + ZH_HANT("資料集的遮罩在影像不保留的地方為白色"), + KO("데이터셋의 마스크는 이미지를 남기지 않는 곳이 흰색입니다"), + DE("Die Masken des Datensatzes sind dort weiß, wo ein Bild NICHT behalten wird"), + FR("Les masques du jeu de données sont blancs là où une image n'est PAS gardée"), + ES("Las máscaras del conjunto de datos son blancas donde una imagen NO se conserva"), + PT("As máscaras do conjunto de dados são brancas onde uma imagem NÃO é mantida"), + IT("Le maschere del set di dati sono bianche dove un'immagine NON viene tenuta"), + NL("De maskers van de dataset zijn wit waar een beeld NIET behouden blijft"), + RU("Маски набора данных белые там, где изображение НЕ сохраняется"), + TR("Veri kümesinin maskeleri, görüntünün TUTULMADIĞI yerlerde beyazdır")); + +SS_MSG(opt_render, + EN("--render-anchors ... : views of the scan from where the " + "scanner stood, written as images with known poses, for a scan that has no " + "photographs"), + JA("--render-anchors ... : スキャナーを据えた位置から見た" + "スキャンのビューを、姿勢が分かっている画像として書き出します。写真のない" + "スキャン向けです"), + ZH_HANS("--render-anchors ... :从扫描仪所在位置渲染扫描的" + "视图,作为位姿已知的图像写出,用于没有照片的扫描"), + ZH_HANT("--render-anchors ... :從掃描儀所在位置渲染掃描的" + "視圖,作為位姿已知的影像寫出,用於沒有照片的掃描"), + KO("--render-anchors ... : 스캐너가 서 있던 곳에서 본 " + "스캔의 뷰를 자세가 알려진 이미지로 씁니다. 사진이 없는 스캔용입니다"), + DE("--render-anchors ... : Ansichten des Scans von den " + "Standpunkten des Scanners, als Bilder mit bekannten Posen geschrieben, für " + "einen Scan ohne Fotos"), + FR("--render-anchors ... : des vues du scan depuis " + "l'emplacement du scanner, écrites comme images aux poses connues, pour un scan " + "sans photos"), + ES("--render-anchors ... : vistas del escaneo desde donde " + "estuvo el escáner, escritas como imágenes con poses conocidas, para un escaneo " + "sin fotografías"), + PT("--render-anchors ... : vistas da varredura de onde o " + "scanner estava, gravadas como imagens com poses conhecidas, para uma varredura " + "sem fotografias"), + IT("--render-anchors ... : viste della scansione da dove " + "stava lo scanner, scritte come immagini con pose note, per una scansione senza " + "fotografie"), + NL("--render-anchors ... : aanzichten van de scan vanaf waar " + "de scanner stond, geschreven als beelden met bekende poses, voor een scan " + "zonder foto's"), + RU("--render-anchors ... : виды скана с тех мест, где стоял " + "сканер, записанные как изображения с известными позами, для скана без " + "фотографий"), + TR("--render-anchors ... : taramanın, tarayıcının durduğu " + "yerlerden görünümleri; fotoğrafı olmayan bir tarama için pozu bilinen " + "görüntüler olarak yazılır")); + +SS_MSG(opt_overwrite, + EN("Align again even when nothing it depends on has changed"), + JA("依存するものが何も変わっていなくても、もう一度位置合わせします"), + ZH_HANS("即使所依赖的内容都没有变化,也重新对齐"), + ZH_HANT("即使所依賴的內容都沒有變化,也重新對齊"), + KO("의존하는 것이 바뀌지 않았어도 다시 정렬합니다"), + DE("Erneut ausrichten, auch wenn sich nichts geändert hat, wovon es abhängt"), + FR("Aligner de nouveau même si rien de ce dont l'alignement dépend n'a changé"), + ES("Volver a alinear aunque no haya cambiado nada de lo que depende"), + PT("Alinhar de novo mesmo que nada de que depende tenha mudado"), + IT("Allineare di nuovo anche se nulla di ciò da cui dipende è cambiato"), + NL("Opnieuw uitlijnen, ook als niets waarvan het afhangt is veranderd"), + RU("Совместить заново, даже если ничего из того, от чего это зависит, не " + "изменилось"), + TR("Bağlı olduğu hiçbir şey değişmemiş olsa bile yeniden hizala")); + +SS_MSG(opt_scanner_poses, + EN("Use no reconstruction: place the scan's photographs where the scanner recorded them"), + JA("再構成を使わず、スキャンの写真をスキャナーが記録した位置に置きます"), + ZH_HANS("不使用重建:把扫描中的照片放在扫描仪记录的位置"), + ZH_HANT("不使用重建:把掃描中的照片放在掃描儀記錄的位置"), + KO("재구성을 쓰지 않고, 스캔의 사진을 스캐너가 기록한 위치에 둡니다"), + DE("Keine Rekonstruktion verwenden: die Fotos des Scans dorthin setzen, wo der Scanner " + "sie aufgezeichnet hat"), + FR("N'utiliser aucune reconstruction : placer les photos du scan là où le scanner les a " + "enregistrées"), + ES("No usar ninguna reconstrucción: colocar las fotos del escaneo donde las registró el " + "escáner"), + PT("Não usar reconstrução: colocar as fotos da varredura onde o scanner as registrou"), + IT("Non usare alcuna ricostruzione: mettere le foto della scansione dove le ha registrate " + "lo scanner"), + NL("Geen reconstructie gebruiken: de foto's van de scan neerzetten waar de scanner ze " + "vastlegde"), + RU("Не использовать реконструкцию: поставить фотографии скана туда, где их записал " + "сканер"), + TR("Yeniden oluşturma kullanma: taramanın fotoğraflarını tarayıcının kaydettiği yere koy")); + +SS_MSG(opt_info, + EN("Describe a point cloud file and stop"), + JA("点群ファイルの内容を表示して終了します"), + ZH_HANS("描述点云文件后退出"), + ZH_HANT("描述點雲檔案後結束"), + KO("포인트 클라우드 파일을 설명하고 멈춥니다"), + DE("Eine Punktwolkendatei beschreiben und beenden"), + FR("Décrire un fichier de nuage de points et s'arrêter"), + ES("Describir un archivo de nube de puntos y terminar"), + PT("Descrever um arquivo de nuvem de pontos e parar"), + IT("Descrivere un file di nuvola di punti e fermarsi"), + NL("Een puntenwolkbestand beschrijven en stoppen"), + RU("Описать файл облака точек и завершить работу"), + TR("Bir nokta bulutu dosyasını tanımla ve dur")); + +SS_MSG(err_no_input, + EN("{0}: name a dataset folder and at least one --cloud"), + JA("{0}: データセットフォルダと、少なくとも 1 つの --cloud を指定してください"), + ZH_HANS("{0}:请指定一个数据集文件夹和至少一个 --cloud"), + ZH_HANT("{0}:請指定一個資料集資料夾和至少一個 --cloud"), + KO("{0}: 데이터셋 폴더와 --cloud를 하나 이상 지정하세요"), + DE("{0}: einen Datensatzordner und mindestens ein --cloud angeben"), + FR("{0} : indiquez un dossier de jeu de données et au moins un --cloud"), + ES("{0}: indique una carpeta de conjunto de datos y al menos un --cloud"), + PT("{0}: indique uma pasta de conjunto de dados e pelo menos um --cloud"), + IT("{0}: indicare una cartella del set di dati e almeno un --cloud"), + NL("{0}: geef een datasetmap en minstens één --cloud op"), + RU("{0}: укажите папку набора данных и хотя бы один --cloud"), + TR("{0}: bir veri kümesi klasörü ve en az bir --cloud belirtin")); + +SS_MSG(err_unknown_option, + EN("Unknown option: {0}"), JA("不明なオプション: {0}"), + ZH_HANS("未知选项:{0}"), ZH_HANT("未知選項:{0}"), KO("알 수 없는 옵션: {0}"), + DE("Unbekannte Option: {0}"), FR("Option inconnue : {0}"), + ES("Opción desconocida: {0}"), PT("Opção desconhecida: {0}"), + IT("Opzione sconosciuta: {0}"), NL("Onbekende optie: {0}"), + RU("Неизвестный параметр: {0}"), TR("Bilinmeyen seçenek: {0}")); + +SS_MSG(err_bad_value, + EN("{0}: not a valid value: {1}"), + JA("{0}: 無効な値です: {1}"), + ZH_HANS("{0}:无效的值:{1}"), + ZH_HANT("{0}:無效的值:{1}"), + KO("{0}: 올바른 값이 아닙니다: {1}"), + DE("{0}: kein gültiger Wert: {1}"), + FR("{0} : valeur non valide : {1}"), + ES("{0}: valor no válido: {1}"), + PT("{0}: valor inválido: {1}"), + IT("{0}: valore non valido: {1}"), + NL("{0}: geen geldige waarde: {1}"), + RU("{0}: недопустимое значение: {1}"), + TR("{0}: geçerli bir değer değil: {1}")); + +SS_MSG(err_no_model, + EN("No reconstruction in {0} (sparse/ with cameras.bin and images.bin)"), + JA("{0} に再構成がありません(cameras.bin と images.bin を含む sparse/)"), + ZH_HANS("{0} 中没有重建(需要含 cameras.bin 和 images.bin 的 sparse/)"), + ZH_HANT("{0} 中沒有重建(需要含 cameras.bin 和 images.bin 的 sparse/)"), + KO("{0}에 재구성이 없습니다(cameras.bin과 images.bin이 든 sparse/)"), + DE("Keine Rekonstruktion in {0} (sparse/ mit cameras.bin und images.bin)"), + FR("Aucune reconstruction dans {0} (sparse/ avec cameras.bin et images.bin)"), + ES("No hay reconstrucción en {0} (sparse/ con cameras.bin e images.bin)"), + PT("Nenhuma reconstrução em {0} (sparse/ com cameras.bin e images.bin)"), + IT("Nessuna ricostruzione in {0} (sparse/ con cameras.bin e images.bin)"), + NL("Geen reconstructie in {0} (sparse/ met cameras.bin en images.bin)"), + RU("В {0} нет реконструкции (sparse/ с cameras.bin и images.bin)"), + TR("{0} içinde yeniden oluşturma yok (cameras.bin ve images.bin içeren sparse/)")); + +SS_MSG(err_unreadable_model, + EN("{0} holds a model this cannot read (only COLMAP binary is read); it was left as it is"), + JA("{0} には読めないモデルがあります(読めるのは COLMAP のバイナリだけです)。そのままにしました"), + ZH_HANS("{0} 中的模型无法读取(只读取 COLMAP 二进制格式),已保持原样"), + ZH_HANT("{0} 中的模型無法讀取(只讀取 COLMAP 二進位格式),已保持原樣"), + KO("{0}에 읽을 수 없는 모델이 있습니다(COLMAP 바이너리만 읽습니다). 그대로 두었습니다"), + DE("{0} enthält ein Modell, das sich nicht lesen lässt (gelesen wird nur COLMAP-binär); " + "es bleibt unverändert"), + FR("{0} contient un modèle illisible (seul le format binaire COLMAP est lu) ; il est " + "laissé tel quel"), + ES("{0} contiene un modelo que no se puede leer (solo se lee COLMAP binario); se ha " + "dejado como estaba"), + PT("{0} contém um modelo que não pode ser lido (só se lê COLMAP binário); ficou como " + "estava"), + IT("{0} contiene un modello che non si può leggere (si legge solo COLMAP binario); è " + "stato lasciato com'era"), + NL("{0} bevat een model dat niet te lezen is (alleen COLMAP-binair wordt gelezen); het " + "is gelaten zoals het was"), + RU("{0} содержит модель, которую не удаётся прочитать (читается только двоичный " + "COLMAP); она оставлена как есть"), + TR("{0} okunamayan bir model içeriyor (yalnızca COLMAP ikili biçimi okunur); olduğu " + "gibi bırakıldı")); + +SS_MSG(err_no_points, + EN("The point clouds hold no points"), + JA("点群に点が 1 つもありません"), + ZH_HANS("点云中没有任何点"), + ZH_HANT("點雲中沒有任何點"), + KO("포인트 클라우드에 점이 하나도 없습니다"), + DE("Die Punktwolken enthalten keine Punkte"), + FR("Les nuages de points ne contiennent aucun point"), + ES("Las nubes de puntos no contienen ningún punto"), + PT("As nuvens de pontos não contêm nenhum ponto"), + IT("Le nuvole di punti non contengono alcun punto"), + NL("De puntenwolken bevatten geen punten"), + RU("В облаках точек нет ни одной точки"), + TR("Nokta bulutlarında hiç nokta yok")); + +SS_MSG(err_none_aligned, + EN("No model could be aligned: none of them holds an image whose pose in the scan is " + "known. Add the scan's own images to the reconstruction, or use --mode keep for a " + "model that is already in the scan's frame."), + JA("どのモデルも位置合わせできませんでした。スキャン内での姿勢が分かっている画像を" + "含むモデルがありません。スキャン自身の画像を再構成に加えるか、すでにスキャンの" + "座標系にあるモデルなら --mode keep を使ってください。"), + ZH_HANS("没有任何模型能够对齐:其中没有一个包含在扫描中位姿已知的图像。请把扫描" + "自己的图像加入重建,或者对已经处于扫描坐标系中的模型使用 --mode keep。"), + ZH_HANT("沒有任何模型能夠對齊:其中沒有一個包含在掃描中位姿已知的影像。請把掃描" + "自己的影像加入重建,或者對已經位於掃描座標系中的模型使用 --mode keep。"), + KO("정렬할 수 있는 모델이 없습니다. 스캔 안에서의 자세가 알려진 이미지를 가진 " + "모델이 하나도 없습니다. 스캔 자체의 이미지를 재구성에 추가하거나, 이미 스캔 " + "좌표계에 있는 모델이라면 --mode keep을 쓰세요."), + DE("Kein Modell ließ sich ausrichten: Keines enthält ein Bild, dessen Pose im Scan " + "bekannt ist. Die eigenen Bilder des Scans in die Rekonstruktion aufnehmen, oder " + "--mode keep für ein Modell verwenden, das schon im Bezugssystem des Scans liegt."), + FR("Aucun modèle n'a pu être aligné : aucun ne contient d'image dont la pose dans " + "le scan est connue. Ajoutez les images du scan lui-même à la reconstruction, ou " + "utilisez --mode keep pour un modèle déjà dans le repère du scan."), + ES("No se pudo alinear ningún modelo: ninguno contiene una imagen cuya pose en el " + "escaneo se conozca. Añada las imágenes del propio escaneo a la reconstrucción, o " + "use --mode keep para un modelo que ya está en el sistema de referencia del " + "escaneo."), + PT("Nenhum modelo pôde ser alinhado: nenhum contém uma imagem cuja pose na " + "varredura seja conhecida. Adicione as imagens da própria varredura à " + "reconstrução, ou use --mode keep para um modelo que já está no referencial da " + "varredura."), + IT("Nessun modello è stato allineato: nessuno contiene un'immagine la cui posa " + "nella scansione sia nota. Aggiungere le immagini della scansione stessa alla " + "ricostruzione, oppure usare --mode keep per un modello che è già nel sistema di " + "riferimento della scansione."), + NL("Geen enkel model kon worden uitgelijnd: geen van alle bevat een beeld waarvan " + "de pose in de scan bekend is. Voeg de eigen beelden van de scan aan de " + "reconstructie toe, of gebruik --mode keep voor een model dat al in het " + "assenstelsel van de scan staat."), + RU("Ни одну модель не удалось совместить: ни в одной нет изображения, поза " + "которого в скане известна. Добавьте в реконструкцию изображения самого скана " + "или используйте --mode keep для модели, которая уже в системе координат скана."), + TR("Hiçbir model hizalanamadı: hiçbiri, taramadaki pozu bilinen bir görüntü " + "içermiyor. Taramanın kendi görüntülerini yeniden oluşturmaya ekleyin ya da " + "zaten taramanın koordinat sisteminde olan bir model için --mode keep kullanın.")); + +SS_MSG(info_cloud, + EN("{0}: {1} Points: {2} Colour: {3} Scanner positions: {4}"), + JA("{0}: {1} 点: {2} 色: {3} スキャナー位置: {4}"), + ZH_HANS("{0}:{1} 点:{2} 颜色:{3} 扫描仪位置:{4}"), + ZH_HANT("{0}:{1} 點:{2} 顏色:{3} 掃描儀位置:{4}"), + KO("{0}: {1} 점: {2} 색: {3} 스캐너 위치: {4}"), + DE("{0}: {1} Punkte: {2} Farbe: {3} Scannerstandpunkte: {4}"), + FR("{0} : {1} Points : {2} Couleur : {3} Positions du scanner : {4}"), + ES("{0}: {1} Puntos: {2} Color: {3} Posiciones del escáner: {4}"), + PT("{0}: {1} Pontos: {2} Cor: {3} Posições do scanner: {4}"), + IT("{0}: {1} Punti: {2} Colore: {3} Posizioni dello scanner: {4}"), + NL("{0}: {1} Punten: {2} Kleur: {3} Scannerposities: {4}"), + RU("{0}: {1} Точек: {2} Цвет: {3} Позиций сканера: {4}"), + TR("{0}: {1} Nokta: {2} Renk: {3} Tarayıcı konumu: {4}")); +SS_MSG(word_yes, EN("yes"), JA("あり"), ZH_HANS("有"), ZH_HANT("有"), KO("있음"), + DE("ja"), FR("oui"), ES("sí"), PT("sim"), IT("sì"), NL("ja"), RU("есть"), TR("var")); +SS_MSG(word_no, EN("no"), JA("なし"), ZH_HANS("无"), ZH_HANT("無"), KO("없음"), + DE("nein"), FR("non"), ES("no"), PT("não"), IT("no"), NL("nee"), RU("нет"), TR("yok")); + +SS_MSG(reading_cloud, + EN("Reading the scan: {0}"), + JA("スキャンを読み込み中: {0}"), + ZH_HANS("正在读取扫描:{0}"), + ZH_HANT("正在讀取掃描:{0}"), + KO("스캔 읽는 중: {0}"), + DE("Scan wird gelesen: {0}"), + FR("Lecture du scan : {0}"), + ES("Leyendo el escaneo: {0}"), + PT("Lendo a varredura: {0}"), + IT("Lettura della scansione: {0}"), + NL("Scan lezen: {0}"), + RU("Чтение скана: {0}"), + TR("Tarama okunuyor: {0}")); + +SS_MSG(cloud_read, + EN("Points read: {0} Kept for alignment: {1} Voxel: {2} m"), + JA("読み込んだ点: {0} 位置合わせ用に残した点: {1} ボクセル: {2} m"), + ZH_HANS("已读取的点:{0} 保留用于对齐:{1} 体素:{2} m"), + ZH_HANT("已讀取的點:{0} 保留用於對齊:{1} 體素:{2} m"), + KO("읽은 점: {0} 정렬용으로 남긴 점: {1} 복셀: {2} m"), + DE("Gelesene Punkte: {0} Für die Ausrichtung behalten: {1} Voxel: {2} m"), + FR("Points lus : {0} Gardés pour l'alignement : {1} Voxel : {2} m"), + ES("Puntos leídos: {0} Conservados para alinear: {1} Vóxel: {2} m"), + PT("Pontos lidos: {0} Mantidos para o alinhamento: {1} Voxel: {2} m"), + IT("Punti letti: {0} Tenuti per l'allineamento: {1} Voxel: {2} m"), + NL("Gelezen punten: {0} Behouden voor uitlijning: {1} Voxel: {2} m"), + RU("Прочитано точек: {0} Оставлено для совмещения: {1} Воксель: {2} м"), + TR("Okunan nokta: {0} Hizalama için tutulan: {1} Voksel: {2} m")); + +SS_MSG(reused, + EN("Already aligned with these scans, nothing changed: {0}"), + JA("これらのスキャンとは位置合わせ済みで、変更はありません: {0}"), + ZH_HANS("已与这些扫描对齐,没有任何变化:{0}"), + ZH_HANT("已與這些掃描對齊,沒有任何變化:{0}"), + KO("이미 이 스캔들과 정렬되어 있고 바뀐 것이 없습니다: {0}"), + DE("Bereits an diesen Scans ausgerichtet, nichts geändert: {0}"), + FR("Déjà aligné sur ces scans, rien n'a changé : {0}"), + ES("Ya alineado con estos escaneos, nada ha cambiado: {0}"), + PT("Já alinhado com estas varreduras, nada mudou: {0}"), + IT("Già allineato a queste scansioni, nulla è cambiato: {0}"), + NL("Al uitgelijnd op deze scans, niets veranderd: {0}"), + RU("Уже совмещено с этими сканами, ничего не изменилось: {0}"), + TR("Bu taramalarla zaten hizalı, hiçbir şey değişmedi: {0}")); + +SS_MSG(anchors_read, + EN("Images with a known pose in the scan: {0}"), + JA("スキャン内での姿勢が分かっている画像: {0}"), + ZH_HANS("在扫描中位姿已知的图像:{0}"), + ZH_HANT("在掃描中位姿已知的影像:{0}"), + KO("스캔 안에서 자세가 알려진 이미지: {0}"), + DE("Bilder mit bekannter Pose im Scan: {0}"), + FR("Images dont la pose dans le scan est connue : {0}"), + ES("Imágenes con pose conocida en el escaneo: {0}"), + PT("Imagens com pose conhecida na varredura: {0}"), + IT("Immagini con posa nota nella scansione: {0}"), + NL("Beelden met een bekende pose in de scan: {0}"), + RU("Изображений с известной позой в скане: {0}"), + TR("Taramada pozu bilinen görüntü: {0}")); + +SS_MSG(model_head, + EN("Model {0}: images {1}, points {2}"), + JA("モデル {0}: 画像 {1}、点 {2}"), + ZH_HANS("模型 {0}:图像 {1},点 {2}"), + ZH_HANT("模型 {0}:影像 {1},點 {2}"), + KO("모델 {0}: 이미지 {1}, 점 {2}"), + DE("Modell {0}: Bilder {1}, Punkte {2}"), + FR("Modèle {0} : images {1}, points {2}"), + ES("Modelo {0}: imágenes {1}, puntos {2}"), + PT("Modelo {0}: imagens {1}, pontos {2}"), + IT("Modello {0}: immagini {1}, punti {2}"), + NL("Model {0}: beelden {1}, punten {2}"), + RU("Модель {0}: изображений {1}, точек {2}"), + TR("Model {0}: görüntü {1}, nokta {2}")); + +SS_MSG(model_anchors, + EN("Anchors in it: {0}, agreeing: {1}; their poses differ by {2} deg and {3} m (median)"), + JA("含まれるアンカー画像: {0}、一致: {1}。姿勢の差は {2} 度、{3} m(中央値)"), + ZH_HANS("其中的锚定图像:{0},一致的:{1};位姿相差 {2} 度和 {3} m(中位数)"), + ZH_HANT("其中的錨定影像:{0},一致的:{1};位姿相差 {2} 度和 {3} m(中位數)"), + KO("포함된 앵커 이미지: {0}, 일치: {1}. 자세 차이는 {2}도, {3} m(중앙값)"), + DE("Ankerbilder darin: {0}, übereinstimmend: {1}; ihre Posen weichen um {2} Grad " + "und {3} m ab (Median)"), + FR("Images d'ancrage : {0}, concordantes : {1} ; leurs poses diffèrent de {2} " + "degrés et {3} m (médiane)"), + ES("Imágenes ancla: {0}, coincidentes: {1}; sus poses difieren en {2} grados y {3} " + "m (mediana)"), + PT("Imagens âncora: {0}, concordantes: {1}; as poses diferem em {2} graus e {3} m " + "(mediana)"), + IT("Immagini di ancoraggio: {0}, concordi: {1}; le pose differiscono di {2} gradi " + "e {3} m (mediana)"), + NL("Ankerbeelden erin: {0}, overeenstemmend: {1}; hun poses verschillen {2} " + "graden en {3} m (mediaan)"), + RU("Опорных изображений в ней: {0}, согласующихся: {1}; их позы расходятся на {2} " + "град. и {3} м (медиана)"), + TR("İçindeki çapa görüntüsü: {0}, uyuşan: {1}; pozları {2} derece ve {3} m farklı " + "(medyan)")); + +SS_MSG(model_icp, + EN("Fitted to the scan's surface: {0} m from it (median), {1}% of the points on it"), + JA("スキャンの表面に合わせました: 表面からの距離 {0} m(中央値)、表面上の点 {1}%"), + ZH_HANS("已拟合到扫描表面:距表面 {0} m(中位数),{1}% 的点落在表面上"), + ZH_HANT("已擬合到掃描表面:距表面 {0} m(中位數),{1}% 的點落在表面上"), + KO("스캔 표면에 맞췄습니다: 표면에서 {0} m(중앙값), 표면 위의 점 {1}%"), + DE("An die Oberfläche des Scans angepasst: {0} m davon entfernt (Median), {1} % der " + "Punkte darauf"), + FR("Ajusté sur la surface du scan : à {0} m d'elle (médiane), {1} % des points " + "dessus"), + ES("Ajustado a la superficie del escaneo: a {0} m de ella (mediana), {1} % de los " + "puntos sobre ella"), + PT("Ajustado à superfície da varredura: a {0} m dela (mediana), {1}% dos pontos " + "sobre ela"), + IT("Adattato alla superficie della scansione: a {0} m da essa (mediana), {1}% dei " + "punti su di essa"), + NL("Gepast op het oppervlak van de scan: {0} m ervan af (mediaan), {1}% van de " + "punten erop"), + RU("Подогнано по поверхности скана: {0} м от неё (медиана), {1}% точек на ней"), + TR("Taramanın yüzeyine uyduruldu: ondan {0} m uzakta (medyan), noktaların %{1} " + "kadarı üzerinde")); + +SS_MSG(scale_from_depth, + EN("The anchors stand in one place, so the scale comes from the scan's depth: {0} " + "Points: {1}"), + JA("アンカー画像が 1 か所に集まっているため、スケールはスキャンの深度から求めます: " + "{0} 点: {1}"), + ZH_HANS("锚定图像都在同一处,因此尺度取自扫描的深度:{0} 点:{1}"), + ZH_HANT("錨定影像都在同一處,因此尺度取自掃描的深度:{0} 點:{1}"), + KO("앵커 이미지가 한곳에 모여 있어 스케일은 스캔의 깊이에서 구합니다: {0} 점: {1}"), + DE("Die Ankerbilder stehen an einer Stelle, daher kommt der Maßstab aus der Tiefe " + "des Scans: {0} Punkte: {1}"), + FR("Les images d'ancrage sont toutes au même endroit, l'échelle vient donc de la " + "profondeur du scan : {0} Points : {1}"), + ES("Las imágenes de anclaje están en un mismo sitio, así que la escala sale de la " + "profundidad del escaneo: {0} Puntos: {1}"), + PT("As imagens de ancoragem estão num mesmo lugar, então a escala vem da " + "profundidade da varredura: {0} Pontos: {1}"), + IT("Le immagini di ancoraggio sono tutte nello stesso punto, quindi la scala viene " + "dalla profondità della scansione: {0} Punti: {1}"), + NL("De ankerbeelden staan op één plek, dus de schaal komt uit de diepte van de " + "scan: {0} Punten: {1}"), + RU("Опорные изображения стоят в одном месте, поэтому масштаб берётся из глубины " + "скана: {0} Точек: {1}"), + TR("Çapa görüntüleri tek bir yerde duruyor, bu yüzden ölçek taramanın " + "derinliğinden gelir: {0} Nokta: {1}")); + +SS_MSG(model_icp_rejected, + EN("The surface fit moved the model away from its anchors ({0} m); keeping the anchors' fit"), + JA("表面への当てはめでモデルがアンカー画像から離れたため({0} m)、アンカー画像" + "による当てはめを残します"), + ZH_HANS("表面拟合使模型偏离了它的锚定图像({0} m);保留锚定图像的拟合结果"), + ZH_HANT("表面擬合使模型偏離了它的錨定影像({0} m);保留錨定影像的擬合結果"), + KO("표면 맞춤이 모델을 앵커 이미지에서 멀어지게 했습니다({0} m). 앵커 이미지 " + "맞춤을 유지합니다"), + DE("Die Oberflächenanpassung hat das Modell von seinen Ankerbildern weggeschoben " + "({0} m); die Anpassung an die Ankerbilder bleibt"), + FR("L'ajustement sur la surface a éloigné le modèle de ses images d'ancrage ({0} " + "m) ; l'ajustement sur les images d'ancrage est conservé"), + ES("El ajuste a la superficie alejó el modelo de sus imágenes ancla ({0} m); se " + "conserva el ajuste a las imágenes ancla"), + PT("O ajuste à superfície afastou o modelo das suas imagens âncora ({0} m); o " + "ajuste às imagens âncora é mantido"), + IT("L'adattamento alla superficie ha allontanato il modello dalle sue immagini di " + "ancoraggio ({0} m); resta l'adattamento alle immagini di ancoraggio"), + NL("De oppervlaktepassing duwde het model weg van zijn ankerbeelden ({0} m); de " + "passing op de ankerbeelden blijft"), + RU("Подгонка по поверхности увела модель от её опорных изображений ({0} м); " + "оставлена подгонка по опорным изображениям"), + TR("Yüzey uydurması modeli çapa görüntülerinden uzaklaştırdı ({0} m); çapa " + "görüntülerine uydurma korunuyor")); + +SS_MSG(model_transform, + EN("Scale: {0} Rotation: {1} deg Translation: {2} {3} {4}"), + JA("スケール: {0} 回転: {1} 度 平行移動: {2} {3} {4}"), + ZH_HANS("缩放:{0} 旋转:{1} 度 平移:{2} {3} {4}"), + ZH_HANT("縮放:{0} 旋轉:{1} 度 平移:{2} {3} {4}"), + KO("스케일: {0} 회전: {1}도 이동: {2} {3} {4}"), + DE("Maßstab: {0} Rotation: {1} Grad Translation: {2} {3} {4}"), + FR("Échelle : {0} Rotation : {1} degrés Translation : {2} {3} {4}"), + ES("Escala: {0} Rotación: {1} grados Traslación: {2} {3} {4}"), + PT("Escala: {0} Rotação: {1} graus Translação: {2} {3} {4}"), + IT("Scala: {0} Rotazione: {1} gradi Traslazione: {2} {3} {4}"), + NL("Schaal: {0} Rotatie: {1} graden Translatie: {2} {3} {4}"), + RU("Масштаб: {0} Поворот: {1} град. Сдвиг: {2} {3} {4}"), + TR("Ölçek: {0} Dönme: {1} derece Öteleme: {2} {3} {4}")); + +SS_MSG(model_kept, + EN("Model {0} is already in the scan's frame: {1} m from its surface (median)"), + JA("モデル {0} はすでにスキャンの座標系にあります: 表面からの距離 {1} m(中央値)"), + ZH_HANS("模型 {0} 已处于扫描坐标系中:距其表面 {1} m(中位数)"), + ZH_HANT("模型 {0} 已位於掃描座標系中:距其表面 {1} m(中位數)"), + KO("모델 {0}은(는) 이미 스캔 좌표계에 있습니다: 표면에서 {1} m(중앙값)"), + DE("Modell {0} liegt bereits im Bezugssystem des Scans: {1} m von seiner " + "Oberfläche (Median)"), + FR("Le modèle {0} est déjà dans le repère du scan : à {1} m de sa surface " + "(médiane)"), + ES("El modelo {0} ya está en el sistema de referencia del escaneo: a {1} m de su " + "superficie (mediana)"), + PT("O modelo {0} já está no referencial da varredura: a {1} m da sua superfície " + "(mediana)"), + IT("Il modello {0} è già nel sistema di riferimento della scansione: a {1} m dalla " + "sua superficie (mediana)"), + NL("Model {0} staat al in het assenstelsel van de scan: {1} m van het oppervlak " + "(mediaan)"), + RU("Модель {0} уже в системе координат скана: {1} м от его поверхности (медиана)"), + TR("Model {0} zaten taramanın koordinat sisteminde: yüzeyinden {1} m uzakta " + "(medyan)")); + +SS_MSG(model_dropped, + EN("Model {0} has no image with a known pose in the scan and is left out"), + JA("モデル {0} にはスキャン内での姿勢が分かっている画像がないため、除外します"), + ZH_HANS("模型 {0} 没有在扫描中位姿已知的图像,已排除"), + ZH_HANT("模型 {0} 沒有在掃描中位姿已知的影像,已排除"), + KO("모델 {0}에는 스캔 안에서 자세가 알려진 이미지가 없어 제외합니다"), + DE("Modell {0} hat kein Bild mit bekannter Pose im Scan und wird weggelassen"), + FR("Le modèle {0} n'a aucune image dont la pose dans le scan est connue ; il est " + "écarté"), + ES("El modelo {0} no tiene ninguna imagen con pose conocida en el escaneo y se " + "deja fuera"), + PT("O modelo {0} não tem nenhuma imagem com pose conhecida na varredura e fica de " + "fora"), + IT("Il modello {0} non ha immagini con posa nota nella scansione e viene escluso"), + NL("Model {0} heeft geen beeld met een bekende pose in de scan en wordt weggelaten"), + RU("У модели {0} нет изображений с известной позой в скане, она исключена"), + TR("Model {0}, taramada pozu bilinen hiçbir görüntü içermediği için dışarıda " + "bırakıldı")); + +SS_MSG(frames_agree, + EN("The scans' files do not put them in one frame, but their images in the " + "reconstruction do"), + JA("スキャンのファイルからは 1 つの座標系にあると言えませんが、再構成内の画像は" + "同じ座標系にあることを示しています"), + ZH_HANS("扫描文件本身没有表明它们处于同一坐标系,但它们在重建中的图像表明是"), + ZH_HANT("掃描檔案本身沒有表明它們處於同一座標系,但它們在重建中的影像表明是"), + KO("스캔 파일만으로는 같은 좌표계에 있다고 할 수 없지만, 재구성 안의 이미지는 " + "같은 좌표계에 있음을 보여 줍니다"), + DE("Die Dateien der Scans legen sie nicht in ein Bezugssystem, ihre Bilder in der " + "Rekonstruktion aber schon"), + FR("Les fichiers des scans ne les placent pas dans un même repère, mais leurs " + "images dans la reconstruction, si"), + ES("Los archivos de los escaneos no los ponen en un mismo sistema de referencia, " + "pero sus imágenes en la reconstrucción sí"), + PT("Os arquivos das varreduras não as colocam num mesmo referencial, mas as suas " + "imagens na reconstrução, sim"), + IT("I file delle scansioni non le mettono in un unico sistema di riferimento, ma " + "le loro immagini nella ricostruzione sì"), + NL("De bestanden van de scans zetten ze niet in één assenstelsel, hun beelden in de " + "reconstructie wel"), + RU("Файлы сканов не помещают их в одну систему координат, но их изображения в " + "реконструкции помещают"), + TR("Taramaların dosyaları onları tek bir koordinat sistemine koymuyor, ama " + "yeniden oluşturmadaki görüntüleri koyuyor")); + +SS_MSG(frame_reference, + EN("{0}: the frame the other scans are placed in"), + JA("{0}: ほかのスキャンを配置する基準の座標系"), + ZH_HANS("{0}:其他扫描放置到的基准坐标系"), + ZH_HANT("{0}:其他掃描放置到的基準座標系"), + KO("{0}: 다른 스캔을 배치하는 기준 좌표계"), + DE("{0}: das Bezugssystem, in das die anderen Scans gesetzt werden"), + FR("{0} : le repère dans lequel les autres scans sont placés"), + ES("{0}: el sistema de referencia en el que se sitúan los demás escaneos"), + PT("{0}: o referencial em que as outras varreduras são posicionadas"), + IT("{0}: il sistema di riferimento in cui vengono posizionate le altre scansioni"), + NL("{0}: het assenstelsel waarin de andere scans worden geplaatst"), + RU("{0}: система координат, в которую помещаются остальные сканы"), + TR("{0}: diğer taramaların yerleştirildiği koordinat sistemi")); + +SS_MSG(frame_placed, + EN("{0}: placed through its images in the reconstruction Images: {1} " + "Turned: {2} deg Moved: {3} m"), + JA("{0}: 再構成内の画像を通して配置 画像: {1} 回転: {2} 度 移動: {3} m"), + ZH_HANS("{0}:通过它在重建中的图像放置 图像:{1} 旋转:{2} 度 移动:{3} m"), + ZH_HANT("{0}:透過它在重建中的影像放置 影像:{1} 旋轉:{2} 度 移動:{3} m"), + KO("{0}: 재구성 안의 이미지를 통해 배치 이미지: {1} 회전: {2}도 이동: {3} m"), + DE("{0}: über seine Bilder in der Rekonstruktion platziert Bilder: {1} " + "Gedreht: {2} Grad Verschoben: {3} m"), + FR("{0} : placé par ses images dans la reconstruction Images : {1} " + "Rotation : {2} deg Déplacement : {3} m"), + ES("{0}: situado mediante sus imágenes en la reconstrucción Imágenes: {1} " + "Giro: {2} grados Desplazamiento: {3} m"), + PT("{0}: posicionada pelas suas imagens na reconstrução Imagens: {1} " + "Rotação: {2} graus Deslocamento: {3} m"), + IT("{0}: posizionata tramite le sue immagini nella ricostruzione Immagini: {1} " + "Rotazione: {2} gradi Spostamento: {3} m"), + NL("{0}: geplaatst via zijn beelden in de reconstructie Beelden: {1} " + "Gedraaid: {2} graden Verschoven: {3} m"), + RU("{0}: размещён по своим изображениям в реконструкции Изображений: {1} " + "Поворот: {2} град. Сдвиг: {3} м"), + TR("{0}: yeniden oluşturmadaki görüntüleriyle yerleştirildi Görüntü: {1} " + "Dönme: {2} derece Kayma: {3} m")); + +SS_MSG(frame_left_out, + EN("{0}: in a frame of its own, and the reconstruction placed none of its images; " + "left out"), + JA("{0}: 独自の座標系にあり、再構成はその画像を 1 枚も配置できなかったため、除外" + "します"), + ZH_HANS("{0}:处于自己的坐标系中,而重建未能放置它的任何图像,已排除"), + ZH_HANT("{0}:處於自己的座標系中,而重建未能放置它的任何影像,已排除"), + KO("{0}: 자체 좌표계에 있고 재구성이 그 이미지를 하나도 배치하지 못해 제외합니다"), + DE("{0}: in einem eigenen Bezugssystem, und die Rekonstruktion hat keines seiner " + "Bilder platziert; weggelassen"), + FR("{0} : dans un repère à lui, et la reconstruction n'a placé aucune de ses " + "images ; écarté"), + ES("{0}: en un sistema de referencia propio, y la reconstrucción no situó ninguna " + "de sus imágenes; se deja fuera"), + PT("{0}: num referencial próprio, e a reconstrução não posicionou nenhuma das suas " + "imagens; deixada de fora"), + IT("{0}: in un sistema di riferimento proprio, e la ricostruzione non ha " + "posizionato nessuna delle sue immagini; esclusa"), + NL("{0}: in een eigen assenstelsel, en de reconstructie plaatste geen van zijn " + "beelden; weggelaten"), + RU("{0}: в собственной системе координат, а реконструкция не разместила ни одного " + "его изображения; исключён"), + TR("{0}: kendi koordinat sisteminde ve yeniden oluşturma görüntülerinden hiçbirini " + "yerleştiremedi; dışarıda bırakıldı")); + +SS_MSG(placed_by_scanner, + EN("Images the reconstruction did not place, put where the scanner recorded them: {0}"), + JA("再構成で配置できず、スキャナーが記録した位置に置いた画像: {0}"), + ZH_HANS("重建未能定位、改放在扫描仪记录位置的图像:{0}"), + ZH_HANT("重建未能定位、改放在掃描儀記錄位置的影像:{0}"), + KO("재구성이 배치하지 못해 스캐너가 기록한 위치에 둔 이미지: {0}"), + DE("Bilder, die die Rekonstruktion nicht platziert hat, an die vom Scanner " + "aufgezeichnete Stelle gesetzt: {0}"), + FR("Images non placées par la reconstruction, mises là où le scanner les a " + "enregistrées : {0}"), + ES("Imágenes que la reconstrucción no situó, colocadas donde las registró el " + "escáner: {0}"), + PT("Imagens que a reconstrução não posicionou, colocadas onde o scanner as " + "registrou: {0}"), + IT("Immagini non posizionate dalla ricostruzione, messe dove le ha registrate lo " + "scanner: {0}"), + NL("Beelden die de reconstructie niet plaatste, neergezet waar de scanner ze " + "vastlegde: {0}"), + RU("Изображений, не размещённых реконструкцией и поставленных туда, где их " + "записал сканер: {0}"), + TR("Yeniden oluşturmanın yerleştiremediği, tarayıcının kaydettiği yere konan " + "görüntü: {0}")); + +SS_MSG(not_placed, + EN("Images the reconstruction did not place, left out: {0}. The anchors disagree with " + "the fit by {1} m, so the scanner's poses would not line up with it"), + JA("再構成で配置できず、除外した画像: {0}。アンカー画像が当てはめと {1} m " + "ずれているため、スキャナーの姿勢では合いません"), + ZH_HANS("重建未能定位、已排除的图像:{0}。锚定图像与拟合相差 {1} m," + "扫描仪的位姿与之对不齐"), + ZH_HANT("重建未能定位、已排除的影像:{0}。錨定影像與擬合相差 {1} m," + "掃描儀的位姿與之對不齊"), + KO("재구성이 배치하지 못해 제외한 이미지: {0}. 앵커 이미지가 맞춤과 {1} m " + "어긋나 스캐너의 자세로는 맞지 않습니다"), + DE("Bilder, die die Rekonstruktion nicht platziert hat, weggelassen: {0}. Die " + "Ankerbilder weichen um {1} m von der Anpassung ab, die Scannerposen passen nicht dazu"), + FR("Images non placées par la reconstruction, laissées de côté : {0}. Les images " + "d'ancrage s'écartent de l'ajustement de {1} m ; les poses du scanner n'y " + "correspondraient pas"), + ES("Imágenes que la reconstrucción no situó, omitidas: {0}. Las imágenes de anclaje " + "difieren del ajuste en {1} m, así que las poses del escáner no encajarían"), + PT("Imagens que a reconstrução não posicionou, deixadas de fora: {0}. As imagens de " + "ancoragem diferem do ajuste em {1} m, então as poses do scanner não se alinhariam"), + IT("Immagini non posizionate dalla ricostruzione, escluse: {0}. Le immagini di " + "ancoraggio si discostano dall'adattamento di {1} m, le pose dello scanner non " + "combacerebbero"), + NL("Beelden die de reconstructie niet plaatste, weggelaten: {0}. De ankerbeelden wijken " + "{1} m af van de passing, dus de scannerposes zouden er niet op aansluiten"), + RU("Изображения, которые реконструкция не разместила, исключены: {0}. Опорные " + "изображения расходятся с подгонкой на {1} м, позы сканера с ней не совпадут"), + TR("Yeniden oluşturmanın yerleştiremediği görüntüler dışarıda bırakıldı: {0}. Çapa " + "görüntüleri uyumdan {1} m sapıyor, tarayıcının pozları ona oturmaz")); + +SS_MSG(images_off_scan, + EN("Images whose own points mostly miss the scan's surface, left out as misplaced by the " + "reconstruction: {0}"), + JA("自身の点の大半がスキャンの表面から外れており、再構成が誤った位置に置いたとして" + "除外した画像: {0}"), + ZH_HANS("自身的点大多偏离扫描表面、被视为重建放错位置而排除的图像:{0}"), + ZH_HANT("自身的點大多偏離掃描表面、被視為重建放錯位置而排除的影像:{0}"), + KO("자신의 점 대부분이 스캔 표면에서 벗어나 재구성이 잘못 배치한 것으로 보고 " + "제외한 이미지: {0}"), + DE("Bilder, deren eigene Punkte meist neben der Oberfläche des Scans liegen, als von " + "der Rekonstruktion falsch platziert weggelassen: {0}"), + FR("Images dont les propres points tombent surtout hors de la surface du scan, " + "écartées comme mal placées par la reconstruction : {0}"), + ES("Imágenes cuyos propios puntos quedan casi todos fuera de la superficie del " + "escaneo, omitidas por estar mal situadas por la reconstrucción: {0}"), + PT("Imagens cujos próprios pontos ficam quase todos fora da superfície da varredura, " + "deixadas de fora como mal posicionadas pela reconstrução: {0}"), + IT("Immagini i cui punti cadono per lo più fuori dalla superficie della scansione, " + "escluse perché mal posizionate dalla ricostruzione: {0}"), + NL("Beelden waarvan de eigen punten grotendeels naast het oppervlak van de scan " + "liggen, weggelaten als door de reconstructie verkeerd geplaatst: {0}"), + RU("Изображений, чьи собственные точки в основном мимо поверхности скана, исключено " + "как неверно размещённых реконструкцией: {0}"), + TR("Kendi noktalarının çoğu taramanın yüzeyine düşmeyen, yeniden oluşturmanın yanlış " + "yere koyduğu için dışarıda bırakılan görüntü: {0}")); + +SS_MSG(aligned_summary, + EN("Aligned models: {0} of {1} Images: {2}"), + JA("位置合わせしたモデル: {0} / {1} 画像: {2}"), + ZH_HANS("已对齐的模型:{0} / {1} 图像:{2}"), + ZH_HANT("已對齊的模型:{0} / {1} 影像:{2}"), + KO("정렬한 모델: {0} / {1} 이미지: {2}"), + DE("Ausgerichtete Modelle: {0} von {1} Bilder: {2}"), + FR("Modèles alignés : {0} sur {1} Images : {2}"), + ES("Modelos alineados: {0} de {1} Imágenes: {2}"), + PT("Modelos alinhados: {0} de {1} Imagens: {2}"), + IT("Modelli allineati: {0} di {1} Immagini: {2}"), + NL("Uitgelijnde modellen: {0} van {1} Beelden: {2}"), + RU("Совмещено моделей: {0} из {1} Изображений: {2}"), + TR("Hizalanan model: {0} / {1} Görüntü: {2}")); + +SS_MSG(colorizing, + EN("The scan has no colour; its points take theirs from the images"), + JA("スキャンに色がないため、点の色は画像から取ります"), + ZH_HANS("扫描没有颜色;其点的颜色取自图像"), + ZH_HANT("掃描沒有顏色;其點的顏色取自影像"), + KO("스캔에 색이 없어, 점의 색은 이미지에서 가져옵니다"), + DE("Der Scan hat keine Farbe; seine Punkte erhalten ihre aus den Bildern"), + FR("Le scan n'a pas de couleur ; ses points prennent la leur dans les images"), + ES("El escaneo no tiene color; sus puntos toman el suyo de las imágenes"), + PT("A varredura não tem cor; seus pontos tiram a cor das imagens"), + IT("La scansione non ha colore; i suoi punti lo prendono dalle immagini"), + NL("De scan heeft geen kleur; de punten krijgen die uit de beelden"), + RU("Скан не содержит цвета; точки берут его из изображений"), + TR("Taramada renk yok; noktaları rengini görüntülerden alır")); + +// Read back by the dataset screen: {0} stays before {1} with a separator. +SS_MSG(progress_maps, + EN("Depth maps: {0} / {1}"), + JA("深度マップ: {0} / {1}"), + ZH_HANS("深度图:{0} / {1}"), + ZH_HANT("深度圖:{0} / {1}"), + KO("깊이 맵: {0} / {1}"), + DE("Tiefenkarten: {0} / {1}"), + FR("Cartes de profondeur : {0} / {1}"), + ES("Mapas de profundidad: {0} / {1}"), + PT("Mapas de profundidade: {0} / {1}"), + IT("Mappe di profondità: {0} / {1}"), + NL("Dieptekaarten: {0} / {1}"), + RU("Карты глубины: {0} / {1}"), + TR("Derinlik haritaları: {0} / {1}")); + +SS_MSG(seed_summary, + EN("Seed points: {0} from the scan (voxel {1} m), {2} of the reconstruction's where the " + "scan has none"), + JA("初期点: スキャンから {0}(ボクセル {1} m)、スキャンに点がない場所では再構成" + "から {2}"), + ZH_HANS("初始点:来自扫描的 {0}(体素 {1} m),以及扫描没有点之处来自重建的 {2}"), + ZH_HANT("初始點:來自掃描的 {0}(體素 {1} m),以及掃描沒有點之處來自重建的 {2}"), + KO("초기 점: 스캔에서 {0}(복셀 {1} m), 스캔에 점이 없는 곳은 재구성에서 {2}"), + DE("Startpunkte: {0} aus dem Scan (Voxel {1} m), {2} aus der Rekonstruktion, wo " + "der Scan keine hat"), + FR("Points de départ : {0} issus du scan (voxel {1} m), {2} de la reconstruction " + "là où le scan n'en a pas"), + ES("Puntos iniciales: {0} del escaneo (vóxel {1} m), {2} de la reconstrucción " + "donde el escaneo no tiene"), + PT("Pontos iniciais: {0} da varredura (voxel {1} m), {2} da reconstrução onde a " + "varredura não tem"), + IT("Punti iniziali: {0} dalla scansione (voxel {1} m), {2} della ricostruzione " + "dove la scansione non ne ha"), + NL("Beginpunten: {0} uit de scan (voxel {1} m), {2} uit de reconstructie waar de " + "scan er geen heeft"), + RU("Начальных точек: {0} из скана (воксель {1} м), {2} из реконструкции там, где " + "в скане их нет"), + TR("Başlangıç noktası: taramadan {0} (voksel {1} m), taramada nokta olmayan " + "yerlerde yeniden oluşturmadan {2}")); + +SS_MSG(tracks_summary, + EN("Scan points seen by an image: {0}% Observations: {1}"), + JA("画像から見えるスキャン点: {0}% 観測: {1}"), + ZH_HANS("被图像看到的扫描点:{0}% 观测:{1}"), + ZH_HANT("被影像看到的掃描點:{0}% 觀測:{1}"), + KO("이미지에 보이는 스캔 점: {0}% 관측: {1}"), + DE("Von einem Bild gesehene Scanpunkte: {0} % Beobachtungen: {1}"), + FR("Points du scan vus par une image : {0} % Observations : {1}"), + ES("Puntos del escaneo vistos por una imagen: {0} % Observaciones: {1}"), + PT("Pontos da varredura vistos por uma imagem: {0}% Observações: {1}"), + IT("Punti della scansione visti da un'immagine: {0}% Osservazioni: {1}"), + NL("Scanpunten die een beeld ziet: {0}% Waarnemingen: {1}"), + RU("Точек скана, видимых на изображениях: {0}% Наблюдений: {1}"), + TR("Bir görüntünün gördüğü tarama noktaları: %{0} Gözlem: {1}")); + +SS_MSG(done, + EN("Dataset written: {0} Images: {1} Seed points: {2}"), + JA("データセットを書き出しました: {0} 画像: {1} 初期点: {2}"), + ZH_HANS("数据集已写入:{0} 图像:{1} 初始点:{2}"), + ZH_HANT("資料集已寫入:{0} 影像:{1} 初始點:{2}"), + KO("데이터셋을 썼습니다: {0} 이미지: {1} 초기 점: {2}"), + DE("Datensatz geschrieben: {0} Bilder: {1} Startpunkte: {2}"), + FR("Jeu de données écrit : {0} Images : {1} Points de départ : {2}"), + ES("Conjunto de datos escrito: {0} Imágenes: {1} Puntos iniciales: {2}"), + PT("Conjunto de dados gravado: {0} Imagens: {1} Pontos iniciais: {2}"), + IT("Set di dati scritto: {0} Immagini: {1} Punti iniziali: {2}"), + NL("Dataset geschreven: {0} Beelden: {1} Beginpunten: {2}"), + RU("Набор данных записан: {0} Изображений: {1} Начальных точек: {2}"), + TR("Veri kümesi yazıldı: {0} Görüntü: {1} Başlangıç noktası: {2}")); + +SS_MSG(rendered_views, + EN("Views rendered from the scan: {0}"), + JA("スキャンからレンダリングしたビュー: {0}"), + ZH_HANS("从扫描渲染的视图:{0}"), + ZH_HANT("從掃描渲染的視圖:{0}"), + KO("스캔에서 렌더링한 뷰: {0}"), + DE("Aus dem Scan gerenderte Ansichten: {0}"), + FR("Vues rendues depuis le scan : {0}"), + ES("Vistas renderizadas desde el escaneo: {0}"), + PT("Vistas renderizadas a partir da varredura: {0}"), + IT("Viste renderizzate dalla scansione: {0}"), + NL("Uit de scan gerenderde aanzichten: {0}"), + RU("Видов, отрендеренных из скана: {0}"), + TR("Taramadan işlenen görünüm: {0}")); +// =========================================================================== +// The dataset screen +// =========================================================================== + +SS_MSG(step_align, + EN("Align"), JA("位置合わせ"), ZH_HANS("对齐"), ZH_HANT("對齊"), + KO("정렬"), DE("Ausrichtung"), FR("Alignement"), ES("Alineación"), + PT("Alinhamento"), IT("Allineamento"), NL("Uitlijnen"), RU("Совмещение"), + TR("Hizalama")); + +SS_MSG(stage_align, + EN("Aligning with the laser scan"), + JA("レーザースキャンに位置合わせしています"), + ZH_HANS("正在与激光扫描对齐"), + ZH_HANT("正在與雷射掃描對齊"), + KO("레이저 스캔에 정렬하는 중"), + DE("Wird am Laserscan ausgerichtet"), + FR("Alignement sur le scan laser"), + ES("Alineando con el escaneo láser"), + PT("Alinhando com a varredura a laser"), + IT("Allineamento alla scansione laser"), + NL("Uitlijnen op de laserscan"), + RU("Совмещение с лазерным сканом"), + TR("Lazer taramasıyla hizalanıyor")); + +SS_MSG(err_spawn, + EN("Could not start the alignment ({0})"), + JA("位置合わせを起動できませんでした({0})"), + ZH_HANS("无法启动对齐({0})"), + ZH_HANT("無法啟動對齊({0})"), + KO("정렬을 시작하지 못했습니다({0})"), + DE("Die Ausrichtung konnte nicht gestartet werden ({0})"), + FR("Impossible de lancer l'alignement ({0})"), + ES("No se pudo iniciar la alineación ({0})"), + PT("Não foi possível iniciar o alinhamento ({0})"), + IT("Non è stato possibile avviare l'allineamento ({0})"), + NL("De uitlijning kon niet worden gestart ({0})"), + RU("Не удалось запустить совмещение ({0})"), + TR("Hizalama başlatılamadı ({0})")); + +SS_MSG(err_failed, + EN("The alignment with the laser scan failed; the log says why"), + JA("レーザースキャンとの位置合わせに失敗しました。理由はログにあります"), + ZH_HANS("与激光扫描对齐失败;原因见日志"), + ZH_HANT("與雷射掃描對齊失敗;原因見日誌"), + KO("레이저 스캔과의 정렬에 실패했습니다. 이유는 로그에 있습니다"), + DE("Die Ausrichtung am Laserscan ist fehlgeschlagen; das Protokoll nennt den Grund"), + FR("L'alignement sur le scan laser a échoué ; le journal dit pourquoi"), + ES("La alineación con el escaneo láser falló; el registro indica por qué"), + PT("O alinhamento com a varredura a laser falhou; o registro diz por quê"), + IT("L'allineamento alla scansione laser non è riuscito; il registro dice perché"), + NL("De uitlijning op de laserscan is mislukt; het logboek zegt waarom"), + RU("Совмещение с лазерным сканом не удалось; причина -- в журнале"), + TR("Lazer taramasıyla hizalama başarısız oldu; nedeni günlükte")); + +SS_MSG(sfm_failed_scanner_poses, + EN("The reconstruction failed; the scans' photographs keep the poses the scanner recorded"), + JA("再構成に失敗しました。スキャンの写真はスキャナーが記録した姿勢のままにします"), + ZH_HANS("重建失败;扫描中的照片保留扫描仪记录的位姿"), + ZH_HANT("重建失敗;掃描中的照片保留掃描儀記錄的位姿"), + KO("재구성에 실패했습니다. 스캔의 사진은 스캐너가 기록한 자세를 유지합니다"), + DE("Die Rekonstruktion ist fehlgeschlagen; die Fotos der Scans behalten die vom " + "Scanner aufgezeichneten Posen"), + FR("La reconstruction a échoué ; les photos des scans gardent les poses " + "enregistrées par le scanner"), + ES("La reconstrucción falló; las fotografías de los escaneos conservan las poses " + "que registró el escáner"), + PT("A reconstrução falhou; as fotografias das varreduras mantêm as poses " + "registradas pelo scanner"), + IT("La ricostruzione non è riuscita; le fotografie delle scansioni mantengono le " + "pose registrate dallo scanner"), + NL("De reconstructie is mislukt; de foto's van de scans houden de poses die de " + "scanner vastlegde"), + RU("Реконструкция не удалась; фотографии сканов сохраняют позы, записанные " + "сканером"), + TR("Yeniden oluşturma başarısız oldu; taramaların fotoğrafları tarayıcının " + "kaydettiği pozları korur")); + +SS_MSG(add_scan, + EN("Add LiDAR scan..."), + JA("LiDAR スキャンを追加..."), + ZH_HANS("添加 LiDAR 扫描..."), + ZH_HANT("新增 LiDAR 掃描..."), + KO("LiDAR 스캔 추가..."), + DE("LiDAR-Scan hinzufügen..."), + FR("Ajouter un scan LiDAR..."), + ES("Añadir escaneo LiDAR..."), + PT("Adicionar varredura LiDAR..."), + IT("Aggiungi scansione LiDAR..."), + NL("LiDAR-scan toevoegen..."), + RU("Добавить скан LiDAR..."), + TR("LiDAR taraması ekle...")); + +SS_MSG(add_scan_help, + EN("A laser scan of the same place (E57, LAS or PLY). The reconstruction is moved into " + "its frame -- position, rotation and true scale -- and takes its points as seed " + "points and its depth and normals as supervision. An E57 that carries photographs " + "can be the only input."), + JA("同じ場所のレーザースキャン(E57、LAS、PLY)です。再構成はその座標系へ移され" + "(位置、回転、実寸のスケール)、スキャンの点を初期点に、深度と法線を教師信号" + "として使います。写真を含む E57 なら、それだけを入力にできます。"), + ZH_HANS("同一地点的激光扫描(E57、LAS 或 PLY)。重建会被移入它的坐标系——位置、" + "旋转和真实尺度——并以它的点作为初始点、以它的深度与法线作为监督。带照片" + "的 E57 可以作为唯一输入。"), + ZH_HANT("同一地點的雷射掃描(E57、LAS 或 PLY)。重建會被移入它的座標系——位置、" + "旋轉和真實尺度——並以它的點作為初始點、以它的深度與法線作為監督。帶照片" + "的 E57 可以作為唯一輸入。"), + KO("같은 장소의 레이저 스캔(E57, LAS, PLY)입니다. 재구성은 그 좌표계로 " + "옮겨지고(위치, 회전, 실제 스케일), 스캔의 점을 초기 점으로, 깊이와 법선을 " + "지도 학습으로 씁니다. 사진이 든 E57이라면 그것만 입력으로 써도 됩니다."), + DE("Ein Laserscan desselben Orts (E57, LAS oder PLY). Die Rekonstruktion wird in " + "sein Bezugssystem verschoben -- Position, Rotation und echter Maßstab -- und " + "übernimmt seine Punkte als Startpunkte und seine Tiefe und Normalen als " + "Überwachung. Ein E57 mit Fotos kann die einzige Eingabe sein."), + FR("Un scan laser du même lieu (E57, LAS ou PLY). La reconstruction est déplacée " + "dans son repère -- position, rotation et échelle réelle -- et prend ses points " + "comme points de départ, sa profondeur et ses normales comme supervision. Un E57 " + "qui contient des photos peut être la seule entrée."), + ES("Un escaneo láser del mismo lugar (E57, LAS o PLY). La reconstrucción se mueve " + "a su sistema de referencia -- posición, rotación y escala real -- y toma sus " + "puntos como puntos iniciales y su profundidad y normales como supervisión. Un " + "E57 que lleva fotografías puede ser la única entrada."), + PT("Uma varredura a laser do mesmo lugar (E57, LAS ou PLY). A reconstrução é " + "movida para o seu referencial -- posição, rotação e escala real -- e usa os " + "seus pontos como pontos iniciais e a sua profundidade e normais como " + "supervisão. Um E57 com fotografias pode ser a única entrada."), + IT("Una scansione laser dello stesso luogo (E57, LAS o PLY). La ricostruzione " + "viene spostata nel suo sistema di riferimento -- posizione, rotazione e scala " + "reale -- e ne prende i punti come punti iniziali e profondità e normali come " + "supervisione. Un E57 che contiene fotografie può essere l'unico ingresso."), + NL("Een laserscan van dezelfde plek (E57, LAS of PLY). De reconstructie wordt naar " + "het assenstelsel ervan verplaatst -- positie, rotatie en ware schaal -- en " + "neemt de punten over als beginpunten en de diepte en normalen als supervisie. " + "Een E57 met foto's kan de enige invoer zijn."), + RU("Лазерный скан того же места (E57, LAS или PLY). Реконструкция переносится в " + "его систему координат -- положение, поворот и истинный масштаб -- и берёт его " + "точки как начальные, а его глубину и нормали -- для контроля. E57 с " + "фотографиями может быть единственным входом."), + TR("Aynı yerin lazer taraması (E57, LAS veya PLY). Yeniden oluşturma onun " + "koordinat sistemine taşınır -- konum, dönme ve gerçek ölçek -- ve onun " + "noktalarını başlangıç noktası, derinliğini ve normallerini denetim olarak alır. " + "Fotoğraf içeren bir E57 tek girdi olabilir.")); + +SS_MSG(pick_scan, + EN("Choose laser scans"), JA("レーザースキャンを選択"), ZH_HANS("选择激光扫描"), + ZH_HANT("選擇雷射掃描"), KO("레이저 스캔 선택"), DE("Laserscans wählen"), + FR("Choisir des scans laser"), ES("Elegir escaneos láser"), + PT("Escolher varreduras a laser"), IT("Scegli scansioni laser"), + NL("Kies laserscans"), RU("Выберите лазерные сканы"), TR("Lazer taramalarını seç")); + +SS_MSG(scan_row, + EN("Points: {0} Photographs: {1}"), + JA("点: {0} 写真: {1}"), + ZH_HANS("点:{0} 照片:{1}"), + ZH_HANT("點:{0} 照片:{1}"), + KO("점: {0} 사진: {1}"), + DE("Punkte: {0} Fotos: {1}"), + FR("Points : {0} Photos : {1}"), + ES("Puntos: {0} Fotografías: {1}"), + PT("Pontos: {0} Fotografias: {1}"), + IT("Punti: {0} Fotografie: {1}"), + NL("Punten: {0} Foto's: {1}"), + RU("Точек: {0} Фотографий: {1}"), + TR("Nokta: {0} Fotoğraf: {1}")); + +SS_MSG(scan_unreadable, + EN("{0}: {1}"), JA("{0}: {1}"), ZH_HANS("{0}:{1}"), ZH_HANT("{0}:{1}"), + KO("{0}: {1}"), DE("{0}: {1}"), FR("{0} : {1}"), ES("{0}: {1}"), PT("{0}: {1}"), + IT("{0}: {1}"), NL("{0}: {1}"), RU("{0}: {1}"), TR("{0}: {1}")); + +SS_MSG(use_scan_photos, + EN("Reconstruct the scans' photographs with the rest"), + JA("スキャンの写真もほかの画像と一緒に再構成する"), + ZH_HANS("把扫描中的照片与其余图像一起重建"), + ZH_HANT("把掃描中的照片與其餘影像一起重建"), + KO("스캔의 사진도 나머지와 함께 재구성"), + DE("Die Fotos der Scans mit den übrigen rekonstruieren"), + FR("Reconstruire les photos des scans avec le reste"), + ES("Reconstruir las fotografías de los escaneos con el resto"), + PT("Reconstruir as fotografias das varreduras com o restante"), + IT("Ricostruire le fotografie delle scansioni insieme al resto"), + NL("De foto's van de scans met de rest reconstrueren"), + RU("Включить фотографии сканов в реконструкцию"), + TR("Taramaların fotoğraflarını diğerleriyle birlikte yeniden oluştur")); + +SS_MSG(use_scan_photos_help, + EN("Their poses in the scan are what places the reconstruction. Off, or for a scan " + "without photographs, views rendered from where the scanner stood do that instead. " + "Photographs the reconstruction cannot place keep the scanner's pose."), + JA("スキャン内でのこれらの写真の姿勢が、再構成の位置を決めます。オフにした場合や" + "写真のないスキャンでは、スキャナーを据えた位置からレンダリングしたビューが" + "その役を担います。再構成で配置できなかった写真は、スキャナーの姿勢のままに" + "します。"), + ZH_HANS("这些照片在扫描中的位姿决定重建的位置。关闭此项,或扫描没有照片时,改由" + "从扫描仪所在位置渲染的视图来完成这件事。重建无法定位的照片保留扫描仪的" + "位姿。"), + ZH_HANT("這些照片在掃描中的位姿決定重建的位置。關閉此項,或掃描沒有照片時,改由" + "從掃描儀所在位置渲染的視圖來完成這件事。重建無法定位的照片保留掃描儀的" + "位姿。"), + KO("스캔 안에서의 이 사진들의 자세가 재구성의 위치를 정합니다. 끄거나 사진이 " + "없는 스캔이면, 스캐너가 서 있던 곳에서 렌더링한 뷰가 대신 그 일을 합니다. " + "재구성이 배치하지 못한 사진은 스캐너의 자세를 유지합니다."), + DE("Ihre Posen im Scan sind es, die die Rekonstruktion platzieren. Ausgeschaltet, " + "oder bei einem Scan ohne Fotos, übernehmen das vom Scannerstandpunkt gerenderte " + "Ansichten. Fotos, die die Rekonstruktion nicht platzieren kann, behalten die " + "Pose des Scanners."), + FR("Ce sont leurs poses dans le scan qui placent la reconstruction. Désactivé, ou " + "pour un scan sans photos, des vues rendues depuis l'emplacement du scanner s'en " + "chargent. Les photos que la reconstruction ne peut pas placer gardent la pose " + "du scanner."), + ES("Sus poses en el escaneo son las que sitúan la reconstrucción. Desactivado, o " + "con un escaneo sin fotografías, lo hacen en su lugar vistas renderizadas desde " + "donde estuvo el escáner. Las fotografías que la reconstrucción no puede situar " + "conservan la pose del escáner."), + PT("As poses delas na varredura são o que posiciona a reconstrução. Desligado, ou " + "numa varredura sem fotografias, vistas renderizadas de onde o scanner estava " + "fazem isso. As fotografias que a reconstrução não consegue posicionar mantêm a " + "pose do scanner."), + IT("Sono le loro pose nella scansione a posizionare la ricostruzione. Se " + "disattivato, o per una scansione senza fotografie, lo fanno viste renderizzate " + "da dove stava lo scanner. Le fotografie che la ricostruzione non riesce a " + "posizionare mantengono la posa dello scanner."), + NL("Hun poses in de scan bepalen waar de reconstructie komt. Uitgeschakeld, of bij " + "een scan zonder foto's, doen aanzichten die vanaf de plek van de scanner zijn " + "gerenderd dat. Foto's die de reconstructie niet kan plaatsen, houden de pose " + "van de scanner."), + RU("Именно их позы в скане задают положение реконструкции. Если выключено или в " + "скане нет фотографий, это делают виды, отрендеренные с мест, где стоял сканер. " + "Фотографии, которые реконструкция не смогла разместить, сохраняют позу " + "сканера."), + TR("Yeniden oluşturmayı yerleştiren, bu fotoğrafların taramadaki pozlarıdır. " + "Kapalıysa ya da fotoğrafsız bir taramada, bunu tarayıcının durduğu yerden " + "işlenen görünümler yapar. Yeniden oluşturmanın yerleştiremediği fotoğraflar " + "tarayıcının pozunu korur.")); + +SS_MSG(keep_scanner_poses, + EN("Use the scanner's camera poses as they are"), + JA("スキャナーのカメラ姿勢をそのまま使う"), + ZH_HANS("直接使用扫描仪记录的相机位姿"), + ZH_HANT("直接使用掃描儀記錄的相機位姿"), + KO("스캐너의 카메라 자세를 그대로 사용"), + DE("Die Kameraposen des Scanners unverändert verwenden"), + FR("Utiliser telles quelles les poses de caméra du scanner"), + ES("Usar tal cual las poses de cámara del escáner"), + PT("Usar as poses de câmera do scanner como estão"), + IT("Usare così come sono le pose della fotocamera dello scanner"), + NL("De cameraposes van de scanner gebruiken zoals ze zijn"), + RU("Использовать позы камер сканера как есть"), + TR("Tarayıcının kamera pozlarını olduğu gibi kullan")); + +SS_MSG(keep_scanner_poses_help, + EN("The scans' photographs are the only images, so they are placed where the scanner " + "recorded them and no reconstruction runs. Untick to reconstruct them instead: for " + "a scanner whose camera poses are less accurate than a reconstruction would be."), + JA("画像はスキャンの写真だけなので、スキャナーが記録した位置に置き、再構成は" + "行いません。オフにすると代わりに再構成します。スキャナーのカメラ姿勢が再構成" + "より不正確な場合に使います。"), + ZH_HANS("图像只有扫描中的照片,因此直接放在扫描仪记录的位置,不运行重建。取消" + "勾选则改为重建它们:适用于相机位姿不如重建准确的扫描仪。"), + ZH_HANT("影像只有掃描中的照片,因此直接放在掃描儀記錄的位置,不執行重建。取消" + "勾選則改為重建它們:適用於相機位姿不如重建準確的掃描儀。"), + KO("이미지가 스캔의 사진뿐이므로 스캐너가 기록한 위치에 두고 재구성은 하지 " + "않습니다. 끄면 대신 재구성합니다. 카메라 자세가 재구성보다 부정확한 스캐너에 " + "씁니다."), + DE("Die Fotos der Scans sind die einzigen Bilder, also werden sie dort platziert, " + "wo der Scanner sie aufgezeichnet hat, und keine Rekonstruktion läuft. " + "Abschalten, um sie stattdessen zu rekonstruieren: für einen Scanner, dessen " + "Kameraposen ungenauer sind, als eine Rekonstruktion es wäre."), + FR("Les photos des scans sont les seules images : elles sont placées là où le " + "scanner les a enregistrées et aucune reconstruction n'a lieu. Décocher pour les " + "reconstruire à la place : pour un scanner dont les poses de caméra sont moins " + "précises que ne le serait une reconstruction."), + ES("Las fotografías de los escaneos son las únicas imágenes, así que se colocan " + "donde las registró el escáner y no se ejecuta ninguna reconstrucción. " + "Desmárcalo para reconstruirlas en su lugar: para un escáner cuyas poses de " + "cámara son menos precisas de lo que sería una reconstrucción."), + PT("As fotografias das varreduras são as únicas imagens, então são colocadas onde " + "o scanner as registrou e nenhuma reconstrução é executada. Desmarque para " + "reconstruí-las em vez disso: para um scanner cujas poses de câmera são menos " + "precisas do que seria uma reconstrução."), + IT("Le fotografie delle scansioni sono le uniche immagini, quindi vengono messe " + "dove le ha registrate lo scanner e non si esegue alcuna ricostruzione. " + "Deseleziona per ricostruirle invece: per uno scanner le cui pose della " + "fotocamera sono meno precise di quanto sarebbe una ricostruzione."), + NL("De foto's van de scans zijn de enige beelden, dus ze worden neergezet waar de " + "scanner ze vastlegde en er draait geen reconstructie. Uitvinken om ze in " + "plaats daarvan te reconstrueren: voor een scanner waarvan de cameraposes minder " + "nauwkeurig zijn dan een reconstructie zou zijn."), + RU("Фотографии сканов -- единственные изображения, поэтому они ставятся туда, где " + "их записал сканер, и реконструкция не запускается. Снимите флажок, чтобы вместо " + "этого реконструировать их: для сканера, чьи позы камер менее точны, чем была бы " + "реконструкция."), + TR("Tek görüntüler taramaların fotoğrafları olduğundan, tarayıcının kaydettiği " + "yere konur ve yeniden oluşturma çalışmaz. Bunun yerine onları yeniden " + "oluşturmak için işareti kaldırın: kamera pozları bir yeniden oluşturmadan daha " + "az doğru olan bir tarayıcı için.")); + +SS_MSG(scanner_poses_used, + EN("The scans' photographs are the only images: they keep the poses the scanner " + "recorded, and no reconstruction runs"), + JA("画像はスキャンの写真だけです。スキャナーが記録した姿勢のまま使い、再構成は" + "行いません"), + ZH_HANS("图像只有扫描中的照片:保留扫描仪记录的位姿,不运行重建"), + ZH_HANT("影像只有掃描中的照片:保留掃描儀記錄的位姿,不執行重建"), + KO("이미지가 스캔의 사진뿐입니다. 스캐너가 기록한 자세를 유지하고 재구성은 하지 " + "않습니다"), + DE("Die Fotos der Scans sind die einzigen Bilder: Sie behalten die vom Scanner " + "aufgezeichneten Posen, und keine Rekonstruktion läuft"), + FR("Les photos des scans sont les seules images : elles gardent les poses " + "enregistrées par le scanner, et aucune reconstruction n'a lieu"), + ES("Las fotografías de los escaneos son las únicas imágenes: conservan las poses " + "que registró el escáner y no se ejecuta ninguna reconstrucción"), + PT("As fotografias das varreduras são as únicas imagens: mantêm as poses " + "registradas pelo scanner, e nenhuma reconstrução é executada"), + IT("Le fotografie delle scansioni sono le uniche immagini: mantengono le pose " + "registrate dallo scanner e non si esegue alcuna ricostruzione"), + NL("De foto's van de scans zijn de enige beelden: ze houden de poses die de scanner " + "vastlegde, en er draait geen reconstructie"), + RU("Фотографии сканов -- единственные изображения: они сохраняют позы, записанные " + "сканером, и реконструкция не запускается"), + TR("Tek görüntüler taramaların fotoğrafları: tarayıcının kaydettiği pozları " + "korurlar ve yeniden oluşturma çalışmaz")); + +SS_MSG(scan_in_frame, + EN("The dataset is already in the scan's frame"), + JA("データセットはすでにスキャンの座標系にある"), + ZH_HANS("数据集已处于扫描的坐标系中"), + ZH_HANT("資料集已位於掃描的座標系中"), + KO("데이터셋이 이미 스캔 좌표계에 있음"), + DE("Der Datensatz liegt bereits im Bezugssystem des Scans"), + FR("Le jeu de données est déjà dans le repère du scan"), + ES("El conjunto de datos ya está en el sistema de referencia del escaneo"), + PT("O conjunto de dados já está no referencial da varredura"), + IT("Il set di dati è già nel sistema di riferimento della scansione"), + NL("De dataset staat al in het assenstelsel van de scan"), + RU("Набор данных уже в системе координат скана"), + TR("Veri kümesi zaten taramanın koordinat sisteminde")); + +SS_MSG(scan_in_frame_help, + EN("For an export whose cameras the scanner's software placed, such as an XGRIDS " + "one: nothing is moved, the scan only adds seed points, depth and normals."), + JA("XGRIDS のエクスポートのように、スキャナーのソフトウェアがカメラを配置した" + "ものに使います。何も動かさず、スキャンは初期点、深度、法線を加えるだけです。"), + ZH_HANS("用于相机由扫描仪软件定位的导出数据,例如 XGRIDS 的导出:不移动任何东西," + "扫描只添加初始点、深度和法线。"), + ZH_HANT("用於相機由掃描儀軟體定位的匯出資料,例如 XGRIDS 的匯出:不移動任何東西," + "掃描只加入初始點、深度和法線。"), + KO("XGRIDS 내보내기처럼 스캐너 소프트웨어가 카메라를 배치한 내보내기용입니다. " + "아무것도 옮기지 않고, 스캔은 초기 점, 깊이, 법선만 더합니다."), + DE("Für einen Export, dessen Kameras die Software des Scanners platziert hat, etwa " + "einen von XGRIDS: Nichts wird verschoben, der Scan fügt nur Startpunkte, Tiefe " + "und Normalen hinzu."), + FR("Pour un export dont les caméras ont été placées par le logiciel du scanner, " + "comme un export XGRIDS : rien n'est déplacé, le scan ajoute seulement des " + "points de départ, la profondeur et les normales."), + ES("Para una exportación cuyas cámaras situó el software del escáner, como una de " + "XGRIDS: no se mueve nada, el escaneo solo añade puntos iniciales, profundidad y " + "normales."), + PT("Para uma exportação cujas câmeras o software do scanner posicionou, como uma " + "da XGRIDS: nada é movido, a varredura só acrescenta pontos iniciais, " + "profundidade e normais."), + IT("Per un'esportazione le cui fotocamere sono state posizionate dal software " + "dello scanner, come una di XGRIDS: non si sposta nulla, la scansione aggiunge " + "solo punti iniziali, profondità e normali."), + NL("Voor een export waarvan de software van de scanner de camera's plaatste, zoals " + "een van XGRIDS: er wordt niets verplaatst, de scan voegt alleen beginpunten, " + "diepte en normalen toe."), + RU("Для экспорта, камеры которого расставило ПО сканера, например экспорта " + "XGRIDS: ничего не сдвигается, скан лишь добавляет начальные точки, глубину и " + "нормали."), + TR("Kameralarını tarayıcının yazılımının yerleştirdiği bir dışa aktarım için, " + "örneğin bir XGRIDS dışa aktarımı: hiçbir şey taşınmaz, tarama yalnızca " + "başlangıç noktaları, derinlik ve normaller ekler.")); + +SS_MSG(scan_needs_builtin, + EN("Laser scans need the built-in reconstruction"), + JA("レーザースキャンには内蔵の再構成が必要です"), + ZH_HANS("激光扫描需要使用内置重建"), + ZH_HANT("雷射掃描需要使用內建重建"), + KO("레이저 스캔에는 내장 재구성이 필요합니다"), + DE("Laserscans brauchen die eingebaute Rekonstruktion"), + FR("Les scans laser demandent la reconstruction intégrée"), + ES("Los escaneos láser requieren la reconstrucción integrada"), + PT("As varreduras a laser exigem a reconstrução integrada"), + IT("Le scansioni laser richiedono la ricostruzione integrata"), + NL("Laserscans vereisen de ingebouwde reconstructie"), + RU("Для лазерных сканов нужна встроенная реконструкция"), + TR("Lazer taramaları yerleşik yeniden oluşturmayı gerektirir")); + +SS_MSG(xgrids_found, + EN("An XGRIDS export: its fisheye reconstruction and its point cloud were added, and its " + "masks are read the other way round."), + JA("XGRIDS のエクスポートです。魚眼の再構成と点群を追加し、マスクは白黒を逆に" + "して読みます。"), + ZH_HANS("这是 XGRIDS 导出:已添加它的鱼眼重建和点云,它的蒙版按反相读取。"), + ZH_HANT("這是 XGRIDS 匯出:已加入它的魚眼重建和點雲,它的遮罩按反相讀取。"), + KO("XGRIDS 내보내기입니다. 어안 재구성과 포인트 클라우드를 추가했고, 마스크는 " + "반대로 읽습니다."), + DE("Ein XGRIDS-Export: Seine Fischaugen-Rekonstruktion und seine Punktwolke wurden " + "hinzugefügt, und seine Masken werden umgekehrt gelesen."), + FR("Un export XGRIDS : sa reconstruction fisheye et son nuage de points ont été " + "ajoutés, et ses masques sont lus à l'inverse."), + ES("Una exportación de XGRIDS: se añadieron su reconstrucción de ojo de pez y su " + "nube de puntos, y sus máscaras se leen al revés."), + PT("Uma exportação da XGRIDS: a reconstrução olho de peixe e a nuvem de pontos " + "foram adicionadas, e as máscaras são lidas ao contrário."), + IT("Un'esportazione XGRIDS: sono state aggiunte la sua ricostruzione fisheye e la " + "sua nuvola di punti, e le sue maschere vengono lette al contrario."), + NL("Een XGRIDS-export: de fisheye-reconstructie en de puntenwolk zijn toegevoegd, " + "en de maskers worden omgekeerd gelezen."), + RU("Экспорт XGRIDS: добавлены его реконструкция «рыбий глаз» и облако точек, а " + "маски читаются наоборот."), + TR("Bir XGRIDS dışa aktarımı: balıkgözü yeniden oluşturması ve nokta bulutu " + "eklendi, maskeleri de ters okunuyor.")); + +SS_MSG(scan_replaces_geometry, + EN("Depth and normals come from the laser scan, not from a model"), + JA("深度と法線は、モデルではなくレーザースキャンから得ます"), + ZH_HANS("深度与法线来自激光扫描,而不是模型"), + ZH_HANT("深度與法線來自雷射掃描,而不是模型"), + KO("깊이와 법선은 모델이 아니라 레이저 스캔에서 얻습니다"), + DE("Tiefe und Normalen kommen aus dem Laserscan, nicht aus einem Modell"), + FR("La profondeur et les normales viennent du scan laser, pas d'un modèle"), + ES("La profundidad y las normales vienen del escaneo láser, no de un modelo"), + PT("A profundidade e as normais vêm da varredura a laser, não de um modelo"), + IT("Profondità e normali vengono dalla scansione laser, non da un modello"), + NL("Diepte en normalen komen uit de laserscan, niet uit een model"), + RU("Глубина и нормали берутся из лазерного скана, а не из модели"), + TR("Derinlik ve normaller bir modelden değil, lazer taramasından gelir")); + +SS_MSG(extracted, + EN("Images taken from the scan: {0} Pinhole: {1} Panorama: {2}"), + JA("スキャンから取り出した画像: {0} ピンホール: {1} パノラマ: {2}"), + ZH_HANS("从扫描取出的图像:{0} 针孔:{1} 全景:{2}"), + ZH_HANT("從掃描取出的影像:{0} 針孔:{1} 全景:{2}"), + KO("스캔에서 꺼낸 이미지: {0} 핀홀: {1} 파노라마: {2}"), + DE("Aus dem Scan entnommene Bilder: {0} Lochkamera: {1} Panorama: {2}"), + FR("Images tirées du scan : {0} Sténopé : {1} Panorama : {2}"), + ES("Imágenes extraídas del escaneo: {0} Estenopeicas: {1} Panorámicas: {2}"), + PT("Imagens tiradas da varredura: {0} Pinhole: {1} Panorama: {2}"), + IT("Immagini estratte dalla scansione: {0} Stenopeiche: {1} Panorami: {2}"), + NL("Uit de scan gehaalde beelden: {0} Pinhole: {1} Panorama: {2}"), + RU("Изображений извлечено из скана: {0} Обскура: {1} Панорамы: {2}"), + TR("Taramadan alınan görüntü: {0} İğne deliği: {1} Panorama: {2}")); + +} // namespace lidar +} // namespace msg +} // namespace i18n +} // namespace spirula + +#include "i18n/EndCatalog.h" diff --git a/tools/gui_mcp.py b/tools/gui_mcp.py index ff47dbb07..3a78c70bf 100755 --- a/tools/gui_mcp.py +++ b/tools/gui_mcp.py @@ -148,6 +148,16 @@ "properties": {"frames": {"type": "integer"}}, }, }, + { + "name": "gui_drop", + "description": "Drop files or folders on the window, as a drag from a " + "file manager would.", + "inputSchema": { + "type": "object", + "properties": {"paths": {"type": "array", "items": {"type": "string"}}}, + "required": ["paths"], + }, + }, { "name": "gui_screenshot", "description": "The current framebuffer, as an image.", @@ -234,6 +244,7 @@ class Args: point_args(a), value=a["value"], enter="1" if a.get("enter", True) else "0")), "gui_wait": ("/ui/wait", lambda a: {"frames": a.get("frames", 4)}), + "gui_drop": ("/ui/drop", lambda a: {"paths": "\n".join(a["paths"])}), } if name not in routes: return [{"type": "text", "text": "unknown tool: " + name}], True diff --git a/tools/guictl.py b/tools/guictl.py index 53319c798..e0c8ed927 100755 --- a/tools/guictl.py +++ b/tools/guictl.py @@ -184,6 +184,11 @@ def cmd_text(args): print(json.dumps(call("/ui/text", p))) +def cmd_drop(args): + paths = "\n".join(os.path.abspath(p) for p in args.paths) + print(json.dumps(call("/ui/drop", {"paths": paths}))) + + def cmd_wait(args): print(json.dumps(call("/ui/wait", {"frames": args.frames}))) @@ -280,6 +285,10 @@ def build_parser(): p.add_argument("--no-enter", action="store_true") p.set_defaults(func=cmd_text) + p = sub.add_parser("drop", help="drop files or folders on the window") + p.add_argument("paths", nargs="+") + p.set_defaults(func=cmd_drop) + p = sub.add_parser("wait", help="let N frames pass") p.add_argument("--frames", type=int, default=4) p.set_defaults(func=cmd_wait)