pixelflow 0.5.0__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. {pixelflow-0.5.0 → pixelflow-0.6.0}/LICENSE +1 -1
  2. {pixelflow-0.5.0 → pixelflow-0.6.0}/PKG-INFO +22 -12
  3. {pixelflow-0.5.0 → pixelflow-0.6.0}/README.md +15 -4
  4. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/__init__.py +6 -2
  5. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/detections/detections.py +144 -5
  6. pixelflow-0.6.0/pixelflow/masks.py +212 -0
  7. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/validators.py +0 -23
  8. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow.egg-info/PKG-INFO +22 -12
  9. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow.egg-info/SOURCES.txt +1 -0
  10. {pixelflow-0.5.0 → pixelflow-0.6.0}/pyproject.toml +6 -8
  11. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/__init__.py +0 -0
  12. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/anchors.py +0 -0
  13. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/blur.py +0 -0
  14. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/box.py +0 -0
  15. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/classification.py +0 -0
  16. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/crossing.py +0 -0
  17. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/dot.py +0 -0
  18. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/filled_box.py +0 -0
  19. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/fps_counter.py +0 -0
  20. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/grid_overlay.py +0 -0
  21. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/heatmap.py +0 -0
  22. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/keypoint.py +0 -0
  23. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/keypoint_skeleton.py +0 -0
  24. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/label.py +0 -0
  25. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/mask.py +0 -0
  26. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/motion_dots.py +0 -0
  27. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/motion_trails.py +0 -0
  28. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/oval.py +0 -0
  29. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/pixelate.py +0 -0
  30. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/polygon.py +0 -0
  31. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/scale_bar.py +0 -0
  32. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/utils.py +0 -0
  33. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/annotators/zones.py +0 -0
  34. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/arrays.py +0 -0
  35. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/buffer.py +0 -0
  36. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/classifications/__init__.py +0 -0
  37. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/classifications/classifications.py +0 -0
  38. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/classifications/converters.py +0 -0
  39. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/colors.py +0 -0
  40. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/crossings.py +0 -0
  41. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/detections/__init__.py +0 -0
  42. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/detections/converters.py +0 -0
  43. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/detections/filters.py +0 -0
  44. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/labels.py +0 -0
  45. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/media.py +0 -0
  46. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/slicer.py +0 -0
  47. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/smoother.py +0 -0
  48. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/strategies.py +0 -0
  49. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/timer.py +0 -0
  50. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/tracker/__init__.py +0 -0
  51. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/tracker/bytetrack.py +0 -0
  52. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/tracker/kalman_filter.py +0 -0
  53. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/tracker/matching.py +0 -0
  54. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/tracker/track.py +0 -0
  55. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/transforms/__init__.py +0 -0
  56. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/transforms/detections.py +0 -0
  57. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/transforms/image.py +0 -0
  58. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/transforms/inverse.py +0 -0
  59. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow/zones.py +0 -0
  60. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow.egg-info/dependency_links.txt +0 -0
  61. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow.egg-info/requires.txt +0 -0
  62. {pixelflow-0.5.0 → pixelflow-0.6.0}/pixelflow.egg-info/top_level.txt +0 -0
  63. {pixelflow-0.5.0 → pixelflow-0.6.0}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  MIT License
2
2
 
3
- Copyright (c) 2025 datamarkin
3
+ Copyright (c) 2025 Emrah NAZIF
4
4
 
5
5
  Permission is hereby granted, free of charge, to any person obtaining a copy
6
6
  of this software and associated documentation files (the "Software"), to deal
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pixelflow
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Computer vision processing library for visualizing and analyzing detection results from ML models
5
- Author-email: Emrah Nazif <emrah@datamarkin.com>, Datamarkin <support@datamarkin.com>
5
+ Author-email: Emrah Nazif <emrah@datamarkin.com>
6
6
  License: MIT
7
- Project-URL: Homepage, https://github.com/datamarkin/pixelflow
8
- Project-URL: Repository, https://github.com/datamarkin/pixelflow
9
- Project-URL: Issues, https://github.com/datamarkin/pixelflow/issues
10
- Project-URL: Documentation, https://pixelflow.datamarkin.com
7
+ Project-URL: Homepage, https://github.com/emrahnazif/pixelflow
8
+ Project-URL: Repository, https://github.com/emrahnazif/pixelflow
9
+ Project-URL: Issues, https://github.com/emrahnazif/pixelflow/issues
10
+ Project-URL: Documentation, https://emrah.fr/pixelflow/
11
11
  Keywords: computer-vision,visualization,annotation,post-processing,tracking,opencv,yolo,detectron2,mayaku,ml-results
12
12
  Classifier: Development Status :: 3 - Alpha
13
13
  Classifier: Intended Audience :: Developers
@@ -15,14 +15,13 @@ Classifier: Intended Audience :: Science/Research
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Classifier: Operating System :: OS Independent
17
17
  Classifier: Programming Language :: Python :: 3
18
- Classifier: Programming Language :: Python :: 3.9
19
18
  Classifier: Programming Language :: Python :: 3.10
