File: lasreader.py

package info (click to toggle)
python-laspy 2.5.4-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid, trixie
  • size: 3,928 kB
  • sloc: python: 9,065; makefile: 20
file content (374 lines) | stat: -rw-r--r-- 13,509 bytes parent folder | download
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
import io
import logging
from typing import BinaryIO, Iterable, Optional, Union

from . import errors
from ._compression.backend import LazBackend
from ._compression.lazrsbackend import LazrsPointReader
from ._pointreader import IPointReader
from .compression import DecompressionSelection
from .header import LasHeader
from .lasdata import LasData
from .point import record
from .vlrs.vlrlist import VLRList

logger = logging.getLogger(__name__)


class LasReader:
    """The reader class handles LAS and LAZ via one of the supported backend"""

    def __init__(
        self,
        source: BinaryIO,
        closefd: bool = True,
        laz_backend: Optional[Union[LazBackend, Iterable[LazBackend]]] = None,
        read_evlrs: bool = True,
        decompression_selection: DecompressionSelection = DecompressionSelection.all(),
    ):
        """
        Initialize the LasReader

        Parameters
        ----------
        source: file_object
        closefd: bool, default True
        laz_backend: LazBackend or list of LazBackend, optional
        read_evlrs: bool, default True
            only applies to __init__ phase, and for files
            that support evlrs
        decompression_selection: optional, DecompressionSelection
            Selection of fields to decompress, only works form point format >= 6 <= 10
            Ignored on other point formats

        .. versionadded:: 2.4
            The ``read_evlrs`` and ``decompression_selection`` parameters.
        """
        self.closefd = closefd
        if laz_backend is None:
            laz_backend = LazBackend.detect_available()
        self.laz_backend = laz_backend
        self.header = LasHeader.read_from(source, read_evlrs=read_evlrs)
        self.decompression_selection = decompression_selection

        # The point source is lazily instanciated.
        # Because some reader implementation may
        # read informations that require to seek towards the end of
        # the file (eg: chunk table), and we prefer to limit opening
        # to reading the header
        self._point_source: Optional["IPointReader"] = None
        self._source = source

        self.points_read = 0

    @property
    def evlrs(self) -> Optional[VLRList]:
        return self.header.evlrs

    @evlrs.setter
    def evlrs(self, evlrs: VLRList) -> None:
        self.header.evlrs = evlrs

    @property
    def point_source(self) -> "IPointReader":
        if self._point_source is None:
            self._point_source = self._create_point_source(self._source)
        return self._point_source

    def read_points(self, n: int) -> record.ScaleAwarePointRecord:
        """Read n points from the file


        Will only read as many points as the header advertise.
        That is, if you ask to read 50 points and there are only 45 points left
        this function will only read 45 points.

        If there are no points left to read, returns an empty point record.

        Parameters
        ----------
        n: The number of points to read
           if n is less than 0, this function will read the remaining points
        """
        points_left = self.header.point_count - self.points_read
        if points_left <= 0:
            return record.ScaleAwarePointRecord.empty(
                self.header.point_format,
                self.header.scales,
                self.header.offsets,
            )

        if n < 0:
            n = points_left
        else:
            n = min(n, points_left)

        r = record.PackedPointRecord.from_buffer(
            self.point_source.read_n_points(n), self.header.point_format
        )
        if len(r) < n:
            logger.error(f"Could only read {len(r)} of the requested {n} points")

        points = record.ScaleAwarePointRecord(
            r.array, r.point_format, self.header.scales, self.header.offsets
        )

        self.points_read += n
        return points

    def read(self) -> LasData:
        """
        Reads all the points that are not read and returns a LasData object

        This will also read EVLRS

        """
        points = self.read_points(-1)
        las_data = LasData(header=self.header, points=points)

        shall_read_evlr = (
            self.header.version.minor >= 4
            and self.header.number_of_evlrs > 0
            and self.evlrs is None
        )
        if shall_read_evlr:
            # If we have to read evlrs by now, it either means:
            #   - the user asked for them not to be read during the opening phase.
            #   - and/or the stream is not seekable, thus they could not be read during opening phase
            #
            if self.point_source.source.seekable():
                self.read_evlrs()
            else:
                # In that case we are still going to
                # try to read the evlrs by relying on the fact that they should generally be
                # right after the last point, which is where we are now.
                if self.header.are_points_compressed:
                    if not isinstance(self.point_source, LazrsPointReader):
                        raise errors.LaspyException(
                            "Reading EVLRs from a LAZ in a non-seekable stream "
                            "can only be done with lazrs backend"
                        )
                    # Few things: If the stream is non seekable, only a LazrsPointReader
                    # could have been created (parallel requires ability to seek)
                    #
                    # Also, to work, the next lines of code assumes that:
                    # 1) We actually are just after the last point
                    # 2) The chunk table _starts_ just after the last point
                    # 3) The first EVLR starts just after the chunk table
                    # These assumptions should be fine for most of the cases
                    # and non seekable sources are probably not that common
                    _ = self.point_source.read_chunk_table_only()

                    # Since the LazrsDecompressor uses a buffered reader
                    # the python file object's position is not at the position we
                    # think it is.
                    # So we have to read data from the decompressor's
                    # buffered stream.
                    class LocalReader:
                        def __init__(self, source: LazrsPointReader) -> None:
                            self.source = source

                        def read(self, n: int) -> bytes:
                            return self.source.read_raw_bytes(n)

                    self.evlrs = VLRList.read_from(
                        LocalReader(self.point_source),
                        self.header.number_of_evlrs,
                        extended=True,
                    )
                else:
                    # For this to work, we assume that the first evlr
                    # start just after the last point
                    self.header.evlrs = VLRList.read_from(
                        self.point_source.source,
                        self.header.number_of_evlrs,
                        extended=True,
                    )
        return las_data

    def seek(self, pos: int, whence: int = io.SEEK_SET) -> int:
        """Seeks to the start of the point at the given pos

        Parameters
        ----------
        pos: index of the point to seek to
        whence: optional, controls how the pos parameter is interpreted:
                io.SEEK_SET: (default) pos is the index of the point from the beginning
                io.SEEK_CUR: pos is the point_index relative to the point_index of the last point read
                io.SEEK_END: pos is the point_index relative to last point
        Returns
        -------
        The index of the point the reader seeked to, relative to the first point
        """
        if whence == io.SEEK_SET:
            allowed_range = range(0, self.header.point_count)
            point_index = pos
        elif whence == io.SEEK_CUR:
            allowed_range = range(
                -self.points_read, self.header.point_count - self.points_read
            )
            point_index = self.points_read + pos
        elif whence == io.SEEK_END:
            allowed_range = range(-self.header.point_count, 0)
            point_index = self.header.point_count + pos
        else:
            raise ValueError(f"Invalid value for whence: {whence}")

        if pos not in allowed_range:
            whence_str = ["start", "current point", "end"]
            raise IndexError(
                f"When seeking from the {whence_str[whence]}, pos must be in {allowed_range}"
            )

        self.point_source.seek(point_index)
        self.points_read = point_index
        return point_index

    def chunk_iterator(self, points_per_iteration: int) -> "PointChunkIterator":
        """Returns an iterator, that will read points by chunks
        of the requested size

        :param points_per_iteration: number of points to be read with each iteration
        :return:
        """
        return PointChunkIterator(self, points_per_iteration)

    def read_evlrs(self):
        self.header.read_evlrs(self._source)

    def close(self) -> None:
        """closes the file object used by the reader"""

        if self.closefd:
            # We check the actual source,
            # to avoid creating it, just to close it
            if self._point_source is not None:
                self._point_source.close()
            else:
                self._source.close()

    def _create_laz_backend(self, source) -> IPointReader:
        """Creates the laz backend to use according to `self.laz_backend`.

        If `self.laz_backend` contains mutilple backends, this functions will
        try to create them in order until one of them is successfully constructed.

        If none could be constructed, the error of the last backend tried wil be raised
        """
        if not self.laz_backend:
            raise errors.LaspyException(
                "No LazBackend selected, cannot decompress data"
            )

        try:
            backends = iter(self.laz_backend)
        except TypeError:
            backends = (self.laz_backend,)

        last_error: Optional[Exception] = None
        for backend in backends:
            try:
                if not backend.is_available():
                    raise errors.LaspyException(f"The '{backend}' is not available")

                reader: IPointReader = backend.create_reader(
                    source,
                    self.header,
                    decompression_selection=self.decompression_selection,
                )
            except Exception as e:
                last_error = e
                logger.error(e)
            else:
                self.header.vlrs.pop(self.header.vlrs.index("LasZipVlr"))
                return reader

        raise last_error

    def _create_point_source(self, source) -> IPointReader:
        if self.header.point_count > 0:
            if self.header.are_points_compressed:
                point_source = self._create_laz_backend(source)
                if point_source is None:
                    raise errors.LaspyException(
                        "Data is compressed, but no LazBacked could be initialized"
                    )
                return point_source
            else:
                return UncompressedPointReader(source, self.header)
        else:
            return EmptyPointReader()

    def __enter__(self):
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        self.close()


