ccfx 1.0.8__tar.gz → 1.1.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccfx
3
- Version: 1.0.8
3
+ Version: 1.1.0
4
4
  Summary: This package simplifies regular common actions for quick prototyping in a user friendly way
5
5
  Author-email: Celray James CHAWANDA <celray@chawanda.com>
6
6
  License-Expression: MIT
@@ -26,6 +26,9 @@ Requires-Dist: mutagen
26
26
  Requires-Dist: requests
27
27
  Requires-Dist: tqdm
28
28
  Requires-Dist: pillow
29
+ Requires-Dist: scipy
30
+ Requires-Dist: rasterio
31
+ Requires-Dist: matplotlib
29
32
  Dynamic: license-file
30
33
 
31
34
  # ccfx
@@ -22,7 +22,6 @@ import platform
22
22
  import zipfile
23
23
  import pickle
24
24
  import time
25
- from shapely.geometry import box, Point
26
25
  import geopandas, pandas
27
26
  from collections import defaultdict
28
27
  import py7zr
@@ -38,6 +37,12 @@ import yt_dlp
38
37
  from typing import Optional, Any
39
38
  from datetime import datetime, timedelta
40
39
  from PIL import Image
40
+ import scipy as scipy
41
+ from shapely.geometry import LineString, Polygon, MultiPolygon, box, Point
42
+ from shapely.ops import polygonize, unary_union
43
+ import rasterio
44
+ from rasterio import features
45
+ from rasterio.transform import from_bounds
41
46
 
42
47
  # functions
43
48
  def listFiles(path: str, ext: Optional[str] = None) -> list:
