Compare commits
250 Commits
28f11ab190
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 035c4910a4 | |||
| b4b2921217 | |||
| 1aaf5259e2 | |||
| 6e8fd4aa42 | |||
| 00a17c9102 | |||
| 2898b649bb | |||
| c7c9012ef1 | |||
| a965987ab9 | |||
| bd8066c471 | |||
| 43feae622e | |||
| 88630bbcbc | |||
| a77edb5fa0 | |||
| 9647ae6345 | |||
| 53549ebee9 | |||
| 238b139b10 | |||
| bf32bb98ae | |||
| 73e72bcb3b | |||
| 4db35616af | |||
| 914ae29073 | |||
| 0dc724405b | |||
| 25112d496b | |||
| 958e066042 | |||
| 99fbfa3201 | |||
| a369b84d39 | |||
| 5c44d147b0 | |||
| 4b9e8ecf45 | |||
| 3d36a36f26 | |||
| 53af41b181 | |||
| cd7cc426ae | |||
| 2537a5ffe4 | |||
| c6b285ae18 | |||
| 91165056d7 | |||
| 24dc67b8ca | |||
| 66d3fa6893 | |||
| a78ad45013 | |||
| f27061b0ef | |||
| bd1824a7ca | |||
| 4806c95095 | |||
| 16c841d14d | |||
| bfb9ed54d9 | |||
| 3ac725c2aa | |||
| b5a0811cbd | |||
| 1ac8cd04b3 | |||
| 203d036a92 | |||
| fa2ac22f9f | |||
| 2013ccf627 | |||
| e1d94f2b24 | |||
| 9df6d73db8 | |||
| 01340a0a81 | |||
| 44ed4220b9 | |||
| 151744d144 | |||
| e823a11929 | |||
| c1c01e86ca | |||
| 184aceeee3 | |||
| db2aa57ce5 | |||
| 92c2e62166 | |||
| 86c31a49d9 | |||
| f5b8656bc2 | |||
| b9c60ffc25 | |||
| b6c7863b77 | |||
| 612d024161 | |||
| 840440eb1a | |||
| c3bf49f301 | |||
| 192a5c7124 | |||
| 2246ef9f45 | |||
| c52d9b9399 | |||
| 10284dad81 | |||
| a2dc4a2186 | |||
| 5d76681ded | |||
| f8acef2da4 | |||
| 65b80034cb | |||
| 5400592afd | |||
| e6616ed1b1 | |||
| 048e8ef033 | |||
| c08d5c5999 | |||
| 8c1efb1b05 | |||
| f942392fb3 | |||
| c749d9af80 | |||
| 71e5870306 | |||
| e813be2890 | |||
| 80fb35cced | |||
| d8b4439382 | |||
| 463228baf5 | |||
| e7571a78f4 | |||
| ea008ba23c | |||
| 366c338c5d | |||
| 0d26ffaca4 | |||
| aaf78bf0da | |||
| 43d350fff2 | |||
| d1b9e7c470 | |||
| c50234f5c1 | |||
| 171155e528 | |||
| 710a1f7de3 | |||
| 13fbc45b74 | |||
| 8b4f8026cc | |||
| 5c66935157 | |||
| bae760837c | |||
| 4a1649a568 | |||
| ea1a6e58f4 | |||
| 0c3e5e21bf | |||
| 472efbb9d9 | |||
| dd2f40460b | |||
| b2c7cf11e9 | |||
| e0e5c8d933 | |||
| 04e391551e | |||
| f9f442a2d0 | |||
| 0fd108bc9a | |||
| 83ef71934b | |||
| b123b12d0d | |||
| 1bd935646e | |||
| c3e0088a60 | |||
| 68a1cc3e7d | |||
| 498a1911b1 | |||
| d068da20f4 | |||
| 84a0748f0b | |||
| 8c45b30bca | |||
| 615a3dce0d | |||
| 1ce05d33ba | |||
| 1aea3b8a6e | |||
| fbac3b0dbb | |||
| b90b5e5725 | |||
| ed6f809029 | |||
| 8a7e2609c5 | |||
| dd1bc12667 | |||
| 9c14249f88 | |||
| 47ec7fed04 | |||
| e80278a2dd | |||
| f1d4145e43 | |||
| 1d987a341a | |||
| 47ce52da37 | |||
| 6c86271428 | |||
| d0d2f66b11 | |||
| eeaeff6fe0 | |||
| d478b28e0d | |||
| b440da3094 | |||
| fdf7d98850 | |||
| 3ee5c1bddc | |||
| 1d1d113a92 | |||
| e162e4fe92 | |||
| cd86cfc9f2 | |||
| 33a553c092 | |||
| 2979dca40a | |||
| cb097c55f1 | |||
| 70364d0458 | |||
| c88c2cc354 | |||
| 9085a82bdd | |||
| 85891a5f99 | |||
| 66b23834fd | |||
| 5baa2572ea | |||
| c7c092d3f3 | |||
| f0d540be27 | |||
| c8dfcca954 | |||
| b9cf9f0125 | |||
| b8899004f3 | |||
| 8c4663c4ef | |||
| 9dd0c837b4 | |||
| c56b012246 | |||
| 46f4441357 | |||
| d60828d787 | |||
| cfd919a377 | |||
| d235fa693e | |||
| 97e4a140eb | |||
| ae2b156b87 | |||
| 01aaa36eb0 | |||
| 83b40af001 | |||
| 1a086f9362 | |||
| 8f77960183 | |||
| d6af05b6db | |||
| 76245b3adc | |||
| 6559310adc | |||
| 01ea25168a | |||
| fc0aa1317b | |||
| a6886a8ab8 | |||
| 4d4cba9876 | |||
| 0847ea1abd | |||
| 37e9e99f64 | |||
| 15382b8fe7 | |||
| 9763597af8 | |||
| 4d60526952 | |||
| 87ead9189f | |||
| 1e1a886766 | |||
| 34179e5922 | |||
| f9272e76eb | |||
| 1a05963c31 | |||
| 3a8f8d26d3 | |||
| 56d6e04b48 | |||
| 6efbfa0c11 | |||
| d1b26fe8b4 | |||
| 1da8efc528 | |||
| 0dbf82f76b | |||
| 4651ba51f1 | |||
| 2961fe088d | |||
| 0fb591d0b3 | |||
| 709e637e88 | |||
| 252cda9ad3 | |||
| d29d45d4fd | |||
| b7e4fac9e7 | |||
| a815679a38 | |||
| ce8560aafb | |||
| 0740af6024 | |||
| b1ade237a7 | |||
| eb9b4d9c8c | |||
| 84993c4fc8 | |||
| ed0e8b3d6d | |||
| e5e4dea2a3 | |||
| 8a266303a5 | |||
| 366de8e796 | |||
| b85e757871 | |||
| 7f08f38457 | |||
| 161b221992 | |||
| 4a8492dcd2 | |||
| a7c5398faf | |||
| f919015e6b | |||
| 6f3f03d863 | |||
| dd237d0723 | |||
| b54131e4e7 | |||
| 204dcf491d | |||
| 0adcc8f32a | |||
| 4ffd4cd321 | |||
| b510ec9637 | |||
| 30cdd9d0e5 | |||
| fa89b41355 | |||
| 24c8021bd3 | |||
| 25811834ea | |||
| cf0d53223e | |||
| b55d3ddcd9 | |||
| 199af9ee0d | |||
| f50118b699 | |||
| 1e0c42c36b | |||
| c4c88c8175 | |||
| 887f735a27 | |||
| 0a73926427 | |||
| 007e371db6 | |||
| 6c8a5dad8e | |||
| 6e9bb9ad8e | |||
| df103e4070 | |||
| 1f823a7465 | |||
| 85bef2b3bd | |||
| 525ecd2cf5 | |||
| b59e3bd570 | |||
| f8780a2d43 | |||
| ad4130906b | |||
| 10eed9efcd | |||
| dbefc5b359 | |||
| 31240dabf9 | |||
| 22bba12d7e | |||
| d739e40862 | |||
| cd89bbf4e4 | |||
| 692c413f13 | |||
| cf09fd172e |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
__pycache__
|
||||
croppa/build/lib
|
||||
croppa/croppa.egg-info
|
||||
*.log
|
||||
*.mp4
|
||||
|
||||
14
cleaner/go.mod
Normal file
14
cleaner/go.mod
Normal file
@@ -0,0 +1,14 @@
|
||||
module tcleaner
|
||||
|
||||
go 1.23.6
|
||||
|
||||
require git.site.quack-lab.dev/dave/cylogger v1.4.0
|
||||
|
||||
require (
|
||||
github.com/google/go-cmp v0.5.9 // indirect
|
||||
github.com/hexops/valast v1.5.0 // indirect
|
||||
golang.org/x/mod v0.7.0 // indirect
|
||||
golang.org/x/sys v0.3.0 // indirect
|
||||
golang.org/x/tools v0.4.0 // indirect
|
||||
mvdan.cc/gofumpt v0.4.0 // indirect
|
||||
)
|
||||
28
cleaner/go.sum
Normal file
28
cleaner/go.sum
Normal file
@@ -0,0 +1,28 @@
|
||||
git.site.quack-lab.dev/dave/cylogger v1.4.0 h1:3Ca7V5JWvruARJd5S8xDFwW9LnZ9QInqkYLRdrEFvuY=
|
||||
git.site.quack-lab.dev/dave/cylogger v1.4.0/go.mod h1:wctgZplMvroA4X6p8f4B/LaCKtiBcT1Pp+L14kcS8jk=
|
||||
github.com/frankban/quicktest v1.14.3 h1:FJKSZTDHjyhriyC81FLQ0LY93eSai0ZyR/ZIkd3ZUKE=
|
||||
github.com/frankban/quicktest v1.14.3/go.mod h1:mgiwOwqx65TmIk1wJ6Q7wvnVMocbUorkibMOrVTHZps=
|
||||
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/hexops/autogold v0.8.1 h1:wvyd/bAJ+Dy+DcE09BoLk6r4Fa5R5W+O+GUzmR985WM=
|
||||
github.com/hexops/autogold v0.8.1/go.mod h1:97HLDXyG23akzAoRYJh/2OBs3kd80eHyKPvZw0S5ZBY=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/hexops/valast v1.5.0 h1:FBTuvVi0wjTngtXJRZXMbkN/Dn6DgsUsBwch2DUJU8Y=
|
||||
github.com/hexops/valast v1.5.0/go.mod h1:Jcy1pNH7LNraVaAZDLyv21hHg2WBv9Nf9FL6fGxU7o4=
|
||||
github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0=
|
||||
github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||
golang.org/x/mod v0.7.0 h1:LapD9S96VoQRhi/GrNTqeBJFrUjs5UHCAtTlgwA5oZA=
|
||||
golang.org/x/mod v0.7.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sys v0.3.0 h1:w8ZOecv6NaNa/zC8944JTU3vz4u6Lagfk4RPQxv92NQ=
|
||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/tools v0.4.0 h1:7mTAgkunk3fr4GAloyyCasadO6h9zSsQZbwvcaIciV4=
|
||||
golang.org/x/tools v0.4.0/go.mod h1:UE5sM2OK9E/d67R0ANs2xJizIymRP5gJU295PvKXxjQ=
|
||||
mvdan.cc/gofumpt v0.4.0 h1:JVf4NN1mIpHogBj7ABpgOyZc65/UUOkKQFkoURsz4MM=
|
||||
mvdan.cc/gofumpt v0.4.0/go.mod h1:PljLOHDeZqgS8opHRKLzp2It2VBuSdteAgqUfzMTxlQ=
|
||||
7
cleaner/install_grader_context_menu.reg
Normal file
7
cleaner/install_grader_context_menu.reg
Normal file
@@ -0,0 +1,7 @@
|
||||
Windows Registry Editor Version 5.00
|
||||
|
||||
[HKEY_CURRENT_USER\Software\Classes\*\shell\Clean name]
|
||||
@="Clean name"
|
||||
|
||||
[HKEY_CURRENT_USER\Software\Classes\*\shell\Clean name\command]
|
||||
@="C:\\Users\\administrator\\go\\bin\\tcleaner.exe \"%1\""
|
||||
76
cleaner/main.go
Normal file
76
cleaner/main.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
logger "git.site.quack-lab.dev/dave/cylogger"
|
||||
)
|
||||
|
||||
func main() {
|
||||
flag.Parse()
|
||||
logger.InitFlag()
|
||||
if flag.NArg() == 0 {
|
||||
fmt.Println("Usage: cleaner <files>")
|
||||
os.Exit(1)
|
||||
}
|
||||
// regex to match "2025-07-08"
|
||||
re := regexp.MustCompile(`\d{4}-\d{2}-\d{2}`)
|
||||
editedRe := regexp.MustCompile(`_edited_\d{5}`)
|
||||
|
||||
for _, file := range flag.Args() {
|
||||
filelog := logger.Default.WithPrefix(file)
|
||||
filelog.Info("Processing file")
|
||||
|
||||
info, err := os.Stat(file)
|
||||
if err != nil {
|
||||
filelog.Error("ERROR: %v\n", err)
|
||||
continue
|
||||
}
|
||||
if info.IsDir() {
|
||||
filelog.Info("SKIP (directory): %s\n", file)
|
||||
continue
|
||||
}
|
||||
|
||||
name := filepath.Base(file)
|
||||
match := re.FindStringSubmatch(name)
|
||||
filelog.Debug("Match: %v", match)
|
||||
if match == nil {
|
||||
filelog.Info("SKIP (no date pattern): %s\n", name)
|
||||
continue
|
||||
}
|
||||
|
||||
namePart := match[0]
|
||||
editMatch := editedRe.FindStringSubmatch(name)
|
||||
filelog.Debug("Edit match: %v", editMatch)
|
||||
if editMatch != nil {
|
||||
namePart = namePart + editMatch[0]
|
||||
filelog.Info("Video has edited part, new name: %s", namePart)
|
||||
}
|
||||
|
||||
newName := namePart + filepath.Ext(name)
|
||||
filelog.Debug("New name: %s", newName)
|
||||
if name == newName {
|
||||
filelog.Info("SKIP (already named): %s\n", name)
|
||||
continue
|
||||
}
|
||||
|
||||
filelog.Debug("Checking if target exists: %s", newName)
|
||||
if _, err := os.Stat(newName); err == nil {
|
||||
filelog.Info("SKIP (target exists): %s -> %s\n", name, newName)
|
||||
continue
|
||||
}
|
||||
|
||||
filelog.Info("Renaming to: %s", newName)
|
||||
err = os.Rename(name, newName)
|
||||
if err != nil {
|
||||
filelog.Error("ERROR renaming %s: %v\n", name, err)
|
||||
} else {
|
||||
filelog.Info("RENAMED: %s -> %s\n", name, newName)
|
||||
}
|
||||
filelog.Info("All done")
|
||||
continue
|
||||
}
|
||||
}
|
||||
68
croppa/capture.py
Normal file
68
croppa/capture.py
Normal file
@@ -0,0 +1,68 @@
|
||||
import cv2
|
||||
from collections import OrderedDict
|
||||
|
||||
|
||||
class Cv2BufferedCap:
|
||||
"""Buffered wrapper around cv2.VideoCapture that handles frame loading, seeking, and caching correctly"""
|
||||
|
||||
def __init__(self, video_path, backend=None, cache_size=10000):
|
||||
self.video_path = video_path
|
||||
self.cap = cv2.VideoCapture(str(video_path), backend)
|
||||
if not self.cap.isOpened():
|
||||
raise ValueError(f"Could not open video: {video_path}")
|
||||
|
||||
# Video properties
|
||||
self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
self.fps = self.cap.get(cv2.CAP_PROP_FPS)
|
||||
self.frame_width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
self.frame_height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
|
||||
# Current position tracking
|
||||
self.current_frame = 0
|
||||
|
||||
# Frame cache (LRU)
|
||||
self.cache_size = cache_size
|
||||
self.frame_cache = OrderedDict()
|
||||
|
||||
def get_frame(self, frame_number):
|
||||
"""Get frame at specific index - always accurate"""
|
||||
# Clamp frame number to valid range
|
||||
frame_number = max(0, min(frame_number, self.total_frames - 1))
|
||||
|
||||
# Check cache first
|
||||
if frame_number in self.frame_cache:
|
||||
self.frame_cache.move_to_end(frame_number)
|
||||
return self.frame_cache[frame_number]
|
||||
|
||||
# Optimize for sequential reading (next frame)
|
||||
if frame_number == self.current_frame + 1:
|
||||
ret, frame = self.cap.read()
|
||||
else:
|
||||
# Seek for non-sequential access
|
||||
self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
|
||||
ret, frame = self.cap.read()
|
||||
|
||||
if ret:
|
||||
self.current_frame = frame_number
|
||||
# Store in cache, evict least recently used if cache is full
|
||||
if len(self.frame_cache) >= self.cache_size:
|
||||
self.frame_cache.popitem(last=False)
|
||||
self.frame_cache[frame_number] = frame
|
||||
self.frame_cache.move_to_end(frame_number)
|
||||
return frame
|
||||
else:
|
||||
raise ValueError(f"Failed to read frame {frame_number}")
|
||||
|
||||
def advance_frame(self, frames=1):
|
||||
"""Advance by specified number of frames"""
|
||||
new_frame = self.current_frame + frames
|
||||
return self.get_frame(new_frame)
|
||||
|
||||
def release(self):
|
||||
"""Release the video capture"""
|
||||
if self.cap:
|
||||
self.cap.release()
|
||||
|
||||
def isOpened(self):
|
||||
"""Check if capture is opened"""
|
||||
return self.cap and self.cap.isOpened()
|
||||
5500
croppa/main.py
5500
croppa/main.py
File diff suppressed because it is too large
Load Diff
351
croppa/project_view.py
Normal file
351
croppa/project_view.py
Normal file
@@ -0,0 +1,351 @@
|
||||
import cv2
|
||||
import json
|
||||
import numpy as np
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class ProjectView:
|
||||
"""Project view that displays videos in current directory with progress bars"""
|
||||
|
||||
# Project view configuration
|
||||
THUMBNAIL_SIZE = (200, 150) # Width, Height
|
||||
THUMBNAIL_MARGIN = 20
|
||||
PROGRESS_BAR_HEIGHT = 8
|
||||
TEXT_HEIGHT = 30
|
||||
|
||||
# Colors
|
||||
BG_COLOR = (40, 40, 40)
|
||||
THUMBNAIL_BG_COLOR = (60, 60, 60)
|
||||
PROGRESS_BG_COLOR = (80, 80, 80)
|
||||
PROGRESS_FILL_COLOR = (0, 120, 255)
|
||||
TEXT_COLOR = (255, 255, 255)
|
||||
SELECTED_COLOR = (255, 165, 0)
|
||||
|
||||
def __init__(self, directory: Path, video_editor):
|
||||
self.directory = directory
|
||||
self.video_editor = video_editor
|
||||
self.video_files = []
|
||||
self.thumbnails = {}
|
||||
self.progress_data = {}
|
||||
self.selected_index = 0
|
||||
self.scroll_offset = 0
|
||||
self.items_per_row = 2 # Default to 2 items per row
|
||||
self.window_width = 1920 # Increased to accommodate 1080p videos
|
||||
self.window_height = 1200
|
||||
|
||||
self._load_video_files()
|
||||
self._load_progress_data()
|
||||
|
||||
def _calculate_thumbnail_size(self, window_width: int) -> tuple:
|
||||
"""Calculate thumbnail size based on items per row and window width"""
|
||||
available_width = window_width - self.THUMBNAIL_MARGIN
|
||||
item_width = (available_width - (self.items_per_row - 1) * self.THUMBNAIL_MARGIN) // self.items_per_row
|
||||
thumbnail_width = max(50, item_width) # Minimum 50px width
|
||||
thumbnail_height = int(thumbnail_width * self.THUMBNAIL_SIZE[1] / self.THUMBNAIL_SIZE[0]) # Maintain aspect ratio
|
||||
return (thumbnail_width, thumbnail_height)
|
||||
|
||||
def _load_video_files(self):
|
||||
"""Load all video files from directory"""
|
||||
self.video_files = []
|
||||
for file_path in self.directory.iterdir():
|
||||
if (file_path.is_file() and
|
||||
file_path.suffix.lower() in self.video_editor.VIDEO_EXTENSIONS):
|
||||
self.video_files.append(file_path)
|
||||
self.video_files.sort(key=lambda x: x.name)
|
||||
|
||||
def _load_progress_data(self):
|
||||
"""Load progress data from JSON state files"""
|
||||
self.progress_data = {}
|
||||
for video_path in self.video_files:
|
||||
state_file = video_path.with_suffix('.json')
|
||||
if state_file.exists():
|
||||
try:
|
||||
with open(state_file, 'r') as f:
|
||||
state = json.load(f)
|
||||
current_frame = state.get('current_frame', 0)
|
||||
|
||||
# Get total frames from video
|
||||
cap = cv2.VideoCapture(str(video_path))
|
||||
if cap.isOpened():
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
cap.release()
|
||||
|
||||
if total_frames > 0:
|
||||
progress = current_frame / (total_frames - 1)
|
||||
self.progress_data[video_path] = {
|
||||
'current_frame': current_frame,
|
||||
'total_frames': total_frames,
|
||||
'progress': progress
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error loading progress for {video_path.name}: {e}")
|
||||
|
||||
def refresh_progress_data(self):
|
||||
"""Refresh progress data from JSON files (call when editor state changes)"""
|
||||
self._load_progress_data()
|
||||
|
||||
def get_progress_for_video(self, video_path: Path) -> float:
|
||||
"""Get progress (0.0 to 1.0) for a video"""
|
||||
if video_path in self.progress_data:
|
||||
return self.progress_data[video_path]['progress']
|
||||
return 0.0
|
||||
|
||||
def get_thumbnail_for_video(self, video_path: Path, size: tuple = None) -> np.ndarray:
|
||||
"""Get thumbnail for a video, generating it if needed"""
|
||||
if size is None:
|
||||
size = self.THUMBNAIL_SIZE
|
||||
|
||||
# Cache the original thumbnail by video path only (not size)
|
||||
if video_path in self.thumbnails:
|
||||
original_thumbnail = self.thumbnails[video_path]
|
||||
# Resize the cached thumbnail to the requested size
|
||||
return cv2.resize(original_thumbnail, size)
|
||||
|
||||
# Generate original thumbnail on demand (only once per video)
|
||||
try:
|
||||
cap = cv2.VideoCapture(str(video_path))
|
||||
if cap.isOpened():
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
if total_frames > 0:
|
||||
middle_frame = total_frames // 2
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, middle_frame)
|
||||
ret, frame = cap.read()
|
||||
if ret:
|
||||
# Store original thumbnail at original size
|
||||
original_thumbnail = cv2.resize(frame, self.THUMBNAIL_SIZE)
|
||||
self.thumbnails[video_path] = original_thumbnail
|
||||
cap.release()
|
||||
# Return resized version
|
||||
return cv2.resize(original_thumbnail, size)
|
||||
cap.release()
|
||||
except Exception as e:
|
||||
print(f"Error generating thumbnail for {video_path.name}: {e}")
|
||||
|
||||
# Return a placeholder if thumbnail generation failed
|
||||
placeholder = np.full((size[1], size[0], 3),
|
||||
self.THUMBNAIL_BG_COLOR, dtype=np.uint8)
|
||||
return placeholder
|
||||
|
||||
def draw(self) -> np.ndarray:
|
||||
"""Draw the project view"""
|
||||
# Get actual window size dynamically
|
||||
try:
|
||||
# Try to get the actual window size from OpenCV
|
||||
window_rect = cv2.getWindowImageRect("Project View")
|
||||
if window_rect[2] > 0 and window_rect[3] > 0: # width and height > 0
|
||||
actual_width = window_rect[2]
|
||||
actual_height = window_rect[3]
|
||||
else:
|
||||
# Fallback to default size
|
||||
actual_width = self.window_width
|
||||
actual_height = self.window_height
|
||||
except:
|
||||
# Fallback to default size
|
||||
actual_width = self.window_width
|
||||
actual_height = self.window_height
|
||||
|
||||
canvas = np.full((actual_height, actual_width, 3), self.BG_COLOR, dtype=np.uint8)
|
||||
|
||||
if not self.video_files:
|
||||
# No videos message
|
||||
text = "No videos found in directory"
|
||||
font = cv2.FONT_HERSHEY_SIMPLEX
|
||||
text_size = cv2.getTextSize(text, font, 1.0, 2)[0]
|
||||
text_x = (actual_width - text_size[0]) // 2
|
||||
text_y = (actual_height - text_size[1]) // 2
|
||||
cv2.putText(canvas, text, (text_x, text_y), font, 1.0, self.TEXT_COLOR, 2)
|
||||
return canvas
|
||||
|
||||
# Calculate layout - use fixed items_per_row and calculate thumbnail size to fit
|
||||
items_per_row = min(self.items_per_row, len(self.video_files)) # Don't exceed number of videos
|
||||
|
||||
# Calculate thumbnail size to fit the desired number of items per row
|
||||
thumbnail_width, thumbnail_height = self._calculate_thumbnail_size(actual_width)
|
||||
|
||||
# Calculate item height dynamically based on thumbnail size
|
||||
item_height = thumbnail_height + self.PROGRESS_BAR_HEIGHT + self.TEXT_HEIGHT + self.THUMBNAIL_MARGIN
|
||||
|
||||
item_width = (actual_width - (items_per_row + 1) * self.THUMBNAIL_MARGIN) // items_per_row
|
||||
|
||||
# Draw videos in grid
|
||||
for i, video_path in enumerate(self.video_files):
|
||||
row = i // items_per_row
|
||||
col = i % items_per_row
|
||||
|
||||
# Skip if scrolled out of view
|
||||
if row < self.scroll_offset:
|
||||
continue
|
||||
if row > self.scroll_offset + (actual_height // item_height):
|
||||
break
|
||||
|
||||
# Calculate position
|
||||
x = self.THUMBNAIL_MARGIN + col * (item_width + self.THUMBNAIL_MARGIN)
|
||||
y = self.THUMBNAIL_MARGIN + (row - self.scroll_offset) * item_height
|
||||
|
||||
# Draw thumbnail background
|
||||
cv2.rectangle(canvas,
|
||||
(x, y),
|
||||
(x + thumbnail_width, y + thumbnail_height),
|
||||
self.THUMBNAIL_BG_COLOR, -1)
|
||||
|
||||
# Draw selection highlight
|
||||
if i == self.selected_index:
|
||||
cv2.rectangle(canvas,
|
||||
(x - 2, y - 2),
|
||||
(x + thumbnail_width + 2, y + thumbnail_height + 2),
|
||||
self.SELECTED_COLOR, 3)
|
||||
|
||||
# Draw thumbnail
|
||||
thumbnail = self.get_thumbnail_for_video(video_path, (thumbnail_width, thumbnail_height))
|
||||
# Thumbnail is already the correct size, no need to resize
|
||||
resized_thumbnail = thumbnail
|
||||
|
||||
# Ensure thumbnail doesn't exceed canvas bounds
|
||||
end_y = min(y + thumbnail_height, actual_height)
|
||||
end_x = min(x + thumbnail_width, actual_width)
|
||||
thumb_height = end_y - y
|
||||
thumb_width = end_x - x
|
||||
|
||||
if thumb_height > 0 and thumb_width > 0:
|
||||
# Resize thumbnail to fit within bounds if necessary
|
||||
if thumb_height != thumbnail_height or thumb_width != thumbnail_width:
|
||||
resized_thumbnail = cv2.resize(thumbnail, (thumb_width, thumb_height))
|
||||
|
||||
canvas[y:end_y, x:end_x] = resized_thumbnail
|
||||
|
||||
# Draw progress bar
|
||||
progress_y = y + thumbnail_height + 5
|
||||
progress_width = thumbnail_width
|
||||
progress = self.get_progress_for_video(video_path)
|
||||
|
||||
# Progress background
|
||||
cv2.rectangle(canvas,
|
||||
(x, progress_y),
|
||||
(x + progress_width, progress_y + self.PROGRESS_BAR_HEIGHT),
|
||||
self.PROGRESS_BG_COLOR, -1)
|
||||
|
||||
# Progress fill
|
||||
if progress > 0:
|
||||
fill_width = int(progress_width * progress)
|
||||
cv2.rectangle(canvas,
|
||||
(x, progress_y),
|
||||
(x + fill_width, progress_y + self.PROGRESS_BAR_HEIGHT),
|
||||
self.PROGRESS_FILL_COLOR, -1)
|
||||
|
||||
# Draw filename
|
||||
filename = video_path.name
|
||||
# Truncate if too long
|
||||
if len(filename) > 25:
|
||||
filename = filename[:22] + "..."
|
||||
|
||||
text_y = progress_y + self.PROGRESS_BAR_HEIGHT + 20
|
||||
cv2.putText(canvas, filename, (x, text_y),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.6, self.TEXT_COLOR, 2)
|
||||
|
||||
# Draw progress percentage
|
||||
if video_path in self.progress_data:
|
||||
progress_text = f"{progress * 100:.0f}%"
|
||||
text_size = cv2.getTextSize(progress_text, cv2.FONT_HERSHEY_SIMPLEX, 0.4, 1)[0]
|
||||
progress_text_x = x + progress_width - text_size[0]
|
||||
cv2.putText(canvas, progress_text, (progress_text_x, text_y),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.4, self.TEXT_COLOR, 1)
|
||||
|
||||
# Draw instructions
|
||||
instructions = [
|
||||
"Project View - Videos in current directory",
|
||||
"WASD: Navigate | E: Open video | Q: Fewer items per row | Y: More items per row | q: Quit | ESC: Back to editor",
|
||||
f"Showing {len(self.video_files)} videos | {items_per_row} per row | Thumbnail: {thumbnail_width}x{thumbnail_height}"
|
||||
]
|
||||
|
||||
for i, instruction in enumerate(instructions):
|
||||
y_pos = actual_height - 60 + i * 20
|
||||
cv2.putText(canvas, instruction, (10, y_pos),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.5, self.TEXT_COLOR, 1)
|
||||
|
||||
return canvas
|
||||
|
||||
def handle_key(self, key: int) -> str:
|
||||
"""Handle keyboard input, returns action taken"""
|
||||
if key == 27: # ESC
|
||||
return "back_to_editor"
|
||||
elif key == ord('q'): # lowercase q - Quit
|
||||
return "quit"
|
||||
elif key == ord('e') or key == ord('E'): # E - Open video
|
||||
if self.video_files and 0 <= self.selected_index < len(self.video_files):
|
||||
return f"open_video:{self.video_files[self.selected_index]}"
|
||||
elif key == ord('w') or key == ord('W'): # W - Up
|
||||
current_items_per_row = min(self.items_per_row, len(self.video_files))
|
||||
if self.selected_index >= current_items_per_row:
|
||||
self.selected_index -= current_items_per_row
|
||||
else:
|
||||
self.selected_index = 0
|
||||
self._update_scroll()
|
||||
elif key == ord('s') or key == ord('S'): # S - Down
|
||||
current_items_per_row = min(self.items_per_row, len(self.video_files))
|
||||
if self.selected_index + current_items_per_row < len(self.video_files):
|
||||
self.selected_index += current_items_per_row
|
||||
else:
|
||||
self.selected_index = len(self.video_files) - 1
|
||||
self._update_scroll()
|
||||
elif key == ord('a') or key == ord('A'): # A - Left
|
||||
if self.selected_index > 0:
|
||||
self.selected_index -= 1
|
||||
self._update_scroll()
|
||||
elif key == ord('d') or key == ord('D'): # D - Right
|
||||
if self.selected_index < len(self.video_files) - 1:
|
||||
self.selected_index += 1
|
||||
self._update_scroll()
|
||||
elif key == ord('Q'): # uppercase Q - Fewer items per row (larger thumbnails)
|
||||
if self.items_per_row > 1:
|
||||
self.items_per_row -= 1
|
||||
print(f"Items per row: {self.items_per_row}")
|
||||
elif key == ord('y') or key == ord('Y'): # Y - More items per row (smaller thumbnails)
|
||||
self.items_per_row += 1
|
||||
print(f"Items per row: {self.items_per_row}")
|
||||
|
||||
return "none"
|
||||
|
||||
def _update_scroll(self):
|
||||
"""Update scroll offset based on selected item"""
|
||||
if not self.video_files:
|
||||
return
|
||||
|
||||
# Use fixed items per row
|
||||
items_per_row = min(self.items_per_row, len(self.video_files))
|
||||
|
||||
# Get window dimensions for calculations
|
||||
try:
|
||||
window_rect = cv2.getWindowImageRect("Project View")
|
||||
if window_rect[2] > 0 and window_rect[3] > 0:
|
||||
window_width = window_rect[2]
|
||||
window_height = window_rect[3]
|
||||
else:
|
||||
window_width = self.window_width
|
||||
window_height = self.window_height
|
||||
except:
|
||||
window_width = self.window_width
|
||||
window_height = self.window_height
|
||||
|
||||
# Calculate thumbnail size and item height dynamically
|
||||
thumbnail_width, thumbnail_height = self._calculate_thumbnail_size(window_width)
|
||||
item_height = thumbnail_height + self.PROGRESS_BAR_HEIGHT + self.TEXT_HEIGHT + self.THUMBNAIL_MARGIN
|
||||
|
||||
selected_row = self.selected_index // items_per_row
|
||||
visible_rows = max(1, window_height // item_height)
|
||||
|
||||
# Calculate how many rows we can actually show
|
||||
total_rows = (len(self.video_files) + items_per_row - 1) // items_per_row
|
||||
|
||||
# If we can show all rows, no scrolling needed
|
||||
if total_rows <= visible_rows:
|
||||
self.scroll_offset = 0
|
||||
return
|
||||
|
||||
# Update scroll to keep selected item visible
|
||||
if selected_row < self.scroll_offset:
|
||||
self.scroll_offset = selected_row
|
||||
elif selected_row >= self.scroll_offset + visible_rows:
|
||||
self.scroll_offset = selected_row - visible_rows + 1
|
||||
|
||||
# Ensure scroll offset doesn't go negative or beyond available content
|
||||
self.scroll_offset = max(0, min(self.scroll_offset, total_rows - visible_rows))
|
||||
@@ -2,12 +2,15 @@
|
||||
name = "croppa"
|
||||
version = "0.1.0"
|
||||
description = "Fast and lightweight video editor for cropping, zooming, and cutting"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"opencv-python>=4.8.0",
|
||||
"numpy>=1.24.0"
|
||||
"numpy>=1.24.0",
|
||||
"Pillow>=10.0.0"
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
croppa = "main:main"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["capture", "main", "project_view", "tracking", "utils"]
|
||||
138
croppa/spec.md
Normal file
138
croppa/spec.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Croppa - Feature Specification
|
||||
|
||||
## Overview
|
||||
Croppa is a lightweight video and image editor that provides real-time editing capabilities with persistent state management.
|
||||
|
||||
## Notes:
|
||||
|
||||
Note the distinction between lowercase and uppercase keys
|
||||
Uppercase keys imply shift+key
|
||||
|
||||
Note that every transformation (cropping, motion track points) are almost always applied to an already transformed frame
|
||||
Be that by rotation, cropping, zooming or motion tracking itself
|
||||
Which means that the user input must be "de-transformed" before being applied to the frame
|
||||
In other words if we zoom into an area and right click to add a tracking point it must be added to that exact pixel ON THE ORIGINAL FRAME
|
||||
And NOT the zoomed in / processed frame
|
||||
Likwise with rotations
|
||||
All coordinates (crop region, zoom center, motion tracking point) are to be in reference to the original raw unprocessed frame
|
||||
To then display these points they must be transformed to the processed - display - frame
|
||||
Likewise when accepting user input from the processed display frame the coordinates must be transformed back to the original raw unprocessed frame
|
||||
A simple example if we are rotated by 90 degrees and click on the top left corner of the display frame
|
||||
That coordinate is to be mapped to the bottom left corner of the original raw unprocessed frame
|
||||
|
||||
The input to the editor is either a list of video files
|
||||
Or a directory
|
||||
In the case a directory is provided the editor is to open "all" editable files in the given directory
|
||||
In the case multiple files are open we are able to navigate between them using n and N keys for next and previous file
|
||||
Be careful to save and load settings when navigating this way
|
||||
|
||||
## Core Features
|
||||
|
||||
### Video Playback
|
||||
- **Space**: Play/pause video
|
||||
- **a/d**: Seek backward/forward 1 frame
|
||||
- **A/D**: Seek backward/forward 10 frames
|
||||
- **Ctrl+a/d**: Seek backward/forward 60 frames
|
||||
- **Mouse Wheel**: Seek backward/forward 1 frame (ignores seek multiplier)
|
||||
- **W/S**: Increase/decrease playback speed (0.1x to 10.0x, increments of 0.2)
|
||||
- **Q/Y**: Increase/decrease seek multiplier (multiplies the frame count for a/d/A/D/Ctrl+a/d keys by 1.0x to 100.0x, increments of 2.0)
|
||||
- **q**: Quit the program
|
||||
- **Timeline**: Click anywhere to jump to that position
|
||||
- **Auto-repeat**: Hold seek keys for continuous seeking at 1 FPS rate
|
||||
|
||||
### Visual Transformations
|
||||
- **-**: Rotate 90 degrees clockwise
|
||||
- **e/E**: Increase/decrease brightness (-100 to +100, increments of 5)
|
||||
- **r/R**: Increase/decrease contrast (0.1 to 3.0, increments of 0.1)
|
||||
- **Ctrl+Scroll**: Zoom in/out (0.1x to 10.0x, increments of 0.1)
|
||||
- **Ctrl+Click**: Set zoom center point
|
||||
|
||||
### Cropping
|
||||
- **Shift+Click+Drag**: Select crop area with green rectangle preview
|
||||
- **h/j/k/l**: Expand crop from right/down/up/left edges (15 pixels per keypress)
|
||||
- **H/J/K/L**: Contract crop to left/down/up/right edges (15 pixels per keypress)
|
||||
- **u**: Undo last crop
|
||||
- **c**: Clear all cropping
|
||||
- **C**: Complete reset (crop, zoom, rotation, brightness, contrast, tracking points, cut markers)
|
||||
|
||||
### Motion Tracking
|
||||
- **Right-click**: Add tracking point (green circle with white border)
|
||||
- **Right-click existing point**: Remove tracking point (within 10px)
|
||||
- **Right-click near existing point**: Snap to existing point from any frame (within 10px radius)
|
||||
- **Right-click near motion path**: Snap to closest point on yellow arrow line between tracking points (within 10px radius)
|
||||
- **v**: Toggle motion tracking on/off
|
||||
- **V**: Clear all tracking points
|
||||
- **Blue cross**: Shows computed tracking position
|
||||
- **Automatic interpolation**: Tracks between keyframes
|
||||
- **Crop follows**: Crop area centers on tracked object
|
||||
- **Display** Points are rendered as blue dots per frame, in addition the previous tracking point (red) and next tracking point (magenta) are shown with yellow arrows indicating motion direction
|
||||
|
||||
#### Motion Tracking Navigation
|
||||
- **,**: Jump to previous tracking marker (previous frame that has one or more tracking points). Goes to first marker if at beginning.
|
||||
- **.**: Jump to next tracking marker (next frame that has one or more tracking points). Goes to last marker if at end.
|
||||
|
||||
### Markers and Looping
|
||||
- **1**: Set cut start marker at current frame
|
||||
- **2**: Set cut end marker at current frame
|
||||
- **t**: Toggle loop playback between markers
|
||||
- **Red lines**: Markers shown on timeline with numbers
|
||||
- **Continuous loop**: Playback loops between markers when enabled
|
||||
|
||||
### File Management
|
||||
- **Enter**: Render video (overwrites if filename contains "_edited_")
|
||||
- **b**: Render video with new "_edited_001" filename (does NOT overwrite!)
|
||||
- **s**: Save screenshot with auto-incrementing filename (video_frame_00001.jpg, video_frame_00002.jpg, etc. - NEVER overwrite existing screenshots)
|
||||
- **N/n**: Next/previous video in directory
|
||||
- **p**: Toggle project view (directory browser)
|
||||
|
||||
### Project View
|
||||
- **wasd**: Navigate through video thumbnails
|
||||
- **e**: Open selected video
|
||||
- **Q/Y**: Change thumbnail size (fewer/more per row, size automatically computed to fit row)
|
||||
- **q**: Quit
|
||||
- **Progress bars**: Show editing progress for each video (blue bar showing current_frame/total_frames)
|
||||
- **ESC**: Return to editor
|
||||
|
||||
### Display and Interface
|
||||
- **f**: Toggle fullscreen
|
||||
- **Status overlay**: Shows "Frame: 1500/3000 | Speed: 1.5x | Zoom: 2.0x | Seek: 5.0x | Rotation: 90° | Brightness: 10 | Contrast: 1.2 | Motion: ON (3 pts) | Playing/Paused"
|
||||
- **Timeline**: Visual progress bar with current position handle
|
||||
- **Feedback messages**: Temporary on-screen notifications (e.g. "Screenshot saved: video_frame_00001.jpg")
|
||||
- **Progress bar**: Shows rendering progress with FPS counter (e.g. "Processing 1500/3000 frames | 25.3 FPS")
|
||||
|
||||
### State Management
|
||||
- **Auto-save**: Settings saved automatically on changes and on quit
|
||||
- **Per-video state**: Each video remembers its own settings
|
||||
- **Cross-session**: Settings persist between application restarts
|
||||
- **JSON files**: State stored as .json files next to videos with the same name as the video
|
||||
|
||||
### Rendering
|
||||
- **Background rendering**: Continue editing while rendering (rendering happens in separate thread, you can still seek/play/edit)
|
||||
- **x**: Cancel active render
|
||||
- **FFmpeg output**: Invoke FFmpeg process, pipe raw video frames via stdin, output MP4 with H.264 encoding (CRF 18, preset fast)
|
||||
- **Progress tracking**: Real-time progress with FPS display
|
||||
- **Overwrite protection**: Only overwrites files with "_edited_" in name
|
||||
|
||||
### Image Mode
|
||||
- **Same controls**: All editing features work on static images
|
||||
- **No playback**: Space key disabled, no timeline
|
||||
- **Screenshot mode**: Treats single images like video frames
|
||||
|
||||
### Error Handling
|
||||
- **Format support**: MP4, AVI, MOV, MKV, WMV, FLV, WebM, M4V, JPG, PNG, BMP, TIFF, WebP
|
||||
- **Backend fallback**: Tries multiple video backends automatically
|
||||
- **Error messages**: Clear feedback for common issues
|
||||
- **Graceful degradation**: Continues working when possible
|
||||
|
||||
### Performance Features
|
||||
- **Frame caching**: Smooth seeking with cached frames (cache the decoded frames, LRU eviction, max 3000 frames)
|
||||
- **Transformation caching**: Fast repeated operations (cache transformed frames during auto-repeat seeking)
|
||||
- **Memory management**: Automatic cache cleanup
|
||||
|
||||
### Window Management
|
||||
- **Resizable**: Window can be resized dynamically
|
||||
- **Multi-window**: Project view opens in separate window
|
||||
- **Focus handling**: Keys only affect active window
|
||||
- **Context menu**: Right-click integration on Windows
|
||||
|
||||
This specification describes what Croppa does from the user's perspective - the features, controls, and behaviors that make up the application.
|
||||
248
croppa/tracking.py
Normal file
248
croppa/tracking.py
Normal file
@@ -0,0 +1,248 @@
|
||||
import cv2
|
||||
import numpy as np
|
||||
from typing import Dict, Any
|
||||
|
||||
|
||||
class FeatureTracker:
|
||||
"""Semi-automatic feature tracking with SIFT/SURF/ORB support and full state serialization"""
|
||||
|
||||
def __init__(self):
|
||||
# Feature detection parameters
|
||||
self.detector_type = 'SIFT' # 'SIFT', 'SURF', 'ORB'
|
||||
self.max_features = 1000
|
||||
self.match_threshold = 0.7
|
||||
|
||||
# Tracking state
|
||||
self.features = {} # {frame_number: {'keypoints': [...], 'descriptors': [...], 'positions': [...]}}
|
||||
self.tracking_enabled = False
|
||||
self.auto_tracking = False
|
||||
|
||||
# Initialize detectors
|
||||
self._init_detectors()
|
||||
|
||||
def _init_detectors(self):
|
||||
"""Initialize feature detectors based on type"""
|
||||
try:
|
||||
if self.detector_type == 'SIFT':
|
||||
self.detector = cv2.SIFT_create(nfeatures=self.max_features)
|
||||
elif self.detector_type == 'SURF':
|
||||
# SURF requires opencv-contrib-python, fallback to SIFT
|
||||
print("Warning: SURF requires opencv-contrib-python package. Using SIFT instead.")
|
||||
self.detector = cv2.SIFT_create(nfeatures=self.max_features)
|
||||
self.detector_type = 'SIFT'
|
||||
elif self.detector_type == 'ORB':
|
||||
self.detector = cv2.ORB_create(nfeatures=self.max_features)
|
||||
else:
|
||||
raise ValueError(f"Unknown detector type: {self.detector_type}")
|
||||
except Exception as e:
|
||||
print(f"Warning: Could not initialize {self.detector_type} detector: {e}")
|
||||
# Fallback to ORB
|
||||
self.detector_type = 'ORB'
|
||||
self.detector = cv2.ORB_create(nfeatures=self.max_features)
|
||||
|
||||
def set_detector_type(self, detector_type: str):
|
||||
"""Change detector type and reinitialize"""
|
||||
if detector_type in ['SIFT', 'SURF', 'ORB']:
|
||||
self.detector_type = detector_type
|
||||
self._init_detectors()
|
||||
print(f"Switched to {detector_type} detector")
|
||||
else:
|
||||
print(f"Invalid detector type: {detector_type}")
|
||||
|
||||
def extract_features(self, frame: np.ndarray, frame_number: int, coord_mapper=None) -> bool:
|
||||
"""Extract features from a frame and store them"""
|
||||
try:
|
||||
# Convert to grayscale if needed
|
||||
if len(frame.shape) == 3:
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
else:
|
||||
gray = frame
|
||||
|
||||
# Extract keypoints and descriptors
|
||||
keypoints, descriptors = self.detector.detectAndCompute(gray, None)
|
||||
|
||||
if keypoints is None or descriptors is None:
|
||||
return False
|
||||
|
||||
# Map coordinates back to original frame space if mapper provided
|
||||
if coord_mapper:
|
||||
mapped_positions = []
|
||||
for kp in keypoints:
|
||||
orig_x, orig_y = coord_mapper(kp.pt[0], kp.pt[1])
|
||||
mapped_positions.append((int(orig_x), int(orig_y)))
|
||||
else:
|
||||
mapped_positions = [(int(kp.pt[0]), int(kp.pt[1])) for kp in keypoints]
|
||||
|
||||
# Store features
|
||||
self.features[frame_number] = {
|
||||
'keypoints': keypoints,
|
||||
'descriptors': descriptors,
|
||||
'positions': mapped_positions
|
||||
}
|
||||
|
||||
print(f"Extracted {len(keypoints)} features from frame {frame_number}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error extracting features from frame {frame_number}: {e}")
|
||||
return False
|
||||
|
||||
def extract_features_from_region(self, frame: np.ndarray, frame_number: int, coord_mapper=None) -> bool:
|
||||
"""Extract features from a frame and ADD them to existing features"""
|
||||
try:
|
||||
# Convert to grayscale if needed
|
||||
if len(frame.shape) == 3:
|
||||
gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY)
|
||||
else:
|
||||
gray = frame
|
||||
|
||||
# Extract keypoints and descriptors
|
||||
keypoints, descriptors = self.detector.detectAndCompute(gray, None)
|
||||
|
||||
if keypoints is None or descriptors is None:
|
||||
return False
|
||||
|
||||
# Map coordinates back to original frame space if mapper provided
|
||||
if coord_mapper:
|
||||
mapped_positions = []
|
||||
for kp in keypoints:
|
||||
orig_x, orig_y = coord_mapper(kp.pt[0], kp.pt[1])
|
||||
mapped_positions.append((int(orig_x), int(orig_y)))
|
||||
else:
|
||||
mapped_positions = [(int(kp.pt[0]), int(kp.pt[1])) for kp in keypoints]
|
||||
|
||||
# Add to existing features or create new entry
|
||||
if frame_number in self.features:
|
||||
# Check if descriptor dimensions match
|
||||
existing_features = self.features[frame_number]
|
||||
if existing_features['descriptors'].shape[1] != descriptors.shape[1]:
|
||||
print(f"Warning: Descriptor dimension mismatch ({existing_features['descriptors'].shape[1]} vs {descriptors.shape[1]}). Cannot concatenate. Replacing features.")
|
||||
# Replace instead of concatenate when dimensions don't match
|
||||
existing_features['keypoints'] = keypoints
|
||||
existing_features['descriptors'] = descriptors
|
||||
existing_features['positions'] = mapped_positions
|
||||
else:
|
||||
# Append to existing features
|
||||
existing_features['keypoints'] = np.concatenate([existing_features['keypoints'], keypoints])
|
||||
existing_features['descriptors'] = np.concatenate([existing_features['descriptors'], descriptors])
|
||||
existing_features['positions'].extend(mapped_positions)
|
||||
print(f"Added {len(keypoints)} features to frame {frame_number} (total: {len(existing_features['positions'])})")
|
||||
else:
|
||||
# Create new features entry
|
||||
self.features[frame_number] = {
|
||||
'keypoints': keypoints,
|
||||
'descriptors': descriptors,
|
||||
'positions': mapped_positions
|
||||
}
|
||||
print(f"Extracted {len(keypoints)} features from frame {frame_number}")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error extracting features from frame {frame_number}: {e}")
|
||||
return False
|
||||
|
||||
def track_features_optical_flow(self, prev_frame, curr_frame, prev_points):
|
||||
"""Track features using Lucas-Kanade optical flow"""
|
||||
try:
|
||||
# Convert to grayscale if needed
|
||||
if len(prev_frame.shape) == 3:
|
||||
prev_gray = cv2.cvtColor(prev_frame, cv2.COLOR_BGR2GRAY)
|
||||
else:
|
||||
prev_gray = prev_frame
|
||||
|
||||
if len(curr_frame.shape) == 3:
|
||||
curr_gray = cv2.cvtColor(curr_frame, cv2.COLOR_BGR2GRAY)
|
||||
else:
|
||||
curr_gray = curr_frame
|
||||
|
||||
# Parameters for Lucas-Kanade optical flow
|
||||
lk_params = dict(winSize=(15, 15),
|
||||
maxLevel=2,
|
||||
criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0.03))
|
||||
|
||||
# Calculate optical flow
|
||||
new_points, status, _ = cv2.calcOpticalFlowPyrLK(prev_gray, curr_gray, prev_points, None, **lk_params)
|
||||
|
||||
# Filter out bad tracks
|
||||
good_new = new_points[status == 1]
|
||||
good_old = prev_points[status == 1]
|
||||
|
||||
return good_new, good_old, status
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error in optical flow tracking: {e}")
|
||||
return None, None, None
|
||||
|
||||
def clear_features(self):
|
||||
"""Clear all stored features"""
|
||||
self.features.clear()
|
||||
print("All features cleared")
|
||||
|
||||
def get_feature_count(self, frame_number: int) -> int:
|
||||
"""Get number of features for a frame"""
|
||||
if frame_number in self.features:
|
||||
return len(self.features[frame_number]['positions'])
|
||||
return 0
|
||||
|
||||
def serialize_features(self) -> Dict[str, Any]:
|
||||
"""Serialize features for state saving"""
|
||||
serialized = {}
|
||||
|
||||
for frame_num, frame_data in self.features.items():
|
||||
frame_key = str(frame_num)
|
||||
serialized[frame_key] = {
|
||||
'positions': frame_data['positions'],
|
||||
'keypoints': None, # Keypoints are not serialized (too large)
|
||||
'descriptors': None # Descriptors are not serialized (too large)
|
||||
}
|
||||
|
||||
return serialized
|
||||
|
||||
def deserialize_features(self, serialized_data: Dict[str, Any]):
|
||||
"""Deserialize features from state loading"""
|
||||
self.features.clear()
|
||||
|
||||
for frame_key, frame_data in serialized_data.items():
|
||||
frame_num = int(frame_key)
|
||||
self.features[frame_num] = {
|
||||
'positions': frame_data['positions'],
|
||||
'keypoints': None,
|
||||
'descriptors': None
|
||||
}
|
||||
|
||||
print(f"Deserialized features for {len(self.features)} frames")
|
||||
|
||||
def get_state_dict(self) -> Dict[str, Any]:
|
||||
"""Get complete state for serialization"""
|
||||
return {
|
||||
'detector_type': self.detector_type,
|
||||
'max_features': self.max_features,
|
||||
'match_threshold': self.match_threshold,
|
||||
'tracking_enabled': self.tracking_enabled,
|
||||
'auto_tracking': self.auto_tracking,
|
||||
'features': self.serialize_features()
|
||||
}
|
||||
|
||||
def load_state_dict(self, state_dict: Dict[str, Any]):
|
||||
"""Load complete state from serialization"""
|
||||
if 'detector_type' in state_dict:
|
||||
self.detector_type = state_dict['detector_type']
|
||||
self._init_detectors()
|
||||
|
||||
if 'max_features' in state_dict:
|
||||
self.max_features = state_dict['max_features']
|
||||
|
||||
if 'match_threshold' in state_dict:
|
||||
self.match_threshold = state_dict['match_threshold']
|
||||
|
||||
if 'tracking_enabled' in state_dict:
|
||||
self.tracking_enabled = state_dict['tracking_enabled']
|
||||
|
||||
if 'auto_tracking' in state_dict:
|
||||
self.auto_tracking = state_dict['auto_tracking']
|
||||
|
||||
if 'features' in state_dict:
|
||||
self.deserialize_features(state_dict['features'])
|
||||
|
||||
print("Feature tracker state loaded")
|
||||
34
croppa/utils.py
Normal file
34
croppa/utils.py
Normal file
@@ -0,0 +1,34 @@
|
||||
import cv2
|
||||
import ctypes
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
|
||||
def load_image_utf8(image_path):
|
||||
"""Load image with UTF-8 path support using PIL, then convert to OpenCV format"""
|
||||
try:
|
||||
# Use PIL to load image with UTF-8 support
|
||||
pil_image = Image.open(image_path)
|
||||
# Convert PIL image to OpenCV format (BGR)
|
||||
cv_image = cv2.cvtColor(np.array(pil_image), cv2.COLOR_RGB2BGR)
|
||||
return cv_image
|
||||
except Exception as e:
|
||||
raise ValueError(f"Could not load image file: {image_path} - {e}")
|
||||
|
||||
|
||||
def get_active_window_title():
|
||||
"""Get the title of the currently active window"""
|
||||
try:
|
||||
# Get handle to foreground window
|
||||
hwnd = ctypes.windll.user32.GetForegroundWindow()
|
||||
|
||||
# Get window title length
|
||||
length = ctypes.windll.user32.GetWindowTextLengthW(hwnd)
|
||||
|
||||
# Create buffer and get window title
|
||||
buffer = ctypes.create_unicode_buffer(length + 1)
|
||||
ctypes.windll.user32.GetWindowTextW(hwnd, buffer, length + 1)
|
||||
|
||||
return buffer.value
|
||||
except:
|
||||
return ""
|
||||
@@ -5,6 +5,8 @@ description = "Media Grader - Grade media files by moving them to numbered folde
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"opencv-python>=4.12.0.88",
|
||||
"ruff>=0.12.12",
|
||||
"vulture>=2.14",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
@@ -21,3 +23,7 @@ include = ["main.py"]
|
||||
members = [
|
||||
"croppa",
|
||||
]
|
||||
|
||||
[tool.ruff]
|
||||
# Ensure F841 is enabled (it's part of default linting)
|
||||
select = ["F841"]
|
||||
|
||||
100
uv.lock
generated
100
uv.lock
generated
@@ -15,12 +15,14 @@ source = { virtual = "croppa" }
|
||||
dependencies = [
|
||||
{ name = "numpy" },
|
||||
{ name = "opencv-python" },
|
||||
{ name = "pillow" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "numpy", specifier = ">=1.24.0" },
|
||||
{ name = "opencv-python", specifier = ">=4.8.0" },
|
||||
{ name = "pillow", specifier = ">=10.0.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -29,10 +31,16 @@ version = "0.1.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "opencv-python" },
|
||||
{ name = "ruff" },
|
||||
{ name = "vulture" },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [{ name = "opencv-python", specifier = ">=4.12.0.88" }]
|
||||
requires-dist = [
|
||||
{ name = "opencv-python", specifier = ">=4.12.0.88" },
|
||||
{ name = "ruff", specifier = ">=0.12.12" },
|
||||
{ name = "vulture", specifier = ">=2.14" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
@@ -78,3 +86,93 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/96/213fea371d3cb2f1d537612a105792aa0a6659fb2665b22cad709a75bd94/opencv_python-4.12.0.88-cp37-abi3-win32.whl", hash = "sha256:ff554d3f725b39878ac6a2e1fa232ec509c36130927afc18a1719ebf4fbf4357", size = 30284131, upload-time = "2025-07-07T09:14:08.819Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/80/eb88edc2e2b11cd2dd2e56f1c80b5784d11d6e6b7f04a1145df64df40065/opencv_python-4.12.0.88-cp37-abi3-win_amd64.whl", hash = "sha256:d98edb20aa932fd8ebd276a72627dad9dc097695b3d435a4257557bbb49a79d2", size = 39000307, upload-time = "2025-07-07T09:14:16.641Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pillow"
|
||||
version = "11.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f3/0d/d0d6dea55cd152ce3d6767bb38a8fc10e33796ba4ba210cbab9354b6d238/pillow-11.3.0.tar.gz", hash = "sha256:3828ee7586cd0b2091b6209e5ad53e20d0649bbe87164a459d0676e035e8f523", size = 47113069, upload-time = "2025-07-01T09:16:30.666Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1e/93/0952f2ed8db3a5a4c7a11f91965d6184ebc8cd7cbb7941a260d5f018cd2d/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1c627742b539bba4309df89171356fcb3cc5a9178355b2727d1b74a6cf155fbd", size = 2128328, upload-time = "2025-07-01T09:14:35.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/e8/100c3d114b1a0bf4042f27e0f87d2f25e857e838034e98ca98fe7b8c0a9c/pillow-11.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:30b7c02f3899d10f13d7a48163c8969e4e653f8b43416d23d13d1bbfdc93b9f8", size = 2170652, upload-time = "2025-07-01T09:14:37.203Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/86/3f758a28a6e381758545f7cdb4942e1cb79abd271bea932998fc0db93cb6/pillow-11.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7859a4cc7c9295f5838015d8cc0a9c215b77e43d07a25e460f35cf516df8626f", size = 2227443, upload-time = "2025-07-01T09:14:39.344Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/f4/91d5b3ffa718df2f53b0dc109877993e511f4fd055d7e9508682e8aba092/pillow-11.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ec1ee50470b0d050984394423d96325b744d55c701a439d2bd66089bff963d3c", size = 5278474, upload-time = "2025-07-01T09:14:41.843Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/0e/37d7d3eca6c879fbd9dba21268427dffda1ab00d4eb05b32923d4fbe3b12/pillow-11.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7db51d222548ccfd274e4572fdbf3e810a5e66b00608862f947b163e613b67dd", size = 4686038, upload-time = "2025-07-01T09:14:44.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b0/3426e5c7f6565e752d81221af9d3676fdbb4f352317ceafd42899aaf5d8a/pillow-11.3.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2d6fcc902a24ac74495df63faad1884282239265c6839a0a6416d33faedfae7e", size = 5864407, upload-time = "2025-07-03T13:10:15.628Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/c1/c6c423134229f2a221ee53f838d4be9d82bab86f7e2f8e75e47b6bf6cd77/pillow-11.3.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f0f5d8f4a08090c6d6d578351a2b91acf519a54986c055af27e7a93feae6d3f1", size = 7639094, upload-time = "2025-07-03T13:10:21.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/c9/09e6746630fe6372c67c648ff9deae52a2bc20897d51fa293571977ceb5d/pillow-11.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c37d8ba9411d6003bba9e518db0db0c58a680ab9fe5179f040b0463644bc9805", size = 5973503, upload-time = "2025-07-01T09:14:45.698Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/1c/a2a29649c0b1983d3ef57ee87a66487fdeb45132df66ab30dd37f7dbe162/pillow-11.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:13f87d581e71d9189ab21fe0efb5a23e9f28552d5be6979e84001d3b8505abe8", size = 6642574, upload-time = "2025-07-01T09:14:47.415Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/de/d5cc31cc4b055b6c6fd990e3e7f0f8aaf36229a2698501bcb0cdf67c7146/pillow-11.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:023f6d2d11784a465f09fd09a34b150ea4672e85fb3d05931d89f373ab14abb2", size = 6084060, upload-time = "2025-07-01T09:14:49.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/ea/502d938cbaeec836ac28a9b730193716f0114c41325db428e6b280513f09/pillow-11.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:45dfc51ac5975b938e9809451c51734124e73b04d0f0ac621649821a63852e7b", size = 6721407, upload-time = "2025-07-01T09:14:51.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/9c/9c5e2a73f125f6cbc59cc7087c8f2d649a7ae453f83bd0362ff7c9e2aee2/pillow-11.3.0-cp313-cp313-win32.whl", hash = "sha256:a4d336baed65d50d37b88ca5b60c0fa9d81e3a87d4a7930d3880d1624d5b31f3", size = 6273841, upload-time = "2025-07-01T09:14:54.142Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/85/397c73524e0cd212067e0c969aa245b01d50183439550d24d9f55781b776/pillow-11.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:0bce5c4fd0921f99d2e858dc4d4d64193407e1b99478bc5cacecba2311abde51", size = 6978450, upload-time = "2025-07-01T09:14:56.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/d2/622f4547f69cd173955194b78e4d19ca4935a1b0f03a302d655c9f6aae65/pillow-11.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:1904e1264881f682f02b7f8167935cce37bc97db457f8e7849dc3a6a52b99580", size = 2423055, upload-time = "2025-07-01T09:14:58.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/80/a8a2ac21dda2e82480852978416cfacd439a4b490a501a288ecf4fe2532d/pillow-11.3.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:4c834a3921375c48ee6b9624061076bc0a32a60b5532b322cc0ea64e639dd50e", size = 5281110, upload-time = "2025-07-01T09:14:59.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/d6/b79754ca790f315918732e18f82a8146d33bcd7f4494380457ea89eb883d/pillow-11.3.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5e05688ccef30ea69b9317a9ead994b93975104a677a36a8ed8106be9260aa6d", size = 4689547, upload-time = "2025-07-01T09:15:01.648Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/20/716b8717d331150cb00f7fdd78169c01e8e0c219732a78b0e59b6bdb2fd6/pillow-11.3.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:1019b04af07fc0163e2810167918cb5add8d74674b6267616021ab558dc98ced", size = 5901554, upload-time = "2025-07-03T13:10:27.018Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/cf/a9f3a2514a65bb071075063a96f0a5cf949c2f2fce683c15ccc83b1c1cab/pillow-11.3.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f944255db153ebb2b19c51fe85dd99ef0ce494123f21b9db4877ffdfc5590c7c", size = 7669132, upload-time = "2025-07-03T13:10:33.01Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/3c/da78805cbdbee9cb43efe8261dd7cc0b4b93f2ac79b676c03159e9db2187/pillow-11.3.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1f85acb69adf2aaee8b7da124efebbdb959a104db34d3a2cb0f3793dbae422a8", size = 6005001, upload-time = "2025-07-01T09:15:03.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/fa/ce044b91faecf30e635321351bba32bab5a7e034c60187fe9698191aef4f/pillow-11.3.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05f6ecbeff5005399bb48d198f098a9b4b6bdf27b8487c7f38ca16eeb070cd59", size = 6668814, upload-time = "2025-07-01T09:15:05.655Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/51/90f9291406d09bf93686434f9183aba27b831c10c87746ff49f127ee80cb/pillow-11.3.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:a7bc6e6fd0395bc052f16b1a8670859964dbd7003bd0af2ff08342eb6e442cfe", size = 6113124, upload-time = "2025-07-01T09:15:07.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/5a/6fec59b1dfb619234f7636d4157d11fb4e196caeee220232a8d2ec48488d/pillow-11.3.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:83e1b0161c9d148125083a35c1c5a89db5b7054834fd4387499e06552035236c", size = 6747186, upload-time = "2025-07-01T09:15:09.317Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/6b/00187a044f98255225f172de653941e61da37104a9ea60e4f6887717e2b5/pillow-11.3.0-cp313-cp313t-win32.whl", hash = "sha256:2a3117c06b8fb646639dce83694f2f9eac405472713fcb1ae887469c0d4f6788", size = 6277546, upload-time = "2025-07-01T09:15:11.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/5c/6caaba7e261c0d75bab23be79f1d06b5ad2a2ae49f028ccec801b0e853d6/pillow-11.3.0-cp313-cp313t-win_amd64.whl", hash = "sha256:857844335c95bea93fb39e0fa2726b4d9d758850b34075a7e3ff4f4fa3aa3b31", size = 6985102, upload-time = "2025-07-01T09:15:13.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/7e/b623008460c09a0cb38263c93b828c666493caee2eb34ff67f778b87e58c/pillow-11.3.0-cp313-cp313t-win_arm64.whl", hash = "sha256:8797edc41f3e8536ae4b10897ee2f637235c94f27404cac7297f7b607dd0716e", size = 2424803, upload-time = "2025-07-01T09:15:15.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/f4/04905af42837292ed86cb1b1dabe03dce1edc008ef14c473c5c7e1443c5d/pillow-11.3.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:d9da3df5f9ea2a89b81bb6087177fb1f4d1c7146d583a3fe5c672c0d94e55e12", size = 5278520, upload-time = "2025-07-01T09:15:17.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/b0/33d79e377a336247df6348a54e6d2a2b85d644ca202555e3faa0cf811ecc/pillow-11.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0b275ff9b04df7b640c59ec5a3cb113eefd3795a8df80bac69646ef699c6981a", size = 4686116, upload-time = "2025-07-01T09:15:19.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/2d/ed8bc0ab219ae8768f529597d9509d184fe8a6c4741a6864fea334d25f3f/pillow-11.3.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0743841cabd3dba6a83f38a92672cccbd69af56e3e91777b0ee7f4dba4385632", size = 5864597, upload-time = "2025-07-03T13:10:38.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/3d/b932bb4225c80b58dfadaca9d42d08d0b7064d2d1791b6a237f87f661834/pillow-11.3.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2465a69cf967b8b49ee1b96d76718cd98c4e925414ead59fdf75cf0fd07df673", size = 7638246, upload-time = "2025-07-03T13:10:44.987Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/b5/0487044b7c096f1b48f0d7ad416472c02e0e4bf6919541b111efd3cae690/pillow-11.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:41742638139424703b4d01665b807c6468e23e699e8e90cffefe291c5832b027", size = 5973336, upload-time = "2025-07-01T09:15:21.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/2d/524f9318f6cbfcc79fbc004801ea6b607ec3f843977652fdee4857a7568b/pillow-11.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:93efb0b4de7e340d99057415c749175e24c8864302369e05914682ba642e5d77", size = 6642699, upload-time = "2025-07-01T09:15:23.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/d2/a9a4f280c6aefedce1e8f615baaa5474e0701d86dd6f1dede66726462bbd/pillow-11.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7966e38dcd0fa11ca390aed7c6f20454443581d758242023cf36fcb319b1a874", size = 6083789, upload-time = "2025-07-01T09:15:25.1Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/54/86b0cd9dbb683a9d5e960b66c7379e821a19be4ac5810e2e5a715c09a0c0/pillow-11.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:98a9afa7b9007c67ed84c57c9e0ad86a6000da96eaa638e4f8abe5b65ff83f0a", size = 6720386, upload-time = "2025-07-01T09:15:27.378Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/95/88efcaf384c3588e24259c4203b909cbe3e3c2d887af9e938c2022c9dd48/pillow-11.3.0-cp314-cp314-win32.whl", hash = "sha256:02a723e6bf909e7cea0dac1b0e0310be9d7650cd66222a5f1c571455c0a45214", size = 6370911, upload-time = "2025-07-01T09:15:29.294Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/cc/934e5820850ec5eb107e7b1a72dd278140731c669f396110ebc326f2a503/pillow-11.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:a418486160228f64dd9e9efcd132679b7a02a5f22c982c78b6fc7dab3fefb635", size = 7117383, upload-time = "2025-07-01T09:15:31.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/e9/9c0a616a71da2a5d163aa37405e8aced9a906d574b4a214bede134e731bc/pillow-11.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:155658efb5e044669c08896c0c44231c5e9abcaadbc5cd3648df2f7c0b96b9a6", size = 2511385, upload-time = "2025-07-01T09:15:33.328Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/33/c88376898aff369658b225262cd4f2659b13e8178e7534df9e6e1fa289f6/pillow-11.3.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:59a03cdf019efbfeeed910bf79c7c93255c3d54bc45898ac2a4140071b02b4ae", size = 5281129, upload-time = "2025-07-01T09:15:35.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/70/d376247fb36f1844b42910911c83a02d5544ebd2a8bad9efcc0f707ea774/pillow-11.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f8a5827f84d973d8636e9dc5764af4f0cf2318d26744b3d902931701b0d46653", size = 4689580, upload-time = "2025-07-01T09:15:37.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/1c/537e930496149fbac69efd2fc4329035bbe2e5475b4165439e3be9cb183b/pillow-11.3.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ee92f2fd10f4adc4b43d07ec5e779932b4eb3dbfbc34790ada5a6669bc095aa6", size = 5902860, upload-time = "2025-07-03T13:10:50.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/57/80f53264954dcefeebcf9dae6e3eb1daea1b488f0be8b8fef12f79a3eb10/pillow-11.3.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c96d333dcf42d01f47b37e0979b6bd73ec91eae18614864622d9b87bbd5bbf36", size = 7670694, upload-time = "2025-07-03T13:10:56.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ff/4727d3b71a8578b4587d9c276e90efad2d6fe0335fd76742a6da08132e8c/pillow-11.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4c96f993ab8c98460cd0c001447bff6194403e8b1d7e149ade5f00594918128b", size = 6005888, upload-time = "2025-07-01T09:15:39.436Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/ae/716592277934f85d3be51d7256f3636672d7b1abfafdc42cf3f8cbd4b4c8/pillow-11.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:41342b64afeba938edb034d122b2dda5db2139b9a4af999729ba8818e0056477", size = 6670330, upload-time = "2025-07-01T09:15:41.269Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e7/bb/7fe6cddcc8827b01b1a9766f5fdeb7418680744f9082035bdbabecf1d57f/pillow-11.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:068d9c39a2d1b358eb9f245ce7ab1b5c3246c7c8c7d9ba58cfa5b43146c06e50", size = 6114089, upload-time = "2025-07-01T09:15:43.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/f5/06bfaa444c8e80f1a8e4bff98da9c83b37b5be3b1deaa43d27a0db37ef84/pillow-11.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a1bc6ba083b145187f648b667e05a2534ecc4b9f2784c2cbe3089e44868f2b9b", size = 6748206, upload-time = "2025-07-01T09:15:44.937Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/77/bc6f92a3e8e6e46c0ca78abfffec0037845800ea38c73483760362804c41/pillow-11.3.0-cp314-cp314t-win32.whl", hash = "sha256:118ca10c0d60b06d006be10a501fd6bbdfef559251ed31b794668ed569c87e12", size = 6377370, upload-time = "2025-07-01T09:15:46.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/82/3a721f7d69dca802befb8af08b7c79ebcab461007ce1c18bd91a5d5896f9/pillow-11.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:8924748b688aa210d79883357d102cd64690e56b923a186f35a82cbc10f997db", size = 7121500, upload-time = "2025-07-01T09:15:48.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/c7/5572fa4a3f45740eaab6ae86fcdf7195b55beac1371ac8c619d880cfe948/pillow-11.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:79ea0d14d3ebad43ec77ad5272e6ff9bba5b679ef73375ea760261207fa8e0aa", size = 2512835, upload-time = "2025-07-01T09:15:50.399Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruff"
|
||||
version = "0.12.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/f0/e0965dd709b8cabe6356811c0ee8c096806bb57d20b5019eb4e48a117410/ruff-0.12.12.tar.gz", hash = "sha256:b86cd3415dbe31b3b46a71c598f4c4b2f550346d1ccf6326b347cc0c8fd063d6", size = 5359915, upload-time = "2025-09-04T16:50:18.273Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/09/79/8d3d687224d88367b51c7974cec1040c4b015772bfbeffac95face14c04a/ruff-0.12.12-py3-none-linux_armv6l.whl", hash = "sha256:de1c4b916d98ab289818e55ce481e2cacfaad7710b01d1f990c497edf217dafc", size = 12116602, upload-time = "2025-09-04T16:49:18.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/c3/6e599657fe192462f94861a09aae935b869aea8a1da07f47d6eae471397c/ruff-0.12.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7acd6045e87fac75a0b0cdedacf9ab3e1ad9d929d149785903cff9bb69ad9727", size = 12868393, upload-time = "2025-09-04T16:49:23.043Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/d2/9e3e40d399abc95336b1843f52fc0daaceb672d0e3c9290a28ff1a96f79d/ruff-0.12.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:abf4073688d7d6da16611f2f126be86523a8ec4343d15d276c614bda8ec44edb", size = 12036967, upload-time = "2025-09-04T16:49:26.04Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/03/6816b2ed08836be272e87107d905f0908be5b4a40c14bfc91043e76631b8/ruff-0.12.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:968e77094b1d7a576992ac078557d1439df678a34c6fe02fd979f973af167577", size = 12276038, upload-time = "2025-09-04T16:49:29.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/d5/707b92a61310edf358a389477eabd8af68f375c0ef858194be97ca5b6069/ruff-0.12.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42a67d16e5b1ffc6d21c5f67851e0e769517fb57a8ebad1d0781b30888aa704e", size = 11901110, upload-time = "2025-09-04T16:49:32.07Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/3d/f8b1038f4b9822e26ec3d5b49cf2bc313e3c1564cceb4c1a42820bf74853/ruff-0.12.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b216ec0a0674e4b1214dcc998a5088e54eaf39417327b19ffefba1c4a1e4971e", size = 13668352, upload-time = "2025-09-04T16:49:35.148Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/0e/91421368ae6c4f3765dd41a150f760c5f725516028a6be30e58255e3c668/ruff-0.12.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:59f909c0fdd8f1dcdbfed0b9569b8bf428cf144bec87d9de298dcd4723f5bee8", size = 14638365, upload-time = "2025-09-04T16:49:38.892Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/5d/88f3f06a142f58ecc8ecb0c2fe0b82343e2a2b04dcd098809f717cf74b6c/ruff-0.12.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ac93d87047e765336f0c18eacad51dad0c1c33c9df7484c40f98e1d773876f5", size = 14060812, upload-time = "2025-09-04T16:49:42.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/fc/8962e7ddd2e81863d5c92400820f650b86f97ff919c59836fbc4c1a6d84c/ruff-0.12.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01543c137fd3650d322922e8b14cc133b8ea734617c4891c5a9fccf4bfc9aa92", size = 13050208, upload-time = "2025-09-04T16:49:46.434Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/06/8deb52d48a9a624fd37390555d9589e719eac568c020b27e96eed671f25f/ruff-0.12.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afc2fa864197634e549d87fb1e7b6feb01df0a80fd510d6489e1ce8c0b1cc45", size = 13311444, upload-time = "2025-09-04T16:49:49.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/81/de5a29af7eb8f341f8140867ffb93f82e4fde7256dadee79016ac87c2716/ruff-0.12.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0c0945246f5ad776cb8925e36af2438e66188d2b57d9cf2eed2c382c58b371e5", size = 13279474, upload-time = "2025-09-04T16:49:53.465Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/14/d9577fdeaf791737ada1b4f5c6b59c21c3326f3f683229096cccd7674e0c/ruff-0.12.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0fbafe8c58e37aae28b84a80ba1817f2ea552e9450156018a478bf1fa80f4e4", size = 12070204, upload-time = "2025-09-04T16:49:56.882Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/77/04/a910078284b47fad54506dc0af13839c418ff704e341c176f64e1127e461/ruff-0.12.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b9c456fb2fc8e1282affa932c9e40f5ec31ec9cbb66751a316bd131273b57c23", size = 11880347, upload-time = "2025-09-04T16:49:59.729Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/58/30185fcb0e89f05e7ea82e5817b47798f7fa7179863f9d9ba6fd4fe1b098/ruff-0.12.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f12856123b0ad0147d90b3961f5c90e7427f9acd4b40050705499c98983f489", size = 12891844, upload-time = "2025-09-04T16:50:02.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/9c/28a8dacce4855e6703dcb8cdf6c1705d0b23dd01d60150786cd55aa93b16/ruff-0.12.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a1b5a2bf7dd2c47e3b46d077cd9c0fc3b93e6c6cc9ed750bd312ae9dc302ee", size = 13360687, upload-time = "2025-09-04T16:50:05.8Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/fa/05b6428a008e60f79546c943e54068316f32ec8ab5c4f73e4563934fbdc7/ruff-0.12.12-py3-none-win32.whl", hash = "sha256:173be2bfc142af07a01e3a759aba6f7791aa47acf3604f610b1c36db888df7b1", size = 12052870, upload-time = "2025-09-04T16:50:09.121Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/60/d1e335417804df452589271818749d061b22772b87efda88354cf35cdb7a/ruff-0.12.12-py3-none-win_amd64.whl", hash = "sha256:e99620bf01884e5f38611934c09dd194eb665b0109104acae3ba6102b600fd0d", size = 13178016, upload-time = "2025-09-04T16:50:12.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vulture"
|
||||
version = "2.14"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8e/25/925f35db758a0f9199113aaf61d703de891676b082bd7cf73ea01d6000f7/vulture-2.14.tar.gz", hash = "sha256:cb8277902a1138deeab796ec5bef7076a6e0248ca3607a3f3dee0b6d9e9b8415", size = 58823, upload-time = "2024-12-08T17:39:43.319Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/56/0cc15b8ff2613c1d5c3dc1f3f576ede1c43868c1bc2e5ccaa2d4bcd7974d/vulture-2.14-py2.py3-none-any.whl", hash = "sha256:d9a90dba89607489548a49d557f8bac8112bd25d3cbc8aeef23e860811bd5ed9", size = 28915, upload-time = "2024-12-08T17:39:40.573Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user