class PointChunkIterator:
    def __init__(self, reader: LasReader, points_per_iteration: int) -> None:
        self.reader = reader
        self.points_per_iteration = points_per_iteration

    def __next__(self) -> record.ScaleAwarePointRecord:
        points = self.reader.read_points(self.points_per_iteration)
        if not points:
            raise StopIteration
        return points

    def __iter__(self) -> "PointChunkIterator":
        return self


class UncompressedPointReader(IPointReader):
    """Implementation of IPointReader for the simple uncompressed case"""

    def __init__(self, source, header: LasHeader) -> None:
        self._source = source
        self.header = header

    @property
    def source(self):
        return self._source

    def read_n_points(self, n: int) -> bytearray:
        try:
            readinto = self.source.readinto
        except AttributeError:
            data = bytearray(self.source.read(n * self.header.point_format.size))
        else:
            data = bytearray(n * self.header.point_format.size)
            num_read = readinto(data)
            if num_read < len(data):
                data = data[:num_read]

        return data

    def seek(self, point_index: int) -> None:
        self.source.seek(
            self.header.offset_to_point_data
            + (point_index * self.header.point_format.size)
        )

    def close(self):
        self.source.close()


class EmptyPointReader(IPointReader):
    """Does nothing but returning empty bytes.
    Used to make sure we handle empty LAS files in a robust way.
    """

    @property
    def source(self):
        pass

    def read_n_points(self, n: int) -> bytearray:
        return bytearray()

    def close(self) -> None:
        pass

    def seek(self, point_index: int) -> None:
        pass