@@ -479,9 +484,9 @@ def alert(message:str, server:str = "http://ntfy.sh", topic:str = "pythonAlerts"
479
484
  return: True if the alert was sent successfully, False otherwise
480
485
  '''
481
486
  print(message) if printIt else None; header_data = {}
482
- if not messageTitle is None: header_data["Title"] = messageTitle
483
- if not priority is None: header_data["Priority"] = priority
484
- if not len(tags) == 0: header_data["Tags"] = ",".join(tags)
487
+ if not messageTitle is None: header_data["Title"] = str(messageTitle).replace("\r"," ").replace("\n"," ")
488
+ if not priority is None: header_data["Priority"] = str(int(priority))
489
+ if not len(tags) == 0: header_data["Tags"] = ",".join(map(str, tags))
485
490
 
486
491
  try:
487
492
  if v: print(f"sending alert to {server}/{topic}")
@@ -517,6 +522,7 @@ def deletePath(path:str, v:bool = False) -> bool:
517
522
  if v:
518
523
  print(f'! {path} does not exist')
519
524
  deleted = False
525
+ return deleted
520
526
 
521
527
 
522
528
  def downloadChunk(url: str, start: int, end: int, path: str) -> None:
@@ -528,6 +534,54 @@ def downloadChunk(url: str, start: int, end: int, path: str) -> None:
528
534
  f.write(chunk)
529
535
 
530
536
 
537
+ def correctFisheye(inputFile: str, outputFile: str = '',
538
+ k1: float = -0.1, k2: float = 0.05,
539
+ cx: float = 0.5, cy: float = 0.5,
540
+ crf: int = 20) -> str:
541
+ """
542
+ Correct fisheye distortion in a video and save as MP4.
543
+
544
+ Args:
545
+ inputFile (str): Path to the input video (any format).
546
+ outputFile (str, optional): Path for the corrected MP4. If None, auto-creates.
547
+ k1, k2 (float): Lens distortion coefficients.
548
+ cx, cy (float): Optical center (0.5 = image center).
549
+ crf (int): Constant Rate Factor (lower = better quality, larger file).
550
+
551
+ Returns:
552
+ str: Path to the corrected MP4 file.
553
+ """
554
+ if not os.path.exists(inputFile):
555
+ raise FileNotFoundError(f"Input file not found: {inputFile}")
556
+
557
+ # Default output path
558
+ if outputFile == '':
559
+ base, _ = os.path.splitext(inputFile)
560
+ outputFile = f"{base}_corrected.mp4"
561
+
562
+ cmd = [
563
+ "ffmpeg", "-hide_banner", "-loglevel", "error", "-stats",
564
+ "-i", inputFile,
565
+ "-vf", f"lenscorrection=cx={cx}:cy={cy}:k1={k1}:k2={k2}",
566
+ "-c:v", "libx264", "-preset", "slow", f"-crf", str(crf),
567
+ "-pix_fmt", "yuv420p",
568
+ "-c:a", "aac", "-b:a", "192k",
569
+ "-movflags", "+faststart",
570
+ outputFile
571
+ ]
572
+
573
+ subprocess.run(cmd, check=True)
574
+ return outputFile
575
+
576
+ def correctLens(inputFile: str, outputFile: str = '',
577
+ k1: float = -0.1, k2: float = 0.05,
578
+ cx: float = 0.5, cy: float = 0.5,
579
+ crf: int = 20) -> str:
580
+ """
581
+ Alias for correctFisheye
582
+ """
583
+ return correctFisheye(inputFile, outputFile, k1, k2, cx, cy, crf)
584
+
531
585
  def formatStringBlock(input_str: str, max_chars: int = 70) -> str:
532
586
  '''
533
587
  This function takes a string and formats it into a block of text
@@ -558,7 +612,6 @@ def formatStringBlock(input_str: str, max_chars: int = 70) -> str:
558
612
 
559
613
 
560
614
 
561
-
562
615
  def downloadFile(url: str, save_path: str, exists_action: str = 'resume', num_connections: int = 5, v: bool = False) -> None:
563
616
  if v:
564
617
  print(f"\ndownloading {url}")
@@ -573,13 +626,17 @@ def downloadFile(url: str, save_path: str, exists_action: str = 'resume', num_co
573
626
  if os.path.exists(save_fname):
574
627
  if exists_action == 'skip':
575
628
  if v:
576
- print(f"File exists, skipping: {save_fname}")
629
+ print(f"file exists, skipping: {save_fname}")
577
630
  return
578
631
  elif exists_action == 'overwrite':
579
632
  os.remove(save_fname)
580
633
  # 'resume' is handled below
581
634
 
582
- # Get file size
635
+ # Get file size (suppress urllib3 warnings when v=False)
636
+ import urllib3
637
+ if not v:
638
+ urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
639
+
583
640
  response = requests.head(url)
584
641
  file_size = int(response.headers.get('content-length', 0))
585
642
 
@@ -589,7 +646,7 @@ def downloadFile(url: str, save_path: str, exists_action: str = 'resume', num_co
589
646
  initial_pos = os.path.getsize(save_fname)
590
647
  if initial_pos >= file_size:
591
648
  if v:
592
- print(f"File already completed: {save_fname}")
649
+ print(f"file already completed: {save_fname}")
593
650
  return
594
651
 
595
652
  # Calculate chunk sizes
@@ -609,14 +666,22 @@ def downloadFile(url: str, save_path: str, exists_action: str = 'resume', num_co
609
666
  executor.submit(downloadChunk, url, start, end, temp_files[i])
610
667
  )
611
668
 
612
- # Wait for all downloads to complete with progress bar
613
- with tqdm(total=file_size-initial_pos, initial=initial_pos, unit='B',
614
- unit_scale=True, desc=fname) as pbar:
615
- completed = initial_pos
616
- while completed < file_size:
669
+ # Wait for all downloads to complete with progress bar (conditionally show progress)
670
+ if v:
671
+ with tqdm(total=file_size-initial_pos, initial=initial_pos, unit='B',
672
+ unit_scale=True, desc=fname) as pbar:
673
+ completed = initial_pos
674
+ while completed < file_size:
675
+ current = sum(os.path.getsize(f) for f in temp_files if os.path.exists(f))
676
+ pbar.update(current - completed)
677
+ completed = current
678
+ else:
679
+ # Wait silently without progress bar
680
+ while True:
617
681
  current = sum(os.path.getsize(f) for f in temp_files if os.path.exists(f))
618
- pbar.update(current - completed)
619
- completed = current
682
+ if current >= file_size - initial_pos:
683
+ break
684
+ time.sleep(0.1)
620
685
 
621
686
  # Merge chunks
622
687
  with open(save_fname, 'ab' if initial_pos > 0 else 'wb') as outfile:
@@ -627,6 +692,77 @@ def downloadFile(url: str, save_path: str, exists_action: str = 'resume', num_co
627
692
  os.remove(temp_file)
628
693
 
629
694
 
695
+
696
+
697
+ def createPolygonFromOuterPoints(pointsGdf: geopandas.GeoDataFrame, alpha: float = 1.6, keepHoles: bool = False) -> geopandas.GeoDataFrame:
698
+ """
699
+ Concave hull (alpha-shape) from points.
700
+ alpha: larger -> tighter/more detailed; too large can fragment.
701
+ keepHoles: keep interior holes if True, otherwise drop them.
702
+ """
703
+
704
+ pointsGdf = pointsGdf[pointsGdf.geometry.type.eq("Point") & pointsGdf.geometry.notna()]
705
+ if pointsGdf.empty or pointsGdf.geometry.nunique() < 3:
706
+ raise ValueError("need at least three distinct points")
707
+
708
+ # coordinates (N, 2)
709
+ coords = numpy.array([(g.x, g.y) for g in pointsGdf.geometry])
710
+
711
+ tri = scipy.spatial.Delaunay(coords)
712
+ simplices = tri.simplices # (M, 3) indices into coords
713
+ triPts = coords[simplices] # (M, 3, 2)
714
+
715
+ # side lengths
716
+ a = numpy.linalg.norm(triPts[:, 1] - triPts[:, 2], axis=1)
717
+ b = numpy.linalg.norm(triPts[:, 0] - triPts[:, 2], axis=1)
718
+ c = numpy.linalg.norm(triPts[:, 0] - triPts[:, 1], axis=1)
719
+
720
+ s = (a + b + c) / 2.0
721
+ # heron area, guard against tiny/negative due to fp error
722
+ areaSq = numpy.maximum(s * (s - a) * (s - b) * (s - c), 0.0)
723
+ area = numpy.sqrt(areaSq)
724
+ valid = area > 0.0
725
+ if not numpy.any(valid):
726
+ hull = pointsGdf.unary_union.convex_hull
727
+ return geopandas.GeoDataFrame({"name": ["outerBoundary"]}, geometry=[hull], crs=pointsGdf.crs)
728
+
729
+ circumradius = (a * b * c) / (4.0 * area)
730
+ keep = valid & (circumradius < (1.0 / alpha))
731
+ keptSimplices = simplices[keep]
732
+ if keptSimplices.size == 0:
733
+ hull = pointsGdf.unary_union.convex_hull
734
+ return geopandas.GeoDataFrame({"name": ["outerBoundary"]}, geometry=[hull], crs=pointsGdf.crs)
735
+
736
+ # count triangle edges; boundary edges appear exactly once
737
+ edgeCounts: dict[tuple[int, int], int] = {}
738
+ for i0, i1, i2 in keptSimplices:
739
+ for e in ((i0, i1), (i1, i2), (i2, i0)):
740
+ key = (e[0], e[1]) if e[0] < e[1] else (e[1], e[0])
741
+ edgeCounts[key] = edgeCounts.get(key, 0) + 1
742
+
743
+ boundaryLines = [
744
+ LineString([coords[i], coords[j]])
745
+ for (i, j), count in edgeCounts.items()
746
+ if count == 1
747
+ ]
748
+ if not boundaryLines:
749
+ hull = pointsGdf.unary_union.convex_hull
750
+ return geopandas.GeoDataFrame({"name": ["outerBoundary"]}, geometry=[hull], crs=pointsGdf.crs)
751
+
752
+ polygons = list(polygonize(boundaryLines))
753
+ if not polygons:
754
+ hull = pointsGdf.unary_union.convex_hull
755
+ return geopandas.GeoDataFrame({"name": ["outerBoundary"]}, geometry=[hull], crs=pointsGdf.crs)
756
+
757
+ merged = unary_union(polygons)
758
+ if isinstance(merged, MultiPolygon):
759
+ merged = max(merged.geoms, key=lambda g: g.area)
760
+ if not keepHoles and isinstance(merged, Polygon):
761
+ merged = Polygon(merged.exterior)
762
+
763
+ return geopandas.GeoDataFrame({"name": ["outerBoundary"]}, geometry=[merged], crs=pointsGdf.crs)
764
+
765
+
630
766
  def mergeRasterTiles(tileList:list, outFile:str) -> str:
631
767
  '''
632
768
  Merge raster tiles into one raster file
@@ -977,6 +1113,116 @@ def extractCompressedFile(inputFile: str, outputDir: str, v: bool = False) -> No
977
1113
  """
978
1114
  uncompress(inputFile, outputDir, v)
979
1115
 
1116
+
1117
+
1118
+ def rasterizeGDF(gdf: geopandas.GeoDataFrame, valueField: str, outRasterFN: str, resolution: float, isCOG: bool = True, allTouched: bool = False, profileOverrides: dict | None = None) -> str:
1119
+ """
1120
+ Rasterize a GeoDataFrame to GeoTIFF/COG.
1121
+
1122
+ Parameters
1123
+ ----------
1124
+ gdf : geopandas.GeoDataFrame
1125
+ valueField : str
1126
+ Column to burn as pixel values.
1127
+ outRasterFN : str
1128
+ resolution : float
1129
+ Pixel size in CRS units (assumes a projected CRS).
1130
+ isCOG : bool, default True
1131
+ If True, writes Cloud-Optimized GeoTIFF. Otherwise plain GeoTIFF.
1132
+ allTouched : bool, default False
1133
+ Pass-through to rasterio.features.rasterize.
1134
+ profileOverrides : dict | None
1135
+ Extra GDAL profile options (e.g., {"compress": "LZW"}). Overrides defaults.
1136
+
1137
+ Returns
1138
+ -------
1139
+ str
1140
+ The path written to (outRasterFN).
1141
+ """
1142
+ # basic checks
1143
+ if gdf is None or len(gdf) == 0 or gdf.geometry.isna().all():
1144
+ raise ValueError("gdf is empty or has no valid geometries.")
1145
+ if gdf.crs is None:
1146
+ raise ValueError("gdf must have a defined CRS.")
1147
+ if resolution <= 0:
1148
+ raise ValueError("resolution must be > 0.")
1149
+ if valueField not in gdf.columns:
1150
+ raise ValueError(f"valueField '{valueField}' not found in gdf.")
1151
+
1152
+ # compute raster shape + transform
1153
+ bounds = gdf.total_bounds # (minx, miny, maxx, maxy)
1154
+ width = int(numpy.ceil((bounds[2] - bounds[0]) / float(resolution)))
1155
+ height = int(numpy.ceil((bounds[3] - bounds[1]) / float(resolution)))
1156
+ if width < 1 or height < 1:
1157
+ raise ValueError("computed raster dimensions are invalid (check resolution and bounds).")
1158
+
1159
+ transform = from_bounds(bounds[0], bounds[1], bounds[2], bounds[3], width, height)
1160
+
1161
+ # infer dtype + nodata
1162
+ pandasDtype = gdf[valueField].dtype
1163
+ if numpy.issubdtype(pandasDtype, numpy.floating):
1164
+ dtype = numpy.float32
1165
+ nodata = numpy.nan
1166
+ fillValue = numpy.nan
1167
+ elif numpy.issubdtype(pandasDtype, numpy.bool_):
1168
+ dtype = numpy.uint8
1169
+ nodata = 255 # sentinel for bool raster
1170
+ fillValue = nodata
1171
+ else:
1172
+ dtype = numpy.int32
1173
+ nodata = -9999
1174
+ fillValue = nodata
1175
+
1176
+ # prefill target array
1177
+ raster = numpy.full((height, width), fillValue, dtype=dtype)
1178
+
1179
+ # build shapes generator (ensure python scalars)
1180
+ shapes = ((geom, (None if numpy.isnan(val) else val) if isinstance(val, float) else int(val) if numpy.issubdtype(type(val), numpy.integer) else float(val))
1181
+ for geom, val in zip(gdf.geometry, gdf[valueField]))
1182
+
1183
+ # burn
1184
+ features.rasterize(
1185
+ shapes=shapes,
1186
+ out_shape=raster.shape,
1187
+ transform=transform,
1188
+ fill=fillValue,
1189
+ out=raster,
1190
+ all_touched=allTouched,
1191
+ dtype=dtype
1192
+ )
1193
+
1194
+ # default profile settings
1195
+ profile = {
1196
+ "driver": "COG" if isCOG else "GTiff",
1197
+ "height": raster.shape[0],
1198
+ "width": raster.shape[1],
1199
+ "count": 1,
1200
+ "dtype": raster.dtype,
1201
+ "crs": gdf.crs,
1202
+ "transform": transform,
1203
+ "nodata": nodata,
1204
+ }
1205
+ # sane compression defaults
1206
+ if isCOG:
1207
+ profile.setdefault("compress", "LZW")
1208
+ profile.setdefault("blocksize", 512) # GDAL COG option via rasterio
1209
+ # overviews are handled by the COG driver
1210
+ else:
1211
+ profile.setdefault("compress", "LZW"),
1212
+ profile.setdefault("tiled", True)
1213
+ profile.setdefault("blockxsize", 512)
1214
+ profile.setdefault("blockysize", 512)
1215
+
1216
+ if profileOverrides:
1217
+ profile.update(profileOverrides)
1218
+
1219
+ # write
1220
+ with rasterio.open(outRasterFN, "w", **profile) as dst:
1221
+ dst.write(raster, 1)
1222
+
1223
+ return outRasterFN
1224
+
1225
+
980
1226
  def moveDirectory(srcDir:str, destDir:str, v:bool = False) -> bool:
981
1227
  '''
982
1228
  this function moves all files from srcDir to destDir
@@ -1588,9 +1834,9 @@ def showProgress(count: int, end: int, message: str, barLength: int = 100) -> No
1588
1834
  percentStr = f'{percent:03.1f}'
1589
1835
  filled = int(barLength * count / end)
1590
1836
  bar = '█' * filled + '░' * (barLength - filled)
1591
- print(f'\r{message} |{bar}| {percent}% [{count}/{end}]', end='', flush=True)
1837
+ print(f'\r{message} |{bar}| {percentStr}% [{count}/{end}]', end='', flush=True)
1592
1838
  if count == end:
1593
- print(f'\r{message} |{bar}| {percent}% [{count}/{end}] ', end='', flush=True)
1839
+ print(f'\r{message} |{bar}| {percentStr}% [{count}/{end}] ', end='', flush=True)
1594
1840
  print()
1595
1841
 
1596
1842
 
@@ -2023,7 +2269,7 @@ def getTimeseriesStats(data:pandas.DataFrame, observed:Optional[str] = None, sim
2023
2269
 
2024
2270
  return stats
2025
2271
 
2026
- def readSWATPlusOutputs(filePath: str, column: Optional[str] = None, unit: Optional[int] = None, gis_id: Optional[int] = None, name: Optional[str] = None) -> Optional[pandas.DataFrame]:
2272
+ def readSWATPlusOutputs(filePath: str, column: Optional[str] = None, unit: Optional[int] = None, gis_id: Optional[int] = None, name: Optional[str] = None, coerceNumeric: bool = True) -> Optional[pandas.DataFrame]:
2027
2273
  '''
2028
2274
  Read SWAT+ output files and return a pandas DataFrame with proper date handling
2029
2275
  and optional filtering capabilities.
@@ -2085,8 +2331,15 @@ def readSWATPlusOutputs(filePath: str, column: Optional[str] = None, unit: Optio
2085
2331
  # Convert all columns to numeric except 'name' (which is string)
2086
2332
  for col in df.columns:
2087
2333
  if col != 'name':
2088
- df[col] = pandas.to_numeric(df[col], errors='coerce')
2334
+ if coerceNumeric:
2335
+ df[col] = pandas.to_numeric(df[col], errors='coerce')
2089
2336
 
2337
+ if not coerceNumeric:
2338
+ # If not coercing to numeric, ensure date columns are numeric
2339
+ for mandatoryCol in ['yr', 'mon', 'day', 'gis_id']:
2340
+ if mandatoryCol in df.columns:
2341
+ df[mandatoryCol] = pandas.to_numeric(df[mandatoryCol], errors='coerce')
2342
+
2090
2343
  # Create date column from yr, mon, day
2091
2344
  try:
2092
2345
  df['date'] = pandas.to_datetime(pandas.DataFrame({'year': df.yr, 'month': df.mon, 'day': df.day}))
@@ -2126,4 +2379,5 @@ def readSWATPlusOutputs(filePath: str, column: Optional[str] = None, unit: Optio
2126
2379
 
2127
2380
  return df
2128
2381
 
2382
+
2129
2383
  ignoreWarnings()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ccfx
3
- Version: 1.0.8
3
+ Version: 1.1.0
4
4
  Summary: This package simplifies regular common actions for quick prototyping in a user friendly way
5
5
  Author-email: Celray James CHAWANDA <celray@chawanda.com>
6
6
  License-Expression: MIT
@@ -26,6 +26,9 @@ Requires-Dist: mutagen
26
26
  Requires-Dist: requests
27
27
  Requires-Dist: tqdm
28
28
  Requires-Dist: pillow
29
+ Requires-Dist: scipy
30
+ Requires-Dist: rasterio
31
+ Requires-Dist: matplotlib
29
32
  Dynamic: license-file
30
33
 
31
34
  # ccfx
@@ -14,3 +14,6 @@ mutagen
14
14
  requests
15
15
  tqdm
16
16
  pillow
17
+ scipy
18
+ rasterio
19
+ matplotlib
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ccfx"
7
- version = "1.0.8"
7
+ version = "1.1.0"
8
8
  description = "This package simplifies regular common actions for quick prototyping in a user friendly way"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -31,6 +31,9 @@ dependencies = [
31
31
  "requests",
32
32
  "tqdm",
33
33
  "pillow",
34
+ "scipy",
35
+ "rasterio",
36
+ "matplotlib",
34
37
  ]
35
38
 
36
39
  [project.urls]
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
File without changes
File without changes