20
19
  Classifier: Programming Language :: Python :: 3.11
21
20
  Classifier: Programming Language :: Python :: 3.12
22
21
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
23
22
  Classifier: Topic :: Scientific/Engineering :: Image Recognition
24
23
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
- Requires-Python: >=3.9
24
+ Requires-Python: >=3.10
26
25
  Description-Content-Type: text/markdown
27
26
  License-File: LICENSE
28
27
  Requires-Dist: shapely>=2.0.0
@@ -39,8 +38,8 @@ Dynamic: license-file
39
38
  # PixelFlow
40
39
 
41
40
  [![PyPI version](https://badge.fury.io/py/pixelflow.svg)](https://badge.fury.io/py/pixelflow)
42
- [![CI](https://github.com/datamarkin/pixelflow/actions/workflows/ci.yml/badge.svg)](https://github.com/datamarkin/pixelflow/actions/workflows/ci.yml)
43
- [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
41
+ [![CI](https://github.com/emrahnazif/pixelflow/actions/workflows/ci.yml/badge.svg)](https://github.com/emrahnazif/pixelflow/actions/workflows/ci.yml)
42
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
44
43
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
45
44
 
46
45
  **The computer vision library that gets out of your way.**
@@ -132,6 +131,17 @@ detections = pf.from_easyocr(reader.readtext("sign.jpg"))
132
131
  detections = pf.from_arrays(boxes, scores, class_ids, labels=model.class_names)
133
132
  ```
134
133
 
134
+ Segmentation models answer with raster masks. One call turns them into polygons,
135
+ whichever model they came from:
136
+
137
+ ```python
138
+ detections = pf.from_arrays(boxes, scores, masks=masks) # boolean (N, H, W)
139
+ detections.masks_to_segments() # fills det.segments
140
+ detections.masks_to_segments(keep_masks=False) # ...and drops the rasters
141
+
142
+ polygon = pf.mask_to_polygon(mask) # or one mask at a time
143
+ ```
144
+
135
145
  ### Classifications - When the Model Only Names
136
146
 
137
147
  A classifier localises nothing, so its result carries no geometry at all:
@@ -457,7 +467,7 @@ report = detections.to_json_with_metrics()
457
467
 
458
468
  ## Documentation
459
469
 
460
- Full documentation available at [https://datamarkin.com/docs/pixelflow/](https://datamarkin.com/docs/pixelflow/)
470
+ Full documentation available at [https://emrah.fr/pixelflow/](https://emrah.fr/pixelflow/)
461
471
 
462
472
  ## Contributing
463
473
 
@@ -469,4 +479,4 @@ MIT License - see LICENSE file for details.
469
479
 
470
480
  ---
471
481
 
472
- Built with love by [Datamarkin](https://datamarkin.com)
482
+ Built with love by [Emrah Nazif](https://emrah.fr)
@@ -1,8 +1,8 @@
1
1
  # PixelFlow
2
2
 
3
3
  [![PyPI version](https://badge.fury.io/py/pixelflow.svg)](https://badge.fury.io/py/pixelflow)
4
- [![CI](https://github.com/datamarkin/pixelflow/actions/workflows/ci.yml/badge.svg)](https://github.com/datamarkin/pixelflow/actions/workflows/ci.yml)
5
- [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
4
+ [![CI](https://github.com/emrahnazif/pixelflow/actions/workflows/ci.yml/badge.svg)](https://github.com/emrahnazif/pixelflow/actions/workflows/ci.yml)
5
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
7
7
 
8
8
  **The computer vision library that gets out of your way.**
@@ -94,6 +94,17 @@ detections = pf.from_easyocr(reader.readtext("sign.jpg"))
94
94
  detections = pf.from_arrays(boxes, scores, class_ids, labels=model.class_names)
95
95
  ```
96
96
 
97
+ Segmentation models answer with raster masks. One call turns them into polygons,
98
+ whichever model they came from:
99
+
100
+ ```python
101
+ detections = pf.from_arrays(boxes, scores, masks=masks) # boolean (N, H, W)
102
+ detections.masks_to_segments() # fills det.segments
103
+ detections.masks_to_segments(keep_masks=False) # ...and drops the rasters
104
+
105
+ polygon = pf.mask_to_polygon(mask) # or one mask at a time
106
+ ```
107
+
97
108
  ### Classifications - When the Model Only Names
98
109
 
99
110
  A classifier localises nothing, so its result carries no geometry at all:
@@ -419,7 +430,7 @@ report = detections.to_json_with_metrics()
419
430
 
420
431
  ## Documentation
421
432
 
422
- Full documentation available at [https://datamarkin.com/docs/pixelflow/](https://datamarkin.com/docs/pixelflow/)
433
+ Full documentation available at [https://emrah.fr/pixelflow/](https://emrah.fr/pixelflow/)
423
434
 
424
435
  ## Contributing
425
436
 
@@ -431,4 +442,4 @@ MIT License - see LICENSE file for details.
431
442
 
432
443
  ---
433
444
 
434
- Built with love by [Datamarkin](https://datamarkin.com)
445
+ Built with love by [Emrah Nazif](https://emrah.fr)
@@ -1,5 +1,5 @@
1
- __version__ = "0.5.0"
2
- __author__ = "Datamarkin"
1
+ __version__ = "0.6.0"
2
+ __author__ = "Emrah NAZIF"
3
3
 
4
4
  # Import core modules
5
5
  from . import media
@@ -29,6 +29,7 @@ from .media import (
29
29
  read_image, read_video, encode_image, display_video, display_image, save_image,
30
30
  close_display, to_pil, from_pil,
31
31
  )
32
+ from .masks import mask_to_polygon
32
33
  from .zones import Zones
33
34
  from .crossings import Crossings
34
35
  from .slicer import SlicedInference, auto_slice_size
@@ -95,6 +96,9 @@ __all__ = [
95
96
  "to_pil",
96
97
  "from_pil",
97
98
 
99
+ # Mask geometry
100
+ "mask_to_polygon",
101
+
98
102
  # Spatial analysis
99
103
  "zones",
100
104
  "Zones",
@@ -17,8 +17,8 @@ from PIL import Image
17
17
  from pixelflow.validators import (validate_bbox,
18
18
  validate_segments,
19
19
  round_coord,
20
- round_to_decimal,
21
- simplify_polygon)
20
+ round_to_decimal)
21
+ from pixelflow.masks import mask_to_polygon, simplify_polygon
22
22
  from typing import (List,
23
23
  Iterator, Optional, Union, Any, Dict)
24
24
 
@@ -33,6 +33,11 @@ __all__ = ["KeyPoint", "Detection", "Detections"]
33
33
  _PNG_MASK_DTYPES = frozenset(("bool", "uint8", "uint16"))
34
34
 
35
35
 
36
+ def _is_raster_mask(mask: Any) -> bool:
37
+ """Whether an entry of `Detection.masks` is a pixel raster rather than a polygon."""
38
+ return isinstance(mask, np.ndarray)
39
+
40
+
36
41
  def _is_png_encodable(mask: np.ndarray) -> bool:
37
42
  """Whether a mask array can be losslessly PNG-encoded by Pillow."""
38
43
  if mask.size == 0 or str(mask.dtype) not in _PNG_MASK_DTYPES:
@@ -170,6 +175,11 @@ class Detection:
170
175
  segments this field is not yet normalized to one shape.
171
176
  segments (Optional[List[List[float]]]): Object outline as one polygon, normalized by
172
177
  validate_segments to [[x, y], ...] at two decimals, or None.
178
+ Coordinates follow the pixel-corner convention bbox uses:
179
+ pixel (row i, column j) covers x from j to j + 1 and y
180
+ from i to i + 1. Polygons a model computed itself (the
181
+ Ultralytics converter passes them through) only
182
+ approximate this; masks_to_segments() makes them exact.
173
183
  keypoints (Optional[List[KeyPoint]]): List of KeyPoint objects for pose/structure data.
174
184
  class_id (Optional[Union[int, str]]): Numeric or string class identifier from model.
175
185
  class_name (Optional[str]): Human-readable class name (e.g., "person", "vehicle").
@@ -373,7 +383,7 @@ class Detection:
373
383
  if self.masks:
374
384
  serializable_masks = []
375
385
  for mask in self.masks:
376
- if isinstance(mask, np.ndarray):
386
+ if _is_raster_mask(mask):
377
387
  if _is_png_encodable(mask):
378
388
  # Binary mask - PNG-encode, then base64 for JSON
379
389
  # transport. PNG compresses masks losslessly; encoding
@@ -533,8 +543,83 @@ class Detection:
533
543
  - Recommended for real-time applications with complex segmentation masks
534
544
  """
535
545
  if self.masks:
536
- # Apply the simplify function to each mask (assuming self.masks is a list of polygons)
537
- self.masks = [simplify_polygon(mask, tolerance, preserve_topology) for mask in self.masks]
546
+ # A raster is not a polygon: it passes through untouched, as documented.
547
+ self.masks = [
548
+ mask if _is_raster_mask(mask)
549
+ else simplify_polygon(mask, tolerance, preserve_topology)
550
+ for mask in self.masks
551
+ ]
552
+
553
+ def masks_to_segments(self, tolerance: float = 1.5, keep_masks: bool = True) -> bool:
554
+ """
555
+ Fill `segments` with the outline of this detection's raster mask.
556
+
557
+ Converts the raster in `masks` to one polygon with `mask_to_polygon` and
558
+ stores it in `segments`. Lets a result from any segmentation model be drawn,
559
+ stored or sent as a polygon without carrying the raster along.
560
+
561
+ Args:
562
+ tolerance (float): Simplification tolerance in pixels, passed to
563
+ `mask_to_polygon`. Range: [0.0, inf). Default is 1.5.
564
+ keep_masks (bool): Whether to keep the raster in `masks` after converting.
565
+ False drops it to save memory. Default is True.
566
+
567
+ Returns:
568
+ bool: True if `segments` was set, False if the detection was left unchanged.
569
+
570
+ Raises:
571
+ ValueError: If `tolerance` is negative.
572
+
573
+ Example:
574
+ >>> import numpy as np
575
+ >>> import pixelflow as pf
576
+ >>>
577
+ >>> mask = np.zeros((480, 640), dtype=bool)
578
+ >>> mask[100:200, 50:300] = True
579
+ >>> detection = pf.Detection(bbox=[50, 100, 300, 200], masks=[mask])
580
+ >>>
581
+ >>> # Fill segments, keep the raster for drawing
582
+ >>> detection.masks_to_segments()
583
+ True
584
+ >>> detection.segments
585
+ [[50.0, 100.0], [50.0, 200.0], [300.0, 200.0], [300.0, 100.0]]
586
+ >>>
587
+ >>> # Finer outline, raster dropped
588
+ >>> detection.masks_to_segments(tolerance=0.5, keep_masks=False)
589
+ True
590
+ >>> detection.masks is None
591
+ True
592
+
593
+ Notes:
594
+ - Modifies the detection in-place
595
+ - A detection with no mask, a polygon mask, or an empty raster is left
596
+ unchanged
597
+ - Existing `segments` are replaced when a raster is present: the raster
598
+ is what the model produced, and a polygon derived from it follows the
599
+ pixel-corner convention exactly
600
+ - Several rasters of the same shape are treated as parts of one object
601
+ and merged before tracing; `segments` still receives one outline
602
+ - With `keep_masks=False` only a converted raster is dropped. A raster
603
+ that produced no polygon stays, so no geometry is lost silently
604
+ """
605
+ if not self.masks:
606
+ return False
607
+
608
+ # Only a 2D raster can be traced; stacked or multi-channel arrays are left alone.
609
+ rasters = [m for m in self.masks if _is_raster_mask(m) and m.ndim == 2]
610
+ if not rasters:
611
+ return False
612
+ rasters = [m for m in rasters if m.shape == rasters[0].shape]
613
+
614
+ polygon = mask_to_polygon(np.logical_or.reduce(rasters), tolerance=tolerance)
615
+ if polygon is None:
616
+ return False
617
+
618
+ self.segments = polygon
619
+ if not keep_masks:
620
+ converted = {id(m) for m in rasters}
621
+ self.masks = [m for m in self.masks if id(m) not in converted] or None
622
+ return True
538
623
 
539
624
  def copy(self) -> "Detection":
540
625
  """
@@ -796,6 +881,60 @@ class Detections:
796
881
  detection.simplify_masks(tolerance=tolerance, preserve_topology=preserve_topology)
797
882
  return self
798
883
 
884
+ def masks_to_segments(self, tolerance: float = 1.5, keep_masks: bool = True) -> 'Detections':
885
+ """
886
+ Fill `segments` from the raster mask of every detection in the collection.
887
+
888
+ Turns the output of any segmentation model into polygons: each detection's
889
+ raster in `masks` is traced along its pixel corners, simplified, and stored in
890
+ `segments`. Every source then shares one polygon convention, whichever model
891
+ produced it.
892
+
893
+ Args:
894
+ tolerance (float): Simplification tolerance in pixels. Higher values give
895
+ fewer points. Range: [0.0, inf). Default is 1.5.
896
+ keep_masks (bool): Whether to keep the rasters in `masks` after
897
+ converting. False drops them to save memory.
898
+ Default is True.
899
+
900
+ Returns:
901
+ Detections: Returns self for method chaining.
902
+
903
+ Raises:
904
+ ValueError: If `tolerance` is negative.
905
+
906
+ Example:
907
+ >>> import pixelflow as pf
908
+ >>>
909
+ >>> # SAM-style output: boxes and boolean (N, H, W) masks
910
+ >>> detections = pf.from_arrays(boxes, scores, masks=masks)
911
+ >>> detections.masks_to_segments()
912
+ >>> detections[0].segments # [[x, y], ...]
913
+ >>>
914
+ >>> # Draw outlines, which read `segments`
915
+ >>> image = pf.annotate.polygon(image, detections)
916
+ >>>
917
+ >>> # Keep only polygons, e.g. before serializing
918
+ >>> payload = detections.masks_to_segments(keep_masks=False).to_json()
919
+ >>>
920
+ >>> # Finer outlines at the cost of more points
921
+ >>> detections.masks_to_segments(tolerance=0.5)
922
+
923
+ Notes:
924
+ - Modifies detections in-place; order and every other field are untouched
925
+ - Detections without a raster mask, or with an empty one, are left unchanged
926
+ - Existing `segments` are replaced where a raster is present, which also
927
+ brings Ultralytics' own polygons onto the pixel-corner convention
928
+ - One outline per detection: holes and all but the largest region are
929
+ dropped. See `mask_to_polygon` for the exact rules
930
+
931
+ Performance Notes:
932
+ - A few milliseconds per detection at full-HD; cost follows object size
933
+ """
934
+ for detection in self.detections:
935
+ detection.masks_to_segments(tolerance=tolerance, keep_masks=keep_masks)
936
+ return self
937
+
799
938
  def copy(self) -> "Detections":
800
939
  """
801
940
  Create a deep copy of all detections in the collection.
@@ -0,0 +1,212 @@
1
+ """Raster masks to polygons.
2
+
3
+ Segmentation models answer in pixels - a boolean ``H x W`` raster per object - while
4
+ everything that stores, edits or transmits an outline wants a polygon. This module is
5
+ the bridge, and it settles the one question a bridge has to settle: where exactly a
6
+ polygon vertex sits relative to the pixels it encloses.
7
+
8
+ The convention is pixel corners. Pixel ``(row i, column j)`` covers the square from
9
+ ``x = j`` to ``x = j + 1`` and ``y = i`` to ``y = i + 1``, and the polygon runs along
10
+ the outside edges of the pixels it encloses. A mask covering columns 10..19 therefore
11
+ spans ``x = 10`` to ``x = 20``, which is the same box ``[x1, y1, x2, y2]`` describes
12
+ for that object. Tracing through pixel centres instead - what ``cv2.findContours``
13
+ returns - shrinks every object by half a pixel on each side and turns a one-pixel-wide
14
+ line into a polygon of zero area.
15
+ """
16
+
17
+ import cv2
18
+ import numpy as np
19
+
20
+ from pixelflow.validators import validate_segments
21
+
22
+ __all__ = ["mask_to_polygon", "simplify_polygon"]
23
+
24
+
25
+ def _trace_outline(region: np.ndarray) -> np.ndarray:
26
+ """
27
+ Trace the outer boundary of one connected region along its pixel corners.
28
+
29
+ Returns ``(N, 2)`` int32 ``(x, y)`` points in doubled coordinates: pixel corners
30
+ land on even numbers and the midpoints of pixel edges on odd ones, so the caller
31
+ halves the result. Staying in integers until then keeps every vertex exact.
32
+ """
33
+ height, width = region.shape
34
+
35
+ # Each pixel becomes a 3x3 block on a (2H+1, 2W+1) grid: its centre at an
36
+ # odd/odd position, dilated out to its own corners. Neighbouring pixels share
37
+ # the grid points on their common edge, so the filled shape is the region's
38
+ # true footprint and its border passes through real pixel corners.
39
+ grid = np.zeros((2 * height + 1, 2 * width + 1), dtype=np.uint8)
40
+ grid[1::2, 1::2] = region
41
+ grid = cv2.dilate(grid, np.ones((3, 3), dtype=np.uint8))
42
+
43
+ contours, _ = cv2.findContours(grid, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE)
44
+ points = max(contours, key=len)[:, 0, :]
45
+
46
+ # OpenCV's border follower steps diagonally across a concave corner, from one
47
+ # edge midpoint to the next, because the corner itself has no background among
48
+ # its four neighbours. Every diagonal step is such a cut.
49
+ following = np.roll(points, -1, axis=0)
50
+ diagonal = np.abs(following - points).sum(axis=1) == 2
51
+ start, end = points[diagonal], following[diagonal]
52
+ # Each midpoint has one even coordinate; the corner takes the even one of each.
53
+ corners = np.where(start % 2 == 0, start, end)
54
+ return np.insert(points, np.nonzero(diagonal)[0] + 1, corners, axis=0)
55
+
56
+
57
+ def _simplify_outline(outline: np.ndarray, epsilon: float) -> np.ndarray:
58
+ """
59
+ Douglas-Peucker on a closed outline, with its four extreme vertices pinned.
60
+
61
+ Simplifying the loop as a whole is free to drop the leftmost or topmost vertex
62
+ whenever it lies within `epsilon` of the result, which pulls the polygon's
63
+ bounding box inside the mask's. Cutting the loop at one vertex per side and
64
+ simplifying each arc between them keeps those vertices, so the box around the
65
+ polygon is exactly the box around the mask at any tolerance.
66
+ """
67
+ xs, ys = outline[:, 0], outline[:, 1]
68
+
69
+ def extreme(primary, secondary):
70
+ # First along one axis, ties broken along the other.
71
+ candidates = np.flatnonzero(primary == primary.min())
72
+ return int(candidates[np.argmin(secondary[candidates])])
73
+
74
+ # Each side is a straight run of points; taking the end of the run rather than
75
+ # any point on it pins a real corner, so a rectangle keeps exactly four.
76
+ anchors = sorted([
77
+ extreme(xs, ys), # leftmost, then topmost
78
+ extreme(ys, -xs), # topmost, then rightmost
79
+ extreme(-xs, -ys), # rightmost, then bottommost
80
+ extreme(-ys, xs), # bottommost, then leftmost
81
+ ])
82
+
83
+ arcs = []
84
+ for start, end in zip(anchors, anchors[1:] + [anchors[0] + len(outline)]):
85
+ # Both end anchors included; the last arc wraps round to the first anchor.
86
+ arc = outline.take(np.arange(start, end + 1), axis=0, mode="wrap")
87
+ # An open curve keeps both end points; the last one opens the next arc.
88
+ arcs.append(cv2.approxPolyDP(arc.reshape(-1, 1, 2), epsilon, False)[:-1, 0, :])
89
+ return np.concatenate(arcs)
90
+
91
+
92
+ def mask_to_polygon(mask, tolerance: float = 1.5):
93
+ """
94
+ Convert one raster mask into one polygon outlining its largest region.
95
+
96
+ Traces the outer boundary of the largest connected region along its pixel
97
+ corners, then reduces the vertex count with Douglas-Peucker simplification.
98
+ The result is in the shape `Detection.segments` holds, so it can be assigned
99
+ directly.
100
+
101
+ Args:
102
+ mask: 2D array of shape `(H, W)`. Non-zero pixels are the object, so a
103
+ boolean mask and a 0/255 uint8 mask behave the same. A probability
104
+ map must be thresholded by the caller first.
105
+ tolerance (float): Maximum distance in pixels a simplified edge may stray
106
+ from the traced outline. Range: [0.0, inf). `0` keeps
107
+ the exact pixel staircase and only removes points on
108
+ straight runs. Default is 1.5.
109
+
110
+ Returns:
111
+ Optional[List[List[float]]]: The polygon as `[[x, y], ...]` in the mask's own
112
+ pixel coordinates, not closed (the first point is not repeated at the
113
+ end). None if the mask is empty.
114
+
115
+ Raises:
116
+ ValueError: If `mask` is not 2D, or `tolerance` is negative.
117
+
118
+ Example:
119
+ >>> import numpy as np
120
+ >>> import pixelflow as pf
121
+ >>>
122
+ >>> mask = np.zeros((480, 640), dtype=bool)
123
+ >>> mask[100:200, 50:300] = True
124
+ >>>
125
+ >>> # A rectangle comes back as its four pixel corners
126
+ >>> pf.mask_to_polygon(mask)
127
+ [[50.0, 100.0], [50.0, 200.0], [300.0, 200.0], [300.0, 100.0]]
128
+ >>>
129
+ >>> # Exact outline, every stair of the pixel boundary kept
130
+ >>> outline = pf.mask_to_polygon(mask, tolerance=0)
131
+ >>>
132
+ >>> # Assign straight to a detection
133
+ >>> detection = pf.Detection(bbox=[50, 100, 300, 200])
134
+ >>> detection.segments = pf.mask_to_polygon(mask)
135
+
136
+ Notes:
137
+ - Coordinates are pixel corners: a mask covering columns 10..19 spans
138
+ x = 10 to x = 20, so the polygon's bounding box equals the mask's
139
+ - One polygon per mask. Holes are filled and every region but the largest
140
+ is dropped, because `segments` holds a single outline
141
+ - Regions are 4-connected: pixels touching only at a corner are separate
142
+ regions. A region that pinches to a single corner still yields one
143
+ outline, which touches itself at that corner
144
+ - Simplification only removes vertices, and never the outermost one on
145
+ each side, so the remaining points are exact pixel corners and the
146
+ bounding box is exact at any tolerance
147
+ - The pixel staircase on a slanted edge is itself about one pixel of
148
+ noise. Below a tolerance of roughly 1.5 the stairs survive and a large
149
+ object keeps hundreds of points; at 1.5 it keeps tens
150
+ - No object is too small: a one-pixel mask returns the four corners of
151
+ that pixel at any tolerance, because the extreme vertices are kept
152
+
153
+ Performance Notes:
154
+ - One pass over the frame finds the occupied area; all other work is
155
+ proportional to that area, not the image
156
+ - In a 1920x1281 mask: well under 1 ms for a 20 px object, around 5 ms
157
+ for one 1000 px across
158
+ """
159
+ mask = np.asarray(mask)
160
+ if mask.ndim != 2:
161
+ raise ValueError(f"mask must be 2D (H, W), got shape {mask.shape}")
162
+ if tolerance < 0:
163
+ raise ValueError(f"tolerance must be >= 0, got {tolerance}")
164
+
165
+ if mask.dtype != np.bool_:
166
+ mask = mask != 0
167
+ # A boolean array is already the 0/1 bytes OpenCV wants, so view it, don't copy it.
168
+ binary = np.ascontiguousarray(mask).view(np.uint8)
169
+
170
+ # Crop to the occupied part of the frame before anything else: an object is
171
+ # usually a small part of the image, and every later step costs by the pixel.
172
+ left, top, width, height = cv2.boundingRect(binary)
173
+ if width == 0:
174
+ return None
175
+ binary = binary[top:top + height, left:left + width]
176
+
177
+ # Label 0 is the background, so the regions start at 1.
178
+ _, labels, stats, _ = cv2.connectedComponentsWithStats(binary, connectivity=4)
179
+ largest = 1 + int(np.argmax(stats[1:, cv2.CC_STAT_AREA]))
180
+ x, y, width, height = (int(v) for v in stats[largest, :4])
181
+ region = (labels[y:y + height, x:x + width] == largest).astype(np.uint8)
182
+
183
+ outline = _simplify_outline(_trace_outline(region), 2.0 * tolerance) # doubled, like the points
184
+ return validate_segments(outline / 2.0 + (left + x, top + y))
185
+
186
+
187
+ def simplify_polygon(polygon_points, tolerance: float = 2.0, preserve_topology: bool = True):
188
+ """
189
+ Reduce the number of points in one polygon with Douglas-Peucker simplification.
190
+
191
+ Args:
192
+ polygon_points: The polygon as a sequence of (x, y) points. A closing point
193
+ that repeats the first one is accepted and dropped.
194
+ tolerance (float): Maximum distance in pixels a simplified edge may stray from
195
+ the original outline (higher = more simplified).
196
+ preserve_topology (bool): Accepted for backwards compatibility and ignored.
197
+ Plain Douglas-Peucker can, on a thin shape and a
198
+ large tolerance, produce an outline that crosses
199
+ itself.
200
+
201
+ Returns:
202
+ List[List[float]]: The simplified polygon as ``[[x, y], ...]`` at the shared
203
+ precision, not closed.
204
+ """
205
+ points = np.asarray(polygon_points, dtype=np.float32).reshape(-1, 2)
206
+ if len(points) > 1 and np.array_equal(points[0], points[-1]):
207
+ points = points[:-1]
208
+ if len(points) > 3:
209
+ # Douglas-Peucker only ever drops vertices, so the survivors are the caller's
210
+ # own points; validate_segments rounds the float32 artefacts back out of them.
211
+ points = cv2.approxPolyDP(points.reshape(-1, 1, 2), float(tolerance), True)[:, 0, :]
212
+ return validate_segments(points)
@@ -9,7 +9,6 @@ Optional fields; see round_coord for why a KeyPoint raises instead.
9
9
  import math
10
10
 
11
11
  import numpy as np
12
- from shapely.geometry import Polygon as Shapely_Polygon
13
12
 
14
13
 
15
14
  # How much precision pixelflow keeps. Truncating pixel coordinates to whole
@@ -124,25 +123,3 @@ def round_to_decimal(value, decimals=CONFIDENCE_DECIMALS):
124
123
  if value is not None:
125
124
  return round(float(value), decimals)
126
125
  return None
127
-
128
-
129
- def simplify_polygon(polygon_points: list, tolerance: float = 2.0, preserve_topology: bool = True) -> list:
130
- """
131
- Simplifies a single polygon using Shapely.
132
-
133
- Args:
134
- polygon_points (list): A list of tuples representing a polygon.
135
- tolerance (float): The tolerance factor for simplification (higher = more simplified).
136
- preserve_topology (bool): If True, the function will try to preserve the polygon's topology.
137
-
138
- Returns:
139
- list: A simplified polygon represented as a list of tuples.
140
- """
141
- # Convert the list of tuples to a Shapely Polygon
142
- polygon = Shapely_Polygon(polygon_points)
143
-
144
- # Simplify the polygon using the specified tolerance
145
- simplified_polygon = polygon.simplify(tolerance=tolerance, preserve_topology=preserve_topology)
146
-
147
- # Return the simplified coordinates as a list of tuples
148
- return list(simplified_polygon.exterior.coords)
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pixelflow
3
- Version: 0.5.0
3
+ Version: 0.6.0
4
4
  Summary: Computer vision processing library for visualizing and analyzing detection results from ML models
5
- Author-email: Emrah Nazif <emrah@datamarkin.com>, Datamarkin <support@datamarkin.com>
5
+ Author-email: Emrah Nazif <emrah@datamarkin.com>
6
6
  License: MIT
7
- Project-URL: Homepage, https://github.com/datamarkin/pixelflow
8
- Project-URL: Repository, https://github.com/datamarkin/pixelflow
9
- Project-URL: Issues, https://github.com/datamarkin/pixelflow/issues
10
- Project-URL: Documentation, https://pixelflow.datamarkin.com
7
+ Project-URL: Homepage, https://github.com/emrahnazif/pixelflow
8
+ Project-URL: Repository, https://github.com/emrahnazif/pixelflow
9
+ Project-URL: Issues, https://github.com/emrahnazif/pixelflow/issues
10
+ Project-URL: Documentation, https://emrah.fr/pixelflow/
11
11
  Keywords: computer-vision,visualization,annotation,post-processing,tracking,opencv,yolo,detectron2,mayaku,ml-results
12
12
  Classifier: Development Status :: 3 - Alpha
13
13
  Classifier: Intended Audience :: Developers
@@ -15,14 +15,13 @@ Classifier: Intended Audience :: Science/Research
15
15
  Classifier: License :: OSI Approved :: MIT License
16
16
  Classifier: Operating System :: OS Independent
17
17
  Classifier: Programming Language :: Python :: 3
18
- Classifier: Programming Language :: Python :: 3.9
19
18
  Classifier: Programming Language :: Python :: 3.10
20
19
  Classifier: Programming Language :: Python :: 3.11
21
20
  Classifier: Programming Language :: Python :: 3.12
22
21
  Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
23
22
  Classifier: Topic :: Scientific/Engineering :: Image Recognition
24
23
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
- Requires-Python: >=3.9
24
+ Requires-Python: >=3.10
26
25
  Description-Content-Type: text/markdown
27
26
  License-File: LICENSE
28
27
  Requires-Dist: shapely>=2.0.0
@@ -39,8 +38,8 @@ Dynamic: license-file
39
38
  # PixelFlow
40
39
 
41
40
  [![PyPI version](https://badge.fury.io/py/pixelflow.svg)](https://badge.fury.io/py/pixelflow)
42
- [![CI](https://github.com/datamarkin/pixelflow/actions/workflows/ci.yml/badge.svg)](https://github.com/datamarkin/pixelflow/actions/workflows/ci.yml)
43
- [![Python 3.9+](https://img.shields.io/badge/python-3.9+-blue.svg)](https://www.python.org/downloads/)
41
+ [![CI](https://github.com/emrahnazif/pixelflow/actions/workflows/ci.yml/badge.svg)](https://github.com/emrahnazif/pixelflow/actions/workflows/ci.yml)
42
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10+-blue.svg)](https://www.python.org/downloads/)
44
43
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
45
44
 
46
45
  **The computer vision library that gets out of your way.**
@@ -132,6 +131,17 @@ detections = pf.from_easyocr(reader.readtext("sign.jpg"))
132
131
  detections = pf.from_arrays(boxes, scores, class_ids, labels=model.class_names)
133
132
  ```
134
133
 
134
+ Segmentation models answer with raster masks. One call turns them into polygons,
135
+ whichever model they came from:
136
+
137
+ ```python
138
+ detections = pf.from_arrays(boxes, scores, masks=masks) # boolean (N, H, W)
139
+ detections.masks_to_segments() # fills det.segments
140
+ detections.masks_to_segments(keep_masks=False) # ...and drops the rasters
141
+
142
+ polygon = pf.mask_to_polygon(mask) # or one mask at a time
143
+ ```
144
+
135
145
  ### Classifications - When the Model Only Names
136
146
 
137
147
  A classifier localises nothing, so its result carries no geometry at all:
@@ -457,7 +467,7 @@ report = detections.to_json_with_metrics()
457
467
 
458
468
  ## Documentation
459
469
 
460
- Full documentation available at [https://datamarkin.com/docs/pixelflow/](https://datamarkin.com/docs/pixelflow/)
470
+ Full documentation available at [https://emrah.fr/pixelflow/](https://emrah.fr/pixelflow/)
461
471
 
462
472
  ## Contributing
463
473
 
@@ -469,4 +479,4 @@ MIT License - see LICENSE file for details.
469
479
 
470
480
  ---
471
481
 
472
- Built with love by [Datamarkin](https://datamarkin.com)
482
+ Built with love by [Emrah Nazif](https://emrah.fr)
@@ -7,6 +7,7 @@ pixelflow/buffer.py
7
7
  pixelflow/colors.py
8
8
  pixelflow/crossings.py
9
9
  pixelflow/labels.py
10
+ pixelflow/masks.py
10
11
  pixelflow/media.py
11
12
  pixelflow/slicer.py
12
13
  pixelflow/smoother.py
@@ -7,11 +7,10 @@ name = "pixelflow"
7
7
  dynamic = ["version"]
8
8
  description = "Computer vision processing library for visualizing and analyzing detection results from ML models"
9
9
  readme = "README.md"
10
- requires-python = ">=3.9"
10
+ requires-python = ">=3.10"
11
11
  license = {text = "MIT"}
12
12
  authors = [
13
- {name = "Emrah Nazif", email = "emrah@datamarkin.com"},
14
- {name = "Datamarkin", email = "support@datamarkin.com"}
13
+ {name = "Emrah Nazif", email = "emrah@datamarkin.com"}
15
14
  ]
16
15
 
17
16
  keywords = [
@@ -34,7 +33,6 @@ classifiers = [
34
33
  "License :: OSI Approved :: MIT License",
35
34
  "Operating System :: OS Independent",
36
35
  "Programming Language :: Python :: 3",
37
- "Programming Language :: Python :: 3.9",
38
36
  "Programming Language :: Python :: 3.10",
39
37
  "Programming Language :: Python :: 3.11",
40
38
  "Programming Language :: Python :: 3.12",
@@ -59,10 +57,10 @@ dev = [
59
57
  ]
60
58
 
61
59
  [project.urls]
62
- Homepage = "https://github.com/datamarkin/pixelflow"
63
- Repository = "https://github.com/datamarkin/pixelflow"
64
- Issues = "https://github.com/datamarkin/pixelflow/issues"
65
- Documentation = "https://pixelflow.datamarkin.com"
60
+ Homepage = "https://github.com/emrahnazif/pixelflow"
61
+ Repository = "https://github.com/emrahnazif/pixelflow"
62
+ Issues = "https://github.com/emrahnazif/pixelflow/issues"
63
+ Documentation = "https://emrah.fr/pixelflow/"
66
64
 
67
65
  [tool.setuptools.dynamic]
68
66
  version = {attr = "pixelflow.__version__"}
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes