| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| from itertools import islice as _islice |
|
|
| import numpy as _np |
| from sys import byteorder as _byteorder |
|
|
|
|
| try: |
| _range = xrange |
| except NameError: |
| _range = range |
|
|
|
|
| |
| _data_type_relation = [ |
| ('int8', 'i1'), |
| ('char', 'i1'), |
| ('uint8', 'u1'), |
| ('uchar', 'b1'), |
| ('uchar', 'u1'), |
| ('int16', 'i2'), |
| ('short', 'i2'), |
| ('uint16', 'u2'), |
| ('ushort', 'u2'), |
| ('int32', 'i4'), |
| ('int', 'i4'), |
| ('uint32', 'u4'), |
| ('uint', 'u4'), |
| ('float32', 'f4'), |
| ('float', 'f4'), |
| ('float64', 'f8'), |
| ('double', 'f8') |
| ] |
|
|
| _data_types = dict(_data_type_relation) |
| _data_type_reverse = dict((b, a) for (a, b) in _data_type_relation) |
|
|
| _types_list = [] |
| _types_set = set() |
| for (_a, _b) in _data_type_relation: |
| if _a not in _types_set: |
| _types_list.append(_a) |
| _types_set.add(_a) |
| if _b not in _types_set: |
| _types_list.append(_b) |
| _types_set.add(_b) |
|
|
|
|
| _byte_order_map = { |
| 'ascii': '=', |
| 'binary_little_endian': '<', |
| 'binary_big_endian': '>' |
| } |
|
|
| _byte_order_reverse = { |
| '<': 'binary_little_endian', |
| '>': 'binary_big_endian' |
| } |
|
|
| _native_byte_order = {'little': '<', 'big': '>'}[_byteorder] |
|
|
|
|
| def _lookup_type(type_str): |
| if type_str not in _data_type_reverse: |
| try: |
| type_str = _data_types[type_str] |
| except KeyError: |
| raise ValueError("field type %r not in %r" % |
| (type_str, _types_list)) |
|
|
| return _data_type_reverse[type_str] |
|
|
|
|
| def _split_line(line, n): |
| fields = line.split(None, n) |
| if len(fields) == n: |
| fields.append('') |
|
|
| assert len(fields) == n + 1 |
|
|
| return fields |
|
|
|
|
| def make2d(array, cols=None, dtype=None): |
| ''' |
| Make a 2D array from an array of arrays. The `cols' and `dtype' |
| arguments can be omitted if the array is not empty. |
| |
| ''' |
| if (cols is None or dtype is None) and not len(array): |
| raise RuntimeError("cols and dtype must be specified for empty " |
| "array") |
|
|
| if cols is None: |
| cols = len(array[0]) |
|
|
| if dtype is None: |
| dtype = array[0].dtype |
|
|
| return _np.fromiter(array, [('_', dtype, (cols,))], |
| count=len(array))['_'] |
|
|
|
|
| class PlyParseError(Exception): |
|
|
| ''' |
| Raised when a PLY file cannot be parsed. |
| |
| The attributes `element', `row', `property', and `message' give |
| additional information. |
| |
| ''' |
|
|
| def __init__(self, message, element=None, row=None, prop=None): |
| self.message = message |
| self.element = element |
| self.row = row |
| self.prop = prop |
|
|
| s = '' |
| if self.element: |
| s += 'element %r: ' % self.element.name |
| if self.row is not None: |
| s += 'row %d: ' % self.row |
| if self.prop: |
| s += 'property %r: ' % self.prop.name |
| s += self.message |
|
|
| Exception.__init__(self, s) |
|
|
| def __repr__(self): |
| return ('PlyParseError(%r, element=%r, row=%r, prop=%r)' % |
| self.message, self.element, self.row, self.prop) |
|
|
|
|
| class PlyData(object): |
|
|
| ''' |
| PLY file header and data. |
| |
| A PlyData instance is created in one of two ways: by the static |
| method PlyData.read (to read a PLY file), or directly from __init__ |
| given a sequence of elements (which can then be written to a PLY |
| file). |
| |
| ''' |
|
|
| def __init__(self, elements=[], text=False, byte_order='=', |
| comments=[], obj_info=[]): |
| ''' |
| elements: sequence of PlyElement instances. |
| |
| text: whether the resulting PLY file will be text (True) or |
| binary (False). |
| |
| byte_order: '<' for little-endian, '>' for big-endian, or '=' |
| for native. This is only relevant if `text' is False. |
| |
| comments: sequence of strings that will be placed in the header |
| between the 'ply' and 'format ...' lines. |
| |
| obj_info: like comments, but will be placed in the header with |
| "obj_info ..." instead of "comment ...". |
| |
| ''' |
| if byte_order == '=' and not text: |
| byte_order = _native_byte_order |
|
|
| self.byte_order = byte_order |
| self.text = text |
|
|
| self.comments = list(comments) |
| self.obj_info = list(obj_info) |
| self.elements = elements |
|
|
| def _get_elements(self): |
| return self._elements |
|
|
| def _set_elements(self, elements): |
| self._elements = tuple(elements) |
| self._index() |
|
|
| elements = property(_get_elements, _set_elements) |
|
|
| def _get_byte_order(self): |
| return self._byte_order |
|
|
| def _set_byte_order(self, byte_order): |
| if byte_order not in ['<', '>', '=']: |
| raise ValueError("byte order must be '<', '>', or '='") |
|
|
| self._byte_order = byte_order |
|
|
| byte_order = property(_get_byte_order, _set_byte_order) |
|
|
| def _index(self): |
| self._element_lookup = dict((elt.name, elt) for elt in |
| self._elements) |
| if len(self._element_lookup) != len(self._elements): |
| raise ValueError("two elements with same name") |
|
|
| @staticmethod |
| def _parse_header(stream): |
| ''' |
| Parse a PLY header from a readable file-like stream. |
| |
| ''' |
| lines = [] |
| comments = {'comment': [], 'obj_info': []} |
| while True: |
| line = stream.readline().decode('ascii').strip() |
| fields = _split_line(line, 1) |
|
|
| if fields[0] == 'end_header': |
| break |
|
|
| elif fields[0] in comments.keys(): |
| lines.append(fields) |
| else: |
| lines.append(line.split()) |
|
|
| a = 0 |
| if lines[a] != ['ply']: |
| raise PlyParseError("expected 'ply'") |
|
|
| a += 1 |
| while lines[a][0] in comments.keys(): |
| comments[lines[a][0]].append(lines[a][1]) |
| a += 1 |
|
|
| if lines[a][0] != 'format': |
| raise PlyParseError("expected 'format'") |
|
|
| if lines[a][2] != '1.0': |
| raise PlyParseError("expected version '1.0'") |
|
|
| if len(lines[a]) != 3: |
| raise PlyParseError("too many fields after 'format'") |
|
|
| fmt = lines[a][1] |
|
|
| if fmt not in _byte_order_map: |
| raise PlyParseError("don't understand format %r" % fmt) |
|
|
| byte_order = _byte_order_map[fmt] |
| text = fmt == 'ascii' |
|
|
| a += 1 |
| while a < len(lines) and lines[a][0] in comments.keys(): |
| comments[lines[a][0]].append(lines[a][1]) |
| a += 1 |
|
|
| return PlyData(PlyElement._parse_multi(lines[a:]), |
| text, byte_order, |
| comments['comment'], comments['obj_info']) |
|
|
| @staticmethod |
| def read(stream): |
| ''' |
| Read PLY data from a readable file-like object or filename. |
| |
| ''' |
| must_close = False |
| try: |
| if isinstance(stream, str): |
| stream = open(stream, 'rb') |
| must_close = True |
|
|
| data = PlyData._parse_header(stream) |
|
|
| for elt in data: |
| elt._read(stream, data.text, data.byte_order) |
|
|
| finally: |
| if must_close: |
| stream.close() |
|
|
| return data |
|
|
| def write(self, stream): |
| ''' |
| Write PLY data to a writeable file-like object or filename. |
| |
| ''' |
| must_close = False |
| try: |
| if isinstance(stream, str): |
| stream = open(stream, 'wb') |
| must_close = True |
|
|
| stream.write(self.header.encode('ascii')) |
| stream.write(b'\r\n') |
|
|
| for elt in self: |
| elt._write(stream, self.text, self.byte_order) |
|
|
| finally: |
| if must_close: |
| stream.close() |
|
|
| @property |
| def header(self): |
| ''' |
| Provide PLY-formatted metadata for the instance. |
| |
| ''' |
| lines = ['ply'] |
|
|
| if self.text: |
| lines.append('format ascii 1.0') |
| else: |
| lines.append('format ' + |
| _byte_order_reverse[self.byte_order] + |
| ' 1.0') |
|
|
| |
| |
| for c in self.comments: |
| lines.append('comment ' + c) |
|
|
| for c in self.obj_info: |
| lines.append('obj_info ' + c) |
|
|
| lines.extend(elt.header for elt in self.elements) |
| lines.append('end_header') |
| return '\r\n'.join(lines) |
|
|
| def __iter__(self): |
| return iter(self.elements) |
|
|
| def __len__(self): |
| return len(self.elements) |
|
|
| def __contains__(self, name): |
| return name in self._element_lookup |
|
|
| def __getitem__(self, name): |
| return self._element_lookup[name] |
|
|
| def __str__(self): |
| return self.header |
|
|
| def __repr__(self): |
| return ('PlyData(%r, text=%r, byte_order=%r, ' |
| 'comments=%r, obj_info=%r)' % |
| (self.elements, self.text, self.byte_order, |
| self.comments, self.obj_info)) |
|
|
|
|
| class PlyElement(object): |
|
|
| ''' |
| PLY file element. |
| |
| A client of this library doesn't normally need to instantiate this |
| directly, so the following is only for the sake of documenting the |
| internals. |
| |
| Creating a PlyElement instance is generally done in one of two ways: |
| as a byproduct of PlyData.read (when reading a PLY file) and by |
| PlyElement.describe (before writing a PLY file). |
| |
| ''' |
|
|
| def __init__(self, name, properties, count, comments=[]): |
| ''' |
| This is not part of the public interface. The preferred methods |
| of obtaining PlyElement instances are PlyData.read (to read from |
| a file) and PlyElement.describe (to construct from a numpy |
| array). |
| |
| ''' |
| self._name = str(name) |
| self._check_name() |
| self._count = count |
|
|
| self._properties = tuple(properties) |
| self._index() |
|
|
| self.comments = list(comments) |
|
|
| self._have_list = any(isinstance(p, PlyListProperty) |
| for p in self.properties) |
|
|
| @property |
| def count(self): |
| return self._count |
|
|
| def _get_data(self): |
| return self._data |
|
|
| def _set_data(self, data): |
| self._data = data |
| self._count = len(data) |
| self._check_sanity() |
|
|
| data = property(_get_data, _set_data) |
|
|
| def _check_sanity(self): |
| for prop in self.properties: |
| if prop.name not in self._data.dtype.fields: |
| raise ValueError("dangling property %r" % prop.name) |
|
|
| def _get_properties(self): |
| return self._properties |
|
|
| def _set_properties(self, properties): |
| self._properties = tuple(properties) |
| self._check_sanity() |
| self._index() |
|
|
| properties = property(_get_properties, _set_properties) |
|
|
| def _index(self): |
| self._property_lookup = dict((prop.name, prop) |
| for prop in self._properties) |
| if len(self._property_lookup) != len(self._properties): |
| raise ValueError("two properties with same name") |
|
|
| def ply_property(self, name): |
| return self._property_lookup[name] |
|
|
| @property |
| def name(self): |
| return self._name |
|
|
| def _check_name(self): |
| if any(c.isspace() for c in self._name): |
| msg = "element name %r contains spaces" % self._name |
| raise ValueError(msg) |
|
|
| def dtype(self, byte_order='='): |
| ''' |
| Return the numpy dtype of the in-memory representation of the |
| data. (If there are no list properties, and the PLY format is |
| binary, then this also accurately describes the on-disk |
| representation of the element.) |
| |
| ''' |
| return [(prop.name, prop.dtype(byte_order)) |
| for prop in self.properties] |
|
|
| @staticmethod |
| def _parse_multi(header_lines): |
| ''' |
| Parse a list of PLY element definitions. |
| |
| ''' |
| elements = [] |
| while header_lines: |
| (elt, header_lines) = PlyElement._parse_one(header_lines) |
| elements.append(elt) |
|
|
| return elements |
|
|
| @staticmethod |
| def _parse_one(lines): |
| ''' |
| Consume one element definition. The unconsumed input is |
| returned along with a PlyElement instance. |
| |
| ''' |
| a = 0 |
| line = lines[a] |
|
|
| if line[0] != 'element': |
| raise PlyParseError("expected 'element'") |
| if len(line) > 3: |
| raise PlyParseError("too many fields after 'element'") |
| if len(line) < 3: |
| raise PlyParseError("too few fields after 'element'") |
|
|
| (name, count) = (line[1], int(line[2])) |
|
|
| comments = [] |
| properties = [] |
| while True: |
| a += 1 |
| if a >= len(lines): |
| break |
|
|
| if lines[a][0] == 'comment': |
| comments.append(lines[a][1]) |
| elif lines[a][0] == 'property': |
| properties.append(PlyProperty._parse_one(lines[a])) |
| else: |
| break |
|
|
| return (PlyElement(name, properties, count, comments), |
| lines[a:]) |
|
|
| @staticmethod |
| def describe(data, name, len_types={}, val_types={}, |
| comments=[]): |
| ''' |
| Construct a PlyElement from an array's metadata. |
| |
| len_types and val_types can be given as mappings from list |
| property names to type strings (like 'u1', 'f4', etc., or |
| 'int8', 'float32', etc.). These can be used to define the length |
| and value types of list properties. List property lengths |
| always default to type 'u1' (8-bit unsigned integer), and value |
| types default to 'i4' (32-bit integer). |
| |
| ''' |
| if not isinstance(data, _np.ndarray): |
| raise TypeError("only numpy arrays are supported") |
|
|
| if len(data.shape) != 1: |
| raise ValueError("only one-dimensional arrays are " |
| "supported") |
|
|
| count = len(data) |
|
|
| properties = [] |
| descr = data.dtype.descr |
|
|
| for t in descr: |
| if not isinstance(t[1], str): |
| raise ValueError("nested records not supported") |
|
|
| if not t[0]: |
| raise ValueError("field with empty name") |
|
|
| if len(t) != 2 or t[1][1] == 'O': |
| |
| |
|
|
| if t[1][1] == 'O': |
| if len(t) != 2: |
| raise ValueError("non-scalar object fields not " |
| "supported") |
|
|
| len_str = _data_type_reverse[len_types.get(t[0], 'u1')] |
| if t[1][1] == 'O': |
| val_type = val_types.get(t[0], 'i4') |
| val_str = _lookup_type(val_type) |
| else: |
| val_str = _lookup_type(t[1][1:]) |
|
|
| prop = PlyListProperty(t[0], len_str, val_str) |
| else: |
| val_str = _lookup_type(t[1][1:]) |
| prop = PlyProperty(t[0], val_str) |
|
|
| properties.append(prop) |
|
|
| elt = PlyElement(name, properties, count, comments) |
| elt.data = data |
|
|
| return elt |
|
|
| def _read(self, stream, text, byte_order): |
| ''' |
| Read the actual data from a PLY file. |
| |
| ''' |
| if text: |
| self._read_txt(stream) |
| else: |
| if self._have_list: |
| |
| |
| self._read_bin(stream, byte_order) |
| else: |
| |
| |
| self._data = _np.fromfile(stream, self.dtype(byte_order), |
| self.count) |
|
|
| if len(self._data) < self.count: |
| k = len(self._data) |
| del self._data |
| raise PlyParseError("early end-of-file", self, k) |
|
|
| self._check_sanity() |
|
|
| def _write(self, stream, text, byte_order): |
| ''' |
| Write the data to a PLY file. |
| |
| ''' |
| if text: |
| self._write_txt(stream) |
| else: |
| if self._have_list: |
| |
| |
| self._write_bin(stream, byte_order) |
| else: |
| |
| |
| self.data.astype(self.dtype(byte_order), |
| copy=False).tofile(stream) |
|
|
| def _read_txt(self, stream): |
| ''' |
| Load a PLY element from an ASCII-format PLY file. The element |
| may contain list properties. |
| |
| ''' |
| self._data = _np.empty(self.count, dtype=self.dtype()) |
|
|
| k = 0 |
| for line in _islice(iter(stream.readline, b''), self.count): |
| fields = iter(line.strip().split()) |
| for prop in self.properties: |
| try: |
| self._data[prop.name][k] = prop._from_fields(fields) |
| except StopIteration: |
| raise PlyParseError("early end-of-line", |
| self, k, prop) |
| except ValueError: |
| raise PlyParseError("malformed input", |
| self, k, prop) |
| try: |
| next(fields) |
| except StopIteration: |
| pass |
| else: |
| raise PlyParseError("expected end-of-line", self, k) |
| k += 1 |
|
|
| if k < self.count: |
| del self._data |
| raise PlyParseError("early end-of-file", self, k) |
|
|
| def _write_txt(self, stream): |
| ''' |
| Save a PLY element to an ASCII-format PLY file. The element may |
| contain list properties. |
| |
| ''' |
| for rec in self.data: |
| fields = [] |
| for prop in self.properties: |
| fields.extend(prop._to_fields(rec[prop.name])) |
|
|
| _np.savetxt(stream, [fields], '%.18g', newline='\r\n') |
|
|
| def _read_bin(self, stream, byte_order): |
| ''' |
| Load a PLY element from a binary PLY file. The element may |
| contain list properties. |
| |
| ''' |
| self._data = _np.empty(self.count, dtype=self.dtype(byte_order)) |
|
|
| for k in _range(self.count): |
| for prop in self.properties: |
| try: |
| self._data[prop.name][k] = \ |
| prop._read_bin(stream, byte_order) |
| except StopIteration: |
| raise PlyParseError("early end-of-file", |
| self, k, prop) |
|
|
| def _write_bin(self, stream, byte_order): |
| ''' |
| Save a PLY element to a binary PLY file. The element may |
| contain list properties. |
| |
| ''' |
| for rec in self.data: |
| for prop in self.properties: |
| prop._write_bin(rec[prop.name], stream, byte_order) |
|
|
| @property |
| def header(self): |
| ''' |
| Format this element's metadata as it would appear in a PLY |
| header. |
| |
| ''' |
| lines = ['element %s %d' % (self.name, self.count)] |
|
|
| |
| |
| for c in self.comments: |
| lines.append('comment ' + c) |
|
|
| lines.extend(list(map(str, self.properties))) |
|
|
| return '\r\n'.join(lines) |
|
|
| def __getitem__(self, key): |
| return self.data[key] |
|
|
| def __setitem__(self, key, value): |
| self.data[key] = value |
|
|
| def __str__(self): |
| return self.header |
|
|
| def __repr__(self): |
| return ('PlyElement(%r, %r, count=%d, comments=%r)' % |
| (self.name, self.properties, self.count, |
| self.comments)) |
|
|
|
|
| class PlyProperty(object): |
|
|
| ''' |
| PLY property description. This class is pure metadata; the data |
| itself is contained in PlyElement instances. |
| |
| ''' |
|
|
| def __init__(self, name, val_dtype): |
| self._name = str(name) |
| self._check_name() |
| self.val_dtype = val_dtype |
|
|
| def _get_val_dtype(self): |
| return self._val_dtype |
|
|
| def _set_val_dtype(self, val_dtype): |
| self._val_dtype = _data_types[_lookup_type(val_dtype)] |
|
|
| val_dtype = property(_get_val_dtype, _set_val_dtype) |
|
|
| @property |
| def name(self): |
| return self._name |
|
|
| def _check_name(self): |
| if any(c.isspace() for c in self._name): |
| msg = "Error: property name %r contains spaces" % self._name |
| raise RuntimeError(msg) |
|
|
| @staticmethod |
| def _parse_one(line): |
| assert line[0] == 'property' |
|
|
| if line[1] == 'list': |
| if len(line) > 5: |
| raise PlyParseError("too many fields after " |
| "'property list'") |
| if len(line) < 5: |
| raise PlyParseError("too few fields after " |
| "'property list'") |
|
|
| return PlyListProperty(line[4], line[2], line[3]) |
|
|
| else: |
| if len(line) > 3: |
| raise PlyParseError("too many fields after " |
| "'property'") |
| if len(line) < 3: |
| raise PlyParseError("too few fields after " |
| "'property'") |
|
|
| return PlyProperty(line[2], line[1]) |
|
|
| def dtype(self, byte_order='='): |
| ''' |
| Return the numpy dtype description for this property (as a tuple |
| of strings). |
| |
| ''' |
| return byte_order + self.val_dtype |
|
|
| def _from_fields(self, fields): |
| ''' |
| Parse from generator. Raise StopIteration if the property could |
| not be read. |
| |
| ''' |
| return _np.dtype(self.dtype()).type(next(fields)) |
|
|
| def _to_fields(self, data): |
| ''' |
| Return generator over one item. |
| |
| ''' |
| yield _np.dtype(self.dtype()).type(data) |
|
|
| def _read_bin(self, stream, byte_order): |
| ''' |
| Read data from a binary stream. Raise StopIteration if the |
| property could not be read. |
| |
| ''' |
| try: |
| return _np.fromfile(stream, self.dtype(byte_order), 1)[0] |
| except IndexError: |
| raise StopIteration |
|
|
| def _write_bin(self, data, stream, byte_order): |
| ''' |
| Write data to a binary stream. |
| |
| ''' |
| _np.dtype(self.dtype(byte_order)).type(data).tofile(stream) |
|
|
| def __str__(self): |
| val_str = _data_type_reverse[self.val_dtype] |
| return 'property %s %s' % (val_str, self.name) |
|
|
| def __repr__(self): |
| return 'PlyProperty(%r, %r)' % (self.name, |
| _lookup_type(self.val_dtype)) |
|
|
|
|
| class PlyListProperty(PlyProperty): |
|
|
| ''' |
| PLY list property description. |
| |
| ''' |
|
|
| def __init__(self, name, len_dtype, val_dtype): |
| PlyProperty.__init__(self, name, val_dtype) |
|
|
| self.len_dtype = len_dtype |
|
|
| def _get_len_dtype(self): |
| return self._len_dtype |
|
|
| def _set_len_dtype(self, len_dtype): |
| self._len_dtype = _data_types[_lookup_type(len_dtype)] |
|
|
| len_dtype = property(_get_len_dtype, _set_len_dtype) |
|
|
| def dtype(self, byte_order='='): |
| ''' |
| List properties always have a numpy dtype of "object". |
| |
| ''' |
| return '|O' |
|
|
| def list_dtype(self, byte_order='='): |
| ''' |
| Return the pair (len_dtype, val_dtype) (both numpy-friendly |
| strings). |
| |
| ''' |
| return (byte_order + self.len_dtype, |
| byte_order + self.val_dtype) |
|
|
| def _from_fields(self, fields): |
| (len_t, val_t) = self.list_dtype() |
|
|
| n = int(_np.dtype(len_t).type(next(fields))) |
|
|
| data = _np.loadtxt(list(_islice(fields, n)), val_t, ndmin=1) |
| if len(data) < n: |
| raise StopIteration |
|
|
| return data |
|
|
| def _to_fields(self, data): |
| ''' |
| Return generator over the (numerical) PLY representation of the |
| list data (length followed by actual data). |
| |
| ''' |
| (len_t, val_t) = self.list_dtype() |
|
|
| data = _np.asarray(data, dtype=val_t).ravel() |
|
|
| yield _np.dtype(len_t).type(data.size) |
| for x in data: |
| yield x |
|
|
| def _read_bin(self, stream, byte_order): |
| (len_t, val_t) = self.list_dtype(byte_order) |
|
|
| try: |
| n = _np.fromfile(stream, len_t, 1)[0] |
| except IndexError: |
| raise StopIteration |
|
|
| data = _np.fromfile(stream, val_t, n) |
| if len(data) < n: |
| raise StopIteration |
|
|
| return data |
|
|
| def _write_bin(self, data, stream, byte_order): |
| ''' |
| Write data to a binary stream. |
| |
| ''' |
| (len_t, val_t) = self.list_dtype(byte_order) |
|
|
| data = _np.asarray(data, dtype=val_t).ravel() |
|
|
| _np.array(data.size, dtype=len_t).tofile(stream) |
| data.tofile(stream) |
|
|
| def __str__(self): |
| len_str = _data_type_reverse[self.len_dtype] |
| val_str = _data_type_reverse[self.val_dtype] |
| return 'property list %s %s %s' % (len_str, val_str, self.name) |
|
|
| def __repr__(self): |
| return ('PlyListProperty(%r, %r, %r)' % |
| (self.name, |
| _lookup_type(self.len_dtype), |
| _lookup_type(self.val_dtype))) |
|
|