mirror of
https://github.com/hanzoai/insights.git
synced 2026-10-30 16:15:31 +00:00
716 files rewritten, 30 renamed, same family mapping as the frontend pass.
Held back, and each for a reason rather than by oversight:
db_table values insights_hogfunction, insights_hogflow,
insights_hogfunctiontemplate, hogflow_templates.
Renaming a table is ALTER TABLE, so it ships as a
migration with the raw SQL in nodejs that names it.
hog_invocation_results a Datastore table with Kafka engine tables on it
products/review_hog the module path is the Django app path; moving it
moves app_label and every migration is recorded under
(app_label, name). That is the same history break as
renaming a migration file, one level up.
*/migrations/* five files, untouched
whogho an integration vendor
Checked: 395 of 397 changed Python files compile. The two that do not —
insightsql/database/database.py and temporal/schedule.py — fail identically at
origin/main under the same interpreter, because they use `type X = ...` and
`except*`, which need the 3.13 this repo pins and not the 3.9 doing the check.
Running the check against the original is what told those apart from a real
break.
Co-authored-by: Hanzo Dev <[email protected]>
127 lines
4.6 KiB
Python
127 lines
4.6 KiB
Python
"""
|
|
Image comparison using pixelscript (Rust-accelerated).
|
|
|
|
Single decode via pixelscript.Comparison; reuses the decoded buffers across
|
|
diff_count, ssim, diff_image, thumbnail, and clusters without paying for
|
|
re-decode.
|
|
"""
|
|
|
|
from dataclasses import dataclass
|
|
|
|
from blake3 import blake3
|
|
from pixelscript import Comparison
|
|
|
|
from .diff_metadata import ClusterSummary, DiffCluster
|
|
|
|
# Aligned-bbox merge tunables passed through to pixelscript's clusters().
|
|
# Catches the "list shifted vertically" pattern where every row of a
|
|
# list becomes its own cluster despite being a single semantic change.
|
|
# Validated against ~20k real master snapshot diffs: 62% collapse to a
|
|
# single regional cluster, mean drops 8.33 -> 1.79 clusters.
|
|
CLUSTER_MERGE_GAP_PX = 60
|
|
CLUSTER_MERGE_OVERLAP_RATIO = 0.5
|
|
|
|
THUMB_WIDTH = 200
|
|
THUMB_HEIGHT = 140
|
|
|
|
# Clustering parameters tuned for screenshot diffs. See pixelscript PR6_REVIEW
|
|
# for the rationale: dilation merges glyph fragments into region-level
|
|
# clusters; min_pixels + min_side filter sub-character noise; max_clusters
|
|
# caps at the rough UI legibility ceiling for bbox overlays. `total` on the
|
|
# stored summary preserves the true count when the cap kicks in.
|
|
#
|
|
# Dilation tuning: 8 closes ~16px gaps, which is the common spacing
|
|
# between text rows in a UI list (line height ~20px + ~13px gap). At
|
|
# dilation=4 a list-shift diff exploded into one cluster per text row
|
|
# plus one per separator; bumping to 8 collapses those into a single
|
|
# regional cluster covering the list area, which is what humans
|
|
# actually mean when they say "the list shifted".
|
|
CLUSTER_MIN_PIXELS = 16
|
|
CLUSTER_MIN_SIDE = 4
|
|
CLUSTER_DILATION = 8
|
|
CLUSTER_MAX = 20
|
|
|
|
|
|
@dataclass
|
|
class CompareResult:
|
|
diff_image: bytes | None
|
|
diff_hash: str
|
|
diff_percentage: float # 0.0 to 100.0 — fraction of pixels that differ
|
|
diff_pixel_count: int
|
|
ssim_score: float # 0.0 to 1.0 — 1.0 = identical, lower = more different
|
|
width: int
|
|
height: int
|
|
thumbnail: bytes | None
|
|
thumbnail_hash: str
|
|
size_mismatch: bool # baseline and current have different dimensions
|
|
cluster_summary: ClusterSummary | None # None when not computed (size mismatch / no thumbnail-only mode)
|
|
|
|
|
|
def compare_images(
|
|
baseline_bytes: bytes,
|
|
current_bytes: bytes,
|
|
threshold: float = 0.1,
|
|
with_thumbnail: bool = True,
|
|
with_clusters: bool = True,
|
|
) -> CompareResult:
|
|
"""Compare two PNG images: pixelmatch + SSIM + optional thumbnail + clusters.
|
|
|
|
One decode of each PNG; subsequent ops reuse the decoded RGBA buffers.
|
|
When sizes differ, pixelscript pads to the largest dimensions and runs
|
|
every metric against the padded buffers — including clusters. The
|
|
padded region surfaces as a cluster of its own, which is the right
|
|
answer ("here's the new content area") rather than something to hide.
|
|
"""
|
|
cmp = Comparison(baseline_bytes, current_bytes)
|
|
|
|
diff_pixel_count = cmp.diff_count(threshold=threshold)
|
|
ssim_score = cmp.ssim()
|
|
diff_image = cmp.diff_image(threshold=threshold, alpha=0.1)
|
|
thumbnail = cmp.current_thumbnail(width=THUMB_WIDTH, height=THUMB_HEIGHT) if with_thumbnail else None
|
|
|
|
width = cmp.width
|
|
height = cmp.height
|
|
total_pixels = width * height
|
|
diff_percentage = (diff_pixel_count / total_pixels * 100) if total_pixels > 0 else 0.0
|
|
|
|
diff_hash = blake3(diff_image).hexdigest() if diff_image else ""
|
|
thumbnail_hash = blake3(thumbnail).hexdigest() if thumbnail else ""
|
|
|
|
cluster_summary: ClusterSummary | None = None
|
|
if with_clusters and diff_pixel_count > 0:
|
|
clusters_result = cmp.clusters(
|
|
threshold=threshold,
|
|
min_pixels=CLUSTER_MIN_PIXELS,
|
|
min_side=CLUSTER_MIN_SIDE,
|
|
dilation=CLUSTER_DILATION,
|
|
max_clusters=CLUSTER_MAX,
|
|
merge_gap=CLUSTER_MERGE_GAP_PX,
|
|
merge_overlap=CLUSTER_MERGE_OVERLAP_RATIO,
|
|
)
|
|
cluster_summary = ClusterSummary(
|
|
items=[
|
|
DiffCluster(
|
|
bbox=(c.bbox.x, c.bbox.y, c.bbox.width, c.bbox.height),
|
|
px=c.pixel_count,
|
|
centroid=c.centroid,
|
|
)
|
|
for c in clusters_result.clusters
|
|
],
|
|
total=clusters_result.total_clusters,
|
|
truncated=clusters_result.truncated,
|
|
)
|
|
|
|
return CompareResult(
|
|
diff_image=diff_image,
|
|
diff_hash=diff_hash,
|
|
diff_percentage=round(diff_percentage, 4),
|
|
diff_pixel_count=diff_pixel_count,
|
|
ssim_score=ssim_score,
|
|
width=width,
|
|
height=height,
|
|
thumbnail=thumbnail,
|
|
thumbnail_hash=thumbnail_hash,
|
|
size_mismatch=cmp.size_mismatch,
|
|
cluster_summary=cluster_summary,
|
|
)
|